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