1use serde::{Deserialize, Serialize};
5
6pub(crate) const INTERACTIVE_INFO_ENDPOINT: &str = "https://api.bilibili.com/x/stein/edgeinfo_v2";
7
8#[derive(Debug, Clone, Deserialize, Serialize)]
12pub struct InteractiveVideoInfoResponseData {
13 pub title: String,
15 pub edge_id: u64,
17 #[serde(default)]
19 pub story_list: Vec<InteractiveVideoStory>,
20 pub edges: Option<InteractiveVideoEdges>,
22 pub preload: Option<InteractiveVideoPreload>,
24 #[serde(default)]
26 pub hidden_vars: Vec<InteractiveVideoHiddenVar>,
27 pub is_leaf: u8,
29 #[serde(default)]
31 pub no_tutorial: u8,
32 #[serde(default)]
34 pub no_backtracking: u8,
35 #[serde(default)]
37 pub no_evaluation: u8,
38}
39
40#[derive(Debug, Clone, Deserialize, Serialize)]
42pub struct InteractiveVideoStory {
43 pub node_id: u64,
45 pub edge_id: u64,
47 pub title: String,
49 pub cid: u64,
51 pub start_pos: u64,
53 pub cover: String,
55 #[serde(default)]
57 pub is_current: u8,
58 pub cursor: u64,
60}
61
62#[derive(Debug, Clone, Deserialize, Serialize)]
64pub struct InteractiveVideoEdges {
65 pub dimension: Option<InteractiveVideoDimension>,
67 #[serde(default)]
69 pub questions: Vec<InteractiveVideoQuestion>,
70 pub skin: Option<serde_json::Value>,
72}
73
74#[derive(Debug, Clone, Deserialize, Serialize)]
76pub struct InteractiveVideoDimension {
77 pub width: u32,
79 pub height: u32,
81 pub rotate: u8,
83 pub sar: String,
85}
86
87#[derive(Debug, Clone, Deserialize, Serialize)]
89pub struct InteractiveVideoQuestion {
90 pub id: u64,
92 #[serde(rename = "type")]
94 pub question_type: u8,
95 pub start_time_r: u32,
97 pub duration: i64,
99 pub pause_video: u8,
101 pub title: String,
103 pub choices: Vec<InteractiveVideoChoice>,
105}
106
107#[derive(Debug, Clone, Deserialize, Serialize)]
109pub struct InteractiveVideoChoice {
110 pub id: u64,
112 pub platform_action: String,
114 pub native_action: String,
116 pub condition: String,
118 pub cid: u64,
120 pub option: String,
122 #[serde(default)]
124 pub is_default: Option<u8>,
125 #[serde(default)]
127 pub is_hidden: Option<u8>,
128}
129
130#[derive(Debug, Clone, Deserialize, Serialize)]
132pub struct InteractiveVideoPreload {
133 #[serde(default)]
135 pub video: Vec<InteractiveVideoPreloadVideo>,
136}
137
138#[derive(Debug, Clone, Deserialize, Serialize)]
140pub struct InteractiveVideoPreloadVideo {
141 pub aid: u64,
143 pub cid: u64,
145}
146
147#[derive(Debug, Clone, Deserialize, Serialize)]
149pub struct InteractiveVideoHiddenVar {
150 pub value: i64,
152 pub id: String,
154 pub id_v2: String,
156 #[serde(rename = "type")]
158 pub var_type: u8,
159 pub is_show: u8,
161 pub name: String,
163}
164
165#[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}