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}