lastfm_edit/
trait.rs

1use crate::edit::ExactScrobbleEdit;
2use crate::events::{ClientEvent, ClientEventReceiver};
3use crate::iterator::AsyncPaginatedIterator;
4use crate::session::LastFmEditSession;
5use crate::{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    /// Create an incremental discovery iterator for scrobble editing.
132    ///
133    /// This returns the appropriate discovery iterator based on what fields are specified
134    /// in the ScrobbleEdit. The iterator yields `ExactScrobbleEdit` results incrementally,
135    /// which helps avoid rate limiting issues when discovering many scrobbles.
136    ///
137    /// Returns a `Box<dyn AsyncDiscoveryIterator<ExactScrobbleEdit>>` to handle the different
138    /// discovery strategies uniformly.
139    fn discover_scrobbles(
140        &self,
141        edit: ScrobbleEdit,
142    ) -> Box<dyn crate::AsyncDiscoveryIterator<crate::ExactScrobbleEdit>>;
143
144    // =============================================================================
145    // ITERATOR METHODS - Core library browsing functionality
146    // =============================================================================
147
148    /// Create an iterator for browsing an artist's tracks from the user's library.
149    fn artist_tracks(&self, artist: &str) -> crate::ArtistTracksIterator;
150
151    /// Create an iterator for browsing an artist's albums from the user's library.
152    fn artist_albums(&self, artist: &str) -> crate::ArtistAlbumsIterator;
153
154    /// Create an iterator for browsing tracks from a specific album.
155    fn album_tracks(&self, album_name: &str, artist_name: &str) -> crate::AlbumTracksIterator;
156
157    /// Create an iterator for browsing the user's recent tracks/scrobbles.
158    fn recent_tracks(&self) -> crate::RecentTracksIterator;
159
160    /// Create an iterator for browsing the user's recent tracks starting from a specific page.
161    fn recent_tracks_from_page(&self, starting_page: u32) -> crate::RecentTracksIterator;
162
163    // =============================================================================
164    // CORE DATA METHODS - Essential data access
165    // =============================================================================
166
167    /// Get the currently authenticated username.
168    fn username(&self) -> String;
169
170    /// Fetch recent scrobbles from the user's listening history.
171    async fn get_recent_scrobbles(&self, page: u32) -> Result<Vec<Track>>;
172
173    /// Find the most recent scrobble for a specific track.
174    async fn find_recent_scrobble_for_track(
175        &self,
176        track_name: &str,
177        artist_name: &str,
178        max_pages: u32,
179    ) -> Result<Option<Track>>;
180
181    /// Get a page of tracks from the user's library for the specified artist.
182    async fn get_artist_tracks_page(&self, artist: &str, page: u32) -> Result<crate::TrackPage>;
183
184    /// Get a page of albums from the user's library for the specified artist.
185    async fn get_artist_albums_page(&self, artist: &str, page: u32) -> Result<crate::AlbumPage>;
186
187    /// Get a page of tracks from the user's recent listening history.
188    async fn get_recent_tracks_page(&self, page: u32) -> Result<crate::TrackPage> {
189        let tracks = self.get_recent_scrobbles(page).await?;
190        let has_next_page = !tracks.is_empty();
191        Ok(crate::TrackPage {
192            tracks,
193            page_number: page,
194            has_next_page,
195            total_pages: None,
196        })
197    }
198
199    // =============================================================================
200    // CONVENIENCE METHODS - Higher-level helpers and shortcuts
201    // =============================================================================
202
203    /// Discover all scrobble edit variations based on the provided ScrobbleEdit template.
204    ///
205    /// This method analyzes what fields are specified in the input ScrobbleEdit and discovers
206    /// all relevant scrobble instances that match the criteria:
207    /// - If track_name_original is specified: discovers all album variations of that track
208    /// - If only album_name_original is specified: discovers all tracks in that album
209    /// - If neither is specified: discovers all tracks by that artist
210    ///
211    /// Returns fully-specified ExactScrobbleEdit instances with all metadata populated
212    /// from the user's library, ready for editing operations.
213    async fn discover_scrobble_edit_variations(
214        &self,
215        edit: &ScrobbleEdit,
216    ) -> Result<Vec<ExactScrobbleEdit>> {
217        // Use the incremental iterator and collect all results
218        let mut discovery_iterator = self.discover_scrobbles(edit.clone());
219        discovery_iterator.collect_all().await
220    }
221
222    /// Get tracks from a specific album page.
223    async fn get_album_tracks(&self, album_name: &str, artist_name: &str) -> Result<Vec<Track>> {
224        let mut tracks_iterator = self.album_tracks(album_name, artist_name);
225        tracks_iterator.collect_all().await
226    }
227
228    /// Find a scrobble by its timestamp in recent scrobbles.
229    async fn find_scrobble_by_timestamp(&self, timestamp: u64) -> Result<Track> {
230        log::debug!("Searching for scrobble with timestamp {timestamp}");
231
232        // Search through recent scrobbles to find the one with matching timestamp
233        for page in 1..=10 {
234            // Search up to 10 pages of recent scrobbles
235            let scrobbles = self.get_recent_scrobbles(page).await?;
236
237            for scrobble in scrobbles {
238                if let Some(scrobble_timestamp) = scrobble.timestamp {
239                    if scrobble_timestamp == timestamp {
240                        log::debug!(
241                            "Found scrobble: '{}' by '{}' with album: '{:?}', album_artist: '{:?}'",
242                            scrobble.name,
243                            scrobble.artist,
244                            scrobble.album,
245                            scrobble.album_artist
246                        );
247                        return Ok(scrobble);
248                    }
249                }
250            }
251        }
252
253        Err(LastFmError::Parse(format!(
254            "Could not find scrobble with timestamp {timestamp}"
255        )))
256    }
257
258    /// Edit album metadata by updating scrobbles with new album name.
259    async fn edit_album(
260        &self,
261        old_album_name: &str,
262        new_album_name: &str,
263        artist_name: &str,
264    ) -> Result<EditResponse> {
265        log::debug!("Editing album '{old_album_name}' -> '{new_album_name}' by '{artist_name}'");
266
267        let edit = ScrobbleEdit::for_album(old_album_name, artist_name, artist_name)
268            .with_album_name(new_album_name);
269
270        self.edit_scrobble(&edit).await
271    }
272
273    /// Edit artist metadata by updating scrobbles with new artist name.
274    ///
275    /// This edits ALL tracks from the artist that are found in recent scrobbles.
276    async fn edit_artist(
277        &self,
278        old_artist_name: &str,
279        new_artist_name: &str,
280    ) -> Result<EditResponse> {
281        log::debug!("Editing artist '{old_artist_name}' -> '{new_artist_name}'");
282
283        let edit = ScrobbleEdit::for_artist(old_artist_name, new_artist_name);
284
285        self.edit_scrobble(&edit).await
286    }
287
288    /// Edit artist metadata for a specific track only.
289    ///
290    /// This edits only the specified track if found in recent scrobbles.
291    async fn edit_artist_for_track(
292        &self,
293        track_name: &str,
294        old_artist_name: &str,
295        new_artist_name: &str,
296    ) -> Result<EditResponse> {
297        log::debug!("Editing artist for track '{track_name}' from '{old_artist_name}' -> '{new_artist_name}'");
298
299        let edit = ScrobbleEdit::from_track_and_artist(track_name, old_artist_name)
300            .with_artist_name(new_artist_name);
301
302        self.edit_scrobble(&edit).await
303    }
304
305    /// Edit artist metadata for all tracks in a specific album.
306    ///
307    /// This edits ALL tracks from the specified album that are found in recent scrobbles.
308    async fn edit_artist_for_album(
309        &self,
310        album_name: &str,
311        old_artist_name: &str,
312        new_artist_name: &str,
313    ) -> Result<EditResponse> {
314        log::debug!("Editing artist for album '{album_name}' from '{old_artist_name}' -> '{new_artist_name}'");
315
316        let edit = ScrobbleEdit::for_album(album_name, old_artist_name, old_artist_name)
317            .with_artist_name(new_artist_name);
318
319        self.edit_scrobble(&edit).await
320    }
321
322    // =============================================================================
323    // SESSION & EVENT MANAGEMENT - Authentication and monitoring
324    // =============================================================================
325
326    /// Extract the current session state for persistence.
327    ///
328    /// This allows you to save the authentication state and restore it later
329    /// without requiring the user to log in again.
330    ///
331    /// # Returns
332    ///
333    /// Returns a [`LastFmEditSession`] that can be serialized and saved.
334    fn get_session(&self) -> LastFmEditSession;
335
336    /// Restore session state from a previously saved session.
337    ///
338    /// This allows you to restore authentication state without logging in again.
339    ///
340    /// # Arguments
341    ///
342    /// * `session` - Previously saved session state
343    fn restore_session(&self, session: LastFmEditSession);
344
345    /// Subscribe to internal client events.
346    ///
347    /// Returns a broadcast receiver that can be used to listen to events like rate limiting.
348    /// Multiple subscribers can listen simultaneously.
349    ///
350    /// # Example
351    /// ```rust,no_run
352    /// use lastfm_edit::{LastFmEditClientImpl, LastFmEditSession, ClientEvent};
353    ///
354    /// let http_client = http_client::native::NativeClient::new();
355    /// let test_session = LastFmEditSession::new("test".to_string(), vec!["sessionid=.test123".to_string()], Some("csrf".to_string()), "https://www.last.fm".to_string());
356    /// let client = LastFmEditClientImpl::from_session(Box::new(http_client), test_session);
357    /// let mut events = client.subscribe();
358    ///
359    /// // Listen for events in a background task
360    /// tokio::spawn(async move {
361    ///     while let Ok(event) = events.recv().await {
362    ///         match event {
363    ///             ClientEvent::RequestStarted { request } => {
364    ///                 println!("Request started: {}", request.short_description());
365    ///             }
366    ///             ClientEvent::RequestCompleted { request, status_code, duration_ms } => {
367    ///                 println!("Request completed: {} - {} ({} ms)", request.short_description(), status_code, duration_ms);
368    ///             }
369    ///             ClientEvent::RateLimited { delay_seconds, .. } => {
370    ///                 println!("Rate limited! Waiting {} seconds", delay_seconds);
371    ///             }
372    ///             ClientEvent::EditAttempted { edit, success, .. } => {
373    ///                 println!("Edit attempt: '{}' -> '{}' - {}",
374    ///                          edit.track_name_original, edit.track_name,
375    ///                          if success { "Success" } else { "Failed" });
376    ///             }
377    ///         }
378    ///     }
379    /// });
380    /// ```
381    fn subscribe(&self) -> ClientEventReceiver;
382
383    /// Get the latest client event without subscribing to future events.
384    ///
385    /// This returns the most recent event that occurred, or `None` if no events have occurred yet.
386    /// Unlike `subscribe()`, this provides instant access to the current state without waiting.
387    ///
388    /// # Example
389    /// ```rust,no_run
390    /// use lastfm_edit::{LastFmEditClientImpl, LastFmEditSession, ClientEvent};
391    ///
392    /// let http_client = http_client::native::NativeClient::new();
393    /// let test_session = LastFmEditSession::new("test".to_string(), vec!["sessionid=.test123".to_string()], Some("csrf".to_string()), "https://www.last.fm".to_string());
394    /// let client = LastFmEditClientImpl::from_session(Box::new(http_client), test_session);
395    ///
396    /// if let Some(ClientEvent::RateLimited { delay_seconds, .. }) = client.latest_event() {
397    ///     println!("Currently rate limited for {} seconds", delay_seconds);
398    /// }
399    /// ```
400    fn latest_event(&self) -> Option<ClientEvent>;
401
402    /// Validate if the current session is still working.
403    ///
404    /// This method makes a test request to a protected Last.fm settings page to verify
405    /// that the current session is still valid. If the session has expired or become
406    /// invalid, Last.fm will redirect to the login page.
407    ///
408    /// This is useful for checking session validity before attempting operations that
409    /// require authentication, especially after loading a previously saved session.
410    ///
411    /// # Returns
412    ///
413    /// Returns `true` if the session is valid and can be used for authenticated operations,
414    /// `false` if the session is invalid or expired.
415    async fn validate_session(&self) -> bool;
416}