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