Skip to main content

bpi_rs/audio/
client.rs

1use crate::audio::info::{AudioInfoData, AudioMemberType, AudioTag};
2use crate::audio::music_list::{
3    AudioCollection, AudioCollectionsListData, AudioHotMenuData, AudioRankMenuData,
4};
5use crate::audio::musicstream_url::{AudioStreamUrlData, AudioStreamUrlWebData};
6use crate::audio::params::{
7    AudioCollectionInfoParams, AudioPageParams, AudioRankListParams, AudioRankPeriodParams,
8    AudioSongParams, AudioStreamUrlParams, AudioStreamUrlWebParams,
9};
10use crate::audio::rank::{AudioRankDetailData, AudioRankMusicListData, AudioRankPeriodData};
11use crate::audio::status_number::AudioStatusNumberData;
12use crate::{BilibiliRequest, BpiClient, BpiResult};
13
14const INFO_ENDPOINT: &str = "https://www.bilibili.com/audio/music-service-c/web/song/info";
15const TAGS_ENDPOINT: &str = "https://www.bilibili.com/audio/music-service-c/web/tag/song";
16const MEMBERS_ENDPOINT: &str = "https://www.bilibili.com/audio/music-service-c/web/member/song";
17const LYRIC_ENDPOINT: &str = "https://www.bilibili.com/audio/music-service-c/web/song/lyric";
18const STATUS_NUMBER_ENDPOINT: &str = "https://www.bilibili.com/audio/music-service-c/web/stat/song";
19const COLLECTION_STATUS_ENDPOINT: &str =
20    "https://www.bilibili.com/audio/music-service-c/web/collections/songs-coll";
21const COIN_COUNT_ENDPOINT: &str = "https://www.bilibili.com/audio/music-service-c/web/coin/audio";
22const STREAM_URL_WEB_ENDPOINT: &str = "https://www.bilibili.com/audio/music-service-c/web/url";
23const STREAM_URL_ENDPOINT: &str = "https://api.bilibili.com/audio/music-service-c/url";
24const COLLECTIONS_LIST_ENDPOINT: &str =
25    "https://www.bilibili.com/audio/music-service-c/web/collections/list";
26const COLLECTION_INFO_ENDPOINT: &str =
27    "https://www.bilibili.com/audio/music-service-c/web/collections/info";
28const HOT_MENU_ENDPOINT: &str = "https://www.bilibili.com/audio/music-service-c/web/menu/hit";
29const RANK_MENU_ENDPOINT: &str = "https://www.bilibili.com/audio/music-service-c/web/menu/rank";
30const RANK_PERIOD_ENDPOINT: &str =
31    "https://api.bilibili.com/x/copyright-music-publicity/toplist/all_period";
32const RANK_DETAIL_ENDPOINT: &str =
33    "https://api.bilibili.com/x/copyright-music-publicity/toplist/detail";
34const RANK_MUSIC_LIST_ENDPOINT: &str =
35    "https://api.bilibili.com/x/copyright-music-publicity/toplist/music_list";
36
37/// Audio API client.
38#[derive(Clone, Copy)]
39pub struct AudioClient<'a> {
40    pub(crate) client: &'a BpiClient,
41}
42
43impl<'a> AudioClient<'a> {
44    pub(crate) fn new(client: &'a BpiClient) -> Self {
45        Self { client }
46    }
47
48    #[cfg(test)]
49    pub(crate) fn info_endpoint(&self) -> &'static str {
50        INFO_ENDPOINT
51    }
52
53    #[cfg(test)]
54    pub(crate) fn tags_endpoint(&self) -> &'static str {
55        TAGS_ENDPOINT
56    }
57
58    #[cfg(test)]
59    pub(crate) fn members_endpoint(&self) -> &'static str {
60        MEMBERS_ENDPOINT
61    }
62
63    #[cfg(test)]
64    pub(crate) fn lyric_endpoint(&self) -> &'static str {
65        LYRIC_ENDPOINT
66    }
67
68    #[cfg(test)]
69    pub(crate) fn status_number_endpoint(&self) -> &'static str {
70        STATUS_NUMBER_ENDPOINT
71    }
72
73    #[cfg(test)]
74    pub(crate) fn collection_status_endpoint(&self) -> &'static str {
75        COLLECTION_STATUS_ENDPOINT
76    }
77
78    #[cfg(test)]
79    pub(crate) fn coin_count_endpoint(&self) -> &'static str {
80        COIN_COUNT_ENDPOINT
81    }
82
83    #[cfg(test)]
84    pub(crate) fn stream_url_web_endpoint(&self) -> &'static str {
85        STREAM_URL_WEB_ENDPOINT
86    }
87
88    #[cfg(test)]
89    pub(crate) fn stream_url_endpoint(&self) -> &'static str {
90        STREAM_URL_ENDPOINT
91    }
92
93    #[cfg(test)]
94    pub(crate) fn collections_list_endpoint(&self) -> &'static str {
95        COLLECTIONS_LIST_ENDPOINT
96    }
97
98    #[cfg(test)]
99    pub(crate) fn collection_info_endpoint(&self) -> &'static str {
100        COLLECTION_INFO_ENDPOINT
101    }
102
103    #[cfg(test)]
104    pub(crate) fn hot_menu_endpoint(&self) -> &'static str {
105        HOT_MENU_ENDPOINT
106    }
107
108    #[cfg(test)]
109    pub(crate) fn rank_menu_endpoint(&self) -> &'static str {
110        RANK_MENU_ENDPOINT
111    }
112
113    #[cfg(test)]
114    pub(crate) fn rank_period_endpoint(&self) -> &'static str {
115        RANK_PERIOD_ENDPOINT
116    }
117
118    #[cfg(test)]
119    pub(crate) fn rank_detail_endpoint(&self) -> &'static str {
120        RANK_DETAIL_ENDPOINT
121    }
122
123    #[cfg(test)]
124    pub(crate) fn rank_music_list_endpoint(&self) -> &'static str {
125        RANK_MUSIC_LIST_ENDPOINT
126    }
127
128    /// Gets basic information for an audio track.
129    pub async fn info(&self, params: AudioSongParams) -> BpiResult<AudioInfoData> {
130        self.client
131            .get(INFO_ENDPOINT)
132            .query(&params.query_pairs())
133            .send_bpi_payload("audio.info")
134            .await
135    }
136
137    /// Gets tags for an audio track.
138    pub async fn tags(&self, params: AudioSongParams) -> BpiResult<Vec<AudioTag>> {
139        self.client
140            .get(TAGS_ENDPOINT)
141            .query(&params.query_pairs())
142            .send_bpi_payload("audio.tags")
143            .await
144    }
145
146    /// Gets creator members for an audio track.
147    pub async fn members(&self, params: AudioSongParams) -> BpiResult<Vec<AudioMemberType>> {
148        self.client
149            .get(MEMBERS_ENDPOINT)
150            .query(&params.query_pairs())
151            .send_bpi_payload("audio.members")
152            .await
153    }
154
155    /// Gets the lyric body for an audio track.
156    pub async fn lyric(&self, params: AudioSongParams) -> BpiResult<String> {
157        self.client
158            .get(LYRIC_ENDPOINT)
159            .query(&params.query_pairs())
160            .send_bpi_payload("audio.lyric")
161            .await
162    }
163
164    /// Gets status counters for an audio track.
165    pub async fn status_number(&self, params: AudioSongParams) -> BpiResult<AudioStatusNumberData> {
166        self.client
167            .get(STATUS_NUMBER_ENDPOINT)
168            .query(&params.query_pairs())
169            .send_bpi_payload("audio.status_number")
170            .await
171    }
172
173    /// Gets whether the current account has collected an audio track.
174    pub async fn collection_status(&self, params: AudioSongParams) -> BpiResult<bool> {
175        self.client
176            .get(COLLECTION_STATUS_ENDPOINT)
177            .query(&params.query_pairs())
178            .send_bpi_payload("audio.collection_status")
179            .await
180    }
181
182    /// Gets the current account's coin count for an audio track.
183    pub async fn coin_count(&self, params: AudioSongParams) -> BpiResult<i32> {
184        self.client
185            .get(COIN_COUNT_ENDPOINT)
186            .query(&params.query_pairs())
187            .send_bpi_payload("audio.coin_count")
188            .await
189    }
190
191    /// Gets the web audio stream URL payload.
192    pub async fn stream_url_web(
193        &self,
194        params: AudioStreamUrlWebParams,
195    ) -> BpiResult<AudioStreamUrlWebData> {
196        self.client
197            .get(STREAM_URL_WEB_ENDPOINT)
198            .query(&params.query_pairs())
199            .send_bpi_payload("audio.stream_url_web")
200            .await
201    }
202
203    /// Gets the app-style audio stream URL payload.
204    pub async fn stream_url(&self, params: AudioStreamUrlParams) -> BpiResult<AudioStreamUrlData> {
205        self.client
206            .get(STREAM_URL_ENDPOINT)
207            .with_bilibili_headers()
208            .query(&params.query_pairs())
209            .send_bpi_payload("audio.stream_url")
210            .await
211    }
212
213    /// Gets the current account's created audio collections.
214    pub async fn collections_list(
215        &self,
216        params: AudioPageParams,
217    ) -> BpiResult<AudioCollectionsListData> {
218        self.client
219            .get(COLLECTIONS_LIST_ENDPOINT)
220            .query(&params.query_pairs())
221            .send_bpi_payload("audio.collections_list")
222            .await
223    }
224
225    /// Gets information for an audio collection.
226    pub async fn collection_info(
227        &self,
228        params: AudioCollectionInfoParams,
229    ) -> BpiResult<Option<AudioCollection>> {
230        self.client
231            .get(COLLECTION_INFO_ENDPOINT)
232            .query(&params.query_pairs())
233            .send_bpi_optional_payload("audio.collection_info")
234            .await
235    }
236
237    /// Gets popular audio collections.
238    pub async fn hot_menu(&self, params: AudioPageParams) -> BpiResult<AudioHotMenuData> {
239        self.client
240            .get(HOT_MENU_ENDPOINT)
241            .query(&params.query_pairs())
242            .send_bpi_payload("audio.hot_menu")
243            .await
244    }
245
246    /// Gets ranked audio collection menus.
247    pub async fn rank_menu(&self, params: AudioPageParams) -> BpiResult<AudioRankMenuData> {
248        self.client
249            .get(RANK_MENU_ENDPOINT)
250            .query(&params.query_pairs())
251            .send_bpi_payload("audio.rank_menu")
252            .await
253    }
254
255    /// Gets available periods for an audio rank list.
256    pub async fn rank_period(
257        &self,
258        params: AudioRankPeriodParams,
259    ) -> BpiResult<AudioRankPeriodData> {
260        let csrf = self.client.csrf().unwrap_or_default();
261
262        self.client
263            .get(RANK_PERIOD_ENDPOINT)
264            .query(&params.query_pairs(&csrf))
265            .send_bpi_payload("audio.rank_period")
266            .await
267    }
268
269    /// Gets detail for a single audio rank list period.
270    pub async fn rank_detail(&self, params: AudioRankListParams) -> BpiResult<AudioRankDetailData> {
271        let csrf = self.client.csrf().unwrap_or_default();
272
273        self.client
274            .get(RANK_DETAIL_ENDPOINT)
275            .query(&params.query_pairs(&csrf))
276            .send_bpi_payload("audio.rank_detail")
277            .await
278    }
279
280    /// Gets music entries for a single audio rank list period.
281    pub async fn rank_music_list(
282        &self,
283        params: AudioRankListParams,
284    ) -> BpiResult<AudioRankMusicListData> {
285        let csrf = self.client.csrf().unwrap_or_default();
286
287        self.client
288            .get(RANK_MUSIC_LIST_ENDPOINT)
289            .query(&params.query_pairs(&csrf))
290            .send_bpi_payload("audio.rank_music_list")
291            .await
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use std::future::Future;
298
299    use crate::audio::info::{AudioInfoData, AudioMemberType, AudioTag};
300    use crate::audio::music_list::{
301        AudioCollection, AudioCollectionsListData, AudioHotMenuData, AudioRankMenuData,
302    };
303    use crate::audio::musicstream_url::{AudioQuality, AudioStreamUrlData, AudioStreamUrlWebData};
304    use crate::audio::params::{
305        AudioCollectionInfoParams, AudioPageParams, AudioRankListParams, AudioRankListType,
306        AudioRankPeriodParams, AudioSongParams, AudioStreamUrlParams, AudioStreamUrlWebParams,
307    };
308    use crate::audio::rank::{AudioRankDetailData, AudioRankMusicListData, AudioRankPeriodData};
309    use crate::audio::status_number::AudioStatusNumberData;
310    use crate::ids::AudioId;
311    use crate::probe::contract::HttpMethod;
312    use crate::probe::endpoint_contract::EndpointContract;
313    use crate::{BpiClient, BpiResult};
314
315    fn assert_info_future<F>(_future: F)
316    where
317        F: Future<Output = BpiResult<AudioInfoData>>,
318    {
319    }
320
321    fn assert_tags_future<F>(_future: F)
322    where
323        F: Future<Output = BpiResult<Vec<AudioTag>>>,
324    {
325    }
326
327    fn assert_members_future<F>(_future: F)
328    where
329        F: Future<Output = BpiResult<Vec<AudioMemberType>>>,
330    {
331    }
332
333    fn assert_lyric_future<F>(_future: F)
334    where
335        F: Future<Output = BpiResult<String>>,
336    {
337    }
338
339    fn assert_status_number_future<F>(_future: F)
340    where
341        F: Future<Output = BpiResult<AudioStatusNumberData>>,
342    {
343    }
344
345    fn assert_bool_future<F>(_future: F)
346    where
347        F: Future<Output = BpiResult<bool>>,
348    {
349    }
350
351    fn assert_coin_count_future<F>(_future: F)
352    where
353        F: Future<Output = BpiResult<i32>>,
354    {
355    }
356
357    fn assert_stream_url_web_future<F>(_future: F)
358    where
359        F: Future<Output = BpiResult<AudioStreamUrlWebData>>,
360    {
361    }
362
363    fn assert_stream_url_future<F>(_future: F)
364    where
365        F: Future<Output = BpiResult<AudioStreamUrlData>>,
366    {
367    }
368
369    fn assert_collections_list_future<F>(_future: F)
370    where
371        F: Future<Output = BpiResult<AudioCollectionsListData>>,
372    {
373    }
374
375    fn assert_collection_info_future<F>(_future: F)
376    where
377        F: Future<Output = BpiResult<Option<AudioCollection>>>,
378    {
379    }
380
381    fn assert_hot_menu_future<F>(_future: F)
382    where
383        F: Future<Output = BpiResult<AudioHotMenuData>>,
384    {
385    }
386
387    fn assert_rank_menu_future<F>(_future: F)
388    where
389        F: Future<Output = BpiResult<AudioRankMenuData>>,
390    {
391    }
392
393    fn assert_rank_period_future<F>(_future: F)
394    where
395        F: Future<Output = BpiResult<AudioRankPeriodData>>,
396    {
397    }
398
399    fn assert_rank_detail_future<F>(_future: F)
400    where
401        F: Future<Output = BpiResult<AudioRankDetailData>>,
402    {
403    }
404
405    fn assert_rank_music_list_future<F>(_future: F)
406    where
407        F: Future<Output = BpiResult<AudioRankMusicListData>>,
408    {
409    }
410
411    fn contract(endpoint: &str) -> BpiResult<EndpointContract> {
412        let bytes = match endpoint {
413            "info" => include_bytes!("../../tests/contracts/audio/info/contract.json").as_slice(),
414            "tags" => include_bytes!("../../tests/contracts/audio/tags/contract.json").as_slice(),
415            "members" => {
416                include_bytes!("../../tests/contracts/audio/members/contract.json").as_slice()
417            }
418            "lyric" => include_bytes!("../../tests/contracts/audio/lyric/contract.json").as_slice(),
419            "status-number" => {
420                include_bytes!("../../tests/contracts/audio/status-number/contract.json").as_slice()
421            }
422            "collection-status" => {
423                include_bytes!("../../tests/contracts/audio/collection-status/contract.json")
424                    .as_slice()
425            }
426            "coin-count" => {
427                include_bytes!("../../tests/contracts/audio/coin-count/contract.json").as_slice()
428            }
429            "stream-url-web" => {
430                include_bytes!("../../tests/contracts/audio/stream-url-web/contract.json")
431                    .as_slice()
432            }
433            "stream-url" => {
434                include_bytes!("../../tests/contracts/audio/stream-url/contract.json").as_slice()
435            }
436            "collections-list" => {
437                include_bytes!("../../tests/contracts/audio/collections-list/contract.json")
438                    .as_slice()
439            }
440            "collection-info" => {
441                include_bytes!("../../tests/contracts/audio/collection-info/contract.json")
442                    .as_slice()
443            }
444            "hot-menu" => {
445                include_bytes!("../../tests/contracts/audio/hot-menu/contract.json").as_slice()
446            }
447            "rank-menu" => {
448                include_bytes!("../../tests/contracts/audio/rank-menu/contract.json").as_slice()
449            }
450            "rank-period" => {
451                include_bytes!("../../tests/contracts/audio/rank-period/contract.json").as_slice()
452            }
453            "rank-detail" => {
454                include_bytes!("../../tests/contracts/audio/rank-detail/contract.json").as_slice()
455            }
456            "rank-music-list" => {
457                include_bytes!("../../tests/contracts/audio/rank-music-list/contract.json")
458                    .as_slice()
459            }
460            _ => unreachable!("unknown audio contract"),
461        };
462
463        EndpointContract::from_slice(bytes)
464    }
465
466    #[test]
467    fn audio_client_exposes_promoted_endpoint_urls() -> BpiResult<()> {
468        let client = BpiClient::new()?;
469        let audio = client.audio();
470
471        assert_eq!(
472            audio.info_endpoint(),
473            "https://www.bilibili.com/audio/music-service-c/web/song/info"
474        );
475        assert_eq!(
476            audio.tags_endpoint(),
477            "https://www.bilibili.com/audio/music-service-c/web/tag/song"
478        );
479        assert_eq!(
480            audio.members_endpoint(),
481            "https://www.bilibili.com/audio/music-service-c/web/member/song"
482        );
483        assert_eq!(
484            audio.lyric_endpoint(),
485            "https://www.bilibili.com/audio/music-service-c/web/song/lyric"
486        );
487        assert_eq!(
488            audio.status_number_endpoint(),
489            "https://www.bilibili.com/audio/music-service-c/web/stat/song"
490        );
491        assert_eq!(
492            audio.collection_status_endpoint(),
493            "https://www.bilibili.com/audio/music-service-c/web/collections/songs-coll"
494        );
495        assert_eq!(
496            audio.coin_count_endpoint(),
497            "https://www.bilibili.com/audio/music-service-c/web/coin/audio"
498        );
499        assert_eq!(
500            audio.stream_url_web_endpoint(),
501            "https://www.bilibili.com/audio/music-service-c/web/url"
502        );
503        assert_eq!(
504            audio.stream_url_endpoint(),
505            "https://api.bilibili.com/audio/music-service-c/url"
506        );
507        assert_eq!(
508            audio.collections_list_endpoint(),
509            "https://www.bilibili.com/audio/music-service-c/web/collections/list"
510        );
511        assert_eq!(
512            audio.collection_info_endpoint(),
513            "https://www.bilibili.com/audio/music-service-c/web/collections/info"
514        );
515        assert_eq!(
516            audio.hot_menu_endpoint(),
517            "https://www.bilibili.com/audio/music-service-c/web/menu/hit"
518        );
519        assert_eq!(
520            audio.rank_menu_endpoint(),
521            "https://www.bilibili.com/audio/music-service-c/web/menu/rank"
522        );
523        assert_eq!(
524            audio.rank_period_endpoint(),
525            "https://api.bilibili.com/x/copyright-music-publicity/toplist/all_period"
526        );
527        assert_eq!(
528            audio.rank_detail_endpoint(),
529            "https://api.bilibili.com/x/copyright-music-publicity/toplist/detail"
530        );
531        assert_eq!(
532            audio.rank_music_list_endpoint(),
533            "https://api.bilibili.com/x/copyright-music-publicity/toplist/music_list"
534        );
535        Ok(())
536    }
537
538    #[test]
539    fn audio_methods_return_payload_futures() -> BpiResult<()> {
540        let client = BpiClient::new()?;
541        let audio = client.audio();
542        let sid = AudioId::new(13603)?;
543        let stream_sid = AudioId::new(15664)?;
544
545        assert_info_future(audio.info(AudioSongParams::new(sid)));
546        assert_tags_future(audio.tags(AudioSongParams::new(sid)));
547        assert_members_future(audio.members(AudioSongParams::new(sid)));
548        assert_lyric_future(audio.lyric(AudioSongParams::new(sid)));
549        assert_status_number_future(audio.status_number(AudioSongParams::new(sid)));
550        assert_bool_future(audio.collection_status(AudioSongParams::new(sid)));
551        assert_coin_count_future(audio.coin_count(AudioSongParams::new(sid)));
552        assert_stream_url_web_future(audio.stream_url_web(AudioStreamUrlWebParams::new(sid)));
553        assert_stream_url_future(audio.stream_url(AudioStreamUrlParams::new(
554            stream_sid,
555            AudioQuality::HighQuality,
556        )));
557        assert_collections_list_future(audio.collections_list(AudioPageParams::new(1, 2)?));
558        assert_collection_info_future(
559            audio.collection_info(AudioCollectionInfoParams::new(15_967_839)?),
560        );
561        assert_hot_menu_future(audio.hot_menu(AudioPageParams::new(1, 3)?));
562        assert_rank_menu_future(audio.rank_menu(AudioPageParams::new(1, 6)?));
563        assert_rank_period_future(
564            audio.rank_period(AudioRankPeriodParams::new(AudioRankListType::Original)),
565        );
566        assert_rank_detail_future(audio.rank_detail(AudioRankListParams::new(76)?));
567        assert_rank_music_list_future(audio.rank_music_list(AudioRankListParams::new(76)?));
568        Ok(())
569    }
570
571    #[test]
572    fn audio_contracts_match_module_client_endpoints() -> BpiResult<()> {
573        let client = BpiClient::new()?;
574        let audio = client.audio();
575
576        let expectations = [
577            ("info", "audio.info", audio.info_endpoint()),
578            ("tags", "audio.tags", audio.tags_endpoint()),
579            ("members", "audio.members", audio.members_endpoint()),
580            ("lyric", "audio.lyric", audio.lyric_endpoint()),
581            (
582                "status-number",
583                "audio.status_number",
584                audio.status_number_endpoint(),
585            ),
586            (
587                "collection-status",
588                "audio.collection_status",
589                audio.collection_status_endpoint(),
590            ),
591            (
592                "coin-count",
593                "audio.coin_count",
594                audio.coin_count_endpoint(),
595            ),
596            (
597                "stream-url-web",
598                "audio.stream_url_web",
599                audio.stream_url_web_endpoint(),
600            ),
601            (
602                "stream-url",
603                "audio.stream_url",
604                audio.stream_url_endpoint(),
605            ),
606            (
607                "collections-list",
608                "audio.collections_list",
609                audio.collections_list_endpoint(),
610            ),
611            (
612                "collection-info",
613                "audio.collection_info",
614                audio.collection_info_endpoint(),
615            ),
616            ("hot-menu", "audio.hot_menu", audio.hot_menu_endpoint()),
617            ("rank-menu", "audio.rank_menu", audio.rank_menu_endpoint()),
618            (
619                "rank-period",
620                "audio.rank_period",
621                audio.rank_period_endpoint(),
622            ),
623            (
624                "rank-detail",
625                "audio.rank_detail",
626                audio.rank_detail_endpoint(),
627            ),
628            (
629                "rank-music-list",
630                "audio.rank_music_list",
631                audio.rank_music_list_endpoint(),
632            ),
633        ];
634
635        for (endpoint, name, url) in expectations {
636            let contract = contract(endpoint)?;
637
638            assert_eq!(contract.name, name);
639            assert_eq!(contract.request.method, HttpMethod::Get);
640            assert_eq!(contract.request.url.as_str(), url);
641        }
642        Ok(())
643    }
644}