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