use std::path::{Path, PathBuf};
use walkdir::WalkDir;
use crate::Result;
use crate::core::uuidv7::Uuidv7Generator;
#[derive(Debug, Clone)]
pub struct MediaFile {
pub id: String,
pub path: PathBuf,
pub file_type: MediaFileType,
pub size: u64,
pub name: String,
pub extension: String,
pub relative_path: String,
}
pub fn generate_file_id(generator: &mut Uuidv7Generator) -> String {
format!("file_{}", generator.next_id().hyphenated())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn create_test_files(dir: &std::path::Path) {
let _ = fs::write(dir.join("video1.mp4"), b"");
let _ = fs::write(dir.join("video2.mkv"), b"");
let _ = fs::write(dir.join("subtitle1.srt"), b"");
let sub = dir.join("season1");
fs::create_dir_all(&sub).unwrap();
let _ = fs::write(sub.join("episode1.mp4"), b"");
let _ = fs::write(sub.join("episode1.srt"), b"");
let _ = fs::write(dir.join("note.txt"), b"");
}
#[test]
fn test_file_discovery_non_recursive() {
let temp = TempDir::new().unwrap();
create_test_files(temp.path());
let disco = FileDiscovery::new();
let files = disco.scan_directory(temp.path(), false).unwrap();
let vids = files
.iter()
.filter(|f| matches!(f.file_type, MediaFileType::Video))
.count();
let subs = files
.iter()
.filter(|f| matches!(f.file_type, MediaFileType::Subtitle))
.count();
assert_eq!(vids, 2);
assert_eq!(subs, 1);
assert!(!files.iter().any(|f| f.relative_path.contains("episode1")));
}
#[test]
fn test_file_discovery_recursive() {
let temp = TempDir::new().unwrap();
create_test_files(temp.path());
let disco = FileDiscovery::new();
let files = disco.scan_directory(temp.path(), true).unwrap();
let vids = files
.iter()
.filter(|f| matches!(f.file_type, MediaFileType::Video))
.count();
let subs = files
.iter()
.filter(|f| matches!(f.file_type, MediaFileType::Subtitle))
.count();
assert_eq!(vids, 3);
assert_eq!(subs, 2);
assert!(files.iter().any(|f| f.relative_path.contains("episode1")));
}
#[test]
fn test_file_classification_and_extensions() {
let temp = TempDir::new().unwrap();
let v = temp.path().join("t.mp4");
fs::write(&v, b"").unwrap();
let s = temp.path().join("t.srt");
fs::write(&s, b"").unwrap();
let x = temp.path().join("t.txt");
fs::write(&x, b"").unwrap();
let disco = FileDiscovery::new();
let vf = disco
.classify_file(&v, temp.path(), &mut Uuidv7Generator::new())
.unwrap()
.unwrap();
assert!(matches!(vf.file_type, MediaFileType::Video));
assert_eq!(vf.name, "t.mp4");
let sf = disco
.classify_file(&s, temp.path(), &mut Uuidv7Generator::new())
.unwrap()
.unwrap();
assert!(matches!(sf.file_type, MediaFileType::Subtitle));
assert_eq!(sf.name, "t.srt");
let none = disco
.classify_file(&x, temp.path(), &mut Uuidv7Generator::new())
.unwrap();
assert!(none.is_none());
assert!(disco.video_extensions.contains(&"mp4".to_string()));
assert!(disco.subtitle_extensions.contains(&"srt".to_string()));
}
#[test]
fn test_empty_and_nonexistent_directory() {
let temp = TempDir::new().unwrap();
let disco = FileDiscovery::new();
let files = disco.scan_directory(temp.path(), false).unwrap();
assert!(files.is_empty());
let res = disco.scan_directory(&std::path::Path::new("/nonexistent/path"), false);
assert!(res.is_err());
}
}
#[cfg(test)]
mod id_tests {
use super::*;
use crate::core::uuidv7::unix_time_ms;
use std::fs;
use tempfile::TempDir;
fn parse_file_id(id: &str) -> uuid::Uuid {
let stripped = id
.strip_prefix("file_")
.expect("file id must begin with `file_`");
uuid::Uuid::parse_str(stripped).expect("file id must contain a valid UUID")
}
#[test]
fn test_media_file_structure_with_unique_id() {
let temp = TempDir::new().unwrap();
let video_path = temp.path().join("[Test][01].mkv");
fs::write(&video_path, b"dummy content").unwrap();
let disco = FileDiscovery::new();
let files = disco.scan_directory(temp.path(), false).unwrap();
let video_file = files
.iter()
.find(|f| matches!(f.file_type, MediaFileType::Video))
.unwrap();
assert!(!video_file.id.is_empty());
assert!(video_file.id.starts_with("file_"));
assert_eq!(video_file.id.len(), 41);
let parsed = parse_file_id(&video_file.id);
assert_eq!(parsed.get_version_num(), 7);
assert_eq!(video_file.name, "[Test][01].mkv");
assert_eq!(video_file.extension, "mkv");
assert_eq!(video_file.relative_path, "[Test][01].mkv");
}
#[test]
fn test_uuidv7_id_generation() {
let mut gen1 = Uuidv7Generator::new();
let id1 = generate_file_id(&mut gen1);
assert!(id1.starts_with("file_"));
assert_eq!(id1.len(), 41);
let parsed1 = parse_file_id(&id1);
assert_eq!(parsed1.get_version_num(), 7);
let id2 = generate_file_id(&mut gen1);
let parsed2 = parse_file_id(&id2);
assert_eq!(parsed2.get_version_num(), 7);
assert!(
unix_time_ms(&parsed2) > unix_time_ms(&parsed1),
"second id's unix_time_ts must strictly exceed the first"
);
}
#[test]
fn test_recursive_mode_with_unique_ids() {
let temp = TempDir::new().unwrap();
let sub_dir = temp.path().join("season1");
fs::create_dir_all(&sub_dir).unwrap();
let video1 = temp.path().join("movie.mkv");
let video2 = sub_dir.join("episode1.mkv");
fs::write(&video1, b"content1").unwrap();
fs::write(&video2, b"content2").unwrap();
let disco = FileDiscovery::new();
let files = disco.scan_directory(temp.path(), true).unwrap();
let root_video = files.iter().find(|f| f.name == "movie.mkv").unwrap();
let sub_video = files.iter().find(|f| f.name == "episode1.mkv").unwrap();
assert_ne!(root_video.id, sub_video.id);
assert_eq!(root_video.id.len(), 41);
assert_eq!(sub_video.id.len(), 41);
assert_eq!(parse_file_id(&root_video.id).get_version_num(), 7);
assert_eq!(parse_file_id(&sub_video.id).get_version_num(), 7);
assert_eq!(root_video.relative_path, "movie.mkv");
assert_eq!(sub_video.relative_path, "season1/episode1.mkv");
}
#[test]
fn test_uuidv7_id_shape_basic() {
let mut generator = Uuidv7Generator::new();
let id = generate_file_id(&mut generator);
assert!(id.starts_with("file_"));
assert_eq!(id.len(), 41);
assert_eq!(parse_file_id(&id).get_version_num(), 7);
}
}
impl Default for FileDiscovery {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub enum MediaFileType {
Video,
Subtitle,
}
pub struct FileDiscovery {
video_extensions: Vec<String>,
subtitle_extensions: Vec<String>,
}
impl FileDiscovery {
pub fn new() -> Self {
Self {
video_extensions: vec![
"mp4".to_string(),
"mkv".to_string(),
"avi".to_string(),
"mov".to_string(),
"wmv".to_string(),
"flv".to_string(),
"m4v".to_string(),
"webm".to_string(),
],
subtitle_extensions: vec![
"srt".to_string(),
"ass".to_string(),
"vtt".to_string(),
"sub".to_string(),
"ssa".to_string(),
"idx".to_string(),
],
}
}
pub fn scan_directory(&self, root_path: &Path, recursive: bool) -> Result<Vec<MediaFile>> {
let mut files = Vec::new();
let mut id_gen = Uuidv7Generator::new();
let walker = if recursive {
WalkDir::new(root_path).into_iter()
} else {
WalkDir::new(root_path).max_depth(1).into_iter()
};
for entry in walker {
let entry = entry?;
let path = entry.path();
let ft = entry.file_type();
if ft.is_symlink() {
log::debug!("Skipping symlink: {}", path.display());
continue;
}
if ft.is_file() {
if let Some(media_file) = self.classify_file(path, root_path, &mut id_gen)? {
files.push(media_file);
}
}
}
Ok(files)
}
pub fn scan_file_list(&self, file_paths: &[PathBuf]) -> Result<Vec<MediaFile>> {
let mut media_files = Vec::new();
let mut id_gen = Uuidv7Generator::new();
for path in file_paths {
if !path.exists() {
continue; }
if !path.is_file() {
continue; }
if let Some(extension) = path.extension().and_then(|e| e.to_str()) {
let extension_lower = extension.to_lowercase();
let file_type = if self.video_extensions.contains(&extension_lower) {
MediaFileType::Video
} else if self.subtitle_extensions.contains(&extension_lower) {
MediaFileType::Subtitle
} else {
continue; };
if let Ok(metadata) = path.metadata() {
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
let relative_path = name.clone();
let media_file = MediaFile {
id: generate_file_id(&mut id_gen),
path: path.clone(),
file_type,
size: metadata.len(),
name,
extension: extension_lower,
relative_path,
};
media_files.push(media_file);
}
}
}
Ok(media_files)
}
fn classify_file(
&self,
path: &Path,
scan_root: &Path,
id_gen: &mut Uuidv7Generator,
) -> Result<Option<MediaFile>> {
let extension = path
.extension()
.and_then(|ext| ext.to_str())
.map(|s| s.to_lowercase())
.unwrap_or_default();
let file_type = if self.video_extensions.contains(&extension) {
MediaFileType::Video
} else if self.subtitle_extensions.contains(&extension) {
MediaFileType::Subtitle
} else {
return Ok(None);
};
let metadata = std::fs::metadata(path)?;
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default()
.to_string();
let relative_path = path
.strip_prefix(scan_root)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/");
let id = generate_file_id(id_gen);
Ok(Some(MediaFile {
id,
path: path.to_path_buf(),
file_type,
size: metadata.len(),
name,
extension,
relative_path,
}))
}
}