use serde::{Deserialize, Serialize};
pub(crate) const PLAYER_INFO_V2_ENDPOINT: &str = "https://api.bilibili.com/x/player/wbi/v2";
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct PlayerInfoResponseData {
pub aid: u64,
pub bvid: String,
pub allow_bp: bool,
pub no_share: bool,
pub cid: u64,
pub dm_mask: Option<DmMaskInfo>,
pub subtitle: Option<SubtitleInfo>,
#[serde(default)]
pub view_points: Vec<ViewPoint>,
pub ip_info: Option<serde_json::Value>,
pub login_mid: u64,
pub login_mid_hash: Option<String>,
pub is_owner: bool,
pub name: String,
pub permission: String,
pub level_info: Option<serde_json::Value>,
pub vip: Option<serde_json::Value>,
pub answer_status: u8,
pub block_time: u64,
pub role: String,
pub last_play_time: u64,
pub last_play_cid: u64,
pub now_time: u64,
pub online_count: Option<u64>,
pub need_login_subtitle: bool,
pub preview_toast: String,
pub interaction: Option<InteractionInfo>,
pub options: Option<PlayerOptions>,
pub guide_attention: Option<serde_json::Value>,
pub jump_card: Option<serde_json::Value>,
pub operation_card: Option<serde_json::Value>,
pub online_switch: Option<serde_json::Value>,
pub fawkes: Option<serde_json::Value>,
pub show_switch: Option<serde_json::Value>,
pub bgm_info: Option<BgmInfo>,
pub toast_block: bool,
pub is_upower_exclusive: bool,
pub is_upower_play: bool,
pub is_ugc_pay_preview: bool,
pub elec_high_level: Option<ElecHighLevel>,
pub disable_show_up_info: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct DmMaskInfo {
pub cid: u64,
pub plat: u8,
pub fps: u64,
pub time: u64,
pub mask_url: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SubtitleInfo {
pub allow_submit: bool,
pub lan: String,
pub lan_doc: String,
#[serde(default)]
pub subtitles: Vec<SubtitleItem>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SubtitleItem {
pub ai_status: u8,
pub ai_type: u8,
pub id: u64,
pub id_str: String,
pub is_lock: bool,
pub lan: String,
pub lan_doc: String,
pub subtitle_url: String,
#[serde(rename = "type")]
pub subtitle_type: u8,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ViewPoint {
pub content: String,
pub from: u64,
pub to: u64,
#[serde(rename = "type")]
pub point_type: u8,
#[serde(rename = "imgUrl")]
pub img_url: String,
#[serde(rename = "logoUrl")]
pub logo_url: String,
pub team_type: String,
pub team_name: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct InteractionInfo {
pub graph_version: u64,
pub msg: Option<String>,
pub error_toast: Option<String>,
pub mark: Option<u8>,
pub need_reload: Option<u8>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct PlayerOptions {
pub is_360: bool,
pub without_vip: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct BgmInfo {
pub music_id: String,
pub music_title: String,
pub jump_url: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ElecHighLevel {
pub privilege_type: u64,
pub title: String,
pub sub_title: String,
pub show_button: bool,
pub button_text: String,
pub jump_url: Option<serde_json::Value>,
pub intro: String,
#[serde(default)]
pub open: bool,
#[serde(default)]
pub new: bool,
#[serde(default)]
pub question_text: String,
#[serde(default)]
pub qa_detail_link: String,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ids::{Aid, Cid};
use crate::probe::contract::HttpMethod;
use crate::probe::endpoint_contract::EndpointContract;
use crate::video::params::VideoPlayerInfoParams;
use crate::{ApiEnvelope, BpiClient, BpiError, BpiResult};
use tracing::info;
const TEST_AID: u64 = 1906473802;
const TEST_CID: u64 = 636329244;
#[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
#[tokio::test]
async fn test_video_player_info_v2_by_aid() -> Result<(), BpiError> {
let bpi = BpiClient::new().expect("client should build");
let params = VideoPlayerInfoParams::from_aid(Aid::new(TEST_AID)?, Cid::new(TEST_CID)?);
let data = bpi.video().player_info_v2(params).await?;
info!("播放器信息: {:?}", data);
assert_eq!(data.aid, TEST_AID);
assert_eq!(data.cid, TEST_CID);
Ok(())
}
#[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
#[tokio::test]
async fn test_video_player_info_v2_by_bvid() -> Result<(), BpiError> {
let bpi = BpiClient::new().expect("client should build");
let params = VideoPlayerInfoParams::from_aid(Aid::new(TEST_AID)?, Cid::new(TEST_CID)?);
let data = bpi.video().player_info_v2(params).await?;
info!("播放器信息: {:?}", data);
assert_eq!(data.aid, TEST_AID);
assert_eq!(data.cid, TEST_CID);
Ok(())
}
fn contract() -> BpiResult<EndpointContract> {
EndpointContract::from_slice(include_bytes!(
"../../tests/contracts/video/player-read/player-info-v2/contract.json"
))
}
#[test]
fn video_player_info_v2_contract_matches_endpoint_request() -> BpiResult<()> {
let contract = contract()?;
let params = VideoPlayerInfoParams::from_bvid("BV1xx411c7mD".parse()?, Cid::new(62131)?);
assert_eq!(contract.name, "video.player_info_v2");
assert_eq!(contract.request.method, HttpMethod::Get);
assert_eq!(contract.request.url.as_str(), PLAYER_INFO_V2_ENDPOINT);
assert!(contract.request.auth.requires_wbi());
assert_eq!(
contract.request.query.get("bvid").map(String::as_str),
Some("BV1xx411c7mD")
);
assert_eq!(
contract.request.query.get("cid").map(String::as_str),
Some("62131")
);
assert_eq!(
params.query_pairs(),
vec![
("cid", "62131".to_string()),
("bvid", "BV1xx411c7mD".to_string())
]
);
assert_eq!(contract.cases.len(), 3);
Ok(())
}
#[test]
fn video_player_info_v2_response_fixtures_parse_declared_model() -> BpiResult<()> {
for bytes in [
include_bytes!(
"../../tests/contracts/video/player-read/player-info-v2/responses/anonymous.success.json"
)
.as_slice(),
include_bytes!(
"../../tests/contracts/video/player-read/player-info-v2/responses/normal.success.json"
)
.as_slice(),
include_bytes!(
"../../tests/contracts/video/player-read/player-info-v2/responses/vip.success.json"
)
.as_slice(),
] {
let payload =
ApiEnvelope::<PlayerInfoResponseData>::from_slice(bytes)?.into_payload()?;
assert_eq!(payload.bvid, "BV1xx411c7mD");
assert_eq!(payload.cid, 62131);
}
Ok(())
}
fn local_probe_body(profile: &str) -> Option<serde_json::Value> {
let path = format!(
"target/bpi-probe-runs/video/player-read/player-info-v2/{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 video_player_info_v2_model_matches_local_probe_outputs_when_available() -> BpiResult<()> {
for profile in ["anonymous", "normal", "vip"] {
let Some(body) = local_probe_body(profile) else {
continue;
};
let payload = serde_json::from_value::<ApiEnvelope<PlayerInfoResponseData>>(body)?
.into_payload()?;
assert_eq!(payload.bvid, "BV1xx411c7mD");
}
Ok(())
}
}