Skip to main content

bpi_rs/audio/
rank.rs

1// 音频榜单
2//
3// [查看 API 文档](https://github.com/Yuelioi/bilibili-API-collect/tree/cfc5fddcc8a94b74d91970bb5b4eaeb349addc47/docs/audio/rank.md)
4
5use crate::BilibiliRequest;
6use crate::BpiError;
7use crate::audio::AudioClient;
8use crate::response::BpiResult;
9use serde::{Deserialize, Serialize};
10
11const RANK_SUBSCRIBE_ENDPOINT: &str =
12    "https://api.bilibili.com/x/copyright-music-publicity/toplist/subscribe/update";
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct AudioRankPeriodData {
16    pub list: std::collections::HashMap<String, Vec<AudioRankPeriod>>,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct AudioRankPeriod {
21    #[serde(rename = "ID")]
22    pub id: u64,
23    pub priod: u64,
24    pub publish_time: u64,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct AudioRankDetailData {
29    pub listen_fid: u64,
30    pub all_fid: u64,
31    pub fav_mid: u64,
32    pub cover_url: String,
33    pub is_subscribe: bool,
34    pub listen_count: u64,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct AudioRankMusicListData {
39    pub list: Vec<AudioRankMusicItem>,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct AudioRankMusicItem {
44    pub music_id: String,
45    pub music_title: String,
46    pub singer: String,
47    pub album: String,
48    pub mv_aid: u64,
49    pub mv_bvid: String,
50    pub mv_cover: String,
51    pub heat: u64,
52    pub rank: u64,
53    pub can_listen: bool,
54    pub recommendation: String,
55    pub creation_aid: u64,
56    pub creation_bvid: String,
57    pub creation_cover: String,
58    pub creation_title: String,
59    pub creation_up: u64,
60    pub creation_nickname: String,
61    pub creation_duration: u64,
62    pub creation_play: u64,
63    pub creation_reason: String,
64    pub achievements: Vec<String>,
65    pub material_id: u64,
66    pub material_use_num: u64,
67    pub material_duration: u64,
68    pub material_show: u64,
69    pub song_type: u64,
70}
71
72/// Parameters for subscribing or unsubscribing from an audio rank list.
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub struct AudioRankSubscribeParams {
75    state: u32,
76    list_id: Option<u64>,
77}
78
79impl AudioRankSubscribeParams {
80    pub fn new(state: u32) -> BpiResult<Self> {
81        if !matches!(state, 1 | 2) {
82            return Err(BpiError::invalid_parameter("state", "value must be 1 or 2"));
83        }
84
85        Ok(Self {
86            state,
87            list_id: None,
88        })
89    }
90
91    pub fn list_id(mut self, list_id: u64) -> BpiResult<Self> {
92        if list_id == 0 {
93            return Err(BpiError::invalid_parameter(
94                "list_id",
95                "id must be non-zero",
96            ));
97        }
98
99        self.list_id = Some(list_id);
100        Ok(self)
101    }
102
103    fn form_pairs(&self, csrf: &str) -> Vec<(&'static str, String)> {
104        let mut params = vec![
105            ("state", self.state.to_string()),
106            ("csrf", csrf.to_string()),
107        ];
108        if let Some(list_id) = self.list_id {
109            params.push(("list_id", list_id.to_string()));
110        }
111
112        params
113    }
114}
115
116impl<'a> AudioClient<'a> {
117    /// Subscribes or unsubscribes from an audio rank list.
118    pub async fn subscribe_rank(
119        &self,
120        params: AudioRankSubscribeParams,
121    ) -> BpiResult<Option<serde_json::Value>> {
122        let csrf = self.client.csrf()?;
123
124        self.client
125            .post(RANK_SUBSCRIBE_ENDPOINT)
126            .form(&params.form_pairs(&csrf))
127            .send_bpi_optional_payload("audio.rank.subscribe")
128            .await
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use crate::audio::params::{AudioRankListParams, AudioRankListType, AudioRankPeriodParams};
136    use crate::probe::contract::HttpMethod;
137    use crate::probe::endpoint_contract::EndpointContract;
138    use crate::{ApiEnvelope, BpiResult};
139
140    const TEST_LIST_ID: u64 = 76;
141
142    fn contract(endpoint: &str) -> BpiResult<EndpointContract> {
143        let bytes = match endpoint {
144            "rank-period" => {
145                include_bytes!("../../tests/contracts/audio/rank-period/contract.json").as_slice()
146            }
147            "rank-detail" => {
148                include_bytes!("../../tests/contracts/audio/rank-detail/contract.json").as_slice()
149            }
150            "rank-music-list" => {
151                include_bytes!("../../tests/contracts/audio/rank-music-list/contract.json")
152                    .as_slice()
153            }
154            _ => unreachable!("unknown audio rank contract"),
155        };
156
157        EndpointContract::from_slice(bytes)
158    }
159
160    #[test]
161    fn audio_rank_period_contract_matches_endpoint_request() -> BpiResult<()> {
162        let contract = contract("rank-period")?;
163        let params = AudioRankPeriodParams::new(AudioRankListType::Original);
164
165        assert_eq!(contract.name, "audio.rank_period");
166        assert_eq!(contract.request.method, HttpMethod::Get);
167        assert_eq!(
168            contract.request.url.as_str(),
169            "https://api.bilibili.com/x/copyright-music-publicity/toplist/all_period"
170        );
171        assert_eq!(
172            contract.request.query.get("list_type").map(String::as_str),
173            Some("2")
174        );
175        assert_eq!(
176            contract.request.query.get("csrf").map(String::as_str),
177            Some("${csrf}")
178        );
179        assert_eq!(
180            params.query_pairs(""),
181            vec![("list_type", "2".to_string()), ("csrf", String::new())]
182        );
183        assert_eq!(contract.cases.len(), 3);
184        assert_eq!(
185            contract.cases[0].response.rust_model.as_deref(),
186            Some("AudioRankPeriodData")
187        );
188        Ok(())
189    }
190
191    #[test]
192    fn audio_rank_period_response_fixture_parses_declared_model() -> BpiResult<()> {
193        let payload = ApiEnvelope::<AudioRankPeriodData>::from_slice(include_bytes!(
194            "../../tests/contracts/audio/rank-period/responses/success.json"
195        ))?
196        .into_payload()?;
197
198        assert!(!payload.list.is_empty());
199        Ok(())
200    }
201
202    #[test]
203    fn audio_rank_detail_contract_matches_endpoint_request() -> BpiResult<()> {
204        let contract = contract("rank-detail")?;
205        let params = AudioRankListParams::new(TEST_LIST_ID)?;
206
207        assert_eq!(contract.name, "audio.rank_detail");
208        assert_eq!(contract.request.method, HttpMethod::Get);
209        assert_eq!(
210            contract.request.url.as_str(),
211            "https://api.bilibili.com/x/copyright-music-publicity/toplist/detail"
212        );
213        assert_eq!(
214            contract.request.query.get("list_id").map(String::as_str),
215            Some("76")
216        );
217        assert_eq!(
218            params.query_pairs(""),
219            vec![("list_id", "76".to_string()), ("csrf", String::new())]
220        );
221        assert_eq!(
222            contract.cases[2].response.fixture.as_deref(),
223            Some("responses/vip.success.json")
224        );
225        Ok(())
226    }
227
228    #[test]
229    fn audio_rank_detail_response_fixtures_parse_declared_model() -> BpiResult<()> {
230        let public_payload = ApiEnvelope::<AudioRankDetailData>::from_slice(include_bytes!(
231            "../../tests/contracts/audio/rank-detail/responses/public.success.json"
232        ))?
233        .into_payload()?;
234        let vip_payload = ApiEnvelope::<AudioRankDetailData>::from_slice(include_bytes!(
235            "../../tests/contracts/audio/rank-detail/responses/vip.success.json"
236        ))?
237        .into_payload()?;
238
239        assert_eq!(public_payload.listen_fid, vip_payload.listen_fid);
240        assert!(!public_payload.is_subscribe);
241        assert!(vip_payload.is_subscribe);
242        Ok(())
243    }
244
245    #[test]
246    fn audio_rank_music_list_contract_matches_endpoint_request() -> BpiResult<()> {
247        let contract = contract("rank-music-list")?;
248
249        assert_eq!(contract.name, "audio.rank_music_list");
250        assert_eq!(contract.request.method, HttpMethod::Get);
251        assert_eq!(
252            contract.request.url.as_str(),
253            "https://api.bilibili.com/x/copyright-music-publicity/toplist/music_list"
254        );
255        assert_eq!(
256            contract.cases[0].response.rust_model.as_deref(),
257            Some("AudioRankMusicListData")
258        );
259        Ok(())
260    }
261
262    #[test]
263    fn audio_rank_music_list_response_fixture_parses_declared_model() -> BpiResult<()> {
264        let payload = ApiEnvelope::<AudioRankMusicListData>::from_slice(include_bytes!(
265            "../../tests/contracts/audio/rank-music-list/responses/success.json"
266        ))?
267        .into_payload()?;
268
269        assert!(!payload.list.is_empty());
270        assert_eq!(payload.list[0].rank, 1);
271        Ok(())
272    }
273
274    #[test]
275    fn audio_rank_subscribe_params_rejects_invalid_state() {
276        let err = AudioRankSubscribeParams::new(3).unwrap_err();
277
278        assert!(matches!(
279            err,
280            BpiError::InvalidParameter { field: "state", .. }
281        ));
282    }
283
284    fn local_probe_body(endpoint: &str, profile: &str) -> Option<serde_json::Value> {
285        let path =
286            format!("target/bpi-probe-runs/audio/extra-read/{endpoint}/{profile}.response.json");
287        let bytes = std::fs::read(path).ok()?;
288        let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
289        value
290            .get("response")
291            .and_then(|response| response.get("body"))
292            .cloned()
293    }
294
295    #[test]
296    fn audio_rank_models_match_local_probe_outputs_when_available() -> BpiResult<()> {
297        for profile in ["anonymous", "normal", "vip"] {
298            if let Some(body) = local_probe_body("rank-period", profile) {
299                let payload = serde_json::from_value::<ApiEnvelope<AudioRankPeriodData>>(body)?
300                    .into_payload()?;
301
302                assert!(!payload.list.is_empty());
303            }
304
305            if let Some(body) = local_probe_body("rank-detail", profile) {
306                let payload = serde_json::from_value::<ApiEnvelope<AudioRankDetailData>>(body)?
307                    .into_payload()?;
308
309                assert!(payload.listen_fid > 0);
310            }
311
312            if let Some(body) = local_probe_body("rank-music-list", profile) {
313                let payload = serde_json::from_value::<ApiEnvelope<AudioRankMusicListData>>(body)?
314                    .into_payload()?;
315
316                assert!(!payload.list.is_empty());
317            }
318        }
319        Ok(())
320    }
321}