Skip to main content

bpi_rs/live/
manage.rs

1// --- 直播间管理 API 结构体 ---
2
3use crate::BilibiliRequest;
4use crate::BpiResult;
5use crate::live::LiveClient;
6use reqwest::multipart::Form;
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9
10const FETCH_WEB_UP_STREAM_ADDR_ENDPOINT: &str =
11    "https://api.live.bilibili.com/xlive/app-blink/v1/live/FetchWebUpStreamAddr";
12const WEB_LIVE_CENTER_START_ENDPOINT: &str =
13    "https://api.live.bilibili.com/xlive/app-blink/v1/streaming/WebLiveCenterStartLive";
14
15/// 开通直播间响应数据
16
17#[derive(Debug, Clone, Deserialize, Serialize)]
18pub struct CreateRoomData {
19    #[serde(rename = "roomID")]
20    pub room_id: Option<String>,
21}
22
23/// 直播间信息更新响应数据
24#[derive(Debug, Clone, Deserialize, Serialize)]
25pub struct UpdateRoomData {
26    pub sub_session_key: String,
27    pub audit_info: Option<AuditInfo>,
28}
29
30/// 审核信息
31#[derive(Debug, Clone, Deserialize, Serialize)]
32pub struct AuditInfo {
33    pub audit_title_reason: String,
34    pub audit_title_status: u8,
35    pub audit_title: Option<String>,
36    pub update_title: Option<String>,
37}
38
39/// RTMP 推流地址信息
40#[derive(Debug, Clone, Deserialize, Serialize)]
41pub struct RtmpInfo {
42    pub addr: String,
43    pub code: String,
44}
45
46/// 网页 link 中心推流地址(FetchWebUpStreamAddr)
47#[derive(Debug, Clone, Deserialize, Serialize)]
48pub struct WebUpStreamAddrData {
49    pub addr: RtmpInfo,
50    pub line: Value,
51    #[serde(default)]
52    pub srt_addr: Value,
53}
54
55/// 开始直播响应数据
56#[derive(Debug, Clone, Deserialize, Serialize)]
57pub struct StartLiveData {
58    pub change: u8,
59    pub status: String,
60    #[serde(default)]
61    pub rtmp: Option<RtmpInfo>,
62    pub live_key: String,
63    pub sub_session_key: String,
64    pub need_face_auth: bool,
65    // 其他不明确的字段都使用 Value
66    pub room_type: Value,
67    pub protocols: Value,
68    pub notice: Value,
69    pub qr: Value,
70    pub service_source: String,
71    pub rtmp_backup: Value,
72    pub up_stream_extra: Value,
73}
74
75/// 关闭直播响应数据
76#[derive(Debug, Clone, Deserialize, Serialize)]
77pub struct StopLiveData {
78    pub change: u8,
79    pub status: String,
80}
81
82/// 预更新直播间信息响应数据
83#[derive(Debug, Clone, Deserialize, Serialize)]
84pub struct UpdatePreLiveInfoData {
85    pub audit_info: Option<AuditInfo>,
86}
87
88/// PC直播姬版本号响应数据
89#[derive(Debug, Clone, Deserialize, Serialize)]
90pub struct PcLiveVersionData {
91    pub curr_version: String,
92    pub build: u64,
93    pub instruction: String,
94    pub file_size: String,
95    pub file_md5: String,
96    pub content: String,
97    pub download_url: String,
98    pub hdiffpatch_switch: u8,
99}
100
101impl<'a> LiveClient<'a> {
102    /// 开通直播间
103    pub async fn live_create_room(&self) -> BpiResult<CreateRoomData> {
104        let csrf = self.client.csrf()?;
105        let form = Form::new()
106            .text("platform", "web")
107            .text("visit_id", "")
108            .text("csrf", csrf.clone())
109            .text("csrf_token", csrf);
110
111        self.client
112            .post("https://api.live.bilibili.com/xlive/app-blink/v1/preLive/CreateRoom")
113            .multipart(form)
114            .send_bpi_payload("live.room.create")
115            .await
116    }
117
118    /// 更新直播间信息
119    ///
120    /// # 参数
121    /// * `room_id` - 直播间 ID
122    /// * `title` - 标题,可选
123    /// * `area_id` - 分区 ID,可选
124    /// * `add_tag` - 要添加的标签,可选
125    /// * `del_tag` - 要删除的标签,可选
126    pub async fn live_update_room_info(
127        &self,
128        room_id: u64,
129        title: Option<&str>,
130        area_id: Option<u64>,
131        add_tag: Option<&str>,
132        del_tag: Option<&str>,
133    ) -> BpiResult<UpdateRoomData> {
134        let csrf = self.client.csrf()?;
135        let mut form = Form::new()
136            .text("room_id", room_id.to_string())
137            .text("csrf", csrf.clone())
138            .text("csrf_token", csrf);
139
140        if let Some(t) = title {
141            form = form.text("title", t.to_string());
142        }
143        if let Some(a) = area_id {
144            form = form.text("area_id", a.to_string());
145        }
146        if let Some(a_tag) = add_tag {
147            form = form.text("add_tag", a_tag.to_string());
148        }
149        if let Some(d_tag) = del_tag {
150            form = form.text("del_tag", d_tag.to_string());
151        }
152
153        self.client
154            .post("https://api.live.bilibili.com/room/v1/Room/update")
155            .multipart(form)
156            .send_bpi_payload("live.room.update")
157            .await
158    }
159
160    /// 获取网页 link 中心推流地址
161    pub async fn live_fetch_web_up_stream_addr(&self) -> BpiResult<WebUpStreamAddrData> {
162        let csrf = self.client.csrf()?;
163        let form = web_up_stream_addr_form(&csrf);
164
165        self.client
166            .post(FETCH_WEB_UP_STREAM_ADDR_ENDPOINT)
167            .form(&form)
168            .send_bpi_payload("live.web_up_stream_addr")
169            .await
170    }
171
172    /// 网页 link 中心开始直播(第三方软件开播,WBI 签名)
173    pub async fn live_web_center_start(
174        &self,
175        room_id: u64,
176        area_v2: u64,
177    ) -> BpiResult<StartLiveData> {
178        let csrf = self.client.csrf()?;
179        let form = web_live_center_start_query(room_id, area_v2, &csrf);
180        let signed = self.client.get_wbi_sign2(form).await?;
181
182        // 浏览器 link 中心:WBI 参数在 query string,POST body 为空
183        self.client
184            .post(WEB_LIVE_CENTER_START_ENDPOINT)
185            .query(&signed)
186            .send_bpi_payload("live.web_center_start")
187            .await
188    }
189
190    /// 开始直播(直播姬旧接口)
191    #[allow(dead_code)]
192    async fn live_start_legacy(
193        &self,
194        room_id: u64,
195        area_v2: u64,
196        platform: &str,
197    ) -> BpiResult<StartLiveData> {
198        let csrf = self.client.csrf()?;
199        let form = Form::new()
200            .text("room_id", room_id.to_string())
201            .text("area_v2", area_v2.to_string())
202            .text("platform", platform.to_string())
203            .text("csrf", csrf.clone())
204            .text("csrf_token", csrf);
205
206        self.client
207            .post("https://api.live.bilibili.com/room/v1/Room/startLive")
208            .multipart(form)
209            .send_bpi_payload("live.start")
210            .await
211    }
212
213    /// 关闭直播
214    ///
215    /// # 参数
216    /// * `room_id` - 直播间 ID
217    /// * `platform` - 直播平台,如 "pc_link"
218    pub async fn live_stop(&self, room_id: u64, platform: &str) -> BpiResult<StopLiveData> {
219        let csrf = self.client.csrf()?;
220        let form = Form::new()
221            .text("platform", platform.to_string())
222            .text("room_id", room_id.to_string())
223            .text("csrf", csrf.clone())
224            .text("csrf_token", csrf);
225
226        self.client
227            .post("https://api.live.bilibili.com/room/v1/Room/stopLive")
228            .multipart(form)
229            .send_bpi_payload("live.stop")
230            .await
231    }
232
233    /// 预更新直播间信息
234    ///
235    /// # 参数
236    /// * `title` - 标题,可选
237    /// * `cover` - 封面 URL,可选
238    pub async fn live_update_pre_live_info(
239        &self,
240        title: Option<&str>,
241        cover: Option<&str>,
242    ) -> BpiResult<UpdatePreLiveInfoData> {
243        let csrf = self.client.csrf()?;
244        let mut form = Form::new()
245            .text("platform", "web")
246            .text("mobi_app", "web")
247            .text("build", "1")
248            .text("csrf", csrf.clone())
249            .text("csrf_token", csrf);
250
251        if let Some(t) = title {
252            form = form.text("title", t.to_string());
253        }
254        if let Some(c) = cover {
255            form = form.text("cover", c.to_string());
256        }
257
258        self.client
259            .post("https://api.live.bilibili.com/xlive/app-blink/v1/preLive/UpdatePreLiveInfo")
260            .multipart(form)
261            .send_bpi_payload("live.pre_live_info.update")
262            .await
263    }
264
265    /// 更新直播间公告
266    ///
267    /// # 参数
268    /// * `room_id` - 直播间 ID
269    /// * `uid` - 用户ID
270    /// * `content` - 公告内容
271    pub async fn live_update_room_news(
272        &self,
273        room_id: u64,
274        uid: u64,
275        content: &str,
276    ) -> BpiResult<Value> {
277        let csrf = self.client.csrf()?;
278        let form = Form::new()
279            .text("room_id", room_id.to_string())
280            .text("uid", uid.to_string())
281            .text("content", content.to_string())
282            .text("csrf", csrf.clone())
283            .text("csrf_token", csrf);
284
285        self.client
286            .post("https://api.live.bilibili.com/xlive/app-blink/v1/index/updateRoomNews")
287            .multipart(form)
288            .send_bpi_payload("live.room_news.update")
289            .await
290    }
291}
292
293fn web_up_stream_addr_form(csrf: &str) -> Vec<(&'static str, String)> {
294    vec![
295        ("platform", "pc".to_string()),
296        ("backup_stream", "0".to_string()),
297        ("csrf", csrf.to_string()),
298        ("csrf_token", csrf.to_string()),
299    ]
300}
301
302fn web_live_center_start_query(
303    room_id: u64,
304    area_v2: u64,
305    csrf: &str,
306) -> Vec<(&'static str, String)> {
307    vec![
308        ("room_id", room_id.to_string()),
309        ("platform", "pc".to_string()),
310        ("area_v2", area_v2.to_string()),
311        ("backup_stream", "0".to_string()),
312        ("csrf", csrf.to_string()),
313        ("csrf_token", csrf.to_string()),
314    ]
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use crate::probe::contract::HttpMethod;
321    use crate::probe::endpoint_contract::EndpointContract;
322    use crate::{ApiEnvelope, BpiResult};
323
324    fn version_contract() -> BpiResult<EndpointContract> {
325        EndpointContract::from_slice(include_bytes!(
326            "../../tests/contracts/live/public-core/version/contract.json"
327        ))
328    }
329
330    #[test]
331    fn live_version_contract_matches_endpoint_request() -> BpiResult<()> {
332        let contract = version_contract()?;
333
334        assert_eq!(contract.name, "live.version");
335        assert_eq!(contract.request.method, HttpMethod::Get);
336        assert_eq!(
337            contract.request.url.as_str(),
338            "https://api.live.bilibili.com/xlive/app-blink/v1/liveVersionInfo/getHomePageLiveVersion"
339        );
340        assert_eq!(
341            contract
342                .request
343                .query
344                .get("system_version")
345                .map(String::as_str),
346            Some("2")
347        );
348        assert_eq!(contract.cases.len(), 3);
349        assert_eq!(
350            contract.cases[0].response.rust_model.as_deref(),
351            Some("PcLiveVersionData")
352        );
353        Ok(())
354    }
355
356    #[test]
357    fn live_version_response_fixture_parses_declared_model() -> BpiResult<()> {
358        let payload = ApiEnvelope::<PcLiveVersionData>::from_slice(include_bytes!(
359            "../../tests/contracts/live/public-core/version/responses/success.json"
360        ))?
361        .into_payload()?;
362
363        assert_eq!(payload.curr_version, "7.61.0.10694");
364        Ok(())
365    }
366
367    fn local_probe_body(profile: &str) -> Option<serde_json::Value> {
368        let path =
369            format!("target/bpi-probe-runs/live/public-core/version/{profile}.response.json");
370        let bytes = std::fs::read(path).ok()?;
371        let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
372        value
373            .get("response")
374            .and_then(|response| response.get("body"))
375            .cloned()
376    }
377
378    #[test]
379    fn live_version_model_matches_local_probe_outputs_when_available() -> BpiResult<()> {
380        for profile in ["anonymous", "normal", "vip"] {
381            if let Some(body) = local_probe_body(profile) {
382                let payload = serde_json::from_value::<ApiEnvelope<PcLiveVersionData>>(body)?
383                    .into_payload()?;
384                assert!(!payload.curr_version.is_empty());
385            }
386        }
387        Ok(())
388    }
389
390    #[test]
391    fn web_up_stream_addr_form_contains_web_defaults_and_csrf_aliases() {
392        assert_eq!(
393            web_up_stream_addr_form("csrf-token"),
394            vec![
395                ("platform", "pc".to_string()),
396                ("backup_stream", "0".to_string()),
397                ("csrf", "csrf-token".to_string()),
398                ("csrf_token", "csrf-token".to_string()),
399            ]
400        );
401    }
402
403    #[test]
404    fn web_center_start_query_contains_wbi_signed_inputs() {
405        assert_eq!(
406            web_live_center_start_query(4_354_019, 309, "csrf-token"),
407            vec![
408                ("room_id", "4354019".to_string()),
409                ("platform", "pc".to_string()),
410                ("area_v2", "309".to_string()),
411                ("backup_stream", "0".to_string()),
412                ("csrf", "csrf-token".to_string()),
413                ("csrf_token", "csrf-token".to_string()),
414            ]
415        );
416    }
417
418    #[test]
419    fn web_up_stream_addr_response_accepts_srt_addr_missing() -> BpiResult<()> {
420        let payload = ApiEnvelope::<WebUpStreamAddrData>::from_slice(
421            br#"{
422                "code": 0,
423                "message": "0",
424                "data": {
425                    "addr": { "addr": "rtmp://example/live/", "code": "stream-key" },
426                    "line": []
427                }
428            }"#,
429        )?
430        .into_payload()?;
431
432        assert_eq!(payload.addr.addr, "rtmp://example/live/");
433        assert_eq!(payload.addr.code, "stream-key");
434        assert!(payload.srt_addr.is_null());
435        Ok(())
436    }
437
438    #[test]
439    fn start_live_response_accepts_null_rtmp() -> BpiResult<()> {
440        let payload = ApiEnvelope::<StartLiveData>::from_slice(
441            br#"{
442                "code": 0,
443                "message": "0",
444                "data": {
445                    "change": 1,
446                    "status": "LIVE",
447                    "rtmp": null,
448                    "live_key": "live-key",
449                    "sub_session_key": "sub-session",
450                    "need_face_auth": false,
451                    "room_type": 0,
452                    "protocols": [],
453                    "notice": {},
454                    "qr": "",
455                    "service_source": "",
456                    "rtmp_backup": [],
457                    "up_stream_extra": {}
458                }
459            }"#,
460        )?
461        .into_payload()?;
462
463        assert!(payload.rtmp.is_none());
464        assert_eq!(payload.live_key, "live-key");
465        Ok(())
466    }
467}