Skip to main content

lastfm_client/api/
recent_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::{
7    RecentTrack, RecentTrackExtended, Timestamped, TrackLimit, TrackList, UserRecentTracks,
8    UserRecentTracksExtended,
9};
10use crate::url_builder::QueryParams;
11
12use serde::de::DeserializeOwned;
13use std::fmt;
14use std::sync::Arc;
15
16use super::fetch_utils::{ProgressCallback, ResourceContainer, fetch};
17
18/// Client for fetching recent tracks
19pub struct RecentTracksClient {
20    http: Arc<dyn HttpClient>,
21    config: Arc<Config>,
22}
23
24impl fmt::Debug for RecentTracksClient {
25    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
26        f.debug_struct("RecentTracksClient")
27            .field("config", &self.config)
28            .finish_non_exhaustive()
29    }
30}
31
32impl RecentTracksClient {
33    /// Create a new recent tracks client
34    pub fn new(http: Arc<dyn HttpClient>, config: Arc<Config>) -> Self {
35        Self { http, config }
36    }
37
38    /// Create a builder for recent tracks requests
39    pub fn builder(&self, username: impl Into<String>) -> RecentTracksRequestBuilder {
40        RecentTracksRequestBuilder::new(self.http.clone(), self.config.clone(), username.into())
41    }
42}
43
44/// Builder for recent tracks requests
45pub struct RecentTracksRequestBuilder {
46    http: Arc<dyn HttpClient>,
47    config: Arc<Config>,
48    username: String,
49    limit: Option<u32>,
50    from: Option<i64>,
51    to: Option<i64>,
52    extended: bool,
53    progress_callback: Option<ProgressCallback>,
54}
55
56impl fmt::Debug for RecentTracksRequestBuilder {
57    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58        f.debug_struct("RecentTracksRequestBuilder")
59            .field("username", &self.username)
60            .field("limit", &self.limit)
61            .field("from", &self.from)
62            .field("to", &self.to)
63            .field("extended", &self.extended)
64            .finish_non_exhaustive()
65    }
66}
67
68impl RecentTracksRequestBuilder {
69    fn new(http: Arc<dyn HttpClient>, config: Arc<Config>, username: String) -> Self {
70        Self {
71            http,
72            config,
73            username,
74            limit: None,
75            from: None,
76            to: None,
77            extended: false,
78            progress_callback: None,
79        }
80    }
81
82    /// Set the maximum number of tracks to fetch
83    ///
84    /// # Arguments
85    /// * `limit` - Maximum number of tracks to fetch. The Last.fm API supports fetching up to thousands of tracks.
86    ///   If you need all tracks, use `unlimited()` instead.
87    #[must_use]
88    pub const fn limit(mut self, limit: u32) -> Self {
89        self.limit = Some(limit);
90        self
91    }
92
93    /// Fetch all available tracks (no limit)
94    #[must_use]
95    pub const fn unlimited(mut self) -> Self {
96        self.limit = None;
97        self
98    }
99
100    /// Fetch tracks from this timestamp onwards
101    ///
102    /// # Arguments
103    /// * `timestamp` - Unix timestamp in seconds (not milliseconds) since January 1, 1970 UTC
104    ///
105    /// # Example
106    /// ```ignore
107    /// // Fetch tracks since January 1, 2024 00:00:00 UTC
108    /// let tracks = client.recent_tracks()
109    ///     .builder("username")
110    ///     .since(1704067200)
111    ///     .fetch()
112    ///     .await?;
113    /// ```
114    #[must_use]
115    pub const fn since(mut self, timestamp: i64) -> Self {
116        self.from = Some(timestamp);
117        self
118    }
119
120    /// Fetch tracks between two timestamps
121    ///
122    /// # Arguments
123    /// * `from` - Start Unix timestamp in seconds (not milliseconds) since January 1, 1970 UTC
124    /// * `to` - End Unix timestamp in seconds (not milliseconds) since January 1, 1970 UTC
125    ///
126    /// # Example
127    /// ```ignore
128    /// // Fetch tracks between January 1, 2024 and February 1, 2024 (UTC)
129    /// let tracks = client.recent_tracks()
130    ///     .builder("username")
131    ///     .between(1704067200, 1706745600)
132    ///     .fetch()
133    ///     .await?;
134    /// ```
135    #[must_use]
136    pub const fn between(mut self, from: i64, to: i64) -> Self {
137        self.from = Some(from);
138        self.to = Some(to);
139        self
140    }
141
142    /// Fetch extended track information
143    #[must_use]
144    pub const fn extended(mut self, extended: bool) -> Self {
145        self.extended = extended;
146        self
147    }
148
149    /// Register a progress callback invoked with `(fetched, total)` after each batch.
150    #[must_use]
151    pub fn on_progress(mut self, callback: impl Fn(u32, u32) + Send + Sync + 'static) -> Self {
152        self.progress_callback = Some(Arc::new(callback));
153        self
154    }
155
156    /// Fetch the tracks
157    ///
158    /// # Errors
159    /// Returns an error if:
160    /// - The HTTP request fails or the response cannot be parsed
161    /// - The date range is invalid (to <= from when both timestamps are set)
162    pub async fn fetch(self) -> Result<TrackList<RecentTrack>> {
163        // Validate date range if both from and to are set
164        if let (Some(from), Some(to)) = (self.from, self.to)
165            && to <= from
166        {
167            return Err(crate::error::LastFmError::Config(format!(
168                "Invalid date range: 'to' timestamp ({to}) must be greater than 'from' timestamp ({from})"
169            )));
170        }
171
172        let mut params = self.build_params();
173
174        if self.extended {
175            params.insert("extended".to_string(), "1".to_string());
176        }
177
178        let limit = self
179            .limit
180            .map_or(TrackLimit::Unlimited, TrackLimit::Limited);
181
182        self.fetch_tracks::<UserRecentTracks>(limit, params)
183            .await
184            .map(TrackList::from)
185    }
186
187    /// Fetch tracks with extended information
188    ///
189    /// # Errors
190    /// Returns an error if:
191    /// - The HTTP request fails or the response cannot be parsed
192    /// - The date range is invalid (to <= from when both timestamps are set)
193    pub async fn fetch_extended(self) -> Result<TrackList<RecentTrackExtended>> {
194        // Validate date range if both from and to are set
195        if let (Some(from), Some(to)) = (self.from, self.to)
196            && to <= from
197        {
198            return Err(crate::error::LastFmError::Config(format!(
199                "Invalid date range: 'to' timestamp ({to}) must be greater than 'from' timestamp ({from})"
200            )));
201        }
202
203        let mut params = self.build_params();
204        params.insert("extended".to_string(), "1".to_string());
205
206        let limit = self
207            .limit
208            .map_or(TrackLimit::Unlimited, TrackLimit::Limited);
209
210        self.fetch_tracks_extended::<UserRecentTracksExtended>(limit, params)
211            .await
212            .map(TrackList::from)
213    }
214
215    /// Fetch tracks and save them to a file
216    ///
217    /// # Arguments
218    /// * `format` - The file format to save the tracks in
219    /// * `filename_prefix` - Prefix for the generated filename
220    ///
221    /// # Errors
222    /// Returns an error if the HTTP request fails, response cannot be parsed, or file cannot be saved.
223    ///
224    /// # Returns
225    /// * `Result<String>` - The filename of the saved file
226    pub async fn fetch_and_save(self, format: FileFormat, filename_prefix: &str) -> Result<String> {
227        let tracks = self.fetch().await?;
228        tracing::info!("Saving {} recent tracks to file", tracks.len());
229        let filename = FileHandler::save(&tracks, &format, filename_prefix)
230            .map_err(crate::error::LastFmError::Io)?;
231        if let Some(latest_ts) = tracks
232            .first()
233            .and_then(crate::types::Timestamped::get_timestamp)
234        {
235            FileHandler::write_sidecar_timestamp(&filename, latest_ts)
236                .map_err(crate::error::LastFmError::Io)?;
237        }
238        Ok(filename)
239    }
240
241    /// Fetch tracks with extended information and save them to a file
242    ///
243    /// # Arguments
244    /// * `format` - The file format to save the tracks in
245    /// * `filename_prefix` - Prefix for the generated filename
246    ///
247    /// # Errors
248    /// Returns an error if the HTTP request fails, response cannot be parsed, or file cannot be saved.
249    ///
250    /// # Returns
251    /// * `Result<String>` - The filename of the saved file
252    pub async fn fetch_extended_and_save(
253        self,
254        format: FileFormat,
255        filename_prefix: &str,
256    ) -> Result<String> {
257        let tracks = self.fetch_extended().await?;
258        tracing::info!("Saving {} recent tracks (extended) to file", tracks.len());
259
260        let filename = FileHandler::save(&tracks, &format, filename_prefix)
261            .map_err(crate::error::LastFmError::Io)?;
262
263        if let Some(latest_ts) = tracks
264            .first()
265            .and_then(crate::types::Timestamped::get_timestamp)
266        {
267            FileHandler::write_sidecar_timestamp(&filename, latest_ts)
268                .map_err(crate::error::LastFmError::Io)?;
269        }
270        Ok(filename)
271    }
272
273    /// Fetch only tracks newer than the most recent entry in an existing JSON file and prepend
274    /// them to it. If the file does not exist, all tracks are fetched and the file is created.
275    ///
276    /// # Arguments
277    /// * `file_path` - Path to the JSON file to update (or create)
278    ///
279    /// # Errors
280    /// Returns an error if the HTTP request fails, the response cannot be parsed, or the file
281    /// cannot be read or written.
282    ///
283    /// # Returns
284    /// * `Result<usize>` - Number of new tracks prepended
285    pub async fn fetch_and_update(self, file_path: &str) -> Result<usize> {
286        self.update_impl(file_path, Self::fetch).await
287    }
288
289    /// Fetch only extended tracks newer than the most recent entry in an existing JSON file and
290    /// prepend them to it. If the file does not exist, all tracks are fetched and the file is
291    /// created.
292    ///
293    /// # Arguments
294    /// * `file_path` - Path to the JSON file to update (or create)
295    ///
296    /// # Errors
297    /// Returns an error if the HTTP request fails, the response cannot be parsed, or the file
298    /// cannot be read or written.
299    ///
300    /// # Returns
301    /// * `Result<usize>` - Number of new tracks prepended
302    pub async fn fetch_extended_and_update(self, file_path: &str) -> Result<usize> {
303        self.update_impl(file_path, Self::fetch_extended).await
304    }
305
306    async fn update_impl<T, F, Fut>(self, file_path: &str, fetch_fn: F) -> Result<usize>
307    where
308        T: serde::de::DeserializeOwned + serde::Serialize + Clone + Timestamped,
309        F: FnOnce(Self) -> Fut,
310        Fut: std::future::Future<Output = Result<TrackList<T>>>,
311    {
312        let ext = std::path::Path::new(file_path)
313            .extension()
314            .and_then(|e| e.to_str())
315            .map(str::to_ascii_lowercase);
316        let is_csv = ext.as_deref() == Some("csv");
317        let is_ndjson = ext.as_deref() == Some("ndjson");
318
319        let since_timestamp = if let Some(ts) = FileHandler::read_sidecar_timestamp(file_path) {
320            // Fast path: sidecar has the latest timestamp, no need to read the full file.
321            Some(ts)
322        } else if !is_csv && !is_ndjson && std::path::Path::new(file_path).exists() {
323            // Slow path for JSON only - CSV/NDJSON round-trips through serde are unreliable for
324            // complex nested types, so the sidecar is the only trusted timestamp source.
325            let existing: Vec<T> =
326                FileHandler::load(file_path).map_err(crate::error::LastFmError::Io)?;
327            let ts = existing.iter().filter_map(Timestamped::get_timestamp).max();
328            if let Some(t) = ts {
329                FileHandler::write_sidecar_timestamp(file_path, t)
330                    .map_err(crate::error::LastFmError::Io)?;
331            }
332            ts
333        } else {
334            None
335        };
336
337        let builder = match since_timestamp {
338            Some(ts) => self.since(i64::from(ts) + 1),
339            None => self,
340        };
341
342        let new_tracks = fetch_fn(builder).await?;
343        let count = new_tracks.len();
344
345        if !new_tracks.is_empty() {
346            if let Some(latest_ts) = new_tracks.first().and_then(Timestamped::get_timestamp) {
347                FileHandler::write_sidecar_timestamp(file_path, latest_ts)
348                    .map_err(crate::error::LastFmError::Io)?;
349            }
350            if is_csv {
351                FileHandler::append_or_create_csv(&new_tracks, file_path)
352                    .map_err(crate::error::LastFmError::Io)?;
353            } else if is_ndjson {
354                FileHandler::append_or_create_ndjson(&new_tracks, file_path)
355                    .map_err(crate::error::LastFmError::Io)?;
356            } else {
357                FileHandler::prepend_json(&new_tracks, file_path)
358                    .map_err(crate::error::LastFmError::Io)?;
359            }
360        }
361
362        Ok(count)
363    }
364
365    /// Fetch tracks and save them to a new `SQLite` database file.
366    ///
367    /// # Arguments
368    /// * `filename_prefix` - Prefix for the generated filename
369    ///
370    /// # Errors
371    /// Returns an error if the HTTP request fails, the response cannot be parsed, or the database cannot be saved.
372    ///
373    /// # Returns
374    /// * `Result<String>` - Path to the saved database file
375    #[cfg(feature = "sqlite")]
376    pub async fn fetch_and_save_sqlite(
377        self,
378        filename_prefix: &str,
379    ) -> crate::error::Result<String> {
380        let tracks = self.fetch().await?;
381        tracing::info!("Saving {} recent tracks to SQLite", tracks.len());
382        crate::file_handler::FileHandler::save_sqlite(&tracks, filename_prefix)
383            .map_err(crate::error::LastFmError::Io)
384    }
385
386    /// Fetch only tracks newer than the most recent entry in an existing `SQLite` database and
387    /// append them to it. If the database file does not exist, all tracks are fetched and
388    /// the database is created.
389    ///
390    /// The latest timestamp is determined by querying `MAX(date_uts)` directly from the
391    /// database - no sidecar file is needed.
392    ///
393    /// # Arguments
394    /// * `db_path` - Path to the `SQLite` database file to update (or create)
395    ///
396    /// # Errors
397    /// Returns an error if the HTTP request fails, the response cannot be parsed, or the database cannot be written.
398    ///
399    /// # Returns
400    /// * `Result<usize>` - Number of new tracks inserted
401    #[cfg(feature = "sqlite")]
402    pub async fn fetch_and_update_sqlite(self, db_path: &str) -> crate::error::Result<usize> {
403        let since_timestamp = crate::file_handler::FileHandler::read_sqlite_max_timestamp(
404            db_path,
405            <RecentTrack as crate::sqlite::SqliteExportable>::table_name(),
406        );
407
408        let builder = match since_timestamp {
409            Some(ts) => self.since(i64::from(ts) + 1),
410            None => self,
411        };
412
413        let new_tracks = builder.fetch().await?;
414        let count = new_tracks.len();
415
416        if !new_tracks.is_empty() {
417            crate::file_handler::FileHandler::append_or_create_sqlite(&new_tracks, db_path)
418                .map_err(crate::error::LastFmError::Io)?;
419        }
420
421        Ok(count)
422    }
423
424    /// Fetch extended tracks and save them to a new `SQLite` database file.
425    ///
426    /// # Arguments
427    /// * `filename_prefix` - Prefix for the generated filename
428    ///
429    /// # Errors
430    /// Returns an error if the HTTP request fails, the response cannot be parsed, or the database cannot be saved.
431    ///
432    /// # Returns
433    /// * `Result<String>` - Path to the saved database file
434    #[cfg(feature = "sqlite")]
435    pub async fn fetch_extended_and_save_sqlite(
436        self,
437        filename_prefix: &str,
438    ) -> crate::error::Result<String> {
439        let tracks = self.fetch_extended().await?;
440        tracing::info!("Saving {} extended recent tracks to SQLite", tracks.len());
441        crate::file_handler::FileHandler::save_sqlite(&tracks, filename_prefix)
442            .map_err(crate::error::LastFmError::Io)
443    }
444
445    /// Fetch only extended tracks newer than the most recent entry in an existing `SQLite` database
446    /// and append them to it. If the database file does not exist, all tracks are fetched and
447    /// the database is created.
448    ///
449    /// The latest timestamp is determined by querying `MAX(date_uts)` directly from the
450    /// database - no sidecar file is needed.
451    ///
452    /// # Arguments
453    /// * `db_path` - Path to the `SQLite` database file to update (or create)
454    ///
455    /// # Errors
456    /// Returns an error if the HTTP request fails, the response cannot be parsed, or the database cannot be written.
457    ///
458    /// # Returns
459    /// * `Result<usize>` - Number of new tracks inserted
460    #[cfg(feature = "sqlite")]
461    pub async fn fetch_extended_and_update_sqlite(
462        self,
463        db_path: &str,
464    ) -> crate::error::Result<usize> {
465        let since_timestamp = crate::file_handler::FileHandler::read_sqlite_max_timestamp(
466            db_path,
467            <RecentTrackExtended as crate::sqlite::SqliteExportable>::table_name(),
468        );
469
470        let builder = match since_timestamp {
471            Some(ts) => self.since(i64::from(ts) + 1),
472            None => self,
473        };
474
475        let new_tracks = builder.fetch_extended().await?;
476        let count = new_tracks.len();
477
478        if !new_tracks.is_empty() {
479            crate::file_handler::FileHandler::append_or_create_sqlite(&new_tracks, db_path)
480                .map_err(crate::error::LastFmError::Io)?;
481        }
482
483        Ok(count)
484    }
485
486    /// Analyze tracks and return statistics
487    ///
488    /// # Arguments
489    /// * `threshold` - Minimum play count threshold. Tracks with fewer plays than this value will be
490    ///   counted separately in `tracks_below_threshold`. For example, use 5 to identify
491    ///   tracks played less than 5 times.
492    ///
493    /// # Errors
494    /// Returns an error if the HTTP request fails or the response cannot be parsed.
495    ///
496    /// # Returns
497    /// * `Result<crate::analytics::TrackStats>` - Analysis results including play counts, most played tracks, etc.
498    pub async fn analyze(self, threshold: usize) -> Result<crate::analytics::TrackStats> {
499        let tracks = self.fetch().await?;
500        Ok(AnalysisHandler::analyze_tracks(&tracks, threshold))
501    }
502
503    /// Analyze tracks and print statistics
504    ///
505    /// # Arguments
506    /// * `threshold` - Minimum play count threshold. Tracks with fewer plays than this value will be
507    ///   counted separately. For example, use 5 to identify tracks played less than 5 times.
508    ///
509    /// # Errors
510    /// Returns an error if the HTTP request fails or the response cannot be parsed.
511    pub async fn analyze_and_print(self, threshold: usize) -> Result<()> {
512        let stats = self.analyze(threshold).await?;
513        AnalysisHandler::print_analysis(&stats);
514        Ok(())
515    }
516
517    /// Check if the user is currently playing a track
518    ///
519    /// # Errors
520    /// Returns an error if the HTTP request fails or the response cannot be parsed.
521    ///
522    /// # Returns
523    /// * `Result<Option<RecentTrack>>` - The currently playing track if any
524    pub async fn check_currently_playing(self) -> Result<Option<RecentTrack>> {
525        let tracks = self.limit(1).fetch().await?;
526
527        // Check if the first track has the "now playing" attribute
528        Ok(tracks.first().and_then(|track| {
529            if track
530                .attr
531                .as_ref()
532                .is_some_and(|val| val.nowplaying == "true")
533            {
534                Some(track.clone())
535            } else {
536                None
537            }
538        }))
539    }
540
541    fn build_params(&self) -> QueryParams {
542        let mut params = QueryParams::new();
543
544        if let Some(from_timestamp) = self.from {
545            params.insert("from".to_string(), from_timestamp.to_string());
546        }
547
548        if let Some(to_timestamp) = self.to {
549            params.insert("to".to_string(), to_timestamp.to_string());
550        }
551
552        params
553    }
554
555    async fn fetch_tracks<T>(
556        &self,
557        limit: TrackLimit,
558        additional_params: QueryParams,
559    ) -> Result<Vec<RecentTrack>>
560    where
561        T: DeserializeOwned + ResourceContainer<ItemType = RecentTrack>,
562    {
563        fetch::<RecentTrack, T>(
564            self.http.clone(),
565            self.config.clone(),
566            self.username.clone(),
567            "user.getrecenttracks",
568            limit,
569            additional_params,
570            self.progress_callback.as_ref(),
571        )
572        .await
573    }
574
575    async fn fetch_tracks_extended<T>(
576        &self,
577        limit: TrackLimit,
578        additional_params: QueryParams,
579    ) -> Result<Vec<RecentTrackExtended>>
580    where
581        T: DeserializeOwned + ResourceContainer<ItemType = RecentTrackExtended>,
582    {
583        fetch::<RecentTrackExtended, T>(
584            self.http.clone(),
585            self.config.clone(),
586            self.username.clone(),
587            "user.getrecenttracks",
588            limit,
589            additional_params,
590            self.progress_callback.as_ref(),
591        )
592        .await
593    }
594}
595
596impl ResourceContainer for UserRecentTracks {
597    type ItemType = RecentTrack;
598
599    fn total(&self) -> u32 {
600        self.recenttracks.attr.total
601    }
602
603    fn items(self) -> Vec<Self::ItemType> {
604        self.recenttracks.track
605    }
606}
607
608impl ResourceContainer for UserRecentTracksExtended {
609    type ItemType = RecentTrackExtended;
610
611    fn total(&self) -> u32 {
612        self.recenttracks.attr.total
613    }
614
615    fn items(self) -> Vec<Self::ItemType> {
616        self.recenttracks.track
617    }
618}