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