audiobook_forge/utils/
cache.rs

1//! Filesystem cache for Audible metadata
2
3use anyhow::{Context, Result};
4use std::path::{Path, PathBuf};
5use std::time::{Duration, SystemTime};
6
7use crate::models::AudibleMetadata;
8
9/// Filesystem cache for Audible metadata
10pub struct AudibleCache {
11    cache_dir: PathBuf,
12    ttl: Duration,
13}
14
15impl AudibleCache {
16    /// Create a new cache with default settings (7 days TTL)
17    pub fn new() -> Result<Self> {
18        Self::with_ttl(Duration::from_secs(7 * 24 * 3600))
19    }
20
21    /// Create a new cache with custom TTL
22    pub fn with_ttl(ttl: Duration) -> Result<Self> {
23        let cache_dir = dirs::cache_dir()
24            .context("No cache directory found")?
25            .join("audiobook-forge")
26            .join("audible");
27
28        // Create cache directory if it doesn't exist
29        std::fs::create_dir_all(&cache_dir)
30            .context("Failed to create cache directory")?;
31
32        Ok(Self { cache_dir, ttl })
33    }
34
35    /// Create a new cache with TTL from config (in hours)
36    pub fn with_ttl_hours(hours: u64) -> Result<Self> {
37        if hours == 0 {
38            // No caching - use 0 duration
39            Self::with_ttl(Duration::from_secs(0))
40        } else {
41            Self::with_ttl(Duration::from_secs(hours * 3600))
42        }
43    }
44
45    /// Get cached metadata for an ASIN
46    pub async fn get(&self, asin: &str) -> Option<AudibleMetadata> {
47        // If TTL is 0, caching is disabled
48        if self.ttl.as_secs() == 0 {
49            return None;
50        }
51
52        let cache_path = self.cache_path(asin);
53
54        if !cache_path.exists() {
55            tracing::debug!("Cache miss for ASIN: {}", asin);
56            return None;
57        }
58
59        // Check if cache is expired
60        if let Ok(metadata) = std::fs::metadata(&cache_path) {
61            if let Ok(modified) = metadata.modified() {
62                if let Ok(elapsed) = SystemTime::now().duration_since(modified) {
63                    if elapsed > self.ttl {
64                        tracing::debug!("Cache expired for ASIN: {} (age: {:?})", asin, elapsed);
65                        // Clean up expired cache file
66                        let _ = std::fs::remove_file(&cache_path);
67                        return None;
68                    }
69                }
70            }
71        }
72
73        // Read and deserialize cache file
74        match tokio::fs::read_to_string(&cache_path).await {
75            Ok(content) => match serde_json::from_str::<AudibleMetadata>(&content) {
76                Ok(metadata) => {
77                    tracing::debug!("Cache hit for ASIN: {}", asin);
78                    Some(metadata)
79                }
80                Err(e) => {
81                    tracing::warn!("Failed to parse cache file for {}: {}", asin, e);
82                    // Clean up corrupted cache file
83                    let _ = std::fs::remove_file(&cache_path);
84                    None
85                }
86            },
87            Err(e) => {
88                tracing::debug!("Failed to read cache file for {}: {}", asin, e);
89                None
90            }
91        }
92    }
93
94    /// Store metadata in cache for an ASIN
95    pub async fn set(&self, asin: &str, metadata: &AudibleMetadata) -> Result<()> {
96        // If TTL is 0, caching is disabled
97        if self.ttl.as_secs() == 0 {
98            return Ok(());
99        }
100
101        let cache_path = self.cache_path(asin);
102
103        let json = serde_json::to_string_pretty(metadata)
104            .context("Failed to serialize metadata")?;
105
106        tokio::fs::write(&cache_path, json)
107            .await
108            .context("Failed to write cache file")?;
109
110        tracing::debug!("Cached metadata for ASIN: {} at {}", asin, cache_path.display());
111
112        Ok(())
113    }
114
115    /// Clear cache for a specific ASIN
116    pub fn clear(&self, asin: &str) -> Result<()> {
117        let cache_path = self.cache_path(asin);
118
119        if cache_path.exists() {
120            std::fs::remove_file(&cache_path)
121                .context("Failed to remove cache file")?;
122            tracing::debug!("Cleared cache for ASIN: {}", asin);
123        }
124
125        Ok(())
126    }
127
128    /// Clear all cached metadata
129    pub fn clear_all(&self) -> Result<()> {
130        if self.cache_dir.exists() {
131            for entry in std::fs::read_dir(&self.cache_dir)? {
132                if let Ok(entry) = entry {
133                    let path = entry.path();
134                    if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("json") {
135                        let _ = std::fs::remove_file(&path);
136                    }
137                }
138            }
139            tracing::debug!("Cleared all Audible cache");
140        }
141
142        Ok(())
143    }
144
145    /// Get the cache file path for an ASIN
146    fn cache_path(&self, asin: &str) -> PathBuf {
147        self.cache_dir.join(format!("{}.json", asin))
148    }
149
150    /// Get cache directory path
151    pub fn cache_dir(&self) -> &Path {
152        &self.cache_dir
153    }
154
155    /// Get cache statistics (number of files, total size)
156    pub fn stats(&self) -> Result<CacheStats> {
157        let mut count = 0;
158        let mut total_size = 0u64;
159
160        if self.cache_dir.exists() {
161            for entry in std::fs::read_dir(&self.cache_dir)? {
162                if let Ok(entry) = entry {
163                    let path = entry.path();
164                    if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("json") {
165                        count += 1;
166                        if let Ok(metadata) = std::fs::metadata(&path) {
167                            total_size += metadata.len();
168                        }
169                    }
170                }
171            }
172        }
173
174        Ok(CacheStats {
175            file_count: count,
176            total_size_bytes: total_size,
177        })
178    }
179}
180
181/// Cache statistics
182#[derive(Debug, Clone)]
183pub struct CacheStats {
184    pub file_count: usize,
185    pub total_size_bytes: u64,
186}
187
188impl CacheStats {
189    /// Get total size in megabytes
190    pub fn size_mb(&self) -> f64 {
191        self.total_size_bytes as f64 / (1024.0 * 1024.0)
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use crate::models::{AudibleAuthor, AudibleSeries};
199
200    fn create_test_metadata() -> AudibleMetadata {
201        AudibleMetadata {
202            asin: "B001".to_string(),
203            title: "Test Book".to_string(),
204            subtitle: None,
205            authors: vec![AudibleAuthor {
206                asin: None,
207                name: "Test Author".to_string(),
208            }],
209            narrators: vec!["Test Narrator".to_string()],
210            publisher: Some("Test Publisher".to_string()),
211            published_year: Some(2020),
212            description: Some("Test description".to_string()),
213            cover_url: None,
214            isbn: None,
215            genres: vec!["Fiction".to_string()],
216            tags: vec![],
217            series: vec![],
218            language: Some("English".to_string()),
219            runtime_length_ms: Some(3600000),
220            rating: Some(4.5),
221            is_abridged: Some(false),
222        }
223    }
224
225    #[tokio::test]
226    async fn test_cache_set_and_get() {
227        let cache = AudibleCache::new().unwrap();
228        let metadata = create_test_metadata();
229
230        // Set cache
231        cache.set("B001", &metadata).await.unwrap();
232
233        // Get cache
234        let cached = cache.get("B001").await;
235        assert!(cached.is_some());
236
237        let cached = cached.unwrap();
238        assert_eq!(cached.asin, "B001");
239        assert_eq!(cached.title, "Test Book");
240
241        // Clean up
242        cache.clear("B001").unwrap();
243    }
244
245    #[tokio::test]
246    async fn test_cache_miss() {
247        let cache = AudibleCache::new().unwrap();
248
249        let cached = cache.get("NONEXISTENT").await;
250        assert!(cached.is_none());
251    }
252
253    #[tokio::test]
254    async fn test_cache_disabled() {
255        let cache = AudibleCache::with_ttl(Duration::from_secs(0)).unwrap();
256        let metadata = create_test_metadata();
257
258        // Set cache (should be no-op)
259        cache.set("B001", &metadata).await.unwrap();
260
261        // Get cache (should return None since caching is disabled)
262        let cached = cache.get("B001").await;
263        assert!(cached.is_none());
264    }
265
266    #[test]
267    fn test_cache_stats() {
268        let cache = AudibleCache::new().unwrap();
269        let stats = cache.stats().unwrap();
270
271        // Just verify it doesn't crash
272        assert!(stats.file_count >= 0);
273        assert!(stats.total_size_bytes >= 0);
274    }
275}