1use std::collections::HashMap;
6
7use crate::models::{DashStreams, SupportFormat};
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct CourseVideoStreamData {
13 #[serde(flatten)]
14 pub base: crate::models::VideoStreamData,
15
16 pub seek_param: String,
18 pub video_project: bool,
20 #[serde(rename = "type")]
22 pub data_type: String,
23 pub result: String,
25 pub seek_type: String,
27 pub from: String,
29 pub no_rexcode: i32,
31 pub message: String,
33 pub fragment_videos: Option<Vec<FragmentVideo>>,
35 pub status: i32,
37}
38
39#[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#[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#[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#[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}