Skip to main content

scrobble_scrubber/
track_cache.rs

1use chrono::{DateTime, Utc};
2use lastfm_edit::{LastFmEditClient, Track};
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::fs;
6use std::path::PathBuf;
7
8#[derive(Debug, Clone)]
9pub struct CacheMergeStats {
10    pub added: usize,
11    pub updated: usize,
12    pub duplicates: usize,
13    pub total_processed: usize,
14}
15
16/// Cache structure for storing track data on disk
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct TrackCache {
19    /// Recent tracks (ordered newest first)
20    pub recent_tracks: Vec<Track>,
21    /// Artist tracks by artist name
22    pub artist_tracks: HashMap<String, Vec<Track>>,
23    /// Cache metadata
24    pub metadata: CacheMetadata,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct CacheMetadata {
29    /// When the cache was last updated
30    pub last_updated: u64, // Unix timestamp
31    /// Cache format version for future compatibility
32    pub version: u32,
33}
34
35impl Default for TrackCache {
36    fn default() -> Self {
37        Self {
38            recent_tracks: Vec::new(),
39            artist_tracks: HashMap::new(),
40            metadata: CacheMetadata {
41                last_updated: std::time::SystemTime::now()
42                    .duration_since(std::time::UNIX_EPOCH)
43                    .unwrap_or_default()
44                    .as_secs(),
45                version: 1,
46            },
47        }
48    }
49}
50
51impl TrackCache {
52    /// Get the cache file path using the config
53    fn cache_file_path() -> std::result::Result<PathBuf, Box<dyn std::error::Error>> {
54        use crate::config::ScrobbleScrubberConfig;
55
56        // Try to load config to get the proper storage directory
57        match ScrobbleScrubberConfig::load() {
58            Ok(config) => {
59                let state_file_path = std::path::Path::new(&config.storage.state_file);
60                let cache_dir = state_file_path.parent().ok_or_else(|| {
61                    std::io::Error::other("Could not determine parent directory of state file")
62                })?;
63
64                fs::create_dir_all(cache_dir)?;
65                Ok(cache_dir.join("track_cache.json"))
66            }
67            Err(_) => {
68                // Fallback to XDG cache dir if config can't be loaded
69                let cache_dir = dirs::cache_dir()
70                    .or_else(|| dirs::home_dir().map(|h| h.join(".cache")))
71                    .ok_or_else(|| std::io::Error::other("Could not determine cache directory"))?;
72
73                let app_cache_dir = cache_dir.join("scrobble-scrubber");
74                fs::create_dir_all(&app_cache_dir)?;
75
76                Ok(app_cache_dir.join("track_cache.json"))
77            }
78        }
79    }
80
81    /// Load cache from disk, returns default cache if file doesn't exist or can't be read
82    pub fn load() -> Self {
83        match Self::cache_file_path() {
84            Ok(path) => {
85                match fs::read_to_string(&path) {
86                    Ok(content) => match serde_json::from_str::<Self>(&content) {
87                        Ok(cache) => {
88                            log::info!("Loaded track cache from {}", path.display());
89                            cache
90                        }
91                        Err(e) => {
92                            log::warn!("Failed to parse cache file: {e}, using empty cache");
93                            Self::default()
94                        }
95                    },
96                    Err(_) => {
97                        // File doesn't exist or can't be read, return default
98                        Self::default()
99                    }
100                }
101            }
102            Err(e) => {
103                log::warn!("Could not determine cache path: {e}, using empty cache");
104                Self::default()
105            }
106        }
107    }
108
109    /// Save cache to disk
110    pub fn save(&self) -> std::result::Result<(), Box<dyn std::error::Error>> {
111        let path = Self::cache_file_path()?;
112        let content = serde_json::to_string_pretty(self)?;
113        fs::write(&path, content)?;
114        log::debug!("Saved track cache to {}", path.display());
115        Ok(())
116    }
117
118    /// Get recent tracks (limited to first n tracks)
119    pub fn get_recent_tracks(&self, limit: usize) -> &[Track] {
120        let end = std::cmp::min(limit, self.recent_tracks.len());
121        &self.recent_tracks[..end]
122    }
123
124    /// Add recent tracks to the cache (merges and maintains order)
125    pub fn add_recent_tracks(&mut self, mut tracks: Vec<Track>) {
126        // Sort new tracks newest first
127        tracks.sort_by(|a, b| {
128            match (a.timestamp, b.timestamp) {
129                (Some(a_ts), Some(b_ts)) => b_ts.cmp(&a_ts), // Reverse order for newest first
130                (Some(_), None) => std::cmp::Ordering::Less, // Tracks with timestamps come first
131                (None, Some(_)) => std::cmp::Ordering::Greater,
132                (None, None) => std::cmp::Ordering::Equal,
133            }
134        });
135
136        // Add to front of existing tracks and maintain order
137        tracks.append(&mut self.recent_tracks);
138        self.recent_tracks = tracks;
139        self.update_timestamp();
140    }
141
142    /// Get artist tracks
143    pub fn get_artist_tracks(&self, artist: &str) -> Option<&Vec<Track>> {
144        self.artist_tracks.get(artist)
145    }
146
147    /// Cache artist tracks
148    pub fn cache_artist_tracks(&mut self, artist: String, tracks: Vec<Track>) {
149        self.artist_tracks.insert(artist, tracks);
150        self.update_timestamp();
151    }
152
153    /// Clear all cached data
154    pub fn clear(&mut self) {
155        self.recent_tracks.clear();
156        self.artist_tracks.clear();
157        self.update_timestamp();
158    }
159
160    /// Clear cached data for a specific artist
161    pub fn clear_artist(&mut self, artist: &str) {
162        self.artist_tracks.remove(artist);
163        self.update_timestamp();
164    }
165
166    /// Update the last updated timestamp
167    fn update_timestamp(&mut self) {
168        self.metadata.last_updated = std::time::SystemTime::now()
169            .duration_since(std::time::UNIX_EPOCH)
170            .unwrap_or_default()
171            .as_secs();
172    }
173
174    /// Get cache statistics
175    pub fn stats(&self) -> CacheStats {
176        let recent_track_count = self.recent_tracks.len();
177        let artist_track_count: usize = self.artist_tracks.values().map(|v| v.len()).sum();
178
179        CacheStats {
180            recent_pages: 0, // No longer using pages
181            recent_track_count,
182            artist_count: self.artist_tracks.len(),
183            artist_track_count,
184            total_tracks: recent_track_count + artist_track_count,
185            last_updated: self.metadata.last_updated,
186        }
187    }
188
189    /// Get all recent tracks (already sorted newest first)
190    pub fn get_all_recent_tracks(&self) -> Vec<Track> {
191        self.recent_tracks.clone()
192    }
193
194    /// Get the N most recent tracks
195    pub fn get_recent_tracks_limited(&self, limit: usize) -> Vec<Track> {
196        self.recent_tracks.iter().take(limit).cloned().collect()
197    }
198
199    /// Get the timestamp of the most recent track in cache (if any)
200    pub fn get_most_recent_timestamp(&self) -> Option<DateTime<Utc>> {
201        self.recent_tracks
202            .first() // Since tracks are sorted newest first
203            .and_then(|track| track.timestamp)
204            .and_then(|ts| DateTime::from_timestamp(ts as i64, 0))
205    }
206
207    /// Merge new tracks from API into the cache
208    pub fn merge_recent_tracks(&mut self, new_tracks: Vec<Track>) -> CacheMergeStats {
209        let mut stats = CacheMergeStats {
210            added: 0,
211            updated: 0,
212            duplicates: 0,
213            total_processed: new_tracks.len(),
214        };
215
216        // Filter tracks with timestamps and sort newest first
217        let mut filtered_new_tracks: Vec<Track> = new_tracks
218            .into_iter()
219            .filter(|track| track.timestamp.is_some()) // Skip tracks without timestamps
220            .collect();
221
222        // Sort new tracks newest first
223        filtered_new_tracks.sort_by(|a, b| {
224            match (a.timestamp, b.timestamp) {
225                (Some(a_ts), Some(b_ts)) => b_ts.cmp(&a_ts), // Reverse order for newest first
226                (Some(_), None) => std::cmp::Ordering::Less,
227                (None, Some(_)) => std::cmp::Ordering::Greater,
228                (None, None) => std::cmp::Ordering::Equal,
229            }
230        });
231
232        // Simple deduplication: merge with existing tracks, keeping newest and avoiding duplicates
233        let mut all_tracks = filtered_new_tracks;
234        all_tracks.extend(self.recent_tracks.iter().cloned());
235
236        // Remove duplicates by timestamp, keeping the first occurrence (newest)
237        all_tracks.sort_by(|a, b| match (a.timestamp, b.timestamp) {
238            (Some(a_ts), Some(b_ts)) => b_ts.cmp(&a_ts),
239            (Some(_), None) => std::cmp::Ordering::Less,
240            (None, Some(_)) => std::cmp::Ordering::Greater,
241            (None, None) => std::cmp::Ordering::Equal,
242        });
243        all_tracks.dedup_by(|a, b| {
244            a.timestamp == b.timestamp && a.name == b.name && a.artist == b.artist
245        });
246
247        let old_count = self.recent_tracks.len();
248        let new_count = all_tracks.len();
249        stats.added = new_count.saturating_sub(old_count);
250
251        self.recent_tracks = all_tracks;
252        self.update_timestamp();
253
254        log::debug!(
255            "Cache merge completed: {} tracks total (simplified merge)",
256            self.recent_tracks.len()
257        );
258
259        stats
260    }
261
262    /// Update cache with latest tracks from Last.fm API
263    /// Fetches tracks until we hit EITHER the fetch_bound OR the cache's most recent timestamp
264    /// (whichever comes first chronologically). If fetch_bound is None, fetches without lower bound.
265    pub async fn update_cache_from_api(
266        &mut self,
267        client: &(dyn LastFmEditClient + Send + Sync),
268        fetch_bound: Option<DateTime<Utc>>,
269    ) -> lastfm_edit::Result<()> {
270        let mut recent_iterator = client.recent_tracks();
271        let mut api_tracks = Vec::new();
272        let mut fetched = 0;
273
274        let cache_tip = self.get_most_recent_timestamp();
275
276        // Compute the effective stopping bound - use the more recent (higher) of cache_tip and fetch_bound
277        let stop_at = match (cache_tip, fetch_bound) {
278            (Some(cache), Some(bound)) => Some(cache.max(bound)),
279            (Some(cache), None) => Some(cache),
280            (None, Some(bound)) => Some(bound),
281            (None, None) => None,
282        };
283
284        while let Some(track) = recent_iterator.next().await? {
285            fetched += 1;
286
287            if let Some(track_ts) = track.timestamp {
288                let track_time = DateTime::from_timestamp(track_ts as i64, 0);
289                if let Some(track_time) = track_time {
290                    // Stop if we've reached our computed stopping bound
291                    if let Some(stop_time) = stop_at {
292                        if track_time <= stop_time {
293                            let bound_type = match (cache_tip, fetch_bound) {
294                                (Some(cache), Some(_bound)) if stop_time == cache => "cache tip",
295                                (Some(_), Some(_)) => "fetch bound",
296                                (Some(_), None) => "cache tip",
297                                (None, Some(_)) => "fetch bound",
298                                _ => "unknown bound", // shouldn't happen
299                            };
300                            log::info!(
301                                "Reached {} at track '{}' by '{}' at {}, stopping fetch after {} tracks",
302                                bound_type, track.name, track.artist, track_time, fetched
303                            );
304                            break;
305                        }
306                    }
307                }
308            }
309
310            api_tracks.push(track);
311        }
312
313        log::debug!(
314            "Fetched {} tracks from API, merging with cache...",
315            api_tracks.len()
316        );
317
318        // Merge with existing cache
319        self.merge_recent_tracks(api_tracks);
320
321        // Save updated cache
322        if let Err(e) = self.save() {
323            log::warn!("Failed to save updated cache: {e}");
324        } else {
325            log::info!("Cache updated and saved successfully");
326        }
327
328        Ok(())
329    }
330}
331
332#[derive(Debug)]
333pub struct CacheStats {
334    pub recent_pages: usize,
335    pub recent_track_count: usize,
336    pub artist_count: usize,
337    pub artist_track_count: usize,
338    pub total_tracks: usize,
339    pub last_updated: u64,
340}
341
342impl std::fmt::Display for CacheStats {
343    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
344        let last_updated = if self.last_updated > 0 {
345            chrono::DateTime::from_timestamp(self.last_updated as i64, 0)
346                .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
347                .unwrap_or_else(|| "Unknown".to_string())
348        } else {
349            "Never".to_string()
350        };
351
352        write!(
353            f,
354            "Cache Statistics:\n  Recent: {} pages ({} tracks)\n  Artists: {} artists ({} tracks)\n  Total: {} tracks\n  Last Updated: {}",
355            self.recent_pages,
356            self.recent_track_count,
357            self.artist_count,
358            self.artist_track_count,
359            self.total_tracks,
360            last_updated
361        )
362    }
363}