1use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct AudioInfoData {
10 pub id: i64,
12 pub uid: i64,
14 pub uname: String,
16 pub author: String,
18 pub title: String,
20 pub cover: String,
22 pub intro: String,
24 pub lyric: String,
26 pub crtype: i32,
28 pub duration: i64,
30 pub passtime: i64,
32 pub curtime: i64,
34 pub aid: i64,
36 pub bvid: String,
38 pub cid: i64,
40 pub msid: i64,
42 pub attr: i64,
44 pub limit: i64,
46 #[serde(rename = "activityId")]
48 pub activity_id: i64,
49 pub limitdesc: String,
50 pub ctime: Option<serde_json::Value>,
52 pub statistic: AudioStatistic,
54 #[serde(rename = "vipInfo")]
56 pub vip_info: AudioVipInfo,
57 #[serde(rename = "collectIds")]
59 pub collect_ids: Vec<i64>,
60 pub coin_num: i64,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct AudioStatistic {
67 pub sid: i64,
69 pub play: i64,
71 pub collect: i64,
73 pub comment: i64,
75 pub share: i64,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct AudioVipInfo {
82 pub r#type: i32,
84 pub status: i32,
86 pub due_date: i64,
88 pub vip_pay_type: i32,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct AudioTag {
95 pub r#type: String,
97 pub subtype: i32,
99 pub key: i32,
101 pub info: String,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct AudioMemberType {
108 pub list: Vec<AudioMember>,
110 pub r#type: i32,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct AudioMember {
117 pub mid: i64,
119 pub name: String,
121 pub member_id: i64,
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128 use crate::audio::params::AudioSongParams;
129 use crate::ids::AudioId;
130 use crate::probe::contract::HttpMethod;
131 use crate::probe::endpoint_contract::EndpointContract;
132 use crate::{ApiEnvelope, BpiClient, BpiError, BpiResult};
133
134 const TEST_SID: u64 = 13603;
135
136 fn contract(endpoint: &str) -> BpiResult<EndpointContract> {
137 let bytes = match endpoint {
138 "info" => include_bytes!("../../tests/contracts/audio/info/contract.json").as_slice(),
139 "tags" => include_bytes!("../../tests/contracts/audio/tags/contract.json").as_slice(),
140 "members" => {
141 include_bytes!("../../tests/contracts/audio/members/contract.json").as_slice()
142 }
143 "lyric" => include_bytes!("../../tests/contracts/audio/lyric/contract.json").as_slice(),
144 _ => unreachable!("unknown audio info contract"),
145 };
146
147 EndpointContract::from_slice(bytes)
148 }
149
150 #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
151 #[tokio::test]
152 async fn test_audio_info() -> Result<(), Box<BpiError>> {
153 let bpi = BpiClient::new().expect("client should build");
154 let data = bpi
155 .audio()
156 .info(AudioSongParams::new(AudioId::new(TEST_SID)?))
157 .await?;
158 assert!(!data.title.is_empty());
159 assert!(!data.author.is_empty());
160 assert!(data.duration > 0);
161
162 Ok(())
163 }
164
165 #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
166 #[tokio::test]
167 async fn test_audio_tags() -> Result<(), Box<BpiError>> {
168 let bpi = BpiClient::new().expect("client should build");
169 let data = bpi
170 .audio()
171 .tags(AudioSongParams::new(AudioId::new(TEST_SID)?))
172 .await?;
173
174 tracing::info!("{:#?}", data);
175
176 Ok(())
177 }
178
179 #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
180 #[tokio::test]
181 async fn test_audio_members() -> Result<(), Box<BpiError>> {
182 let bpi = BpiClient::new().expect("client should build");
183 let data = bpi
184 .audio()
185 .members(AudioSongParams::new(AudioId::new(TEST_SID)?))
186 .await?;
187
188 tracing::info!("{:#?}", data);
189
190 Ok(())
191 }
192
193 #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
194 #[tokio::test]
195 async fn test_audio_lyric() -> Result<(), Box<BpiError>> {
196 let bpi = BpiClient::new().expect("client should build");
197
198 let data = bpi
199 .audio()
200 .lyric(AudioSongParams::new(AudioId::new(TEST_SID)?))
201 .await?;
202
203 tracing::info!("{:#?}", data);
204
205 Ok(())
206 }
207
208 #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
209 #[tokio::test]
210 async fn test_audio_info_fields() -> Result<(), Box<BpiError>> {
211 let bpi = BpiClient::new().expect("client should build");
212
213 let data = bpi
214 .audio()
215 .info(AudioSongParams::new(AudioId::new(13598)?))
216 .await?;
217
218 assert!(data.id > 0);
219 assert!(data.uid > 0);
220 assert!(!data.uname.is_empty());
221 assert!(!data.title.is_empty());
222 assert!(data.duration > 0);
223 assert!(data.passtime > 0);
224
225 let stats = &data.statistic;
226 assert!(stats.sid > 0);
227 assert!(stats.play >= 0);
228 assert!(stats.collect >= 0);
229
230 Ok(())
231 }
232
233 #[test]
234 fn audio_info_contract_matches_endpoint_request() -> BpiResult<()> {
235 let contract = contract("info")?;
236 let params = AudioSongParams::new(AudioId::new(TEST_SID)?);
237
238 assert_eq!(contract.name, "audio.info");
239 assert_eq!(contract.request.method, HttpMethod::Get);
240 assert_eq!(
241 contract.request.url.as_str(),
242 "https://www.bilibili.com/audio/music-service-c/web/song/info"
243 );
244 assert_eq!(
245 contract.request.query.get("sid").map(String::as_str),
246 Some("13603")
247 );
248 assert_eq!(params.query_pairs(), vec![("sid", "13603".to_string())]);
249 assert_eq!(contract.cases.len(), 3);
250 assert_eq!(
251 contract.cases[0].response.rust_model.as_deref(),
252 Some("AudioInfoData")
253 );
254 Ok(())
255 }
256
257 #[test]
258 fn audio_info_response_fixtures_parse_declared_model() -> BpiResult<()> {
259 for bytes in [
260 include_bytes!("../../tests/contracts/audio/info/responses/anonymous.success.json")
261 .as_slice(),
262 include_bytes!("../../tests/contracts/audio/info/responses/normal.success.json")
263 .as_slice(),
264 include_bytes!("../../tests/contracts/audio/info/responses/vip.success.json")
265 .as_slice(),
266 ] {
267 let payload = ApiEnvelope::<AudioInfoData>::from_slice(bytes)?.into_payload()?;
268
269 assert_eq!(payload.id, TEST_SID as i64);
270 assert!(!payload.title.is_empty());
271 }
272 Ok(())
273 }
274
275 #[test]
276 fn audio_tags_contract_matches_endpoint_request() -> BpiResult<()> {
277 let contract = contract("tags")?;
278
279 assert_eq!(contract.name, "audio.tags");
280 assert_eq!(contract.request.method, HttpMethod::Get);
281 assert_eq!(
282 contract.request.url.as_str(),
283 "https://www.bilibili.com/audio/music-service-c/web/tag/song"
284 );
285 assert_eq!(
286 contract.request.query.get("sid").map(String::as_str),
287 Some("13603")
288 );
289 assert_eq!(contract.cases.len(), 3);
290 assert_eq!(
291 contract.cases[0].response.rust_model.as_deref(),
292 Some("Vec<AudioTag>")
293 );
294 Ok(())
295 }
296
297 #[test]
298 fn audio_tags_response_fixtures_parse_declared_model() -> BpiResult<()> {
299 for bytes in [
300 include_bytes!("../../tests/contracts/audio/tags/responses/anonymous.success.json")
301 .as_slice(),
302 include_bytes!("../../tests/contracts/audio/tags/responses/normal.success.json")
303 .as_slice(),
304 include_bytes!("../../tests/contracts/audio/tags/responses/vip.success.json")
305 .as_slice(),
306 ] {
307 let payload = ApiEnvelope::<Vec<AudioTag>>::from_slice(bytes)?.into_payload()?;
308
309 assert!(!payload.is_empty());
310 }
311 Ok(())
312 }
313
314 #[test]
315 fn audio_members_contract_matches_endpoint_request() -> BpiResult<()> {
316 let contract = contract("members")?;
317
318 assert_eq!(contract.name, "audio.members");
319 assert_eq!(contract.request.method, HttpMethod::Get);
320 assert_eq!(
321 contract.request.url.as_str(),
322 "https://www.bilibili.com/audio/music-service-c/web/member/song"
323 );
324 assert_eq!(
325 contract.request.query.get("sid").map(String::as_str),
326 Some("13603")
327 );
328 assert_eq!(contract.cases.len(), 3);
329 assert_eq!(
330 contract.cases[0].response.rust_model.as_deref(),
331 Some("Vec<AudioMemberType>")
332 );
333 Ok(())
334 }
335
336 #[test]
337 fn audio_members_response_fixtures_parse_declared_model() -> BpiResult<()> {
338 for bytes in [
339 include_bytes!("../../tests/contracts/audio/members/responses/anonymous.success.json")
340 .as_slice(),
341 include_bytes!("../../tests/contracts/audio/members/responses/normal.success.json")
342 .as_slice(),
343 include_bytes!("../../tests/contracts/audio/members/responses/vip.success.json")
344 .as_slice(),
345 ] {
346 let payload = ApiEnvelope::<Vec<AudioMemberType>>::from_slice(bytes)?.into_payload()?;
347
348 assert!(!payload.is_empty());
349 }
350 Ok(())
351 }
352
353 #[test]
354 fn audio_lyric_contract_matches_endpoint_request() -> BpiResult<()> {
355 let contract = contract("lyric")?;
356
357 assert_eq!(contract.name, "audio.lyric");
358 assert_eq!(contract.request.method, HttpMethod::Get);
359 assert_eq!(
360 contract.request.url.as_str(),
361 "https://www.bilibili.com/audio/music-service-c/web/song/lyric"
362 );
363 assert_eq!(
364 contract.request.query.get("sid").map(String::as_str),
365 Some("13603")
366 );
367 assert_eq!(contract.cases.len(), 3);
368 assert_eq!(
369 contract.cases[0].response.fixture_kind.as_deref(),
370 Some("sanitized_probe_body")
371 );
372 Ok(())
373 }
374
375 #[test]
376 fn audio_lyric_response_fixtures_parse_declared_model() -> BpiResult<()> {
377 for bytes in [
378 include_bytes!("../../tests/contracts/audio/lyric/responses/anonymous.success.json")
379 .as_slice(),
380 include_bytes!("../../tests/contracts/audio/lyric/responses/normal.success.json")
381 .as_slice(),
382 include_bytes!("../../tests/contracts/audio/lyric/responses/vip.success.json")
383 .as_slice(),
384 ] {
385 let payload = ApiEnvelope::<String>::from_slice(bytes)?.into_payload()?;
386
387 assert_eq!(payload, "<lyrics redacted from probe body>");
388 }
389 Ok(())
390 }
391
392 fn local_probe_body(endpoint: &str, profile: &str) -> Option<serde_json::Value> {
393 let path =
394 format!("target/bpi-probe-runs/audio/public-read/{endpoint}/{profile}.response.json");
395 let bytes = std::fs::read(path).ok()?;
396 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
397 value
398 .get("response")
399 .and_then(|response| response.get("body"))
400 .cloned()
401 }
402
403 #[test]
404 fn audio_info_models_match_local_probe_outputs_when_available() -> BpiResult<()> {
405 for profile in ["anonymous", "normal", "vip"] {
406 if let Some(body) = local_probe_body("info", profile) {
407 let payload =
408 serde_json::from_value::<ApiEnvelope<AudioInfoData>>(body)?.into_payload()?;
409
410 assert_eq!(payload.id, TEST_SID as i64);
411 }
412
413 if let Some(body) = local_probe_body("tags", profile) {
414 let payload =
415 serde_json::from_value::<ApiEnvelope<Vec<AudioTag>>>(body)?.into_payload()?;
416
417 assert!(!payload.is_empty());
418 }
419
420 if let Some(body) = local_probe_body("members", profile) {
421 let payload = serde_json::from_value::<ApiEnvelope<Vec<AudioMemberType>>>(body)?
422 .into_payload()?;
423
424 assert!(!payload.is_empty());
425 }
426
427 if let Some(body) = local_probe_body("lyric", profile) {
428 let payload =
429 serde_json::from_value::<ApiEnvelope<String>>(body)?.into_payload()?;
430
431 assert!(!payload.is_empty());
432 }
433 }
434 Ok(())
435 }
436}