Skip to main content

bpi_rs/cheese/
videostream_url.rs

1//! 课程视频流 URL API
2//!
3//! [参考文档](https://github.com/Yuelioi/bilibili-API-collect/tree/cfc5fddcc8a94b74d91970bb5b4eaeb349addc47/docs/cheese/videostream_url.md)
4
5use std::collections::HashMap;
6
7use crate::models::{DashStreams, SupportFormat};
8use serde::{Deserialize, Serialize};
9
10/// 课程视频流数据
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct CourseVideoStreamData {
13    #[serde(flatten)]
14    pub base: crate::models::VideoStreamData,
15
16    /// 定位参数
17    pub seek_param: String,
18    /// 是否为视频项目
19    pub video_project: bool,
20    /// 数据类型
21    #[serde(rename = "type")]
22    pub data_type: String,
23    /// 结果状态
24    pub result: String,
25    /// 定位类型
26    pub seek_type: String,
27    /// 来源
28    pub from: String,
29    /// 是否重编码
30    pub no_rexcode: i32,
31    /// 响应消息
32    pub message: String,
33    /// 分片视频信息
34    pub fragment_videos: Option<Vec<FragmentVideo>>,
35    /// 状态码
36    pub status: i32,
37}
38
39/// 分片视频
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct FragmentVideo {
42    pub fragment_info: FragmentInfo,
43    pub playable_status: bool,
44    pub video_info: VideoInfo,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct FragmentInfo {
49    pub fragment_type: String,
50    pub index: i64,
51    pub aid: i64,
52    pub fragment_position: String,
53    pub cid: i64,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct VideoInfo {
58    pub no_rexcode: i64,
59    pub fnval: i64,
60    pub video_project: bool,
61    pub expire_time: i64,
62    pub backup_url: Vec<Option<serde_json::Value>>,
63    pub fnver: i64,
64    pub support_formats: Vec<String>,
65    pub support_description: Vec<String>,
66    #[serde(rename = "type")]
67    pub video_info_type: String,
68    pub url: String,
69    pub quality: i64,
70    pub timelength: i64,
71    pub volume: CourseVolume,
72    pub accept_formats: Vec<SupportFormat>,
73    pub support_quality: Vec<i64>,
74    pub file_info: HashMap<String, FileInfo>,
75    pub dash: DashStreams,
76    pub video_codecid: i64,
77    pub cid: i64,
78}
79
80/// 音量信息
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct CourseVolume {
83    pub measured_i: f64,
84    pub target_i: f64,
85    pub target_offset: f64,
86    pub measured_lra: f64,
87    pub target_tp: f64,
88    pub measured_tp: f64,
89    pub measured_threshold: f64,
90    pub multi_scene_args: MultiSceneArgs,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct MultiSceneArgs {
95    pub normal_target_i: String,
96    pub undersized_target_i: String,
97    pub high_dynamic_target_i: String,
98}
99
100/// 文件信息
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct FileInfo {
103    pub infos: Vec<FileInfoEntry>,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct FileInfoEntry {
108    pub ahead: String,
109    pub vhead: String,
110    pub filesize: i64,
111    pub order: i64,
112    pub timelength: i64,
113}
114
115// ==========================
116// 测试
117// ==========================
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use crate::cheese::CheeseVideoStreamParams;
123    use crate::ids::{Aid, Cid, EpisodeId};
124    use crate::models::{Fnval, VideoQuality};
125    use crate::probe::contract::HttpMethod;
126    use crate::probe::endpoint_contract::EndpointContract;
127    use crate::{ApiEnvelope, BpiClient, BpiError, BpiResult};
128
129    const TEST_AVID: u64 = 997984154;
130    const TEST_EP_ID: u64 = 163956;
131    const TEST_CID: u64 = 1183682680;
132
133    fn contract() -> BpiResult<EndpointContract> {
134        EndpointContract::from_slice(include_bytes!(
135            "../../tests/contracts/cheese/playurl/contract.json"
136        ))
137    }
138
139    #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
140    #[tokio::test]
141    async fn test_cheese_playurl() -> Result<(), Box<BpiError>> {
142        let bpi = BpiClient::new().expect("client should build");
143
144        let data = bpi
145            .cheese()
146            .video_stream(
147                CheeseVideoStreamParams::new(
148                    Aid::new(TEST_AVID)?,
149                    EpisodeId::new(TEST_EP_ID)?,
150                    Cid::new(TEST_CID)?,
151                )
152                .with_quality(VideoQuality::P8K)
153                .with_fnval(
154                    Fnval::DASH
155                        | Fnval::FOURK
156                        | Fnval::EIGHTK
157                        | Fnval::HDR
158                        | Fnval::DOLBY_AUDIO
159                        | Fnval::DOLBY_VISION
160                        | Fnval::AV1,
161                ),
162            )
163            .await?;
164
165        tracing::info!("{:#?}", data);
166
167        Ok(())
168    }
169
170    #[test]
171    fn cheese_video_stream_params_serializes_playback_flags() -> Result<(), BpiError> {
172        let params = CheeseVideoStreamParams::new(
173            Aid::new(TEST_AVID)?,
174            EpisodeId::new(TEST_EP_ID)?,
175            Cid::new(TEST_CID)?,
176        )
177        .with_quality(VideoQuality::P8K)
178        .with_fnval(Fnval::DASH | Fnval::FOURK);
179
180        assert_eq!(
181            params.query_pairs(),
182            vec![
183                ("avid", TEST_AVID.to_string()),
184                ("ep_id", TEST_EP_ID.to_string()),
185                ("cid", TEST_CID.to_string()),
186                ("fnver", "0".to_string()),
187                ("fourk", "1".to_string()),
188                ("qn", VideoQuality::P8K.as_u32().to_string()),
189                ("fnval", (Fnval::DASH | Fnval::FOURK).bits().to_string()),
190            ]
191        );
192        Ok(())
193    }
194
195    #[test]
196    fn cheese_playurl_contract_matches_endpoint_request() -> BpiResult<()> {
197        let contract = contract()?;
198        let params = CheeseVideoStreamParams::new(
199            Aid::new(TEST_AVID)?,
200            EpisodeId::new(TEST_EP_ID)?,
201            Cid::new(TEST_CID)?,
202        )
203        .with_quality(VideoQuality::P480)
204        .with_fnval(Fnval::DASH);
205
206        assert_eq!(contract.name, "cheese.playurl");
207        assert_eq!(contract.request.method, HttpMethod::Get);
208        assert_eq!(
209            contract.request.url.as_str(),
210            "https://api.bilibili.com/pugv/player/web/playurl"
211        );
212        assert_eq!(
213            contract.request.query.get("avid").map(String::as_str),
214            Some("997984154")
215        );
216        assert_eq!(
217            contract.request.query.get("ep_id").map(String::as_str),
218            Some("163956")
219        );
220        assert_eq!(
221            contract.request.query.get("cid").map(String::as_str),
222            Some("1183682680")
223        );
224        assert_eq!(
225            contract.request.query.get("fnver").map(String::as_str),
226            Some("0")
227        );
228        assert_eq!(
229            contract.request.query.get("qn").map(String::as_str),
230            Some("32")
231        );
232        assert_eq!(
233            contract.request.query.get("fnval").map(String::as_str),
234            Some("16")
235        );
236        assert_eq!(
237            params.query_pairs(),
238            vec![
239                ("avid", TEST_AVID.to_string()),
240                ("ep_id", TEST_EP_ID.to_string()),
241                ("cid", TEST_CID.to_string()),
242                ("fnver", "0".to_string()),
243                ("qn", "32".to_string()),
244                ("fnval", "16".to_string()),
245            ]
246        );
247        assert_eq!(contract.cases.len(), 3);
248        assert_eq!(
249            contract.cases[0].response.rust_model.as_deref(),
250            Some("CourseVideoStreamData")
251        );
252        assert_eq!(
253            contract.cases[0].response.fixture_kind.as_deref(),
254            Some("sanitized_probe_body")
255        );
256        Ok(())
257    }
258
259    #[test]
260    fn cheese_playurl_response_fixtures_parse_declared_model() -> BpiResult<()> {
261        for bytes in [
262            include_bytes!("../../tests/contracts/cheese/playurl/responses/anonymous.success.json")
263                .as_slice(),
264            include_bytes!("../../tests/contracts/cheese/playurl/responses/normal.success.json")
265                .as_slice(),
266            include_bytes!("../../tests/contracts/cheese/playurl/responses/vip.success.json")
267                .as_slice(),
268        ] {
269            let payload =
270                ApiEnvelope::<CourseVideoStreamData>::from_slice(bytes)?.into_payload()?;
271
272            assert_eq!(payload.base.quality, VideoQuality::P480.as_u32());
273            assert!(!payload.base.has_paid);
274            assert!(payload.base.supports_dash());
275            assert_eq!(
276                payload
277                    .base
278                    .best_video()
279                    .map(|track| track.base_url.as_str()),
280                Some("https://example.invalid/bilibili/playurl/redacted.m4s")
281            );
282            assert!(
283                payload
284                    .fragment_videos
285                    .as_ref()
286                    .is_some_and(|fragments| !fragments.is_empty())
287            );
288        }
289        Ok(())
290    }
291
292    fn local_probe_body(profile: &str) -> Option<serde_json::Value> {
293        let path = format!("target/bpi-probe-runs/cheese/read/playurl/{profile}.response.json");
294        let bytes = std::fs::read(path).ok()?;
295        let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
296        value
297            .get("response")
298            .and_then(|response| response.get("body"))
299            .cloned()
300    }
301
302    #[test]
303    fn cheese_playurl_model_matches_local_probe_outputs_when_available() -> BpiResult<()> {
304        for profile in ["anonymous", "normal", "vip"] {
305            let Some(body) = local_probe_body(profile) else {
306                continue;
307            };
308            let payload = serde_json::from_value::<ApiEnvelope<CourseVideoStreamData>>(body)?
309                .into_payload()?;
310
311            assert_eq!(payload.base.quality, VideoQuality::P480.as_u32());
312            assert!(!payload.base.has_paid);
313            assert!(payload.base.supports_dash());
314        }
315        Ok(())
316    }
317}