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, or C)
149        if matches!(book.case, BookCase::A | BookCase::B | BookCase::C) {
150            // Sort MP3 files naturally
151            crate::utils::natural_sort(&mut book.mp3_files);
152
153            // Auto-extract embedded cover art if enabled and no standalone cover found
154            if self.auto_extract_cover
155                && book.cover_file.is_none()
156                && !book.mp3_files.is_empty()
157            {
158                // Try extracting from first audio file (mp3_files includes both MP3 and M4A)
159                let first_audio = book.mp3_files.first();
160
161                if let Some(audio_file) = first_audio {
162                    // Create temp file for extracted cover
163                    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    /// Check if a path is hidden (starts with .)
190    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    /// Check if a file is cover art based on filename
198    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        // Create some MP3 files
250        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); // Multiple MP3s
260        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        // Hidden directory should be skipped
275        assert_eq!(books.len(), 0);
276    }
277}