1use 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#[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 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(¶ms.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}