Skip to main content

bpi_rs/live/
client.rs

1use crate::live::danmaku::LiveDanmuInfoData;
2use crate::live::emoticons::EmoticonData;
3use crate::live::follow_up_live::{FollowUpLiveData, LiveWebListData};
4use crate::live::gift::{BlindGiftData, RoomGiftData};
5use crate::live::guard::GuardListData;
6use crate::live::info::RoomInfoData;
7use crate::live::live_area::LiveParentArea;
8use crate::live::live_bill::GiftTypeItem;
9use crate::live::live_replay::ReplayListData;
10use crate::live::live_stream::LiveStreamData;
11use crate::live::manage::PcLiveVersionData;
12use crate::live::recommend::RecommendData;
13use crate::live::redpocket::LotteryInfoData;
14use crate::live::report::{HeartBeatData, LiveWebHeartBeatParams};
15use crate::live::silent_user_manage::{
16    BannedUserListData, LiveBannedUserListParams, LiveShieldKeywordListParams,
17    LiveSilentUserListParams, ShieldKeywordListData, SilentUserListData,
18};
19use crate::live::user::MyMedalsData;
20use crate::{BilibiliRequest, BpiClient, BpiResult};
21
22const AREA_LIST_ENDPOINT: &str = "https://api.live.bilibili.com/room/v1/Area/getList";
23const ROOM_INFO_ENDPOINT: &str = "https://api.live.bilibili.com/room/v1/Room/get_info";
24const STREAM_ENDPOINT: &str = "https://api.live.bilibili.com/room/v1/Room/playUrl";
25const RECOMMEND_ENDPOINT: &str =
26    "https://api.live.bilibili.com/xlive/web-interface/v1/webMain/getMoreRecList";
27const VERSION_ENDPOINT: &str =
28    "https://api.live.bilibili.com/xlive/app-blink/v1/liveVersionInfo/getHomePageLiveVersion";
29const GIFT_TYPES_ENDPOINT: &str = "https://api.live.bilibili.com/gift/v1/master/getGiftTypes";
30const ROOM_GIFT_LIST_ENDPOINT: &str =
31    "https://api.live.bilibili.com/xlive/web-room/v1/giftPanel/roomGiftList";
32const BLIND_GIFT_INFO_ENDPOINT: &str =
33    "https://api.live.bilibili.com/xlive/general-interface/v1/blindFirstWin/getInfo";
34const DANMU_INFO_ENDPOINT: &str =
35    "https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo";
36const EMOTICONS_ENDPOINT: &str =
37    "https://api.live.bilibili.com/xlive/web-ucenter/v2/emoticon/GetEmoticons";
38const LOTTERY_INFO_ENDPOINT: &str =
39    "https://api.live.bilibili.com/xlive/lottery-interface/v1/lottery/getLotteryInfoWeb";
40const MY_MEDALS_ENDPOINT: &str =
41    "https://api.live.bilibili.com/xlive/app-ucenter/v1/user/GetMyMedals";
42const FOLLOW_UP_LIST_ENDPOINT: &str =
43    "https://api.live.bilibili.com/xlive/web-ucenter/user/following";
44const FOLLOW_UP_WEB_LIST_ENDPOINT: &str =
45    "https://api.live.bilibili.com/xlive/web-ucenter/v1/xfetter/GetWebList";
46const REPLAY_LIST_ENDPOINT: &str =
47    "https://api.live.bilibili.com/xlive/app-blink/v1/anchorVideo/AnchorGetReplayList";
48const GUARD_LIST_ENDPOINT: &str =
49    "https://api.live.bilibili.com/xlive/app-room/v2/guardTab/topListNew";
50const SILENT_USERS_ENDPOINT: &str =
51    "https://api.live.bilibili.com/xlive/web-ucenter/v1/banned/GetSilentUserList";
52const BANNED_USERS_ENDPOINT: &str =
53    "https://api.live.bilibili.com/xlive/app-ucenter/v2/xbanned/banned/GetBlackList";
54const SHIELD_KEYWORDS_ENDPOINT: &str =
55    "https://api.live.bilibili.com/xlive/app-ucenter/v1/banned/GetShieldKeywordList";
56const WEB_HEART_BEAT_ENDPOINT: &str =
57    "https://live-trace.bilibili.com/xlive/rdata-interface/v1/heartbeat/webHeartBeat";
58
59/// Live API client.
60#[derive(Clone, Copy)]
61pub struct LiveClient<'a> {
62    pub(crate) client: &'a BpiClient,
63}
64
65impl<'a> LiveClient<'a> {
66    pub(crate) fn new(client: &'a BpiClient) -> Self {
67        Self { client }
68    }
69
70    #[cfg(test)]
71    pub(crate) fn area_list_endpoint(&self) -> &'static str {
72        AREA_LIST_ENDPOINT
73    }
74
75    #[cfg(test)]
76    pub(crate) fn room_info_endpoint(&self) -> &'static str {
77        ROOM_INFO_ENDPOINT
78    }
79
80    #[cfg(test)]
81    pub(crate) fn stream_endpoint(&self) -> &'static str {
82        STREAM_ENDPOINT
83    }
84
85    #[cfg(test)]
86    pub(crate) fn recommend_endpoint(&self) -> &'static str {
87        RECOMMEND_ENDPOINT
88    }
89
90    #[cfg(test)]
91    pub(crate) fn version_endpoint(&self) -> &'static str {
92        VERSION_ENDPOINT
93    }
94
95    /// Fetches all live area categories.
96    pub async fn area_list(&self) -> BpiResult<Vec<LiveParentArea>> {
97        self.client
98            .get(AREA_LIST_ENDPOINT)
99            .with_bilibili_headers()
100            .send_bpi_payload("live.area_list")
101            .await
102    }
103
104    /// Fetches public room information by real room ID.
105    pub async fn room_info(&self, room_id: i64) -> BpiResult<RoomInfoData> {
106        self.client
107            .get(ROOM_INFO_ENDPOINT)
108            .with_bilibili_headers()
109            .query(&[("room_id", room_id.to_string())])
110            .send_bpi_payload("live.room_info")
111            .await
112    }
113
114    /// Fetches live stream URLs by real room ID.
115    pub async fn stream(
116        &self,
117        cid: i64,
118        platform: Option<&str>,
119        quality: Option<i32>,
120        qn: Option<i32>,
121    ) -> BpiResult<LiveStreamData> {
122        let mut query = vec![("cid", cid.to_string())];
123
124        if let Some(platform) = platform {
125            query.push(("platform", platform.to_string()));
126        }
127        if let Some(quality) = quality {
128            query.push(("quality", quality.to_string()));
129        }
130        if let Some(qn) = qn {
131            query.push(("qn", qn.to_string()));
132        }
133
134        self.client
135            .get(STREAM_ENDPOINT)
136            .with_bilibili_headers()
137            .query(&query)
138            .send_bpi_payload("live.stream")
139            .await
140    }
141
142    /// Fetches the web homepage live recommendation list.
143    pub async fn recommend(&self) -> BpiResult<RecommendData> {
144        self.client
145            .get(RECOMMEND_ENDPOINT)
146            .with_bilibili_headers()
147            .query(&[("platform", "web"), ("web_location", "333.1007")])
148            .send_bpi_payload("live.recommend")
149            .await
150    }
151
152    /// Fetches the current PC live client version metadata.
153    pub async fn version(&self) -> BpiResult<PcLiveVersionData> {
154        self.client
155            .get(VERSION_ENDPOINT)
156            .with_bilibili_headers()
157            .query(&[("system_version", "2")])
158            .send_bpi_payload("live.version")
159            .await
160    }
161
162    /// Fetches the authenticated live gift type list.
163    pub async fn gift_types(&self) -> BpiResult<Vec<GiftTypeItem>> {
164        self.client
165            .get(GIFT_TYPES_ENDPOINT)
166            .with_bilibili_headers()
167            .send_bpi_payload("live.gift_types")
168            .await
169    }
170
171    /// Fetches the gift panel for a live room.
172    pub async fn room_gift_list(
173        &self,
174        room_id: i64,
175        area_parent_id: Option<i32>,
176        area_id: Option<i32>,
177    ) -> BpiResult<RoomGiftData> {
178        let mut query = vec![
179            ("room_id", room_id.to_string()),
180            ("platform", "web".to_string()),
181        ];
182
183        if let Some(area_parent_id) = area_parent_id {
184            query.push(("area_parent_id", area_parent_id.to_string()));
185        }
186
187        if let Some(area_id) = area_id {
188            query.push(("area_id", area_id.to_string()));
189        }
190
191        self.client
192            .get(ROOM_GIFT_LIST_ENDPOINT)
193            .with_bilibili_headers()
194            .query(&query)
195            .send_bpi_payload("live.room_gift_list")
196            .await
197    }
198
199    /// Fetches blind gift probability details.
200    pub async fn blind_gift_info(&self, gift_id: i64) -> BpiResult<BlindGiftData> {
201        self.client
202            .get(BLIND_GIFT_INFO_ENDPOINT)
203            .with_bilibili_headers()
204            .query(&[("gift_id", gift_id.to_string())])
205            .send_bpi_payload("live.blind_gift_info")
206            .await
207    }
208
209    /// Fetches live WebSocket danmu token and host information.
210    pub async fn danmu_info(&self, room_id: u64, info_type: u8) -> BpiResult<LiveDanmuInfoData> {
211        let query = self
212            .client
213            .get_wbi_sign2(vec![
214                ("id", room_id.to_string()),
215                ("type", info_type.to_string()),
216            ])
217            .await?;
218
219        self.client
220            .get(DANMU_INFO_ENDPOINT)
221            .with_bilibili_headers()
222            .query(&query)
223            .send_bpi_payload("live.danmu_info")
224            .await
225    }
226
227    /// Fetches live room emoticon packages.
228    pub async fn emoticons(&self, room_id: i64, platform: &str) -> BpiResult<EmoticonData> {
229        self.client
230            .get(EMOTICONS_ENDPOINT)
231            .with_bilibili_headers()
232            .query(&[
233                ("room_id", room_id.to_string()),
234                ("platform", platform.to_string()),
235            ])
236            .send_bpi_payload("live.emoticons")
237            .await
238    }
239
240    /// Fetches live room lottery information.
241    pub async fn lottery_info(&self, room_id: i64) -> BpiResult<LotteryInfoData> {
242        let query = self
243            .client
244            .get_wbi_sign2(vec![("roomid", room_id.to_string())])
245            .await?;
246
247        self.client
248            .get(LOTTERY_INFO_ENDPOINT)
249            .with_bilibili_headers()
250            .query(&query)
251            .send_bpi_payload("live.lottery_info")
252            .await
253    }
254
255    /// Fetches the current account's live fan medals.
256    pub async fn my_medals(&self, page: i32, page_size: i32) -> BpiResult<MyMedalsData> {
257        self.client
258            .get(MY_MEDALS_ENDPOINT)
259            .with_bilibili_headers()
260            .query(&[
261                ("page", page.to_string()),
262                ("page_size", page_size.to_string()),
263            ])
264            .send_bpi_payload("live.my_medals")
265            .await
266    }
267
268    /// Fetches followed streamers and their live status.
269    pub async fn follow_up_list(
270        &self,
271        page: Option<i32>,
272        page_size: Option<i32>,
273        ignore_record: Option<i32>,
274        hit_ab: Option<bool>,
275    ) -> BpiResult<FollowUpLiveData> {
276        let mut query = Vec::new();
277
278        if let Some(page) = page {
279            query.push(("page", page.to_string()));
280        }
281        if let Some(page_size) = page_size {
282            query.push(("page_size", page_size.to_string()));
283        }
284        if let Some(ignore_record) = ignore_record {
285            query.push(("ignoreRecord", ignore_record.to_string()));
286        }
287        if let Some(hit_ab) = hit_ab {
288            query.push(("hit_ab", hit_ab.to_string()));
289        }
290
291        self.client
292            .get(FOLLOW_UP_LIST_ENDPOINT)
293            .with_bilibili_headers()
294            .query(&query)
295            .send_bpi_payload("live.follow_up_list")
296            .await
297    }
298
299    /// Fetches followed streamers that are currently live.
300    pub async fn follow_up_web_list(&self, hit_ab: Option<bool>) -> BpiResult<LiveWebListData> {
301        let mut query = Vec::new();
302
303        if let Some(hit_ab) = hit_ab {
304            query.push(("hit_ab", hit_ab.to_string()));
305        }
306
307        self.client
308            .get(FOLLOW_UP_WEB_LIST_ENDPOINT)
309            .with_bilibili_headers()
310            .query(&query)
311            .send_bpi_payload("live.follow_up_web_list")
312            .await
313    }
314
315    /// Fetches the current account's live replay list.
316    pub async fn replay_list(
317        &self,
318        page: Option<i32>,
319        page_size: Option<i32>,
320    ) -> BpiResult<ReplayListData> {
321        let mut query = Vec::new();
322
323        if let Some(page) = page {
324            query.push(("page", page.to_string()));
325        }
326        if let Some(page_size) = page_size {
327            query.push(("page_size", page_size.to_string()));
328        }
329
330        self.client
331            .get(REPLAY_LIST_ENDPOINT)
332            .with_bilibili_headers()
333            .query(&query)
334            .send_bpi_payload("live.replay_list")
335            .await
336    }
337
338    /// Fetches live guard members for a room.
339    pub async fn guard_list(
340        &self,
341        room_id: i64,
342        ruid: i64,
343        page: Option<i32>,
344        page_size: Option<i32>,
345        typ: Option<i32>,
346    ) -> BpiResult<GuardListData> {
347        let query = [
348            ("roomid", room_id.to_string()),
349            ("ruid", ruid.to_string()),
350            ("page", page.unwrap_or(1).to_string()),
351            ("page_size", page_size.unwrap_or(20).to_string()),
352            ("typ", typ.unwrap_or(5).to_string()),
353        ];
354
355        self.client
356            .get(GUARD_LIST_ENDPOINT)
357            .with_bilibili_headers()
358            .query(&query)
359            .send_bpi_payload("live.guard_list")
360            .await
361    }
362
363    /// Fetches silent users for a live room.
364    pub async fn silent_users(
365        &self,
366        params: LiveSilentUserListParams,
367    ) -> BpiResult<SilentUserListData> {
368        let csrf = self.client.csrf().unwrap_or_default();
369        let form = params.form_pairs(&csrf);
370
371        self.client
372            .post(SILENT_USERS_ENDPOINT)
373            .with_bilibili_headers()
374            .form(&form)
375            .send_bpi_payload("live.silent_users")
376            .await
377    }
378
379    /// Fetches banned users for a live anchor.
380    pub async fn banned_users(
381        &self,
382        params: LiveBannedUserListParams,
383    ) -> BpiResult<BannedUserListData> {
384        let csrf = self.client.csrf().unwrap_or_default();
385        let query = params.query_pairs(&csrf);
386
387        self.client
388            .get(BANNED_USERS_ENDPOINT)
389            .with_bilibili_headers()
390            .query(&query)
391            .send_bpi_payload("live.banned_users")
392            .await
393    }
394
395    /// Fetches shield keywords for a live room.
396    pub async fn shield_keywords(
397        &self,
398        params: LiveShieldKeywordListParams,
399    ) -> BpiResult<ShieldKeywordListData> {
400        let csrf = self.client.csrf().unwrap_or_default();
401        let form = params.form_pairs(&csrf);
402
403        self.client
404            .post(SHIELD_KEYWORDS_ENDPOINT)
405            .with_bilibili_headers()
406            .form(&form)
407            .send_bpi_payload("live.shield_keywords")
408            .await
409    }
410
411    /// Sends a web heartbeat for live telemetry.
412    pub async fn web_heart_beat(&self, params: LiveWebHeartBeatParams) -> BpiResult<HeartBeatData> {
413        self.client
414            .get(WEB_HEART_BEAT_ENDPOINT)
415            .with_bilibili_headers()
416            .query(&params.query_pairs())
417            .send_bpi_payload("live.web_heart_beat")
418            .await
419    }
420}
421
422#[cfg(test)]
423mod tests {
424    use std::future::Future;
425
426    use crate::ids::{Mid, RoomId};
427    use crate::live::danmaku::LiveDanmuInfoData;
428    use crate::live::emoticons::EmoticonData;
429    use crate::live::follow_up_live::{FollowUpLiveData, LiveWebListData};
430    use crate::live::gift::{BlindGiftData, RoomGiftData};
431    use crate::live::guard::GuardListData;
432    use crate::live::info::RoomInfoData;
433    use crate::live::live_area::LiveParentArea;
434    use crate::live::live_bill::GiftTypeItem;
435    use crate::live::live_replay::ReplayListData;
436    use crate::live::live_stream::LiveStreamData;
437    use crate::live::manage::PcLiveVersionData;
438    use crate::live::recommend::RecommendData;
439    use crate::live::redpocket::LotteryInfoData;
440    use crate::live::report::{HeartBeatData, LiveWebHeartBeatParams};
441    use crate::live::silent_user_manage::{
442        BannedUserListData, LiveBannedUserListParams, LiveShieldKeywordListParams,
443        LiveSilentUserListParams, ShieldKeywordListData, SilentUserListData,
444    };
445    use crate::live::user::MyMedalsData;
446    use crate::probe::contract::HttpMethod;
447    use crate::probe::endpoint_contract::EndpointContract;
448    use crate::{BpiClient, BpiError, BpiResult};
449
450    fn assert_area_list_future<F>(_future: F)
451    where
452        F: Future<Output = BpiResult<Vec<LiveParentArea>>>,
453    {
454    }
455
456    fn assert_room_info_future<F>(_future: F)
457    where
458        F: Future<Output = BpiResult<RoomInfoData>>,
459    {
460    }
461
462    fn assert_stream_future<F>(_future: F)
463    where
464        F: Future<Output = BpiResult<LiveStreamData>>,
465    {
466    }
467
468    fn assert_recommend_future<F>(_future: F)
469    where
470        F: Future<Output = BpiResult<RecommendData>>,
471    {
472    }
473
474    fn assert_version_future<F>(_future: F)
475    where
476        F: Future<Output = BpiResult<PcLiveVersionData>>,
477    {
478    }
479
480    fn assert_gift_types_future<F>(_future: F)
481    where
482        F: Future<Output = BpiResult<Vec<GiftTypeItem>>>,
483    {
484    }
485
486    fn assert_room_gift_list_future<F>(_future: F)
487    where
488        F: Future<Output = BpiResult<RoomGiftData>>,
489    {
490    }
491
492    fn assert_blind_gift_info_future<F>(_future: F)
493    where
494        F: Future<Output = BpiResult<BlindGiftData>>,
495    {
496    }
497
498    fn assert_danmu_info_future<F>(_future: F)
499    where
500        F: Future<Output = BpiResult<LiveDanmuInfoData>>,
501    {
502    }
503
504    fn assert_emoticons_future<F>(_future: F)
505    where
506        F: Future<Output = BpiResult<EmoticonData>>,
507    {
508    }
509
510    fn assert_lottery_info_future<F>(_future: F)
511    where
512        F: Future<Output = BpiResult<LotteryInfoData>>,
513    {
514    }
515
516    fn assert_my_medals_future<F>(_future: F)
517    where
518        F: Future<Output = BpiResult<MyMedalsData>>,
519    {
520    }
521
522    fn assert_follow_up_list_future<F>(_future: F)
523    where
524        F: Future<Output = BpiResult<FollowUpLiveData>>,
525    {
526    }
527
528    fn assert_follow_up_web_list_future<F>(_future: F)
529    where
530        F: Future<Output = BpiResult<LiveWebListData>>,
531    {
532    }
533
534    fn assert_replay_list_future<F>(_future: F)
535    where
536        F: Future<Output = BpiResult<ReplayListData>>,
537    {
538    }
539
540    fn assert_guard_list_future<F>(_future: F)
541    where
542        F: Future<Output = BpiResult<GuardListData>>,
543    {
544    }
545
546    fn assert_silent_users_future<F>(_future: F)
547    where
548        F: Future<Output = BpiResult<SilentUserListData>>,
549    {
550    }
551
552    fn assert_banned_users_future<F>(_future: F)
553    where
554        F: Future<Output = BpiResult<BannedUserListData>>,
555    {
556    }
557
558    fn assert_shield_keywords_future<F>(_future: F)
559    where
560        F: Future<Output = BpiResult<ShieldKeywordListData>>,
561    {
562    }
563
564    fn assert_web_heart_beat_future<F>(_future: F)
565    where
566        F: Future<Output = BpiResult<HeartBeatData>>,
567    {
568    }
569
570    fn contract(endpoint: &str) -> BpiResult<EndpointContract> {
571        let bytes: &[u8] = match endpoint {
572            "area-list" => {
573                include_bytes!("../../tests/contracts/live/public-core/area-list/contract.json")
574            }
575            "room-info" => {
576                include_bytes!("../../tests/contracts/live/public-core/room-info/contract.json")
577            }
578            "stream" => {
579                include_bytes!("../../tests/contracts/live/public-core/stream/contract.json")
580            }
581            "recommend" => {
582                include_bytes!("../../tests/contracts/live/public-core/recommend/contract.json")
583            }
584            "version" => {
585                include_bytes!("../../tests/contracts/live/public-core/version/contract.json")
586            }
587            _ => {
588                return Err(BpiError::invalid_parameter(
589                    "endpoint",
590                    "unknown live contract",
591                ));
592            }
593        };
594
595        EndpointContract::from_slice(bytes)
596    }
597
598    #[test]
599    fn live_public_core_methods_return_payload_futures() -> Result<(), BpiError> {
600        let client = BpiClient::new()?;
601        let live = client.live();
602
603        assert_area_list_future(live.area_list());
604        assert_room_info_future(live.room_info(23_174_842));
605        assert_stream_future(live.stream(14_073_662, Some("web"), None, Some(10_000)));
606        assert_recommend_future(live.recommend());
607        assert_version_future(live.version());
608        Ok(())
609    }
610
611    #[test]
612    fn live_client_exposes_remaining_read_methods() -> BpiResult<()> {
613        let client = BpiClient::new()?;
614        let live = client.live();
615
616        assert_gift_types_future(live.gift_types());
617        assert_room_gift_list_future(live.room_gift_list(23_174_842, None, None));
618        assert_blind_gift_info_future(live.blind_gift_info(32_251));
619        assert_danmu_info_future(live.danmu_info(21_733_448, 0));
620        assert_emoticons_future(live.emoticons(14_047, "pc"));
621        assert_lottery_info_future(live.lottery_info(23_174_842));
622        assert_my_medals_future(live.my_medals(1, 10));
623        assert_follow_up_list_future(live.follow_up_list(Some(1), Some(2), Some(1), Some(true)));
624        assert_follow_up_web_list_future(live.follow_up_web_list(Some(false)));
625        assert_replay_list_future(live.replay_list(Some(1), Some(2)));
626        assert_guard_list_future(live.guard_list(23_174_842, 504_140_200, None, None, None));
627        assert_silent_users_future(
628            live.silent_users(
629                LiveSilentUserListParams::new(RoomId::new(3_818_081)?).page_size(10)?,
630            ),
631        );
632        assert_banned_users_future(
633            live.banned_users(LiveBannedUserListParams::new(Mid::new(4_279_370)?).page_size(10)?),
634        );
635        assert_shield_keywords_future(
636            live.shield_keywords(LiveShieldKeywordListParams::new(RoomId::new(3_818_081)?)),
637        );
638        assert_web_heart_beat_future(live.web_heart_beat(LiveWebHeartBeatParams::new(23_174_842)?));
639
640        let source = include_str!("client.rs");
641        let payload_helper = concat!(".send_", "bpi_payload");
642        assert!(
643            source.matches(payload_helper).count() >= 20,
644            "LiveClient should use payload helpers for public-core and remaining promoted read methods"
645        );
646
647        Ok(())
648    }
649
650    #[test]
651    fn live_public_core_contracts_match_module_client_endpoints() -> BpiResult<()> {
652        let client = BpiClient::new()?;
653        let live = client.live();
654        let cases = [
655            ("area-list", "live.area_list", live.area_list_endpoint()),
656            ("room-info", "live.room_info", live.room_info_endpoint()),
657            ("stream", "live.stream", live.stream_endpoint()),
658            ("recommend", "live.recommend", live.recommend_endpoint()),
659            ("version", "live.version", live.version_endpoint()),
660        ];
661
662        for (endpoint, name, url) in cases {
663            let contract = contract(endpoint)?;
664
665            assert_eq!(contract.name, name);
666            assert_eq!(contract.request.method, HttpMethod::Get);
667            assert_eq!(contract.request.url.as_str(), url);
668        }
669
670        Ok(())
671    }
672}