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