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, } 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> { // 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 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 = 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> { 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 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 = 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> = 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> = 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> = 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> = 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> = 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> = 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> = scanner .scan_directory(Path::new("/nonexistent/path"), None) .await; // Should return an error for non-existent directory assert!(result.is_err()); } }