subx_cli/core/matcher/
discovery.rs

1use std::path::{Path, PathBuf};
2use walkdir::WalkDir;
3
4use crate::Result;
5
6/// 媒體檔案類型
7#[derive(Debug, Clone)]
8pub struct MediaFile {
9    pub path: PathBuf,
10    pub file_type: MediaFileType,
11    pub size: u64,
12    pub name: String,
13    pub extension: String,
14}
15
16// 單元測試: FileDiscovery 檔案匹配邏輯
17#[cfg(test)]
18mod tests {
19    use super::*;
20    use std::fs;
21    use tempfile::TempDir;
22
23    fn create_test_files(dir: &std::path::Path) {
24        let _ = fs::write(dir.join("video1.mp4"), b"");
25        let _ = fs::write(dir.join("video2.mkv"), b"");
26        let _ = fs::write(dir.join("subtitle1.srt"), b"");
27        let sub = dir.join("season1");
28        fs::create_dir_all(&sub).unwrap();
29        let _ = fs::write(sub.join("episode1.mp4"), b"");
30        let _ = fs::write(sub.join("episode1.srt"), b"");
31        let _ = fs::write(dir.join("note.txt"), b"");
32    }
33
34    #[test]
35    fn test_file_discovery_non_recursive() {
36        let temp = TempDir::new().unwrap();
37        create_test_files(temp.path());
38        let disco = FileDiscovery::new();
39        let files = disco.scan_directory(temp.path(), false).unwrap();
40        let vids = files
41            .iter()
42            .filter(|f| matches!(f.file_type, MediaFileType::Video))
43            .count();
44        let subs = files
45            .iter()
46            .filter(|f| matches!(f.file_type, MediaFileType::Subtitle))
47            .count();
48        assert_eq!(vids, 2);
49        assert_eq!(subs, 1);
50        assert!(!files.iter().any(|f| f.name == "episode1"));
51    }
52
53    #[test]
54    fn test_file_discovery_recursive() {
55        let temp = TempDir::new().unwrap();
56        create_test_files(temp.path());
57        let disco = FileDiscovery::new();
58        let files = disco.scan_directory(temp.path(), true).unwrap();
59        let vids = files
60            .iter()
61            .filter(|f| matches!(f.file_type, MediaFileType::Video))
62            .count();
63        let subs = files
64            .iter()
65            .filter(|f| matches!(f.file_type, MediaFileType::Subtitle))
66            .count();
67        assert_eq!(vids, 3);
68        assert_eq!(subs, 2);
69        assert!(files.iter().any(|f| f.name == "episode1"));
70    }
71
72    #[test]
73    fn test_file_classification_and_extensions() {
74        let temp = TempDir::new().unwrap();
75        let v = temp.path().join("t.mp4");
76        fs::write(&v, b"").unwrap();
77        let s = temp.path().join("t.srt");
78        fs::write(&s, b"").unwrap();
79        let x = temp.path().join("t.txt");
80        fs::write(&x, b"").unwrap();
81        let disco = FileDiscovery::new();
82        let vf = disco.classify_file(&v).unwrap().unwrap();
83        assert!(matches!(vf.file_type, MediaFileType::Video));
84        assert_eq!(vf.name, "t");
85        let sf = disco.classify_file(&s).unwrap().unwrap();
86        assert!(matches!(sf.file_type, MediaFileType::Subtitle));
87        assert_eq!(sf.name, "t");
88        let none = disco.classify_file(&x).unwrap();
89        assert!(none.is_none());
90        assert!(disco.video_extensions.contains(&"mp4".to_string()));
91        assert!(disco.subtitle_extensions.contains(&"srt".to_string()));
92    }
93
94    #[test]
95    fn test_empty_and_nonexistent_directory() {
96        let temp = TempDir::new().unwrap();
97        let disco = FileDiscovery::new();
98        let files = disco.scan_directory(temp.path(), false).unwrap();
99        assert!(files.is_empty());
100        let res = disco.scan_directory(&std::path::Path::new("/nonexistent/path"), false);
101        assert!(res.is_err());
102    }
103}
104
105impl Default for FileDiscovery {
106    fn default() -> Self {
107        Self::new()
108    }
109}
110
111/// 媒體檔案類型枚舉
112#[derive(Debug, Clone)]
113pub enum MediaFileType {
114    Video,
115    Subtitle,
116}
117
118/// 檔案探索器
119pub struct FileDiscovery {
120    video_extensions: Vec<String>,
121    subtitle_extensions: Vec<String>,
122}
123
124impl FileDiscovery {
125    /// 建立新的檔案探索器,預設辨識常見影片與字幕副檔名
126    pub fn new() -> Self {
127        Self {
128            video_extensions: vec![
129                "mp4".to_string(),
130                "mkv".to_string(),
131                "avi".to_string(),
132                "mov".to_string(),
133                "wmv".to_string(),
134                "flv".to_string(),
135                "m4v".to_string(),
136                "webm".to_string(),
137            ],
138            subtitle_extensions: vec![
139                "srt".to_string(),
140                "ass".to_string(),
141                "vtt".to_string(),
142                "sub".to_string(),
143                "ssa".to_string(),
144                "idx".to_string(),
145            ],
146        }
147    }
148
149    /// 掃描指定目錄,並回傳所有符合媒體類型的檔案清單
150    pub fn scan_directory(&self, path: &Path, recursive: bool) -> Result<Vec<MediaFile>> {
151        let mut files = Vec::new();
152
153        let walker = if recursive {
154            WalkDir::new(path).into_iter()
155        } else {
156            WalkDir::new(path).max_depth(1).into_iter()
157        };
158
159        for entry in walker {
160            let entry = entry?;
161            let path = entry.path();
162
163            if path.is_file() {
164                if let Some(media_file) = self.classify_file(path)? {
165                    files.push(media_file);
166                }
167            }
168        }
169
170        Ok(files)
171    }
172
173    /// 根據副檔名判別媒體檔案類型,並擷取基本屬性
174    fn classify_file(&self, path: &Path) -> Result<Option<MediaFile>> {
175        let extension = path
176            .extension()
177            .and_then(|ext| ext.to_str())
178            .map(|s| s.to_lowercase())
179            .unwrap_or_default();
180
181        let file_type = if self.video_extensions.contains(&extension) {
182            MediaFileType::Video
183        } else if self.subtitle_extensions.contains(&extension) {
184            MediaFileType::Subtitle
185        } else {
186            return Ok(None);
187        };
188
189        let metadata = std::fs::metadata(path)?;
190        let name = path
191            .file_stem()
192            .and_then(|stem| stem.to_str())
193            .unwrap_or_default()
194            .to_string();
195
196        Ok(Some(MediaFile {
197            path: path.to_path_buf(),
198            file_type,
199            size: metadata.len(),
200            name,
201            extension,
202        }))
203    }
204}