Skip to main content

bpi_rs/audio/
params.rs

1use crate::audio::musicstream_url::AudioQuality;
2use crate::ids::AudioId;
3use crate::{BpiError, BpiResult};
4
5/// Parameters for audio endpoints that identify a single song by `sid`.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub struct AudioSongParams {
8    sid: AudioId,
9}
10
11impl AudioSongParams {
12    pub fn new(sid: AudioId) -> Self {
13        Self { sid }
14    }
15
16    pub(crate) fn query_pairs(&self) -> Vec<(&'static str, String)> {
17        vec![("sid", self.sid.to_string())]
18    }
19}
20
21/// Parameters for audio favorite-folder add/remove operations.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct AudioCollectionToFavParams {
24    rid: AudioId,
25    add_media_ids: Vec<String>,
26    del_media_ids: Vec<String>,
27}
28
29impl AudioCollectionToFavParams {
30    pub fn new(
31        rid: AudioId,
32        add_media_ids: impl IntoIterator<Item = impl Into<String>>,
33        del_media_ids: impl IntoIterator<Item = impl Into<String>>,
34    ) -> BpiResult<Self> {
35        let add_media_ids = normalize_id_list("add_media_ids", add_media_ids)?;
36        let del_media_ids = normalize_id_list("del_media_ids", del_media_ids)?;
37
38        if add_media_ids.is_empty() && del_media_ids.is_empty() {
39            return Err(BpiError::invalid_parameter(
40                "media_ids",
41                "at least one add or delete media id is required",
42            ));
43        }
44
45        Ok(Self {
46            rid,
47            add_media_ids,
48            del_media_ids,
49        })
50    }
51
52    pub(crate) fn form_pairs(&self, csrf: &str) -> Vec<(&'static str, String)> {
53        let mut pairs = vec![
54            ("rid", self.rid.to_string()),
55            ("type", "12".to_string()),
56            ("csrf", csrf.to_string()),
57        ];
58
59        if !self.add_media_ids.is_empty() {
60            pairs.push(("add_media_ids", self.add_media_ids.join(",")));
61        }
62        if !self.del_media_ids.is_empty() {
63            pairs.push(("del_media_ids", self.del_media_ids.join(",")));
64        }
65
66        pairs
67    }
68}
69
70/// Parameters for adding an audio song to a collection.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub struct AudioCollectionToParams {
73    sid: AudioId,
74    cids: u64,
75}
76
77impl AudioCollectionToParams {
78    pub fn new(sid: AudioId, cids: u64) -> BpiResult<Self> {
79        Ok(Self {
80            sid,
81            cids: validate_nonzero("cids", cids)?,
82        })
83    }
84
85    pub(crate) fn form_pairs(&self, csrf: &str) -> Vec<(&'static str, String)> {
86        vec![
87            ("sid", self.sid.to_string()),
88            ("cids", self.cids.to_string()),
89            ("csrf", csrf.to_string()),
90        ]
91    }
92}
93
94/// Parameters for `/audio/music-service-c/web/coin/add`.
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub struct AudioCoinParams {
97    sid: AudioId,
98    multiply: u32,
99}
100
101impl AudioCoinParams {
102    pub fn new(sid: AudioId, multiply: u32) -> BpiResult<Self> {
103        Ok(Self {
104            sid,
105            multiply: validate_coin_multiply(multiply)?,
106        })
107    }
108
109    pub(crate) fn form_pairs(&self, csrf: &str) -> Vec<(&'static str, String)> {
110        vec![
111            ("sid", self.sid.to_string()),
112            ("multiply", self.multiply.to_string()),
113            ("csrf", csrf.to_string()),
114        ]
115    }
116}
117
118/// Pagination parameters for audio list endpoints.
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub struct AudioPageParams {
121    page: u32,
122    page_size: u32,
123}
124
125impl AudioPageParams {
126    pub fn new(page: u32, page_size: u32) -> BpiResult<Self> {
127        Ok(Self {
128            page: validate_nonzero("pn", page)?,
129            page_size: validate_nonzero("ps", page_size)?,
130        })
131    }
132
133    pub(crate) fn query_pairs(&self) -> Vec<(&'static str, String)> {
134        vec![
135            ("pn", self.page.to_string()),
136            ("ps", self.page_size.to_string()),
137        ]
138    }
139}
140
141/// Parameters for `/audio/music-service-c/web/collections/info`.
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub struct AudioCollectionInfoParams {
144    sid: u64,
145}
146
147impl AudioCollectionInfoParams {
148    pub fn new(sid: u64) -> BpiResult<Self> {
149        Ok(Self {
150            sid: validate_nonzero("sid", sid)?,
151        })
152    }
153
154    pub(crate) fn query_pairs(&self) -> Vec<(&'static str, String)> {
155        vec![("sid", self.sid.to_string())]
156    }
157}
158
159/// Audio rank list categories accepted by Bilibili's rank period endpoint.
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161pub enum AudioRankListType {
162    Hot,
163    Original,
164    Custom(u32),
165}
166
167impl AudioRankListType {
168    fn query_value(self) -> String {
169        match self {
170            Self::Hot => "1".to_string(),
171            Self::Original => "2".to_string(),
172            Self::Custom(value) => value.to_string(),
173        }
174    }
175}
176
177/// Parameters for `/x/copyright-music-publicity/toplist/all_period`.
178#[derive(Debug, Clone, Copy, PartialEq, Eq)]
179pub struct AudioRankPeriodParams {
180    list_type: AudioRankListType,
181}
182
183impl AudioRankPeriodParams {
184    pub fn new(list_type: AudioRankListType) -> Self {
185        Self { list_type }
186    }
187
188    pub fn custom(list_type: u32) -> BpiResult<Self> {
189        Ok(Self {
190            list_type: AudioRankListType::Custom(validate_nonzero("list_type", list_type)?),
191        })
192    }
193
194    pub(crate) fn query_pairs(&self, csrf: &str) -> Vec<(&'static str, String)> {
195        vec![
196            ("list_type", self.list_type.query_value()),
197            ("csrf", csrf.to_string()),
198        ]
199    }
200}
201
202/// Parameters for audio rank endpoints that identify a single rank list.
203#[derive(Debug, Clone, Copy, PartialEq, Eq)]
204pub struct AudioRankListParams {
205    list_id: u64,
206}
207
208impl AudioRankListParams {
209    pub fn new(list_id: u64) -> BpiResult<Self> {
210        Ok(Self {
211            list_id: validate_nonzero("list_id", list_id)?,
212        })
213    }
214
215    pub(crate) fn query_pairs(&self, csrf: &str) -> Vec<(&'static str, String)> {
216        vec![
217            ("list_id", self.list_id.to_string()),
218            ("csrf", csrf.to_string()),
219        ]
220    }
221}
222
223/// Parameters for `/audio/music-service-c/web/url`.
224#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225pub struct AudioStreamUrlWebParams {
226    sid: AudioId,
227    quality: AudioQuality,
228    privilege: u32,
229}
230
231impl AudioStreamUrlWebParams {
232    pub fn new(sid: AudioId) -> Self {
233        Self {
234            sid,
235            quality: AudioQuality::HighQuality,
236            privilege: 2,
237        }
238    }
239
240    pub fn with_quality(mut self, quality: AudioQuality) -> Self {
241        self.quality = quality;
242        self
243    }
244
245    pub fn with_privilege(mut self, privilege: u32) -> BpiResult<Self> {
246        self.privilege = validate_nonzero("privilege", privilege)?;
247        Ok(self)
248    }
249
250    pub(crate) fn query_pairs(&self) -> Vec<(&'static str, String)> {
251        vec![
252            ("sid", self.sid.to_string()),
253            ("quality", self.quality.as_u32().to_string()),
254            ("privilege", self.privilege.to_string()),
255        ]
256    }
257}
258
259/// Parameters for `/audio/music-service-c/url`.
260#[derive(Debug, Clone, PartialEq, Eq)]
261pub struct AudioStreamUrlParams {
262    song_id: AudioId,
263    quality: AudioQuality,
264    privilege: u32,
265    mid: u64,
266    platform: String,
267}
268
269impl AudioStreamUrlParams {
270    pub fn new(song_id: AudioId, quality: AudioQuality) -> Self {
271        Self {
272            song_id,
273            quality,
274            privilege: 2,
275            mid: 2,
276            platform: "android".to_string(),
277        }
278    }
279
280    pub fn with_privilege(mut self, privilege: u32) -> BpiResult<Self> {
281        self.privilege = validate_nonzero("privilege", privilege)?;
282        Ok(self)
283    }
284
285    pub fn with_mid(mut self, mid: u64) -> BpiResult<Self> {
286        self.mid = validate_nonzero("mid", mid)?;
287        Ok(self)
288    }
289
290    pub fn with_platform(mut self, platform: impl Into<String>) -> BpiResult<Self> {
291        let platform = platform.into();
292        let platform = platform.trim();
293        if platform.is_empty() {
294            return Err(BpiError::invalid_parameter(
295                "platform",
296                "value cannot be blank",
297            ));
298        }
299
300        self.platform = platform.to_string();
301        Ok(self)
302    }
303
304    pub(crate) fn query_pairs(&self) -> Vec<(&'static str, String)> {
305        vec![
306            ("songid", self.song_id.to_string()),
307            ("quality", self.quality.as_u32().to_string()),
308            ("privilege", self.privilege.to_string()),
309            ("mid", self.mid.to_string()),
310            ("platform", self.platform.clone()),
311        ]
312    }
313}
314
315fn validate_nonzero<T>(field: &'static str, value: T) -> BpiResult<T>
316where
317    T: PartialEq + From<u8>,
318{
319    if value == T::from(0) {
320        return Err(BpiError::invalid_parameter(field, "value must be non-zero"));
321    }
322
323    Ok(value)
324}
325
326fn validate_coin_multiply(value: u32) -> BpiResult<u32> {
327    if matches!(value, 1 | 2) {
328        return Ok(value);
329    }
330
331    Err(BpiError::invalid_parameter(
332        "multiply",
333        "value must be 1 or 2",
334    ))
335}
336
337fn normalize_id_list(
338    field: &'static str,
339    values: impl IntoIterator<Item = impl Into<String>>,
340) -> BpiResult<Vec<String>> {
341    values
342        .into_iter()
343        .map(|value| {
344            let value = value.into();
345            let value = value.trim();
346            if value.is_empty() {
347                return Err(BpiError::invalid_parameter(field, "value cannot be blank"));
348            }
349
350            Ok(value.to_string())
351        })
352        .collect()
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    #[test]
360    fn audio_song_params_serializes_sid_query() -> BpiResult<()> {
361        let params = AudioSongParams::new(AudioId::new(13603)?);
362
363        assert_eq!(params.query_pairs(), vec![("sid", "13603".to_string())]);
364        Ok(())
365    }
366
367    #[test]
368    fn audio_song_params_accepts_owned_audio_id() -> BpiResult<()> {
369        let sid = AudioId::new(15664)?;
370        let params = AudioSongParams::new(sid);
371
372        assert_eq!(params.query_pairs(), vec![("sid", "15664".to_string())]);
373        Ok(())
374    }
375
376    #[test]
377    fn audio_collection_to_fav_params_requires_an_operation() -> BpiResult<()> {
378        let err = AudioCollectionToFavParams::new(
379            AudioId::new(13603)?,
380            Vec::<String>::new(),
381            Vec::<String>::new(),
382        )
383        .unwrap_err();
384
385        assert!(matches!(
386            err,
387            BpiError::InvalidParameter {
388                field: "media_ids",
389                ..
390            }
391        ));
392        Ok(())
393    }
394
395    #[test]
396    fn audio_collection_to_params_rejects_zero_collection_id() -> BpiResult<()> {
397        let err = AudioCollectionToParams::new(AudioId::new(13603)?, 0).unwrap_err();
398
399        assert!(matches!(
400            err,
401            BpiError::InvalidParameter { field: "cids", .. }
402        ));
403        Ok(())
404    }
405
406    #[test]
407    fn audio_coin_params_rejects_invalid_multiply() -> BpiResult<()> {
408        let err = AudioCoinParams::new(AudioId::new(13603)?, 0).unwrap_err();
409
410        assert!(matches!(
411            err,
412            BpiError::InvalidParameter {
413                field: "multiply",
414                ..
415            }
416        ));
417        Ok(())
418    }
419
420    #[test]
421    fn audio_page_params_serializes_page_query() -> BpiResult<()> {
422        let params = AudioPageParams::new(1, 20)?;
423
424        assert_eq!(
425            params.query_pairs(),
426            vec![("pn", "1".to_string()), ("ps", "20".to_string())]
427        );
428        Ok(())
429    }
430
431    #[test]
432    fn audio_page_params_rejects_zero_page() {
433        let err = AudioPageParams::new(0, 20).unwrap_err();
434
435        assert!(matches!(
436            err,
437            BpiError::InvalidParameter { field: "pn", .. }
438        ));
439    }
440
441    #[test]
442    fn audio_collection_info_params_serializes_collection_id_query() -> BpiResult<()> {
443        let params = AudioCollectionInfoParams::new(15_967_839)?;
444
445        assert_eq!(params.query_pairs(), vec![("sid", "15967839".to_string())]);
446        Ok(())
447    }
448
449    #[test]
450    fn audio_rank_period_params_serializes_builtin_list_type() {
451        let params = AudioRankPeriodParams::new(AudioRankListType::Original);
452
453        assert_eq!(
454            params.query_pairs("csrf-token"),
455            vec![
456                ("list_type", "2".to_string()),
457                ("csrf", "csrf-token".to_string())
458            ]
459        );
460    }
461
462    #[test]
463    fn audio_rank_period_params_rejects_zero_custom_type() {
464        let err = AudioRankPeriodParams::custom(0).unwrap_err();
465
466        assert!(matches!(
467            err,
468            BpiError::InvalidParameter {
469                field: "list_type",
470                ..
471            }
472        ));
473    }
474
475    #[test]
476    fn audio_rank_list_params_serializes_list_id_query() -> BpiResult<()> {
477        let params = AudioRankListParams::new(76)?;
478
479        assert_eq!(
480            params.query_pairs("csrf-token"),
481            vec![
482                ("list_id", "76".to_string()),
483                ("csrf", "csrf-token".to_string())
484            ]
485        );
486        Ok(())
487    }
488
489    #[test]
490    fn audio_stream_url_web_params_serializes_default_query() -> BpiResult<()> {
491        let params = AudioStreamUrlWebParams::new(AudioId::new(13603)?);
492
493        assert_eq!(
494            params.query_pairs(),
495            vec![
496                ("sid", "13603".to_string()),
497                ("quality", "2".to_string()),
498                ("privilege", "2".to_string()),
499            ]
500        );
501        Ok(())
502    }
503
504    #[test]
505    fn audio_stream_url_params_serializes_default_query() -> BpiResult<()> {
506        let params = AudioStreamUrlParams::new(AudioId::new(15664)?, AudioQuality::HighQuality);
507
508        assert_eq!(
509            params.query_pairs(),
510            vec![
511                ("songid", "15664".to_string()),
512                ("quality", "2".to_string()),
513                ("privilege", "2".to_string()),
514                ("mid", "2".to_string()),
515                ("platform", "android".to_string()),
516            ]
517        );
518        Ok(())
519    }
520
521    #[test]
522    fn audio_stream_url_params_serializes_custom_query() -> BpiResult<()> {
523        let params = AudioStreamUrlParams::new(AudioId::new(15664)?, AudioQuality::Lossless)
524            .with_privilege(3)?
525            .with_mid(42)?
526            .with_platform("ios")?;
527
528        assert_eq!(
529            params.query_pairs(),
530            vec![
531                ("songid", "15664".to_string()),
532                ("quality", "3".to_string()),
533                ("privilege", "3".to_string()),
534                ("mid", "42".to_string()),
535                ("platform", "ios".to_string()),
536            ]
537        );
538        Ok(())
539    }
540
541    #[test]
542    fn audio_stream_url_params_rejects_blank_platform() {
543        let err = AudioStreamUrlParams::new(
544            AudioId::new(15664).expect("valid audio id"),
545            AudioQuality::HighQuality,
546        )
547        .with_platform("  ")
548        .unwrap_err();
549
550        assert!(matches!(
551            err,
552            BpiError::InvalidParameter {
553                field: "platform",
554                ..
555            }
556        ));
557    }
558}