audiobook_forge/core/
scanner.rs1use crate::models::{BookFolder, BookCase, Config};
4use anyhow::{Context, Result};
5use std::path::Path;
6use walkdir::WalkDir;
7
8pub struct Scanner {
10 cover_filenames: Vec<String>,
12 auto_extract_cover: bool,
14}
15
16impl Scanner {
17 pub fn new() -> Self {
19 Self {
20 cover_filenames: vec![
21 "cover.jpg".to_string(),
22 "folder.jpg".to_string(),
23 "cover.png".to_string(),
24 "folder.png".to_string(),
25 ],
26 auto_extract_cover: true,
27 }
28 }
29
30 pub fn with_cover_filenames(cover_filenames: Vec<String>) -> Self {
32 Self {
33 cover_filenames,
34 auto_extract_cover: true,
35 }
36 }
37
38 pub fn from_config(config: &Config) -> Self {
40 Self {
41 cover_filenames: config.metadata.cover_filenames.clone(),
42 auto_extract_cover: config.metadata.auto_extract_cover,
43 }
44 }
45
46 pub fn scan_directory(&self, root: &Path) -> Result<Vec<BookFolder>> {
48 if !root.exists() {
49 anyhow::bail!("Directory does not exist: {}", root.display());
50 }
51
52 if !root.is_dir() {
53 anyhow::bail!("Path is not a directory: {}", root.display());
54 }
55
56 let mut book_folders = Vec::new();
57
58 for entry in WalkDir::new(root)
61 .max_depth(2)
62 .min_depth(1)
63 .into_iter()
64 .filter_entry(|e| e.file_type().is_dir())
65 {
66 let entry = entry.context("Failed to read directory entry")?;
67 let path = entry.path();
68
69 if self.is_hidden(path) {
71 continue;
72 }
73
74 if let Some(book) = self.scan_folder(path)? {
76 book_folders.push(book);
77 }
78 }
79
80 Ok(book_folders)
81 }
82
83 pub fn scan_single_directory(&self, path: &Path) -> Result<BookFolder> {
85 if !path.exists() {
86 anyhow::bail!("Directory does not exist: {}", path.display());
87 }
88
89 if !path.is_dir() {
90 anyhow::bail!("Path is not a directory: {}", path.display());
91 }
92
93 if let Some(book) = self.scan_folder(path)? {
95 Ok(book)
96 } else {
97 anyhow::bail!("Current directory does not contain valid audiobook files");
98 }
99 }
100
101 fn scan_folder(&self, path: &Path) -> Result<Option<BookFolder>> {
103 let mut book = BookFolder::new(path.to_path_buf());
104
105 for entry in std::fs::read_dir(path).context("Failed to read directory")? {
107 let entry = entry.context("Failed to read directory entry")?;
108 let file_path = entry.path();
109
110 if !file_path.is_file() {
111 continue;
112 }
113
114 let extension = file_path
115 .extension()
116 .and_then(|s| s.to_str())
117 .map(|s| s.to_lowercase());
118
119 match extension.as_deref() {
120 Some("mp3") => {
121 book.mp3_files.push(file_path);
122 }
123 Some("m4b") => {
124 book.m4b_files.push(file_path);
125 }
126 Some("m4a") => {
127 book.mp3_files.push(file_path);
129 }
130 Some("cue") => {
131 book.cue_file = Some(file_path);
132 }
133 Some("jpg") | Some("png") | Some("jpeg") => {
134 if book.cover_file.is_none() {
136 if self.is_cover_art(&file_path) {
137 book.cover_file = Some(file_path);
138 }
139 }
140 }
141 _ => {}
142 }
143 }
144
145 book.classify();
147
148 if matches!(book.case, BookCase::A | BookCase::B | BookCase::C | BookCase::E) {
150 crate::utils::natural_sort(&mut book.mp3_files);
152
153 if book.case == BookCase::E {
155 crate::utils::sort_by_part_number(&mut book.m4b_files);
156 }
157
158 if self.auto_extract_cover && book.cover_file.is_none() {
160 let first_audio = if !book.mp3_files.is_empty() {
162 book.mp3_files.first()
163 } else if !book.m4b_files.is_empty() {
164 book.m4b_files.first()
165 } else {
166 None
167 };
168
169 if let Some(audio_file) = first_audio {
170 let extracted_cover = path.join(".extracted_cover.jpg");
172
173 match crate::audio::extract_embedded_cover(audio_file, &extracted_cover) {
174 Ok(true) => {
175 tracing::info!(
176 "Extracted embedded cover from: {}",
177 audio_file.file_name().unwrap_or_default().to_string_lossy()
178 );
179 book.cover_file = Some(extracted_cover);
180 }
181 Ok(false) => {
182 tracing::debug!("No embedded cover found in first audio file");
183 }
184 Err(e) => {
185 tracing::warn!("Failed to extract embedded cover: {}", e);
186 }
187 }
188 }
189 }
190
191 Ok(Some(book))
192 } else {
193 Ok(None)
194 }
195 }
196
197 fn is_hidden(&self, path: &Path) -> bool {
199 path.file_name()
200 .and_then(|s| s.to_str())
201 .map(|s| s.starts_with('.'))
202 .unwrap_or(false)
203 }
204
205 fn is_cover_art(&self, path: &Path) -> bool {
207 if let Some(filename) = path.file_name().and_then(|s| s.to_str()) {
208 let filename_lower = filename.to_lowercase();
209 self.cover_filenames
210 .iter()
211 .any(|cover| cover.to_lowercase() == filename_lower)
212 } else {
213 false
214 }
215 }
216}
217
218impl Default for Scanner {
219 fn default() -> Self {
220 Self::new()
221 }
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227 use std::fs;
228 use tempfile::tempdir;
229
230 #[test]
231 fn test_scanner_creation() {
232 let scanner = Scanner::new();
233 assert_eq!(scanner.cover_filenames.len(), 4);
234 }
235
236 #[test]
237 fn test_scanner_with_custom_covers() {
238 let scanner = Scanner::with_cover_filenames(vec!["custom.jpg".to_string()]);
239 assert_eq!(scanner.cover_filenames.len(), 1);
240 assert_eq!(scanner.cover_filenames[0], "custom.jpg");
241 }
242
243 #[test]
244 fn test_scan_empty_directory() {
245 let dir = tempdir().unwrap();
246 let scanner = Scanner::new();
247 let books = scanner.scan_directory(dir.path()).unwrap();
248 assert_eq!(books.len(), 0);
249 }
250
251 #[test]
252 fn test_scan_directory_with_audiobook() {
253 let dir = tempdir().unwrap();
254 let book_dir = dir.path().join("Test Book");
255 fs::create_dir(&book_dir).unwrap();
256
257 fs::write(book_dir.join("01.mp3"), b"fake mp3 data").unwrap();
259 fs::write(book_dir.join("02.mp3"), b"fake mp3 data").unwrap();
260 fs::write(book_dir.join("cover.jpg"), b"fake image data").unwrap();
261
262 let scanner = Scanner::new();
263 let books = scanner.scan_directory(dir.path()).unwrap();
264
265 assert_eq!(books.len(), 1);
266 assert_eq!(books[0].name, "Test Book");
267 assert_eq!(books[0].case, BookCase::A); assert_eq!(books[0].mp3_files.len(), 2);
269 assert!(books[0].cover_file.is_some());
270 }
271
272 #[test]
273 fn test_hidden_directory_skipped() {
274 let dir = tempdir().unwrap();
275 let hidden_dir = dir.path().join(".hidden");
276 fs::create_dir(&hidden_dir).unwrap();
277 fs::write(hidden_dir.join("01.mp3"), b"fake mp3 data").unwrap();
278
279 let scanner = Scanner::new();
280 let books = scanner.scan_directory(dir.path()).unwrap();
281
282 assert_eq!(books.len(), 0);
284 }
285}