Skip to main content

bpi_rs/search/
search_params.rs

1use crate::{BpiError, BpiResult};
2
3/// 搜索目标类型
4#[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/// 搜索结果排序
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum SearchOrder {
34    // 视频、专栏、相簿
35    TotalRank,
36    Click,
37    PubDate,
38    Dm,
39    Stow,
40    Scores,
41    Attention, // 专栏
42    // 直播
43    Online,
44    LiveTime,
45    // 用户
46    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/// 用户粉丝数及等级排序顺序
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum OrderSort {
73    Descending, // 由高到低
74    Ascending,  // 由低到高
75}
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/// 用户分类筛选
87#[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/// 视频时长筛选
107#[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/// 专栏及相簿分区筛选
129#[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,      // 相簿画友
140    Photography, // 相簿摄影
141}
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/// Parameters for article search.
161#[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/// Parameters for bangumi search.
206#[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/// Parameters for Bilibili user search.
235#[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/// Parameters for combined live-room/live-user search.
280#[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/// Parameters for live-room search.
309#[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/// Parameters for live-user search.
346#[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/// Parameters for movie/film search.
391#[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/// Parameters for video search.
420#[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}