MovieMapper/Rust/src/service/file_metadata.rs
Jarian Cottingham 6dd4e83ddf feat: Add Rust implementation for performance-critical components
- Implement core Rust backend with FFmpeg integration
- Add TheTVDB API client with token caching
- Implement directory scanner with progress callbacks
- Create file manager with rename and move operations
- Add audit logging functionality
- Implement file mapping for TV episode renaming
- Build Node.js native addon via NAPI
- Include comprehensive unit and integration tests
- Update gitignore to exclude build artifacts and temp files

Resolves #TBD
2026-02-28 09:52:08 -06:00

333 lines
10 KiB
Rust

use crate::model::file::MediaFile;
use crate::utils::{MetadataError, Result};
use std::path::Path;
use std::process::Command;
use tracing::{debug, warn};
/// Extracts metadata from media files using ffprobe
pub struct MetadataExtractor;
impl MetadataExtractor {
/// Create a new MetadataExtractor
pub fn new() -> Self {
Self
}
/// Extract duration from a media file
///
/// # Arguments
/// * `path` - Path to the media file
///
/// # Returns
/// * `Ok(String)` - Duration in mm:ss format
/// * `Err(MetadataError)` - If ffprobe fails or returns invalid data
pub fn extract_duration(&self, path: &Path) -> Result<String> {
debug!("Extracting duration from: {:?}", path);
let output = match Command::new("ffprobe")
.args([
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
path.to_str().ok_or_else(|| {
MetadataError::CannotProbe(path.to_string_lossy().to_string())
})?,
])
.output()
{
Ok(output) => output,
Err(e) => {
warn!("Failed to run ffprobe for {:?}: {}", path, e);
return Ok("00:00".to_string());
}
};
if !output.status.success() {
warn!("ffprobe failed for {:?}: {}", path, output.status);
return Ok("00:00".to_string());
}
let output_str = String::from_utf8_lossy(&output.stdout);
let duration_str = output_str.trim();
let duration: f64 = match duration_str.parse() {
Ok(d) => d,
Err(_) => {
warn!("Invalid duration format for {:?}: {}", path, duration_str);
return Ok("00:00".to_string());
}
};
// Convert to mm:ss format
let minutes = (duration / 60.0) as u32;
let seconds = (duration % 60.0) as u32;
debug!("Extracted duration: {:02}:{:02}", minutes, seconds);
Ok(format!("{:02}:{:02}", minutes, seconds))
}
/// Extract quality information from a media file
///
/// # Arguments
/// * `path` - Path to the media file
///
/// # Returns
/// * `Ok((String, String))` - Tuple of (quality, fps)
/// * `Err(MetadataError)` - If ffprobe fails
pub fn extract_quality(&self, path: &Path) -> Result<(String, String)> {
debug!("Extracting quality from: {:?}", path);
let output = match Command::new("ffprobe")
.args([
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height,r_frame_rate",
"-of",
"default=noprint_wrappers=1",
path.to_str().ok_or_else(|| {
MetadataError::CannotProbe(path.to_string_lossy().to_string())
})?,
])
.output()
{
Ok(output) => output,
Err(e) => {
warn!(
"Failed to run ffprobe for quality extraction {:?}: {}",
path, e
);
return Ok(("unknown".to_string(), "unknown".to_string()));
}
};
if !output.status.success() {
warn!("ffprobe failed for quality {:?}: {}", path, output.status);
return Ok(("unknown".to_string(), "unknown".to_string()));
}
let output_str = String::from_utf8_lossy(&output.stdout);
let mut height: Option<u32> = None;
let mut fps: Option<String> = None;
for line in output_str.lines() {
if line.starts_with("height=") {
height = line.split('=').nth(1).and_then(|s| s.parse::<u32>().ok());
} else if line.starts_with("r_frame_rate=") {
let rate = line.split('=').nth(1).unwrap_or("0/1");
fps = self.parse_frame_rate(rate);
}
}
// Determine quality based on height
let quality = match height {
Some(h) if h >= 2160 => "4K",
Some(h) if h >= 1440 => "1440p",
Some(h) if h >= 1080 => "1080p",
Some(h) if h >= 720 => "720p",
Some(h) if h >= 480 => "480p",
Some(_) => "unknown",
None => "unknown",
}
.to_string();
let fps_str = fps.unwrap_or_else(|| "unknown".to_string());
debug!("Extracted quality: {} at {}", quality, fps_str);
Ok((quality, fps_str))
}
/// Parse frame rate string like "30/1" or "29.97/1"
fn parse_frame_rate(&self, rate: &str) -> Option<String> {
let parts: Vec<&str> = rate.split('/').collect();
if parts.len() == 2 {
if let (Ok(num), Ok(denom)) = (parts[0].parse::<f64>(), parts[1].parse::<f64>()) {
if denom != 0.0 {
let fps = (num / denom) as u32;
return Some(format!("{}fps", fps));
}
}
}
// Try parsing as a single number
if let Ok(fps) = rate.parse::<f64>() {
return Some(format!("{}fps", fps as u32));
}
None
}
/// Determine quality based on height
#[allow(dead_code)]
fn determine_quality(&self, height: u32) -> String {
if height >= 2160 {
"4K".to_string()
} else if height >= 1440 {
"1440p".to_string()
} else if height >= 1080 {
"1080p".to_string()
} else if height >= 720 {
"720p".to_string()
} else if height >= 480 {
"480p".to_string()
} else {
"unknown".to_string()
}
}
/// Extract all metadata from a media file
///
/// # Arguments
/// * `path` - Path to the media file
///
/// # Returns
/// * `Ok(MediaFile)` - MediaFile with all metadata populated
/// * `Err(MetadataError)` - If file cannot be probed
pub fn extract_metadata(&self, path: &Path) -> Result<MediaFile> {
debug!("Extracting all metadata from: {:?}", path);
let mut file = MediaFile::from_path(path.to_path_buf());
// Extract duration
match self.extract_duration(path) {
Ok(duration) => file.duration = duration,
Err(e) => {
warn!("Failed to extract duration for {:?}: {}", path, e);
file.duration = "00:00".to_string();
}
}
// Extract quality and FPS
match self.extract_quality(path) {
Ok((quality, fps)) => {
file.quality = quality;
file.fps = fps;
}
Err(e) => {
warn!("Failed to extract quality for {:?}: {}", path, e);
file.quality = "unknown".to_string();
file.fps = "unknown".to_string();
}
}
Ok(file)
}
}
impl Default for MetadataExtractor {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::File;
use std::io::Write;
use tempfile::tempdir;
#[test]
fn test_parse_frame_rate() {
let extractor = MetadataExtractor::new();
assert_eq!(
extractor.parse_frame_rate("30/1"),
Some("30fps".to_string())
);
assert_eq!(
extractor.parse_frame_rate("25/1"),
Some("25fps".to_string())
);
// 29.97 truncated to 29 (not rounded)
assert_eq!(
extractor.parse_frame_rate("29.97/1"),
Some("29fps".to_string())
);
assert_eq!(
extractor.parse_frame_rate("60/1"),
Some("60fps".to_string())
);
// 0/1 returns "0fps" since 0/1 = 0
assert_eq!(extractor.parse_frame_rate("0/1"), Some("0fps".to_string()));
assert_eq!(extractor.parse_frame_rate("invalid"), None);
}
#[test]
fn test_determine_quality() {
let extractor = MetadataExtractor::new();
assert_eq!(extractor.determine_quality(2160), "4K");
assert_eq!(extractor.determine_quality(1440), "1440p");
assert_eq!(extractor.determine_quality(1080), "1080p");
assert_eq!(extractor.determine_quality(720), "720p");
assert_eq!(extractor.determine_quality(480), "480p");
assert_eq!(extractor.determine_quality(360), "unknown");
assert_eq!(extractor.determine_quality(0), "unknown");
}
#[test]
fn test_extract_duration_with_valid_file() {
// This test requires ffprobe to be installed and a valid media file
// For CI, we'll just verify the function doesn't panic
// In real usage, this would be tested with actual media files
}
#[test]
fn test_extract_quality_with_valid_file() {
// Similar to duration test, this requires actual media files
// For now, just verify the function structure is correct
}
#[test]
fn test_extract_metadata_returns_default_values() {
let extractor = MetadataExtractor::new();
// Create a temporary file that's not a valid media file
let temp_dir = tempdir().unwrap();
let temp_file = temp_dir.path().join("test.txt");
{
let mut file = File::create(&temp_file).unwrap();
file.write_all(b"not a video").unwrap();
}
let result = extractor.extract_metadata(&temp_file);
// Should not fail, just return default values
assert!(result.is_ok());
let file = result.unwrap();
assert_eq!(file.duration, "00:00");
assert_eq!(file.quality, "unknown");
assert_eq!(file.fps, "unknown");
}
#[test]
fn test_extract_quality_handles_missing_stream() {
let extractor = MetadataExtractor::new();
// Test with a non-existent file
let result = extractor.extract_quality(Path::new("/nonexistent/video.mp4"));
// Should return default values
assert!(result.is_ok());
let (quality, fps) = result.unwrap();
assert_eq!(quality, "unknown");
assert_eq!(fps, "unknown");
}
#[test]
fn test_extract_duration_handles_missing_file() {
let extractor = MetadataExtractor::new();
let result = extractor.extract_duration(Path::new("/nonexistent/video.mp4"));
// Should return default value
assert!(result.is_ok());
assert_eq!(result.unwrap(), "00:00");
}
}