1use serde::{Deserialize, Serialize};
5
6pub(crate) const PLAY_URL_ENDPOINT: &str = "https://api.bilibili.com/x/player/wbi/playurl";
7
8#[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#[derive(Debug, Clone, Deserialize, Serialize)]
23pub struct DashDolby {
24 pub r#type: u8,
25 pub audio: Option<Vec<DashStream>>,
26}
27
28#[derive(Debug, Clone, Deserialize, Serialize)]
30pub struct DashFlac {
31 pub audio: Vec<DashStream>,
32}
33
34#[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#[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#[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#[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#[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}