audiobook_forge/core/
scanner.rs

1//! Directory scanner for discovering audiobook folders
2
3use crate::models::{BookFolder, BookCase, Config};
4use anyhow::{Context, Result};
5use std::path::Path;
6use walkdir::WalkDir;
7
8/// Scanner for discovering audiobook folders in a directory tree
9pub struct Scanner {
10    /// Cover art filenames to search for
11    cover_filenames: Vec<String>,
12    /// Auto-extract embedded cover art
13    auto_extract_cover: bool,
14}
15
16impl Scanner {
17    /// Create a new scanner with default cover filenames
18    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    /// Create scanner with custom cover filenames
31    pub fn with_cover_filenames(cover_filenames: Vec<String>) -> Self {
32        Self {
33            cover_filenames,
34            auto_extract_cover: true,
35        }
36    }
37
38    /// Create scanner from config
39    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    /// Scan a directory for audiobook folders
47    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        // Walk through directory tree, but only go 2 levels deep
59        // (root → book folders → files)
60        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            // Skip hidden directories
70            if self.is_hidden(path) {
71                continue;
72            }
73
74            // Check if this is a valid audiobook folder
75            if let Some(book) = self.scan_folder(path)? {
76                book_folders.push(book);
77            }
78        }
79
80        Ok(book_folders)
81    }
82
83    /// Scan a single directory as an audiobook folder (for auto-detect mode)
84    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        // Scan the folder
94        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    /// Scan a single folder and determine if it's an audiobook
102    fn scan_folder(&self, path: &Path) -> Result<Option<BookFolder>> {
103        let mut book = BookFolder::new(path.to_path_buf());
104
105        // Find audio files
106        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                    // M4A files are treated like MP3s (can be converted)
128                    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                    // Check if this is a cover art file
135                    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        // Classify the book
146        book.classify();
147
148        // Only return if it's a valid audiobook folder (Cases A, B, C, or E)
149        if matches!(book.case, BookCase::A | BookCase::B | BookCase::C | BookCase::E) {
150            // Sort MP3 files naturally
151            crate::utils::natural_sort(&mut book.mp3_files);
152
153            // Sort M4B files by part number for Case E
154            if book.case == BookCase::E {
155                crate::utils::sort_by_part_number(&mut book.m4b_files);
156            }
157
158            // Auto-extract embedded cover art if enabled and no standalone cover found
159            if self.auto_extract_cover && book.cover_file.is_none() {
160                // Try extracting from first audio file
161                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                    // Create temp file for extracted cover
171                    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    /// Check if a path is hidden (starts with .)
198    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    /// Check if a file is cover art based on filename
206    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        // Create some MP3 files
258        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); // Multiple MP3s
268        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        // Hidden directory should be skipped
283        assert_eq!(books.len(), 0);
284    }
285}