lastfm_client/
analytics.rs

1use std::fs::File;
2use std::io::BufReader;
3use std::{collections::HashMap, path::Path};
4
5use serde::de::DeserializeOwned;
6
7use crate::types::{LovedTrack, RecentTrack, Timestamped};
8
9/// Trait for types that can be analyzed as tracks
10#[allow(dead_code)]
11pub trait TrackAnalyzable {
12    /// Get the artist name from the track
13    fn get_artist_name(&self) -> String;
14
15    /// Get the track name from the track
16    fn get_track_name(&self) -> String;
17
18    /// Get the full track identifier (usually "artist - track")
19    fn get_track_identifier(&self) -> String {
20        format!("{} - {}", self.get_artist_name(), self.get_track_name())
21    }
22}
23
24impl TrackAnalyzable for RecentTrack {
25    fn get_artist_name(&self) -> String {
26        self.artist.text.clone()
27    }
28
29    fn get_track_name(&self) -> String {
30        self.name.clone()
31    }
32}
33
34impl TrackAnalyzable for LovedTrack {
35    fn get_artist_name(&self) -> String {
36        self.artist.name.clone()
37    }
38
39    fn get_track_name(&self) -> String {
40        self.name.clone()
41    }
42}
43
44/// Represents statistics about tracks
45#[derive(Debug)]
46pub struct TrackStats {
47    /// Total number of tracks
48    pub total_tracks: usize,
49    /// Map of artist names to play counts
50    pub artist_play_counts: HashMap<String, usize>,
51    /// Map of track names to play counts
52    pub track_play_counts: HashMap<String, usize>,
53    /// Map of tracks played less than threshold
54    pub tracks_below_threshold: HashMap<String, usize>,
55    /// Map of tracks played more than threshold
56    pub tracks_above_threshold: HashMap<String, usize>,
57    /// Most played artist
58    pub most_played_artist: Option<(String, usize)>,
59    /// Most played track
60    pub most_played_track: Option<(String, usize)>,
61}
62
63pub struct AnalysisHandler;
64
65impl AnalysisHandler {
66    /// Analyze tracks from a JSON file
67    ///
68    /// # Arguments
69    /// * `file_path` - Path to the JSON file containing track data
70    /// * `threshold` - Minimum play count threshold. Tracks with fewer plays than this value will be
71    ///   counted separately. For example, use 5 to identify tracks played less than 5 times.
72    ///
73    /// # Errors
74    /// * `std::io::Error` - If there was an error reading the file
75    /// * `serde_json::Error` - If the JSON cannot be parsed
76    ///
77    /// # Returns
78    /// * `Result<TrackStats, Box<dyn std::error::Error>>` - Analysis results
79    pub fn analyze_file<T: DeserializeOwned + TrackAnalyzable>(
80        file_path: &Path,
81        threshold: usize,
82    ) -> Result<TrackStats, Box<dyn std::error::Error>> {
83        let file = File::open(file_path)?;
84        let reader = BufReader::new(file);
85
86        let tracks: Vec<T> = serde_json::from_reader(reader)?;
87
88        Ok(Self::analyze_tracks(&tracks, threshold))
89    }
90
91    /// Analyze a vector of tracks
92    ///
93    /// # Arguments
94    /// * `tracks` - Vector of tracks to analyze (must implement `TrackAnalyzable`)
95    /// * `threshold` - Minimum play count threshold. Tracks with fewer plays than this value will be
96    ///   counted separately. For example, use 5 to identify tracks played less than 5 times.
97    ///
98    /// # Returns
99    /// * `TrackStats` - Analysis results containing play counts, most played tracks, and threshold-based groupings
100    pub fn analyze_tracks<T: TrackAnalyzable>(tracks: &[T], threshold: usize) -> TrackStats {
101        let mut artist_play_counts: HashMap<String, usize> = HashMap::new();
102        let mut track_play_counts: HashMap<String, usize> = HashMap::new();
103
104        // Count plays for each artist and track
105        for track in tracks {
106            let artist_name = track.get_artist_name();
107            let track_identifier = track.get_track_identifier();
108
109            *artist_play_counts.entry(artist_name).or_insert(0) += 1;
110            *track_play_counts.entry(track_identifier).or_insert(0) += 1;
111        }
112
113        // Find most played artist and track
114        let most_played_artist = artist_play_counts
115            .iter()
116            .max_by_key(|(_, count)| *count)
117            .map(|(name, count)| (name.clone(), *count));
118
119        let most_played_track = track_play_counts
120            .iter()
121            .max_by_key(|(_, count)| *count)
122            .map(|(name, count)| (name.clone(), *count));
123
124        // Find tracks played less than threshold
125        let tracks_below_threshold: HashMap<String, usize> = track_play_counts
126            .iter()
127            .filter(|(_, count)| **count < threshold)
128            .map(|(name, count)| (name.clone(), *count))
129            .collect();
130
131        // Find tracks played more than threshold
132        let tracks_above_threshold: HashMap<String, usize> = track_play_counts
133            .iter()
134            .filter(|(_, count)| **count >= threshold)
135            .map(|(name, count)| (name.clone(), *count))
136            .collect();
137
138        TrackStats {
139            total_tracks: tracks.len(),
140            artist_play_counts,
141            track_play_counts,
142            tracks_below_threshold,
143            tracks_above_threshold,
144            most_played_artist,
145            most_played_track,
146        }
147    }
148
149    /// Print analysis results in a formatted way
150    ///
151    /// # Arguments
152    /// * `stats` - `TrackStats` to print
153    pub fn print_analysis(stats: &TrackStats) {
154        println!("=== Track Analysis ===");
155        println!("Total tracks: {}", stats.total_tracks);
156
157        if let Some((artist, count)) = &stats.most_played_artist {
158            println!("\nMost played artist: {artist} ({count} plays)");
159        }
160
161        if let Some((track, count)) = &stats.most_played_track {
162            println!("Most played track: {track} ({count} plays)");
163        }
164
165        println!("\nTop 10 Artists:");
166        let mut artists: Vec<_> = stats.artist_play_counts.iter().collect();
167        artists.sort_by(|a, b| b.1.cmp(a.1));
168        for (artist, count) in artists.iter().take(10) {
169            println!("  {artist} - {count} plays");
170        }
171
172        println!("\nTop 10 Tracks:");
173        let mut tracks: Vec<_> = stats.track_play_counts.iter().collect();
174        tracks.sort_by(|a, b| b.1.cmp(a.1));
175        for (track, count) in tracks.iter().take(10) {
176            println!("  {track} - {count} plays");
177        }
178
179        println!(
180            "\nTracks below threshold: {}",
181            stats.tracks_below_threshold.len()
182        );
183
184        println!(
185            "\nTracks above threshold: {}",
186            stats.tracks_above_threshold.len()
187        );
188    }
189
190    /// Get the most recent timestamp from a JSON file.
191    ///
192    /// # Arguments
193    /// * `file_path` - Path to the JSON file
194    ///
195    /// # Errors
196    /// * `std::io::Error` - If the file cannot be opened or read
197    /// * `LastFmError::Io` - If the file cannot be opened
198    /// * `LastFmError::Parse` - If the JSON cannot be deserialized
199    ///
200    /// # Returns
201    /// * `Option<i64>` - Most recent timestamp
202    #[allow(dead_code)]
203    pub fn get_most_recent_timestamp<T: DeserializeOwned + Timestamped>(
204        file_path: &Path,
205    ) -> crate::error::Result<Option<i64>> {
206        let file = File::open(file_path)?;
207        let reader = BufReader::new(file);
208        let tracks: Vec<T> = serde_json::from_reader(reader)?;
209
210        Ok(tracks
211            .iter()
212            .filter_map(Timestamped::get_timestamp)
213            .map(i64::from)
214            .max())
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use crate::types::{BaseMbidText, BaseObject, Date, Streamable};
222
223    fn create_recent_track(artist: &str, name: &str) -> RecentTrack {
224        RecentTrack {
225            artist: BaseMbidText {
226                mbid: String::new(),
227                text: artist.to_string(),
228            },
229            streamable: false,
230            image: Vec::new(),
231            album: BaseMbidText {
232                mbid: String::new(),
233                text: String::new(),
234            },
235            attr: None,
236            date: None,
237            name: name.to_string(),
238            mbid: String::new(),
239            url: String::new(),
240        }
241    }
242
243    fn create_loved_track(artist: &str, name: &str) -> LovedTrack {
244        LovedTrack {
245            artist: BaseObject {
246                mbid: String::new(),
247                url: String::new(),
248                name: artist.to_string(),
249            },
250            date: Date {
251                uts: 0,
252                text: String::new(),
253            },
254            image: Vec::new(),
255            streamable: Streamable {
256                fulltrack: String::new(),
257                text: String::new(),
258            },
259            name: name.to_string(),
260            mbid: String::new(),
261            url: String::new(),
262        }
263    }
264
265    #[test]
266    fn test_analyze_recent_tracks() {
267        let tracks = vec![
268            create_recent_track("Artist1", "Song1"),
269            create_recent_track("Artist1", "Song1"),
270            create_recent_track("Artist1", "Song2"),
271            create_recent_track("Artist2", "Song3"),
272        ];
273
274        let stats = AnalysisHandler::analyze_tracks(&tracks, 2);
275
276        assert_eq!(stats.total_tracks, 4);
277        assert_eq!(stats.artist_play_counts["Artist1"], 3);
278        assert_eq!(stats.artist_play_counts["Artist2"], 1);
279        assert_eq!(stats.track_play_counts["Artist1 - Song1"], 2);
280        assert_eq!(stats.most_played_artist, Some(("Artist1".to_string(), 3)));
281    }
282
283    #[test]
284    fn test_analyze_loved_tracks() {
285        let tracks = vec![
286            create_loved_track("Artist1", "Song1"),
287            create_loved_track("Artist1", "Song1"),
288            create_loved_track("Artist1", "Song2"),
289            create_loved_track("Artist2", "Song3"),
290        ];
291
292        let stats = AnalysisHandler::analyze_tracks(&tracks, 2);
293
294        assert_eq!(stats.total_tracks, 4);
295        assert_eq!(stats.artist_play_counts["Artist1"], 3);
296        assert_eq!(stats.artist_play_counts["Artist2"], 1);
297        assert_eq!(stats.track_play_counts["Artist1 - Song1"], 2);
298        assert_eq!(stats.most_played_artist, Some(("Artist1".to_string(), 3)));
299    }
300}