1use 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#[derive(Debug, Clone, Deserialize, Serialize)]
18pub struct CreateRoomData {
19 #[serde(rename = "roomID")]
20 pub room_id: Option<String>,
21}
22
23#[derive(Debug, Clone, Deserialize, Serialize)]
25pub struct UpdateRoomData {
26 pub sub_session_key: String,
27 pub audit_info: Option<AuditInfo>,
28}
29
30#[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#[derive(Debug, Clone, Deserialize, Serialize)]
41pub struct RtmpInfo {
42 pub addr: String,
43 pub code: String,
44}
45
46#[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#[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 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#[derive(Debug, Clone, Deserialize, Serialize)]
77pub struct StopLiveData {
78 pub change: u8,
79 pub status: String,
80}
81
82#[derive(Debug, Clone, Deserialize, Serialize)]
84pub struct UpdatePreLiveInfoData {
85 pub audit_info: Option<AuditInfo>,
86}
87
88#[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 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 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 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 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 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 #[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 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 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 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}