1use crate::request::BilibiliRequest;
2use crate::{BpiClient, BpiResult};
3
4use super::collection::{
5 GetSeasonsArchivesData, GetSeasonsSeriesData, GetSeriesArchivesData, GetSeriesData,
6 HOME_SEASONS_SERIES_ENDPOINT, SEASONS_ARCHIVES_LIST_ENDPOINT, SEASONS_SERIES_LIST_ENDPOINT,
7 SERIES_ARCHIVES_ENDPOINT, SERIES_INFO_ENDPOINT, VideoCollectionHomeSeasonsSeriesParams,
8 VideoCollectionSeasonsArchivesParams, VideoCollectionSeasonsSeriesParams,
9 VideoCollectionSeriesArchivesParams, VideoCollectionSeriesInfoParams,
10};
11use super::interact_video::{INTERACTIVE_INFO_ENDPOINT, InteractiveVideoInfoResponseData};
12use super::model::{VideoDetail, VideoPage, VideoView};
13use super::online::{ONLINE_TOTAL_ENDPOINT, OnlineTotalResponseData};
14use super::params::{
15 InteractiveVideoInfoParams, VideoAiSummaryParams, VideoDescParams, VideoDetailParams,
16 VideoHomepageRecommendationsParams, VideoOnlineTotalParams, VideoPageListParams,
17 VideoPlayUrlParams, VideoPlayerInfoParams, VideoRelatedParams, VideoTagsParams,
18 VideoViewParams,
19};
20use super::player::{PLAYER_INFO_V2_ENDPOINT, PlayerInfoResponseData};
21use super::recommend::{
22 HOMEPAGE_RECOMMENDATIONS_ENDPOINT, RELATED_VIDEOS_ENDPOINT, RcmdFeedResponseData, RelatedVideo,
23};
24use super::summary::{AI_SUMMARY_ENDPOINT, AiSummaryResponseData};
25use super::tags::{TAGS_ENDPOINT, VideoTag};
26use super::videostream_url::{PLAY_URL_ENDPOINT, PlayUrlResponseData};
27
28const DESC_ENDPOINT: &str = "https://api.bilibili.com/x/web-interface/archive/desc";
29const DETAIL_ENDPOINT: &str = "https://api.bilibili.com/x/web-interface/view/detail";
30const PAGELIST_ENDPOINT: &str = "https://api.bilibili.com/x/player/pagelist";
31const VIEW_ENDPOINT: &str = "https://api.bilibili.com/x/web-interface/view";
32
33#[derive(Clone, Copy)]
35pub struct VideoClient<'a> {
36 pub(crate) client: &'a BpiClient,
37}
38
39impl<'a> VideoClient<'a> {
40 pub(crate) fn new(client: &'a BpiClient) -> Self {
41 Self { client }
42 }
43
44 #[cfg(test)]
45 pub(crate) fn endpoint(&self) -> &'static str {
46 VIEW_ENDPOINT
47 }
48
49 #[cfg(test)]
50 pub(crate) fn detail_endpoint(&self) -> &'static str {
51 DETAIL_ENDPOINT
52 }
53
54 #[cfg(test)]
55 pub(crate) fn page_list_endpoint(&self) -> &'static str {
56 PAGELIST_ENDPOINT
57 }
58
59 #[cfg(test)]
60 pub(crate) fn desc_endpoint(&self) -> &'static str {
61 DESC_ENDPOINT
62 }
63
64 pub async fn view(&self, params: VideoViewParams) -> BpiResult<VideoView> {
66 self.client
67 .get(VIEW_ENDPOINT)
68 .query(¶ms.query_pairs())
69 .send_bpi_payload("video.view")
70 .await
71 }
72
73 pub async fn detail(&self, params: VideoDetailParams) -> BpiResult<VideoDetail> {
75 self.client
76 .get(DETAIL_ENDPOINT)
77 .query(¶ms.query_pairs())
78 .send_bpi_payload("video.detail")
79 .await
80 }
81
82 pub async fn page_list(&self, params: VideoPageListParams) -> BpiResult<Vec<VideoPage>> {
84 self.client
85 .get(PAGELIST_ENDPOINT)
86 .query(¶ms.query_pairs())
87 .send_bpi_payload("video.pagelist")
88 .await
89 }
90
91 pub async fn desc(&self, params: VideoDescParams) -> BpiResult<String> {
93 self.client
94 .get(DESC_ENDPOINT)
95 .query(¶ms.query_pairs())
96 .send_bpi_payload("video.desc")
97 .await
98 }
99
100 pub async fn play_url(&self, params: VideoPlayUrlParams) -> BpiResult<PlayUrlResponseData> {
102 let params = self.client.get_wbi_sign2(params.query_pairs()).await?;
103
104 self.client
105 .get(PLAY_URL_ENDPOINT)
106 .with_bilibili_headers()
107 .query(¶ms)
108 .send_bpi_payload("video.play_url")
109 .await
110 }
111
112 pub async fn seasons_archives_list(
114 &self,
115 params: VideoCollectionSeasonsArchivesParams,
116 ) -> BpiResult<GetSeasonsArchivesData> {
117 let params = self.client.get_wbi_sign2(params.query_pairs()).await?;
118
119 self.client
120 .get(SEASONS_ARCHIVES_LIST_ENDPOINT)
121 .with_bilibili_headers()
122 .query(¶ms)
123 .send_bpi_payload("video.collection.seasons_archives_list")
124 .await
125 }
126
127 pub async fn home_seasons_series(
129 &self,
130 params: VideoCollectionHomeSeasonsSeriesParams,
131 ) -> BpiResult<GetSeasonsSeriesData> {
132 let params = self.client.get_wbi_sign2(params.query_pairs()).await?;
133
134 self.client
135 .get(HOME_SEASONS_SERIES_ENDPOINT)
136 .query(¶ms)
137 .send_bpi_payload("video.collection.home_seasons_series")
138 .await
139 }
140
141 pub async fn seasons_series_list(
143 &self,
144 params: VideoCollectionSeasonsSeriesParams,
145 ) -> BpiResult<GetSeasonsSeriesData> {
146 let params = self.client.get_wbi_sign2(params.query_pairs()).await?;
147
148 self.client
149 .get(SEASONS_SERIES_LIST_ENDPOINT)
150 .query(¶ms)
151 .send_bpi_payload("video.collection.seasons_series_list")
152 .await
153 }
154
155 pub async fn series_info(
157 &self,
158 params: VideoCollectionSeriesInfoParams,
159 ) -> BpiResult<GetSeriesData> {
160 self.client
161 .get(SERIES_INFO_ENDPOINT)
162 .query(¶ms.query_pairs())
163 .send_bpi_payload("video.collection.series_info")
164 .await
165 }
166
167 pub async fn series_archives(
169 &self,
170 params: VideoCollectionSeriesArchivesParams,
171 ) -> BpiResult<GetSeriesArchivesData> {
172 self.client
173 .get(SERIES_ARCHIVES_ENDPOINT)
174 .query(¶ms.query_pairs())
175 .send_bpi_payload("video.collection.series_archives")
176 .await
177 }
178
179 pub async fn online_total(
181 &self,
182 params: VideoOnlineTotalParams,
183 ) -> BpiResult<OnlineTotalResponseData> {
184 self.client
185 .get(ONLINE_TOTAL_ENDPOINT)
186 .query(¶ms.query_pairs())
187 .send_bpi_payload("video.online_total")
188 .await
189 }
190
191 pub async fn player_info_v2(
193 &self,
194 params: VideoPlayerInfoParams,
195 ) -> BpiResult<PlayerInfoResponseData> {
196 let params = self.client.get_wbi_sign2(params.query_pairs()).await?;
197
198 self.client
199 .get(PLAYER_INFO_V2_ENDPOINT)
200 .query(¶ms)
201 .send_bpi_payload("video.player_info_v2")
202 .await
203 }
204
205 pub async fn related_videos(&self, params: VideoRelatedParams) -> BpiResult<Vec<RelatedVideo>> {
207 self.client
208 .get(RELATED_VIDEOS_ENDPOINT)
209 .query(¶ms.query_pairs())
210 .send_bpi_payload("video.related_videos")
211 .await
212 }
213
214 pub async fn homepage_recommendations(
216 &self,
217 params: VideoHomepageRecommendationsParams,
218 ) -> BpiResult<RcmdFeedResponseData> {
219 let params = self.client.get_wbi_sign2(params.query_pairs()).await?;
220
221 self.client
222 .get(HOMEPAGE_RECOMMENDATIONS_ENDPOINT)
223 .query(¶ms)
224 .send_bpi_payload("video.homepage_recommendations")
225 .await
226 }
227
228 pub async fn ai_summary(
230 &self,
231 params: VideoAiSummaryParams,
232 ) -> BpiResult<AiSummaryResponseData> {
233 let params = self.client.get_wbi_sign2(params.query_pairs()).await?;
234
235 self.client
236 .get(AI_SUMMARY_ENDPOINT)
237 .query(¶ms)
238 .send_bpi_payload("video.ai_summary")
239 .await
240 }
241
242 pub async fn tags(&self, params: VideoTagsParams) -> BpiResult<Vec<VideoTag>> {
244 self.client
245 .get(TAGS_ENDPOINT)
246 .query(¶ms.query_pairs())
247 .send_bpi_payload("video.tags")
248 .await
249 }
250
251 pub async fn interactive_video_info(
253 &self,
254 params: InteractiveVideoInfoParams,
255 ) -> BpiResult<InteractiveVideoInfoResponseData> {
256 self.client
257 .get(INTERACTIVE_INFO_ENDPOINT)
258 .query(¶ms.query_pairs())
259 .send_bpi_payload("video.interactive_video_info")
260 .await
261 }
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267 use crate::{
268 ApiEnvelope, BpiClient, BpiError, BpiResult,
269 ids::{Aid, Cid, Mid, SeasonId},
270 probe::{contract::HttpMethod, endpoint_contract::EndpointContract},
271 video::params::VideoHomepageRecommendationsParams,
272 video::{
273 InteractiveVideoInfoParams, VideoAiSummaryParams,
274 VideoCollectionHomeSeasonsSeriesParams, VideoCollectionSeasonsArchivesParams,
275 VideoCollectionSeasonsSeriesParams, VideoCollectionSeriesArchivesParams,
276 VideoCollectionSeriesInfoParams, VideoOnlineTotalParams, VideoPlayerInfoParams,
277 VideoRelatedParams, VideoTagsParams,
278 },
279 };
280 use serde::de::DeserializeOwned;
281
282 fn contract(endpoint: &str) -> BpiResult<EndpointContract> {
283 let bytes: &[u8] = match endpoint {
284 "view" => include_bytes!("../../tests/contracts/video/info-read/view/contract.json"),
285 "detail" => {
286 include_bytes!("../../tests/contracts/video/info-read/detail/contract.json")
287 }
288 "pagelist" => {
289 include_bytes!("../../tests/contracts/video/info-read/pagelist/contract.json")
290 }
291 "desc" => include_bytes!("../../tests/contracts/video/info-read/desc/contract.json"),
292 _ => {
293 return Err(BpiError::invalid_parameter(
294 "endpoint",
295 "unknown video contract",
296 ));
297 }
298 };
299
300 EndpointContract::from_slice(bytes)
301 }
302
303 fn local_probe_body(endpoint: &str, profile: &str) -> Option<serde_json::Value> {
304 let path =
305 format!("target/bpi-probe-runs/video/info-read/{endpoint}/{profile}.response.json");
306 let bytes = std::fs::read(path).ok()?;
307 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
308 value
309 .get("response")
310 .and_then(|response| response.get("body"))
311 .cloned()
312 }
313
314 fn parse_local_probe_outputs<T>(endpoint: &str, profiles: &[&str]) -> BpiResult<()>
315 where
316 T: DeserializeOwned,
317 {
318 for profile in profiles {
319 let Some(body) = local_probe_body(endpoint, profile) else {
320 continue;
321 };
322
323 let _payload = serde_json::from_value::<ApiEnvelope<T>>(body)?.into_payload()?;
324 }
325
326 Ok(())
327 }
328
329 #[test]
330 fn video_client_borrows_root_client() -> Result<(), crate::BpiError> {
331 let client = BpiClient::new()?;
332 let video = client.video();
333
334 assert_eq!(
335 video.endpoint(),
336 "https://api.bilibili.com/x/web-interface/view"
337 );
338 Ok(())
339 }
340
341 #[test]
342 fn video_client_exposes_info_read_endpoints() -> Result<(), crate::BpiError> {
343 let client = BpiClient::new()?;
344 let video = client.video();
345
346 assert_eq!(
347 video.detail_endpoint(),
348 "https://api.bilibili.com/x/web-interface/view/detail"
349 );
350 assert_eq!(
351 video.page_list_endpoint(),
352 "https://api.bilibili.com/x/player/pagelist"
353 );
354 assert_eq!(
355 video.desc_endpoint(),
356 "https://api.bilibili.com/x/web-interface/archive/desc"
357 );
358 Ok(())
359 }
360
361 #[test]
362 fn video_client_methods_use_payload_request_helpers() {
363 let source = include_str!("client.rs");
364 let payload_helper = concat!(".send_", "bpi_payload");
365 let legacy_envelope_helper = concat!(".send_", "bpi::<");
366 let legacy_flat_playurl = concat!(".video_", "playurl(");
367
368 assert!(
369 source.matches(payload_helper).count() >= 5,
370 "VideoClient read methods should return decoded payloads directly"
371 );
372 assert!(
373 !source.contains(legacy_envelope_helper),
374 "VideoClient should not use legacy envelope-returning request helpers"
375 );
376 assert!(
377 !source.contains(legacy_flat_playurl),
378 "VideoClient::play_url should be implemented as a payload-helper-backed domain method"
379 );
380 }
381
382 #[test]
383 fn video_client_exposes_collection_and_player_read_methods() -> BpiResult<()> {
384 let client = BpiClient::new()?;
385 let video = client.video();
386
387 std::mem::drop(
388 video.seasons_archives_list(VideoCollectionSeasonsArchivesParams::new(
389 Mid::new(4279370)?,
390 SeasonId::new(4294056)?,
391 )),
392 );
393 std::mem::drop(
394 video.home_seasons_series(VideoCollectionHomeSeasonsSeriesParams::new(Mid::new(
395 4279370,
396 )?)),
397 );
398 std::mem::drop(
399 video.seasons_series_list(VideoCollectionSeasonsSeriesParams::new(Mid::new(4279370)?)),
400 );
401 std::mem::drop(video.series_info(VideoCollectionSeriesInfoParams::new(250285)?));
402 std::mem::drop(
403 video.series_archives(VideoCollectionSeriesArchivesParams::new(
404 Mid::new(4279370)?,
405 250285,
406 )?),
407 );
408 std::mem::drop(video.online_total(VideoOnlineTotalParams::from_bvid(
409 "BV1xx411c7mD".parse()?,
410 Cid::new(62131)?,
411 )));
412 std::mem::drop(video.player_info_v2(VideoPlayerInfoParams::from_bvid(
413 "BV1xx411c7mD".parse()?,
414 Cid::new(62131)?,
415 )));
416 std::mem::drop(
417 video.related_videos(VideoRelatedParams::from_bvid("BV1xx411c7mD".parse()?)),
418 );
419 std::mem::drop(video.homepage_recommendations(VideoHomepageRecommendationsParams::new()));
420 std::mem::drop(video.ai_summary(VideoAiSummaryParams::from_bvid(
421 "BV1xx411c7mD".parse()?,
422 Cid::new(62131)?,
423 928123,
424 )?));
425 std::mem::drop(
426 video.tags(VideoTagsParams::from_bvid("BV1xx411c7mD".parse()?).cid(Cid::new(62131)?)),
427 );
428 std::mem::drop(
429 video.interactive_video_info(InteractiveVideoInfoParams::from_aid(
430 Aid::new(114347430905959)?,
431 1273647,
432 )?),
433 );
434
435 let source = include_str!("client.rs");
436 let payload_helper = concat!(".send_", "bpi_payload");
437
438 assert!(
439 source.matches(payload_helper).count() >= 17,
440 "VideoClient should use payload helpers for info, playurl, collection, and player read methods"
441 );
442 Ok(())
443 }
444
445 #[test]
446 fn video_info_read_contracts_match_endpoint_requests() -> BpiResult<()> {
447 let expectations = [
448 (
449 "view",
450 "video.view",
451 VIEW_ENDPOINT,
452 &[("bvid", "BV1xx411c7mD")][..],
453 "VideoView",
454 ),
455 (
456 "detail",
457 "video.detail",
458 DETAIL_ENDPOINT,
459 &[("bvid", "BV1xx411c7mD"), ("need_elec", "0")][..],
460 "VideoDetail",
461 ),
462 (
463 "pagelist",
464 "video.pagelist",
465 PAGELIST_ENDPOINT,
466 &[("bvid", "BV1xx411c7mD")][..],
467 "Vec<VideoPage>",
468 ),
469 (
470 "desc",
471 "video.desc",
472 DESC_ENDPOINT,
473 &[("bvid", "BV1xx411c7mD")][..],
474 "String",
475 ),
476 ];
477
478 for (endpoint, name, url, query_pairs, rust_model) in expectations {
479 let contract = contract(endpoint)?;
480
481 assert_eq!(contract.name, name);
482 assert_eq!(contract.request.method, HttpMethod::Get);
483 assert_eq!(contract.request.url.as_str(), url);
484 assert_eq!(contract.cases.len(), 3);
485 assert!(
486 contract
487 .cases
488 .iter()
489 .all(|case| case.response.api_code == Some(0)),
490 "{endpoint} should have successful anonymous, normal, and vip cases"
491 );
492 assert!(
493 contract
494 .cases
495 .iter()
496 .any(|case| case.response.rust_model.as_deref() == Some(rust_model)),
497 "{endpoint} should declare {rust_model}"
498 );
499
500 for &(key, value) in query_pairs {
501 assert_eq!(
502 contract.request.query.get(key).map(String::as_str),
503 Some(value)
504 );
505 }
506 }
507
508 Ok(())
509 }
510
511 #[test]
512 fn video_info_read_response_fixtures_parse_declared_models() -> BpiResult<()> {
513 let view = ApiEnvelope::<VideoView>::from_slice(include_bytes!(
514 "../../tests/contracts/video/info-read/view/responses/success.json"
515 ))?
516 .into_payload()?;
517 let detail = ApiEnvelope::<VideoDetail>::from_slice(include_bytes!(
518 "../../tests/contracts/video/info-read/detail/responses/success.json"
519 ))?
520 .into_payload()?;
521 let pagelist = ApiEnvelope::<Vec<VideoPage>>::from_slice(include_bytes!(
522 "../../tests/contracts/video/info-read/pagelist/responses/success.json"
523 ))?
524 .into_payload()?;
525 let desc = ApiEnvelope::<String>::from_slice(include_bytes!(
526 "../../tests/contracts/video/info-read/desc/responses/success.json"
527 ))?
528 .into_payload()?;
529
530 assert_eq!(view.bvid.as_str(), "BV1xx411c7mD");
531 assert_eq!(detail.view.bvid.as_str(), "BV1xx411c7mD");
532 assert_eq!(pagelist.len(), 1);
533 assert_eq!(desc, "www");
534 Ok(())
535 }
536
537 #[test]
538 fn video_info_read_models_match_local_probe_outputs_when_available() -> BpiResult<()> {
539 parse_local_probe_outputs::<VideoView>("view", &["anonymous", "normal", "vip"])?;
540 parse_local_probe_outputs::<VideoDetail>("detail", &["anonymous", "normal", "vip"])?;
541 parse_local_probe_outputs::<Vec<VideoPage>>("pagelist", &["anonymous", "normal", "vip"])?;
542 parse_local_probe_outputs::<String>("desc", &["anonymous", "normal", "vip"])?;
543
544 Ok(())
545 }
546}