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