sermon-converter/src/services/sermon_converter.rs
Benjamin Slingo 18bddf4e6f Major refactoring: Event-driven sermon converter
- Complete removal of polling loops for better performance
- Event-driven file processing with stability tracking
- Proper structured logging with tracing
- Graceful shutdown handling (SIGTERM/SIGINT)
- Disk space monitoring (5GB minimum threshold)
- Shared video_processing crate integration
- Filename compatibility improvements (| → () separator)
- Environment-based configuration
- Comprehensive error handling and logging

Features:
- Intelligent sermon filename parsing (Title - Speaker (Date).mp4)
- Date extraction with full month name support
- Title and speaker metadata extraction
- Environment variable configuration (SERMON_WATCH_PATH, SERMON_OUTPUT_PATH)
- Hardware-accelerated video conversion (Intel QSV)
- NFO file generation for Jellyfin compatibility
- Syncthing temporary file filtering
- AV1 video encoding with configurable settings

Performance improvements:
- No more wasteful 1-2 second polling
- Only processes files when filesystem events occur
- Proper resource management and cleanup
2025-09-06 18:30:06 -04:00

143 lines
5.3 KiB
Rust

use std::path::PathBuf;
use anyhow::{Result, anyhow};
use chrono::NaiveDate;
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,
}
}
// Note: File stability checking is now handled by the caller using the shared crate
pub async fn extract_date_from_filename(&self, filename: &str) -> Result<NaiveDate> {
// 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) {
let month = caps.get(1).unwrap().as_str();
let day = caps.get(2).unwrap().as_str();
let year = caps.get(3).unwrap().as_str();
let date_str = format!("{} {} {}", day, month, year);
let date = NaiveDate::parse_from_str(&date_str, "%d %B %Y")?;
Ok(date)
} else {
Err(anyhow!("Could not extract date from filename part within parentheses"))
}
} else {
Err(anyhow!("No date found in parentheses in filename"))
}
}
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(),
speaker.trim().to_string()
));
}
}
Err(anyhow!("Could not extract title and speaker from filename"))
}
pub async fn process_file(&self, path: PathBuf) -> Result<()> {
// Early checks
if path.to_string_lossy().contains(".syncthing.") {
return Err(anyhow!("Skipping Syncthing temporary file"));
}
if path.extension().and_then(|ext| ext.to_str()) != Some("mp4") {
return Err(anyhow!("Ignoring non-MP4 file"));
}
if !path.exists() {
return Err(anyhow!("File no longer exists: {}", path.display()));
}
// Note: File stability is now handled by the caller
println!("Processing sermon: {}", path.display());
// Get the filename
let filename = path.file_name()
.ok_or_else(|| anyhow!("Invalid filename"))?
.to_str()
.ok_or_else(|| anyhow!("Invalid UTF-8 in filename"))?;
// Extract date, title, and speaker from filename
let date = self.extract_date_from_filename(filename).await?;
let (title, speaker) = self.extract_title_and_speaker(filename).await?;
// Create date-based directory structure
let year_dir = self.output_path.join(date.format("%Y").to_string());
let month_dir = year_dir.join(format!("{}-{}",
date.format("%m"), // numeric month (12)
date.format("%B") // full month name (December)
));
// Create directories if they don't exist
tokio::fs::create_dir_all(&month_dir).await?;
// Keep original filename but ensure .mp4 extension
let output_file = month_dir.join(filename);
println!("Converting to AV1 and saving to: {}", output_file.display());
// Convert video using shared crate
self.video_converter.convert_video(&path, &output_file).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());
// 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
}
}