mirror of
https://github.com/Xinrea/bili-shadowreplay.git
synced 2025-11-25 04:22:24 +08:00
feat: break recording when resolution changes (close #144)
This commit is contained in:
@@ -8,7 +8,7 @@ use crate::subtitle_generator::{
|
||||
};
|
||||
use async_ffmpeg_sidecar::event::{FfmpegEvent, LogLevel};
|
||||
use async_ffmpeg_sidecar::log_parser::FfmpegLogParser;
|
||||
use tokio::io::BufReader;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
|
||||
pub async fn clip_from_m3u8(
|
||||
reporter: Option<&impl ProgressReporterTrait>,
|
||||
@@ -668,6 +668,48 @@ pub async fn check_ffmpeg() -> Result<String, String> {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_video_resolution(file: &str) -> Result<String, String> {
|
||||
// ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=s=x:p=0 input.mp4
|
||||
let child = tokio::process::Command::new(ffprobe_path())
|
||||
.arg("-i")
|
||||
.arg(file)
|
||||
.arg("-v")
|
||||
.arg("error")
|
||||
.arg("-select_streams")
|
||||
.arg("v:0")
|
||||
.arg("-show_entries")
|
||||
.arg("stream=width,height")
|
||||
.arg("-of")
|
||||
.arg("csv=s=x:p=0")
|
||||
.stdout(Stdio::piped())
|
||||
.spawn();
|
||||
if let Err(e) = child {
|
||||
log::error!("Faild to spwan ffprobe process: {e}");
|
||||
return Err(e.to_string());
|
||||
}
|
||||
|
||||
let mut child = child.unwrap();
|
||||
let stdout = child.stdout.take();
|
||||
if stdout.is_none() {
|
||||
log::error!("Failed to take ffprobe output");
|
||||
return Err("Failed to take ffprobe output".into());
|
||||
}
|
||||
|
||||
let stdout = stdout.unwrap();
|
||||
let reader = BufReader::new(stdout);
|
||||
let mut lines = reader.lines();
|
||||
let line = lines.next_line().await.unwrap();
|
||||
if line.is_none() {
|
||||
return Err("Failed to parse resolution from output".into());
|
||||
}
|
||||
let line = line.unwrap();
|
||||
let resolution = line.split("x").collect::<Vec<&str>>();
|
||||
if resolution.len() != 2 {
|
||||
return Err("Failed to parse resolution from output".into());
|
||||
}
|
||||
Ok(format!("{}x{}", resolution[0], resolution[1]))
|
||||
}
|
||||
|
||||
fn ffmpeg_path() -> PathBuf {
|
||||
let mut path = Path::new("ffmpeg").to_path_buf();
|
||||
if cfg!(windows) {
|
||||
@@ -685,3 +727,16 @@ fn ffprobe_path() -> PathBuf {
|
||||
|
||||
path
|
||||
}
|
||||
|
||||
// tests
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_video_size() {
|
||||
let file = Path::new("/Users/xinreasuper/Desktop/shadowreplay-test/output2/[1789714684][1753965688317][摄像头被前夫抛妻弃子直播挣点奶粉][2025-07-31_12-58-14].mp4");
|
||||
let resolution = get_video_resolution(file.to_str().unwrap()).await.unwrap();
|
||||
println!("Resolution: {}", resolution);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use super::entry::{EntryStore, Range};
|
||||
use super::errors::RecorderError;
|
||||
use super::PlatformType;
|
||||
use crate::database::account::AccountRow;
|
||||
use crate::ffmpeg::get_video_resolution;
|
||||
use crate::progress_manager::Event;
|
||||
use crate::progress_reporter::EventEmitter;
|
||||
use crate::recorder_manager::RecorderEvent;
|
||||
@@ -69,6 +70,7 @@ pub struct BiliRecorder {
|
||||
live_end_channel: broadcast::Sender<RecorderEvent>,
|
||||
enabled: Arc<RwLock<bool>>,
|
||||
last_segment_offset: Arc<RwLock<Option<i64>>>, // 保存上次处理的最后一个片段的偏移
|
||||
current_header_info: Arc<RwLock<Option<HeaderInfo>>>, // 保存当前的分辨率
|
||||
|
||||
danmu_task: Arc<Mutex<Option<JoinHandle<()>>>>,
|
||||
record_task: Arc<Mutex<Option<JoinHandle<()>>>>,
|
||||
@@ -99,6 +101,12 @@ pub struct BiliRecorderOptions {
|
||||
pub channel: broadcast::Sender<RecorderEvent>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct HeaderInfo {
|
||||
url: String,
|
||||
resolution: String,
|
||||
}
|
||||
|
||||
impl BiliRecorder {
|
||||
pub async fn new(options: BiliRecorderOptions) -> Result<Self, super::errors::RecorderError> {
|
||||
let client = BiliClient::new()?;
|
||||
@@ -143,7 +151,7 @@ impl BiliRecorder {
|
||||
live_end_channel: options.channel,
|
||||
enabled: Arc::new(RwLock::new(options.auto_start)),
|
||||
last_segment_offset: Arc::new(RwLock::new(None)),
|
||||
|
||||
current_header_info: Arc::new(RwLock::new(None)),
|
||||
danmu_task: Arc::new(Mutex::new(None)),
|
||||
record_task: Arc::new(Mutex::new(None)),
|
||||
master_manifest: Arc::new(RwLock::new(None)),
|
||||
@@ -159,6 +167,7 @@ impl BiliRecorder {
|
||||
*self.last_update.write().await = Utc::now().timestamp();
|
||||
*self.danmu_storage.write().await = None;
|
||||
*self.last_segment_offset.write().await = None;
|
||||
*self.current_header_info.write().await = None;
|
||||
}
|
||||
|
||||
async fn should_record(&self) -> bool {
|
||||
@@ -311,8 +320,6 @@ impl BiliRecorder {
|
||||
|
||||
let variant = variant.unwrap();
|
||||
|
||||
log::info!("Variant: {:?}", variant);
|
||||
|
||||
let new_stream = self.stream_from_variant(variant).await;
|
||||
if new_stream.is_err() {
|
||||
log::error!(
|
||||
@@ -500,6 +507,17 @@ impl BiliRecorder {
|
||||
Ok(header_url)
|
||||
}
|
||||
|
||||
async fn get_resolution(
|
||||
&self,
|
||||
header_url: &str,
|
||||
) -> Result<String, super::errors::RecorderError> {
|
||||
log::debug!("Get resolution from {}", header_url);
|
||||
let resolution = get_video_resolution(header_url)
|
||||
.await
|
||||
.map_err(|e| super::errors::RecorderError::FfmpegError { err: e })?;
|
||||
Ok(resolution)
|
||||
}
|
||||
|
||||
async fn fetch_real_stream(
|
||||
&self,
|
||||
stream: &BiliStream,
|
||||
@@ -571,6 +589,13 @@ impl BiliRecorder {
|
||||
let mut work_dir;
|
||||
let mut is_first_record = false;
|
||||
|
||||
// Get url from EXT-X-MAP
|
||||
let header_url = self.get_header_url().await?;
|
||||
if header_url.is_empty() {
|
||||
return Err(super::errors::RecorderError::EmptyHeader);
|
||||
}
|
||||
let full_header_url = current_stream.ts_url(&header_url);
|
||||
|
||||
// Check header if None
|
||||
if (self.entry_store.read().await.as_ref().is_none()
|
||||
|| self
|
||||
@@ -583,19 +608,11 @@ impl BiliRecorder {
|
||||
.is_none())
|
||||
&& current_stream.format == StreamType::FMP4
|
||||
{
|
||||
// Get url from EXT-X-MAP
|
||||
let header_url = self.get_header_url().await?;
|
||||
if header_url.is_empty() {
|
||||
return Err(super::errors::RecorderError::EmptyHeader);
|
||||
}
|
||||
|
||||
timestamp = Utc::now().timestamp_millis();
|
||||
*self.live_id.write().await = timestamp.to_string();
|
||||
work_dir = self.get_work_dir(timestamp.to_string().as_str()).await;
|
||||
is_first_record = true;
|
||||
|
||||
let full_header_url = current_stream.ts_url(&header_url);
|
||||
|
||||
let file_name = header_url.split('/').next_back().unwrap();
|
||||
let mut header = TsEntry {
|
||||
url: file_name.to_string(),
|
||||
@@ -662,6 +679,20 @@ impl BiliRecorder {
|
||||
.unwrap()
|
||||
.add_entry(header)
|
||||
.await;
|
||||
|
||||
let new_resolution = self.get_resolution(&full_header_url).await?;
|
||||
|
||||
log::info!(
|
||||
"[{}] Initial header resolution: {} {}",
|
||||
self.room_id,
|
||||
header_url,
|
||||
new_resolution
|
||||
);
|
||||
|
||||
*self.current_header_info.write().await = Some(HeaderInfo {
|
||||
url: header_url.clone(),
|
||||
resolution: new_resolution,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Download header failed: {}", e);
|
||||
@@ -687,6 +718,33 @@ impl BiliRecorder {
|
||||
}
|
||||
}
|
||||
|
||||
// check resolution change
|
||||
let current_header_info = self.current_header_info.read().await.clone();
|
||||
if current_header_info.is_some() {
|
||||
let current_header_info = current_header_info.unwrap();
|
||||
if current_header_info.url != header_url {
|
||||
let new_resolution = self.get_resolution(&full_header_url).await?;
|
||||
log::warn!(
|
||||
"[{}] Header url changed: {} => {}, resolution: {} => {}",
|
||||
self.room_id,
|
||||
current_header_info.url,
|
||||
header_url,
|
||||
current_header_info.resolution,
|
||||
new_resolution
|
||||
);
|
||||
if current_header_info.resolution != new_resolution {
|
||||
self.reset().await;
|
||||
|
||||
return Err(super::errors::RecorderError::ResolutionChanged {
|
||||
err: format!(
|
||||
"Resolution changed: {} => {}",
|
||||
current_header_info.resolution, new_resolution
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match playlist {
|
||||
Playlist::MasterPlaylist(pl) => log::debug!("Master playlist:\n{:?}", pl),
|
||||
Playlist::MediaPlaylist(pl) => {
|
||||
|
||||
@@ -22,4 +22,6 @@ custom_error! {pub RecorderError
|
||||
DanmuStreamError {err: danmu_stream::DanmuStreamError} = "Danmu stream error: {err}",
|
||||
SubtitleNotFound {live_id: String} = "Subtitle not found: {live_id}",
|
||||
SubtitleGenerationFailed {error: String} = "Subtitle generation failed: {error}",
|
||||
FfmpegError {err: String} = "FFmpeg error: {err}",
|
||||
ResolutionChanged {err: String} = "Resolution changed: {err}",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user