lastfm_edit/
scrobble_edit_context.rs

1use crate::{LastFmClient, Result, ScrobbleEdit};
2
3/// Context object that bridges track listing data with edit functionality.
4///
5/// This structure provides a high-level interface for editing scrobbles by combining
6/// track discovery data with edit operations. It handles the complexity of choosing
7/// between bulk edits and specific scrobble edits.
8///
9/// # Examples
10///
11/// ```rust,no_run
12/// use lastfm_edit::{ScrobbleEditContext, EditStrategy, IntoEditContext};
13/// use lastfm_edit::{LastFmClient, AsyncPaginatedIterator};
14///
15/// # tokio_test::block_on(async {
16/// let mut client = LastFmClient::new(Box::new(http_client::native::NativeClient::new()));
17/// // client.login(...).await?;
18///
19/// // Find tracks and convert to edit contexts
20/// let mut tracks = client.artist_tracks("Radiohead");
21/// let first_track = tracks.next().await?.unwrap();
22/// let edit_context = first_track.into_edit_context();
23///
24/// // Execute a bulk edit
25/// let success = edit_context.execute_edit(
26///     &mut client,
27///     "Corrected Track Name".to_string(),
28///     None
29/// ).await?;
30///
31/// if success {
32///     println!("Edit completed successfully");
33/// }
34/// # Ok::<(), Box<dyn std::error::Error>>(())
35/// # });
36/// ```
37#[derive(Debug, Clone)]
38pub struct ScrobbleEditContext {
39    /// The track name to be edited
40    pub track_name: String,
41    /// The artist name
42    pub artist_name: String,
43
44    /// Edit strategy - determines how to perform the edit
45    pub strategy: EditStrategy,
46
47    /// Optional album information if available
48    pub album_name: Option<String>,
49
50    /// Playcount from track listing (informational)
51    ///
52    /// This gives an estimate of how many scrobbles might be affected
53    /// when using [`EditStrategy::EditAll`].
54    pub playcount: u32,
55}
56
57/// Strategy for performing scrobble edits.
58///
59/// This enum determines whether to edit all matching scrobbles or only
60/// specific instances identified by timestamps.
61///
62/// # Examples
63///
64/// ```rust
65/// use lastfm_edit::EditStrategy;
66///
67/// // Edit all instances - good for fixing systematic errors
68/// let bulk_strategy = EditStrategy::EditAll;
69///
70/// // Edit specific scrobbles - good for precision edits
71/// let specific_strategy = EditStrategy::SpecificScrobbles(vec![1640995200, 1641000000]);
72/// ```
73#[derive(Debug, Clone)]
74pub enum EditStrategy {
75    /// Edit all instances of this track/artist combination.
76    ///
77    /// Uses Last.fm's bulk edit functionality to update all scrobbles
78    /// with matching metadata. This is efficient but affects all instances.
79    EditAll,
80
81    /// Edit specific scrobbles with known timestamps.
82    ///
83    /// Targets only the scrobbles identified by the provided timestamps.
84    /// This requires fetching actual scrobble data first but provides
85    /// more precise control.
86    SpecificScrobbles(Vec<u64>),
87}
88
89impl ScrobbleEditContext {
90    /// Create an edit context from track listing data.
91    ///
92    /// This is the primary bridge between track discovery and edit functionality.
93    /// The context defaults to [`EditStrategy::EditAll`] for simplicity.
94    ///
95    /// # Arguments
96    ///
97    /// * `track_name` - The track name from track listings
98    /// * `artist_name` - The artist name
99    /// * `playcount` - Play count (gives estimate of affected scrobbles)
100    /// * `album_name` - Optional album name if available
101    ///
102    /// # Examples
103    ///
104    /// ```rust
105    /// use lastfm_edit::ScrobbleEditContext;
106    ///
107    /// let context = ScrobbleEditContext::from_track_listing(
108    ///     "Paranoid Android".to_string(),
109    ///     "Radiohead".to_string(),
110    ///     42,
111    ///     Some("OK Computer".to_string())
112    /// );
113    /// ```
114    pub fn from_track_listing(
115        track_name: String,
116        artist_name: String,
117        playcount: u32,
118        album_name: Option<String>,
119    ) -> Self {
120        Self {
121            track_name,
122            artist_name,
123            album_name,
124            playcount,
125            strategy: EditStrategy::EditAll, // Default to edit_all for simplicity
126        }
127    }
128
129    /// Create a scrobble edit request from this context.
130    ///
131    /// This method generates a [`ScrobbleEdit`] based on the context's strategy
132    /// and the provided new values.
133    ///
134    /// # Arguments
135    ///
136    /// * `new_track_name` - The corrected track name
137    /// * `new_album_name` - Optional corrected album name
138    ///
139    /// # Examples
140    ///
141    /// ```rust
142    /// # use lastfm_edit::ScrobbleEditContext;
143    /// let context = ScrobbleEditContext::from_track_listing(
144    ///     "Wrong Name".to_string(),
145    ///     "Artist".to_string(),
146    ///     10,
147    ///     None
148    /// );
149    ///
150    /// let edit = context.create_edit(
151    ///     "Correct Name".to_string(),
152    ///     Some("Album".to_string())
153    /// );
154    /// ```
155    pub fn create_edit(
156        &self,
157        new_track_name: String,
158        new_album_name: Option<String>,
159    ) -> ScrobbleEdit {
160        let original_album = self.album_name.as_deref().unwrap_or(&self.track_name);
161        let target_album = new_album_name.as_deref().unwrap_or(&new_track_name);
162
163        match &self.strategy {
164            EditStrategy::EditAll => {
165                ScrobbleEdit::from_track_info(
166                    &self.track_name,
167                    original_album,
168                    &self.artist_name,
169                    0, // No timestamp needed for edit_all
170                )
171                .with_track_name(&new_track_name)
172                .with_album_name(target_album)
173                .with_edit_all(true)
174            }
175            EditStrategy::SpecificScrobbles(timestamps) => {
176                // For now, just use the first timestamp
177                // In a full implementation, you'd want to handle multiple timestamps
178                let timestamp = timestamps.first().copied().unwrap_or(0);
179
180                ScrobbleEdit::from_track_info(
181                    &self.track_name,
182                    original_album,
183                    &self.artist_name,
184                    timestamp,
185                )
186                .with_track_name(&new_track_name)
187                .with_album_name(target_album)
188                .with_edit_all(false)
189            }
190        }
191    }
192
193    /// Execute the edit using the provided client.
194    ///
195    /// This method automatically handles the complexity of finding real scrobble
196    /// data when needed and submitting the edit request to Last.fm.
197    ///
198    /// # Arguments
199    ///
200    /// * `client` - Authenticated Last.fm client
201    /// * `new_track_name` - The corrected track name
202    /// * `new_album_name` - Optional corrected album name
203    ///
204    /// # Returns
205    ///
206    /// Returns `Ok(true)` if the edit was successful, `Ok(false)` if it failed,
207    /// or `Err(...)` if there was a network or other error.
208    ///
209    /// # Examples
210    ///
211    /// ```rust,no_run
212    /// # use lastfm_edit::{ScrobbleEditContext, LastFmClient};
213    /// # tokio_test::block_on(async {
214    /// let mut client = LastFmClient::new(Box::new(http_client::native::NativeClient::new()));
215    /// let context = ScrobbleEditContext::from_track_listing(
216    ///     "Wrong Name".to_string(),
217    ///     "Artist".to_string(),
218    ///     5,
219    ///     None
220    /// );
221    ///
222    /// let success = context.execute_edit(
223    ///     &mut client,
224    ///     "Correct Name".to_string(),
225    ///     None
226    /// ).await?;
227    /// # Ok::<(), Box<dyn std::error::Error>>(())
228    /// # });
229    /// ```
230    pub async fn execute_edit(
231        &self,
232        client: &mut LastFmClient,
233        new_track_name: String,
234        new_album_name: Option<String>,
235    ) -> Result<bool> {
236        // For EditAll strategy, try to get a real scrobble timestamp first
237        let edit = match &self.strategy {
238            EditStrategy::EditAll => {
239                // Try to find a recent scrobble to get real timestamp data
240                match client
241                    .find_recent_scrobble_for_track(&self.track_name, &self.artist_name, 3)
242                    .await?
243                {
244                    Some(recent_scrobble) => {
245                        if let Some(timestamp) = recent_scrobble.timestamp {
246                            // Use real scrobble data for better compatibility
247                            ScrobbleEdit::from_track_info(
248                                &self.track_name,
249                                &self.track_name, // Use track name as album fallback
250                                &self.artist_name,
251                                timestamp,
252                            )
253                            .with_track_name(&new_track_name)
254                            .with_album_name(new_album_name.as_deref().unwrap_or(&new_track_name))
255                            .with_edit_all(true)
256                        } else {
257                            // Fallback to original approach if no timestamp
258                            self.create_edit(new_track_name.clone(), new_album_name.clone())
259                        }
260                    }
261                    None => {
262                        // No recent scrobble found, use original approach
263                        self.create_edit(new_track_name.clone(), new_album_name.clone())
264                    }
265                }
266            }
267            EditStrategy::SpecificScrobbles(_) => {
268                // For specific scrobbles, use the provided timestamps
269                self.create_edit(new_track_name.clone(), new_album_name.clone())
270            }
271        };
272
273        let response = client.edit_scrobble(&edit).await?;
274        Ok(response.success)
275    }
276
277    /// Execute the edit with real scrobble data lookup.
278    ///
279    /// This convenience method ensures that real scrobble timestamps are used
280    /// by explicitly searching the user's recent scrobbles first. This can be
281    /// more reliable than relying on track listing data.
282    ///
283    /// # Arguments
284    ///
285    /// * `client` - Authenticated Last.fm client
286    /// * `new_track_name` - The corrected track name
287    /// * `new_album_name` - Optional corrected album name
288    ///
289    /// # Returns
290    ///
291    /// Returns `Ok(true)` if successful, or an error if no recent scrobble
292    /// could be found or if the edit failed.
293    ///
294    /// # Examples
295    ///
296    /// ```rust,no_run
297    /// # use lastfm_edit::{ScrobbleEditContext, LastFmClient};
298    /// # tokio_test::block_on(async {
299    /// let mut client = LastFmClient::new(Box::new(http_client::native::NativeClient::new()));
300    /// let context = ScrobbleEditContext::from_track_listing(
301    ///     "Misspelled Track".to_string(),
302    ///     "Artist".to_string(),
303    ///     1,
304    ///     None
305    /// );
306    ///
307    /// // This will search recent scrobbles for real timestamp data
308    /// let success = context.execute_edit_with_real_data(
309    ///     &mut client,
310    ///     "Correctly Spelled Track".to_string(),
311    ///     None
312    /// ).await?;
313    /// # Ok::<(), Box<dyn std::error::Error>>(())
314    /// # });
315    /// ```
316    pub async fn execute_edit_with_real_data(
317        &self,
318        client: &mut LastFmClient,
319        new_track_name: String,
320        new_album_name: Option<String>,
321    ) -> Result<bool> {
322        // First, try to find the most recent scrobble for this track
323        match client
324            .find_recent_scrobble_for_track(&self.track_name, &self.artist_name, 5)
325            .await?
326        {
327            Some(recent_scrobble) => {
328                if let Some(timestamp) = recent_scrobble.timestamp {
329                    // Create edit with real scrobble data
330                    let edit = ScrobbleEdit::from_track_info(
331                        &recent_scrobble.name,
332                        &recent_scrobble.name, // Use track name as album fallback
333                        &recent_scrobble.artist,
334                        timestamp,
335                    )
336                    .with_track_name(&new_track_name)
337                    .with_album_name(new_album_name.as_deref().unwrap_or(&new_track_name))
338                    .with_edit_all(true);
339
340                    let response = client.edit_scrobble(&edit).await?;
341                    Ok(response.success)
342                } else {
343                    Err(crate::LastFmError::Parse(
344                        "Found recent scrobble but no timestamp available".to_string(),
345                    ))
346                }
347            }
348            None => Err(crate::LastFmError::Parse(format!(
349                "No recent scrobble found for '{}' by '{}'",
350                self.track_name, self.artist_name
351            ))),
352        }
353    }
354
355    /// Get a human-readable description of what this edit will do.
356    ///
357    /// This is useful for logging or user confirmation before executing edits.
358    ///
359    /// # Arguments
360    ///
361    /// * `new_track_name` - The target track name for the edit
362    ///
363    /// # Examples
364    ///
365    /// ```rust
366    /// # use lastfm_edit::ScrobbleEditContext;
367    /// let context = ScrobbleEditContext::from_track_listing(
368    ///     "Old Name".to_string(),
369    ///     "Artist".to_string(),
370    ///     15,
371    ///     None
372    /// );
373    ///
374    /// let description = context.describe_edit("New Name");
375    /// println!("{}", description);
376    /// // Output: "Will edit ALL instances of 'Old Name' by 'Artist' to 'New Name' (approximately 15 scrobbles)"
377    /// ```
378    pub fn describe_edit(&self, new_track_name: &str) -> String {
379        match &self.strategy {
380            EditStrategy::EditAll => {
381                format!(
382                    "Will edit ALL instances of '{}' by '{}' to '{}' (approximately {} scrobbles)",
383                    self.track_name, self.artist_name, new_track_name, self.playcount
384                )
385            }
386            EditStrategy::SpecificScrobbles(timestamps) => {
387                format!(
388                    "Will edit {} specific scrobbles of '{}' by '{}' to '{}'",
389                    timestamps.len(),
390                    self.track_name,
391                    self.artist_name,
392                    new_track_name
393                )
394            }
395        }
396    }
397}
398
399/// Helper trait to convert track data into edit contexts.
400///
401/// This trait provides a convenient way to transform track listing data
402/// into editable contexts, bridging the gap between discovery and editing.
403///
404/// # Examples
405///
406/// ```rust,no_run
407/// use lastfm_edit::{LastFmClient, AsyncPaginatedIterator, IntoEditContext};
408///
409/// # tokio_test::block_on(async {
410/// let mut client = LastFmClient::new(Box::new(http_client::native::NativeClient::new()));
411/// // client.login(...).await?;
412///
413/// let mut tracks = client.artist_tracks("Radiohead");
414/// if let Some(track) = tracks.next().await? {
415///     let edit_context = track.into_edit_context();
416///
417///     // Now ready to edit
418///     let success = edit_context.execute_edit(
419///         &mut client,
420///         "Fixed Track Name".to_string(),
421///         None
422///     ).await?;
423/// }
424/// # Ok::<(), Box<dyn std::error::Error>>(())
425/// # });
426/// ```
427pub trait IntoEditContext {
428    /// Convert this item into a [`ScrobbleEditContext`].
429    fn into_edit_context(self) -> ScrobbleEditContext;
430}
431
432impl IntoEditContext for crate::Track {
433    /// Convert a [`Track`](crate::Track) into a [`ScrobbleEditContext`].
434    ///
435    /// The resulting context uses [`EditStrategy::EditAll`] by default and
436    /// doesn't include album information since track listings don't have
437    /// reliable album data.
438    fn into_edit_context(self) -> ScrobbleEditContext {
439        ScrobbleEditContext::from_track_listing(
440            self.name,
441            self.artist,
442            self.playcount,
443            None, // Track listing doesn't have reliable album data
444        )
445    }
446}