feat: break recording when resolution changes (close #144)

This commit is contained in:
Xinrea
2025-07-31 22:39:38 +08:00
parent 084dd23df1
commit 429f909152
3 changed files with 127 additions and 12 deletions

View File

@@ -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);
}
}

View File

@@ -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) => {

View File

@@ -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}",
}