Skip to main content

bpi_rs/video/
videostream_url.rs

1//! 视频流地址相关接口 (web端)
2//!
3//! [查看 API 文档](https://github.com/SocialSisterYi/bilibili-API-collect/tree/master/docs/video)
4use serde::{Deserialize, Serialize};
5
6pub(crate) const PLAY_URL_ENDPOINT: &str = "https://api.bilibili.com/x/player/wbi/playurl";
7
8// --- 视频流URL相关数据结构体 ---
9
10/// DASH 流信息
11#[derive(Debug, Clone, Deserialize, Serialize)]
12pub struct DashInfo {
13    pub video: Vec<DashStream>,
14    pub audio: Vec<DashStream>,
15    #[serde(rename = "dolby")]
16    pub dolby: Option<DashDolby>,
17    pub flac: Option<DashFlac>,
18    pub duration: u64,
19}
20
21/// DASH 流中的 Dolby 音频信息
22#[derive(Debug, Clone, Deserialize, Serialize)]
23pub struct DashDolby {
24    pub r#type: u8,
25    pub audio: Option<Vec<DashStream>>,
26}
27
28/// DASH 流中的 FLAC 音频信息
29#[derive(Debug, Clone, Deserialize, Serialize)]
30pub struct DashFlac {
31    pub audio: Vec<DashStream>,
32}
33
34/// 单个 DASH 流信息
35#[derive(Debug, Clone, Deserialize, Serialize)]
36pub struct DashStream {
37    pub id: u64,
38    #[serde(rename = "baseUrl")]
39    pub base_url: String,
40
41    #[serde(rename = "backupUrl")]
42    pub backup_url: Vec<String>,
43    pub bandwidth: u64,
44    #[serde(rename = "mimeType")]
45    pub mime_type: String,
46    pub codecs: String,
47    pub width: Option<u32>,
48    pub height: Option<u32>,
49    #[serde(rename = "frameRate")]
50    pub frame_rate: Option<String>,
51    pub sar: Option<String>,
52    pub start_with_sap: Option<u8>,
53    pub segment_base: Option<serde_json::Value>,
54    pub md5: Option<String>,
55    pub size: Option<u64>,
56    pub db_type: Option<u8>,
57    pub r#type: Option<String>,
58    pub stream_name: Option<String>,
59    pub orientation: Option<u8>,
60}
61
62/// FLV/MP4 视频分段流信息
63#[derive(Debug, Clone, Deserialize, Serialize)]
64pub struct DurlInfo {
65    pub order: u32,
66    pub length: u64,
67    pub size: u64,
68    pub ahead: String,
69    pub vhead: String,
70    pub url: String,
71    pub backup_url: Vec<String>,
72}
73
74/// 支持的格式详细信息
75#[derive(Debug, Clone, Deserialize, Serialize)]
76pub struct SupportFormat {
77    pub quality: u64,
78    pub format: String,
79    pub new_description: String,
80    pub display_desc: String,
81    pub superscript: String,
82    pub codecs: Option<Vec<String>>,
83}
84
85/// 视频流URL响应数据
86#[derive(Debug, Clone, Deserialize, Serialize)]
87pub struct PlayUrlResponseData {
88    pub from: String,
89    pub result: String,
90    pub message: String,
91    pub quality: u64,
92    pub format: String,
93    pub timelength: u64,
94    pub accept_format: String,
95    pub accept_description: Vec<String>,
96    pub accept_quality: Vec<u64>,
97    pub video_codecid: u8,
98    pub seek_param: String,
99    pub seek_type: String,
100    pub durl: Option<Vec<DurlInfo>>,
101    pub dash: Option<DashInfo>,
102    pub support_formats: Vec<SupportFormat>,
103    pub high_format: Option<serde_json::Value>,
104    pub last_play_time: u64,
105    pub last_play_cid: u64,
106}
107
108// --- 测试模块 ---
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use crate::ids::{Aid, Cid};
114    use crate::probe::contract::HttpMethod;
115    use crate::probe::endpoint_contract::EndpointContract;
116    use crate::video::params::VideoPlayUrlParams;
117    use crate::{ApiEnvelope, BpiClient, BpiError, BpiResult};
118    use tracing::info;
119
120    const TEST_AID: u64 = 113898824998659;
121    const TEST_CID: u64 = 28104724389;
122
123    fn contract() -> BpiResult<EndpointContract> {
124        EndpointContract::from_slice(include_bytes!(
125            "../../tests/contracts/video/playurl/play-url/contract.json"
126        ))
127    }
128
129    #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
130    #[tokio::test]
131    async fn test_video_playurl_mp4_by_aid() -> Result<(), BpiError> {
132        let bpi = BpiClient::new().expect("client should build");
133        let params = VideoPlayUrlParams::from_aid(Aid::new(TEST_AID)?, Cid::new(TEST_CID)?)
134            .quality(64)
135            .format_flags(1);
136        let data = bpi.video().play_url(params).await?;
137
138        info!("MP4 视频流信息: {:?}", data);
139        assert!(data.durl.is_some());
140        assert_eq!(data.quality, 64);
141
142        Ok(())
143    }
144
145    #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
146    #[tokio::test]
147    async fn test_video_playurl_4k() -> Result<(), BpiError> {
148        let bpi = BpiClient::new().expect("client should build");
149        let params = VideoPlayUrlParams::from_aid(Aid::new(TEST_AID)?, Cid::new(TEST_CID)?)
150            .quality(120)
151            .format_flags(16 | 128)
152            .format_version(0)
153            .fourk(true);
154        let data = bpi.video().play_url(params).await?;
155
156        info!("4K 视频流信息: {:?}", data);
157        assert!(data.dash.is_some());
158        assert_eq!(data.quality, 120);
159
160        Ok(())
161    }
162
163    #[test]
164    fn video_play_url_contract_matches_endpoint_request() -> BpiResult<()> {
165        let contract = contract()?;
166        let params = VideoPlayUrlParams::from_bvid("BV1xx411c7mD".parse()?, Cid::new(62131)?)
167            .quality(32)
168            .format_flags(16)
169            .format_version(0);
170
171        assert_eq!(contract.name, "video.play_url");
172        assert_eq!(contract.request.method, HttpMethod::Get);
173        assert_eq!(contract.request.url.as_str(), PLAY_URL_ENDPOINT);
174        assert!(contract.request.auth.requires_wbi());
175        assert_eq!(
176            contract.request.query.get("cid").map(String::as_str),
177            Some("62131")
178        );
179        assert_eq!(
180            contract.request.query.get("bvid").map(String::as_str),
181            Some("BV1xx411c7mD")
182        );
183        assert_eq!(
184            contract.request.query.get("qn").map(String::as_str),
185            Some("32")
186        );
187        assert_eq!(
188            contract.request.query.get("fnval").map(String::as_str),
189            Some("16")
190        );
191        assert_eq!(
192            contract.request.query.get("fnver").map(String::as_str),
193            Some("0")
194        );
195        assert_eq!(
196            contract.request.query.get("platform").map(String::as_str),
197            Some("pc")
198        );
199        assert_eq!(
200            params.query_pairs(),
201            vec![
202                ("cid", "62131".to_string()),
203                ("bvid", "BV1xx411c7mD".to_string()),
204                ("qn", "32".to_string()),
205                ("fnval", "16".to_string()),
206                ("fnver", "0".to_string()),
207                ("platform", "pc".to_string()),
208            ]
209        );
210        assert_eq!(contract.cases.len(), 3);
211        assert!(
212            contract
213                .cases
214                .iter()
215                .all(|case| case.response.api_code == Some(0))
216        );
217        assert!(
218            contract
219                .cases
220                .iter()
221                .all(|case| case.response.rust_model.as_deref() == Some("PlayUrlResponseData"))
222        );
223        Ok(())
224    }
225
226    #[test]
227    fn video_play_url_response_fixtures_parse_declared_model() -> BpiResult<()> {
228        let payload = ApiEnvelope::<PlayUrlResponseData>::from_slice(include_bytes!(
229            "../../tests/contracts/video/playurl/play-url/responses/success.json"
230        ))?
231        .into_payload()?;
232
233        assert_eq!(payload.quality, 32);
234        assert_eq!(payload.format, "flv480");
235        assert_eq!(
236            payload
237                .dash
238                .as_ref()
239                .and_then(|dash| dash.video.first())
240                .map(|track| track.base_url.as_str()),
241            Some("https://example.invalid/bilibili/playurl/redacted.m4s")
242        );
243        Ok(())
244    }
245
246    fn local_probe_body(profile: &str) -> Option<serde_json::Value> {
247        let path = format!("target/bpi-probe-runs/video/playurl/play-url/{profile}.response.json");
248        let bytes = std::fs::read(path).ok()?;
249        let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
250        value
251            .get("response")
252            .and_then(|response| response.get("body"))
253            .cloned()
254    }
255
256    #[test]
257    fn video_play_url_model_matches_local_probe_outputs_when_available() -> BpiResult<()> {
258        for profile in ["anonymous", "normal", "vip"] {
259            let Some(body) = local_probe_body(profile) else {
260                continue;
261            };
262            let payload =
263                serde_json::from_value::<ApiEnvelope<PlayUrlResponseData>>(body)?.into_payload()?;
264
265            assert_eq!(payload.quality, 32);
266            assert!(payload.dash.is_some());
267        }
268        Ok(())
269    }
270}