use serde::{Deserialize, Serialize};
pub(crate) const INTERACTIVE_INFO_ENDPOINT: &str = "https://api.bilibili.com/x/stein/edgeinfo_v2";
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct InteractiveVideoInfoResponseData {
pub title: String,
pub edge_id: u64,
#[serde(default)]
pub story_list: Vec<InteractiveVideoStory>,
pub edges: Option<InteractiveVideoEdges>,
pub preload: Option<InteractiveVideoPreload>,
#[serde(default)]
pub hidden_vars: Vec<InteractiveVideoHiddenVar>,
pub is_leaf: u8,
#[serde(default)]
pub no_tutorial: u8,
#[serde(default)]
pub no_backtracking: u8,
#[serde(default)]
pub no_evaluation: u8,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct InteractiveVideoStory {
pub node_id: u64,
pub edge_id: u64,
pub title: String,
pub cid: u64,
pub start_pos: u64,
pub cover: String,
#[serde(default)]
pub is_current: u8,
pub cursor: u64,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct InteractiveVideoEdges {
pub dimension: Option<InteractiveVideoDimension>,
#[serde(default)]
pub questions: Vec<InteractiveVideoQuestion>,
pub skin: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct InteractiveVideoDimension {
pub width: u32,
pub height: u32,
pub rotate: u8,
pub sar: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct InteractiveVideoQuestion {
pub id: u64,
#[serde(rename = "type")]
pub question_type: u8,
pub start_time_r: u32,
pub duration: i64,
pub pause_video: u8,
pub title: String,
pub choices: Vec<InteractiveVideoChoice>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct InteractiveVideoChoice {
pub id: u64,
pub platform_action: String,
pub native_action: String,
pub condition: String,
pub cid: u64,
pub option: String,
#[serde(default)]
pub is_default: Option<u8>,
#[serde(default)]
pub is_hidden: Option<u8>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct InteractiveVideoPreload {
#[serde(default)]
pub video: Vec<InteractiveVideoPreloadVideo>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct InteractiveVideoPreloadVideo {
pub aid: u64,
pub cid: u64,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct InteractiveVideoHiddenVar {
pub value: i64,
pub id: String,
pub id_v2: String,
#[serde(rename = "type")]
pub var_type: u8,
pub is_show: u8,
pub name: String,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ids::Aid;
use crate::probe::contract::HttpMethod;
use crate::probe::endpoint_contract::EndpointContract;
use crate::video::params::InteractiveVideoInfoParams;
use crate::{ApiEnvelope, BpiClient, BpiError, BpiResult};
use tracing::info;
const TEST_AID: u64 = 114347430905959;
const TEST_GRAPH_VERSION: u64 = 1273647;
#[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
#[tokio::test]
async fn test_video_interactive_video_info_by_aid() -> Result<(), BpiError> {
let bpi = BpiClient::new().expect("client should build");
let params = InteractiveVideoInfoParams::from_aid(Aid::new(TEST_AID)?, TEST_GRAPH_VERSION)?;
let data = bpi.video().interactive_video_info(params).await?;
info!("互动视频信息: {:?}", data);
assert!(!data.title.is_empty());
assert!(!data.story_list.is_empty());
Ok(())
}
fn contract() -> BpiResult<EndpointContract> {
EndpointContract::from_slice(include_bytes!(
"../../tests/contracts/video/player-read/interactive-info/contract.json"
))
}
#[test]
fn video_interactive_info_contract_matches_endpoint_request() -> BpiResult<()> {
let contract = contract()?;
let params = InteractiveVideoInfoParams::from_aid(Aid::new(114347430905959)?, 1273647)?;
assert_eq!(contract.name, "video.interactive_video_info");
assert_eq!(contract.request.method, HttpMethod::Get);
assert_eq!(contract.request.url.as_str(), INTERACTIVE_INFO_ENDPOINT);
assert_eq!(
contract
.request
.query
.get("graph_version")
.map(String::as_str),
Some("1273647")
);
assert_eq!(
contract.request.query.get("aid").map(String::as_str),
Some("114347430905959")
);
assert_eq!(
params.query_pairs(),
vec![
("graph_version", "1273647".to_string()),
("aid", "114347430905959".to_string())
]
);
assert_eq!(contract.cases.len(), 3);
Ok(())
}
#[test]
fn video_interactive_info_response_fixture_parses_declared_model() -> BpiResult<()> {
let payload = ApiEnvelope::<InteractiveVideoInfoResponseData>::from_slice(include_bytes!(
"../../tests/contracts/video/player-read/interactive-info/responses/success.json"
))?
.into_payload()?;
assert_eq!(payload.title, "序幕");
assert!(!payload.story_list.is_empty());
Ok(())
}
fn local_probe_body(profile: &str) -> Option<serde_json::Value> {
let path = format!(
"target/bpi-probe-runs/video/player-read/interactive-info/{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_interactive_info_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<InteractiveVideoInfoResponseData>>(body)?
.into_payload()?;
assert!(!payload.title.is_empty());
}
Ok(())
}
}