Skip to main content

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