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