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#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct TrackCache {
19 pub recent_tracks: Vec<Track>,
21 pub artist_tracks: HashMap<String, Vec<Track>>,
23 pub metadata: CacheMetadata,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct CacheMetadata {
29 pub last_updated: u64, 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 fn cache_file_path() -> std::result::Result<PathBuf, Box<dyn std::error::Error>> {
54 use crate::config::ScrobbleScrubberConfig;
55
56 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 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 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 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 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 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 pub fn add_recent_tracks(&mut self, mut tracks: Vec<Track>) {
126 tracks.sort_by(|a, b| {
128 match (a.timestamp, b.timestamp) {
129 (Some(a_ts), Some(b_ts)) => b_ts.cmp(&a_ts), (Some(_), None) => std::cmp::Ordering::Less, (None, Some(_)) => std::cmp::Ordering::Greater,
132 (None, None) => std::cmp::Ordering::Equal,
133 }
134 });
135
136 tracks.append(&mut self.recent_tracks);
138 self.recent_tracks = tracks;
139 self.update_timestamp();
140 }
141
142 pub fn get_artist_tracks(&self, artist: &str) -> Option<&Vec<Track>> {
144 self.artist_tracks.get(artist)
145 }
146
147 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 pub fn clear(&mut self) {
155 self.recent_tracks.clear();
156 self.artist_tracks.clear();
157 self.update_timestamp();
158 }
159
160 pub fn clear_artist(&mut self, artist: &str) {
162 self.artist_tracks.remove(artist);
163 self.update_timestamp();
164 }
165
166 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 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, 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 pub fn get_all_recent_tracks(&self) -> Vec<Track> {
191 self.recent_tracks.clone()
192 }
193
194 pub fn get_recent_tracks_limited(&self, limit: usize) -> Vec<Track> {
196 self.recent_tracks.iter().take(limit).cloned().collect()
197 }
198
199 pub fn get_most_recent_timestamp(&self) -> Option<DateTime<Utc>> {
201 self.recent_tracks
202 .first() .and_then(|track| track.timestamp)
204 .and_then(|ts| DateTime::from_timestamp(ts as i64, 0))
205 }
206
207 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 let mut filtered_new_tracks: Vec<Track> = new_tracks
218 .into_iter()
219 .filter(|track| track.timestamp.is_some()) .collect();
221
222 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), (Some(_), None) => std::cmp::Ordering::Less,
227 (None, Some(_)) => std::cmp::Ordering::Greater,
228 (None, None) => std::cmp::Ordering::Equal,
229 }
230 });
231
232 let mut all_tracks = filtered_new_tracks;
234 all_tracks.extend(self.recent_tracks.iter().cloned());
235
236 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 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 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 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", };
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 self.merge_recent_tracks(api_tracks);
320
321 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}