lastfm_edit/
edit.rs

1/// Represents a scrobble edit operation.
2///
3/// This structure contains all the information needed to edit a specific scrobble
4/// on Last.fm, including both the original and new metadata values.
5#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
6pub struct ScrobbleEdit {
7    /// Original track name as it appears in the scrobble (optional - if None, edits all tracks)
8    pub track_name_original: Option<String>,
9    /// Original album name as it appears in the scrobble (optional)
10    pub album_name_original: Option<String>,
11    /// Original artist name as it appears in the scrobble (required)
12    pub artist_name_original: String,
13    /// Original album artist name as it appears in the scrobble (optional)
14    pub album_artist_name_original: Option<String>,
15
16    /// New track name to set (optional - if None, keeps original track names)
17    pub track_name: Option<String>,
18    /// New album name to set (optional - if None, keeps original album names)
19    pub album_name: Option<String>,
20    /// New artist name to set
21    pub artist_name: String,
22    /// New album artist name to set (optional - if None, keeps original album artist names)
23    pub album_artist_name: Option<String>,
24
25    /// Unix timestamp of the scrobble to edit (optional)
26    ///
27    /// This identifies the specific scrobble instance to modify.
28    /// If None, the client will attempt to find a representative timestamp.
29    pub timestamp: Option<u64>,
30    /// Whether to edit all instances or just this specific scrobble
31    ///
32    /// When `true`, Last.fm will update all scrobbles with matching metadata.
33    /// When `false`, only this specific scrobble (identified by timestamp) is updated.
34    pub edit_all: bool,
35}
36
37/// Response from a single scrobble edit operation.
38///
39/// This structure contains the result of attempting to edit a specific scrobble instance,
40/// including success status and any error messages.
41#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
42pub struct SingleEditResponse {
43    /// Whether this individual edit operation was successful
44    pub success: bool,
45    /// Optional message describing the result or any errors
46    pub message: Option<String>,
47    /// Information about which album variation was edited
48    pub album_info: Option<String>,
49    /// The exact scrobble edit that was performed
50    pub exact_scrobble_edit: ExactScrobbleEdit,
51}
52
53/// Response from a scrobble edit operation that may affect multiple album variations.
54///
55/// When editing a track that appears on multiple albums, this response contains
56/// the results of all individual edit operations performed.
57#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
58pub struct EditResponse {
59    /// Results of individual edit operations
60    pub individual_results: Vec<SingleEditResponse>,
61}
62
63/// Internal representation of a scrobble edit with all fields fully specified.
64///
65/// This type is used internally by the client after enriching metadata from
66/// Last.fm. Unlike `ScrobbleEdit`, all fields are required and non-optional,
67/// ensuring we have complete information before performing edit operations.
68///
69/// This type represents a fully-specified scrobble edit where all fields are known.
70#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
71pub struct ExactScrobbleEdit {
72    /// Original track name as it appears in the scrobble
73    pub track_name_original: String,
74    /// Original album name as it appears in the scrobble
75    pub album_name_original: String,
76    /// Original artist name as it appears in the scrobble
77    pub artist_name_original: String,
78    /// Original album artist name as it appears in the scrobble
79    pub album_artist_name_original: String,
80
81    /// New track name to set
82    pub track_name: String,
83    /// New album name to set
84    pub album_name: String,
85    /// New artist name to set
86    pub artist_name: String,
87    /// New album artist name to set
88    pub album_artist_name: String,
89
90    /// Unix timestamp of the scrobble to edit
91    pub timestamp: u64,
92    /// Whether to edit all instances or just this specific scrobble
93    pub edit_all: bool,
94}
95
96impl ScrobbleEdit {
97    /// Create a new [`ScrobbleEdit`] with all required fields.
98    ///
99    /// This is the most general constructor that allows setting all fields.
100    /// For convenience, consider using [`from_track_info`](Self::from_track_info) instead.
101    ///
102    /// # Arguments
103    ///
104    /// * `track_name_original` - The current track name in the scrobble
105    /// * `album_name_original` - The current album name in the scrobble
106    /// * `artist_name_original` - The current artist name in the scrobble
107    /// * `album_artist_name_original` - The current album artist name in the scrobble
108    /// * `track_name` - The new track name to set
109    /// * `album_name` - The new album name to set
110    /// * `artist_name` - The new artist name to set
111    /// * `album_artist_name` - The new album artist name to set
112    /// * `timestamp` - Unix timestamp identifying the scrobble
113    /// * `edit_all` - Whether to edit all matching scrobbles or just this one
114    #[allow(clippy::too_many_arguments)]
115    pub fn new(
116        track_name_original: Option<String>,
117        album_name_original: Option<String>,
118        artist_name_original: String,
119        album_artist_name_original: Option<String>,
120        track_name: Option<String>,
121        album_name: Option<String>,
122        artist_name: String,
123        album_artist_name: Option<String>,
124        timestamp: Option<u64>,
125        edit_all: bool,
126    ) -> Self {
127        Self {
128            track_name_original,
129            album_name_original,
130            artist_name_original,
131            album_artist_name_original,
132            track_name,
133            album_name,
134            artist_name,
135            album_artist_name,
136            timestamp,
137            edit_all,
138        }
139    }
140
141    /// Create an edit request from track information (convenience constructor).
142    ///
143    /// This constructor creates a [`ScrobbleEdit`] with the new values initially
144    /// set to the same as the original values. Use the builder methods like
145    /// [`with_track_name`](Self::with_track_name) to specify what should be changed.
146    ///
147    /// # Arguments
148    ///
149    /// * `original_track` - The current track name
150    /// * `original_album` - The current album name
151    /// * `original_artist` - The current artist name
152    /// * `timestamp` - Unix timestamp identifying the scrobble
153    pub fn from_track_info(
154        original_track: &str,
155        original_album: &str,
156        original_artist: &str,
157        timestamp: u64,
158    ) -> Self {
159        Self::new(
160            Some(original_track.to_string()),
161            Some(original_album.to_string()),
162            original_artist.to_string(),
163            Some(original_artist.to_string()), // album_artist defaults to artist
164            Some(original_track.to_string()),
165            Some(original_album.to_string()),
166            original_artist.to_string(),
167            Some(original_artist.to_string()), // album_artist defaults to artist
168            Some(timestamp),
169            false, // edit_all defaults to false
170        )
171    }
172
173    /// Set the new track name.
174    pub fn with_track_name(mut self, track_name: &str) -> Self {
175        self.track_name = Some(track_name.to_string());
176        self
177    }
178
179    /// Set the new album name.
180    pub fn with_album_name(mut self, album_name: &str) -> Self {
181        self.album_name = Some(album_name.to_string());
182        self
183    }
184
185    /// Set the new artist name.
186    ///
187    /// This also sets the album artist name to the same value.
188    pub fn with_artist_name(mut self, artist_name: &str) -> Self {
189        self.artist_name = artist_name.to_string();
190        self.album_artist_name = Some(artist_name.to_string());
191        self
192    }
193
194    /// Set whether to edit all instances of this track.
195    ///
196    /// When `true`, Last.fm will update all scrobbles with the same metadata.
197    /// When `false` (default), only the specific scrobble is updated.
198    pub fn with_edit_all(mut self, edit_all: bool) -> Self {
199        self.edit_all = edit_all;
200        self
201    }
202
203    /// Create an edit request with minimal information, letting the client look up missing metadata.
204    ///
205    /// This constructor is useful when you only know some of the original metadata and want
206    /// the client to automatically fill in missing information by looking up the scrobble.
207    ///
208    /// # Arguments
209    ///
210    /// * `track_name` - The new track name to set
211    /// * `artist_name` - The new artist name to set
212    /// * `album_name` - The new album name to set
213    /// * `timestamp` - Unix timestamp identifying the scrobble
214    pub fn with_minimal_info(
215        track_name: &str,
216        artist_name: &str,
217        album_name: &str,
218        timestamp: u64,
219    ) -> Self {
220        Self::new(
221            Some(track_name.to_string()),
222            Some(album_name.to_string()),
223            artist_name.to_string(),
224            Some(artist_name.to_string()),
225            Some(track_name.to_string()),
226            Some(album_name.to_string()),
227            artist_name.to_string(),
228            Some(artist_name.to_string()),
229            Some(timestamp),
230            false,
231        )
232    }
233    /// Create an edit request with just track and artist information.
234    ///
235    /// This constructor is useful when you only know the track and artist names.
236    /// The client will use these as both original and new values, and will
237    /// attempt to find a representative timestamp and album information.
238    ///
239    /// # Arguments
240    ///
241    /// * `track_name` - The track name (used as both original and new)
242    /// * `artist_name` - The artist name (used as both original and new)
243    pub fn from_track_and_artist(track_name: &str, artist_name: &str) -> Self {
244        Self::new(
245            Some(track_name.to_string()),
246            None, // Client will look up original album name
247            artist_name.to_string(),
248            None, // Client will look up original album artist name
249            Some(track_name.to_string()),
250            None, // Will be filled by client or kept as original
251            artist_name.to_string(),
252            Some(artist_name.to_string()), // album_artist defaults to artist
253            None,                          // Client will find representative timestamp
254            false,
255        )
256    }
257
258    /// Create an edit request for all tracks by an artist.
259    ///
260    /// This constructor creates a [`ScrobbleEdit`] that will edit all tracks
261    /// by the specified artist, changing the artist name to the new value.
262    ///
263    /// # Arguments
264    ///
265    /// * `old_artist_name` - The current artist name to change from
266    /// * `new_artist_name` - The new artist name to change to
267    pub fn for_artist(old_artist_name: &str, new_artist_name: &str) -> Self {
268        Self::new(
269            None, // No specific track - edit all tracks
270            None, // No specific album - edit all albums
271            old_artist_name.to_string(),
272            None, // Client will look up original album artist name
273            None, // No track name change - keep original track names
274            None, // Keep original album names (they can vary)
275            new_artist_name.to_string(),
276            Some(new_artist_name.to_string()), // album_artist also changes for global renames
277            None,                              // Client will find representative timestamp
278            true,                              // Edit all instances by default for artist changes
279        )
280    }
281
282    /// Create an edit request for all tracks in a specific album.
283    ///
284    /// This constructor creates a [`ScrobbleEdit`] that will edit all tracks
285    /// in the specified album by the specified artist.
286    ///
287    /// # Arguments
288    ///
289    /// * `album_name` - The album name containing tracks to edit
290    /// * `artist_name` - The artist name for the album
291    /// * `new_artist_name` - The new artist name to change to
292    pub fn for_album(album_name: &str, old_artist_name: &str, new_artist_name: &str) -> Self {
293        Self::new(
294            None, // No specific track - edit all tracks in album
295            Some(album_name.to_string()),
296            old_artist_name.to_string(),
297            Some(old_artist_name.to_string()),
298            None,                         // No track name change - keep original track names
299            Some(album_name.to_string()), // Keep same album name
300            new_artist_name.to_string(),
301            None, // Keep original album_artist names (they can vary)
302            None, // Client will find representative timestamp
303            true, // Edit all instances by default for album changes
304        )
305    }
306}
307
308impl ExactScrobbleEdit {
309    /// Create a new [`ExactScrobbleEdit`] with all fields specified.
310    #[allow(clippy::too_many_arguments)]
311    pub fn new(
312        track_name_original: String,
313        album_name_original: String,
314        artist_name_original: String,
315        album_artist_name_original: String,
316        track_name: String,
317        album_name: String,
318        artist_name: String,
319        album_artist_name: String,
320        timestamp: u64,
321        edit_all: bool,
322    ) -> Self {
323        Self {
324            track_name_original,
325            album_name_original,
326            artist_name_original,
327            album_artist_name_original,
328            track_name,
329            album_name,
330            artist_name,
331            album_artist_name,
332            timestamp,
333            edit_all,
334        }
335    }
336
337    /// Build the form data for submitting this scrobble edit.
338    ///
339    /// This creates a HashMap containing all the form fields needed to submit
340    /// the edit request to Last.fm, including the CSRF token and all metadata fields.
341    pub fn build_form_data(&self, csrf_token: &str) -> std::collections::HashMap<&str, String> {
342        let mut form_data = std::collections::HashMap::new();
343
344        // Add fresh CSRF token (required)
345        form_data.insert("csrfmiddlewaretoken", csrf_token.to_string());
346
347        // Include ALL form fields (using ExactScrobbleEdit which has all required fields)
348        form_data.insert("track_name_original", self.track_name_original.clone());
349        form_data.insert("track_name", self.track_name.clone());
350        form_data.insert("artist_name_original", self.artist_name_original.clone());
351        form_data.insert("artist_name", self.artist_name.clone());
352        form_data.insert("album_name_original", self.album_name_original.clone());
353        form_data.insert("album_name", self.album_name.clone());
354        form_data.insert(
355            "album_artist_name_original",
356            self.album_artist_name_original.clone(),
357        );
358        form_data.insert("album_artist_name", self.album_artist_name.clone());
359
360        // Include timestamp (ExactScrobbleEdit always has a timestamp)
361        form_data.insert("timestamp", self.timestamp.to_string());
362
363        // Edit flags
364        if self.edit_all {
365            form_data.insert("edit_all", "1".to_string());
366        }
367        form_data.insert("submit", "edit-scrobble".to_string());
368        form_data.insert("ajax", "1".to_string());
369
370        form_data
371    }
372
373    /// Convert this exact edit back to a public ScrobbleEdit.
374    ///
375    /// This is useful when you need to expose the edit data through the public API.
376    pub fn to_scrobble_edit(&self) -> ScrobbleEdit {
377        ScrobbleEdit::new(
378            Some(self.track_name_original.clone()),
379            Some(self.album_name_original.clone()),
380            self.artist_name_original.clone(),
381            Some(self.album_artist_name_original.clone()),
382            Some(self.track_name.clone()),
383            Some(self.album_name.clone()),
384            self.artist_name.clone(),
385            Some(self.album_artist_name.clone()),
386            Some(self.timestamp),
387            self.edit_all,
388        )
389    }
390}
391
392impl EditResponse {
393    /// Create a new EditResponse from a single result.
394    pub fn single(
395        success: bool,
396        message: Option<String>,
397        album_info: Option<String>,
398        exact_scrobble_edit: ExactScrobbleEdit,
399    ) -> Self {
400        Self {
401            individual_results: vec![SingleEditResponse {
402                success,
403                message,
404                album_info,
405                exact_scrobble_edit,
406            }],
407        }
408    }
409
410    /// Create a new EditResponse from multiple results.
411    pub fn from_results(results: Vec<SingleEditResponse>) -> Self {
412        Self {
413            individual_results: results,
414        }
415    }
416
417    /// Check if all individual edit operations were successful.
418    pub fn all_successful(&self) -> bool {
419        !self.individual_results.is_empty() && self.individual_results.iter().all(|r| r.success)
420    }
421
422    /// Check if any individual edit operations were successful.
423    pub fn any_successful(&self) -> bool {
424        self.individual_results.iter().any(|r| r.success)
425    }
426
427    /// Get the total number of edit operations performed.
428    pub fn total_edits(&self) -> usize {
429        self.individual_results.len()
430    }
431
432    /// Get the number of successful edit operations.
433    pub fn successful_edits(&self) -> usize {
434        self.individual_results.iter().filter(|r| r.success).count()
435    }
436
437    /// Get the number of failed edit operations.
438    pub fn failed_edits(&self) -> usize {
439        self.individual_results
440            .iter()
441            .filter(|r| !r.success)
442            .count()
443    }
444
445    /// Generate a summary message describing the overall result.
446    pub fn summary_message(&self) -> String {
447        let total = self.total_edits();
448        let successful = self.successful_edits();
449        let failed = self.failed_edits();
450
451        if total == 0 {
452            return "No edit operations performed".to_string();
453        }
454
455        if successful == total {
456            if total == 1 {
457                "Edit completed successfully".to_string()
458            } else {
459                format!("All {total} edits completed successfully")
460            }
461        } else if successful == 0 {
462            if total == 1 {
463                "Edit failed".to_string()
464            } else {
465                format!("All {total} edits failed")
466            }
467        } else {
468            format!("{successful} of {total} edits succeeded, {failed} failed")
469        }
470    }
471
472    /// Get detailed messages from all edit operations.
473    pub fn detailed_messages(&self) -> Vec<String> {
474        self.individual_results
475            .iter()
476            .enumerate()
477            .map(|(i, result)| {
478                let album_info = result
479                    .album_info
480                    .as_deref()
481                    .map(|info| format!(" ({info})"))
482                    .unwrap_or_default();
483
484                match &result.message {
485                    Some(msg) => format!("{}: {}{}", i + 1, msg, album_info),
486                    None => {
487                        if result.success {
488                            format!("{}: Success{}", i + 1, album_info)
489                        } else {
490                            format!("{}: Failed{}", i + 1, album_info)
491                        }
492                    }
493                }
494            })
495            .collect()
496    }
497
498    /// Check if this response represents a single edit (for backward compatibility).
499    pub fn is_single_edit(&self) -> bool {
500        self.individual_results.len() == 1
501    }
502
503    /// Check if all edits succeeded (for backward compatibility).
504    pub fn success(&self) -> bool {
505        self.all_successful()
506    }
507
508    /// Get a single message for backward compatibility.
509    /// Returns the summary message.
510    pub fn message(&self) -> Option<String> {
511        Some(self.summary_message())
512    }
513}