diff --git a/Cargo.lock b/Cargo.lock index 17b3b0c..6dfed94 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -47,6 +47,17 @@ version = "1.0.95" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04" +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "autocfg" version = "1.4.0" @@ -136,12 +147,53 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "errno" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "filetime" version = "0.2.25" @@ -163,6 +215,18 @@ dependencies = [ "libc", ] +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi 0.14.4+wasi-0.2.4", +] + [[package]] name = "gimli" version = "0.31.1" @@ -242,6 +306,12 @@ dependencies = [ "libc", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.169" @@ -259,6 +329,12 @@ dependencies = [ "redox_syscall", ] +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + [[package]] name = "lock_api" version = "0.4.12" @@ -275,6 +351,15 @@ version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "memchr" version = "2.7.4" @@ -298,7 +383,7 @@ checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" dependencies = [ "libc", "log", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", "windows-sys 0.48.0", ] @@ -309,7 +394,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" dependencies = [ "libc", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", "windows-sys 0.52.0", ] @@ -332,6 +417,24 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "ntapi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399" +dependencies = [ + "windows-sys 0.52.0", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -403,6 +506,32 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.8" @@ -447,6 +576,19 @@ version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +[[package]] +name = "rustix" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" +dependencies = [ + "bitflags 2.6.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + [[package]] name = "same-file" version = "1.0.6" @@ -467,10 +609,23 @@ name = "sermon_converter" version = "0.1.0" dependencies = [ "anyhow", + "async-trait", "chrono", - "notify", "regex", + "tempfile", "tokio", + "tracing", + "tracing-subscriber", + "video_processing", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", ] [[package]] @@ -515,6 +670,43 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sysinfo" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a5b4ddaee55fb2bea2bf0e5000747e5f5c0de765e5a5ff87f4cd106439f4bb3" +dependencies = [ + "cfg-if", + "core-foundation-sys", + "libc", + "ntapi", + "once_cell", + "rayon", + "windows", +] + +[[package]] +name = "tempfile" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b61f8f20e3a6f7e0649d825294eaf317edce30f82cf6026e7e4cb9222a7d1e" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "tokio" version = "1.42.0" @@ -544,12 +736,94 @@ dependencies = [ "syn", ] +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + [[package]] name = "unicode-ident" version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "video_processing" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "notify", + "regex", + "sysinfo", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -566,6 +840,15 @@ version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +[[package]] +name = "wasi" +version = "0.14.4+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a5f4a424faf49c3c2c344f166f0662341d470ea185e939657aaff130f0ec4a" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.99" @@ -620,6 +903,22 @@ version = "0.2.99" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "943aab3fdaaa029a6e0271b35ea10b72b943135afe9bffca82384098ad0e06a6" +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.9" @@ -629,6 +928,22 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +dependencies = [ + "windows-core", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.52.0" @@ -785,3 +1100,9 @@ name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c573471f125075647d03df72e026074b7203790d41351cd6edc96f46bcccd36" diff --git a/Cargo.toml b/Cargo.toml index 8584606..9e65109 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,9 +3,23 @@ name = "sermon_converter" version = "0.1.0" edition = "2021" +[lib] +name = "sermon_converter" +path = "src/lib.rs" + +[[bin]] +name = "sermon_converter" +path = "src/main.rs" + [dependencies] -tokio = { version = "1.0", features = ["full"] } +tokio = { version = "1.0", features = ["full", "signal"] } anyhow = "1.0" -notify = "6.1" chrono = "0.4" regex = "1.5" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +async-trait = "0.1" +video_processing = { git = "https://git.rockvilletollandsda.church/RTSDA/video-processing-support.git", branch = "main" } + +[dev-dependencies] +tempfile = "3.0" diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..775ca6e --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,3 @@ +pub mod services; + +pub use services::sermon_converter::VideoConverter; \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 22f6451..63ba10a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,14 +1,28 @@ use std::path::PathBuf; use std::env; +use std::sync::Arc; use anyhow::Result; -use notify::{Watcher, RecursiveMode, Event, EventKind}; -use tokio::sync::mpsc; +use tracing::{info, error, warn}; +use video_processing::{ + VideoProcessingConfig, StabilityTracker, SystemMonitor, ShutdownHandler, + EventDrivenFileWatcher, FileProcessingService +}; mod services; use services::sermon_converter::VideoConverter; #[tokio::main] async fn main() -> Result<()> { + // Initialize logging + tracing_subscriber::fmt() + .with_env_filter( + env::var("RUST_LOG") + .unwrap_or_else(|_| "sermon_converter=info,video_processing=info".to_string()) + ) + .init(); + + info!("Starting Sermon Converter v0.1.0"); + let watch_path = PathBuf::from( env::var("SERMON_WATCH_PATH").expect("SERMON_WATCH_PATH environment variable must be set") ); @@ -18,110 +32,94 @@ async fn main() -> Result<()> { // Ensure directories exist if !watch_path.exists() { - println!("Creating watch directory: {}", watch_path.display()); + info!("Creating watch directory: {}", watch_path.display()); std::fs::create_dir_all(&watch_path)?; } if !output_path.exists() { - println!("Creating output directory: {}", output_path.display()); + info!("Creating output directory: {}", output_path.display()); std::fs::create_dir_all(&output_path)?; } - println!("Starting sermon converter service..."); - println!("Watching directory: {}", watch_path.display()); - println!("Output directory: {}", output_path.display()); + info!("Watch directory: {}", watch_path.display()); + info!("Output directory: {}", output_path.display()); - let converter = VideoConverter::new(output_path); + // Initialize components + let config = VideoProcessingConfig::from_env(); + let converter = Arc::new(VideoConverter::new(output_path.clone())); + let stability_tracker = Arc::new(StabilityTracker::new(config)); + let system_monitor = SystemMonitor::new(5); // 5GB minimum free space + let (shutdown_handler, mut shutdown_rx) = ShutdownHandler::new(); - // Process existing files first - println!("Checking for existing files..."); - if let Ok(entries) = std::fs::read_dir(&watch_path) { - for entry in entries { - if let Ok(entry) = entry { - let path = entry.path(); - if path.to_string_lossy().contains(".syncthing.") { - println!("Skipping temporary file: {}", path.display()); - continue; - } - if let Some(ext) = path.extension() { - if ext == "mp4" { - println!("Processing existing MP4 file: {}", path.display()); - if let Err(e) = converter.process_file(path).await { - eprintln!("Error processing existing file: {}", e); - } - } - } - } - } + // Check initial disk space + if !system_monitor.check_disk_space(&output_path)? { + error!("Insufficient disk space to start processing"); + return Ok(()); + } + + // Set up file processing service + let file_service = Arc::new(FileProcessingService::new( + Arc::clone(&converter), + Arc::clone(&stability_tracker), + )); + + // Process existing files + if let Err(e) = file_service.process_existing_files(&watch_path).await { + error!("Error processing existing files: {}", e); } // Set up file watcher - let (tx, mut rx) = mpsc::channel(100); - - let mut watcher = notify::recommended_watcher(move |res: Result| { - let tx = tx.clone(); - match res { - Ok(event) => { - match &event.kind { - EventKind::Create(_) => println!("File created: {:?}", event.paths), - EventKind::Modify(notify::event::ModifyKind::Name(mode)) => { - match mode { - notify::event::RenameMode::From => println!("File renamed from: {:?}", event.paths), - notify::event::RenameMode::To => println!("File renamed to: {:?}", event.paths), - notify::event::RenameMode::Both => println!("File renamed: {:?}", event.paths), - _ => println!("Other rename event: {:?}", event), - } - }, - _ => println!("Other file event: {:?}", event), - } - - if let Err(e) = tx.blocking_send(event) { - eprintln!("Error sending event: {}", e); - } + let mut file_watcher = EventDrivenFileWatcher::new(watch_path.clone())?; + + // Start shutdown signal handler + let shutdown_task = tokio::spawn(async move { + shutdown_handler.wait_for_shutdown_signal().await; + }); + + info!("Sermon converter is running. Press Ctrl+C to stop."); + + // Main event loop + loop { + tokio::select! { + // Handle shutdown signal + _ = shutdown_rx.recv() => { + info!("Shutdown signal received, stopping..."); + break; } - Err(e) => eprintln!("Watch error: {}", e), - } - })?; - - watcher.watch(&watch_path, RecursiveMode::NonRecursive)?; - println!("Watcher started successfully"); - - while let Some(event) = rx.recv().await { - match event.kind { - EventKind::Create(_) | - EventKind::Modify(notify::event::ModifyKind::Name(notify::event::RenameMode::To)) => { - for path in event.paths { - // Skip temporary and non-MP4 files - if path.to_string_lossy().contains(".syncthing.") { - println!("Skipping temporary Syncthing file: {}", path.display()); - continue; - } - - if let Some(ext) = path.extension() { - if ext == "mp4" { - println!("New MP4 file detected: {}", path.display()); - // Wait a short time to ensure file is fully written - tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; - - println!("Starting processing for: {}", path.display()); - if let Err(e) = converter.process_file(path.clone()).await { - eprintln!("Error processing file {}: {}", path.display(), e); - } else { - println!("Successfully processed: {}", path.display()); + + // Handle file events + event = file_watcher.next_event() => { + if let Some(event) = event { + if EventDrivenFileWatcher::should_process_event(&event) { + let mp4_paths = EventDrivenFileWatcher::extract_mp4_paths(&event); + + for path in mp4_paths { + // Check disk space before processing + if !system_monitor.check_disk_space(&output_path)? { + warn!("Insufficient disk space, skipping file: {}", path.display()); + continue; } - } else { - println!("Ignoring non-MP4 file: {}", path.display()); + + info!("New MP4 file detected: {}", path.display()); + + let service = Arc::clone(&file_service); + tokio::spawn(async move { + if let Err(e) = service.handle_file_event(path.clone()).await { + error!("Failed to process file {}: {}", path.display(), e); + } + }); } - } else { - println!("Ignoring file without extension: {}", path.display()); } + } else { + warn!("File watcher channel closed, exiting"); + break; } } - _ => { - // Log other events at debug level - println!("Ignoring event: {:?}", event); - } } } + // Wait for shutdown to complete + shutdown_task.await?; + info!("Sermon converter stopped"); + Ok(()) } diff --git a/src/services/sermon_converter.rs b/src/services/sermon_converter.rs index 7d5ac52..e0a3a39 100644 --- a/src/services/sermon_converter.rs +++ b/src/services/sermon_converter.rs @@ -1,56 +1,37 @@ use std::path::PathBuf; use anyhow::{Result, anyhow}; use chrono::NaiveDate; -use tokio::process::Command; -use tokio::time::Duration; use regex::Regex; +use video_processing::{ + VideoProcessingConfig, VideoConverter as SharedVideoConverter, NfoGenerator, FileProcessor +}; pub struct VideoConverter { output_path: PathBuf, + config: VideoProcessingConfig, + video_converter: SharedVideoConverter, + nfo_generator: NfoGenerator, } impl VideoConverter { pub fn new(output_path: PathBuf) -> Self { + let config = VideoProcessingConfig::from_env(); + let video_converter = SharedVideoConverter::new(config.clone()); + let nfo_generator = NfoGenerator::new(config.clone()); + VideoConverter { output_path, + config, + video_converter, + nfo_generator, } } - async fn wait_for_file_ready(&self, path: &PathBuf) -> Result<()> { - let mut last_size = 0; - let mut unchanged_count = 0; - - println!("Waiting for file to stabilize: {}", path.display()); - - // Wait up to 30 minutes for file to stabilize - for i in 0..1800 { - if let Ok(metadata) = tokio::fs::metadata(path).await { - let current_size = metadata.len(); - if current_size > 0 { - if current_size == last_size { - unchanged_count += 1; - // File size unchanged for 30 seconds - if unchanged_count >= 30 { - println!("File size stable for 30 seconds"); - return Ok(()); - } - } else { - if i > 0 { // Don't log first check - println!("File size changed: {} -> {}", last_size, current_size); - } - unchanged_count = 0; - } - last_size = current_size; - } - } - tokio::time::sleep(Duration::from_secs(1)).await; - } - Err(anyhow!("Timeout waiting for file to finish copying")) - } + // Note: File stability checking is now handled by the caller using the shared crate - async fn extract_date_from_filename(&self, filename: &str) -> Result { - // Example filename: "Sermon Title - Speaker | December 4th 2024.mp4" - if let Some(date_part) = filename.split('|').nth(1) { + pub async fn extract_date_from_filename(&self, filename: &str) -> Result { + // Example filename: "Sermon Title - Speaker (December 4th 2024).mp4" + if let Some(date_part) = filename.split('(').nth(1).and_then(|part| part.split(')').next()) { let re = Regex::new(r"(?i)(January|February|March|April|May|June|July|August|September|October|November|December)\s+(\d+)(?:st|nd|rd|th)?\s+(\d{4})")?; if let Some(caps) = re.captures(date_part) { @@ -62,16 +43,16 @@ impl VideoConverter { let date = NaiveDate::parse_from_str(&date_str, "%d %B %Y")?; Ok(date) } else { - Err(anyhow!("Could not extract date from filename part after |")) + Err(anyhow!("Could not extract date from filename part within parentheses")) } } else { - Err(anyhow!("No | separator found in filename")) + Err(anyhow!("No date found in parentheses in filename")) } } - async fn extract_title_and_speaker(&self, filename: &str) -> Result<(String, String)> { - // Example: "God's People in the Time of the End - Pastor Joseph Piresson | December 28th 2024.mp4" - if let Some(main_part) = filename.split('|').next() { + pub async fn extract_title_and_speaker(&self, filename: &str) -> Result<(String, String)> { + // Example: "God's People in the Time of the End - Pastor Joseph Piresson (December 28th 2024).mp4" + if let Some(main_part) = filename.split('(').next() { if let Some((title, speaker)) = main_part.split_once('-') { return Ok(( title.trim().to_string(), @@ -82,46 +63,6 @@ impl VideoConverter { Err(anyhow!("Could not extract title and speaker from filename")) } - async fn create_nfo_file(&self, video_path: &PathBuf, title: &str, speaker: &str, date: &NaiveDate) -> Result<()> { - let nfo_path = video_path.with_extension("nfo"); - - // Extract series name if it exists (before the "-" in the title) - let series_name = if let Some(idx) = title.find('-') { - title[..idx].trim() - } else { - "" - }; - - // Format the full title with date including year - let full_title = format!("{} - {} | {}", - title, - speaker, - date.format("%B %-d %Y") // Format like "December 28 2024" - ); - - let nfo_content = format!(r#" - - {} - Sermons - {} - {} - {} - {} - {} - {} -"#, - full_title, - date.format("%Y").to_string(), - date.format("%m%d").to_string(), - date.format("%Y-%m-%d"), - date.format("%Y"), - date.format("%m%d"), - series_name - ); - - tokio::fs::write(nfo_path, nfo_content).await?; - Ok(()) - } pub async fn process_file(&self, path: PathBuf) -> Result<()> { // Early checks @@ -137,8 +78,7 @@ impl VideoConverter { return Err(anyhow!("File no longer exists: {}", path.display())); } - // Wait for file to be fully copied - self.wait_for_file_ready(&path).await?; + // Note: File stability is now handled by the caller println!("Processing sermon: {}", path.display()); @@ -167,49 +107,36 @@ impl VideoConverter { println!("Converting to AV1 and saving to: {}", output_file.display()); - // Build ffmpeg command for AV1 conversion using QSV - let status = Command::new("ffmpeg") - .arg("-init_hw_device").arg("qsv=hw") - .arg("-filter_hw_device").arg("hw") - .arg("-hwaccel").arg("qsv") - .arg("-hwaccel_output_format").arg("qsv") - .arg("-i").arg(&path) - .arg("-c:v").arg("av1_qsv") - .arg("-preset").arg("4") - .arg("-b:v").arg("6M") - .arg("-maxrate").arg("12M") - .arg("-bufsize").arg("24M") - .arg("-c:a").arg("libopus") // Convert to AAC - .arg("-b:a").arg("192k") // Audio bitrate - .arg("-y") - .arg(&output_file) - .status() - .await?; + // Convert video using shared crate + self.video_converter.convert_video(&path, &output_file).await?; - if !status.success() { - return Err(anyhow!("FFmpeg conversion failed")); - } - - // Verify conversion succeeded - if !output_file.exists() { - return Err(anyhow!("Conversion failed - output file not found")); - } - - let output_size = tokio::fs::metadata(&output_file).await?.len(); - if output_size == 0 { - return Err(anyhow!("Conversion failed - output file is empty")); - } - - // Create NFO file - println!("Creating NFO file..."); - self.create_nfo_file(&output_file, &title, &speaker, &date).await?; + // Create NFO file using shared crate + let full_title = format!("{} - {}", title, speaker); + self.nfo_generator.create_nfo_file(&output_file, &full_title, &date, None).await?; println!("Successfully converted {} to AV1 and created NFO", path.display()); - // Delete original file - println!("Deleting original file: {}", path.display()); - tokio::fs::remove_file(path).await?; + // Handle original file based on configuration + if !self.config.preserve_original_files { + println!("Deleting original file: {}", path.display()); + tokio::fs::remove_file(path).await?; + } else { + println!("Preserving original file: {}", path.display()); + } Ok(()) } } + +#[async_trait::async_trait] +impl FileProcessor for VideoConverter { + async fn process_file(&self, path: PathBuf) -> Result<()> { + self.process_file(path).await + } + + async fn should_skip_existing_file(&self, _path: &PathBuf) -> bool { + // For sermons, we don't have a simple duplicate check like livestreams + // Let the process_file method handle duplicate detection + false + } +} diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs new file mode 100644 index 0000000..70d7631 --- /dev/null +++ b/tests/integration_tests.rs @@ -0,0 +1,223 @@ +use std::env; +use std::fs; +use std::path::PathBuf; +use tempfile::TempDir; +use chrono::{NaiveDate, Datelike}; +use sermon_converter::VideoConverter; + +#[tokio::test] +async fn test_sermon_converter_creation() { + let temp_output = TempDir::new().unwrap(); + + // Test that we can create a new converter + let converter = VideoConverter::new(temp_output.path().to_path_buf()); + + // Test that it was created successfully (converter doesn't expose output_path directly) + // We'll test this indirectly through other methods + assert!(true); // Basic smoke test +} + +#[tokio::test] +async fn test_date_extraction_from_sermon_filename() { + let temp_output = TempDir::new().unwrap(); + let converter = VideoConverter::new(temp_output.path().to_path_buf()); + + // Test valid sermon filename format + let filename = "God's Love and Mercy - Pastor John Smith (December 25th 2024).mp4"; + let result = converter.extract_date_from_filename(filename).await; + assert!(result.is_ok()); + + let date = result.unwrap(); + assert_eq!(date.year(), 2024); + assert_eq!(date.month(), 12); + assert_eq!(date.day(), 25); + + // Test different date formats + let test_cases = vec![ + ("Title - Speaker (January 1st 2024).mp4", (2024, 1, 1)), + ("Title - Speaker (February 29th 2024).mp4", (2024, 2, 29)), // Leap year + ("Title - Speaker (March 3rd 2023).mp4", (2023, 3, 3)), + ("Title - Speaker (April 22nd 2025).mp4", (2025, 4, 22)), + ("Title - Speaker (May 11th 2024).mp4", (2024, 5, 11)), + ("Title - Speaker (June 30th 2024).mp4", (2024, 6, 30)), + ("Title - Speaker (July 4th 2024).mp4", (2024, 7, 4)), + ("Title - Speaker (August 15th 2024).mp4", (2024, 8, 15)), + ("Title - Speaker (September 22nd 2024).mp4", (2024, 9, 22)), + ("Title - Speaker (October 31st 2024).mp4", (2024, 10, 31)), + ("Title - Speaker (November 11th 2024).mp4", (2024, 11, 11)), + ]; + + for (filename, (expected_year, expected_month, expected_day)) in test_cases { + let result = converter.extract_date_from_filename(filename).await; + assert!(result.is_ok(), "Failed to parse filename: {}", filename); + + let date = result.unwrap(); + assert_eq!(date.year(), expected_year, "Wrong year for: {}", filename); + assert_eq!(date.month(), expected_month, "Wrong month for: {}", filename); + assert_eq!(date.day(), expected_day, "Wrong day for: {}", filename); + } +} + +#[tokio::test] +async fn test_invalid_sermon_filenames() { + let temp_output = TempDir::new().unwrap(); + let converter = VideoConverter::new(temp_output.path().to_path_buf()); + + let invalid_cases = vec![ + "no-date-info.mp4", + "Title - Speaker.mp4", + "Title - Speaker (Invalid Date).mp4", + "Title - Speaker (February 30th 2024).mp4", // Invalid date + "Title - Speaker (13th Month 2024).mp4", + "No separator here 2024.mp4", + ]; + + for filename in invalid_cases { + let result = converter.extract_date_from_filename(filename).await; + assert!(result.is_err(), "Should fail to parse invalid filename: {}", filename); + } +} + +#[tokio::test] +async fn test_title_and_speaker_extraction() { + let temp_output = TempDir::new().unwrap(); + let converter = VideoConverter::new(temp_output.path().to_path_buf()); + + let test_cases = vec![ + ( + "The Power of Prayer - Dr. Sarah Johnson (December 25th 2024).mp4", + ("The Power of Prayer", "Dr. Sarah Johnson") + ), + ( + "Faith in Difficult Times - Pastor Michael Brown (January 1st 2024).mp4", + ("Faith in Difficult Times", "Pastor Michael Brown") + ), + ( + "God's Amazing Grace - Elder Mary Wilson (March 15th 2024).mp4", + ("God's Amazing Grace", "Elder Mary Wilson") + ), + ]; + + for (filename, (expected_title, expected_speaker)) in test_cases { + let result = converter.extract_title_and_speaker(filename).await; + assert!(result.is_ok(), "Failed to extract title and speaker from: {}", filename); + + let (title, speaker) = result.unwrap(); + assert_eq!(title, expected_title); + assert_eq!(speaker, expected_speaker); + } +} + +#[tokio::test] +async fn test_invalid_title_speaker_formats() { + let temp_output = TempDir::new().unwrap(); + let converter = VideoConverter::new(temp_output.path().to_path_buf()); + + let invalid_cases = vec![ + "No dash separators here (December 25th 2024).mp4", // No dash separator + ]; + + for filename in invalid_cases { + let result = converter.extract_title_and_speaker(filename).await; + assert!(result.is_err(), "Should fail to parse invalid format: {}", filename); + } +} + +#[tokio::test] +async fn test_environment_variable_integration() { + // Test with custom environment variables + env::set_var("SHOW_TITLE", "Custom Sermon Series"); + env::set_var("CREATE_NFO_FILES", "true"); + env::set_var("PRESERVE_ORIGINAL_FILES", "false"); + env::set_var("AUDIO_CODEC", "libopus"); + env::set_var("AUDIO_BITRATE", "128k"); + + let temp_output = TempDir::new().unwrap(); + let converter = VideoConverter::new(temp_output.path().to_path_buf()); + + // Test that the converter was created with custom configuration + // (We test this indirectly since the config is internal) + assert!(true); // Smoke test that creation succeeded + + // Clean up + env::remove_var("SHOW_TITLE"); + env::remove_var("CREATE_NFO_FILES"); + env::remove_var("PRESERVE_ORIGINAL_FILES"); + env::remove_var("AUDIO_CODEC"); + env::remove_var("AUDIO_BITRATE"); +} + +#[tokio::test] +async fn test_directory_structure_creation() { + let temp_output = TempDir::new().unwrap(); + let converter = VideoConverter::new(temp_output.path().to_path_buf()); + + let test_cases = vec![ + ("Title - Speaker (January 15th 2024).mp4", "2024", "01-January"), + ("Title - Speaker (December 31st 2024).mp4", "2024", "12-December"), + ("Title - Speaker (July 4th 2023).mp4", "2023", "07-July"), + ]; + + for (filename, expected_year, expected_month) in test_cases { + let date = converter.extract_date_from_filename(filename).await.unwrap(); + + // The directory structure should match what the converter expects + let year_dir = temp_output.path().join(expected_year); + let month_dir = year_dir.join(expected_month); + + // Create the directory structure as the converter would + fs::create_dir_all(&month_dir).unwrap(); + + // Verify structure exists + assert!(year_dir.exists()); + assert!(month_dir.exists()); + + // Test that output file would go in the right place + let output_file = month_dir.join(filename); + assert!(!output_file.exists()); // Shouldn't exist initially + } +} + +#[tokio::test] +async fn test_syncthing_temp_file_rejection() { + let temp_output = TempDir::new().unwrap(); + let temp_input = TempDir::new().unwrap(); + let converter = VideoConverter::new(temp_output.path().to_path_buf()); + + // Create a syncthing temp file + let syncthing_file = temp_input.path().join("test.syncthing.tmp.mp4"); + fs::write(&syncthing_file, "temporary content").unwrap(); + + // Process file should reject syncthing files + let result = converter.process_file(syncthing_file).await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Syncthing")); +} + +#[tokio::test] +async fn test_non_mp4_file_rejection() { + let temp_output = TempDir::new().unwrap(); + let temp_input = TempDir::new().unwrap(); + let converter = VideoConverter::new(temp_output.path().to_path_buf()); + + // Create a non-MP4 file + let avi_file = temp_input.path().join("Title - Speaker (December 25th 2024).avi"); + fs::write(&avi_file, "avi content").unwrap(); + + // Process file should reject non-MP4 files + let result = converter.process_file(avi_file).await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("non-MP4")); +} + +#[tokio::test] +async fn test_missing_file_handling() { + let temp_output = TempDir::new().unwrap(); + let converter = VideoConverter::new(temp_output.path().to_path_buf()); + + // Try to process a file that doesn't exist + let missing_file = PathBuf::from("/nonexistent/path/Title - Speaker (December 25th 2024).mp4"); + let result = converter.process_file(missing_file).await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("no longer exists")); +} \ No newline at end of file