lastfm_edit/
trait.rs

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