lastfm_edit/types.rs
1//! Data types for Last.fm music metadata and operations.
2//!
3//! This module contains all the core data structures used throughout the crate,
4//! including track and album metadata, edit operations, error types, session state,
5//! configuration, and event handling.
6
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::fmt;
11use thiserror::Error;
12use tokio::sync::{broadcast, watch};
13
14// ================================================================================================
15// TRACK AND ALBUM METADATA
16// ================================================================================================
17
18/// Represents a music track with associated metadata.
19///
20/// This structure contains track information as parsed from Last.fm pages,
21/// including play count and optional timestamp data for scrobbles.
22#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
23pub struct Track {
24 /// The track name/title
25 pub name: String,
26 /// The artist name
27 pub artist: String,
28 /// Number of times this track has been played/scrobbled
29 pub playcount: u32,
30 /// Unix timestamp of when this track was scrobbled (if available)
31 ///
32 /// This field is populated when tracks are retrieved from recent scrobbles
33 /// or individual scrobble data, but may be `None` for aggregate track listings.
34 pub timestamp: Option<u64>,
35 /// The album name (if available)
36 ///
37 /// This field is populated when tracks are retrieved from recent scrobbles
38 /// where album information is available in the edit forms. May be `None`
39 /// for aggregate track listings or when album information is not available.
40 pub album: Option<String>,
41 /// The album artist name (if available and different from track artist)
42 ///
43 /// This field is populated when tracks are retrieved from recent scrobbles
44 /// where album artist information is available. May be `None` for tracks
45 /// where the album artist is the same as the track artist, or when this
46 /// information is not available.
47 pub album_artist: Option<String>,
48}
49
50impl fmt::Display for Track {
51 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52 let album_part = if let Some(ref album) = self.album {
53 format!(" [{album}]")
54 } else {
55 String::new()
56 };
57 write!(f, "{} - {}{}", self.artist, self.name, album_part)
58 }
59}
60
61/// Represents a paginated collection of tracks.
62///
63/// This structure is returned by track listing methods and provides
64/// information about the current page and pagination state.
65#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
66pub struct TrackPage {
67 /// The tracks on this page
68 pub tracks: Vec<Track>,
69 /// Current page number (1-indexed)
70 pub page_number: u32,
71 /// Whether there are more pages available
72 pub has_next_page: bool,
73 /// Total number of pages, if known
74 ///
75 /// This may be `None` if the total page count cannot be determined
76 /// from the Last.fm response.
77 pub total_pages: Option<u32>,
78}
79
80/// Represents a music album with associated metadata.
81///
82/// This structure contains album information as parsed from Last.fm pages,
83/// including play count and optional timestamp data for scrobbles.
84#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
85pub struct Album {
86 /// The album name/title
87 pub name: String,
88 /// The artist name
89 pub artist: String,
90 /// Number of times this album has been played/scrobbled
91 pub playcount: u32,
92 /// Unix timestamp of when this album was last scrobbled (if available)
93 ///
94 /// This field is populated when albums are retrieved from recent scrobbles
95 /// or individual scrobble data, but may be `None` for aggregate album listings.
96 pub timestamp: Option<u64>,
97}
98
99impl fmt::Display for Album {
100 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
101 write!(f, "{} - {}", self.artist, self.name)
102 }
103}
104
105/// Represents a paginated collection of albums.
106///
107/// This structure is returned by album listing methods and provides
108/// information about the current page and pagination state.
109#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
110pub struct AlbumPage {
111 /// The albums on this page
112 pub albums: Vec<Album>,
113 /// Current page number (1-indexed)
114 pub page_number: u32,
115 /// Whether there are more pages available
116 pub has_next_page: bool,
117 /// Total number of pages, if known
118 ///
119 /// This may be `None` if the total page count cannot be determined
120 /// from the Last.fm response.
121 pub total_pages: Option<u32>,
122}
123
124impl Album {
125 /// Convert the Unix timestamp to a human-readable datetime.
126 ///
127 /// Returns `None` if no timestamp is available or if the timestamp is invalid.
128 #[must_use]
129 pub fn scrobbled_at(&self) -> Option<DateTime<Utc>> {
130 self.timestamp
131 .and_then(|ts| DateTime::from_timestamp(i64::try_from(ts).ok()?, 0))
132 }
133}
134
135// ================================================================================================
136// EDIT OPERATIONS
137// ================================================================================================
138
139/// Represents a scrobble edit operation.
140///
141/// This structure contains all the information needed to edit a specific scrobble
142/// on Last.fm, including both the original and new metadata values.
143#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
144pub struct ScrobbleEdit {
145 /// Original track name as it appears in the scrobble (optional - if None, edits all tracks)
146 pub track_name_original: Option<String>,
147 /// Original album name as it appears in the scrobble (optional)
148 pub album_name_original: Option<String>,
149 /// Original artist name as it appears in the scrobble (required)
150 pub artist_name_original: String,
151 /// Original album artist name as it appears in the scrobble (optional)
152 pub album_artist_name_original: Option<String>,
153
154 /// New track name to set (optional - if None, keeps original track names)
155 pub track_name: Option<String>,
156 /// New album name to set (optional - if None, keeps original album names)
157 pub album_name: Option<String>,
158 /// New artist name to set
159 pub artist_name: String,
160 /// New album artist name to set (optional - if None, keeps original album artist names)
161 pub album_artist_name: Option<String>,
162
163 /// Unix timestamp of the scrobble to edit (optional)
164 ///
165 /// This identifies the specific scrobble instance to modify.
166 /// If None, the client will attempt to find a representative timestamp.
167 pub timestamp: Option<u64>,
168 /// Whether to edit all instances or just this specific scrobble
169 ///
170 /// When `true`, Last.fm will update all scrobbles with matching metadata.
171 /// When `false`, only this specific scrobble (identified by timestamp) is updated.
172 pub edit_all: bool,
173}
174
175impl fmt::Display for ScrobbleEdit {
176 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177 let mut changes = Vec::new();
178
179 // Check if artist is being changed
180 if self.artist_name != self.artist_name_original {
181 changes.push(format!(
182 "Artist: {} → {}",
183 self.artist_name_original, self.artist_name
184 ));
185 }
186
187 // Check if track name is being changed
188 if let Some(ref new_track) = self.track_name {
189 if let Some(ref original_track) = self.track_name_original {
190 if new_track != original_track {
191 changes.push(format!("Track: {original_track} → {new_track}"));
192 }
193 } else {
194 changes.push(format!("Track: → {new_track}"));
195 }
196 }
197
198 // Check if album name is being changed
199 if let Some(ref new_album) = self.album_name {
200 match &self.album_name_original {
201 Some(ref original_album) if new_album != original_album => {
202 changes.push(format!("Album: {original_album} → {new_album}"));
203 }
204 None => {
205 changes.push(format!("Album: → {new_album}"));
206 }
207 _ => {} // No change
208 }
209 }
210
211 // Check if album artist is being changed
212 if let Some(ref new_album_artist) = self.album_artist_name {
213 match &self.album_artist_name_original {
214 Some(ref original_album_artist) if new_album_artist != original_album_artist => {
215 changes.push(format!(
216 "Album Artist: {original_album_artist} → {new_album_artist}"
217 ));
218 }
219 None => {
220 changes.push(format!("Album Artist: → {new_album_artist}"));
221 }
222 _ => {} // No change
223 }
224 }
225
226 if changes.is_empty() {
227 write!(f, "No changes")
228 } else {
229 let scope = if self.edit_all {
230 " (all instances)"
231 } else {
232 ""
233 };
234 write!(f, "{}{}", changes.join(", "), scope)
235 }
236 }
237}
238
239/// Response from a single scrobble edit operation.
240///
241/// This structure contains the result of attempting to edit a specific scrobble instance,
242/// including success status and any error messages.
243#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
244pub struct SingleEditResponse {
245 /// Whether this individual edit operation was successful
246 pub success: bool,
247 /// Optional message describing the result or any errors
248 pub message: Option<String>,
249 /// Information about which album variation was edited
250 pub album_info: Option<String>,
251 /// The exact scrobble edit that was performed
252 pub exact_scrobble_edit: ExactScrobbleEdit,
253}
254
255/// Response from a scrobble edit operation that may affect multiple album variations.
256///
257/// When editing a track that appears on multiple albums, this response contains
258/// the results of all individual edit operations performed.
259#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
260pub struct EditResponse {
261 /// Results of individual edit operations
262 pub individual_results: Vec<SingleEditResponse>,
263}
264
265/// Internal representation of a scrobble edit with all fields fully specified.
266///
267/// This type is used internally by the client after enriching metadata from
268/// Last.fm. Unlike `ScrobbleEdit`, all fields are required and non-optional,
269/// ensuring we have complete information before performing edit operations.
270///
271/// This type represents a fully-specified scrobble edit where all fields are known.
272#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
273pub struct ExactScrobbleEdit {
274 /// Original track name as it appears in the scrobble
275 pub track_name_original: String,
276 /// Original album name as it appears in the scrobble
277 pub album_name_original: String,
278 /// Original artist name as it appears in the scrobble
279 pub artist_name_original: String,
280 /// Original album artist name as it appears in the scrobble
281 pub album_artist_name_original: String,
282
283 /// New track name to set
284 pub track_name: String,
285 /// New album name to set
286 pub album_name: String,
287 /// New artist name to set
288 pub artist_name: String,
289 /// New album artist name to set
290 pub album_artist_name: String,
291
292 /// Unix timestamp of the scrobble to edit
293 pub timestamp: u64,
294 /// Whether to edit all instances or just this specific scrobble
295 pub edit_all: bool,
296}
297
298impl fmt::Display for ExactScrobbleEdit {
299 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
300 let mut changes = Vec::new();
301
302 // Check if artist is being changed
303 if self.artist_name != self.artist_name_original {
304 changes.push(format!(
305 "Artist: {} → {}",
306 self.artist_name_original, self.artist_name
307 ));
308 }
309
310 // Check if track name is being changed
311 if self.track_name != self.track_name_original {
312 changes.push(format!(
313 "Track: {} → {}",
314 self.track_name_original, self.track_name
315 ));
316 }
317
318 // Check if album name is being changed
319 if self.album_name != self.album_name_original {
320 changes.push(format!(
321 "Album: {} → {}",
322 self.album_name_original, self.album_name
323 ));
324 }
325
326 // Check if album artist is being changed
327 if self.album_artist_name != self.album_artist_name_original {
328 changes.push(format!(
329 "Album Artist: {} → {}",
330 self.album_artist_name_original, self.album_artist_name
331 ));
332 }
333
334 if changes.is_empty() {
335 write!(f, "No changes")
336 } else {
337 let scope = if self.edit_all {
338 " (all instances)"
339 } else {
340 ""
341 };
342 write!(f, "{}{}", changes.join(", "), scope)
343 }
344 }
345}
346
347impl ScrobbleEdit {
348 /// Create a new [`ScrobbleEdit`] with all required fields.
349 ///
350 /// This is the most general constructor that allows setting all fields.
351 /// For convenience, consider using [`from_track_info`](Self::from_track_info) instead.
352 ///
353 /// # Arguments
354 ///
355 /// * `track_name_original` - The current track name in the scrobble
356 /// * `album_name_original` - The current album name in the scrobble
357 /// * `artist_name_original` - The current artist name in the scrobble
358 /// * `album_artist_name_original` - The current album artist name in the scrobble
359 /// * `track_name` - The new track name to set
360 /// * `album_name` - The new album name to set
361 /// * `artist_name` - The new artist name to set
362 /// * `album_artist_name` - The new album artist name to set
363 /// * `timestamp` - Unix timestamp identifying the scrobble
364 /// * `edit_all` - Whether to edit all matching scrobbles or just this one
365 #[allow(clippy::too_many_arguments)]
366 pub fn new(
367 track_name_original: Option<String>,
368 album_name_original: Option<String>,
369 artist_name_original: String,
370 album_artist_name_original: Option<String>,
371 track_name: Option<String>,
372 album_name: Option<String>,
373 artist_name: String,
374 album_artist_name: Option<String>,
375 timestamp: Option<u64>,
376 edit_all: bool,
377 ) -> Self {
378 Self {
379 track_name_original,
380 album_name_original,
381 artist_name_original,
382 album_artist_name_original,
383 track_name,
384 album_name,
385 artist_name,
386 album_artist_name,
387 timestamp,
388 edit_all,
389 }
390 }
391
392 /// Create an edit request from track information (convenience constructor).
393 ///
394 /// This constructor creates a [`ScrobbleEdit`] with the new values initially
395 /// set to the same as the original values. Use the builder methods like
396 /// [`with_track_name`](Self::with_track_name) to specify what should be changed.
397 ///
398 /// # Arguments
399 ///
400 /// * `original_track` - The current track name
401 /// * `original_album` - The current album name
402 /// * `original_artist` - The current artist name
403 /// * `timestamp` - Unix timestamp identifying the scrobble
404 pub fn from_track_info(
405 original_track: &str,
406 original_album: &str,
407 original_artist: &str,
408 timestamp: u64,
409 ) -> Self {
410 Self::new(
411 Some(original_track.to_string()),
412 Some(original_album.to_string()),
413 original_artist.to_string(),
414 Some(original_artist.to_string()), // album_artist defaults to artist
415 Some(original_track.to_string()),
416 Some(original_album.to_string()),
417 original_artist.to_string(),
418 Some(original_artist.to_string()), // album_artist defaults to artist
419 Some(timestamp),
420 false, // edit_all defaults to false
421 )
422 }
423
424 /// Set the new track name.
425 pub fn with_track_name(mut self, track_name: &str) -> Self {
426 self.track_name = Some(track_name.to_string());
427 self
428 }
429
430 /// Set the new album name.
431 pub fn with_album_name(mut self, album_name: &str) -> Self {
432 self.album_name = Some(album_name.to_string());
433 self
434 }
435
436 /// Set the new artist name.
437 ///
438 /// This also sets the album artist name to the same value.
439 pub fn with_artist_name(mut self, artist_name: &str) -> Self {
440 self.artist_name = artist_name.to_string();
441 self.album_artist_name = Some(artist_name.to_string());
442 self
443 }
444
445 /// Set whether to edit all instances of this track.
446 ///
447 /// When `true`, Last.fm will update all scrobbles with the same metadata.
448 /// When `false` (default), only the specific scrobble is updated.
449 pub fn with_edit_all(mut self, edit_all: bool) -> Self {
450 self.edit_all = edit_all;
451 self
452 }
453
454 /// Create an edit request with minimal information, letting the client look up missing metadata.
455 ///
456 /// This constructor is useful when you only know some of the original metadata and want
457 /// the client to automatically fill in missing information by looking up the scrobble.
458 ///
459 /// # Arguments
460 ///
461 /// * `track_name` - The new track name to set
462 /// * `artist_name` - The new artist name to set
463 /// * `album_name` - The new album name to set
464 /// * `timestamp` - Unix timestamp identifying the scrobble
465 pub fn with_minimal_info(
466 track_name: &str,
467 artist_name: &str,
468 album_name: &str,
469 timestamp: u64,
470 ) -> Self {
471 Self::new(
472 Some(track_name.to_string()),
473 Some(album_name.to_string()),
474 artist_name.to_string(),
475 Some(artist_name.to_string()),
476 Some(track_name.to_string()),
477 Some(album_name.to_string()),
478 artist_name.to_string(),
479 Some(artist_name.to_string()),
480 Some(timestamp),
481 false,
482 )
483 }
484 /// Create an edit request with just track and artist information.
485 ///
486 /// This constructor is useful when you only know the track and artist names.
487 /// The client will use these as both original and new values, and will
488 /// attempt to find a representative timestamp and album information.
489 ///
490 /// # Arguments
491 ///
492 /// * `track_name` - The track name (used as both original and new)
493 /// * `artist_name` - The artist name (used as both original and new)
494 pub fn from_track_and_artist(track_name: &str, artist_name: &str) -> Self {
495 Self::new(
496 Some(track_name.to_string()),
497 None, // Client will look up original album name
498 artist_name.to_string(),
499 None, // Client will look up original album artist name
500 Some(track_name.to_string()),
501 None, // Will be filled by client or kept as original
502 artist_name.to_string(),
503 Some(artist_name.to_string()), // album_artist defaults to artist
504 None, // Client will find representative timestamp
505 false,
506 )
507 }
508
509 /// Create an edit request for all tracks by an artist.
510 ///
511 /// This constructor creates a [`ScrobbleEdit`] that will edit all tracks
512 /// by the specified artist, changing the artist name to the new value.
513 ///
514 /// # Arguments
515 ///
516 /// * `old_artist_name` - The current artist name to change from
517 /// * `new_artist_name` - The new artist name to change to
518 pub fn for_artist(old_artist_name: &str, new_artist_name: &str) -> Self {
519 Self::new(
520 None, // No specific track - edit all tracks
521 None, // No specific album - edit all albums
522 old_artist_name.to_string(),
523 None, // Client will look up original album artist name
524 None, // No track name change - keep original track names
525 None, // Keep original album names (they can vary)
526 new_artist_name.to_string(),
527 Some(new_artist_name.to_string()), // album_artist also changes for global renames
528 None, // Client will find representative timestamp
529 true, // Edit all instances by default for artist changes
530 )
531 }
532
533 /// Create an edit request for all tracks in a specific album.
534 ///
535 /// This constructor creates a [`ScrobbleEdit`] that will edit all tracks
536 /// in the specified album by the specified artist.
537 ///
538 /// # Arguments
539 ///
540 /// * `album_name` - The album name containing tracks to edit
541 /// * `artist_name` - The artist name for the album
542 /// * `new_artist_name` - The new artist name to change to
543 pub fn for_album(album_name: &str, old_artist_name: &str, new_artist_name: &str) -> Self {
544 Self::new(
545 None, // No specific track - edit all tracks in album
546 Some(album_name.to_string()),
547 old_artist_name.to_string(),
548 Some(old_artist_name.to_string()),
549 None, // No track name change - keep original track names
550 Some(album_name.to_string()), // Keep same album name
551 new_artist_name.to_string(),
552 None, // Keep original album_artist names (they can vary)
553 None, // Client will find representative timestamp
554 true, // Edit all instances by default for album changes
555 )
556 }
557}
558
559impl ExactScrobbleEdit {
560 /// Create a new [`ExactScrobbleEdit`] with all fields specified.
561 #[allow(clippy::too_many_arguments)]
562 pub fn new(
563 track_name_original: String,
564 album_name_original: String,
565 artist_name_original: String,
566 album_artist_name_original: String,
567 track_name: String,
568 album_name: String,
569 artist_name: String,
570 album_artist_name: String,
571 timestamp: u64,
572 edit_all: bool,
573 ) -> Self {
574 Self {
575 track_name_original,
576 album_name_original,
577 artist_name_original,
578 album_artist_name_original,
579 track_name,
580 album_name,
581 artist_name,
582 album_artist_name,
583 timestamp,
584 edit_all,
585 }
586 }
587
588 /// Build the form data for submitting this scrobble edit.
589 ///
590 /// This creates a HashMap containing all the form fields needed to submit
591 /// the edit request to Last.fm, including the CSRF token and all metadata fields.
592 pub fn build_form_data(&self, csrf_token: &str) -> HashMap<&str, String> {
593 let mut form_data = HashMap::new();
594
595 // Add fresh CSRF token (required)
596 form_data.insert("csrfmiddlewaretoken", csrf_token.to_string());
597
598 // Include ALL form fields (using ExactScrobbleEdit which has all required fields)
599 form_data.insert("track_name_original", self.track_name_original.clone());
600 form_data.insert("track_name", self.track_name.clone());
601 form_data.insert("artist_name_original", self.artist_name_original.clone());
602 form_data.insert("artist_name", self.artist_name.clone());
603 form_data.insert("album_name_original", self.album_name_original.clone());
604 form_data.insert("album_name", self.album_name.clone());
605 form_data.insert(
606 "album_artist_name_original",
607 self.album_artist_name_original.clone(),
608 );
609 form_data.insert("album_artist_name", self.album_artist_name.clone());
610
611 // Include timestamp (ExactScrobbleEdit always has a timestamp)
612 form_data.insert("timestamp", self.timestamp.to_string());
613
614 // Edit flags
615 if self.edit_all {
616 form_data.insert("edit_all", "1".to_string());
617 }
618 form_data.insert("submit", "edit-scrobble".to_string());
619 form_data.insert("ajax", "1".to_string());
620
621 form_data
622 }
623
624 /// Convert this exact edit back to a public ScrobbleEdit.
625 ///
626 /// This is useful when you need to expose the edit data through the public API.
627 pub fn to_scrobble_edit(&self) -> ScrobbleEdit {
628 ScrobbleEdit::new(
629 Some(self.track_name_original.clone()),
630 Some(self.album_name_original.clone()),
631 self.artist_name_original.clone(),
632 Some(self.album_artist_name_original.clone()),
633 Some(self.track_name.clone()),
634 Some(self.album_name.clone()),
635 self.artist_name.clone(),
636 Some(self.album_artist_name.clone()),
637 Some(self.timestamp),
638 self.edit_all,
639 )
640 }
641}
642
643impl EditResponse {
644 /// Create a new EditResponse from a single result.
645 pub fn single(
646 success: bool,
647 message: Option<String>,
648 album_info: Option<String>,
649 exact_scrobble_edit: ExactScrobbleEdit,
650 ) -> Self {
651 Self {
652 individual_results: vec![SingleEditResponse {
653 success,
654 message,
655 album_info,
656 exact_scrobble_edit,
657 }],
658 }
659 }
660
661 /// Create a new EditResponse from multiple results.
662 pub fn from_results(results: Vec<SingleEditResponse>) -> Self {
663 Self {
664 individual_results: results,
665 }
666 }
667
668 /// Check if all individual edit operations were successful.
669 pub fn all_successful(&self) -> bool {
670 !self.individual_results.is_empty() && self.individual_results.iter().all(|r| r.success)
671 }
672
673 /// Check if any individual edit operations were successful.
674 pub fn any_successful(&self) -> bool {
675 self.individual_results.iter().any(|r| r.success)
676 }
677
678 /// Get the total number of edit operations performed.
679 pub fn total_edits(&self) -> usize {
680 self.individual_results.len()
681 }
682
683 /// Get the number of successful edit operations.
684 pub fn successful_edits(&self) -> usize {
685 self.individual_results.iter().filter(|r| r.success).count()
686 }
687
688 /// Get the number of failed edit operations.
689 pub fn failed_edits(&self) -> usize {
690 self.individual_results
691 .iter()
692 .filter(|r| !r.success)
693 .count()
694 }
695
696 /// Generate a summary message describing the overall result.
697 pub fn summary_message(&self) -> String {
698 let total = self.total_edits();
699 let successful = self.successful_edits();
700 let failed = self.failed_edits();
701
702 if total == 0 {
703 return "No edit operations performed".to_string();
704 }
705
706 if successful == total {
707 if total == 1 {
708 "Edit completed successfully".to_string()
709 } else {
710 format!("All {total} edits completed successfully")
711 }
712 } else if successful == 0 {
713 if total == 1 {
714 "Edit failed".to_string()
715 } else {
716 format!("All {total} edits failed")
717 }
718 } else {
719 format!("{successful} of {total} edits succeeded, {failed} failed")
720 }
721 }
722
723 /// Get detailed messages from all edit operations.
724 pub fn detailed_messages(&self) -> Vec<String> {
725 self.individual_results
726 .iter()
727 .enumerate()
728 .map(|(i, result)| {
729 let album_info = result
730 .album_info
731 .as_deref()
732 .map(|info| format!(" ({info})"))
733 .unwrap_or_default();
734
735 match &result.message {
736 Some(msg) => format!("{}: {}{}", i + 1, msg, album_info),
737 None => {
738 if result.success {
739 format!("{}: Success{}", i + 1, album_info)
740 } else {
741 format!("{}: Failed{}", i + 1, album_info)
742 }
743 }
744 }
745 })
746 .collect()
747 }
748
749 /// Check if this response represents a single edit (for backward compatibility).
750 pub fn is_single_edit(&self) -> bool {
751 self.individual_results.len() == 1
752 }
753
754 /// Check if all edits succeeded (for backward compatibility).
755 pub fn success(&self) -> bool {
756 self.all_successful()
757 }
758
759 /// Get a single message for backward compatibility.
760 /// Returns the summary message.
761 pub fn message(&self) -> Option<String> {
762 Some(self.summary_message())
763 }
764}
765
766// ================================================================================================
767// ERROR TYPES
768// ================================================================================================
769
770/// Error types for Last.fm operations.
771///
772/// This enum covers all possible errors that can occur when interacting with Last.fm,
773/// including network issues, authentication failures, parsing errors, and rate limiting.
774#[derive(Error, Debug)]
775pub enum LastFmError {
776 /// HTTP/network related errors.
777 ///
778 /// This includes connection failures, timeouts, DNS errors, and other
779 /// low-level networking issues.
780 #[error("HTTP error: {0}")]
781 Http(String),
782
783 /// Authentication failures.
784 ///
785 /// This occurs when login credentials are invalid, sessions expire,
786 /// or authentication is required but not provided.
787 ///
788 /// # Common Causes
789 /// - Invalid username/password
790 /// - Expired session cookies
791 /// - Account locked or suspended
792 /// - Two-factor authentication required
793 #[error("Authentication failed: {0}")]
794 Auth(String),
795
796 /// CSRF token not found in response.
797 ///
798 /// This typically indicates that Last.fm's page structure has changed
799 /// or that the request was blocked.
800 #[error("CSRF token not found")]
801 CsrfNotFound,
802
803 /// Failed to parse Last.fm's response.
804 ///
805 /// This can happen when Last.fm changes their HTML structure or
806 /// returns unexpected data formats.
807 #[error("Failed to parse response: {0}")]
808 Parse(String),
809
810 /// Rate limiting from Last.fm.
811 ///
812 /// Last.fm has rate limits to prevent abuse. When hit, the client
813 /// should wait before making more requests.
814 ///
815 /// The `retry_after` field indicates how many seconds to wait before
816 /// the next request attempt.
817 #[error("Rate limited, retry after {retry_after} seconds")]
818 RateLimit {
819 /// Number of seconds to wait before retrying
820 retry_after: u64,
821 },
822
823 /// Scrobble edit operation failed.
824 ///
825 /// This is returned when an edit request is properly formatted and sent,
826 /// but Last.fm rejects it for business logic reasons.
827 #[error("Edit failed: {0}")]
828 EditFailed(String),
829
830 /// File system I/O errors.
831 ///
832 /// This can occur when saving debug responses or other file operations.
833 #[error("IO error: {0}")]
834 Io(#[from] std::io::Error),
835}
836
837// ================================================================================================
838// SESSION MANAGEMENT
839// ================================================================================================
840
841/// Serializable client session state that can be persisted and restored.
842///
843/// This contains all the authentication state needed to resume a Last.fm session
844/// without requiring the user to log in again.
845#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
846pub struct LastFmEditSession {
847 /// The authenticated username
848 pub username: String,
849 /// Session cookies required for authenticated requests
850 pub cookies: Vec<String>,
851 /// CSRF token for form submissions
852 pub csrf_token: Option<String>,
853 /// Base URL for the Last.fm instance
854 pub base_url: String,
855}
856
857impl LastFmEditSession {
858 /// Create a new client session with the provided state
859 pub fn new(
860 username: String,
861 session_cookies: Vec<String>,
862 csrf_token: Option<String>,
863 base_url: String,
864 ) -> Self {
865 Self {
866 username,
867 cookies: session_cookies,
868 csrf_token,
869 base_url,
870 }
871 }
872
873 /// Check if this session appears to be valid
874 ///
875 /// This performs basic validation but doesn't guarantee the session
876 /// is still active on the server.
877 pub fn is_valid(&self) -> bool {
878 !self.username.is_empty()
879 && !self.cookies.is_empty()
880 && self.csrf_token.is_some()
881 && self
882 .cookies
883 .iter()
884 .any(|cookie| cookie.starts_with("sessionid=") && cookie.len() > 50)
885 }
886
887 /// Serialize session to JSON string
888 pub fn to_json(&self) -> Result<String, serde_json::Error> {
889 serde_json::to_string(self)
890 }
891
892 /// Deserialize session from JSON string
893 pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
894 serde_json::from_str(json)
895 }
896}
897
898// ================================================================================================
899// CLIENT CONFIGURATION
900// ================================================================================================
901
902/// Configuration for rate limit detection behavior
903#[derive(Debug, Clone, PartialEq, Eq)]
904pub struct RateLimitConfig {
905 /// Whether to detect rate limits by HTTP status codes (429, 403)
906 pub detect_by_status: bool,
907 /// Whether to detect rate limits by response body patterns
908 pub detect_by_patterns: bool,
909 /// Patterns to look for in response bodies (used when detect_by_patterns is true)
910 pub patterns: Vec<String>,
911 /// Additional custom patterns to look for in response bodies
912 pub custom_patterns: Vec<String>,
913}
914
915impl Default for RateLimitConfig {
916 fn default() -> Self {
917 Self {
918 detect_by_status: true,
919 detect_by_patterns: true,
920 patterns: vec![
921 "you've tried to log in too many times".to_string(),
922 "you're requesting too many pages".to_string(),
923 "slow down".to_string(),
924 "too fast".to_string(),
925 "rate limit".to_string(),
926 "throttled".to_string(),
927 "temporarily blocked".to_string(),
928 "temporarily restricted".to_string(),
929 "captcha".to_string(),
930 "verify you're human".to_string(),
931 "prove you're not a robot".to_string(),
932 "security check".to_string(),
933 "service temporarily unavailable".to_string(),
934 "quota exceeded".to_string(),
935 "limit exceeded".to_string(),
936 "daily limit".to_string(),
937 ],
938 custom_patterns: vec![],
939 }
940 }
941}
942
943impl RateLimitConfig {
944 /// Create config with all detection disabled
945 pub fn disabled() -> Self {
946 Self {
947 detect_by_status: false,
948 detect_by_patterns: false,
949 patterns: vec![],
950 custom_patterns: vec![],
951 }
952 }
953
954 /// Create config with only status code detection
955 pub fn status_only() -> Self {
956 Self {
957 detect_by_status: true,
958 detect_by_patterns: false,
959 patterns: vec![],
960 custom_patterns: vec![],
961 }
962 }
963
964 /// Create config with only default pattern detection
965 pub fn patterns_only() -> Self {
966 Self {
967 detect_by_status: false,
968 detect_by_patterns: true,
969 ..Default::default()
970 }
971 }
972
973 /// Create config with custom patterns only (no default patterns)
974 pub fn custom_patterns_only(patterns: Vec<String>) -> Self {
975 Self {
976 detect_by_status: false,
977 detect_by_patterns: false,
978 patterns: vec![],
979 custom_patterns: patterns,
980 }
981 }
982
983 /// Create config with both default and custom patterns
984 pub fn with_custom_patterns(mut self, patterns: Vec<String>) -> Self {
985 self.custom_patterns = patterns;
986 self
987 }
988
989 /// Create config with custom patterns (replaces built-in patterns)
990 pub fn with_patterns(mut self, patterns: Vec<String>) -> Self {
991 self.patterns = patterns;
992 self
993 }
994}
995
996/// Unified configuration for retry behavior and rate limiting
997#[derive(Debug, Clone, PartialEq, Eq, Default)]
998pub struct ClientConfig {
999 /// Retry configuration
1000 pub retry: RetryConfig,
1001 /// Rate limit detection configuration
1002 pub rate_limit: RateLimitConfig,
1003}
1004
1005impl ClientConfig {
1006 /// Create a new config with default settings
1007 pub fn new() -> Self {
1008 Self::default()
1009 }
1010
1011 /// Create config with retries disabled
1012 pub fn with_retries_disabled() -> Self {
1013 Self {
1014 retry: RetryConfig::disabled(),
1015 rate_limit: RateLimitConfig::default(),
1016 }
1017 }
1018
1019 /// Create config with rate limit detection disabled
1020 pub fn with_rate_limiting_disabled() -> Self {
1021 Self {
1022 retry: RetryConfig::default(),
1023 rate_limit: RateLimitConfig::disabled(),
1024 }
1025 }
1026
1027 /// Create config with both retries and rate limiting disabled
1028 pub fn minimal() -> Self {
1029 Self {
1030 retry: RetryConfig::disabled(),
1031 rate_limit: RateLimitConfig::disabled(),
1032 }
1033 }
1034
1035 /// Set custom retry configuration
1036 pub fn with_retry_config(mut self, retry_config: RetryConfig) -> Self {
1037 self.retry = retry_config;
1038 self
1039 }
1040
1041 /// Set custom rate limit configuration
1042 pub fn with_rate_limit_config(mut self, rate_limit_config: RateLimitConfig) -> Self {
1043 self.rate_limit = rate_limit_config;
1044 self
1045 }
1046
1047 /// Set custom retry count
1048 pub fn with_max_retries(mut self, max_retries: u32) -> Self {
1049 self.retry.max_retries = max_retries;
1050 self.retry.enabled = max_retries > 0;
1051 self
1052 }
1053
1054 /// Set custom retry delays
1055 pub fn with_retry_delays(mut self, base_delay: u64, max_delay: u64) -> Self {
1056 self.retry.base_delay = base_delay;
1057 self.retry.max_delay = max_delay;
1058 self
1059 }
1060
1061 /// Add custom rate limit patterns
1062 pub fn with_custom_rate_limit_patterns(mut self, patterns: Vec<String>) -> Self {
1063 self.rate_limit.custom_patterns = patterns;
1064 self
1065 }
1066
1067 /// Enable/disable HTTP status code rate limit detection
1068 pub fn with_status_detection(mut self, enabled: bool) -> Self {
1069 self.rate_limit.detect_by_status = enabled;
1070 self
1071 }
1072
1073 /// Enable/disable response pattern rate limit detection
1074 pub fn with_pattern_detection(mut self, enabled: bool) -> Self {
1075 self.rate_limit.detect_by_patterns = enabled;
1076 self
1077 }
1078}
1079
1080/// Configuration for retry behavior
1081#[derive(Debug, Clone, PartialEq, Eq)]
1082pub struct RetryConfig {
1083 /// Maximum number of retry attempts (set to 0 to disable retries)
1084 pub max_retries: u32,
1085 /// Base delay for exponential backoff (in seconds)
1086 pub base_delay: u64,
1087 /// Maximum delay cap (in seconds)
1088 pub max_delay: u64,
1089 /// Whether retries are enabled at all
1090 pub enabled: bool,
1091}
1092
1093impl Default for RetryConfig {
1094 fn default() -> Self {
1095 Self {
1096 max_retries: 3,
1097 base_delay: 5,
1098 max_delay: 300, // 5 minutes
1099 enabled: true,
1100 }
1101 }
1102}
1103
1104impl RetryConfig {
1105 /// Create a config with retries disabled
1106 pub fn disabled() -> Self {
1107 Self {
1108 max_retries: 0,
1109 base_delay: 5,
1110 max_delay: 300,
1111 enabled: false,
1112 }
1113 }
1114
1115 /// Create a config with custom retry count
1116 pub fn with_retries(max_retries: u32) -> Self {
1117 Self {
1118 max_retries,
1119 enabled: max_retries > 0,
1120 ..Default::default()
1121 }
1122 }
1123
1124 /// Create a config with custom delays
1125 pub fn with_delays(base_delay: u64, max_delay: u64) -> Self {
1126 Self {
1127 base_delay,
1128 max_delay,
1129 ..Default::default()
1130 }
1131 }
1132}
1133
1134/// Result of a retry operation with context
1135#[derive(Debug)]
1136pub struct RetryResult<T> {
1137 /// The successful result
1138 pub result: T,
1139 /// Number of retry attempts made
1140 pub attempts_made: u32,
1141 /// Total time spent retrying (in seconds)
1142 pub total_retry_time: u64,
1143}
1144
1145// ================================================================================================
1146// EVENT SYSTEM
1147// ================================================================================================
1148
1149/// Request information for client events
1150#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
1151pub struct RequestInfo {
1152 /// The HTTP method (GET, POST, etc.)
1153 pub method: String,
1154 /// The full URI being requested
1155 pub uri: String,
1156 /// Query parameters as key-value pairs
1157 pub query_params: Vec<(String, String)>,
1158 /// Path without query parameters
1159 pub path: String,
1160}
1161
1162impl RequestInfo {
1163 /// Create RequestInfo from a URL string and method
1164 pub fn from_url_and_method(url: &str, method: &str) -> Self {
1165 // Parse URL manually to avoid adding dependencies
1166 let (path, query_params) = if let Some(query_start) = url.find('?') {
1167 let path = url[..query_start].to_string();
1168 let query_string = &url[query_start + 1..];
1169
1170 let query_params: Vec<(String, String)> = query_string
1171 .split('&')
1172 .filter_map(|pair| {
1173 if let Some(eq_pos) = pair.find('=') {
1174 let key = &pair[..eq_pos];
1175 let value = &pair[eq_pos + 1..];
1176 Some((key.to_string(), value.to_string()))
1177 } else if !pair.is_empty() {
1178 Some((pair.to_string(), String::new()))
1179 } else {
1180 None
1181 }
1182 })
1183 .collect();
1184
1185 (path, query_params)
1186 } else {
1187 (url.to_string(), Vec::new())
1188 };
1189
1190 // Extract just the path part if it's a full URL
1191 let path = if path.starts_with("http://") || path.starts_with("https://") {
1192 if let Some(third_slash) = path[8..].find('/') {
1193 path[8 + third_slash..].to_string()
1194 } else {
1195 "/".to_string()
1196 }
1197 } else {
1198 path
1199 };
1200
1201 Self {
1202 method: method.to_string(),
1203 uri: url.to_string(),
1204 query_params,
1205 path,
1206 }
1207 }
1208
1209 /// Get a short description of the request for logging
1210 pub fn short_description(&self) -> String {
1211 let mut desc = format!("{} {}", self.method, self.path);
1212 if !self.query_params.is_empty() {
1213 let params: Vec<String> = self
1214 .query_params
1215 .iter()
1216 .map(|(k, v)| format!("{k}={v}"))
1217 .collect();
1218 if params.len() <= 2 {
1219 desc.push_str(&format!("?{}", params.join("&")));
1220 } else {
1221 desc.push_str(&format!("?{}...", params[0]));
1222 }
1223 }
1224 desc
1225 }
1226}
1227
1228/// Type of rate limiting detected
1229#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
1230pub enum RateLimitType {
1231 /// HTTP 429 Too Many Requests
1232 Http429,
1233 /// HTTP 403 Forbidden (likely rate limiting)
1234 Http403,
1235 /// Rate limit patterns detected in response body
1236 ResponsePattern,
1237}
1238
1239/// Event type to describe internal HTTP client activity
1240#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1241pub enum ClientEvent {
1242 /// Request started
1243 RequestStarted {
1244 /// Request details
1245 request: RequestInfo,
1246 },
1247 /// Request completed successfully
1248 RequestCompleted {
1249 /// Request details
1250 request: RequestInfo,
1251 /// HTTP status code
1252 status_code: u16,
1253 /// Duration of the request in milliseconds
1254 duration_ms: u64,
1255 },
1256 /// Rate limiting detected with backoff duration in seconds
1257 RateLimited {
1258 /// Duration to wait in seconds
1259 delay_seconds: u64,
1260 /// Request that triggered the rate limit (if available)
1261 request: Option<RequestInfo>,
1262 /// Type of rate limiting detected
1263 rate_limit_type: RateLimitType,
1264 /// Timestamp when the rate limit was detected (seconds since Unix epoch)
1265 rate_limit_timestamp: u64,
1266 },
1267 /// Rate limiting period has ended and normal operation resumed
1268 RateLimitEnded {
1269 /// Request that successfully completed after rate limiting
1270 request: RequestInfo,
1271 /// Type of rate limiting that ended
1272 rate_limit_type: RateLimitType,
1273 /// Total duration the rate limiting was active in seconds
1274 total_rate_limit_duration_seconds: u64,
1275 },
1276 /// Scrobble edit attempt completed
1277 EditAttempted {
1278 /// The exact scrobble edit that was attempted
1279 edit: ExactScrobbleEdit,
1280 /// Whether the edit was successful
1281 success: bool,
1282 /// Optional error message if the edit failed
1283 error_message: Option<String>,
1284 /// Duration of the edit operation in milliseconds
1285 duration_ms: u64,
1286 },
1287}
1288
1289/// Type alias for the broadcast receiver
1290pub type ClientEventReceiver = broadcast::Receiver<ClientEvent>;
1291
1292/// Type alias for the watch receiver
1293pub type ClientEventWatcher = watch::Receiver<Option<ClientEvent>>;
1294
1295/// Shared event broadcasting state that persists across client clones
1296#[derive(Clone)]
1297pub struct SharedEventBroadcaster {
1298 event_tx: broadcast::Sender<ClientEvent>,
1299 last_event_tx: watch::Sender<Option<ClientEvent>>,
1300}
1301
1302impl SharedEventBroadcaster {
1303 /// Create a new shared event broadcaster
1304 pub fn new() -> Self {
1305 let (event_tx, _) = broadcast::channel(100);
1306 let (last_event_tx, _) = watch::channel(None);
1307
1308 Self {
1309 event_tx,
1310 last_event_tx,
1311 }
1312 }
1313
1314 /// Broadcast an event to all subscribers
1315 pub fn broadcast_event(&self, event: ClientEvent) {
1316 let _ = self.event_tx.send(event.clone());
1317 let _ = self.last_event_tx.send(Some(event));
1318 }
1319
1320 /// Subscribe to events
1321 pub fn subscribe(&self) -> ClientEventReceiver {
1322 self.event_tx.subscribe()
1323 }
1324
1325 /// Get the latest event
1326 pub fn latest_event(&self) -> Option<ClientEvent> {
1327 self.last_event_tx.borrow().clone()
1328 }
1329}
1330
1331impl Default for SharedEventBroadcaster {
1332 fn default() -> Self {
1333 Self::new()
1334 }
1335}
1336
1337impl std::fmt::Debug for SharedEventBroadcaster {
1338 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1339 f.debug_struct("SharedEventBroadcaster")
1340 .field("subscribers", &self.event_tx.receiver_count())
1341 .finish()
1342 }
1343}
1344
1345// ================================================================================================
1346// TESTS
1347// ================================================================================================
1348
1349#[cfg(test)]
1350mod tests {
1351 use super::*;
1352
1353 #[test]
1354 fn test_session_validity() {
1355 let valid_session = LastFmEditSession::new(
1356 "testuser".to_string(),
1357 vec!["sessionid=.eJy1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890".to_string()],
1358 Some("csrf_token_123".to_string()),
1359 "https://www.last.fm".to_string(),
1360 );
1361 assert!(valid_session.is_valid());
1362
1363 let invalid_session = LastFmEditSession::new(
1364 "".to_string(),
1365 vec![],
1366 None,
1367 "https://www.last.fm".to_string(),
1368 );
1369 assert!(!invalid_session.is_valid());
1370 }
1371
1372 #[test]
1373 fn test_session_serialization() {
1374 let session = LastFmEditSession::new(
1375 "testuser".to_string(),
1376 vec![
1377 "sessionid=.test123".to_string(),
1378 "csrftoken=abc".to_string(),
1379 ],
1380 Some("csrf_token_123".to_string()),
1381 "https://www.last.fm".to_string(),
1382 );
1383
1384 let json = session.to_json().unwrap();
1385 let restored_session = LastFmEditSession::from_json(&json).unwrap();
1386
1387 assert_eq!(session.username, restored_session.username);
1388 assert_eq!(session.cookies, restored_session.cookies);
1389 assert_eq!(session.csrf_token, restored_session.csrf_token);
1390 assert_eq!(session.base_url, restored_session.base_url);
1391 }
1392}