Skip to main content

lastfm_client/api/
builder_ext.rs

1//! Extension traits shared across all API request builders.
2//!
3//! Import these traits to access the `limit`, `unlimited`, `fetch_and_save`,
4//! `fetch_and_save_sqlite`, `fetch_and_update`, `fetch_and_update_sqlite`,
5//! `analyze`, and `analyze_and_print` methods on any builder type.
6
7use crate::file_handler::{FileFormat, FileHandler};
8use crate::types::Timestamped;
9
10/// Extension trait providing `limit` and `unlimited` builder methods.
11///
12/// Import this trait to set a fetch limit on any request builder.
13///
14/// # Example
15/// ```rust,ignore
16/// use lastfm_client::{LastFmClient, prelude::*};
17///
18/// let tracks = client.top_tracks("username")
19///     .limit(50)
20///     .fetch()
21///     .await?;
22/// ```
23pub trait LimitBuilder: Sized {
24    /// Returns a mutable reference to the builder's `limit` field.
25    ///
26    /// Implement this to opt-in to the default `limit` and `unlimited` methods.
27    #[doc(hidden)]
28    fn limit_mut(&mut self) -> &mut Option<u32>;
29
30    /// Set the maximum number of items to fetch.
31    ///
32    /// # Arguments
33    /// * `n` - Maximum number of items. The Last.fm API supports up to thousands of items.
34    ///   Use `unlimited()` to fetch everything.
35    #[must_use]
36    fn limit(mut self, n: u32) -> Self {
37        *self.limit_mut() = Some(n);
38
39        self
40    }
41
42    /// Fetch all available items (no limit).
43    #[must_use]
44    fn unlimited(mut self) -> Self {
45        *self.limit_mut() = None;
46
47        self
48    }
49}
50
51/// Extension trait providing `fetch_and_save` and `fetch_and_save_sqlite` for request builders.
52#[allow(async_fn_in_trait)]
53///
54/// Import this trait to save fetched data directly to a file or `SQLite` database.
55///
56/// # Example
57/// ```rust,ignore
58/// use lastfm_client::{LastFmClient, FetchAndSave};
59///
60/// let path = client.top_tracks("username")
61///     .fetch_and_save(FileFormat::Json, "top_tracks")
62///     .await?;
63/// ```
64pub trait FetchAndSave: Sized {
65    /// The item type produced by this builder.
66    type Item: serde::Serialize + serde::de::DeserializeOwned + Send + Sync + 'static;
67
68    /// A human-readable label used in log messages (e.g. `"top tracks"`).
69    fn resource_label() -> &'static str;
70
71    /// Return the most recent timestamp from the given items, used to write a sidecar file
72    /// after saving. Return `None` (the default) if the item type has no timestamp.
73    fn latest_timestamp(_items: &[Self::Item]) -> Option<u32> {
74        None
75    }
76
77    /// Execute the underlying fetch and return items.
78    ///
79    /// This internal method is called by the provided `fetch_and_save` default implementations.
80    /// Use the builder's own `fetch` method for direct access.
81    #[doc(hidden)]
82    async fn do_fetch(self) -> crate::error::Result<Vec<Self::Item>>;
83
84    /// Fetch items and save them to a file.
85    ///
86    /// # Arguments
87    /// * `format` - The file format to save the items in
88    /// * `filename_prefix` - Prefix for the generated filename
89    ///
90    /// # Errors
91    /// Returns an error if the HTTP request fails, the response cannot be parsed, or the file
92    /// cannot be saved.
93    ///
94    /// # Returns
95    /// * `Result<String>` - The filename of the saved file
96    async fn fetch_and_save(
97        self,
98        format: FileFormat,
99        filename_prefix: &str,
100    ) -> crate::error::Result<String> {
101        let items = self.do_fetch().await?;
102        tracing::info!("Saving {} {} to file", items.len(), Self::resource_label());
103
104        let filename = FileHandler::save(&items, &format, filename_prefix)
105            .map_err(crate::error::LastFmError::Io)?;
106
107        if let Some(latest_ts) = Self::latest_timestamp(&items) {
108            FileHandler::write_sidecar_timestamp(&filename, latest_ts)
109                .map_err(crate::error::LastFmError::Io)?;
110        }
111
112        Ok(filename)
113    }
114
115    /// Fetch items and save them to a new `SQLite` database file.
116    ///
117    /// # Arguments
118    /// * `filename_prefix` - Prefix for the generated filename
119    ///
120    /// # Errors
121    /// Returns an error if the HTTP request fails, the response cannot be parsed, or the
122    /// database cannot be saved.
123    ///
124    /// # Returns
125    /// * `Result<String>` - Path to the saved database file
126    #[cfg(feature = "sqlite")]
127    async fn fetch_and_save_sqlite(self, filename_prefix: &str) -> crate::error::Result<String>
128    where
129        Self::Item: crate::sqlite::SqliteExportable,
130    {
131        let items = self.do_fetch().await?;
132
133        tracing::info!(
134            "Saving {} {} to SQLite",
135            items.len(),
136            Self::resource_label()
137        );
138
139        FileHandler::save_sqlite(&items, filename_prefix).map_err(crate::error::LastFmError::Io)
140    }
141}
142
143/// Extension trait providing `fetch_and_update` and `fetch_and_update_sqlite` for request
144/// builders whose items carry a timestamp.
145///
146/// Implementations decide how to apply the timestamp - either as a server-side API filter
147/// (e.g. `since`) or as an in-memory filter after fetching everything.
148#[allow(async_fn_in_trait)]
149pub trait FetchAndUpdate: Sized {
150    /// The item type produced by this builder.
151    type Item: serde::Serialize
152        + serde::de::DeserializeOwned
153        + Timestamped
154        + Clone
155        + Send
156        + Sync
157        + 'static;
158
159    /// Fetch items that are newer than `max_ts`, or all items if `None`.
160    ///
161    /// Each builder implements this to decide whether to apply the filter on the API side
162    /// (more efficient) or in memory after fetching.
163    async fn fetch_since(self, max_ts: Option<u32>) -> crate::error::Result<Vec<Self::Item>>;
164
165    /// Fetch only items newer than the most recent entry in an existing file and prepend them
166    /// to it. Creates the file if it does not exist.
167    ///
168    /// The latest timestamp is read from a sidecar file, falling back to scanning the JSON
169    /// file itself. CSV and NDJSON files rely exclusively on the sidecar.
170    ///
171    /// # Errors
172    /// Returns an error if the HTTP request fails, the response cannot be parsed, or the file
173    /// cannot be read or written.
174    ///
175    /// # Returns
176    /// * `Result<usize>` - Number of new items prepended
177    async fn fetch_and_update(self, file_path: &str) -> crate::error::Result<usize> {
178        let ext = std::path::Path::new(file_path)
179            .extension()
180            .and_then(|e| e.to_str())
181            .map(str::to_ascii_lowercase);
182        let is_csv = ext.as_deref() == Some("csv");
183        let is_ndjson = ext.as_deref() == Some("ndjson");
184
185        let max_ts = if let Some(ts) = FileHandler::read_sidecar_timestamp(file_path) {
186            Some(ts)
187        } else if !is_csv && !is_ndjson && std::path::Path::new(file_path).exists() {
188            let existing: Vec<Self::Item> =
189                FileHandler::load(file_path).map_err(crate::error::LastFmError::Io)?;
190            let ts = existing.iter().filter_map(Timestamped::get_timestamp).max();
191
192            if let Some(t) = ts {
193                FileHandler::write_sidecar_timestamp(file_path, t)
194                    .map_err(crate::error::LastFmError::Io)?;
195            }
196
197            ts
198        } else {
199            None
200        };
201
202        let new_items = self.fetch_since(max_ts).await?;
203        let count = new_items.len();
204
205        if !new_items.is_empty() {
206            if let Some(latest_ts) = new_items.first().and_then(Timestamped::get_timestamp) {
207                FileHandler::write_sidecar_timestamp(file_path, latest_ts)
208                    .map_err(crate::error::LastFmError::Io)?;
209            }
210
211            if is_csv {
212                FileHandler::append_or_create_csv(&new_items, file_path)
213                    .map_err(crate::error::LastFmError::Io)?;
214            } else if is_ndjson {
215                FileHandler::append_or_create_ndjson(&new_items, file_path)
216                    .map_err(crate::error::LastFmError::Io)?;
217            } else {
218                FileHandler::prepend_json(&new_items, file_path)
219                    .map_err(crate::error::LastFmError::Io)?;
220            }
221        }
222
223        Ok(count)
224    }
225
226    /// Fetch only items newer than the most recent entry in an existing `SQLite` database and
227    /// append them to it. Creates the database if it does not exist.
228    ///
229    /// The latest timestamp is determined by querying `MAX(date_uts)` directly from the
230    /// database - no sidecar file is needed.
231    ///
232    /// # Errors
233    /// Returns an error if the HTTP request fails, the response cannot be parsed, or the
234    /// database cannot be written.
235    ///
236    /// # Returns
237    /// * `Result<usize>` - Number of new items inserted
238    #[cfg(feature = "sqlite")]
239    async fn fetch_and_update_sqlite(self, db_path: &str) -> crate::error::Result<usize>
240    where
241        Self::Item: crate::sqlite::SqliteExportable,
242    {
243        let max_ts = FileHandler::read_sqlite_max_timestamp(
244            db_path,
245            <Self::Item as crate::sqlite::SqliteExportable>::table_name(),
246        );
247
248        let new_items = self.fetch_since(max_ts).await?;
249        let count = new_items.len();
250
251        if !new_items.is_empty() {
252            FileHandler::append_or_create_sqlite(&new_items, db_path)
253                .map_err(crate::error::LastFmError::Io)?;
254        }
255
256        Ok(count)
257    }
258}
259
260/// Extension trait providing `analyze` and `analyze_and_print` for request builders.
261///
262/// A blanket implementation is provided for every builder that implements [`FetchAndSave`]
263/// whose item type implements [`crate::analytics::TrackAnalyzable`]. Import this trait to
264/// call `.analyze(threshold)` and `.analyze_and_print(threshold)` on any qualifying builder.
265///
266/// # Example
267/// ```rust,ignore
268/// use lastfm_client::{LastFmClient, Analyze};
269///
270/// let stats = client.recent_tracks("username")
271///     .analyze(5)
272///     .await?;
273/// ```
274#[allow(async_fn_in_trait)]
275pub trait Analyze: Sized {
276    /// The item type produced by this builder.
277    type Item: crate::analytics::TrackAnalyzable;
278
279    /// Execute the underlying fetch and return items for analysis.
280    #[doc(hidden)]
281    async fn do_fetch_for_analyze(self) -> crate::error::Result<Vec<Self::Item>>;
282
283    /// Fetch items and return play-count statistics.
284    ///
285    /// # Arguments
286    /// * `threshold` - Tracks with fewer plays than this are counted in
287    ///   `tracks_below_threshold`.
288    ///
289    /// # Errors
290    /// Returns an error if the HTTP request fails or the response cannot be parsed.
291    async fn analyze(self, threshold: usize) -> crate::error::Result<crate::analytics::TrackStats> {
292        let items = self.do_fetch_for_analyze().await?;
293
294        Ok(crate::analytics::AnalysisHandler::analyze_tracks(
295            &items, threshold,
296        ))
297    }
298
299    /// Fetch items, compute statistics, and print them to stdout.
300    ///
301    /// # Arguments
302    /// * `threshold` - Tracks with fewer plays than this are counted in
303    ///   `tracks_below_threshold`.
304    ///
305    /// # Errors
306    /// Returns an error if the HTTP request fails or the response cannot be parsed.
307    async fn analyze_and_print(self, threshold: usize) -> crate::error::Result<()> {
308        let stats = self.analyze(threshold).await?;
309
310        crate::analytics::AnalysisHandler::print_analysis(&stats);
311
312        Ok(())
313    }
314}
315
316impl<T> Analyze for T
317where
318    T: FetchAndSave,
319    T::Item: crate::analytics::TrackAnalyzable,
320{
321    type Item = T::Item;
322
323    async fn do_fetch_for_analyze(self) -> crate::error::Result<Vec<Self::Item>> {
324        self.do_fetch().await
325    }
326}