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