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}