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}