From 08f402d4d1ce3fb170f30e5d2946b77d1ffd6a01 Mon Sep 17 00:00:00 2001 From: Cameron Date: Mon, 19 Jan 2026 22:37:50 -0500 Subject: [PATCH] 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. --- src/main.rs | 156 +++++++++++++++++++++++++++++++++++++++++++- src/video/actors.rs | 92 +++++++++++++++++++++----- 2 files changed, 228 insertions(+), 20 deletions(-) diff --git a/src/main.rs b/src/main.rs index 75296b0..c2b3161 100644 --- a/src/main.rs +++ b/src/main.rs @@ -758,6 +758,9 @@ fn main() -> std::io::Result<()> { let playlist_mgr_for_watcher = app_state.playlist_manager.as_ref().clone(); watch_files(playlist_mgr_for_watcher); + // Start orphaned playlist cleanup job + cleanup_orphaned_playlists(); + // Spawn background job to generate daily conversation summaries { use crate::ai::generate_daily_summaries; @@ -900,6 +903,130 @@ fn run_migrations( 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::().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 = 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) { std::thread::spawn(move || { let base_str = dotenv::var("BASE_PATH").unwrap(); @@ -967,6 +1094,31 @@ fn watch_files(playlist_manager: Addr) { }); } +/// 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( base_path: &Path, exif_dao: Arc>>, @@ -1122,8 +1274,8 @@ fn process_new_files( ); let playlist_path = Path::new(&video_path_base).join(&playlist_filename); - // Check if playlist already exists - if !playlist_path.exists() { + // Check if playlist needs (re)generation + if playlist_needs_generation(&file_path, &playlist_path) { videos_needing_playlists.push(file_path.clone()); } } diff --git a/src/video/actors.rs b/src/video/actors.rs index 7f38b05..51721a9 100644 --- a/src/video/actors.rs +++ b/src/video/actors.rs @@ -100,6 +100,44 @@ pub fn generate_video_thumbnail(path: &Path, destination: &Path) { .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 { playlist_dir: PathBuf, playlist_generator: Addr, @@ -306,25 +344,43 @@ impl Handler for PlaylistGenerator { 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 { - let ffmpeg_result = tokio::process::Command::new("ffmpeg") - .arg("-i") - .arg(&video_file) - .arg("-c:v") - .arg("h264") - .arg("-crf") - .arg("21") - .arg("-preset") - .arg("veryfast") - .arg("-hls_time") - .arg("3") - .arg("-hls_list_size") - .arg("100") - .arg("-vf") - .arg("scale=1080:-2,setsar=1:1") - .arg(playlist_file) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) + let mut cmd = tokio::process::Command::new("ffmpeg"); + cmd.arg("-i").arg(&video_file); + + if use_copy { + // Video is already h264, just copy the stream + cmd.arg("-c:v").arg("copy"); + cmd.arg("-c:a").arg("aac"); // Still need to ensure audio is compatible + } else { + // Need to transcode + cmd.arg("-c:v").arg("h264"); + cmd.arg("-crf").arg("21"); + cmd.arg("-preset").arg("veryfast"); + cmd.arg("-vf").arg("scale=1080:-2,setsar=1:1"); + cmd.arg("-c:a").arg("aac"); + } + + // Common HLS settings + 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() .inspect_err(|e| error!("Failed to run ffmpeg on child process: {}", e)) .map_err(|e| std::io::Error::other(e.to_string()))