use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt;
use thiserror::Error;
use tokio::sync::{broadcast, watch};
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct Track {
pub name: String,
pub artist: String,
pub playcount: u32,
pub timestamp: Option<u64>,
pub album: Option<String>,
pub album_artist: Option<String>,
}
impl fmt::Display for Track {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let album_part = if let Some(ref album) = self.album {
format!(" [{album}]")
} else {
String::new()
};
write!(f, "{} - {}{}", self.artist, self.name, album_part)
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct TrackPage {
pub tracks: Vec<Track>,
pub page_number: u32,
pub has_next_page: bool,
pub total_pages: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct Album {
pub name: String,
pub artist: String,
pub playcount: u32,
pub timestamp: Option<u64>,
}
impl fmt::Display for Album {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} - {}", self.artist, self.name)
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct AlbumPage {
pub albums: Vec<Album>,
pub page_number: u32,
pub has_next_page: bool,
pub total_pages: Option<u32>,
}
impl Album {
#[must_use]
pub fn scrobbled_at(&self) -> Option<DateTime<Utc>> {
self.timestamp
.and_then(|ts| DateTime::from_timestamp(i64::try_from(ts).ok()?, 0))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct Artist {
pub name: String,
pub playcount: u32,
pub timestamp: Option<u64>,
}
impl fmt::Display for Artist {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.name)
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ArtistPage {
pub artists: Vec<Artist>,
pub page_number: u32,
pub has_next_page: bool,
pub total_pages: Option<u32>,
}
impl Artist {
#[must_use]
pub fn scrobbled_at(&self) -> Option<DateTime<Utc>> {
self.timestamp
.and_then(|ts| DateTime::from_timestamp(i64::try_from(ts).ok()?, 0))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct ScrobbleEdit {
pub track_name_original: Option<String>,
pub album_name_original: Option<String>,
pub artist_name_original: String,
pub album_artist_name_original: Option<String>,
pub track_name: Option<String>,
pub album_name: Option<String>,
pub artist_name: String,
pub album_artist_name: Option<String>,
pub timestamp: Option<u64>,
pub edit_all: bool,
}
impl fmt::Display for ScrobbleEdit {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut changes = Vec::new();
if self.artist_name != self.artist_name_original {
changes.push(format!(
"Artist: {} → {}",
self.artist_name_original, self.artist_name
));
}
if let Some(ref new_track) = self.track_name {
if let Some(ref original_track) = self.track_name_original {
if new_track != original_track {
changes.push(format!("Track: {original_track} → {new_track}"));
}
} else {
changes.push(format!("Track: → {new_track}"));
}
}
if let Some(ref new_album) = self.album_name {
match &self.album_name_original {
Some(ref original_album) if new_album != original_album => {
changes.push(format!("Album: {original_album} → {new_album}"));
}
None => {
changes.push(format!("Album: → {new_album}"));
}
_ => {} }
}
if let Some(ref new_album_artist) = self.album_artist_name {
match &self.album_artist_name_original {
Some(ref original_album_artist) if new_album_artist != original_album_artist => {
changes.push(format!(
"Album Artist: {original_album_artist} → {new_album_artist}"
));
}
None => {
changes.push(format!("Album Artist: → {new_album_artist}"));
}
_ => {} }
}
if changes.is_empty() {
write!(f, "No changes")
} else {
let scope = if self.edit_all {
" (all instances)"
} else {
""
};
write!(f, "{}{}", changes.join(", "), scope)
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct SingleEditResponse {
pub success: bool,
pub message: Option<String>,
pub album_info: Option<String>,
pub exact_scrobble_edit: ExactScrobbleEdit,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct EditResponse {
pub individual_results: Vec<SingleEditResponse>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct ExactScrobbleEdit {
pub track_name_original: String,
pub album_name_original: String,
pub artist_name_original: String,
pub album_artist_name_original: String,
pub track_name: String,
pub album_name: String,
pub artist_name: String,
pub album_artist_name: String,
pub timestamp: u64,
pub edit_all: bool,
}
impl fmt::Display for ExactScrobbleEdit {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut changes = Vec::new();
if self.artist_name != self.artist_name_original {
changes.push(format!(
"Artist: {} → {}",
self.artist_name_original, self.artist_name
));
}
if self.track_name != self.track_name_original {
changes.push(format!(
"Track: {} → {}",
self.track_name_original, self.track_name
));
}
if self.album_name != self.album_name_original {
changes.push(format!(
"Album: {} → {}",
self.album_name_original, self.album_name
));
}
if self.album_artist_name != self.album_artist_name_original {
changes.push(format!(
"Album Artist: {} → {}",
self.album_artist_name_original, self.album_artist_name
));
}
if changes.is_empty() {
write!(f, "No changes")
} else {
let scope = if self.edit_all {
" (all instances)"
} else {
""
};
write!(f, "{}{}", changes.join(", "), scope)
}
}
}
impl ScrobbleEdit {
#[allow(clippy::too_many_arguments)]
pub fn new(
track_name_original: Option<String>,
album_name_original: Option<String>,
artist_name_original: String,
album_artist_name_original: Option<String>,
track_name: Option<String>,
album_name: Option<String>,
artist_name: String,
album_artist_name: Option<String>,
timestamp: Option<u64>,
edit_all: bool,
) -> Self {
Self {
track_name_original,
album_name_original,
artist_name_original,
album_artist_name_original,
track_name,
album_name,
artist_name,
album_artist_name,
timestamp,
edit_all,
}
}
pub fn from_track_info(
original_track: &str,
original_album: &str,
original_artist: &str,
timestamp: u64,
) -> Self {
Self::new(
Some(original_track.to_string()),
Some(original_album.to_string()),
original_artist.to_string(),
Some(original_artist.to_string()), Some(original_track.to_string()),
Some(original_album.to_string()),
original_artist.to_string(),
Some(original_artist.to_string()), Some(timestamp),
true, )
}
pub fn with_track_name(mut self, track_name: &str) -> Self {
self.track_name = Some(track_name.to_string());
self
}
pub fn with_album_name(mut self, album_name: &str) -> Self {
self.album_name = Some(album_name.to_string());
self
}
pub fn with_artist_name(mut self, artist_name: &str) -> Self {
self.artist_name = artist_name.to_string();
self.album_artist_name = Some(artist_name.to_string());
self
}
pub fn with_edit_all(mut self, edit_all: bool) -> Self {
self.edit_all = edit_all;
self
}
pub fn with_minimal_info(
track_name: &str,
artist_name: &str,
album_name: &str,
timestamp: u64,
) -> Self {
Self::new(
Some(track_name.to_string()),
Some(album_name.to_string()),
artist_name.to_string(),
Some(artist_name.to_string()),
Some(track_name.to_string()),
Some(album_name.to_string()),
artist_name.to_string(),
Some(artist_name.to_string()),
Some(timestamp),
true,
)
}
pub fn from_track_and_artist(track_name: &str, artist_name: &str) -> Self {
Self::new(
Some(track_name.to_string()),
None, artist_name.to_string(),
None, Some(track_name.to_string()),
None, artist_name.to_string(),
Some(artist_name.to_string()), None, true,
)
}
pub fn for_artist(old_artist_name: &str, new_artist_name: &str) -> Self {
Self::new(
None, None, old_artist_name.to_string(),
None, None, None, new_artist_name.to_string(),
Some(new_artist_name.to_string()), None, true, )
}
pub fn for_album(album_name: &str, old_artist_name: &str, new_artist_name: &str) -> Self {
Self::new(
None, Some(album_name.to_string()),
old_artist_name.to_string(),
Some(old_artist_name.to_string()),
None, Some(album_name.to_string()), new_artist_name.to_string(),
None, None, true, )
}
}
impl ExactScrobbleEdit {
#[allow(clippy::too_many_arguments)]
pub fn new(
track_name_original: String,
album_name_original: String,
artist_name_original: String,
album_artist_name_original: String,
track_name: String,
album_name: String,
artist_name: String,
album_artist_name: String,
timestamp: u64,
edit_all: bool,
) -> Self {
Self {
track_name_original,
album_name_original,
artist_name_original,
album_artist_name_original,
track_name,
album_name,
artist_name,
album_artist_name,
timestamp,
edit_all,
}
}
pub fn build_form_data(&self, csrf_token: &str) -> HashMap<&str, String> {
let mut form_data = HashMap::new();
form_data.insert("csrfmiddlewaretoken", csrf_token.to_string());
form_data.insert("track_name_original", self.track_name_original.clone());
form_data.insert("track_name", self.track_name.clone());
form_data.insert("artist_name_original", self.artist_name_original.clone());
form_data.insert("artist_name", self.artist_name.clone());
form_data.insert("album_name_original", self.album_name_original.clone());
form_data.insert("album_name", self.album_name.clone());
form_data.insert(
"album_artist_name_original",
self.album_artist_name_original.clone(),
);
form_data.insert("album_artist_name", self.album_artist_name.clone());
form_data.insert("timestamp", self.timestamp.to_string());
if self.edit_all {
form_data.insert("edit_all", "1".to_string());
}
form_data.insert("submit", "edit-scrobble".to_string());
form_data.insert("ajax", "1".to_string());
form_data
}
pub fn to_scrobble_edit(&self) -> ScrobbleEdit {
ScrobbleEdit::new(
Some(self.track_name_original.clone()),
Some(self.album_name_original.clone()),
self.artist_name_original.clone(),
Some(self.album_artist_name_original.clone()),
Some(self.track_name.clone()),
Some(self.album_name.clone()),
self.artist_name.clone(),
Some(self.album_artist_name.clone()),
Some(self.timestamp),
self.edit_all,
)
}
}
impl EditResponse {
pub fn single(
success: bool,
message: Option<String>,
album_info: Option<String>,
exact_scrobble_edit: ExactScrobbleEdit,
) -> Self {
Self {
individual_results: vec![SingleEditResponse {
success,
message,
album_info,
exact_scrobble_edit,
}],
}
}
pub fn from_results(results: Vec<SingleEditResponse>) -> Self {
Self {
individual_results: results,
}
}
pub fn all_successful(&self) -> bool {
!self.individual_results.is_empty() && self.individual_results.iter().all(|r| r.success)
}
pub fn any_successful(&self) -> bool {
self.individual_results.iter().any(|r| r.success)
}
pub fn total_edits(&self) -> usize {
self.individual_results.len()
}
pub fn successful_edits(&self) -> usize {
self.individual_results.iter().filter(|r| r.success).count()
}
pub fn failed_edits(&self) -> usize {
self.individual_results
.iter()
.filter(|r| !r.success)
.count()
}
pub fn summary_message(&self) -> String {
let total = self.total_edits();
let successful = self.successful_edits();
let failed = self.failed_edits();
if total == 0 {
return "No edit operations performed".to_string();
}
if successful == total {
if total == 1 {
"Edit completed successfully".to_string()
} else {
format!("All {total} edits completed successfully")
}
} else if successful == 0 {
if total == 1 {
"Edit failed".to_string()
} else {
format!("All {total} edits failed")
}
} else {
format!("{successful} of {total} edits succeeded, {failed} failed")
}
}
pub fn detailed_messages(&self) -> Vec<String> {
self.individual_results
.iter()
.enumerate()
.map(|(i, result)| {
let album_info = result
.album_info
.as_deref()
.map(|info| format!(" ({info})"))
.unwrap_or_default();
match &result.message {
Some(msg) => format!("{}: {}{}", i + 1, msg, album_info),
None => {
if result.success {
format!("{}: Success{}", i + 1, album_info)
} else {
format!("{}: Failed{}", i + 1, album_info)
}
}
}
})
.collect()
}
pub fn is_single_edit(&self) -> bool {
self.individual_results.len() == 1
}
pub fn success(&self) -> bool {
self.all_successful()
}
pub fn message(&self) -> Option<String> {
Some(self.summary_message())
}
}
#[derive(Error, Debug)]
pub enum LastFmError {
#[error("HTTP error: {0}")]
Http(String),
#[error("Authentication failed: {0}")]
Auth(String),
#[error("CSRF token not found")]
CsrfNotFound,
#[error("Failed to parse response: {0}")]
Parse(String),
#[error("Rate limited, retry after {retry_after} seconds")]
RateLimit {
retry_after: u64,
},
#[error("Edit failed: {0}")]
EditFailed(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LastFmEditSession {
pub username: String,
pub cookies: Vec<String>,
pub csrf_token: Option<String>,
pub base_url: String,
}
impl LastFmEditSession {
pub fn new(
username: String,
session_cookies: Vec<String>,
csrf_token: Option<String>,
base_url: String,
) -> Self {
Self {
username,
cookies: session_cookies,
csrf_token,
base_url,
}
}
pub fn is_valid(&self) -> bool {
!self.username.is_empty()
&& !self.cookies.is_empty()
&& self.csrf_token.is_some()
&& self
.cookies
.iter()
.any(|cookie| cookie.starts_with("sessionid=") && cookie.len() > 50)
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string(self)
}
pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(json)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RateLimitConfig {
pub detect_by_status: bool,
pub detect_by_patterns: bool,
pub patterns: Vec<String>,
pub custom_patterns: Vec<String>,
}
impl Default for RateLimitConfig {
fn default() -> Self {
Self {
detect_by_status: true,
detect_by_patterns: true,
patterns: vec![
"you've tried to log in too many times".to_string(),
"you're requesting too many pages".to_string(),
"slow down".to_string(),
"too fast".to_string(),
"rate limit".to_string(),
"throttled".to_string(),
"temporarily blocked".to_string(),
"temporarily restricted".to_string(),
"captcha".to_string(),
"verify you're human".to_string(),
"prove you're not a robot".to_string(),
"security check".to_string(),
"service temporarily unavailable".to_string(),
"quota exceeded".to_string(),
"limit exceeded".to_string(),
"daily limit".to_string(),
],
custom_patterns: vec![],
}
}
}
impl RateLimitConfig {
pub fn disabled() -> Self {
Self {
detect_by_status: false,
detect_by_patterns: false,
patterns: vec![],
custom_patterns: vec![],
}
}
pub fn status_only() -> Self {
Self {
detect_by_status: true,
detect_by_patterns: false,
patterns: vec![],
custom_patterns: vec![],
}
}
pub fn patterns_only() -> Self {
Self {
detect_by_status: false,
detect_by_patterns: true,
..Default::default()
}
}
pub fn custom_patterns_only(patterns: Vec<String>) -> Self {
Self {
detect_by_status: false,
detect_by_patterns: false,
patterns: vec![],
custom_patterns: patterns,
}
}
pub fn with_custom_patterns(mut self, patterns: Vec<String>) -> Self {
self.custom_patterns = patterns;
self
}
pub fn with_patterns(mut self, patterns: Vec<String>) -> Self {
self.patterns = patterns;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OperationalDelayConfig {
pub get_delay_ms: u64,
pub edit_delay_ms: u64,
pub delete_delay_ms: u64,
}
impl Default for OperationalDelayConfig {
fn default() -> Self {
Self {
get_delay_ms: 0,
edit_delay_ms: 1000, delete_delay_ms: 1000, }
}
}
impl OperationalDelayConfig {
pub fn no_delays() -> Self {
Self {
get_delay_ms: 0,
edit_delay_ms: 0,
delete_delay_ms: 0,
}
}
pub fn with_delays(edit_delay_ms: u64, delete_delay_ms: u64) -> Self {
Self {
get_delay_ms: 0,
edit_delay_ms,
delete_delay_ms,
}
}
pub fn with_get_delay_ms(mut self, get_delay_ms: u64) -> Self {
self.get_delay_ms = get_delay_ms;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ClientConfig {
pub retry: RetryConfig,
pub rate_limit: RateLimitConfig,
pub operational_delays: OperationalDelayConfig,
pub api_key: Option<String>,
}
impl ClientConfig {
pub fn new() -> Self {
Self::default()
}
pub fn with_retries_disabled() -> Self {
Self {
retry: RetryConfig::disabled(),
..Default::default()
}
}
pub fn with_rate_limiting_disabled() -> Self {
Self {
rate_limit: RateLimitConfig::disabled(),
..Default::default()
}
}
pub fn minimal() -> Self {
Self {
retry: RetryConfig::disabled(),
rate_limit: RateLimitConfig::disabled(),
..Default::default()
}
}
pub fn for_testing() -> Self {
Self {
retry: RetryConfig {
max_retries: 3,
base_delay: 0, max_delay: 0, enabled: true,
},
operational_delays: OperationalDelayConfig::no_delays(),
..Default::default()
}
}
pub fn with_retry_config(mut self, retry_config: RetryConfig) -> Self {
self.retry = retry_config;
self
}
pub fn with_rate_limit_config(mut self, rate_limit_config: RateLimitConfig) -> Self {
self.rate_limit = rate_limit_config;
self
}
pub fn with_operational_delays(mut self, operational_delays: OperationalDelayConfig) -> Self {
self.operational_delays = operational_delays;
self
}
pub fn with_max_retries(mut self, max_retries: u32) -> Self {
self.retry.max_retries = max_retries;
self.retry.enabled = max_retries > 0;
self
}
pub fn with_retry_delays(mut self, base_delay: u64, max_delay: u64) -> Self {
self.retry.base_delay = base_delay;
self.retry.max_delay = max_delay;
self
}
pub fn with_custom_rate_limit_patterns(mut self, patterns: Vec<String>) -> Self {
self.rate_limit.custom_patterns = patterns;
self
}
pub fn with_status_detection(mut self, enabled: bool) -> Self {
self.rate_limit.detect_by_status = enabled;
self
}
pub fn with_pattern_detection(mut self, enabled: bool) -> Self {
self.rate_limit.detect_by_patterns = enabled;
self
}
pub fn with_api_key(mut self, api_key: String) -> Self {
self.api_key = Some(api_key);
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RetryConfig {
pub max_retries: u32,
pub base_delay: u64,
pub max_delay: u64,
pub enabled: bool,
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
max_retries: 3,
base_delay: 5,
max_delay: 300, enabled: true,
}
}
}
impl RetryConfig {
pub fn disabled() -> Self {
Self {
max_retries: 0,
base_delay: 5,
max_delay: 300,
enabled: false,
}
}
pub fn with_retries(max_retries: u32) -> Self {
Self {
max_retries,
enabled: max_retries > 0,
..Default::default()
}
}
pub fn with_delays(base_delay: u64, max_delay: u64) -> Self {
Self {
base_delay,
max_delay,
..Default::default()
}
}
pub fn unbounded() -> Self {
Self {
max_retries: u32::MAX,
enabled: true,
..Default::default()
}
}
}
#[derive(Debug)]
pub struct RetryResult<T> {
pub result: T,
pub attempts_made: u32,
pub total_retry_time: u64,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct RequestInfo {
pub method: String,
pub uri: String,
pub query_params: Vec<(String, String)>,
pub path: String,
}
impl RequestInfo {
pub fn from_url_and_method(url: &str, method: &str) -> Self {
let (path, query_params) = if let Some(query_start) = url.find('?') {
let path = url[..query_start].to_string();
let query_string = &url[query_start + 1..];
let query_params: Vec<(String, String)> = query_string
.split('&')
.filter_map(|pair| {
if let Some(eq_pos) = pair.find('=') {
let key = &pair[..eq_pos];
let value = &pair[eq_pos + 1..];
Some((key.to_string(), value.to_string()))
} else if !pair.is_empty() {
Some((pair.to_string(), String::new()))
} else {
None
}
})
.collect();
(path, query_params)
} else {
(url.to_string(), Vec::new())
};
let path = if path.starts_with("http://") || path.starts_with("https://") {
if let Some(third_slash) = path[8..].find('/') {
path[8 + third_slash..].to_string()
} else {
"/".to_string()
}
} else {
path
};
Self {
method: method.to_string(),
uri: url.to_string(),
query_params,
path,
}
}
pub fn short_description(&self) -> String {
let mut desc = format!("{} {}", self.method, self.path);
if !self.query_params.is_empty() {
let params: Vec<String> = self
.query_params
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect();
if params.len() <= 2 {
desc.push_str(&format!("?{}", params.join("&")));
} else {
desc.push_str(&format!("?{}...", params[0]));
}
}
desc
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum RateLimitType {
Http429,
Http403,
ResponsePattern,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum DelayReason {
RetryBackoff,
OperationalEditDelay,
OperationalDeleteDelay,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum ClientEvent {
RequestStarted {
request: RequestInfo,
},
RequestCompleted {
request: RequestInfo,
status_code: u16,
duration_ms: u64,
},
RateLimited {
delay_seconds: u64,
request: Option<RequestInfo>,
rate_limit_type: RateLimitType,
rate_limit_timestamp: u64,
},
RateLimitEnded {
request: RequestInfo,
rate_limit_type: RateLimitType,
total_rate_limit_duration_seconds: u64,
},
Delaying {
delay_ms: u64,
reason: DelayReason,
request: Option<RequestInfo>,
delay_timestamp: u64,
},
EditAttempted {
edit: ExactScrobbleEdit,
success: bool,
error_message: Option<String>,
duration_ms: u64,
},
}
pub type ClientEventReceiver = broadcast::Receiver<ClientEvent>;
pub type ClientEventWatcher = watch::Receiver<Option<ClientEvent>>;
#[derive(Clone)]
pub struct SharedEventBroadcaster {
event_tx: broadcast::Sender<ClientEvent>,
last_event_tx: watch::Sender<Option<ClientEvent>>,
}
impl SharedEventBroadcaster {
pub fn new() -> Self {
let (event_tx, _) = broadcast::channel(100);
let (last_event_tx, _) = watch::channel(None);
Self {
event_tx,
last_event_tx,
}
}
pub fn broadcast_event(&self, event: ClientEvent) {
let _ = self.event_tx.send(event.clone());
let _ = self.last_event_tx.send(Some(event));
}
pub fn subscribe(&self) -> ClientEventReceiver {
self.event_tx.subscribe()
}
pub fn latest_event(&self) -> Option<ClientEvent> {
self.last_event_tx.borrow().clone()
}
}
impl Default for SharedEventBroadcaster {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Debug for SharedEventBroadcaster {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SharedEventBroadcaster")
.field("subscribers", &self.event_tx.receiver_count())
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_session_validity() {
let valid_session = LastFmEditSession::new(
"testuser".to_string(),
vec!["sessionid=.eJy1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890".to_string()],
Some("csrf_token_123".to_string()),
"https://www.last.fm".to_string(),
);
assert!(valid_session.is_valid());
let invalid_session = LastFmEditSession::new(
"".to_string(),
vec![],
None,
"https://www.last.fm".to_string(),
);
assert!(!invalid_session.is_valid());
}
#[test]
fn test_session_serialization() {
let session = LastFmEditSession::new(
"testuser".to_string(),
vec![
"sessionid=.test123".to_string(),
"csrftoken=abc".to_string(),
],
Some("csrf_token_123".to_string()),
"https://www.last.fm".to_string(),
);
let json = session.to_json().unwrap();
let restored_session = LastFmEditSession::from_json(&json).unwrap();
assert_eq!(session.username, restored_session.username);
assert_eq!(session.cookies, restored_session.cookies);
assert_eq!(session.csrf_token, restored_session.csrf_token);
assert_eq!(session.base_url, restored_session.base_url);
}
}