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            true, // edit_all defaults to true
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            true,
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            true,
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/// Configuration for operational delays between requests
1050#[derive(Debug, Clone, PartialEq, Eq)]
1051pub struct OperationalDelayConfig {
1052    /// Delay between multiple edit operations (in milliseconds)
1053    pub edit_delay_ms: u64,
1054    /// Delay between delete operations (in milliseconds)
1055    pub delete_delay_ms: u64,
1056}
1057
1058impl Default for OperationalDelayConfig {
1059    fn default() -> Self {
1060        Self {
1061            edit_delay_ms: 1000,   // 1 second
1062            delete_delay_ms: 1000, // 1 second
1063        }
1064    }
1065}
1066
1067impl OperationalDelayConfig {
1068    /// Create config with no delays (useful for testing)
1069    pub fn no_delays() -> Self {
1070        Self {
1071            edit_delay_ms: 0,
1072            delete_delay_ms: 0,
1073        }
1074    }
1075
1076    /// Create config with custom delays
1077    pub fn with_delays(edit_delay_ms: u64, delete_delay_ms: u64) -> Self {
1078        Self {
1079            edit_delay_ms,
1080            delete_delay_ms,
1081        }
1082    }
1083}
1084
1085/// Unified configuration for retry behavior and rate limiting
1086#[derive(Debug, Clone, PartialEq, Eq, Default)]
1087pub struct ClientConfig {
1088    /// Retry configuration
1089    pub retry: RetryConfig,
1090    /// Rate limit detection configuration
1091    pub rate_limit: RateLimitConfig,
1092    /// Operational delay configuration
1093    pub operational_delays: OperationalDelayConfig,
1094}
1095
1096impl ClientConfig {
1097    /// Create a new config with default settings
1098    pub fn new() -> Self {
1099        Self::default()
1100    }
1101
1102    /// Create config with retries disabled
1103    pub fn with_retries_disabled() -> Self {
1104        Self {
1105            retry: RetryConfig::disabled(),
1106            rate_limit: RateLimitConfig::default(),
1107            operational_delays: OperationalDelayConfig::default(),
1108        }
1109    }
1110
1111    /// Create config with rate limit detection disabled
1112    pub fn with_rate_limiting_disabled() -> Self {
1113        Self {
1114            retry: RetryConfig::default(),
1115            rate_limit: RateLimitConfig::disabled(),
1116            operational_delays: OperationalDelayConfig::default(),
1117        }
1118    }
1119
1120    /// Create config with both retries and rate limiting disabled
1121    pub fn minimal() -> Self {
1122        Self {
1123            retry: RetryConfig::disabled(),
1124            rate_limit: RateLimitConfig::disabled(),
1125            operational_delays: OperationalDelayConfig::default(),
1126        }
1127    }
1128
1129    /// Create config optimized for testing (rate limit detection enabled, retries enabled but no delays)
1130    pub fn for_testing() -> Self {
1131        Self {
1132            retry: RetryConfig {
1133                max_retries: 3,
1134                base_delay: 0, // No delay for fast tests
1135                max_delay: 0,  // No delay for fast tests
1136                enabled: true,
1137            },
1138            rate_limit: RateLimitConfig::default(), // Keep detection enabled
1139            operational_delays: OperationalDelayConfig::no_delays(),
1140        }
1141    }
1142
1143    /// Set custom retry configuration
1144    pub fn with_retry_config(mut self, retry_config: RetryConfig) -> Self {
1145        self.retry = retry_config;
1146        self
1147    }
1148
1149    /// Set custom rate limit configuration
1150    pub fn with_rate_limit_config(mut self, rate_limit_config: RateLimitConfig) -> Self {
1151        self.rate_limit = rate_limit_config;
1152        self
1153    }
1154
1155    /// Set custom operational delay configuration
1156    pub fn with_operational_delays(mut self, operational_delays: OperationalDelayConfig) -> Self {
1157        self.operational_delays = operational_delays;
1158        self
1159    }
1160
1161    /// Set custom retry count
1162    pub fn with_max_retries(mut self, max_retries: u32) -> Self {
1163        self.retry.max_retries = max_retries;
1164        self.retry.enabled = max_retries > 0;
1165        self
1166    }
1167
1168    /// Set custom retry delays
1169    pub fn with_retry_delays(mut self, base_delay: u64, max_delay: u64) -> Self {
1170        self.retry.base_delay = base_delay;
1171        self.retry.max_delay = max_delay;
1172        self
1173    }
1174
1175    /// Add custom rate limit patterns
1176    pub fn with_custom_rate_limit_patterns(mut self, patterns: Vec<String>) -> Self {
1177        self.rate_limit.custom_patterns = patterns;
1178        self
1179    }
1180
1181    /// Enable/disable HTTP status code rate limit detection
1182    pub fn with_status_detection(mut self, enabled: bool) -> Self {
1183        self.rate_limit.detect_by_status = enabled;
1184        self
1185    }
1186
1187    /// Enable/disable response pattern rate limit detection
1188    pub fn with_pattern_detection(mut self, enabled: bool) -> Self {
1189        self.rate_limit.detect_by_patterns = enabled;
1190        self
1191    }
1192}
1193
1194/// Configuration for retry behavior
1195#[derive(Debug, Clone, PartialEq, Eq)]
1196pub struct RetryConfig {
1197    /// Maximum number of retry attempts (set to 0 to disable retries)
1198    pub max_retries: u32,
1199    /// Base delay for exponential backoff (in seconds)
1200    pub base_delay: u64,
1201    /// Maximum delay cap (in seconds)
1202    pub max_delay: u64,
1203    /// Whether retries are enabled at all
1204    pub enabled: bool,
1205}
1206
1207impl Default for RetryConfig {
1208    fn default() -> Self {
1209        Self {
1210            max_retries: 3,
1211            base_delay: 5,
1212            max_delay: 300, // 5 minutes
1213            enabled: true,
1214        }
1215    }
1216}
1217
1218impl RetryConfig {
1219    /// Create a config with retries disabled
1220    pub fn disabled() -> Self {
1221        Self {
1222            max_retries: 0,
1223            base_delay: 5,
1224            max_delay: 300,
1225            enabled: false,
1226        }
1227    }
1228
1229    /// Create a config with custom retry count
1230    pub fn with_retries(max_retries: u32) -> Self {
1231        Self {
1232            max_retries,
1233            enabled: max_retries > 0,
1234            ..Default::default()
1235        }
1236    }
1237
1238    /// Create a config with custom delays
1239    pub fn with_delays(base_delay: u64, max_delay: u64) -> Self {
1240        Self {
1241            base_delay,
1242            max_delay,
1243            ..Default::default()
1244        }
1245    }
1246}
1247
1248/// Result of a retry operation with context
1249#[derive(Debug)]
1250pub struct RetryResult<T> {
1251    /// The successful result
1252    pub result: T,
1253    /// Number of retry attempts made
1254    pub attempts_made: u32,
1255    /// Total time spent retrying (in seconds)
1256    pub total_retry_time: u64,
1257}
1258
1259// ================================================================================================
1260// EVENT SYSTEM
1261// ================================================================================================
1262
1263/// Request information for client events
1264#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
1265pub struct RequestInfo {
1266    /// The HTTP method (GET, POST, etc.)
1267    pub method: String,
1268    /// The full URI being requested
1269    pub uri: String,
1270    /// Query parameters as key-value pairs
1271    pub query_params: Vec<(String, String)>,
1272    /// Path without query parameters
1273    pub path: String,
1274}
1275
1276impl RequestInfo {
1277    /// Create RequestInfo from a URL string and method
1278    pub fn from_url_and_method(url: &str, method: &str) -> Self {
1279        // Parse URL manually to avoid adding dependencies
1280        let (path, query_params) = if let Some(query_start) = url.find('?') {
1281            let path = url[..query_start].to_string();
1282            let query_string = &url[query_start + 1..];
1283
1284            let query_params: Vec<(String, String)> = query_string
1285                .split('&')
1286                .filter_map(|pair| {
1287                    if let Some(eq_pos) = pair.find('=') {
1288                        let key = &pair[..eq_pos];
1289                        let value = &pair[eq_pos + 1..];
1290                        Some((key.to_string(), value.to_string()))
1291                    } else if !pair.is_empty() {
1292                        Some((pair.to_string(), String::new()))
1293                    } else {
1294                        None
1295                    }
1296                })
1297                .collect();
1298
1299            (path, query_params)
1300        } else {
1301            (url.to_string(), Vec::new())
1302        };
1303
1304        // Extract just the path part if it's a full URL
1305        let path = if path.starts_with("http://") || path.starts_with("https://") {
1306            if let Some(third_slash) = path[8..].find('/') {
1307                path[8 + third_slash..].to_string()
1308            } else {
1309                "/".to_string()
1310            }
1311        } else {
1312            path
1313        };
1314
1315        Self {
1316            method: method.to_string(),
1317            uri: url.to_string(),
1318            query_params,
1319            path,
1320        }
1321    }
1322
1323    /// Get a short description of the request for logging
1324    pub fn short_description(&self) -> String {
1325        let mut desc = format!("{} {}", self.method, self.path);
1326        if !self.query_params.is_empty() {
1327            let params: Vec<String> = self
1328                .query_params
1329                .iter()
1330                .map(|(k, v)| format!("{k}={v}"))
1331                .collect();
1332            if params.len() <= 2 {
1333                desc.push_str(&format!("?{}", params.join("&")));
1334            } else {
1335                desc.push_str(&format!("?{}...", params[0]));
1336            }
1337        }
1338        desc
1339    }
1340}
1341
1342/// Type of rate limiting detected
1343#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
1344pub enum RateLimitType {
1345    /// HTTP 429 Too Many Requests
1346    Http429,
1347    /// HTTP 403 Forbidden (likely rate limiting)
1348    Http403,
1349    /// Rate limit patterns detected in response body
1350    ResponsePattern,
1351}
1352
1353/// Event type to describe internal HTTP client activity
1354#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1355pub enum ClientEvent {
1356    /// Request started
1357    RequestStarted {
1358        /// Request details
1359        request: RequestInfo,
1360    },
1361    /// Request completed successfully
1362    RequestCompleted {
1363        /// Request details
1364        request: RequestInfo,
1365        /// HTTP status code
1366        status_code: u16,
1367        /// Duration of the request in milliseconds
1368        duration_ms: u64,
1369    },
1370    /// Rate limiting detected with backoff duration in seconds
1371    RateLimited {
1372        /// Duration to wait in seconds
1373        delay_seconds: u64,
1374        /// Request that triggered the rate limit (if available)
1375        request: Option<RequestInfo>,
1376        /// Type of rate limiting detected
1377        rate_limit_type: RateLimitType,
1378        /// Timestamp when the rate limit was detected (seconds since Unix epoch)
1379        rate_limit_timestamp: u64,
1380    },
1381    /// Rate limiting period has ended and normal operation resumed
1382    RateLimitEnded {
1383        /// Request that successfully completed after rate limiting
1384        request: RequestInfo,
1385        /// Type of rate limiting that ended
1386        rate_limit_type: RateLimitType,
1387        /// Total duration the rate limiting was active in seconds
1388        total_rate_limit_duration_seconds: u64,
1389    },
1390    /// Scrobble edit attempt completed
1391    EditAttempted {
1392        /// The exact scrobble edit that was attempted
1393        edit: ExactScrobbleEdit,
1394        /// Whether the edit was successful
1395        success: bool,
1396        /// Optional error message if the edit failed
1397        error_message: Option<String>,
1398        /// Duration of the edit operation in milliseconds
1399        duration_ms: u64,
1400    },
1401}
1402
1403/// Type alias for the broadcast receiver
1404pub type ClientEventReceiver = broadcast::Receiver<ClientEvent>;
1405
1406/// Type alias for the watch receiver
1407pub type ClientEventWatcher = watch::Receiver<Option<ClientEvent>>;
1408
1409/// Shared event broadcasting state that persists across client clones
1410#[derive(Clone)]
1411pub struct SharedEventBroadcaster {
1412    event_tx: broadcast::Sender<ClientEvent>,
1413    last_event_tx: watch::Sender<Option<ClientEvent>>,
1414}
1415
1416impl SharedEventBroadcaster {
1417    /// Create a new shared event broadcaster
1418    pub fn new() -> Self {
1419        let (event_tx, _) = broadcast::channel(100);
1420        let (last_event_tx, _) = watch::channel(None);
1421
1422        Self {
1423            event_tx,
1424            last_event_tx,
1425        }
1426    }
1427
1428    /// Broadcast an event to all subscribers
1429    pub fn broadcast_event(&self, event: ClientEvent) {
1430        let _ = self.event_tx.send(event.clone());
1431        let _ = self.last_event_tx.send(Some(event));
1432    }
1433
1434    /// Subscribe to events
1435    pub fn subscribe(&self) -> ClientEventReceiver {
1436        self.event_tx.subscribe()
1437    }
1438
1439    /// Get the latest event
1440    pub fn latest_event(&self) -> Option<ClientEvent> {
1441        self.last_event_tx.borrow().clone()
1442    }
1443}
1444
1445impl Default for SharedEventBroadcaster {
1446    fn default() -> Self {
1447        Self::new()
1448    }
1449}
1450
1451impl std::fmt::Debug for SharedEventBroadcaster {
1452    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1453        f.debug_struct("SharedEventBroadcaster")
1454            .field("subscribers", &self.event_tx.receiver_count())
1455            .finish()
1456    }
1457}
1458
1459// ================================================================================================
1460// TESTS
1461// ================================================================================================
1462
1463#[cfg(test)]
1464mod tests {
1465    use super::*;
1466
1467    #[test]
1468    fn test_session_validity() {
1469        let valid_session = LastFmEditSession::new(
1470            "testuser".to_string(),
1471            vec!["sessionid=.eJy1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890".to_string()],
1472            Some("csrf_token_123".to_string()),
1473            "https://www.last.fm".to_string(),
1474        );
1475        assert!(valid_session.is_valid());
1476
1477        let invalid_session = LastFmEditSession::new(
1478            "".to_string(),
1479            vec![],
1480            None,
1481            "https://www.last.fm".to_string(),
1482        );
1483        assert!(!invalid_session.is_valid());
1484    }
1485
1486    #[test]
1487    fn test_session_serialization() {
1488        let session = LastFmEditSession::new(
1489            "testuser".to_string(),
1490            vec![
1491                "sessionid=.test123".to_string(),
1492                "csrftoken=abc".to_string(),
1493            ],
1494            Some("csrf_token_123".to_string()),
1495            "https://www.last.fm".to_string(),
1496        );
1497
1498        let json = session.to_json().unwrap();
1499        let restored_session = LastFmEditSession::from_json(&json).unwrap();
1500
1501        assert_eq!(session.username, restored_session.username);
1502        assert_eq!(session.cookies, restored_session.cookies);
1503        assert_eq!(session.csrf_token, restored_session.csrf_token);
1504        assert_eq!(session.base_url, restored_session.base_url);
1505    }
1506}