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}