use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AudioInfoData {
pub id: i64,
pub uid: i64,
pub uname: String,
pub author: String,
pub title: String,
pub cover: String,
pub intro: String,
pub lyric: String,
pub crtype: i32,
pub duration: i64,
pub passtime: i64,
pub curtime: i64,
pub aid: i64,
pub bvid: String,
pub cid: i64,
pub msid: i64,
pub attr: i64,
pub limit: i64,
#[serde(rename = "activityId")]
pub activity_id: i64,
pub limitdesc: String,
pub ctime: Option<serde_json::Value>,
pub statistic: AudioStatistic,
#[serde(rename = "vipInfo")]
pub vip_info: AudioVipInfo,
#[serde(rename = "collectIds")]
pub collect_ids: Vec<i64>,
pub coin_num: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AudioStatistic {
pub sid: i64,
pub play: i64,
pub collect: i64,
pub comment: i64,
pub share: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AudioVipInfo {
pub r#type: i32,
pub status: i32,
pub due_date: i64,
pub vip_pay_type: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AudioTag {
pub r#type: String,
pub subtype: i32,
pub key: i32,
pub info: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AudioMemberType {
pub list: Vec<AudioMember>,
pub r#type: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AudioMember {
pub mid: i64,
pub name: String,
pub member_id: i64,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::audio::params::AudioSongParams;
use crate::ids::AudioId;
use crate::probe::contract::HttpMethod;
use crate::probe::endpoint_contract::EndpointContract;
use crate::{ApiEnvelope, BpiClient, BpiError, BpiResult};
const TEST_SID: u64 = 13603;
fn contract(endpoint: &str) -> BpiResult<EndpointContract> {
let bytes = match endpoint {
"info" => include_bytes!("../../tests/contracts/audio/info/contract.json").as_slice(),
"tags" => include_bytes!("../../tests/contracts/audio/tags/contract.json").as_slice(),
"members" => {
include_bytes!("../../tests/contracts/audio/members/contract.json").as_slice()
}
"lyric" => include_bytes!("../../tests/contracts/audio/lyric/contract.json").as_slice(),
_ => unreachable!("unknown audio info contract"),
};
EndpointContract::from_slice(bytes)
}
#[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
#[tokio::test]
async fn test_audio_info() -> Result<(), Box<BpiError>> {
let bpi = BpiClient::new().expect("client should build");
let data = bpi
.audio()
.info(AudioSongParams::new(AudioId::new(TEST_SID)?))
.await?;
assert!(!data.title.is_empty());
assert!(!data.author.is_empty());
assert!(data.duration > 0);
Ok(())
}
#[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
#[tokio::test]
async fn test_audio_tags() -> Result<(), Box<BpiError>> {
let bpi = BpiClient::new().expect("client should build");
let data = bpi
.audio()
.tags(AudioSongParams::new(AudioId::new(TEST_SID)?))
.await?;
tracing::info!("{:#?}", data);
Ok(())
}
#[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
#[tokio::test]
async fn test_audio_members() -> Result<(), Box<BpiError>> {
let bpi = BpiClient::new().expect("client should build");
let data = bpi
.audio()
.members(AudioSongParams::new(AudioId::new(TEST_SID)?))
.await?;
tracing::info!("{:#?}", data);
Ok(())
}
#[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
#[tokio::test]
async fn test_audio_lyric() -> Result<(), Box<BpiError>> {
let bpi = BpiClient::new().expect("client should build");
let data = bpi
.audio()
.lyric(AudioSongParams::new(AudioId::new(TEST_SID)?))
.await?;
tracing::info!("{:#?}", data);
Ok(())
}
#[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
#[tokio::test]
async fn test_audio_info_fields() -> Result<(), Box<BpiError>> {
let bpi = BpiClient::new().expect("client should build");
let data = bpi
.audio()
.info(AudioSongParams::new(AudioId::new(13598)?))
.await?;
assert!(data.id > 0);
assert!(data.uid > 0);
assert!(!data.uname.is_empty());
assert!(!data.title.is_empty());
assert!(data.duration > 0);
assert!(data.passtime > 0);
let stats = &data.statistic;
assert!(stats.sid > 0);
assert!(stats.play >= 0);
assert!(stats.collect >= 0);
Ok(())
}
#[test]
fn audio_info_contract_matches_endpoint_request() -> BpiResult<()> {
let contract = contract("info")?;
let params = AudioSongParams::new(AudioId::new(TEST_SID)?);
assert_eq!(contract.name, "audio.info");
assert_eq!(contract.request.method, HttpMethod::Get);
assert_eq!(
contract.request.url.as_str(),
"https://www.bilibili.com/audio/music-service-c/web/song/info"
);
assert_eq!(
contract.request.query.get("sid").map(String::as_str),
Some("13603")
);
assert_eq!(params.query_pairs(), vec![("sid", "13603".to_string())]);
assert_eq!(contract.cases.len(), 3);
assert_eq!(
contract.cases[0].response.rust_model.as_deref(),
Some("AudioInfoData")
);
Ok(())
}
#[test]
fn audio_info_response_fixtures_parse_declared_model() -> BpiResult<()> {
for bytes in [
include_bytes!("../../tests/contracts/audio/info/responses/anonymous.success.json")
.as_slice(),
include_bytes!("../../tests/contracts/audio/info/responses/normal.success.json")
.as_slice(),
include_bytes!("../../tests/contracts/audio/info/responses/vip.success.json")
.as_slice(),
] {
let payload = ApiEnvelope::<AudioInfoData>::from_slice(bytes)?.into_payload()?;
assert_eq!(payload.id, TEST_SID as i64);
assert!(!payload.title.is_empty());
}
Ok(())
}
#[test]
fn audio_tags_contract_matches_endpoint_request() -> BpiResult<()> {
let contract = contract("tags")?;
assert_eq!(contract.name, "audio.tags");
assert_eq!(contract.request.method, HttpMethod::Get);
assert_eq!(
contract.request.url.as_str(),
"https://www.bilibili.com/audio/music-service-c/web/tag/song"
);
assert_eq!(
contract.request.query.get("sid").map(String::as_str),
Some("13603")
);
assert_eq!(contract.cases.len(), 3);
assert_eq!(
contract.cases[0].response.rust_model.as_deref(),
Some("Vec<AudioTag>")
);
Ok(())
}
#[test]
fn audio_tags_response_fixtures_parse_declared_model() -> BpiResult<()> {
for bytes in [
include_bytes!("../../tests/contracts/audio/tags/responses/anonymous.success.json")
.as_slice(),
include_bytes!("../../tests/contracts/audio/tags/responses/normal.success.json")
.as_slice(),
include_bytes!("../../tests/contracts/audio/tags/responses/vip.success.json")
.as_slice(),
] {
let payload = ApiEnvelope::<Vec<AudioTag>>::from_slice(bytes)?.into_payload()?;
assert!(!payload.is_empty());
}
Ok(())
}
#[test]
fn audio_members_contract_matches_endpoint_request() -> BpiResult<()> {
let contract = contract("members")?;
assert_eq!(contract.name, "audio.members");
assert_eq!(contract.request.method, HttpMethod::Get);
assert_eq!(
contract.request.url.as_str(),
"https://www.bilibili.com/audio/music-service-c/web/member/song"
);
assert_eq!(
contract.request.query.get("sid").map(String::as_str),
Some("13603")
);
assert_eq!(contract.cases.len(), 3);
assert_eq!(
contract.cases[0].response.rust_model.as_deref(),
Some("Vec<AudioMemberType>")
);
Ok(())
}
#[test]
fn audio_members_response_fixtures_parse_declared_model() -> BpiResult<()> {
for bytes in [
include_bytes!("../../tests/contracts/audio/members/responses/anonymous.success.json")
.as_slice(),
include_bytes!("../../tests/contracts/audio/members/responses/normal.success.json")
.as_slice(),
include_bytes!("../../tests/contracts/audio/members/responses/vip.success.json")
.as_slice(),
] {
let payload = ApiEnvelope::<Vec<AudioMemberType>>::from_slice(bytes)?.into_payload()?;
assert!(!payload.is_empty());
}
Ok(())
}
#[test]
fn audio_lyric_contract_matches_endpoint_request() -> BpiResult<()> {
let contract = contract("lyric")?;
assert_eq!(contract.name, "audio.lyric");
assert_eq!(contract.request.method, HttpMethod::Get);
assert_eq!(
contract.request.url.as_str(),
"https://www.bilibili.com/audio/music-service-c/web/song/lyric"
);
assert_eq!(
contract.request.query.get("sid").map(String::as_str),
Some("13603")
);
assert_eq!(contract.cases.len(), 3);
assert_eq!(
contract.cases[0].response.fixture_kind.as_deref(),
Some("sanitized_probe_body")
);
Ok(())
}
#[test]
fn audio_lyric_response_fixtures_parse_declared_model() -> BpiResult<()> {
for bytes in [
include_bytes!("../../tests/contracts/audio/lyric/responses/anonymous.success.json")
.as_slice(),
include_bytes!("../../tests/contracts/audio/lyric/responses/normal.success.json")
.as_slice(),
include_bytes!("../../tests/contracts/audio/lyric/responses/vip.success.json")
.as_slice(),
] {
let payload = ApiEnvelope::<String>::from_slice(bytes)?.into_payload()?;
assert_eq!(payload, "<lyrics redacted from probe body>");
}
Ok(())
}
fn local_probe_body(endpoint: &str, profile: &str) -> Option<serde_json::Value> {
let path =
format!("target/bpi-probe-runs/audio/public-read/{endpoint}/{profile}.response.json");
let bytes = std::fs::read(path).ok()?;
let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
value
.get("response")
.and_then(|response| response.get("body"))
.cloned()
}
#[test]
fn audio_info_models_match_local_probe_outputs_when_available() -> BpiResult<()> {
for profile in ["anonymous", "normal", "vip"] {
if let Some(body) = local_probe_body("info", profile) {
let payload =
serde_json::from_value::<ApiEnvelope<AudioInfoData>>(body)?.into_payload()?;
assert_eq!(payload.id, TEST_SID as i64);
}
if let Some(body) = local_probe_body("tags", profile) {
let payload =
serde_json::from_value::<ApiEnvelope<Vec<AudioTag>>>(body)?.into_payload()?;
assert!(!payload.is_empty());
}
if let Some(body) = local_probe_body("members", profile) {
let payload = serde_json::from_value::<ApiEnvelope<Vec<AudioMemberType>>>(body)?
.into_payload()?;
assert!(!payload.is_empty());
}
if let Some(body) = local_probe_body("lyric", profile) {
let payload =
serde_json::from_value::<ApiEnvelope<String>>(body)?.into_payload()?;
assert!(!payload.is_empty());
}
}
Ok(())
}
}