1use crate::{BpiError, BpiResult};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum SearchType {
6 Video,
7 MediaBangumi,
8 MediaFt,
9 Live,
10 LiveRoom,
11 LiveUser,
12 Article,
13 BiliUser,
14}
15
16impl SearchType {
17 pub fn as_str(&self) -> &'static str {
18 match self {
19 SearchType::Video => "video",
20 SearchType::MediaBangumi => "media_bangumi",
21 SearchType::MediaFt => "media_ft",
22 SearchType::Live => "live",
23 SearchType::LiveRoom => "live_room",
24 SearchType::LiveUser => "live_user",
25 SearchType::Article => "article",
26 SearchType::BiliUser => "bili_user",
27 }
28 }
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum SearchOrder {
34 TotalRank,
36 Click,
37 PubDate,
38 Dm,
39 Stow,
40 Scores,
41 Attention, Online,
44 LiveTime,
45 Default,
47 Fans,
48 Level,
49}
50
51impl SearchOrder {
52 pub fn as_str(&self) -> &'static str {
53 match self {
54 SearchOrder::TotalRank => "totalrank",
55 SearchOrder::Click => "click",
56 SearchOrder::PubDate => "pubdate",
57 SearchOrder::Dm => "dm",
58 SearchOrder::Stow => "stow",
59 SearchOrder::Scores => "scores",
60 SearchOrder::Attention => "attention",
61 SearchOrder::Online => "online",
62 SearchOrder::LiveTime => "live_time",
63 SearchOrder::Default => "0",
64 SearchOrder::Fans => "fans",
65 SearchOrder::Level => "level",
66 }
67 }
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum OrderSort {
73 Descending, Ascending, }
76
77impl OrderSort {
78 pub fn as_num(&self) -> u8 {
79 match self {
80 OrderSort::Descending => 0,
81 OrderSort::Ascending => 1,
82 }
83 }
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum UserType {
89 All,
90 Up,
91 Normal,
92 Verified,
93}
94
95impl UserType {
96 pub fn as_num(&self) -> u8 {
97 match self {
98 UserType::All => 0,
99 UserType::Up => 1,
100 UserType::Normal => 2,
101 UserType::Verified => 3,
102 }
103 }
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum Duration {
109 All,
110 Under10,
111 From10To30,
112 From30To60,
113 Over60,
114}
115
116impl Duration {
117 pub fn as_num(&self) -> u8 {
118 match self {
119 Duration::All => 0,
120 Duration::Under10 => 1,
121 Duration::From10To30 => 2,
122 Duration::From30To60 => 3,
123 Duration::Over60 => 4,
124 }
125 }
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum CategoryId {
131 All,
132 Animation,
133 Game,
134 Movie,
135 Life,
136 Interest,
137 LightNovel,
138 Technology,
139 Huayou, Photography, }
142
143impl CategoryId {
144 pub fn as_num(&self) -> u8 {
145 match self {
146 CategoryId::All => 0,
147 CategoryId::Animation => 2,
148 CategoryId::Game => 1,
149 CategoryId::Movie => 28,
150 CategoryId::Life => 3,
151 CategoryId::Interest => 29,
152 CategoryId::LightNovel => 16,
153 CategoryId::Technology => 17,
154 CategoryId::Huayou => 1,
155 CategoryId::Photography => 2,
156 }
157 }
158}
159
160#[derive(Debug, Clone, PartialEq, Eq)]
162pub struct SearchArticleParams {
163 keyword: String,
164 order: SearchOrder,
165 category_id: CategoryId,
166 page: u32,
167}
168
169impl SearchArticleParams {
170 pub fn new(keyword: impl Into<String>) -> BpiResult<Self> {
171 Ok(Self {
172 keyword: normalize_search_keyword(keyword)?,
173 order: SearchOrder::TotalRank,
174 category_id: CategoryId::All,
175 page: 1,
176 })
177 }
178
179 pub fn with_order(mut self, order: SearchOrder) -> Self {
180 self.order = order;
181 self
182 }
183
184 pub fn with_category_id(mut self, category_id: CategoryId) -> Self {
185 self.category_id = category_id;
186 self
187 }
188
189 pub fn with_page(mut self, page: u32) -> BpiResult<Self> {
190 self.page = validate_search_page(page)?;
191 Ok(self)
192 }
193
194 pub(crate) fn query_pairs(&self) -> Vec<(&'static str, String)> {
195 vec![
196 ("search_type", SearchType::Article.as_str().to_string()),
197 ("keyword", self.keyword.clone()),
198 ("order", self.order.as_str().to_string()),
199 ("category_id", self.category_id.as_num().to_string()),
200 ("page", self.page.to_string()),
201 ]
202 }
203}
204
205#[derive(Debug, Clone, PartialEq, Eq)]
207pub struct SearchBangumiParams {
208 keyword: String,
209 page: u32,
210}
211
212impl SearchBangumiParams {
213 pub fn new(keyword: impl Into<String>) -> BpiResult<Self> {
214 Ok(Self {
215 keyword: normalize_search_keyword(keyword)?,
216 page: 1,
217 })
218 }
219
220 pub fn with_page(mut self, page: u32) -> BpiResult<Self> {
221 self.page = validate_search_page(page)?;
222 Ok(self)
223 }
224
225 pub(crate) fn query_pairs(&self) -> Vec<(&'static str, String)> {
226 vec![
227 ("search_type", SearchType::MediaBangumi.as_str().to_string()),
228 ("keyword", self.keyword.clone()),
229 ("page", self.page.to_string()),
230 ]
231 }
232}
233
234#[derive(Debug, Clone, PartialEq, Eq)]
236pub struct SearchBiliUserParams {
237 keyword: String,
238 order_sort: OrderSort,
239 user_type: UserType,
240 page: u32,
241}
242
243impl SearchBiliUserParams {
244 pub fn new(keyword: impl Into<String>) -> BpiResult<Self> {
245 Ok(Self {
246 keyword: normalize_search_keyword(keyword)?,
247 order_sort: OrderSort::Ascending,
248 user_type: UserType::All,
249 page: 1,
250 })
251 }
252
253 pub fn with_order_sort(mut self, order_sort: OrderSort) -> Self {
254 self.order_sort = order_sort;
255 self
256 }
257
258 pub fn with_user_type(mut self, user_type: UserType) -> Self {
259 self.user_type = user_type;
260 self
261 }
262
263 pub fn with_page(mut self, page: u32) -> BpiResult<Self> {
264 self.page = validate_search_page(page)?;
265 Ok(self)
266 }
267
268 pub(crate) fn query_pairs(&self) -> Vec<(&'static str, String)> {
269 vec![
270 ("search_type", SearchType::BiliUser.as_str().to_string()),
271 ("keyword", self.keyword.clone()),
272 ("order_sort", self.order_sort.as_num().to_string()),
273 ("user_type", self.user_type.as_num().to_string()),
274 ("page", self.page.to_string()),
275 ]
276 }
277}
278
279#[derive(Debug, Clone, PartialEq, Eq)]
281pub struct SearchLiveParams {
282 keyword: String,
283 page: u32,
284}
285
286impl SearchLiveParams {
287 pub fn new(keyword: impl Into<String>) -> BpiResult<Self> {
288 Ok(Self {
289 keyword: normalize_search_keyword(keyword)?,
290 page: 1,
291 })
292 }
293
294 pub fn with_page(mut self, page: u32) -> BpiResult<Self> {
295 self.page = validate_search_page(page)?;
296 Ok(self)
297 }
298
299 pub(crate) fn query_pairs(&self) -> Vec<(&'static str, String)> {
300 vec![
301 ("search_type", SearchType::Live.as_str().to_string()),
302 ("keyword", self.keyword.clone()),
303 ("page", self.page.to_string()),
304 ]
305 }
306}
307
308#[derive(Debug, Clone, PartialEq, Eq)]
310pub struct SearchLiveRoomParams {
311 keyword: String,
312 order: SearchOrder,
313 page: u32,
314}
315
316impl SearchLiveRoomParams {
317 pub fn new(keyword: impl Into<String>) -> BpiResult<Self> {
318 Ok(Self {
319 keyword: normalize_search_keyword(keyword)?,
320 order: SearchOrder::Online,
321 page: 1,
322 })
323 }
324
325 pub fn with_order(mut self, order: SearchOrder) -> Self {
326 self.order = order;
327 self
328 }
329
330 pub fn with_page(mut self, page: u32) -> BpiResult<Self> {
331 self.page = validate_search_page(page)?;
332 Ok(self)
333 }
334
335 pub(crate) fn query_pairs(&self) -> Vec<(&'static str, String)> {
336 vec![
337 ("search_type", SearchType::LiveRoom.as_str().to_string()),
338 ("keyword", self.keyword.clone()),
339 ("order", self.order.as_str().to_string()),
340 ("page", self.page.to_string()),
341 ]
342 }
343}
344
345#[derive(Debug, Clone, PartialEq, Eq)]
347pub struct SearchLiveUserParams {
348 keyword: String,
349 order_sort: OrderSort,
350 user_type: UserType,
351 page: u32,
352}
353
354impl SearchLiveUserParams {
355 pub fn new(keyword: impl Into<String>) -> BpiResult<Self> {
356 Ok(Self {
357 keyword: normalize_search_keyword(keyword)?,
358 order_sort: OrderSort::Ascending,
359 user_type: UserType::All,
360 page: 1,
361 })
362 }
363
364 pub fn with_order_sort(mut self, order_sort: OrderSort) -> Self {
365 self.order_sort = order_sort;
366 self
367 }
368
369 pub fn with_user_type(mut self, user_type: UserType) -> Self {
370 self.user_type = user_type;
371 self
372 }
373
374 pub fn with_page(mut self, page: u32) -> BpiResult<Self> {
375 self.page = validate_search_page(page)?;
376 Ok(self)
377 }
378
379 pub(crate) fn query_pairs(&self) -> Vec<(&'static str, String)> {
380 vec![
381 ("search_type", SearchType::LiveUser.as_str().to_string()),
382 ("keyword", self.keyword.clone()),
383 ("order_sort", self.order_sort.as_num().to_string()),
384 ("user_type", self.user_type.as_num().to_string()),
385 ("page", self.page.to_string()),
386 ]
387 }
388}
389
390#[derive(Debug, Clone, PartialEq, Eq)]
392pub struct SearchMovieParams {
393 keyword: String,
394 page: u32,
395}
396
397impl SearchMovieParams {
398 pub fn new(keyword: impl Into<String>) -> BpiResult<Self> {
399 Ok(Self {
400 keyword: normalize_search_keyword(keyword)?,
401 page: 1,
402 })
403 }
404
405 pub fn with_page(mut self, page: u32) -> BpiResult<Self> {
406 self.page = validate_search_page(page)?;
407 Ok(self)
408 }
409
410 pub(crate) fn query_pairs(&self) -> Vec<(&'static str, String)> {
411 vec![
412 ("search_type", SearchType::MediaFt.as_str().to_string()),
413 ("keyword", self.keyword.clone()),
414 ("page", self.page.to_string()),
415 ]
416 }
417}
418
419#[derive(Debug, Clone, PartialEq, Eq)]
421pub struct SearchVideoParams {
422 keyword: String,
423 order: SearchOrder,
424 duration: Duration,
425 tids: u32,
426 page: u32,
427}
428
429impl SearchVideoParams {
430 pub fn new(keyword: impl Into<String>) -> BpiResult<Self> {
431 Ok(Self {
432 keyword: normalize_search_keyword(keyword)?,
433 order: SearchOrder::TotalRank,
434 duration: Duration::All,
435 tids: 0,
436 page: 1,
437 })
438 }
439
440 pub fn with_order(mut self, order: SearchOrder) -> Self {
441 self.order = order;
442 self
443 }
444
445 pub fn with_duration(mut self, duration: Duration) -> Self {
446 self.duration = duration;
447 self
448 }
449
450 pub fn with_tid(mut self, tid: u32) -> Self {
451 self.tids = tid;
452 self
453 }
454
455 pub fn with_page(mut self, page: u32) -> BpiResult<Self> {
456 self.page = validate_search_page(page)?;
457 Ok(self)
458 }
459
460 pub(crate) fn query_pairs(&self) -> Vec<(&'static str, String)> {
461 vec![
462 ("search_type", SearchType::Video.as_str().to_string()),
463 ("keyword", self.keyword.clone()),
464 ("order", self.order.as_str().to_string()),
465 ("duration", self.duration.as_num().to_string()),
466 ("tids", self.tids.to_string()),
467 ("page", self.page.to_string()),
468 ]
469 }
470}
471
472fn normalize_search_keyword(keyword: impl Into<String>) -> BpiResult<String> {
473 let keyword = keyword.into().trim().to_string();
474 if keyword.is_empty() {
475 return Err(BpiError::invalid_parameter(
476 "keyword",
477 "search keyword cannot be blank",
478 ));
479 }
480
481 Ok(keyword)
482}
483
484fn validate_search_page(page: u32) -> BpiResult<u32> {
485 if page == 0 {
486 return Err(BpiError::invalid_parameter(
487 "page",
488 "page number must be at least 1",
489 ));
490 }
491
492 Ok(page)
493}
494
495#[cfg(test)]
496mod tests {
497 use super::*;
498
499 #[test]
500 fn search_article_params_serializes_optional_filters() -> Result<(), BpiError> {
501 let params = SearchArticleParams::new(" Rust ")?
502 .with_order(SearchOrder::PubDate)
503 .with_category_id(CategoryId::Technology)
504 .with_page(2)?;
505
506 assert_eq!(
507 params.query_pairs(),
508 vec![
509 ("search_type", "article".to_string()),
510 ("keyword", "Rust".to_string()),
511 ("order", "pubdate".to_string()),
512 ("category_id", "17".to_string()),
513 ("page", "2".to_string()),
514 ]
515 );
516 Ok(())
517 }
518
519 #[test]
520 fn search_bangumi_params_serializes_default_query() -> Result<(), BpiError> {
521 let params = SearchBangumiParams::new(" 天气之子 ")?;
522
523 assert_eq!(
524 params.query_pairs(),
525 vec![
526 ("search_type", "media_bangumi".to_string()),
527 ("keyword", "天气之子".to_string()),
528 ("page", "1".to_string()),
529 ]
530 );
531 Ok(())
532 }
533
534 #[test]
535 fn search_bangumi_params_serializes_page() -> Result<(), BpiError> {
536 let params = SearchBangumiParams::new("天气之子")?.with_page(2)?;
537
538 assert_eq!(
539 params.query_pairs(),
540 vec![
541 ("search_type", "media_bangumi".to_string()),
542 ("keyword", "天气之子".to_string()),
543 ("page", "2".to_string()),
544 ]
545 );
546 Ok(())
547 }
548
549 #[test]
550 fn search_bangumi_params_rejects_blank_keyword() {
551 let err = SearchBangumiParams::new(" ").unwrap_err();
552
553 assert!(matches!(
554 err,
555 BpiError::InvalidParameter {
556 field: "keyword",
557 ..
558 }
559 ));
560 }
561
562 #[test]
563 fn search_bili_user_params_serializes_optional_filters() -> Result<(), BpiError> {
564 let params = SearchBiliUserParams::new(" 老番茄 ")?
565 .with_order_sort(OrderSort::Descending)
566 .with_user_type(UserType::Verified)
567 .with_page(3)?;
568
569 assert_eq!(
570 params.query_pairs(),
571 vec![
572 ("search_type", "bili_user".to_string()),
573 ("keyword", "老番茄".to_string()),
574 ("order_sort", "0".to_string()),
575 ("user_type", "3".to_string()),
576 ("page", "3".to_string()),
577 ]
578 );
579 Ok(())
580 }
581
582 #[test]
583 fn search_live_params_serializes_default_query() -> Result<(), BpiError> {
584 let params = SearchLiveParams::new(" 游戏 ")?;
585
586 assert_eq!(
587 params.query_pairs(),
588 vec![
589 ("search_type", "live".to_string()),
590 ("keyword", "游戏".to_string()),
591 ("page", "1".to_string()),
592 ]
593 );
594 Ok(())
595 }
596
597 #[test]
598 fn search_live_room_params_serializes_optional_filters() -> Result<(), BpiError> {
599 let params = SearchLiveRoomParams::new(" 游戏 ")?
600 .with_order(SearchOrder::LiveTime)
601 .with_page(2)?;
602
603 assert_eq!(
604 params.query_pairs(),
605 vec![
606 ("search_type", "live_room".to_string()),
607 ("keyword", "游戏".to_string()),
608 ("order", "live_time".to_string()),
609 ("page", "2".to_string()),
610 ]
611 );
612 Ok(())
613 }
614
615 #[test]
616 fn search_live_user_params_serializes_optional_filters() -> Result<(), BpiError> {
617 let params = SearchLiveUserParams::new(" 散人 ")?
618 .with_order_sort(OrderSort::Descending)
619 .with_user_type(UserType::Up)
620 .with_page(2)?;
621
622 assert_eq!(
623 params.query_pairs(),
624 vec![
625 ("search_type", "live_user".to_string()),
626 ("keyword", "散人".to_string()),
627 ("order_sort", "0".to_string()),
628 ("user_type", "1".to_string()),
629 ("page", "2".to_string()),
630 ]
631 );
632 Ok(())
633 }
634
635 #[test]
636 fn search_movie_params_serializes_default_query() -> Result<(), BpiError> {
637 let params = SearchMovieParams::new(" 哈利波特 ")?;
638
639 assert_eq!(
640 params.query_pairs(),
641 vec![
642 ("search_type", "media_ft".to_string()),
643 ("keyword", "哈利波特".to_string()),
644 ("page", "1".to_string()),
645 ]
646 );
647 Ok(())
648 }
649
650 #[test]
651 fn search_movie_params_rejects_zero_page() -> Result<(), BpiError> {
652 let err = SearchMovieParams::new("哈利波特")?
653 .with_page(0)
654 .unwrap_err();
655
656 assert!(matches!(
657 err,
658 BpiError::InvalidParameter { field: "page", .. }
659 ));
660 Ok(())
661 }
662
663 #[test]
664 fn search_video_params_serializes_default_query() -> Result<(), BpiError> {
665 let params = SearchVideoParams::new("rust")?;
666
667 assert_eq!(
668 params.query_pairs(),
669 vec![
670 ("search_type", "video".to_string()),
671 ("keyword", "rust".to_string()),
672 ("order", "totalrank".to_string()),
673 ("duration", "0".to_string()),
674 ("tids", "0".to_string()),
675 ("page", "1".to_string()),
676 ]
677 );
678 Ok(())
679 }
680
681 #[test]
682 fn search_video_params_serializes_optional_filters() -> Result<(), BpiError> {
683 let params = SearchVideoParams::new(" rust 教程 ")?
684 .with_order(SearchOrder::Online)
685 .with_duration(Duration::From10To30)
686 .with_tid(171)
687 .with_page(2)?;
688
689 assert_eq!(
690 params.query_pairs(),
691 vec![
692 ("search_type", "video".to_string()),
693 ("keyword", "rust 教程".to_string()),
694 ("order", "online".to_string()),
695 ("duration", "2".to_string()),
696 ("tids", "171".to_string()),
697 ("page", "2".to_string()),
698 ]
699 );
700 Ok(())
701 }
702
703 #[test]
704 fn search_video_params_rejects_blank_keyword() {
705 let err = SearchVideoParams::new(" \t ").unwrap_err();
706
707 assert!(matches!(
708 err,
709 BpiError::InvalidParameter {
710 field: "keyword",
711 ..
712 }
713 ));
714 }
715
716 #[test]
717 fn search_video_params_rejects_zero_page() -> Result<(), BpiError> {
718 let err = SearchVideoParams::new("rust")?.with_page(0).unwrap_err();
719
720 assert!(matches!(
721 err,
722 BpiError::InvalidParameter { field: "page", .. }
723 ));
724 Ok(())
725 }
726}