Skip to main content

bpi_rs/audio/
info.rs

1//! 歌曲基本信息
2//!
3//! [查看 API 文档](https://github.com/Yuelioi/bilibili-API-collect/tree/cfc5fddcc8a94b74d91970bb5b4eaeb349addc47/docs/audio/info.md)
4
5use serde::{Deserialize, Serialize};
6
7/// 歌曲基本信息数据
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct AudioInfoData {
10    /// 音频auid
11    pub id: i64,
12    /// UP主mid
13    pub uid: i64,
14    /// UP主昵称
15    pub uname: String,
16    /// 作者名
17    pub author: String,
18    /// 歌曲标题
19    pub title: String,
20    /// 封面图片url
21    pub cover: String,
22    /// 歌曲简介
23    pub intro: String,
24    /// lrc歌词url
25    pub lyric: String,
26    /// 1 作用尚不明确
27    pub crtype: i32,
28    /// 歌曲时间长度 单位为秒
29    pub duration: i64,
30    /// 歌曲发布时间 时间戳
31    pub passtime: i64,
32    /// 当前请求时间 时间戳
33    pub curtime: i64,
34    /// 关联稿件avid 无为0
35    pub aid: i64,
36    /// 关联稿件bvid 无为空
37    pub bvid: String,
38    /// 关联视频cid 无为0
39    pub cid: i64,
40    /// 0 作用尚不明确
41    pub msid: i64,
42    /// 0 作用尚不明确
43    pub attr: i64,
44    /// 0 作用尚不明确
45    pub limit: i64,
46    /// 0 作用尚不明确
47    #[serde(rename = "activityId")]
48    pub activity_id: i64,
49    pub limitdesc: String,
50    /// null 作用尚不明确
51    pub ctime: Option<serde_json::Value>,
52    /// 状态数
53    pub statistic: AudioStatistic,
54    /// UP主会员状态
55    #[serde(rename = "vipInfo")]
56    pub vip_info: AudioVipInfo,
57    /// 歌曲所在的收藏夹mlid 需要登录(SESSDATA)
58    #[serde(rename = "collectIds")]
59    pub collect_ids: Vec<i64>,
60    /// 投币数
61    pub coin_num: i64,
62}
63
64/// 音频状态数
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct AudioStatistic {
67    /// 音频auid
68    pub sid: i64,
69    /// 播放次数
70    pub play: i64,
71    /// 收藏数
72    pub collect: i64,
73    /// 评论数
74    pub comment: i64,
75    /// 分享数
76    pub share: i64,
77}
78
79/// UP主会员状态
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct AudioVipInfo {
82    /// 会员类型 0:无 1:月会员 2:年会员
83    pub r#type: i32,
84    /// 会员状态 0:无 1:有
85    pub status: i32,
86    /// 会员到期时间 时间戳 毫秒
87    pub due_date: i64,
88    /// 会员开通状态 0:无 1:有
89    pub vip_pay_type: i32,
90}
91
92/// 歌曲TAG
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct AudioTag {
95    /// song 作用尚不明确
96    pub r#type: String,
97    /// ??? 作用尚不明确
98    pub subtype: i32,
99    /// TAG id?? 作用尚不明确
100    pub key: i32,
101    /// TAG名
102    pub info: String,
103}
104
105/// 歌曲创作成员类型
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct AudioMemberType {
108    /// 成员列表
109    pub list: Vec<AudioMember>,
110    /// 成员类型代码 1:歌手 2:作词 3:作曲 4:编曲 5:后期/混音 7:封面制作 8:音源 9:调音 10:演奏 11:乐器 127:UP主
111    pub r#type: i32,
112}
113
114/// 歌曲创作成员
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct AudioMember {
117    /// 0 作用尚不明确
118    pub mid: i64,
119    /// 成员名
120    pub name: String,
121    /// 成员id?? 作用尚不明确
122    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}