Adds the new Rust-based UI (iced), backend service, shared Swift models, CI/CD workflows, build scripts, and project documentation.
529 lines
18 KiB
Rust
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());
|
|
}
|
|
}
|