Skip to main content

lastfm_edit/
trait.rs

1use crate::iterator::AsyncPaginatedIterator;
2use crate::types::{
3    Album, Artist, ArtistPage, ClientEvent, ClientEventReceiver, EditResponse, ExactScrobbleEdit,
4    LastFmEditSession, RateLimitState, RateLimitStateWatcher, ScrobbleEdit, Track,
5};
6use crate::Result;
7use async_trait::async_trait;
8
9/// Low-level trait for individual Last.fm page fetches, search, and session management.
10///
11/// This trait abstracts single-request operations: fetching a page of data,
12/// performing a search query, and managing session/cancellation state.
13/// It serves as the foundation that higher-level traits like [`LastFmEditClient`]
14/// build upon.
15///
16/// # Mocking Support
17///
18/// When the `mock` feature is enabled, this crate provides `MockLastFmBaseClient`
19/// that implements this trait using the `mockall` library.
20#[cfg_attr(feature = "mock", mockall::automock)]
21#[async_trait(?Send)]
22pub trait LastFmBaseClient {
23    // =============================================================================
24    // PAGE FETCHING - Single page data access
25    // =============================================================================
26
27    /// Get a page of artists from the user's library.
28    async fn get_artists_page(&self, page: u32) -> Result<ArtistPage>;
29
30    /// Get a page of tracks from the user's library for the specified artist.
31    async fn get_artist_tracks_page(&self, artist: &str, page: u32) -> Result<crate::TrackPage>;
32
33    /// Get a page of albums from the user's library for the specified artist.
34    async fn get_artist_albums_page(&self, artist: &str, page: u32) -> Result<crate::AlbumPage>;
35
36    /// Get a page of tracks from a specific album in the user's library.
37    async fn get_album_tracks_page(
38        &self,
39        album_name: &str,
40        artist_name: &str,
41        page: u32,
42    ) -> Result<crate::TrackPage>;
43
44    /// Get a page of tracks from the user's recent listening history.
45    async fn get_recent_tracks_page(&self, page: u32) -> Result<crate::TrackPage>;
46
47    // =============================================================================
48    // SEARCH PAGES - Single page search results
49    // =============================================================================
50
51    /// Get a single page of track search results from the user's library.
52    ///
53    /// This performs a search using Last.fm's library search functionality,
54    /// returning one page of tracks that match the provided query string.
55    /// For iterator-based access, use [`LastFmEditClient::search_tracks`] instead.
56    ///
57    /// # Arguments
58    ///
59    /// * `query` - The search query (e.g., "remaster", "live", artist name, etc.)
60    /// * `page` - The page number to retrieve (1-based)
61    ///
62    /// # Returns
63    ///
64    /// Returns a `TrackPage` containing the search results with pagination information.
65    async fn search_tracks_page(&self, query: &str, page: u32) -> Result<crate::TrackPage>;
66
67    /// Get a single page of album search results from the user's library.
68    ///
69    /// This performs a search using Last.fm's library search functionality,
70    /// returning one page of albums that match the provided query string.
71    /// For iterator-based access, use [`LastFmEditClient::search_albums`] instead.
72    ///
73    /// # Arguments
74    ///
75    /// * `query` - The search query (e.g., "remaster", "deluxe", artist name, etc.)
76    /// * `page` - The page number to retrieve (1-based)
77    ///
78    /// # Returns
79    ///
80    /// Returns an `AlbumPage` containing the search results with pagination information.
81    async fn search_albums_page(&self, query: &str, page: u32) -> Result<crate::AlbumPage>;
82
83    /// Get a single page of artist search results from the user's library.
84    ///
85    /// This performs a search using Last.fm's library search functionality,
86    /// returning one page of artists that match the provided query string.
87    /// For iterator-based access, use [`LastFmEditClient::search_artists`] instead.
88    ///
89    /// # Arguments
90    ///
91    /// * `query` - The search query (e.g., artist name, partial match, etc.)
92    /// * `page` - The page number to retrieve (1-based)
93    ///
94    /// # Returns
95    ///
96    /// Returns an `ArtistPage` containing the search results with pagination information.
97    async fn search_artists_page(&self, query: &str, page: u32) -> Result<crate::ArtistPage>;
98
99    // =============================================================================
100    // INFRASTRUCTURE - Session, events, and authentication
101    // =============================================================================
102
103    /// Get the currently authenticated username.
104    fn username(&self) -> String;
105
106    /// Extract the current session state for persistence.
107    ///
108    /// This allows you to save the authentication state and restore it later
109    /// without requiring the user to log in again.
110    ///
111    /// # Returns
112    ///
113    /// Returns a [`LastFmEditSession`] that can be serialized and saved.
114    fn get_session(&self) -> LastFmEditSession;
115
116    /// Subscribe to internal client events.
117    ///
118    /// Returns a broadcast receiver that can be used to listen to events like rate limiting.
119    /// Multiple subscribers can listen simultaneously.
120    ///
121    /// # Example
122    /// ```rust,no_run
123    /// use lastfm_edit::{LastFmEditClientImpl, LastFmEditSession, ClientEvent};
124    ///
125    /// let http_client = http_client::native::NativeClient::new();
126    /// let test_session = LastFmEditSession::new("test".to_string(), vec!["sessionid=.test123".to_string()], Some("csrf".to_string()), "https://www.last.fm".to_string());
127    /// let client = LastFmEditClientImpl::from_session(Box::new(http_client), test_session);
128    /// let mut events = client.subscribe();
129    ///
130    /// // Listen for events in a background task
131    /// tokio::spawn(async move {
132    ///     while let Ok(event) = events.recv().await {
133    ///         match event {
134    ///             ClientEvent::RequestStarted { request } => {
135    ///                 println!("Request started: {}", request.short_description());
136    ///             }
137    ///             ClientEvent::RequestCompleted { request, status_code, duration_ms } => {
138    ///                 println!("Request completed: {} - {} ({} ms)", request.short_description(), status_code, duration_ms);
139    ///             }
140    ///             ClientEvent::RateLimited { delay_seconds, .. } => {
141    ///                 println!("Rate limited! Waiting {} seconds", delay_seconds);
142    ///             }
143    ///             ClientEvent::RateLimitEnded { total_rate_limit_duration_seconds, .. } => {
144    ///                 println!("Rate limiting ended after {} seconds", total_rate_limit_duration_seconds);
145    ///             }
146    ///             ClientEvent::Delaying { delay_ms, reason, .. } => {
147    ///                 println!("Delaying ({reason:?}) for {delay_ms}ms");
148    ///             }
149    ///             ClientEvent::EditAttempted { edit, success, .. } => {
150    ///                 println!("Edit attempt: '{}' -> '{}' - {}",
151    ///                          edit.track_name_original, edit.track_name,
152    ///                          if success { "Success" } else { "Failed" });
153    ///             }
154    ///             _ => {}
155    ///         }
156    ///     }
157    /// });
158    /// ```
159    fn subscribe(&self) -> ClientEventReceiver;
160
161    /// Get the latest client event without subscribing to future events.
162    ///
163    /// This returns the most recent event that occurred, or `None` if no events have occurred yet.
164    /// Unlike `subscribe()`, this provides instant access to the current state without waiting.
165    ///
166    /// # Example
167    /// ```rust,no_run
168    /// use lastfm_edit::{LastFmEditClientImpl, LastFmEditSession, ClientEvent};
169    ///
170    /// let http_client = http_client::native::NativeClient::new();
171    /// let test_session = LastFmEditSession::new("test".to_string(), vec!["sessionid=.test123".to_string()], Some("csrf".to_string()), "https://www.last.fm".to_string());
172    /// let client = LastFmEditClientImpl::from_session(Box::new(http_client), test_session);
173    ///
174    /// if let Some(ClientEvent::RateLimited { delay_seconds, .. }) = client.latest_event() {
175    ///     println!("Currently rate limited for {} seconds", delay_seconds);
176    /// }
177    /// ```
178    fn latest_event(&self) -> Option<ClientEvent>;
179
180    /// Get the current rate-limit state snapshot.
181    ///
182    /// Returns [`RateLimitState::RateLimited`] (with an estimated resume time) while the client
183    /// is parked due to detected rate limiting, and [`RateLimitState::Ready`] otherwise.
184    /// The default implementation always reports `Ready`; clients backed by a
185    /// [`SharedEventBroadcaster`](crate::types::SharedEventBroadcaster) override it.
186    fn rate_limit_state(&self) -> RateLimitState {
187        RateLimitState::Ready
188    }
189
190    /// Get a watch receiver tracking rate-limit state transitions.
191    ///
192    /// Await `.changed()` on the receiver to react to pause/resume without polling. The default
193    /// implementation returns a watcher that always reads `Ready` and never changes.
194    fn watch_rate_limit_state(&self) -> RateLimitStateWatcher {
195        static NEVER_LIMITED: std::sync::OnceLock<tokio::sync::watch::Sender<RateLimitState>> =
196            std::sync::OnceLock::new();
197        NEVER_LIMITED
198            .get_or_init(|| tokio::sync::watch::channel(RateLimitState::Ready).0)
199            .subscribe()
200    }
201
202    /// Validate if the current session is still working.
203    ///
204    /// This method makes a test request to a protected Last.fm settings page to verify
205    /// that the current session is still valid. If the session has expired or become
206    /// invalid, Last.fm will redirect to the login page.
207    ///
208    /// This is useful for checking session validity before attempting operations that
209    /// require authentication, especially after loading a previously saved session.
210    ///
211    /// # Returns
212    ///
213    /// Returns `true` if the session is valid and can be used for authenticated operations,
214    /// `false` if the session is invalid or expired.
215    async fn validate_session(&self) -> bool;
216
217    // =============================================================================
218    // READ HELPER
219    // =============================================================================
220
221    /// Find the most recent scrobble for a specific track.
222    async fn find_recent_scrobble_for_track(
223        &self,
224        track_name: &str,
225        artist_name: &str,
226        max_pages: u32,
227    ) -> Result<Option<Track>>;
228
229    // =============================================================================
230    // CANCELLATION - Cooperative cancellation for long-running operations
231    // =============================================================================
232
233    /// Request cooperative cancellation of ongoing operations (best-effort).
234    ///
235    /// Implementations should interrupt internal waits (retry backoff, operational delays)
236    /// and return `LastFmError::Io(ErrorKind::Interrupted)` where appropriate.
237    fn cancel(&self) {}
238
239    /// Clear the cancellation request so future operations can run again.
240    fn reset_cancel(&self) {}
241
242    /// Whether cancellation has been requested.
243    fn is_cancelled(&self) -> bool {
244        false
245    }
246}
247
248/// High-level trait for Last.fm client operations including iterators, discovery, and editing.
249///
250/// This trait builds on [`LastFmBaseClient`] to provide composite operations:
251/// iterator factories for paginated browsing, scrobble discovery, and editing workflows.
252///
253/// # Mocking Support
254///
255/// When the `mock` feature is enabled, this crate provides `MockLastFmEditClient`
256/// that implements this trait using the `mockall` library.
257///
258#[async_trait(?Send)]
259pub trait LastFmEditClient: LastFmBaseClient {
260    // =============================================================================
261    // CORE EDITING METHODS - Most important functionality
262    // =============================================================================
263
264    /// Edit scrobbles by discovering and updating all matching instances.
265    ///
266    /// This is the main editing method that automatically discovers all scrobble instances
267    /// that match the provided criteria and applies the specified changes to each one.
268    ///
269    /// # How it works
270    ///
271    /// 1. **Discovery**: Analyzes the `ScrobbleEdit` to determine what to search for:
272    ///    - If `track_name_original` is specified: finds all album variations of that track
273    ///    - If only `album_name_original` is specified: finds all tracks in that album
274    ///    - If neither is specified: finds all tracks by that artist
275    ///
276    /// 2. **Enrichment**: For each discovered scrobble, extracts complete metadata
277    ///    including album artist information from the user's library
278    ///
279    /// 3. **Editing**: Applies the requested changes to each discovered instance
280    ///
281    /// # Arguments
282    ///
283    /// * `edit` - A `ScrobbleEdit` specifying what to find and how to change it
284    ///
285    /// # Returns
286    ///
287    /// Returns an `EditResponse` containing results for all edited scrobbles, including:
288    /// - Overall success status
289    /// - Individual results for each scrobble instance
290    /// - Detailed error messages if any edits fail
291    ///
292    /// # Errors
293    ///
294    /// Returns `LastFmError::Parse` if no matching scrobbles are found, or other errors
295    /// for network/authentication issues.
296    ///
297    /// # Example
298    ///
299    /// ```rust,no_run
300    /// # use lastfm_edit::{LastFmEditClient, ScrobbleEdit, Result};
301    /// # async fn example(client: &dyn LastFmEditClient) -> Result<()> {
302    /// // Change track name for all instances of a track
303    /// let edit = ScrobbleEdit::from_track_and_artist("Old Track Name", "Artist")
304    ///     .with_track_name("New Track Name");
305    ///
306    /// let response = client.edit_scrobble(&edit).await?;
307    /// if response.success() {
308    ///     println!("Successfully edited {} scrobbles", response.total_edits());
309    /// }
310    /// # Ok(())
311    /// # }
312    /// ```
313    async fn edit_scrobble(&self, edit: &ScrobbleEdit) -> Result<EditResponse>;
314
315    /// Edit a single scrobble with complete information and retry logic.
316    ///
317    /// This method performs a single edit operation on a fully-specified scrobble.
318    /// Unlike [`edit_scrobble`], this method does not perform discovery, enrichment,
319    /// or multiple edits - it edits exactly one scrobble instance.
320    ///
321    /// # Key Differences from `edit_scrobble`
322    ///
323    /// - **No discovery**: Requires a fully-specified `ExactScrobbleEdit`
324    /// - **Single edit**: Only edits one scrobble instance
325    /// - **No enrichment**: All fields must be provided upfront
326    /// - **Retry logic**: Automatically retries on rate limiting
327    ///
328    /// # Arguments
329    ///
330    /// * `exact_edit` - A fully-specified edit with all required fields populated,
331    ///   including original metadata and timestamps
332    /// * `max_retries` - Maximum number of retry attempts for rate limiting.
333    ///   The method will wait with exponential backoff between retries.
334    ///
335    /// # Returns
336    ///
337    /// Returns an `EditResponse` with a single result indicating success or failure.
338    /// If max retries are exceeded due to rate limiting, returns a failed response
339    /// rather than an error.
340    ///
341    /// # Example
342    ///
343    /// ```rust,no_run
344    /// # use lastfm_edit::{LastFmEditClient, ExactScrobbleEdit, Result};
345    /// # async fn example(client: &dyn LastFmEditClient) -> Result<()> {
346    /// let exact_edit = ExactScrobbleEdit::new(
347    ///     "Original Track".to_string(),
348    ///     "Original Album".to_string(),
349    ///     "Artist".to_string(),
350    ///     "Artist".to_string(),
351    ///     "New Track Name".to_string(),
352    ///     "Original Album".to_string(),
353    ///     "Artist".to_string(),
354    ///     "Artist".to_string(),
355    ///     1640995200, // timestamp
356    ///     false
357    /// );
358    ///
359    /// let response = client.edit_scrobble_single(&exact_edit, 3).await?;
360    /// # Ok(())
361    /// # }
362    /// ```
363    async fn edit_scrobble_single(
364        &self,
365        exact_edit: &ExactScrobbleEdit,
366        max_retries: u32,
367    ) -> Result<EditResponse>;
368
369    /// Delete a scrobble by its identifying information.
370    ///
371    /// This method deletes a specific scrobble from the user's library using the
372    /// artist name, track name, and timestamp to uniquely identify it.
373    ///
374    /// # Arguments
375    ///
376    /// * `artist_name` - The artist name of the scrobble to delete
377    /// * `track_name` - The track name of the scrobble to delete
378    /// * `timestamp` - The unix timestamp of the scrobble to delete
379    ///
380    /// # Returns
381    ///
382    /// Returns `true` if the deletion was successful, `false` otherwise.
383    async fn delete_scrobble(
384        &self,
385        artist_name: &str,
386        track_name: &str,
387        timestamp: u64,
388    ) -> Result<bool>;
389
390    /// Create an incremental discovery iterator for scrobble editing.
391    ///
392    /// This returns the appropriate discovery iterator based on what fields are specified
393    /// in the ScrobbleEdit. The iterator yields `ExactScrobbleEdit` results incrementally,
394    /// which helps avoid rate limiting issues when discovering many scrobbles.
395    ///
396    /// Returns a `Box<dyn AsyncDiscoveryIterator<ExactScrobbleEdit>>` to handle the different
397    /// discovery strategies uniformly.
398    fn discover_scrobbles(
399        &self,
400        edit: ScrobbleEdit,
401    ) -> Box<dyn crate::AsyncDiscoveryIterator<crate::ExactScrobbleEdit>>;
402
403    // =============================================================================
404    // ITERATOR METHODS - Core library browsing functionality
405    // =============================================================================
406
407    /// Create an iterator for browsing all artists in the user's library.
408    fn artists(&self) -> Box<dyn AsyncPaginatedIterator<Artist>>;
409
410    /// Create an iterator for browsing an artist's tracks from the user's library.
411    fn artist_tracks(&self, artist: &str) -> Box<dyn AsyncPaginatedIterator<Track>>;
412
413    /// Create an iterator for browsing an artist's tracks directly using the paginated endpoint.
414    ///
415    /// This alternative approach uses
416    /// `/user/{username}/library/music/{artist}/+tracks` directly with
417    /// pagination, which is more efficient than the album-based approach since
418    /// it doesn't need to iterate through albums first. The downside of this
419    /// approach is that the tracks will not come with album information, which
420    /// will need to get looked up eventually in the process of making edits.
421    fn artist_tracks_direct(&self, artist: &str) -> Box<dyn AsyncPaginatedIterator<Track>>;
422
423    /// Create an iterator for browsing an artist's albums from the user's library.
424    fn artist_albums(&self, artist: &str) -> Box<dyn AsyncPaginatedIterator<Album>>;
425
426    /// Create an iterator for browsing tracks from a specific album.
427    fn album_tracks(
428        &self,
429        album_name: &str,
430        artist_name: &str,
431    ) -> Box<dyn AsyncPaginatedIterator<Track>>;
432
433    /// Create an iterator for browsing the user's recent tracks/scrobbles.
434    fn recent_tracks(&self) -> Box<dyn AsyncPaginatedIterator<Track>>;
435
436    /// Create an iterator for browsing the user's recent tracks starting from a specific page.
437    fn recent_tracks_from_page(&self, starting_page: u32)
438        -> Box<dyn AsyncPaginatedIterator<Track>>;
439
440    /// Create an iterator for searching tracks in the user's library.
441    ///
442    /// This returns an iterator that uses Last.fm's library search functionality
443    /// to find tracks matching the provided query string. The iterator handles
444    /// pagination automatically.
445    ///
446    /// # Arguments
447    ///
448    /// * `query` - The search query (e.g., "remaster", "live", artist name, etc.)
449    ///
450    /// # Returns
451    ///
452    /// Returns a `SearchTracksIterator` for streaming search results.
453    fn search_tracks(&self, query: &str) -> Box<dyn AsyncPaginatedIterator<Track>>;
454
455    /// Create an iterator for searching albums in the user's library.
456    ///
457    /// This returns an iterator that uses Last.fm's library search functionality
458    /// to find albums matching the provided query string. The iterator handles
459    /// pagination automatically.
460    ///
461    /// # Arguments
462    ///
463    /// * `query` - The search query (e.g., "remaster", "deluxe", artist name, etc.)
464    ///
465    /// # Returns
466    ///
467    /// Returns a `SearchAlbumsIterator` for streaming search results.
468    fn search_albums(&self, query: &str) -> Box<dyn AsyncPaginatedIterator<Album>>;
469
470    /// Create an iterator for searching artists in the user's library.
471    ///
472    /// This returns an iterator that uses Last.fm's library search functionality
473    /// to find artists matching the provided query string. The iterator handles
474    /// pagination automatically.
475    ///
476    /// # Arguments
477    ///
478    /// * `query` - The search query (e.g., artist name, partial match, etc.)
479    ///
480    /// # Returns
481    ///
482    /// Returns a `SearchArtistsIterator` for streaming search results.
483    fn search_artists(&self, query: &str) -> Box<dyn AsyncPaginatedIterator<Artist>>;
484
485    // =============================================================================
486    // CONVENIENCE METHODS - Higher-level helpers and shortcuts
487    // =============================================================================
488
489    /// Discover all scrobble edit variations based on the provided ScrobbleEdit template.
490    ///
491    /// This method analyzes what fields are specified in the input ScrobbleEdit and discovers
492    /// all relevant scrobble instances that match the criteria:
493    /// - If track_name_original is specified: discovers all album variations of that track
494    /// - If only album_name_original is specified: discovers all tracks in that album
495    /// - If neither is specified: discovers all tracks by that artist
496    ///
497    /// Returns fully-specified ExactScrobbleEdit instances with all metadata populated
498    /// from the user's library, ready for editing operations.
499    async fn discover_scrobble_edit_variations(
500        &self,
501        edit: &ScrobbleEdit,
502    ) -> Result<Vec<ExactScrobbleEdit>> {
503        // Use the incremental iterator and collect all results
504        let mut discovery_iterator = self.discover_scrobbles(edit.clone());
505        discovery_iterator.collect_all().await
506    }
507
508    /// Get every album variation of a track from the user's library, fully populated from
509    /// the scrobble edit forms on the track's library page.
510    ///
511    /// Returns one [`ExactScrobbleEdit`] per unique `(album, album artist)` combination,
512    /// with all original fields — including the authoritative `album_artist_name_original`
513    /// scraped from the hidden form inputs — filled in. This is the primary way to backfill
514    /// album artist information, which is not available from Last.fm's public API.
515    ///
516    /// # Arguments
517    ///
518    /// * `track_name` - The track name as it appears in the library
519    /// * `artist_name` - The (track) artist name as it appears in the library
520    ///
521    /// # Errors
522    ///
523    /// Returns [`LastFmError::Parse`](crate::LastFmError::Parse) if no scrobble edit forms
524    /// can be found for the track.
525    async fn get_scrobble_edit_variations(
526        &self,
527        track_name: &str,
528        artist_name: &str,
529    ) -> Result<Vec<ExactScrobbleEdit>>;
530
531    /// Resolve the authoritative album artist for a scrobbled track.
532    ///
533    /// This is a convenience wrapper around
534    /// [`get_scrobble_edit_variations`](Self::get_scrobble_edit_variations) that picks a
535    /// single variation and returns its `album_artist_name_original`:
536    ///
537    /// - If `album` is `Some`, the variation whose `album_name_original` matches it
538    ///   **exactly (case-sensitively)** is used.
539    /// - If `album` is `None`, or no variation matches exactly, the **first** discovered
540    ///   variation is used as a fallback.
541    ///
542    /// Returns `Ok(None)` only when no variations are available at all (implementations
543    /// typically return an error in that case instead).
544    ///
545    /// # Arguments
546    ///
547    /// * `artist` - The (track) artist name as it appears in the library
548    /// * `track` - The track name as it appears in the library
549    /// * `album` - The album to disambiguate between variations, if known
550    async fn resolve_album_artist(
551        &self,
552        artist: &str,
553        track: &str,
554        album: Option<&str>,
555    ) -> Result<Option<String>> {
556        let variations = self.get_scrobble_edit_variations(track, artist).await?;
557        let chosen = match album {
558            Some(album_name) => variations
559                .iter()
560                .find(|variation| variation.album_name_original == album_name)
561                .or_else(|| variations.first()),
562            None => variations.first(),
563        };
564        Ok(chosen.map(|variation| variation.album_artist_name_original.clone()))
565    }
566
567    /// Edit album metadata by updating scrobbles with new album name.
568    async fn edit_album(
569        &self,
570        old_album_name: &str,
571        new_album_name: &str,
572        artist_name: &str,
573    ) -> Result<EditResponse> {
574        log::debug!("Editing album '{old_album_name}' -> '{new_album_name}' by '{artist_name}'");
575
576        let edit = ScrobbleEdit::for_album(old_album_name, artist_name, artist_name)
577            .with_album_name(new_album_name);
578
579        self.edit_scrobble(&edit).await
580    }
581
582    /// Edit artist metadata by updating scrobbles with new artist name.
583    ///
584    /// This edits ALL tracks from the artist that are found in recent scrobbles.
585    async fn edit_artist(
586        &self,
587        old_artist_name: &str,
588        new_artist_name: &str,
589    ) -> Result<EditResponse> {
590        log::debug!("Editing artist '{old_artist_name}' -> '{new_artist_name}'");
591
592        let edit = ScrobbleEdit::for_artist(old_artist_name, new_artist_name);
593
594        self.edit_scrobble(&edit).await
595    }
596
597    /// Edit artist metadata for a specific track only.
598    ///
599    /// This edits only the specified track if found in recent scrobbles.
600    async fn edit_artist_for_track(
601        &self,
602        track_name: &str,
603        old_artist_name: &str,
604        new_artist_name: &str,
605    ) -> Result<EditResponse> {
606        log::debug!("Editing artist for track '{track_name}' from '{old_artist_name}' -> '{new_artist_name}'");
607
608        let edit = ScrobbleEdit::from_track_and_artist(track_name, old_artist_name)
609            .with_artist_name(new_artist_name);
610
611        self.edit_scrobble(&edit).await
612    }
613
614    /// Edit artist metadata for all tracks in a specific album.
615    ///
616    /// This edits ALL tracks from the specified album that are found in recent scrobbles.
617    async fn edit_artist_for_album(
618        &self,
619        album_name: &str,
620        old_artist_name: &str,
621        new_artist_name: &str,
622    ) -> Result<EditResponse> {
623        log::debug!("Editing artist for album '{album_name}' from '{old_artist_name}' -> '{new_artist_name}'");
624
625        let edit = ScrobbleEdit::for_album(album_name, old_artist_name, old_artist_name)
626            .with_artist_name(new_artist_name);
627
628        self.edit_scrobble(&edit).await
629    }
630}
631
632#[cfg(feature = "mock")]
633mockall::mock! {
634    pub LastFmEditClient {}
635
636    #[async_trait(?Send)]
637    impl LastFmBaseClient for LastFmEditClient {
638        async fn get_artists_page(&self, page: u32) -> Result<ArtistPage>;
639        async fn get_artist_tracks_page(&self, artist: &str, page: u32) -> Result<crate::TrackPage>;
640        async fn get_artist_albums_page(&self, artist: &str, page: u32) -> Result<crate::AlbumPage>;
641        async fn get_album_tracks_page(
642            &self,
643            album_name: &str,
644            artist_name: &str,
645            page: u32,
646        ) -> Result<crate::TrackPage>;
647        async fn get_recent_tracks_page(&self, page: u32) -> Result<crate::TrackPage>;
648        async fn search_tracks_page(&self, query: &str, page: u32) -> Result<crate::TrackPage>;
649        async fn search_albums_page(&self, query: &str, page: u32) -> Result<crate::AlbumPage>;
650        async fn search_artists_page(&self, query: &str, page: u32) -> Result<crate::ArtistPage>;
651        fn username(&self) -> String;
652        fn get_session(&self) -> LastFmEditSession;
653        fn subscribe(&self) -> ClientEventReceiver;
654        fn latest_event(&self) -> Option<ClientEvent>;
655        async fn validate_session(&self) -> bool;
656        async fn find_recent_scrobble_for_track(
657            &self,
658            track_name: &str,
659            artist_name: &str,
660            max_pages: u32,
661        ) -> Result<Option<Track>>;
662        fn cancel(&self);
663        fn reset_cancel(&self);
664        fn is_cancelled(&self) -> bool;
665    }
666
667    #[async_trait(?Send)]
668    impl LastFmEditClient for LastFmEditClient {
669        async fn edit_scrobble(&self, edit: &ScrobbleEdit) -> Result<EditResponse>;
670        async fn edit_scrobble_single(
671            &self,
672            exact_edit: &ExactScrobbleEdit,
673            max_retries: u32,
674        ) -> Result<EditResponse>;
675        async fn delete_scrobble(
676            &self,
677            artist_name: &str,
678            track_name: &str,
679            timestamp: u64,
680        ) -> Result<bool>;
681        async fn get_scrobble_edit_variations(
682            &self,
683            track_name: &str,
684            artist_name: &str,
685        ) -> Result<Vec<ExactScrobbleEdit>>;
686        fn discover_scrobbles(
687            &self,
688            edit: ScrobbleEdit,
689        ) -> Box<dyn crate::AsyncDiscoveryIterator<crate::ExactScrobbleEdit>>;
690        fn artists(&self) -> Box<dyn AsyncPaginatedIterator<Artist>>;
691        fn artist_tracks(&self, artist: &str) -> Box<dyn AsyncPaginatedIterator<Track>>;
692        fn artist_tracks_direct(&self, artist: &str) -> Box<dyn AsyncPaginatedIterator<Track>>;
693        fn artist_albums(&self, artist: &str) -> Box<dyn AsyncPaginatedIterator<Album>>;
694        fn album_tracks(
695            &self,
696            album_name: &str,
697            artist_name: &str,
698        ) -> Box<dyn AsyncPaginatedIterator<Track>>;
699        fn recent_tracks(&self) -> Box<dyn AsyncPaginatedIterator<Track>>;
700        fn recent_tracks_from_page(&self, starting_page: u32)
701            -> Box<dyn AsyncPaginatedIterator<Track>>;
702        fn search_tracks(&self, query: &str) -> Box<dyn AsyncPaginatedIterator<Track>>;
703        fn search_albums(&self, query: &str) -> Box<dyn AsyncPaginatedIterator<Album>>;
704        fn search_artists(&self, query: &str) -> Box<dyn AsyncPaginatedIterator<Artist>>;
705    }
706}