mirror of
https://github.com/Xinrea/bili-shadowreplay.git
synced 2025-11-24 20:15:34 +08:00
fix: provide codecs master manifest
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) = ¶ms {
|
||||
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) = ¶ms {
|
||||
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) = ¶ms {
|
||||
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) = ¶ms {
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user