audiobook_forge/utils/
cache.rs1use anyhow::{Context, Result};
4use std::path::{Path, PathBuf};
5use std::time::{Duration, SystemTime};
6
7use crate::models::AudibleMetadata;
8
9pub struct AudibleCache {
11 cache_dir: PathBuf,
12 ttl: Duration,
13}
14
15impl AudibleCache {
16 pub fn new() -> Result<Self> {
18 Self::with_ttl(Duration::from_secs(7 * 24 * 3600))
19 }
20
21 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 std::fs::create_dir_all(&cache_dir)
30 .context("Failed to create cache directory")?;
31
32 Ok(Self { cache_dir, ttl })
33 }
34
35 pub fn with_ttl_hours(hours: u64) -> Result<Self> {
37 if hours == 0 {
38 Self::with_ttl(Duration::from_secs(0))
40 } else {
41 Self::with_ttl(Duration::from_secs(hours * 3600))
42 }
43 }
44
45 pub async fn get(&self, asin: &str) -> Option<AudibleMetadata> {
47 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 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 let _ = std::fs::remove_file(&cache_path);
67 return None;
68 }
69 }
70 }
71 }
72
73 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 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 pub async fn set(&self, asin: &str, metadata: &AudibleMetadata) -> Result<()> {
96 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 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 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 fn cache_path(&self, asin: &str) -> PathBuf {
147 self.cache_dir.join(format!("{}.json", asin))
148 }
149
150 pub fn cache_dir(&self) -> &Path {
152 &self.cache_dir
153 }
154
155 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#[derive(Debug, Clone)]
183pub struct CacheStats {
184 pub file_count: usize,
185 pub total_size_bytes: u64,
186}
187
188impl CacheStats {
189 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 cache.set("B001", &metadata).await.unwrap();
232
233 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 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 cache.set("B001", &metadata).await.unwrap();
260
261 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 assert!(stats.file_count >= 0);
273 assert!(stats.total_size_bytes >= 0);
274 }
275}