MovieMapper/Rust/src/service/file_scanner.rs
Jarian Cottingham 68eb4d9f43 feat: Add Rust UI and backend implementation with project infrastructure
Adds the new Rust-based UI (iced), backend service, shared Swift models,
CI/CD workflows, build scripts, and project documentation.
2026-08-19 21:22:08 -05:00

529 lines
18 KiB
Rust

use crate::model::file::MediaFile;
use crate::utils::{Result, ScannerError};
use std::fs;
use std::path::Path;
use tracing::{debug, warn};
/// Scans a directory for media files and folders
pub struct FileScanner {
extensions: Vec<String>,
}
impl FileScanner {
/// Create a new FileScanner with default media extensions
pub fn new() -> Self {
Self {
extensions: vec![
".mp4".to_string(),
".mkv".to_string(),
".avi".to_string(),
".mov".to_string(),
".flv".to_string(),
".webm".to_string(),
],
}
}
/// Check if a file has a media extension
pub fn is_media_file(&self, path: &Path) -> bool {
path.extension()
.and_then(|ext| ext.to_str())
.map(|ext| {
self.extensions
.contains(&format!(".{}", ext.to_lowercase()))
})
.unwrap_or(false)
}
/// Scan a directory for media files and folders (non-recursive)
/// This version doesn't require a callback and can be used in async contexts
///
/// This method is designed to be Send-safe for use with async/await
pub async fn scan_directory_simple(&self, path: &Path) -> Result<Vec<MediaFile>> {
// Inline the scan logic directly to avoid the Send issue with callbacks
let mut items = Vec::new();
let mut file_count = 0;
// First, collect all entries to get a total count
let mut total_files = 0;
let mut dir_count = 0;
// Read directory entries
let entries = match fs::read_dir(path) {
Ok(e) => e,
Err(e) => {
if e.kind() == std::io::ErrorKind::PermissionDenied {
warn!("Permission denied reading directory: {:?}", path);
return Ok(Vec::new());
}
return Err(ScannerError::Io(e).into());
}
};
// First pass: count total files and directories
for entry in entries.flatten() {
if let Ok(file_type) = entry.file_type() {
let file_name = entry.file_name();
let file_name_str = file_name.to_string_lossy();
if file_name_str.starts_with('.') {
continue;
}
if file_type.is_dir() {
dir_count += 1;
} else if file_type.is_file() {
total_files += 1;
}
}
}
debug!(
"Directory scan: {} folders, {} media files to process",
dir_count, total_files
);
// Second pass: process entries (no callback needed)
let entries = match fs::read_dir(path) {
Ok(e) => e,
Err(e) => {
if e.kind() == std::io::ErrorKind::PermissionDenied {
warn!("Permission denied reading directory: {:?}", path);
return Ok(Vec::new());
}
return Err(ScannerError::Io(e).into());
}
};
for entry in entries.flatten() {
let file_type = match entry.file_type() {
Ok(t) => t,
Err(e) => {
warn!("Failed to get file type for {:?}: {}", entry.path(), e);
continue;
}
};
let file_name = entry.file_name();
let file_name_str = file_name.to_string_lossy();
// Skip hidden files
if file_name_str.starts_with('.') {
continue;
}
if file_type.is_dir() {
// Handle directory
let metadata = match entry.metadata() {
Ok(m) => m,
Err(e) => {
warn!(
"Failed to get metadata for directory {:?}: {}",
entry.path(),
e
);
continue;
}
};
items.push(MediaFile {
path: entry.path(),
name: file_name.to_string_lossy().to_string(),
size: 0,
modified: metadata
.modified()
.map(|m| {
// Convert SystemTime to DateTime<Utc>
use chrono::DateTime;
DateTime::from(m)
})
.unwrap_or_else(|_| chrono::Utc::now()),
duration: "".to_string(),
quality: "".to_string(),
fps: "".to_string(),
is_folder: true,
is_problematic: false,
tags: Vec::new(),
});
} else if file_type.is_file() && self.is_media_file(&entry.path()) {
// Handle media file
file_count += 1;
// Extract metadata
let mut file = MediaFile::from_path(entry.path());
// Update size and modified time
match entry.metadata() {
Ok(metadata) => {
file.size = metadata.len();
file.modified = metadata
.modified()
.map(|m| {
let sys_time: chrono::DateTime<chrono::Utc> = m.into();
sys_time
})
.unwrap_or_else(|_| chrono::Utc::now());
}
Err(e) => {
warn!("Failed to get metadata for {:?}: {}", entry.path(), e);
file.is_problematic = true;
}
}
items.push(file);
}
}
debug!("Directory scan complete: {} items found", items.len());
// Sort: folders first, then files
items.sort_by(|a, b| {
if a.is_folder && !b.is_folder {
std::cmp::Ordering::Less
} else if !a.is_folder && b.is_folder {
std::cmp::Ordering::Greater
} else {
a.name.cmp(&b.name)
}
});
Ok(items)
}
/// Scan a directory for media files and folders (non-recursive)
///
/// # Arguments
/// * `path` - The directory path to scan
/// * `progress_callback` - Optional mutable callback for progress updates
/// Signature: `FnMut(usize, usize, &str)` where:
/// - First usize: current file count
/// - Second usize: total files to process
/// - &str: current file name
#[allow(clippy::type_complexity)]
pub async fn scan_directory(
&self,
path: &Path,
mut progress_callback: Option<&mut dyn FnMut(usize, usize, &str)>,
) -> Result<Vec<MediaFile>> {
let mut items = Vec::new();
let mut file_count = 0;
// First, collect all entries to get a total count
let mut total_files = 0;
let mut dir_count = 0;
// Read directory entries
let entries = match fs::read_dir(path) {
Ok(e) => e,
Err(e) => {
if e.kind() == std::io::ErrorKind::PermissionDenied {
warn!("Permission denied reading directory: {:?}", path);
return Ok(Vec::new());
}
return Err(ScannerError::Io(e).into());
}
};
// First pass: count total files and directories
for entry in entries.flatten() {
if let Ok(file_type) = entry.file_type() {
let file_name = entry.file_name();
let file_name_str = file_name.to_string_lossy();
if file_name_str.starts_with('.') {
continue;
}
if file_type.is_dir() {
dir_count += 1;
} else if file_type.is_file() {
total_files += 1;
}
}
}
debug!(
"Directory scan: {} folders, {} media files to process",
dir_count, total_files
);
// Second pass: process entries
let entries = match fs::read_dir(path) {
Ok(e) => e,
Err(e) => {
if e.kind() == std::io::ErrorKind::PermissionDenied {
warn!("Permission denied reading directory: {:?}", path);
return Ok(Vec::new());
}
return Err(ScannerError::Io(e).into());
}
};
for entry in entries.flatten() {
let file_type = match entry.file_type() {
Ok(t) => t,
Err(e) => {
warn!("Failed to get file type for {:?}: {}", entry.path(), e);
continue;
}
};
let file_name = entry.file_name();
let file_name_str = file_name.to_string_lossy();
// Skip hidden files
if file_name_str.starts_with('.') {
continue;
}
if file_type.is_dir() {
// Handle directory
let metadata = match entry.metadata() {
Ok(m) => m,
Err(e) => {
warn!(
"Failed to get metadata for directory {:?}: {}",
entry.path(),
e
);
continue;
}
};
items.push(MediaFile {
path: entry.path(),
name: file_name.to_string_lossy().to_string(),
size: 0,
modified: metadata
.modified()
.map(|m| {
// Convert SystemTime to DateTime<Utc>
use chrono::DateTime;
DateTime::from(m)
})
.unwrap_or_else(|_| chrono::Utc::now()),
duration: "".to_string(),
quality: "".to_string(),
fps: "".to_string(),
is_folder: true,
is_problematic: false,
tags: Vec::new(),
});
} else if file_type.is_file() && self.is_media_file(&entry.path()) {
// Handle media file
file_count += 1;
if let Some(callback) = &mut progress_callback {
callback(file_count, total_files, &file_name_str);
}
// Extract metadata
let mut file = MediaFile::from_path(entry.path());
// Update size and modified time
match entry.metadata() {
Ok(metadata) => {
file.size = metadata.len();
file.modified = metadata
.modified()
.map(|m| {
let sys_time: chrono::DateTime<chrono::Utc> = m.into();
sys_time
})
.unwrap_or_else(|_| chrono::Utc::now());
}
Err(e) => {
warn!("Failed to get metadata for {:?}: {}", entry.path(), e);
file.is_problematic = true;
}
}
items.push(file);
}
}
debug!("Directory scan complete: {} items found", items.len());
// Sort: folders first, then files
items.sort_by(|a, b| {
if a.is_folder && !b.is_folder {
std::cmp::Ordering::Less
} else if !a.is_folder && b.is_folder {
std::cmp::Ordering::Greater
} else {
a.name.cmp(&b.name)
}
});
Ok(items)
}
}
impl Default for FileScanner {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::{self, File};
use tempfile::tempdir;
#[test]
fn test_is_media_file() {
let scanner = FileScanner::new();
assert!(scanner.is_media_file(Path::new("/test/video.mp4")));
assert!(scanner.is_media_file(Path::new("/test/video.MP4")));
assert!(scanner.is_media_file(Path::new("/test/video.mkv")));
assert!(scanner.is_media_file(Path::new("/test/video.avi")));
assert!(scanner.is_media_file(Path::new("/test/video.mov")));
assert!(scanner.is_media_file(Path::new("/test/video.flv")));
assert!(scanner.is_media_file(Path::new("/test/video.webm")));
assert!(!scanner.is_media_file(Path::new("/test/video.txt")));
assert!(!scanner.is_media_file(Path::new("/test/video.jpg")));
assert!(!scanner.is_media_file(Path::new("/test/folder")));
}
#[tokio::test]
async fn test_scan_directory_empty() {
let scanner = FileScanner::new();
let temp_dir = tempdir().unwrap();
let result: Result<Vec<MediaFile>> = scanner.scan_directory(temp_dir.path(), None).await;
assert!(result.is_ok());
assert_eq!(result.unwrap().len(), 0);
}
#[tokio::test]
async fn test_scan_directory_with_media_files() {
let scanner = FileScanner::new();
let temp_dir = tempdir().unwrap();
// Create some test media files
let media_files = vec!["video1.mp4", "video2.mkv", "video3.avi"];
for filename in &media_files {
let path = temp_dir.path().join(filename);
File::create(&path).unwrap();
}
let result: Result<Vec<MediaFile>> = scanner.scan_directory(temp_dir.path(), None).await;
let result = result.unwrap();
assert_eq!(result.len(), 3);
// Verify files are sorted
assert_eq!(result[0].name, "video1.mp4");
assert_eq!(result[1].name, "video2.mkv");
assert_eq!(result[2].name, "video3.avi");
}
#[tokio::test]
async fn test_scan_directory_with_folders() {
let scanner = FileScanner::new();
let temp_dir = tempdir().unwrap();
// Create folders
fs::create_dir(temp_dir.path().join("folder1")).unwrap();
fs::create_dir(temp_dir.path().join("folder2")).unwrap();
let result: Result<Vec<MediaFile>> = scanner.scan_directory(temp_dir.path(), None).await;
let result = result.unwrap();
// Folders should be first
assert_eq!(result.len(), 2);
assert!(result[0].is_folder);
assert!(result[1].is_folder);
}
#[tokio::test]
async fn test_scan_directory_with_mixed_content() {
let scanner = FileScanner::new();
let temp_dir = tempdir().unwrap();
// Create folders
fs::create_dir(temp_dir.path().join("movies")).unwrap();
fs::create_dir(temp_dir.path().join("tv")).unwrap();
// Create media files
let media_files = vec!["movie1.mp4", "movie2.mkv"];
for filename in &media_files {
let path = temp_dir.path().join(filename);
File::create(&path).unwrap();
}
let result: Result<Vec<MediaFile>> = scanner.scan_directory(temp_dir.path(), None).await;
let result = result.unwrap();
// Should have 4 items: 2 folders + 2 media files
assert_eq!(result.len(), 4);
// Folders should come first
assert!(result[0].is_folder);
assert!(result[1].is_folder);
assert!(!result[2].is_folder);
assert!(!result[3].is_folder);
}
#[tokio::test]
async fn test_scan_directory_with_progress_callback() {
let scanner = FileScanner::new();
let temp_dir = tempdir().unwrap();
// Create media files
let media_files = vec!["video1.mp4", "video2.mkv", "video3.avi"];
for filename in &media_files {
let path = temp_dir.path().join(filename);
File::create(&path).unwrap();
}
let mut progress_count = 0;
let mut progress_data = Vec::new();
let mut callback = |current: usize, total: usize, filename: &str| {
progress_count += 1;
progress_data.push((current, total, filename.to_string()));
};
let result: Result<Vec<MediaFile>> = scanner
.scan_directory(temp_dir.path(), Some(&mut callback))
.await;
let result = result.unwrap();
assert_eq!(progress_count, 3);
assert_eq!(result.len(), 3);
}
#[tokio::test]
async fn test_scan_directory_with_hidden_files() {
let scanner = FileScanner::new();
let temp_dir = tempdir().unwrap();
// Create media files (including hidden)
let media_files = vec![".hidden.mp4", "visible.mkv"];
for filename in &media_files {
let path = temp_dir.path().join(filename);
File::create(&path).unwrap();
}
let result: Result<Vec<MediaFile>> = scanner.scan_directory(temp_dir.path(), None).await;
let result = result.unwrap();
// Hidden files should be skipped
assert_eq!(result.len(), 1);
assert_eq!(result[0].name, "visible.mkv");
}
#[tokio::test]
async fn test_scan_directory_nonexistent() {
let scanner = FileScanner::new();
let result: Result<Vec<MediaFile>> = scanner
.scan_directory(Path::new("/nonexistent/path"), None)
.await;
// Should return an error for non-existent directory
assert!(result.is_err());
}
}