Add h264 codec detection and orphaned playlist cleanup job
Implement `is_h264_encoded` to detect existing h264 videos and optimize processing by using stream copy when possible. Introduce a background job for cleaning up orphaned playlists and segments based on missing source videos. Improve checks for playlist generation necessity.
This commit is contained in:
156
src/main.rs
156
src/main.rs
@@ -758,6 +758,9 @@ fn main() -> std::io::Result<()> {
|
|||||||
let playlist_mgr_for_watcher = app_state.playlist_manager.as_ref().clone();
|
let playlist_mgr_for_watcher = app_state.playlist_manager.as_ref().clone();
|
||||||
watch_files(playlist_mgr_for_watcher);
|
watch_files(playlist_mgr_for_watcher);
|
||||||
|
|
||||||
|
// Start orphaned playlist cleanup job
|
||||||
|
cleanup_orphaned_playlists();
|
||||||
|
|
||||||
// Spawn background job to generate daily conversation summaries
|
// Spawn background job to generate daily conversation summaries
|
||||||
{
|
{
|
||||||
use crate::ai::generate_daily_summaries;
|
use crate::ai::generate_daily_summaries;
|
||||||
@@ -900,6 +903,130 @@ fn run_migrations(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clean up orphaned HLS playlists and segments whose source videos no longer exist
|
||||||
|
fn cleanup_orphaned_playlists() {
|
||||||
|
std::thread::spawn(|| {
|
||||||
|
let video_path = dotenv::var("VIDEO_PATH").expect("VIDEO_PATH must be set");
|
||||||
|
let base_path = dotenv::var("BASE_PATH").expect("BASE_PATH must be set");
|
||||||
|
|
||||||
|
// Get cleanup interval from environment (default: 24 hours)
|
||||||
|
let cleanup_interval_secs = dotenv::var("PLAYLIST_CLEANUP_INTERVAL_SECONDS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.parse::<u64>().ok())
|
||||||
|
.unwrap_or(86400); // 24 hours
|
||||||
|
|
||||||
|
info!("Starting orphaned playlist cleanup job");
|
||||||
|
info!(" Cleanup interval: {} seconds", cleanup_interval_secs);
|
||||||
|
info!(" Playlist directory: {}", video_path);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
std::thread::sleep(Duration::from_secs(cleanup_interval_secs));
|
||||||
|
|
||||||
|
info!("Running orphaned playlist cleanup");
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
let mut deleted_count = 0;
|
||||||
|
let mut error_count = 0;
|
||||||
|
|
||||||
|
// Find all .m3u8 files in VIDEO_PATH
|
||||||
|
let playlists: Vec<PathBuf> = WalkDir::new(&video_path)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.filter(|e| e.file_type().is_file())
|
||||||
|
.filter(|e| {
|
||||||
|
e.path()
|
||||||
|
.extension()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.map(|ext| ext.eq_ignore_ascii_case("m3u8"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.map(|e| e.path().to_path_buf())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
info!("Found {} playlist files to check", playlists.len());
|
||||||
|
|
||||||
|
for playlist_path in playlists {
|
||||||
|
// Extract the original video filename from playlist name
|
||||||
|
// Playlist format: {VIDEO_PATH}/{original_filename}.m3u8
|
||||||
|
if let Some(filename) = playlist_path.file_stem() {
|
||||||
|
let video_filename = filename.to_string_lossy();
|
||||||
|
|
||||||
|
// Search for this video file in BASE_PATH
|
||||||
|
let mut video_exists = false;
|
||||||
|
for entry in WalkDir::new(&base_path)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.filter(|e| e.file_type().is_file())
|
||||||
|
{
|
||||||
|
if let Some(entry_stem) = entry.path().file_stem() {
|
||||||
|
if entry_stem == filename && is_video_file(entry.path()) {
|
||||||
|
video_exists = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !video_exists {
|
||||||
|
debug!(
|
||||||
|
"Source video for playlist {} no longer exists, deleting",
|
||||||
|
playlist_path.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete the playlist file
|
||||||
|
if let Err(e) = std::fs::remove_file(&playlist_path) {
|
||||||
|
warn!("Failed to delete playlist {}: {}", playlist_path.display(), e);
|
||||||
|
error_count += 1;
|
||||||
|
} else {
|
||||||
|
deleted_count += 1;
|
||||||
|
|
||||||
|
// Also try to delete associated .ts segment files
|
||||||
|
// They are typically named {filename}N.ts in the same directory
|
||||||
|
if let Some(parent_dir) = playlist_path.parent() {
|
||||||
|
for entry in WalkDir::new(parent_dir)
|
||||||
|
.max_depth(1)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.filter(|e| e.file_type().is_file())
|
||||||
|
{
|
||||||
|
let entry_path = entry.path();
|
||||||
|
if let Some(ext) = entry_path.extension() {
|
||||||
|
if ext.eq_ignore_ascii_case("ts") {
|
||||||
|
// Check if this .ts file belongs to our playlist
|
||||||
|
if let Some(ts_stem) = entry_path.file_stem() {
|
||||||
|
let ts_name = ts_stem.to_string_lossy();
|
||||||
|
if ts_name.starts_with(&*video_filename) {
|
||||||
|
if let Err(e) = std::fs::remove_file(entry_path) {
|
||||||
|
debug!(
|
||||||
|
"Failed to delete segment {}: {}",
|
||||||
|
entry_path.display(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
debug!(
|
||||||
|
"Deleted segment: {}",
|
||||||
|
entry_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"Orphaned playlist cleanup completed in {:?}: deleted {} playlists, {} errors",
|
||||||
|
start.elapsed(),
|
||||||
|
deleted_count,
|
||||||
|
error_count
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fn watch_files(playlist_manager: Addr<VideoPlaylistManager>) {
|
fn watch_files(playlist_manager: Addr<VideoPlaylistManager>) {
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let base_str = dotenv::var("BASE_PATH").unwrap();
|
let base_str = dotenv::var("BASE_PATH").unwrap();
|
||||||
@@ -967,6 +1094,31 @@ fn watch_files(playlist_manager: Addr<VideoPlaylistManager>) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check if a playlist needs to be (re)generated
|
||||||
|
/// Returns true if:
|
||||||
|
/// - Playlist doesn't exist, OR
|
||||||
|
/// - Source video is newer than the playlist
|
||||||
|
fn playlist_needs_generation(video_path: &Path, playlist_path: &Path) -> bool {
|
||||||
|
if !playlist_path.exists() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if source video is newer than playlist
|
||||||
|
if let (Ok(video_meta), Ok(playlist_meta)) = (
|
||||||
|
std::fs::metadata(video_path),
|
||||||
|
std::fs::metadata(playlist_path),
|
||||||
|
) {
|
||||||
|
if let (Ok(video_modified), Ok(playlist_modified)) =
|
||||||
|
(video_meta.modified(), playlist_meta.modified())
|
||||||
|
{
|
||||||
|
return video_modified > playlist_modified;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we can't determine, assume it needs generation
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
fn process_new_files(
|
fn process_new_files(
|
||||||
base_path: &Path,
|
base_path: &Path,
|
||||||
exif_dao: Arc<Mutex<Box<dyn ExifDao>>>,
|
exif_dao: Arc<Mutex<Box<dyn ExifDao>>>,
|
||||||
@@ -1122,8 +1274,8 @@ fn process_new_files(
|
|||||||
);
|
);
|
||||||
let playlist_path = Path::new(&video_path_base).join(&playlist_filename);
|
let playlist_path = Path::new(&video_path_base).join(&playlist_filename);
|
||||||
|
|
||||||
// Check if playlist already exists
|
// Check if playlist needs (re)generation
|
||||||
if !playlist_path.exists() {
|
if playlist_needs_generation(&file_path, &playlist_path) {
|
||||||
videos_needing_playlists.push(file_path.clone());
|
videos_needing_playlists.push(file_path.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,6 +100,44 @@ pub fn generate_video_thumbnail(path: &Path, destination: &Path) {
|
|||||||
.expect("Failure to create video frame");
|
.expect("Failure to create video frame");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check if a video is already encoded with h264 codec
|
||||||
|
/// Returns true if the video uses h264, false otherwise or if detection fails
|
||||||
|
async fn is_h264_encoded(video_path: &str) -> bool {
|
||||||
|
let output = tokio::process::Command::new("ffprobe")
|
||||||
|
.arg("-v")
|
||||||
|
.arg("error")
|
||||||
|
.arg("-select_streams")
|
||||||
|
.arg("v:0")
|
||||||
|
.arg("-show_entries")
|
||||||
|
.arg("stream=codec_name")
|
||||||
|
.arg("-of")
|
||||||
|
.arg("default=noprint_wrappers=1:nokey=1")
|
||||||
|
.arg(video_path)
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match output {
|
||||||
|
Ok(output) if output.status.success() => {
|
||||||
|
let codec = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let codec = codec.trim();
|
||||||
|
debug!("Detected codec for {}: {}", video_path, codec);
|
||||||
|
codec == "h264"
|
||||||
|
}
|
||||||
|
Ok(output) => {
|
||||||
|
warn!(
|
||||||
|
"ffprobe failed for {}: {}",
|
||||||
|
video_path,
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to run ffprobe for {}: {}", video_path, e);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct VideoPlaylistManager {
|
pub struct VideoPlaylistManager {
|
||||||
playlist_dir: PathBuf,
|
playlist_dir: PathBuf,
|
||||||
playlist_generator: Addr<PlaylistGenerator>,
|
playlist_generator: Addr<PlaylistGenerator>,
|
||||||
@@ -306,25 +344,43 @@ impl Handler<GeneratePlaylistMessage> for PlaylistGenerator {
|
|||||||
return Err(std::io::Error::from(std::io::ErrorKind::AlreadyExists));
|
return Err(std::io::Error::from(std::io::ErrorKind::AlreadyExists));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if video is already h264 encoded
|
||||||
|
let is_h264 = is_h264_encoded(&video_file).await;
|
||||||
|
let use_copy = is_h264;
|
||||||
|
|
||||||
|
if use_copy {
|
||||||
|
info!("Video {} is already h264, using stream copy", video_file);
|
||||||
|
span.add_event("Using stream copy (h264 detected)", vec![]);
|
||||||
|
} else {
|
||||||
|
info!("Video {} needs transcoding to h264", video_file);
|
||||||
|
span.add_event("Transcoding to h264", vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let ffmpeg_result = tokio::process::Command::new("ffmpeg")
|
let mut cmd = tokio::process::Command::new("ffmpeg");
|
||||||
.arg("-i")
|
cmd.arg("-i").arg(&video_file);
|
||||||
.arg(&video_file)
|
|
||||||
.arg("-c:v")
|
if use_copy {
|
||||||
.arg("h264")
|
// Video is already h264, just copy the stream
|
||||||
.arg("-crf")
|
cmd.arg("-c:v").arg("copy");
|
||||||
.arg("21")
|
cmd.arg("-c:a").arg("aac"); // Still need to ensure audio is compatible
|
||||||
.arg("-preset")
|
} else {
|
||||||
.arg("veryfast")
|
// Need to transcode
|
||||||
.arg("-hls_time")
|
cmd.arg("-c:v").arg("h264");
|
||||||
.arg("3")
|
cmd.arg("-crf").arg("21");
|
||||||
.arg("-hls_list_size")
|
cmd.arg("-preset").arg("veryfast");
|
||||||
.arg("100")
|
cmd.arg("-vf").arg("scale=1080:-2,setsar=1:1");
|
||||||
.arg("-vf")
|
cmd.arg("-c:a").arg("aac");
|
||||||
.arg("scale=1080:-2,setsar=1:1")
|
}
|
||||||
.arg(playlist_file)
|
|
||||||
.stdout(Stdio::null())
|
// Common HLS settings
|
||||||
.stderr(Stdio::piped())
|
cmd.arg("-hls_time").arg("3");
|
||||||
|
cmd.arg("-hls_list_size").arg("100");
|
||||||
|
cmd.arg(&playlist_file);
|
||||||
|
cmd.stdout(Stdio::null());
|
||||||
|
cmd.stderr(Stdio::piped());
|
||||||
|
|
||||||
|
let ffmpeg_result = cmd
|
||||||
.output()
|
.output()
|
||||||
.inspect_err(|e| error!("Failed to run ffmpeg on child process: {}", e))
|
.inspect_err(|e| error!("Failed to run ffmpeg on child process: {}", e))
|
||||||
.map_err(|e| std::io::Error::other(e.to_string()))
|
.map_err(|e| std::io::Error::other(e.to_string()))
|
||||||
|
|||||||
Reference in New Issue
Block a user