Skip to main content

bpi_rs/video/
interact_video.rs

1//! 互动视频相关接口
2//!
3//! [查看 API 文档](https://github.com/SocialSisterYi/bilibili-API-collect/tree/master/docs/video)
4use serde::{Deserialize, Serialize};
5
6pub(crate) const INTERACTIVE_INFO_ENDPOINT: &str = "https://api.bilibili.com/x/stein/edgeinfo_v2";
7
8// --- 响应数据结构体 ---
9
10/// 互动视频模块详细信息响应数据
11#[derive(Debug, Clone, Deserialize, Serialize)]
12pub struct InteractiveVideoInfoResponseData {
13    /// 视频模块(分P)标题
14    pub title: String,
15    /// 当前模块 ID
16    pub edge_id: u64,
17    /// 进度回溯信息
18    #[serde(default)]
19    pub story_list: Vec<InteractiveVideoStory>,
20    /// 当前模块信息
21    pub edges: Option<InteractiveVideoEdges>,
22    /// 预加载的分P
23    pub preload: Option<InteractiveVideoPreload>,
24    /// 变量列表
25    #[serde(default)]
26    pub hidden_vars: Vec<InteractiveVideoHiddenVar>,
27    /// 是否为结束模块, 0: 普通模块, 1: 结束模块
28    pub is_leaf: u8,
29    /// 禁止记录选择, 1: 禁止
30    #[serde(default)]
31    pub no_tutorial: u8,
32    /// 禁止进度回溯, 1: 禁止
33    #[serde(default)]
34    pub no_backtracking: u8,
35    /// 禁止结尾评分, 1: 禁止
36    #[serde(default)]
37    pub no_evaluation: u8,
38}
39
40/// 进度回溯信息
41#[derive(Debug, Clone, Deserialize, Serialize)]
42pub struct InteractiveVideoStory {
43    /// 模块编号
44    pub node_id: u64,
45    /// 同上
46    pub edge_id: u64,
47    /// 模块(分P)标题
48    pub title: String,
49    /// 模块(分P)cid
50    pub cid: u64,
51    /// 记录播放开始位置,单位为毫秒
52    pub start_pos: u64,
53    /// 分P封面 url
54    pub cover: String,
55    /// 是否为当前模块, 1: 是
56    #[serde(default)]
57    pub is_current: u8,
58    /// 进度序号,从0开始向上增长
59    pub cursor: u64,
60}
61
62/// 当前模块信息
63#[derive(Debug, Clone, Deserialize, Serialize)]
64pub struct InteractiveVideoEdges {
65    /// 当前分P分辨率
66    pub dimension: Option<InteractiveVideoDimension>,
67    /// 问题列表,问题结束模块无此项
68    #[serde(default)]
69    pub questions: Vec<InteractiveVideoQuestion>,
70    /// 问题外观
71    pub skin: Option<serde_json::Value>,
72}
73
74/// 分辨率信息
75#[derive(Debug, Clone, Deserialize, Serialize)]
76pub struct InteractiveVideoDimension {
77    /// 宽度
78    pub width: u32,
79    /// 高度
80    pub height: u32,
81    /// 是否将宽高对换, 0: 正常, 1: 对换
82    pub rotate: u8,
83    /// 作用尚不明确
84    pub sar: String,
85}
86
87/// 问题信息
88#[derive(Debug, Clone, Deserialize, Serialize)]
89pub struct InteractiveVideoQuestion {
90    /// 作用尚不明确
91    pub id: u64,
92    /// 选项显示模式, 0: 不显示选项, 1: 底部选项模式, 2: 坐标定点模式
93    #[serde(rename = "type")]
94    pub question_type: u8,
95    /// 作用尚不明确
96    pub start_time_r: u32,
97    /// 回答限时,单位为毫秒,不限时为-1
98    pub duration: i64,
99    /// 是否暂停播放视频, 0: 不暂停, 1: 暂停播放
100    pub pause_video: u8,
101    /// 作用尚不明确
102    pub title: String,
103    /// 选项列表
104    pub choices: Vec<InteractiveVideoChoice>,
105}
106
107/// 选项信息
108#[derive(Debug, Clone, Deserialize, Serialize)]
109pub struct InteractiveVideoChoice {
110    /// 选项所跳转的模块 id
111    pub id: u64,
112    /// 跳转信息文字, 例如 `JUMP+{模块编号}+{cid}`
113    pub platform_action: String,
114    /// 点击后对变量运算语句
115    pub native_action: String,
116    /// 选项出现条件判断语句
117    pub condition: String,
118    /// 选项所跳转分P的cid
119    pub cid: u64,
120    /// 选项文字
121    pub option: String,
122    /// 是否为默认选项, 1: 是
123    #[serde(default)]
124    pub is_default: Option<u8>,
125    /// 是否为隐藏选项, 1: 是
126    #[serde(default)]
127    pub is_hidden: Option<u8>,
128}
129
130/// 预加载的分P信息
131#[derive(Debug, Clone, Deserialize, Serialize)]
132pub struct InteractiveVideoPreload {
133    /// 预加载的分P列表
134    #[serde(default)]
135    pub video: Vec<InteractiveVideoPreloadVideo>,
136}
137
138/// 预加载的分P
139#[derive(Debug, Clone, Deserialize, Serialize)]
140pub struct InteractiveVideoPreloadVideo {
141    /// 稿件avid
142    pub aid: u64,
143    /// 分P cid
144    pub cid: u64,
145}
146
147/// 变量信息
148#[derive(Debug, Clone, Deserialize, Serialize)]
149pub struct InteractiveVideoHiddenVar {
150    /// 变量值
151    pub value: i64,
152    /// 变量编号
153    pub id: String,
154    /// 变量编号,语句中一般使用此项
155    pub id_v2: String,
156    /// 变量类型, 1: 普通变量, 2: 随机值
157    #[serde(rename = "type")]
158    pub var_type: u8,
159    /// 是否展示变量, 0: 否, 1: 是
160    pub is_show: u8,
161    /// 变量名
162    pub name: String,
163}
164
165// --- 测试模块 ---
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use crate::ids::Aid;
171    use crate::probe::contract::HttpMethod;
172    use crate::probe::endpoint_contract::EndpointContract;
173    use crate::video::params::InteractiveVideoInfoParams;
174    use crate::{ApiEnvelope, BpiClient, BpiError, BpiResult};
175    use tracing::info;
176
177    const TEST_AID: u64 = 114347430905959;
178    const TEST_GRAPH_VERSION: u64 = 1273647;
179
180    #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
181    #[tokio::test]
182    async fn test_video_interactive_video_info_by_aid() -> Result<(), BpiError> {
183        let bpi = BpiClient::new().expect("client should build");
184        let params = InteractiveVideoInfoParams::from_aid(Aid::new(TEST_AID)?, TEST_GRAPH_VERSION)?;
185        let data = bpi.video().interactive_video_info(params).await?;
186
187        info!("互动视频信息: {:?}", data);
188        assert!(!data.title.is_empty());
189        assert!(!data.story_list.is_empty());
190
191        Ok(())
192    }
193
194    fn contract() -> BpiResult<EndpointContract> {
195        EndpointContract::from_slice(include_bytes!(
196            "../../tests/contracts/video/player-read/interactive-info/contract.json"
197        ))
198    }
199
200    #[test]
201    fn video_interactive_info_contract_matches_endpoint_request() -> BpiResult<()> {
202        let contract = contract()?;
203        let params = InteractiveVideoInfoParams::from_aid(Aid::new(114347430905959)?, 1273647)?;
204
205        assert_eq!(contract.name, "video.interactive_video_info");
206        assert_eq!(contract.request.method, HttpMethod::Get);
207        assert_eq!(contract.request.url.as_str(), INTERACTIVE_INFO_ENDPOINT);
208        assert_eq!(
209            contract
210                .request
211                .query
212                .get("graph_version")
213                .map(String::as_str),
214            Some("1273647")
215        );
216        assert_eq!(
217            contract.request.query.get("aid").map(String::as_str),
218            Some("114347430905959")
219        );
220        assert_eq!(
221            params.query_pairs(),
222            vec![
223                ("graph_version", "1273647".to_string()),
224                ("aid", "114347430905959".to_string())
225            ]
226        );
227        assert_eq!(contract.cases.len(), 3);
228        Ok(())
229    }
230
231    #[test]
232    fn video_interactive_info_response_fixture_parses_declared_model() -> BpiResult<()> {
233        let payload = ApiEnvelope::<InteractiveVideoInfoResponseData>::from_slice(include_bytes!(
234            "../../tests/contracts/video/player-read/interactive-info/responses/success.json"
235        ))?
236        .into_payload()?;
237
238        assert_eq!(payload.title, "序幕");
239        assert!(!payload.story_list.is_empty());
240        Ok(())
241    }
242
243    fn local_probe_body(profile: &str) -> Option<serde_json::Value> {
244        let path = format!(
245            "target/bpi-probe-runs/video/player-read/interactive-info/{profile}.response.json"
246        );
247        let bytes = std::fs::read(path).ok()?;
248        let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
249        value
250            .get("response")
251            .and_then(|response| response.get("body"))
252            .cloned()
253    }
254
255    #[test]
256    fn video_interactive_info_model_matches_local_probe_outputs_when_available() -> BpiResult<()> {
257        for profile in ["anonymous", "normal", "vip"] {
258            let Some(body) = local_probe_body(profile) else {
259                continue;
260            };
261            let payload =
262                serde_json::from_value::<ApiEnvelope<InteractiveVideoInfoResponseData>>(body)?
263                    .into_payload()?;
264
265            assert!(!payload.title.is_empty());
266        }
267        Ok(())
268    }
269}