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