fix: provide codecs master manifest

This commit is contained in:
Xinrea
2025-04-29 22:30:02 +08:00
parent fc594b12e0
commit ae20e7fad7
7 changed files with 180 additions and 129 deletions

View File

@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="shaka-player/controls.min.css" />
<link rel="stylesheet" href="shaka-player/youtube-theme.css" />
<script src="node_modules/shaka-player/dist/shaka-player.ui.js"></script>
<script src="node_modules/shaka-player/dist/shaka-player.ui.debug.js"></script>
</head>
<body>
<div id="app"></div>

View File

@@ -77,6 +77,7 @@ pub trait Recorder: Send + Sync + 'static {
async fn stop(&self);
async fn first_segment_ts(&self, live_id: &str) -> i64;
async fn m3u8_content(&self, live_id: &str, start: i64, end: i64) -> String;
async fn master_m3u8(&self, live_id: &str, start: i64, end: i64) -> String;
async fn info(&self) -> RecorderInfo;
async fn comments(&self, live_id: &str) -> Result<Vec<DanmuEntry>, errors::RecorderError>;
async fn is_recording(&self, live_id: &str) -> bool;

View File

@@ -16,7 +16,7 @@ use client::{BiliClient, BiliStream, RoomInfo, StreamType, UserInfo};
use dashmap::DashMap;
use errors::BiliClientError;
use felgens::{ws_socket_object, FelgensError, WsStreamMessageType};
use m3u8_rs::Playlist;
use m3u8_rs::{MediaPlaylist, Playlist, QuotedOrUnquoted, VariantStream};
use rand::Rng;
use regex::Regex;
use std::sync::atomic::{AtomicBool, Ordering};
@@ -239,26 +239,75 @@ impl BiliRecorder {
// current_record => update stream
// auto_start+is_new_stream => update stream and current_record=true
let new_stream = match self
.client
.read()
.await
.get_play_url(&self.account, self.room_id)
.await
{
Ok(stream) => Some(stream),
Err(e) => {
log::error!("[{}]Fetch stream failed: {}", self.room_id, e);
let master_manifest = self.client.read().await.get_index_content(&format!("https://api.live.bilibili.com/xlive/play-gateway/master/url?cid={}&pt=h5&p2p_type=-1&net=0&free_type=0&build=0&feature=2&qn=10000", self.room_id)).await;
if master_manifest.is_err() {
log::error!(
"[{}]Fetch master manifest failed: {}",
self.room_id,
master_manifest.err().unwrap()
);
return true;
}
let master_manifest =
m3u8_rs::parse_playlist_res(master_manifest.as_ref().unwrap().as_bytes())
.map_err(|_| super::errors::RecorderError::M3u8ParseFailed {
content: master_manifest.as_ref().unwrap().clone(),
});
if master_manifest.is_err() {
log::error!(
"[{}]Parse master manifest failed: {}",
self.room_id,
master_manifest.err().unwrap()
);
return true;
}
let master_manifest = master_manifest.unwrap();
let variant = match master_manifest {
Playlist::MasterPlaylist(playlist) => {
let variants = playlist.variants.clone();
variants.into_iter().find(|variant| {
if let Some(other_attributes) = &variant.other_attributes {
if let Some(QuotedOrUnquoted::Quoted(bili_display)) =
other_attributes.get("BILI-DISPLAY")
{
bili_display == "原画"
} else {
false
}
} else {
false
}
})
}
_ => {
log::error!("[{}]Master manifest is not a media playlist", self.room_id);
None
}
};
if new_stream.is_none() {
if variant.is_none() {
log::error!("[{}]No variant found", self.room_id);
return true;
}
let variant = variant.unwrap();
let new_stream = self.stream_from_variant(variant).await;
if new_stream.is_err() {
log::error!(
"[{}]Fetch stream failed: {}",
self.room_id,
new_stream.err().unwrap()
);
return true;
}
let stream = new_stream.unwrap();
log::info!("[{}]New stream: {:?}", self.room_id, stream);
// auto start must be true here, if what fetched is a new stream, set current_record=true to auto start recording
if self.live_stream.read().await.is_none()
|| !self
@@ -297,6 +346,36 @@ impl BiliRecorder {
}
}
async fn stream_from_variant(
&self,
variant: VariantStream,
) -> Result<BiliStream, super::errors::RecorderError> {
let url = variant.uri.clone();
// example url: https://cn-hnld-ct-01-47.bilivideo.com/live-bvc/931676/live_1789460279_3538985/index.m3u8?expires=1745927098&len=0&oi=3729149990&pt=h5&qn=10000&trid=10075ceab17d4c9498264eb76d572b6810ad&sigparams=cdn,expires,len,oi,pt,qn,trid&cdn=cn-gotcha01&sign=686434f3ad01d33e001c80bfb7e1713d&site=3124fc9e0fabc664ace3d1b33638f7f2&free_type=0&mid=0&sche=ban&bvchls=1&sid=cn-hnld-ct-01-47&chash=0&bmt=1&sg=lr&trace=25&isp=ct&rg=East&pv=Shanghai&sk=28cc07215ff940102a1d60dade11467e&codec=0&pp=rtmp&hdr_type=0&hot_cdn=57345&suffix=origin&flvsk=c9154f5b3c6b14808bc5569329cf7f94&origin_bitrate=1281767&score=1&source=puv3_master&p2p_type=-1&deploy_env=prod&sl=1&info_source=origin&vd=nc&zoneid_l=151355393&sid_l=stream_name_cold&src=puv3&order=1
// extract host: cn-hnld-ct-01-47.bilivideo.com
let host = url.split('/').nth(2).unwrap_or_default();
let extra = url.split('?').nth(1).unwrap_or_default();
// extract base url: live-bvc/931676/live_1789460279_3538985/
let base_url = url
.split('/')
.skip(3)
.take(3)
.collect::<Vec<&str>>()
.join("/")
+ "/";
let stream = BiliStream::new(
StreamType::FMP4,
base_url.as_str(),
host,
extra,
variant
.codecs
.as_ref()
.map_or("avc1.64002a,mp4a.40.2", |s| s.as_str()),
);
Ok(stream)
}
async fn danmu(&self) {
let cookies = self.account.cookies.clone();
let uid: u64 = self.account.uid;
@@ -395,19 +474,6 @@ impl BiliRecorder {
url: stream.index(),
});
}
if index_content.contains("BANDWIDTH") {
// this index content provides another m3u8 url
// example: https://765b047cec3b099771d4b1851136046f.v.smtcdns.net/d1--cn-gotcha204-3.bilivideo.com/live-bvc/246284/live_1323355750_55526594/index.m3u8?expires=1741318366&len=0&oi=1961017843&pt=h5&qn=10000&trid=1007049a5300422eeffd2d6995d67b67ca5a&sigparams=cdn,expires,len,oi,pt,qn,trid&cdn=cn-gotcha204&sign=7ef1241439467ef27d3c804c1eda8d4d&site=1c89ef99adec13fab3a3592ee4db26d3&free_type=0&mid=475210&sche=ban&bvchls=1&trace=16&isp=ct&rg=East&pv=Shanghai&source=puv3_onetier&p2p_type=-1&score=1&suffix=origin&deploy_env=prod&flvsk=e5c4d6fb512ed7832b706f0a92f7a8c8&sk=246b3930727a89629f17520b1b551a2f&pp=rtmp&hot_cdn=57345&origin_bitrate=657300&sl=1&info_source=cache&vd=bc&src=puv3&order=1&TxLiveCode=cold_stream&TxDispType=3&svr_type=live_oc&tencent_test_client_ip=116.226.193.243&dispatch_from=OC_MGR61.170.74.11&utime=1741314857497
let new_url = index_content.lines().last().unwrap();
let base_url = new_url.split('/').next().unwrap();
let host = base_url.split('/').next().unwrap();
// extra is params after index.m3u8
let extra = new_url.split(base_url).last().unwrap();
let stream = BiliStream::new(StreamType::FMP4, base_url, host, extra);
log::info!("Update stream: {}", stream);
*self.live_stream.write().await = Some(stream);
return Box::pin(self.get_header_url()).await;
}
let mut header_url = String::from("");
let re = Regex::new(r"h.*\.m4s").unwrap();
if let Some(captures) = re.captures(&index_content) {
@@ -892,6 +958,18 @@ impl super::Recorder for BiliRecorder {
}
}
async fn master_m3u8(&self, live_id: &str, start: i64, end: i64) -> String {
let mut m3u8_content = "#EXTM3U\n".to_string();
m3u8_content += "#EXT-X-VERSION:6\n";
m3u8_content += format!(
"#EXT-X-STREAM-INF:{}\n",
"BANDWIDTH=1280000,RESOLUTION=1920x1080,CODECS=\"avc1.64001F,mp4a.40.2\""
)
.as_str();
m3u8_content += &format!("playlist.m3u8?start={}&end={}\n", start, end);
m3u8_content
}
async fn first_segment_ts(&self, live_id: &str) -> i64 {
if *self.live_id.read().await == live_id {
let entry_store = self.entry_store.read().await;

View File

@@ -86,6 +86,7 @@ pub struct BiliStream {
pub path: String,
pub extra: String,
pub expire: i64,
pub codec: String,
}
impl fmt::Display for BiliStream {
@@ -99,22 +100,35 @@ impl fmt::Display for BiliStream {
}
impl BiliStream {
pub fn new(format: StreamType, base_url: &str, host: &str, extra: &str) -> BiliStream {
pub fn new(
format: StreamType,
base_url: &str,
host: &str,
extra: &str,
codec: &str,
) -> BiliStream {
BiliStream {
format,
host: host.into(),
path: BiliStream::get_path(base_url),
extra: extra.into(),
expire: BiliStream::get_expire(extra).unwrap_or(600000),
codec: codec.into(),
}
}
pub fn index(&self) -> String {
format!("{}{}{}?{}", self.host, self.path, "index.m3u8", self.extra)
format!(
"https://{}/{}/{}?{}",
self.host, self.path, "index.m3u8", self.extra
)
}
pub fn ts_url(&self, seg_name: &str) -> String {
format!("{}{}{}?{}", self.host, self.path, seg_name, self.extra)
format!(
"https://{}/{}/{}?{}",
self.host, self.path, seg_name, self.extra
)
}
pub fn get_path(base_url: &str) -> String {
@@ -352,63 +366,6 @@ impl BiliClient {
Ok(format!("data:{};base64,{}", mime_type, base64))
}
pub async fn get_play_url(
&self,
account: &AccountRow,
room_id: u64,
) -> Result<BiliStream, BiliClientError> {
let mut headers = self.headers.clone();
headers.insert("cookie", account.cookies.parse().unwrap());
let res: GeneralResponse = self
.client
.get(format!(
"https://api.live.bilibili.com/xlive/web-room/v2/index/getRoomPlayInfo?room_id={}&protocol=1&format=0,1,2&codec=0&qn=10000&platform=h5",
room_id
))
.headers(headers)
.send().await?
.json().await?;
if res.code == 0 {
if let response::Data::RoomPlayInfo(data) = res.data {
if let Some(stream) = data.playurl_info.playurl.stream.first() {
// Get fmp4 format
if let Some(f) = stream.format.iter().find(|f| f.format_name == "fmp4") {
self.get_stream(f).await
} else {
log::error!("No fmp4 stream found: {:?}", data);
Err(BiliClientError::InvalidResponse)
}
} else {
log::error!("No stream provided: {:#?}", data);
Err(BiliClientError::InvalidResponse)
}
} else {
log::error!("Invalid response: {:#?}", res);
Err(BiliClientError::InvalidResponse)
}
} else {
log::error!("Invalid response: {:#?}", res);
Err(BiliClientError::InvalidResponse)
}
}
async fn get_stream(&self, format: &Format) -> Result<BiliStream, BiliClientError> {
if let Some(codec) = format.codec.first() {
if let Some(url_info) = codec.url_info.first() {
Ok(BiliStream::new(
StreamType::FMP4,
&codec.base_url,
&url_info.host,
&url_info.extra,
))
} else {
Err(BiliClientError::InvalidFormat)
}
} else {
Err(BiliClientError::InvalidFormat)
}
}
pub async fn get_index_content(&self, url: &String) -> Result<String, BiliClientError> {
Ok(self
.client

View File

@@ -548,6 +548,18 @@ impl Recorder for DouyinRecorder {
m3u8_content
}
async fn master_m3u8(&self, live_id: &str, start: i64, end: i64) -> String {
let mut m3u8_content = "#EXTM3U\n".to_string();
m3u8_content += "#EXT-X-VERSION:6\n";
m3u8_content += format!(
"#EXT-X-STREAM-INF:{}\n",
"BANDWIDTH=1280000,RESOLUTION=1920x1080,CODECS=\"avc1.64001F,mp4a.40.2\""
)
.as_str();
m3u8_content += &format!("playlist.m3u8?start={}&end={}\n", start, end);
m3u8_content
}
async fn first_segment_ts(&self, live_id: &str) -> i64 {
if *self.live_id.read().await == live_id {
let entry_store = self.entry_store.read().await;

View File

@@ -626,6 +626,38 @@ impl RecorderManager {
let params = Some(params);
// parse params, example: start=10&end=20
// start and end are optional
// split params by &, and then split each param by =
let params = if let Some(params) = params {
let params = params
.split('&')
.map(|param| param.split('=').collect::<Vec<&str>>())
.collect::<Vec<Vec<&str>>>();
Some(params)
} else {
None
};
let start = if let Some(params) = &params {
params
.iter()
.find(|param| param[0] == "start")
.map(|param| param[1].parse::<i64>().unwrap())
.unwrap_or(0)
} else {
0
};
let end = if let Some(params) = &params {
params
.iter()
.find(|param| param[0] == "end")
.map(|param| param[1].parse::<i64>().unwrap())
.unwrap_or(0)
} else {
0
};
if path_segs[3] == "playlist.m3u8" {
// get recorder
let recorder_key = format!("{}:{}", platform, room_id);
@@ -638,41 +670,22 @@ impl RecorderManager {
}
let recorder = recorder.unwrap();
// parse params, example: start=10&end=20
// start and end are optional
// split params by &, and then split each param by =
let params = if let Some(params) = params {
let params = params
.split('&')
.map(|param| param.split('=').collect::<Vec<&str>>())
.collect::<Vec<Vec<&str>>>();
Some(params)
} else {
None
};
let start = if let Some(params) = &params {
params
.iter()
.find(|param| param[0] == "start")
.map(|param| param[1].parse::<i64>().unwrap())
.unwrap_or(0)
} else {
0
};
let end = if let Some(params) = &params {
params
.iter()
.find(|param| param[0] == "end")
.map(|param| param[1].parse::<i64>().unwrap())
.unwrap_or(0)
} else {
0
};
// response with recorder generated m3u8, which contains ts entries that cached in local
let m3u8_content = recorder.m3u8_content(live_id, start, end).await;
Ok(m3u8_content.into())
} else if path_segs[3] == "master.m3u8" {
// get recorder
let recorder_key = format!("{}:{}", platform, room_id);
let recorders = self.recorders.read().await;
let recorder = recorders.get(&recorder_key);
if recorder.is_none() {
return Err(RecorderManagerError::HLSError {
err: "Recorder not found".into(),
});
}
let recorder = recorder.unwrap();
let m3u8_content = recorder.master_m3u8(live_id, start, end).await;
Ok(m3u8_content.into())
} else {
// try to find requested ts file in recorder's cache

View File

@@ -185,14 +185,6 @@
player.configure({
streaming: {
lowLatencyMode: true,
liveSync: {
enabled: true,
},
},
manifest: {
hls: {
liveSegmentsDelay: 1, // 直播延迟设为 0.5 个片段2 秒)
},
},
cmsd: {
enabled: false,
@@ -208,7 +200,7 @@
});
try {
const url = `${ENDPOINT}/hls/${platform}/${room_id}/${live_id}/playlist.m3u8?start=${focus_start}&end=${focus_end}`;
const url = `${ENDPOINT}/hls/${platform}/${room_id}/${live_id}/master.m3u8?start=${focus_start}&end=${focus_end}`;
if (!TAURI_ENV) {
await loadGlobalOffset(url);
}
@@ -297,8 +289,6 @@
(video.currentTime + global_offset + ts + focus_start) * 1000
);
console.log("cur:", new Date(cur), global_offset, ts);
let danmus = danmu_records.filter((v) => {
return v.ts >= cur - 1000 && v.ts < cur;
});