- 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
333 lines
10 KiB
Rust
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");
|
|
}
|
|
}
|