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