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