Skip to main content

bpi_rs/video/
player.rs

1//! B站 web 播放器相关接口
2//!
3//! [查看 API 文档](https://github.com/SocialSisterYi/bilibili-API-collect/tree/master/docs/video)
4use serde::{Deserialize, Serialize};
5
6pub(crate) const PLAYER_INFO_V2_ENDPOINT: &str = "https://api.bilibili.com/x/player/wbi/v2";
7
8// --- 响应数据结构体 ---
9
10/// web 播放器信息响应数据
11#[derive(Debug, Clone, Deserialize, Serialize)]
12pub struct PlayerInfoResponseData {
13    /// 视频 aid
14    pub aid: u64,
15    /// 视频 bvid
16    pub bvid: String,
17    pub allow_bp: bool,
18    pub no_share: bool,
19    /// 视频 cid
20    pub cid: u64,
21    /// webmask 防挡字幕信息
22    pub dm_mask: Option<DmMaskInfo>,
23    /// 字幕信息
24    pub subtitle: Option<SubtitleInfo>,
25    /// 分段章节信息
26    #[serde(default)]
27    pub view_points: Vec<ViewPoint>,
28    /// 请求 IP 信息
29    pub ip_info: Option<serde_json::Value>,
30    /// 登录用户 mid
31    pub login_mid: u64,
32    pub login_mid_hash: Option<String>,
33    /// 是否为该视频 UP 主
34    pub is_owner: bool,
35    pub name: String,
36    pub permission: String,
37    /// 登录用户等级信息
38    pub level_info: Option<serde_json::Value>,
39    /// 登录用户 VIP 信息
40    pub vip: Option<serde_json::Value>,
41    /// 答题状态
42    pub answer_status: u8,
43    pub block_time: u64,
44    pub role: String,
45    /// 上次观看时间
46    pub last_play_time: u64,
47    /// 上次观看 cid
48    pub last_play_cid: u64,
49    /// 当前 UNIX 秒级时间戳
50    pub now_time: u64,
51    /// 在线人数
52    pub online_count: Option<u64>,
53    /// 是否必须登陆才能查看字幕
54    pub need_login_subtitle: bool,
55    /// 预告提示
56    pub preview_toast: String,
57    /// 互动视频资讯
58    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    /// 背景音乐信息
67    pub bgm_info: Option<BgmInfo>,
68    pub toast_block: bool,
69    /// 是否为充电专属视频
70    pub is_upower_exclusive: bool,
71    pub is_upower_play: bool,
72    pub is_ugc_pay_preview: bool,
73    /// 充电专属视频信息
74    pub elec_high_level: Option<ElecHighLevel>,
75    pub disable_show_up_info: bool,
76}
77
78/// webmask 防挡字幕信息
79#[derive(Debug, Clone, Deserialize, Serialize)]
80pub struct DmMaskInfo {
81    /// 视频 cid
82    pub cid: u64,
83    pub plat: u8,
84    /// webmask 取样 fps
85    pub fps: u64,
86    pub time: u64,
87    /// webmask 资源 url
88    pub mask_url: String,
89}
90
91/// 字幕信息
92#[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/// 单个字幕信息
102#[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    /// 语言类型英文字母缩写
110    pub lan: String,
111    /// 语言类型中文名称
112    pub lan_doc: String,
113    /// 资源 url 地址
114    pub subtitle_url: String,
115    #[serde(rename = "type")]
116    pub subtitle_type: u8,
117}
118
119/// 分段章节信息
120#[derive(Debug, Clone, Deserialize, Serialize)]
121pub struct ViewPoint {
122    /// 分段章节名
123    pub content: String,
124    /// 分段章节起始秒数
125    pub from: u64,
126    /// 分段章节结束秒数
127    pub to: u64,
128    #[serde(rename = "type")]
129    pub point_type: u8,
130    /// 图片资源地址
131    #[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/// 互动视频资讯
140#[derive(Debug, Clone, Deserialize, Serialize)]
141pub struct InteractionInfo {
142    /// 剧情图 id
143    pub graph_version: u64,
144    /// 未登录有机会返回
145    pub msg: Option<String>,
146    /// 错误信息
147    pub error_toast: Option<String>,
148    pub mark: Option<u8>,
149    pub need_reload: Option<u8>,
150}
151
152/// 播放器选项
153#[derive(Debug, Clone, Deserialize, Serialize)]
154pub struct PlayerOptions {
155    /// 是否 360 全景视频
156    pub is_360: bool,
157    pub without_vip: bool,
158}
159
160/// 背景音乐信息
161#[derive(Debug, Clone, Deserialize, Serialize)]
162pub struct BgmInfo {
163    /// 音乐 id
164    pub music_id: String,
165    /// 音乐标题
166    pub music_title: String,
167    /// 跳转 URL
168    pub jump_url: String,
169}
170
171/// 充电专属视频信息
172#[derive(Debug, Clone, Deserialize, Serialize)]
173pub struct ElecHighLevel {
174    /// 解锁视频所需最低定价档位的代码
175    pub privilege_type: u64,
176    /// 提示标题
177    pub title: String,
178    /// 提示子标题
179    pub sub_title: String,
180    /// 是否显示按钮
181    pub show_button: bool,
182    /// 按钮文本
183    pub button_text: String,
184    /// 跳转url信息
185    pub jump_url: Option<serde_json::Value>,
186    /// 充电介绍语
187    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// --- 测试模块 ---
199
200#[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}