lastfm_edit/
trait.rs

1use crate::iterator::AsyncPaginatedIterator;
2use crate::types::{
3    Album, Artist, ArtistPage, ClientEvent, ClientEventReceiver, EditResponse, ExactScrobbleEdit,
4    LastFmEditSession, LastFmError, ScrobbleEdit, Track,
5};
6use crate::Result;
7use async_trait::async_trait;
8
9/// Trait for Last.fm client operations that can be mocked for testing.
10///
11/// This trait abstracts the core functionality needed for Last.fm scrobble editing
12/// to enable easy mocking and testing. All methods that perform network operations or
13/// state changes are included to support comprehensive test coverage.
14///
15/// # Mocking Support
16///
17/// When the `mock` feature is enabled, this crate provides `MockLastFmEditClient`
18/// that implements this trait using the `mockall` library.
19///
20#[cfg_attr(feature = "mock", mockall::automock)]
21#[async_trait(?Send)]
22pub trait LastFmEditClient {
23    // =============================================================================
24    // CORE EDITING METHODS - Most important functionality
25    // =============================================================================
26
27    /// Edit scrobbles by discovering and updating all matching instances.
28    ///
29    /// This is the main editing method that automatically discovers all scrobble instances
30    /// that match the provided criteria and applies the specified changes to each one.
31    ///
32    /// # How it works
33    ///
34    /// 1. **Discovery**: Analyzes the `ScrobbleEdit` to determine what to search for:
35    ///    - If `track_name_original` is specified: finds all album variations of that track
36    ///    - If only `album_name_original` is specified: finds all tracks in that album
37    ///    - If neither is specified: finds all tracks by that artist
38    ///
39    /// 2. **Enrichment**: For each discovered scrobble, extracts complete metadata
40    ///    including album artist information from the user's library
41    ///
42    /// 3. **Editing**: Applies the requested changes to each discovered instance
43    ///
44    /// # Arguments
45    ///
46    /// * `edit` - A `ScrobbleEdit` specifying what to find and how to change it
47    ///
48    /// # Returns
49    ///
50    /// Returns an `EditResponse` containing results for all edited scrobbles, including:
51    /// - Overall success status
52    /// - Individual results for each scrobble instance
53    /// - Detailed error messages if any edits fail
54    ///
55    /// # Errors
56    ///
57    /// Returns `LastFmError::Parse` if no matching scrobbles are found, or other errors
58    /// for network/authentication issues.
59    ///
60    /// # Example
61    ///
62    /// ```rust,no_run
63    /// # use lastfm_edit::{LastFmEditClient, ScrobbleEdit, Result};
64    /// # async fn example(client: &dyn LastFmEditClient) -> Result<()> {
65    /// // Change track name for all instances of a track
66    /// let edit = ScrobbleEdit::from_track_and_artist("Old Track Name", "Artist")
67    ///     .with_track_name("New Track Name");
68    ///
69    /// let response = client.edit_scrobble(&edit).await?;
70    /// if response.success() {
71    ///     println!("Successfully edited {} scrobbles", response.total_edits());
72    /// }
73    /// # Ok(())
74    /// # }
75    /// ```
76    async fn edit_scrobble(&self, edit: &ScrobbleEdit) -> Result<EditResponse>;
77
78    /// Edit a single scrobble with complete information and retry logic.
79    ///
80    /// This method performs a single edit operation on a fully-specified scrobble.
81    /// Unlike [`edit_scrobble`], this method does not perform discovery, enrichment,
82    /// or multiple edits - it edits exactly one scrobble instance.
83    ///
84    /// # Key Differences from `edit_scrobble`
85    ///
86    /// - **No discovery**: Requires a fully-specified `ExactScrobbleEdit`
87    /// - **Single edit**: Only edits one scrobble instance
88    /// - **No enrichment**: All fields must be provided upfront
89    /// - **Retry logic**: Automatically retries on rate limiting
90    ///
91    /// # Arguments
92    ///
93    /// * `exact_edit` - A fully-specified edit with all required fields populated,
94    ///   including original metadata and timestamps
95    /// * `max_retries` - Maximum number of retry attempts for rate limiting.
96    ///   The method will wait with exponential backoff between retries.
97    ///
98    /// # Returns
99    ///
100    /// Returns an `EditResponse` with a single result indicating success or failure.
101    /// If max retries are exceeded due to rate limiting, returns a failed response
102    /// rather than an error.
103    ///
104    /// # Example
105    ///
106    /// ```rust,no_run
107    /// # use lastfm_edit::{LastFmEditClient, ExactScrobbleEdit, Result};
108    /// # async fn example(client: &dyn LastFmEditClient) -> Result<()> {
109    /// let exact_edit = ExactScrobbleEdit::new(
110    ///     "Original Track".to_string(),
111    ///     "Original Album".to_string(),
112    ///     "Artist".to_string(),
113    ///     "Artist".to_string(),
114    ///     "New Track Name".to_string(),
115    ///     "Original Album".to_string(),
116    ///     "Artist".to_string(),
117    ///     "Artist".to_string(),
118    ///     1640995200, // timestamp
119    ///     false
120    /// );
121    ///
122    /// let response = client.edit_scrobble_single(&exact_edit, 3).await?;
123    /// # Ok(())
124    /// # }
125    /// ```
126    async fn edit_scrobble_single(
127        &self,
128        exact_edit: &ExactScrobbleEdit,
129        max_retries: u32,
130    ) -> Result<EditResponse>;
131
132    /// Delete a scrobble by its identifying information.
133    ///
134    /// This method deletes a specific scrobble from the user's library using the
135    /// artist name, track name, and timestamp to uniquely identify it.
136    ///
137    /// # Arguments
138    ///
139    /// * `artist_name` - The artist name of the scrobble to delete
140    /// * `track_name` - The track name of the scrobble to delete
141    /// * `timestamp` - The unix timestamp of the scrobble to delete
142    ///
143    /// # Returns
144    ///
145    /// Returns `true` if the deletion was successful, `false` otherwise.
146    async fn delete_scrobble(
147        &self,
148        artist_name: &str,
149        track_name: &str,
150        timestamp: u64,
151    ) -> Result<bool>;
152
153    /// Create an incremental discovery iterator for scrobble editing.
154    ///
155    /// This returns the appropriate discovery iterator based on what fields are specified
156    /// in the ScrobbleEdit. The iterator yields `ExactScrobbleEdit` results incrementally,
157    /// which helps avoid rate limiting issues when discovering many scrobbles.
158    ///
159    /// Returns a `Box<dyn AsyncDiscoveryIterator<ExactScrobbleEdit>>` to handle the different
160    /// discovery strategies uniformly.
161    fn discover_scrobbles(
162        &self,
163        edit: ScrobbleEdit,
164    ) -> Box<dyn crate::AsyncDiscoveryIterator<crate::ExactScrobbleEdit>>;
165
166    // =============================================================================
167    // ITERATOR METHODS - Core library browsing functionality
168    // =============================================================================
169
170    /// Create an iterator for browsing all artists in the user's library.
171    fn artists(&self) -> Box<dyn AsyncPaginatedIterator<Artist>>;
172
173    /// Create an iterator for browsing an artist's tracks from the user's library.
174    fn artist_tracks(&self, artist: &str) -> Box<dyn AsyncPaginatedIterator<Track>>;
175
176    /// Create an iterator for browsing an artist's tracks directly using the paginated endpoint.
177    ///
178    /// This alternative approach uses
179    /// `/user/{username}/library/music/{artist}/+tracks` directly with
180    /// pagination, which is more efficient than the album-based approach since
181    /// it doesn't need to iterate through albums first. The downside of this
182    /// approach is that the tracks will not come with album information, which
183    /// will need to get looked up eventually in the process of making edits.
184    fn artist_tracks_direct(&self, artist: &str) -> Box<dyn AsyncPaginatedIterator<Track>>;
185
186    /// Create an iterator for browsing an artist's albums from the user's library.
187    fn artist_albums(&self, artist: &str) -> Box<dyn AsyncPaginatedIterator<Album>>;
188
189    /// Create an iterator for browsing tracks from a specific album.
190    fn album_tracks(
191        &self,
192        album_name: &str,
193        artist_name: &str,
194    ) -> Box<dyn AsyncPaginatedIterator<Track>>;
195
196    /// Create an iterator for browsing the user's recent tracks/scrobbles.
197    fn recent_tracks(&self) -> Box<dyn AsyncPaginatedIterator<Track>>;
198
199    /// Create an iterator for browsing the user's recent tracks starting from a specific page.
200    fn recent_tracks_from_page(&self, starting_page: u32)
201        -> Box<dyn AsyncPaginatedIterator<Track>>;
202
203    /// Create an iterator for searching tracks in the user's library.
204    ///
205    /// This returns an iterator that uses Last.fm's library search functionality
206    /// to find tracks matching the provided query string. The iterator handles
207    /// pagination automatically.
208    ///
209    /// # Arguments
210    ///
211    /// * `query` - The search query (e.g., "remaster", "live", artist name, etc.)
212    ///
213    /// # Returns
214    ///
215    /// Returns a `SearchTracksIterator` for streaming search results.
216    fn search_tracks(&self, query: &str) -> Box<dyn AsyncPaginatedIterator<Track>>;
217
218    /// Create an iterator for searching albums in the user's library.
219    ///
220    /// This returns an iterator that uses Last.fm's library search functionality
221    /// to find albums matching the provided query string. The iterator handles
222    /// pagination automatically.
223    ///
224    /// # Arguments
225    ///
226    /// * `query` - The search query (e.g., "remaster", "deluxe", artist name, etc.)
227    ///
228    /// # Returns
229    ///
230    /// Returns a `SearchAlbumsIterator` for streaming search results.
231    fn search_albums(&self, query: &str) -> Box<dyn AsyncPaginatedIterator<Album>>;
232
233    // =============================================================================
234    // SEARCH METHODS - Library search functionality
235    // =============================================================================
236
237    /// Get a single page of track search results from the user's library.
238    ///
239    /// This performs a search using Last.fm's library search functionality,
240    /// returning one page of tracks that match the provided query string.
241    /// For iterator-based access, use [`search_tracks`](Self::search_tracks) instead.
242    ///
243    /// # Arguments
244    ///
245    /// * `query` - The search query (e.g., "remaster", "live", artist name, etc.)
246    /// * `page` - The page number to retrieve (1-based)
247    ///
248    /// # Returns
249    ///
250    /// Returns a `TrackPage` containing the search results with pagination information.
251    async fn search_tracks_page(&self, query: &str, page: u32) -> Result<crate::TrackPage>;
252
253    /// Get a single page of album search results from the user's library.
254    ///
255    /// This performs a search using Last.fm's library search functionality,
256    /// returning one page of albums that match the provided query string.
257    /// For iterator-based access, use [`search_albums`](Self::search_albums) instead.
258    ///
259    /// # Arguments
260    ///
261    /// * `query` - The search query (e.g., "remaster", "deluxe", artist name, etc.)
262    /// * `page` - The page number to retrieve (1-based)
263    ///
264    /// # Returns
265    ///
266    /// Returns an `AlbumPage` containing the search results with pagination information.
267    async fn search_albums_page(&self, query: &str, page: u32) -> Result<crate::AlbumPage>;
268
269    // =============================================================================
270    // CORE DATA METHODS - Essential data access
271    // =============================================================================
272
273    /// Get the currently authenticated username.
274    fn username(&self) -> String;
275
276    /// Fetch recent scrobbles from the user's listening history.
277    async fn get_recent_scrobbles(&self, page: u32) -> Result<Vec<Track>>;
278
279    /// Find the most recent scrobble for a specific track.
280    async fn find_recent_scrobble_for_track(
281        &self,
282        track_name: &str,
283        artist_name: &str,
284        max_pages: u32,
285    ) -> Result<Option<Track>>;
286
287    /// Get a page of artists from the user's library.
288    async fn get_artists_page(&self, page: u32) -> Result<ArtistPage>;
289
290    /// Get a page of tracks from the user's library for the specified artist.
291    async fn get_artist_tracks_page(&self, artist: &str, page: u32) -> Result<crate::TrackPage>;
292
293    /// Get a page of albums from the user's library for the specified artist.
294    async fn get_artist_albums_page(&self, artist: &str, page: u32) -> Result<crate::AlbumPage>;
295
296    /// Get a page of tracks from a specific album in the user's library.
297    async fn get_album_tracks_page(
298        &self,
299        album_name: &str,
300        artist_name: &str,
301        page: u32,
302    ) -> Result<crate::TrackPage>;
303
304    /// Get a page of tracks from the user's recent listening history.
305    async fn get_recent_tracks_page(&self, page: u32) -> Result<crate::TrackPage> {
306        let tracks = self.get_recent_scrobbles(page).await?;
307        let has_next_page = !tracks.is_empty();
308        Ok(crate::TrackPage {
309            tracks,
310            page_number: page,
311            has_next_page,
312            total_pages: None,
313        })
314    }
315
316    // =============================================================================
317    // CONVENIENCE METHODS - Higher-level helpers and shortcuts
318    // =============================================================================
319
320    /// Discover all scrobble edit variations based on the provided ScrobbleEdit template.
321    ///
322    /// This method analyzes what fields are specified in the input ScrobbleEdit and discovers
323    /// all relevant scrobble instances that match the criteria:
324    /// - If track_name_original is specified: discovers all album variations of that track
325    /// - If only album_name_original is specified: discovers all tracks in that album
326    /// - If neither is specified: discovers all tracks by that artist
327    ///
328    /// Returns fully-specified ExactScrobbleEdit instances with all metadata populated
329    /// from the user's library, ready for editing operations.
330    async fn discover_scrobble_edit_variations(
331        &self,
332        edit: &ScrobbleEdit,
333    ) -> Result<Vec<ExactScrobbleEdit>> {
334        // Use the incremental iterator and collect all results
335        let mut discovery_iterator = self.discover_scrobbles(edit.clone());
336        discovery_iterator.collect_all().await
337    }
338
339    /// Get tracks from a specific album page.
340    async fn get_album_tracks(&self, album_name: &str, artist_name: &str) -> Result<Vec<Track>> {
341        let mut tracks_iterator = self.album_tracks(album_name, artist_name);
342        tracks_iterator.collect_all().await
343    }
344
345    /// Find a scrobble by its timestamp in recent scrobbles.
346    async fn find_scrobble_by_timestamp(&self, timestamp: u64) -> Result<Track> {
347        log::debug!("Searching for scrobble with timestamp {timestamp}");
348
349        // Search through recent scrobbles to find the one with matching timestamp
350        for page in 1..=10 {
351            // Search up to 10 pages of recent scrobbles
352            let scrobbles = self.get_recent_scrobbles(page).await?;
353
354            for scrobble in scrobbles {
355                if let Some(scrobble_timestamp) = scrobble.timestamp {
356                    if scrobble_timestamp == timestamp {
357                        log::debug!(
358                            "Found scrobble: '{}' by '{}' with album: '{:?}', album_artist: '{:?}'",
359                            scrobble.name,
360                            scrobble.artist,
361                            scrobble.album,
362                            scrobble.album_artist
363                        );
364                        return Ok(scrobble);
365                    }
366                }
367            }
368        }
369
370        Err(LastFmError::Parse(format!(
371            "Could not find scrobble with timestamp {timestamp}"
372        )))
373    }
374
375    /// Edit album metadata by updating scrobbles with new album name.
376    async fn edit_album(
377        &self,
378        old_album_name: &str,
379        new_album_name: &str,
380        artist_name: &str,
381    ) -> Result<EditResponse> {
382        log::debug!("Editing album '{old_album_name}' -> '{new_album_name}' by '{artist_name}'");
383
384        let edit = ScrobbleEdit::for_album(old_album_name, artist_name, artist_name)
385            .with_album_name(new_album_name);
386
387        self.edit_scrobble(&edit).await
388    }
389
390    /// Edit artist metadata by updating scrobbles with new artist name.
391    ///
392    /// This edits ALL tracks from the artist that are found in recent scrobbles.
393    async fn edit_artist(
394        &self,
395        old_artist_name: &str,
396        new_artist_name: &str,
397    ) -> Result<EditResponse> {
398        log::debug!("Editing artist '{old_artist_name}' -> '{new_artist_name}'");
399
400        let edit = ScrobbleEdit::for_artist(old_artist_name, new_artist_name);
401
402        self.edit_scrobble(&edit).await
403    }
404
405    /// Edit artist metadata for a specific track only.
406    ///
407    /// This edits only the specified track if found in recent scrobbles.
408    async fn edit_artist_for_track(
409        &self,
410        track_name: &str,
411        old_artist_name: &str,
412        new_artist_name: &str,
413    ) -> Result<EditResponse> {
414        log::debug!("Editing artist for track '{track_name}' from '{old_artist_name}' -> '{new_artist_name}'");
415
416        let edit = ScrobbleEdit::from_track_and_artist(track_name, old_artist_name)
417            .with_artist_name(new_artist_name);
418
419        self.edit_scrobble(&edit).await
420    }
421
422    /// Edit artist metadata for all tracks in a specific album.
423    ///
424    /// This edits ALL tracks from the specified album that are found in recent scrobbles.
425    async fn edit_artist_for_album(
426        &self,
427        album_name: &str,
428        old_artist_name: &str,
429        new_artist_name: &str,
430    ) -> Result<EditResponse> {
431        log::debug!("Editing artist for album '{album_name}' from '{old_artist_name}' -> '{new_artist_name}'");
432
433        let edit = ScrobbleEdit::for_album(album_name, old_artist_name, old_artist_name)
434            .with_artist_name(new_artist_name);
435
436        self.edit_scrobble(&edit).await
437    }
438
439    // =============================================================================
440    // SESSION & EVENT MANAGEMENT - Authentication and monitoring
441    // =============================================================================
442
443    /// Extract the current session state for persistence.
444    ///
445    /// This allows you to save the authentication state and restore it later
446    /// without requiring the user to log in again.
447    ///
448    /// # Returns
449    ///
450    /// Returns a [`LastFmEditSession`] that can be serialized and saved.
451    fn get_session(&self) -> LastFmEditSession;
452
453    /// Restore session state from a previously saved session.
454    ///
455    /// This allows you to restore authentication state without logging in again.
456    ///
457    /// # Arguments
458    ///
459    /// * `session` - Previously saved session state
460    fn restore_session(&self, session: LastFmEditSession);
461
462    /// Subscribe to internal client events.
463    ///
464    /// Returns a broadcast receiver that can be used to listen to events like rate limiting.
465    /// Multiple subscribers can listen simultaneously.
466    ///
467    /// # Example
468    /// ```rust,no_run
469    /// use lastfm_edit::{LastFmEditClientImpl, LastFmEditSession, ClientEvent};
470    ///
471    /// let http_client = http_client::native::NativeClient::new();
472    /// let test_session = LastFmEditSession::new("test".to_string(), vec!["sessionid=.test123".to_string()], Some("csrf".to_string()), "https://www.last.fm".to_string());
473    /// let client = LastFmEditClientImpl::from_session(Box::new(http_client), test_session);
474    /// let mut events = client.subscribe();
475    ///
476    /// // Listen for events in a background task
477    /// tokio::spawn(async move {
478    ///     while let Ok(event) = events.recv().await {
479    ///         match event {
480    ///             ClientEvent::RequestStarted { request } => {
481    ///                 println!("Request started: {}", request.short_description());
482    ///             }
483    ///             ClientEvent::RequestCompleted { request, status_code, duration_ms } => {
484    ///                 println!("Request completed: {} - {} ({} ms)", request.short_description(), status_code, duration_ms);
485    ///             }
486    ///             ClientEvent::RateLimited { delay_seconds, .. } => {
487    ///                 println!("Rate limited! Waiting {} seconds", delay_seconds);
488    ///             }
489    ///             ClientEvent::RateLimitEnded { total_rate_limit_duration_seconds, .. } => {
490    ///                 println!("Rate limiting ended after {} seconds", total_rate_limit_duration_seconds);
491    ///             }
492    ///             ClientEvent::EditAttempted { edit, success, .. } => {
493    ///                 println!("Edit attempt: '{}' -> '{}' - {}",
494    ///                          edit.track_name_original, edit.track_name,
495    ///                          if success { "Success" } else { "Failed" });
496    ///             }
497    ///         }
498    ///     }
499    /// });
500    /// ```
501    fn subscribe(&self) -> ClientEventReceiver;
502
503    /// Get the latest client event without subscribing to future events.
504    ///
505    /// This returns the most recent event that occurred, or `None` if no events have occurred yet.
506    /// Unlike `subscribe()`, this provides instant access to the current state without waiting.
507    ///
508    /// # Example
509    /// ```rust,no_run
510    /// use lastfm_edit::{LastFmEditClientImpl, LastFmEditSession, ClientEvent};
511    ///
512    /// let http_client = http_client::native::NativeClient::new();
513    /// let test_session = LastFmEditSession::new("test".to_string(), vec!["sessionid=.test123".to_string()], Some("csrf".to_string()), "https://www.last.fm".to_string());
514    /// let client = LastFmEditClientImpl::from_session(Box::new(http_client), test_session);
515    ///
516    /// if let Some(ClientEvent::RateLimited { delay_seconds, .. }) = client.latest_event() {
517    ///     println!("Currently rate limited for {} seconds", delay_seconds);
518    /// }
519    /// ```
520    fn latest_event(&self) -> Option<ClientEvent>;
521
522    /// Validate if the current session is still working.
523    ///
524    /// This method makes a test request to a protected Last.fm settings page to verify
525    /// that the current session is still valid. If the session has expired or become
526    /// invalid, Last.fm will redirect to the login page.
527    ///
528    /// This is useful for checking session validity before attempting operations that
529    /// require authentication, especially after loading a previously saved session.
530    ///
531    /// # Returns
532    ///
533    /// Returns `true` if the session is valid and can be used for authenticated operations,
534    /// `false` if the session is invalid or expired.
535    async fn validate_session(&self) -> bool;
536}