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) {
150 crate::utils::natural_sort(&mut book.mp3_files);
152
153 if self.auto_extract_cover
155 && book.cover_file.is_none()
156 && !book.mp3_files.is_empty()
157 {
158 let first_audio = book.mp3_files.first();
160
161 if let Some(audio_file) = first_audio {
162 let extracted_cover = path.join(".extracted_cover.jpg");
164
165 match crate::audio::extract_embedded_cover(audio_file, &extracted_cover) {
166 Ok(true) => {
167 tracing::info!(
168 "Extracted embedded cover from: {}",
169 audio_file.file_name().unwrap_or_default().to_string_lossy()
170 );
171 book.cover_file = Some(extracted_cover);
172 }
173 Ok(false) => {
174 tracing::debug!("No embedded cover found in first audio file");
175 }
176 Err(e) => {
177 tracing::warn!("Failed to extract embedded cover: {}", e);
178 }
179 }
180 }
181 }
182
183 Ok(Some(book))
184 } else {
185 Ok(None)
186 }
187 }
188
189 fn is_hidden(&self, path: &Path) -> bool {
191 path.file_name()
192 .and_then(|s| s.to_str())
193 .map(|s| s.starts_with('.'))
194 .unwrap_or(false)
195 }
196
197 fn is_cover_art(&self, path: &Path) -> bool {
199 if let Some(filename) = path.file_name().and_then(|s| s.to_str()) {
200 let filename_lower = filename.to_lowercase();
201 self.cover_filenames
202 .iter()
203 .any(|cover| cover.to_lowercase() == filename_lower)
204 } else {
205 false
206 }
207 }
208}
209
210impl Default for Scanner {
211 fn default() -> Self {
212 Self::new()
213 }
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219 use std::fs;
220 use tempfile::tempdir;
221
222 #[test]
223 fn test_scanner_creation() {
224 let scanner = Scanner::new();
225 assert_eq!(scanner.cover_filenames.len(), 4);
226 }
227
228 #[test]
229 fn test_scanner_with_custom_covers() {
230 let scanner = Scanner::with_cover_filenames(vec!["custom.jpg".to_string()]);
231 assert_eq!(scanner.cover_filenames.len(), 1);
232 assert_eq!(scanner.cover_filenames[0], "custom.jpg");
233 }
234
235 #[test]
236 fn test_scan_empty_directory() {
237 let dir = tempdir().unwrap();
238 let scanner = Scanner::new();
239 let books = scanner.scan_directory(dir.path()).unwrap();
240 assert_eq!(books.len(), 0);
241 }
242
243 #[test]
244 fn test_scan_directory_with_audiobook() {
245 let dir = tempdir().unwrap();
246 let book_dir = dir.path().join("Test Book");
247 fs::create_dir(&book_dir).unwrap();
248
249 fs::write(book_dir.join("01.mp3"), b"fake mp3 data").unwrap();
251 fs::write(book_dir.join("02.mp3"), b"fake mp3 data").unwrap();
252 fs::write(book_dir.join("cover.jpg"), b"fake image data").unwrap();
253
254 let scanner = Scanner::new();
255 let books = scanner.scan_directory(dir.path()).unwrap();
256
257 assert_eq!(books.len(), 1);
258 assert_eq!(books[0].name, "Test Book");
259 assert_eq!(books[0].case, BookCase::A); assert_eq!(books[0].mp3_files.len(), 2);
261 assert!(books[0].cover_file.is_some());
262 }
263
264 #[test]
265 fn test_hidden_directory_skipped() {
266 let dir = tempdir().unwrap();
267 let hidden_dir = dir.path().join(".hidden");
268 fs::create_dir(&hidden_dir).unwrap();
269 fs::write(hidden_dir.join("01.mp3"), b"fake mp3 data").unwrap();
270
271 let scanner = Scanner::new();
272 let books = scanner.scan_directory(dir.path()).unwrap();
273
274 assert_eq!(books.len(), 0);
276 }
277}