audiobook_forge/core/
scanner.rs1use crate::models::{BookFolder, BookCase};
4use anyhow::{Context, Result};
5use std::path::Path;
6use walkdir::WalkDir;
7
8pub struct Scanner {
10 cover_filenames: Vec<String>,
12}
13
14impl Scanner {
15 pub fn new() -> Self {
17 Self {
18 cover_filenames: vec![
19 "cover.jpg".to_string(),
20 "folder.jpg".to_string(),
21 "cover.png".to_string(),
22 "folder.png".to_string(),
23 ],
24 }
25 }
26
27 pub fn with_cover_filenames(cover_filenames: Vec<String>) -> Self {
29 Self { cover_filenames }
30 }
31
32 pub fn scan_directory(&self, root: &Path) -> Result<Vec<BookFolder>> {
34 if !root.exists() {
35 anyhow::bail!("Directory does not exist: {}", root.display());
36 }
37
38 if !root.is_dir() {
39 anyhow::bail!("Path is not a directory: {}", root.display());
40 }
41
42 let mut book_folders = Vec::new();
43
44 for entry in WalkDir::new(root)
47 .max_depth(2)
48 .min_depth(1)
49 .into_iter()
50 .filter_entry(|e| e.file_type().is_dir())
51 {
52 let entry = entry.context("Failed to read directory entry")?;
53 let path = entry.path();
54
55 if self.is_hidden(path) {
57 continue;
58 }
59
60 if let Some(book) = self.scan_folder(path)? {
62 book_folders.push(book);
63 }
64 }
65
66 Ok(book_folders)
67 }
68
69 pub fn scan_single_directory(&self, path: &Path) -> Result<BookFolder> {
71 if !path.exists() {
72 anyhow::bail!("Directory does not exist: {}", path.display());
73 }
74
75 if !path.is_dir() {
76 anyhow::bail!("Path is not a directory: {}", path.display());
77 }
78
79 if let Some(book) = self.scan_folder(path)? {
81 Ok(book)
82 } else {
83 anyhow::bail!("Current directory does not contain valid audiobook files");
84 }
85 }
86
87 fn scan_folder(&self, path: &Path) -> Result<Option<BookFolder>> {
89 let mut book = BookFolder::new(path.to_path_buf());
90
91 for entry in std::fs::read_dir(path).context("Failed to read directory")? {
93 let entry = entry.context("Failed to read directory entry")?;
94 let file_path = entry.path();
95
96 if !file_path.is_file() {
97 continue;
98 }
99
100 let extension = file_path
101 .extension()
102 .and_then(|s| s.to_str())
103 .map(|s| s.to_lowercase());
104
105 match extension.as_deref() {
106 Some("mp3") => {
107 book.mp3_files.push(file_path);
108 }
109 Some("m4b") => {
110 book.m4b_files.push(file_path);
111 }
112 Some("m4a") => {
113 book.mp3_files.push(file_path);
115 }
116 Some("cue") => {
117 book.cue_file = Some(file_path);
118 }
119 Some("jpg") | Some("png") | Some("jpeg") => {
120 if book.cover_file.is_none() {
122 if self.is_cover_art(&file_path) {
123 book.cover_file = Some(file_path);
124 }
125 }
126 }
127 _ => {}
128 }
129 }
130
131 book.classify();
133
134 if matches!(book.case, BookCase::A | BookCase::B | BookCase::C) {
136 crate::utils::natural_sort(&mut book.mp3_files);
138 Ok(Some(book))
139 } else {
140 Ok(None)
141 }
142 }
143
144 fn is_hidden(&self, path: &Path) -> bool {
146 path.file_name()
147 .and_then(|s| s.to_str())
148 .map(|s| s.starts_with('.'))
149 .unwrap_or(false)
150 }
151
152 fn is_cover_art(&self, path: &Path) -> bool {
154 if let Some(filename) = path.file_name().and_then(|s| s.to_str()) {
155 let filename_lower = filename.to_lowercase();
156 self.cover_filenames
157 .iter()
158 .any(|cover| cover.to_lowercase() == filename_lower)
159 } else {
160 false
161 }
162 }
163}
164
165impl Default for Scanner {
166 fn default() -> Self {
167 Self::new()
168 }
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174 use std::fs;
175 use tempfile::tempdir;
176
177 #[test]
178 fn test_scanner_creation() {
179 let scanner = Scanner::new();
180 assert_eq!(scanner.cover_filenames.len(), 4);
181 }
182
183 #[test]
184 fn test_scanner_with_custom_covers() {
185 let scanner = Scanner::with_cover_filenames(vec!["custom.jpg".to_string()]);
186 assert_eq!(scanner.cover_filenames.len(), 1);
187 assert_eq!(scanner.cover_filenames[0], "custom.jpg");
188 }
189
190 #[test]
191 fn test_scan_empty_directory() {
192 let dir = tempdir().unwrap();
193 let scanner = Scanner::new();
194 let books = scanner.scan_directory(dir.path()).unwrap();
195 assert_eq!(books.len(), 0);
196 }
197
198 #[test]
199 fn test_scan_directory_with_audiobook() {
200 let dir = tempdir().unwrap();
201 let book_dir = dir.path().join("Test Book");
202 fs::create_dir(&book_dir).unwrap();
203
204 fs::write(book_dir.join("01.mp3"), b"fake mp3 data").unwrap();
206 fs::write(book_dir.join("02.mp3"), b"fake mp3 data").unwrap();
207 fs::write(book_dir.join("cover.jpg"), b"fake image data").unwrap();
208
209 let scanner = Scanner::new();
210 let books = scanner.scan_directory(dir.path()).unwrap();
211
212 assert_eq!(books.len(), 1);
213 assert_eq!(books[0].name, "Test Book");
214 assert_eq!(books[0].case, BookCase::A); assert_eq!(books[0].mp3_files.len(), 2);
216 assert!(books[0].cover_file.is_some());
217 }
218
219 #[test]
220 fn test_hidden_directory_skipped() {
221 let dir = tempdir().unwrap();
222 let hidden_dir = dir.path().join(".hidden");
223 fs::create_dir(&hidden_dir).unwrap();
224 fs::write(hidden_dir.join("01.mp3"), b"fake mp3 data").unwrap();
225
226 let scanner = Scanner::new();
227 let books = scanner.scan_directory(dir.path()).unwrap();
228
229 assert_eq!(books.len(), 0);
231 }
232}