audiobook_forge/core/
scanner.rs

1//! Directory scanner for discovering audiobook folders
2
3use crate::models::{BookFolder, BookCase};
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}
13
14impl Scanner {
15    /// Create a new scanner with default cover filenames
16    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    /// Create scanner with custom cover filenames
28    pub fn with_cover_filenames(cover_filenames: Vec<String>) -> Self {
29        Self { cover_filenames }
30    }
31
32    /// Scan a directory for audiobook folders
33    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        // Walk through directory tree, but only go 2 levels deep
45        // (root → book folders → files)
46        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            // Skip hidden directories
56            if self.is_hidden(path) {
57                continue;
58            }
59
60            // Check if this is a valid audiobook folder
61            if let Some(book) = self.scan_folder(path)? {
62                book_folders.push(book);
63            }
64        }
65
66        Ok(book_folders)
67    }
68
69    /// Scan a single directory as an audiobook folder (for auto-detect mode)
70    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        // Scan the folder
80        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    /// Scan a single folder and determine if it's an audiobook
88    fn scan_folder(&self, path: &Path) -> Result<Option<BookFolder>> {
89        let mut book = BookFolder::new(path.to_path_buf());
90
91        // Find audio files
92        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                    // M4A files are treated like MP3s (can be converted)
114                    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                    // Check if this is a cover art file
121                    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        // Classify the book
132        book.classify();
133
134        // Only return if it's a valid audiobook folder (Cases A, B, or C)
135        if matches!(book.case, BookCase::A | BookCase::B | BookCase::C) {
136            // Sort MP3 files naturally
137            crate::utils::natural_sort(&mut book.mp3_files);
138            Ok(Some(book))
139        } else {
140            Ok(None)
141        }
142    }
143
144    /// Check if a path is hidden (starts with .)
145    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    /// Check if a file is cover art based on filename
153    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        // Create some MP3 files
205        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); // Multiple MP3s
215        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        // Hidden directory should be skipped
230        assert_eq!(books.len(), 0);
231    }
232}