Skip to main content

lastfm_client/api/
loved_tracks.rs

1use crate::analytics::AnalysisHandler;
2use crate::client::HttpClient;
3use crate::config::Config;
4use crate::error::Result;
5use crate::file_handler::{FileFormat, FileHandler};
6use crate::types::{LovedTrack, Timestamped, TrackLimit, TrackList, UserLovedTracks};
7
8use serde::de::DeserializeOwned;
9use std::fmt;
10use std::sync::Arc;
11
12use super::fetch_utils::{ResourceContainer, fetch};
13
14/// Client for fetching loved tracks
15pub struct LovedTracksClient {
16    http: Arc<dyn HttpClient>,
17    config: Arc<Config>,
18}
19
20impl fmt::Debug for LovedTracksClient {
21    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
22        f.debug_struct("LovedTracksClient")
23            .field("config", &self.config)
24            .finish_non_exhaustive()
25    }
26}
27
28impl LovedTracksClient {
29    /// Create a new loved tracks client
30    pub fn new(http: Arc<dyn HttpClient>, config: Arc<Config>) -> Self {
31        Self { http, config }
32    }
33
34    /// Create a builder for loved tracks requests
35    pub fn builder(&self, username: impl Into<String>) -> LovedTracksRequestBuilder {
36        LovedTracksRequestBuilder::new(self.http.clone(), self.config.clone(), username.into())
37    }
38}
39
40/// Builder for loved tracks requests
41pub struct LovedTracksRequestBuilder {
42    http: Arc<dyn HttpClient>,
43    config: Arc<Config>,
44    username: String,
45    limit: Option<u32>,
46}
47
48impl fmt::Debug for LovedTracksRequestBuilder {
49    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50        f.debug_struct("LovedTracksRequestBuilder")
51            .field("username", &self.username)
52            .field("limit", &self.limit)
53            .finish_non_exhaustive()
54    }
55}
56
57impl LovedTracksRequestBuilder {
58    fn new(http: Arc<dyn HttpClient>, config: Arc<Config>, username: String) -> Self {
59        Self {
60            http,
61            config,
62            username,
63            limit: None,
64        }
65    }
66
67    /// Set the maximum number of tracks to fetch
68    ///
69    /// # Arguments
70    /// * `limit` - Maximum number of tracks to fetch. The Last.fm API supports fetching up to thousands of tracks.
71    ///   If you need all tracks, use `unlimited()` instead.
72    #[must_use]
73    pub const fn limit(mut self, limit: u32) -> Self {
74        self.limit = Some(limit);
75        self
76    }
77
78    /// Fetch all available tracks (no limit)
79    #[must_use]
80    pub const fn unlimited(mut self) -> Self {
81        self.limit = None;
82        self
83    }
84
85    /// Fetch the tracks
86    ///
87    /// # Errors
88    /// Returns an error if the HTTP request fails or the response cannot be parsed.
89    pub async fn fetch(self) -> Result<TrackList<LovedTrack>> {
90        let limit = self
91            .limit
92            .map_or(TrackLimit::Unlimited, TrackLimit::Limited);
93
94        self.fetch_tracks::<UserLovedTracks>(limit)
95            .await
96            .map(TrackList::from)
97    }
98
99    /// Fetch tracks and save them to a file
100    ///
101    /// # Arguments
102    /// * `format` - The file format to save the tracks in
103    /// * `filename_prefix` - Prefix for the generated filename
104    ///
105    /// # Errors
106    /// Returns an error if the HTTP request fails, response cannot be parsed, or file cannot be saved.
107    ///
108    /// # Returns
109    /// * `Result<String>` - The filename of the saved file
110    pub async fn fetch_and_save(self, format: FileFormat, filename_prefix: &str) -> Result<String> {
111        let tracks = self.fetch().await?;
112        tracing::info!("Saving {} loved tracks to file", tracks.len());
113        let filename = FileHandler::save(&tracks, &format, filename_prefix)
114            .map_err(crate::error::LastFmError::Io)?;
115        if let Some(latest_ts) = tracks
116            .first()
117            .and_then(crate::types::Timestamped::get_timestamp)
118        {
119            FileHandler::write_sidecar_timestamp(&filename, latest_ts)
120                .map_err(crate::error::LastFmError::Io)?;
121        }
122        Ok(filename)
123    }
124
125    /// Fetch tracks and save them to a new `SQLite` database file.
126    ///
127    /// # Arguments
128    /// * `filename_prefix` - Prefix for the generated filename
129    ///
130    /// # Errors
131    /// Returns an error if the HTTP request fails, the response cannot be parsed, or the database cannot be saved.
132    ///
133    /// # Returns
134    /// * `Result<String>` - Path to the saved database file
135    #[cfg(feature = "sqlite")]
136    pub async fn fetch_and_save_sqlite(
137        self,
138        filename_prefix: &str,
139    ) -> crate::error::Result<String> {
140        let tracks = self.fetch().await?;
141        tracing::info!("Saving {} loved tracks to SQLite", tracks.len());
142        crate::file_handler::FileHandler::save_sqlite(&tracks, filename_prefix)
143            .map_err(crate::error::LastFmError::Io)
144    }
145
146    /// Fetch only tracks newer than the most recent entry in an existing `SQLite` database and
147    /// append them to it. If the database file does not exist, all tracks are fetched and
148    /// the database is created.
149    ///
150    /// Because the loved tracks API does not support a `from` timestamp filter, all tracks
151    /// are fetched and those already present (by `date_uts`) are filtered out before inserting.
152    ///
153    /// # Arguments
154    /// * `db_path` - Path to the `SQLite` database file to update (or create)
155    ///
156    /// # Errors
157    /// Returns an error if the HTTP request fails, the response cannot be parsed, or the database cannot be written.
158    ///
159    /// # Returns
160    /// * `Result<usize>` - Number of new tracks inserted
161    #[cfg(feature = "sqlite")]
162    pub async fn fetch_and_update_sqlite(self, db_path: &str) -> crate::error::Result<usize> {
163        let max_existing_ts = crate::file_handler::FileHandler::read_sqlite_max_timestamp(
164            db_path,
165            <LovedTrack as crate::sqlite::SqliteExportable>::table_name(),
166        );
167
168        let all_tracks = self.fetch().await?;
169
170        let new_tracks: Vec<LovedTrack> = match max_existing_ts {
171            Some(max_ts) => all_tracks
172                .into_iter()
173                .filter(|t| t.date.uts > max_ts)
174                .collect(),
175            None => all_tracks.into(),
176        };
177
178        let count = new_tracks.len();
179
180        if !new_tracks.is_empty() {
181            crate::file_handler::FileHandler::append_or_create_sqlite(&new_tracks, db_path)
182                .map_err(crate::error::LastFmError::Io)?;
183        }
184
185        Ok(count)
186    }
187
188    /// Fetch only tracks newer than the most recent entry in an existing JSON file and prepend
189    /// them to it. If the file does not exist, all tracks are fetched and the file is created.
190    ///
191    /// Unlike `recent_tracks`, the loved tracks API does not support a `from` timestamp filter,
192    /// so all tracks are fetched and those already present (by timestamp) are filtered out.
193    ///
194    /// # Arguments
195    /// * `file_path` - Path to the JSON file to update (or create)
196    ///
197    /// # Errors
198    /// Returns an error if the HTTP request fails, the response cannot be parsed, or the file
199    /// cannot be read or written.
200    ///
201    /// # Returns
202    /// * `Result<usize>` - Number of new tracks prepended
203    pub async fn fetch_and_update(self, file_path: &str) -> Result<usize> {
204        let ext = std::path::Path::new(file_path)
205            .extension()
206            .and_then(|e| e.to_str())
207            .map(str::to_ascii_lowercase);
208        let is_csv = ext.as_deref() == Some("csv");
209        let is_ndjson = ext.as_deref() == Some("ndjson");
210
211        let max_existing_ts = if let Some(ts) = FileHandler::read_sidecar_timestamp(file_path) {
212            Some(ts)
213        } else if !is_csv && !is_ndjson && std::path::Path::new(file_path).exists() {
214            let existing: Vec<LovedTrack> =
215                FileHandler::load(file_path).map_err(crate::error::LastFmError::Io)?;
216            let ts = existing.iter().filter_map(Timestamped::get_timestamp).max();
217            if let Some(t) = ts {
218                FileHandler::write_sidecar_timestamp(file_path, t)
219                    .map_err(crate::error::LastFmError::Io)?;
220            }
221            ts
222        } else {
223            None
224        };
225
226        let all_tracks = self.fetch().await?;
227
228        let new_tracks: Vec<LovedTrack> = match max_existing_ts {
229            Some(max_ts) => all_tracks
230                .into_iter()
231                .filter(|t| t.get_timestamp().is_some_and(|ts| ts > max_ts))
232                .collect(),
233            None => all_tracks.into(),
234        };
235
236        let count = new_tracks.len();
237
238        if !new_tracks.is_empty() {
239            if let Some(latest_ts) = new_tracks
240                .first()
241                .and_then(crate::types::Timestamped::get_timestamp)
242            {
243                FileHandler::write_sidecar_timestamp(file_path, latest_ts)
244                    .map_err(crate::error::LastFmError::Io)?;
245            }
246            if is_csv {
247                FileHandler::append_or_create_csv(&new_tracks, file_path)
248                    .map_err(crate::error::LastFmError::Io)?;
249            } else if is_ndjson {
250                FileHandler::append_or_create_ndjson(&new_tracks, file_path)
251                    .map_err(crate::error::LastFmError::Io)?;
252            } else {
253                FileHandler::prepend_json(&new_tracks, file_path)
254                    .map_err(crate::error::LastFmError::Io)?;
255            }
256        }
257
258        Ok(count)
259    }
260
261    /// Analyze tracks and return statistics
262    ///
263    /// # Arguments
264    /// * `threshold` - Minimum play count threshold. Tracks with fewer plays than this value will be
265    ///   counted separately in `tracks_below_threshold`. For example, use 5 to identify
266    ///   tracks played less than 5 times.
267    ///
268    /// # Errors
269    /// Returns an error if the HTTP request fails or the response cannot be parsed.
270    ///
271    /// # Returns
272    /// * `Result<crate::analytics::TrackStats>` - Analysis results including play counts, most played tracks, etc.
273    pub async fn analyze(self, threshold: usize) -> Result<crate::analytics::TrackStats> {
274        let tracks = self.fetch().await?;
275        Ok(AnalysisHandler::analyze_tracks(&tracks, threshold))
276    }
277
278    /// Analyze tracks and print statistics
279    ///
280    /// # Arguments
281    /// * `threshold` - Minimum play count threshold. Tracks with fewer plays than this value will be
282    ///   counted separately. For example, use 5 to identify tracks played less than 5 times.
283    ///
284    /// # Errors
285    /// Returns an error if the HTTP request fails or the response cannot be parsed.
286    pub async fn analyze_and_print(self, threshold: usize) -> Result<()> {
287        let stats = self.analyze(threshold).await?;
288        AnalysisHandler::print_analysis(&stats);
289        Ok(())
290    }
291
292    async fn fetch_tracks<T>(&self, limit: TrackLimit) -> Result<Vec<LovedTrack>>
293    where
294        T: DeserializeOwned + ResourceContainer<ItemType = LovedTrack>,
295    {
296        use crate::url_builder::QueryParams;
297
298        fetch::<LovedTrack, T>(
299            self.http.clone(),
300            self.config.clone(),
301            self.username.clone(),
302            "user.getlovedtracks",
303            limit,
304            QueryParams::new(),
305            None,
306        )
307        .await
308    }
309}
310
311impl ResourceContainer for UserLovedTracks {
312    type ItemType = LovedTrack;
313
314    fn total(&self) -> u32 {
315        self.lovedtracks.attr.total
316    }
317
318    fn items(self) -> Vec<Self::ItemType> {
319        self.lovedtracks.track
320    }
321}