
- 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
143 lines
5.3 KiB
Rust
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
|
|
}
|
|
}
|