Skip to main content

bpi_rs/video/
client.rs

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/// Video domain API client.
34#[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    /// Fetches web video detail by AV ID or BV ID.
65    pub async fn view(&self, params: VideoViewParams) -> BpiResult<VideoView> {
66        self.client
67            .get(VIEW_ENDPOINT)
68            .query(&params.query_pairs())
69            .send_bpi_payload("video.view")
70            .await
71    }
72
73    /// Fetches web video detail, including tags and related videos.
74    pub async fn detail(&self, params: VideoDetailParams) -> BpiResult<VideoDetail> {
75        self.client
76            .get(DETAIL_ENDPOINT)
77            .query(&params.query_pairs())
78            .send_bpi_payload("video.detail")
79            .await
80    }
81
82    /// Fetches the page/content IDs for a video.
83    pub async fn page_list(&self, params: VideoPageListParams) -> BpiResult<Vec<VideoPage>> {
84        self.client
85            .get(PAGELIST_ENDPOINT)
86            .query(&params.query_pairs())
87            .send_bpi_payload("video.pagelist")
88            .await
89    }
90
91    /// Fetches the plain text video description.
92    pub async fn desc(&self, params: VideoDescParams) -> BpiResult<String> {
93        self.client
94            .get(DESC_ENDPOINT)
95            .query(&params.query_pairs())
96            .send_bpi_payload("video.desc")
97            .await
98    }
99
100    /// Fetches signed web playback URLs by AV ID or BV ID plus page/content ID.
101    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(&params)
108            .send_bpi_payload("video.play_url")
109            .await
110    }
111
112    /// Fetches the videos in a specific video season.
113    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(&params)
123            .send_bpi_payload("video.collection.seasons_archives_list")
124            .await
125    }
126
127    /// Fetches a user's home season and series lists.
128    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(&params)
137            .send_bpi_payload("video.collection.home_seasons_series")
138            .await
139    }
140
141    /// Fetches a user's season and series list with pagination.
142    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(&params)
151            .send_bpi_payload("video.collection.seasons_series_list")
152            .await
153    }
154
155    /// Fetches metadata for a specific video series.
156    pub async fn series_info(
157        &self,
158        params: VideoCollectionSeriesInfoParams,
159    ) -> BpiResult<GetSeriesData> {
160        self.client
161            .get(SERIES_INFO_ENDPOINT)
162            .query(&params.query_pairs())
163            .send_bpi_payload("video.collection.series_info")
164            .await
165    }
166
167    /// Fetches videos in a specific video series.
168    pub async fn series_archives(
169        &self,
170        params: VideoCollectionSeriesArchivesParams,
171    ) -> BpiResult<GetSeriesArchivesData> {
172        self.client
173            .get(SERIES_ARCHIVES_ENDPOINT)
174            .query(&params.query_pairs())
175            .send_bpi_payload("video.collection.series_archives")
176            .await
177    }
178
179    /// Fetches the online viewer counters for a video page.
180    pub async fn online_total(
181        &self,
182        params: VideoOnlineTotalParams,
183    ) -> BpiResult<OnlineTotalResponseData> {
184        self.client
185            .get(ONLINE_TOTAL_ENDPOINT)
186            .query(&params.query_pairs())
187            .send_bpi_payload("video.online_total")
188            .await
189    }
190
191    /// Fetches web player metadata for a video page.
192    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(&params)
201            .send_bpi_payload("video.player_info_v2")
202            .await
203    }
204
205    /// Fetches videos related to a video.
206    pub async fn related_videos(&self, params: VideoRelatedParams) -> BpiResult<Vec<RelatedVideo>> {
207        self.client
208            .get(RELATED_VIDEOS_ENDPOINT)
209            .query(&params.query_pairs())
210            .send_bpi_payload("video.related_videos")
211            .await
212    }
213
214    /// Fetches homepage video recommendations.
215    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(&params)
224            .send_bpi_payload("video.homepage_recommendations")
225            .await
226    }
227
228    /// Fetches the AI summary for a video.
229    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(&params)
238            .send_bpi_payload("video.ai_summary")
239            .await
240    }
241
242    /// Fetches tags attached to a video.
243    pub async fn tags(&self, params: VideoTagsParams) -> BpiResult<Vec<VideoTag>> {
244        self.client
245            .get(TAGS_ENDPOINT)
246            .query(&params.query_pairs())
247            .send_bpi_payload("video.tags")
248            .await
249    }
250
251    /// Fetches metadata for an interactive video node.
252    pub async fn interactive_video_info(
253        &self,
254        params: InteractiveVideoInfoParams,
255    ) -> BpiResult<InteractiveVideoInfoResponseData> {
256        self.client
257            .get(INTERACTIVE_INFO_ENDPOINT)
258            .query(&params.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}