1use serde::{Deserialize, Serialize};
5
6pub(crate) const PLAYER_INFO_V2_ENDPOINT: &str = "https://api.bilibili.com/x/player/wbi/v2";
7
8#[derive(Debug, Clone, Deserialize, Serialize)]
12pub struct PlayerInfoResponseData {
13 pub aid: u64,
15 pub bvid: String,
17 pub allow_bp: bool,
18 pub no_share: bool,
19 pub cid: u64,
21 pub dm_mask: Option<DmMaskInfo>,
23 pub subtitle: Option<SubtitleInfo>,
25 #[serde(default)]
27 pub view_points: Vec<ViewPoint>,
28 pub ip_info: Option<serde_json::Value>,
30 pub login_mid: u64,
32 pub login_mid_hash: Option<String>,
33 pub is_owner: bool,
35 pub name: String,
36 pub permission: String,
37 pub level_info: Option<serde_json::Value>,
39 pub vip: Option<serde_json::Value>,
41 pub answer_status: u8,
43 pub block_time: u64,
44 pub role: String,
45 pub last_play_time: u64,
47 pub last_play_cid: u64,
49 pub now_time: u64,
51 pub online_count: Option<u64>,
53 pub need_login_subtitle: bool,
55 pub preview_toast: String,
57 pub interaction: Option<InteractionInfo>,
59 pub options: Option<PlayerOptions>,
60 pub guide_attention: Option<serde_json::Value>,
61 pub jump_card: Option<serde_json::Value>,
62 pub operation_card: Option<serde_json::Value>,
63 pub online_switch: Option<serde_json::Value>,
64 pub fawkes: Option<serde_json::Value>,
65 pub show_switch: Option<serde_json::Value>,
66 pub bgm_info: Option<BgmInfo>,
68 pub toast_block: bool,
69 pub is_upower_exclusive: bool,
71 pub is_upower_play: bool,
72 pub is_ugc_pay_preview: bool,
73 pub elec_high_level: Option<ElecHighLevel>,
75 pub disable_show_up_info: bool,
76}
77
78#[derive(Debug, Clone, Deserialize, Serialize)]
80pub struct DmMaskInfo {
81 pub cid: u64,
83 pub plat: u8,
84 pub fps: u64,
86 pub time: u64,
87 pub mask_url: String,
89}
90
91#[derive(Debug, Clone, Deserialize, Serialize)]
93pub struct SubtitleInfo {
94 pub allow_submit: bool,
95 pub lan: String,
96 pub lan_doc: String,
97 #[serde(default)]
98 pub subtitles: Vec<SubtitleItem>,
99}
100
101#[derive(Debug, Clone, Deserialize, Serialize)]
103pub struct SubtitleItem {
104 pub ai_status: u8,
105 pub ai_type: u8,
106 pub id: u64,
107 pub id_str: String,
108 pub is_lock: bool,
109 pub lan: String,
111 pub lan_doc: String,
113 pub subtitle_url: String,
115 #[serde(rename = "type")]
116 pub subtitle_type: u8,
117}
118
119#[derive(Debug, Clone, Deserialize, Serialize)]
121pub struct ViewPoint {
122 pub content: String,
124 pub from: u64,
126 pub to: u64,
128 #[serde(rename = "type")]
129 pub point_type: u8,
130 #[serde(rename = "imgUrl")]
132 pub img_url: String,
133 #[serde(rename = "logoUrl")]
134 pub logo_url: String,
135 pub team_type: String,
136 pub team_name: String,
137}
138
139#[derive(Debug, Clone, Deserialize, Serialize)]
141pub struct InteractionInfo {
142 pub graph_version: u64,
144 pub msg: Option<String>,
146 pub error_toast: Option<String>,
148 pub mark: Option<u8>,
149 pub need_reload: Option<u8>,
150}
151
152#[derive(Debug, Clone, Deserialize, Serialize)]
154pub struct PlayerOptions {
155 pub is_360: bool,
157 pub without_vip: bool,
158}
159
160#[derive(Debug, Clone, Deserialize, Serialize)]
162pub struct BgmInfo {
163 pub music_id: String,
165 pub music_title: String,
167 pub jump_url: String,
169}
170
171#[derive(Debug, Clone, Deserialize, Serialize)]
173pub struct ElecHighLevel {
174 pub privilege_type: u64,
176 pub title: String,
178 pub sub_title: String,
180 pub show_button: bool,
182 pub button_text: String,
184 pub jump_url: Option<serde_json::Value>,
186 pub intro: String,
188 #[serde(default)]
189 pub open: bool,
190 #[serde(default)]
191 pub new: bool,
192 #[serde(default)]
193 pub question_text: String,
194 #[serde(default)]
195 pub qa_detail_link: String,
196}
197
198#[cfg(test)]
201mod tests {
202 use super::*;
203 use crate::ids::{Aid, Cid};
204 use crate::probe::contract::HttpMethod;
205 use crate::probe::endpoint_contract::EndpointContract;
206 use crate::video::params::VideoPlayerInfoParams;
207 use crate::{ApiEnvelope, BpiClient, BpiError, BpiResult};
208 use tracing::info;
209
210 const TEST_AID: u64 = 1906473802;
211 const TEST_CID: u64 = 636329244;
212
213 #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
214 #[tokio::test]
215 async fn test_video_player_info_v2_by_aid() -> Result<(), BpiError> {
216 let bpi = BpiClient::new().expect("client should build");
217 let params = VideoPlayerInfoParams::from_aid(Aid::new(TEST_AID)?, Cid::new(TEST_CID)?);
218 let data = bpi.video().player_info_v2(params).await?;
219
220 info!("播放器信息: {:?}", data);
221
222 assert_eq!(data.aid, TEST_AID);
223 assert_eq!(data.cid, TEST_CID);
224
225 Ok(())
226 }
227
228 #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
229 #[tokio::test]
230 async fn test_video_player_info_v2_by_bvid() -> Result<(), BpiError> {
231 let bpi = BpiClient::new().expect("client should build");
232 let params = VideoPlayerInfoParams::from_aid(Aid::new(TEST_AID)?, Cid::new(TEST_CID)?);
233 let data = bpi.video().player_info_v2(params).await?;
234
235 info!("播放器信息: {:?}", data);
236
237 assert_eq!(data.aid, TEST_AID);
238 assert_eq!(data.cid, TEST_CID);
239
240 Ok(())
241 }
242
243 fn contract() -> BpiResult<EndpointContract> {
244 EndpointContract::from_slice(include_bytes!(
245 "../../tests/contracts/video/player-read/player-info-v2/contract.json"
246 ))
247 }
248
249 #[test]
250 fn video_player_info_v2_contract_matches_endpoint_request() -> BpiResult<()> {
251 let contract = contract()?;
252 let params = VideoPlayerInfoParams::from_bvid("BV1xx411c7mD".parse()?, Cid::new(62131)?);
253
254 assert_eq!(contract.name, "video.player_info_v2");
255 assert_eq!(contract.request.method, HttpMethod::Get);
256 assert_eq!(contract.request.url.as_str(), PLAYER_INFO_V2_ENDPOINT);
257 assert!(contract.request.auth.requires_wbi());
258 assert_eq!(
259 contract.request.query.get("bvid").map(String::as_str),
260 Some("BV1xx411c7mD")
261 );
262 assert_eq!(
263 contract.request.query.get("cid").map(String::as_str),
264 Some("62131")
265 );
266 assert_eq!(
267 params.query_pairs(),
268 vec![
269 ("cid", "62131".to_string()),
270 ("bvid", "BV1xx411c7mD".to_string())
271 ]
272 );
273 assert_eq!(contract.cases.len(), 3);
274 Ok(())
275 }
276
277 #[test]
278 fn video_player_info_v2_response_fixtures_parse_declared_model() -> BpiResult<()> {
279 for bytes in [
280 include_bytes!(
281 "../../tests/contracts/video/player-read/player-info-v2/responses/anonymous.success.json"
282 )
283 .as_slice(),
284 include_bytes!(
285 "../../tests/contracts/video/player-read/player-info-v2/responses/normal.success.json"
286 )
287 .as_slice(),
288 include_bytes!(
289 "../../tests/contracts/video/player-read/player-info-v2/responses/vip.success.json"
290 )
291 .as_slice(),
292 ] {
293 let payload =
294 ApiEnvelope::<PlayerInfoResponseData>::from_slice(bytes)?.into_payload()?;
295
296 assert_eq!(payload.bvid, "BV1xx411c7mD");
297 assert_eq!(payload.cid, 62131);
298 }
299 Ok(())
300 }
301
302 fn local_probe_body(profile: &str) -> Option<serde_json::Value> {
303 let path = format!(
304 "target/bpi-probe-runs/video/player-read/player-info-v2/{profile}.response.json"
305 );
306 let bytes = std::fs::read(path).ok()?;
307 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
308 value
309 .get("response")
310 .and_then(|response| response.get("body"))
311 .cloned()
312 }
313
314 #[test]
315 fn video_player_info_v2_model_matches_local_probe_outputs_when_available() -> BpiResult<()> {
316 for profile in ["anonymous", "normal", "vip"] {
317 let Some(body) = local_probe_body(profile) else {
318 continue;
319 };
320 let payload = serde_json::from_value::<ApiEnvelope<PlayerInfoResponseData>>(body)?
321 .into_payload()?;
322
323 assert_eq!(payload.bvid, "BV1xx411c7mD");
324 }
325 Ok(())
326 }
327}