diff options
| author | salmonik <raisersoft@proton.me> | 2026-05-16 11:43:32 +0200 |
|---|---|---|
| committer | salmonik <raisersoft@proton.me> | 2026-05-16 11:43:32 +0200 |
| commit | 04add15691578316ff3da5b5a07d05b314488370 (patch) | |
| tree | 458b1066001697faa75466d51c7dfd9008ccac16 /src | |
| parent | aesthetic changes (diff) | |
| download | rsplayer-04add15691578316ff3da5b5a07d05b314488370.tar.gz rsplayer-04add15691578316ff3da5b5a07d05b314488370.zip | |
Add scrobbling support (Last.fm + ListenBrainz) and rsp settings reload
- Full Last.fm API implementation with MD5 signing (track.updateNowPlaying, track.scrobble)
- Full ListenBrainz API implementation (/1/submit-listens for playing_now and single)
- ScrobbleState in daemon tracking track, start time, and unix timestamp per play
- Scrobble threshold: ≥30s play AND (≥half duration OR ≥4 min)
- Duration extraction from symphonia codec params into AudioMetadata and Track
- Token validation at daemon startup and on config change
- ListenBrainz token valid (user: Salmonium) command to re-read config from disk (avoids token in shell history)
- Desktop notifications for token validation results
- Empty-credential filtering (empty string = no-op)
Diffstat (limited to 'src')
| -rw-r--r-- | src/cli.rs | 8 | ||||
| -rw-r--r-- | src/daemon.rs | 325 | ||||
| -rw-r--r-- | src/queue.rs | 20 | ||||
| -rw-r--r-- | src/scrobbler.rs | 301 |
4 files changed, 617 insertions, 37 deletions
| @@ -105,6 +105,8 @@ pub enum SettingsCommands { | |||
| 105 | #[arg(value_name = "KEY")] | 105 | #[arg(value_name = "KEY")] |
| 106 | key: String, | 106 | key: String, |
| 107 | }, | 107 | }, |
| 108 | /// Reload config from disk (re-reads config file, re-validates scrobbling tokens) | ||
| 109 | Reload, | ||
| 108 | } | 110 | } |
| 109 | 111 | ||
| 110 | impl Cli { | 112 | impl Cli { |
| @@ -199,6 +201,9 @@ impl Cli { | |||
| 199 | SettingsCommands::Set { key, value } => { | 201 | SettingsCommands::Set { key, value } => { |
| 200 | config.set(key, value)?; | 202 | config.set(key, value)?; |
| 201 | config.save()?; | 203 | config.save()?; |
| 204 | if key == "listenbrainz_token" { | ||
| 205 | let _ = crate::daemon::send_command("validate".to_string()); | ||
| 206 | } | ||
| 202 | } | 207 | } |
| 203 | SettingsCommands::List => { | 208 | SettingsCommands::List => { |
| 204 | config.list(); | 209 | config.list(); |
| @@ -207,6 +212,9 @@ impl Cli { | |||
| 207 | config.unset(key)?; | 212 | config.unset(key)?; |
| 208 | config.save()?; | 213 | config.save()?; |
| 209 | } | 214 | } |
| 215 | SettingsCommands::Reload => { | ||
| 216 | let _ = crate::daemon::send_command("reload".to_string()); | ||
| 217 | } | ||
| 210 | } | 218 | } |
| 211 | } | 219 | } |
| 212 | Commands::Completions { shell } => { | 220 | Commands::Completions { shell } => { |
diff --git a/src/daemon.rs b/src/daemon.rs index 2b38025..06b925e 100644 --- a/src/daemon.rs +++ b/src/daemon.rs | |||
| @@ -2,13 +2,192 @@ use crate::audio::AudioEngine; | |||
| 2 | use crate::config::Config; | 2 | use crate::config::Config; |
| 3 | use crate::notification; | 3 | use crate::notification; |
| 4 | use crate::queue::{Queue, ShuffleMode, Track}; | 4 | use crate::queue::{Queue, ShuffleMode, Track}; |
| 5 | use crate::scrobbler::Scrobbler; | ||
| 5 | use std::io::{Read, Write}; | 6 | use std::io::{Read, Write}; |
| 6 | use std::os::unix::net::{UnixListener, UnixStream}; | 7 | use std::os::unix::net::{UnixListener, UnixStream}; |
| 7 | use std::path::{Path, PathBuf}; | 8 | use std::path::{Path, PathBuf}; |
| 8 | use std::sync::atomic::{AtomicBool, Ordering}; | 9 | use std::sync::atomic::{AtomicBool, Ordering}; |
| 9 | use std::sync::{Arc, Mutex}; | 10 | use std::sync::{Arc, Mutex, RwLock}; |
| 10 | use std::thread; | 11 | use std::thread; |
| 11 | use std::time::Duration; | 12 | use std::time::{Duration, Instant, SystemTime}; |
| 13 | |||
| 14 | struct ScrobbleState { | ||
| 15 | track: Option<Track>, | ||
| 16 | start_time: Option<Instant>, | ||
| 17 | timestamp: Option<u64>, | ||
| 18 | } | ||
| 19 | |||
| 20 | impl ScrobbleState { | ||
| 21 | fn new() -> Self { | ||
| 22 | ScrobbleState { | ||
| 23 | track: None, | ||
| 24 | start_time: None, | ||
| 25 | timestamp: None, | ||
| 26 | } | ||
| 27 | } | ||
| 28 | |||
| 29 | fn start(&mut self, track: Track) { | ||
| 30 | let ts = SystemTime::now() | ||
| 31 | .duration_since(SystemTime::UNIX_EPOCH) | ||
| 32 | .unwrap_or_default() | ||
| 33 | .as_secs(); | ||
| 34 | let title = track.title.clone().unwrap_or_default(); | ||
| 35 | let artist = track.artist.clone().unwrap_or_default(); | ||
| 36 | eprintln!( | ||
| 37 | "scrobble: tracking start for '{} - {}' (ts={})", | ||
| 38 | artist, title, ts | ||
| 39 | ); | ||
| 40 | self.track = Some(track); | ||
| 41 | self.start_time = Some(Instant::now()); | ||
| 42 | self.timestamp = Some(ts); | ||
| 43 | } | ||
| 44 | |||
| 45 | fn should_scrobble(&self) -> bool { | ||
| 46 | let (Some(track), Some(start)) = (&self.track, self.start_time) else { | ||
| 47 | eprintln!("scrobble: should_scrobble false — no track or start_time"); | ||
| 48 | return false; | ||
| 49 | }; | ||
| 50 | let elapsed = start.elapsed().as_secs(); | ||
| 51 | let result = Self::should_scrobble_with_params(elapsed, track.duration_secs); | ||
| 52 | let threshold = track | ||
| 53 | .duration_secs | ||
| 54 | .map(|d| (d / 2.0) as u64) | ||
| 55 | .unwrap_or(u64::MAX) | ||
| 56 | .min(240); | ||
| 57 | eprintln!( | ||
| 58 | "scrobble: should_scrobble {} (elapsed={}s threshold={}s duration={:?})", | ||
| 59 | result, elapsed, threshold, track.duration_secs | ||
| 60 | ); | ||
| 61 | result | ||
| 62 | } | ||
| 63 | |||
| 64 | fn should_scrobble_with_params(elapsed: u64, duration_secs: Option<f64>) -> bool { | ||
| 65 | if elapsed < 30 { | ||
| 66 | return false; | ||
| 67 | } | ||
| 68 | let threshold = duration_secs | ||
| 69 | .map(|d| (d / 2.0) as u64) | ||
| 70 | .unwrap_or(u64::MAX) | ||
| 71 | .min(240); | ||
| 72 | elapsed >= threshold | ||
| 73 | } | ||
| 74 | |||
| 75 | fn clear(&mut self) { | ||
| 76 | self.track = None; | ||
| 77 | self.start_time = None; | ||
| 78 | self.timestamp = None; | ||
| 79 | } | ||
| 80 | } | ||
| 81 | |||
| 82 | #[cfg(test)] | ||
| 83 | mod tests { | ||
| 84 | use super::*; | ||
| 85 | |||
| 86 | #[test] | ||
| 87 | fn test_scrobble_state_new() { | ||
| 88 | let s = ScrobbleState::new(); | ||
| 89 | assert!(s.track.is_none()); | ||
| 90 | assert!(s.start_time.is_none()); | ||
| 91 | assert!(s.timestamp.is_none()); | ||
| 92 | } | ||
| 93 | |||
| 94 | #[test] | ||
| 95 | fn test_scrobble_state_clear() { | ||
| 96 | let mut s = ScrobbleState::new(); | ||
| 97 | let t = Track { | ||
| 98 | path: PathBuf::from("/test/song.mp3"), | ||
| 99 | title: Some("Test".into()), | ||
| 100 | artist: Some("Artist".into()), | ||
| 101 | album: None, | ||
| 102 | duration_secs: Some(200.0), | ||
| 103 | }; | ||
| 104 | s.start(t); | ||
| 105 | assert!(s.track.is_some()); | ||
| 106 | s.clear(); | ||
| 107 | assert!(s.track.is_none()); | ||
| 108 | assert!(s.start_time.is_none()); | ||
| 109 | assert!(s.timestamp.is_none()); | ||
| 110 | } | ||
| 111 | |||
| 112 | #[test] | ||
| 113 | fn test_should_scrobble_without_track_returns_false() { | ||
| 114 | assert!(!ScrobbleState::new().should_scrobble()); | ||
| 115 | } | ||
| 116 | |||
| 117 | #[test] | ||
| 118 | fn test_should_scrobble_threshold_below_30_seconds() { | ||
| 119 | assert!(!ScrobbleState::should_scrobble_with_params(29, Some(200.0))); | ||
| 120 | } | ||
| 121 | |||
| 122 | #[test] | ||
| 123 | fn test_should_scrobble_threshold_half_duration() { | ||
| 124 | assert!(ScrobbleState::should_scrobble_with_params(100, Some(200.0))); | ||
| 125 | } | ||
| 126 | |||
| 127 | #[test] | ||
| 128 | fn test_should_scrobble_threshold_below_half_duration() { | ||
| 129 | assert!(!ScrobbleState::should_scrobble_with_params(80, Some(200.0))); | ||
| 130 | } | ||
| 131 | |||
| 132 | #[test] | ||
| 133 | fn test_should_scrobble_threshold_capped_at_4min() { | ||
| 134 | assert!(ScrobbleState::should_scrobble_with_params(240, Some(600.0))); | ||
| 135 | assert!(!ScrobbleState::should_scrobble_with_params( | ||
| 136 | 239, | ||
| 137 | Some(600.0) | ||
| 138 | )); | ||
| 139 | } | ||
| 140 | |||
| 141 | #[test] | ||
| 142 | fn test_should_scrobble_no_duration_requires_4min() { | ||
| 143 | assert!(ScrobbleState::should_scrobble_with_params(240, None)); | ||
| 144 | assert!(!ScrobbleState::should_scrobble_with_params(239, None)); | ||
| 145 | } | ||
| 146 | |||
| 147 | #[test] | ||
| 148 | fn test_should_scrobble_short_song() { | ||
| 149 | assert!(ScrobbleState::should_scrobble_with_params(30, Some(60.0))); | ||
| 150 | assert!(!ScrobbleState::should_scrobble_with_params(15, Some(60.0))); | ||
| 151 | } | ||
| 152 | } | ||
| 153 | |||
| 154 | fn try_scrobble_current(scrobbler: &RwLock<Scrobbler>, state: &mut ScrobbleState) { | ||
| 155 | if state.should_scrobble() | ||
| 156 | && let (Some(track), Some(ts)) = (&state.track, state.timestamp) | ||
| 157 | { | ||
| 158 | let artist = track.artist.as_deref().unwrap_or("(unknown artist)"); | ||
| 159 | let title = track.title.as_deref().unwrap_or("(unknown title)"); | ||
| 160 | let album = track.album.as_deref(); | ||
| 161 | eprintln!( | ||
| 162 | "scrobble: sending scrobble for '{} - {}' at ts={}", | ||
| 163 | artist, title, ts | ||
| 164 | ); | ||
| 165 | if let Ok(s) = scrobbler.read() { | ||
| 166 | s.scrobble(artist, title, album, ts); | ||
| 167 | } | ||
| 168 | } else { | ||
| 169 | let title = state | ||
| 170 | .track | ||
| 171 | .as_ref() | ||
| 172 | .and_then(|t| t.title.as_deref()) | ||
| 173 | .unwrap_or("(no track)"); | ||
| 174 | eprintln!( | ||
| 175 | "scrobble: skipping scrobble for '{}' — not enough play time", | ||
| 176 | title | ||
| 177 | ); | ||
| 178 | } | ||
| 179 | state.clear(); | ||
| 180 | } | ||
| 181 | |||
| 182 | fn send_now_playing(scrobbler: &RwLock<Scrobbler>, track: &Track) { | ||
| 183 | let artist = track.artist.as_deref().unwrap_or("(unknown artist)"); | ||
| 184 | let title = track.title.as_deref().unwrap_or("(unknown title)"); | ||
| 185 | let album = track.album.as_deref(); | ||
| 186 | eprintln!("scrobble: sending now_playing for '{} - {}'", artist, title); | ||
| 187 | if let Ok(s) = scrobbler.read() { | ||
| 188 | s.now_playing(artist, title, album); | ||
| 189 | } | ||
| 190 | } | ||
| 12 | 191 | ||
| 13 | pub fn start_daemon() -> Result<(), Box<dyn std::error::Error>> { | 192 | pub fn start_daemon() -> Result<(), Box<dyn std::error::Error>> { |
| 14 | let config = Config::load()?; | 193 | let config = Config::load()?; |
| @@ -68,6 +247,26 @@ pub fn start_daemon() -> Result<(), Box<dyn std::error::Error>> { | |||
| 68 | } | 247 | } |
| 69 | } | 248 | } |
| 70 | 249 | ||
| 250 | // Create scrobbler from config | ||
| 251 | let scrobbler = Arc::new(RwLock::new(Scrobbler::new( | ||
| 252 | config.lastfm_api_key.clone(), | ||
| 253 | config.lastfm_api_secret.clone(), | ||
| 254 | config.lastfm_session_key.clone(), | ||
| 255 | config.listenbrainz_token.clone(), | ||
| 256 | ))); | ||
| 257 | // Validate ListenBrainz token at startup | ||
| 258 | if let Some(token) = &config.listenbrainz_token | ||
| 259 | && !token.is_empty() | ||
| 260 | && let Ok(s) = scrobbler.read() | ||
| 261 | { | ||
| 262 | match s.validate_listenbrainz_token() { | ||
| 263 | Ok(Some(user)) => eprintln!("scrobble: listenbrainz token valid (user: {})", user), | ||
| 264 | Ok(None) => eprintln!("scrobble: listenbrainz token invalid, scrobbling disabled"), | ||
| 265 | Err(e) => eprintln!("scrobble: listenbrainz validation error: {}", e), | ||
| 266 | } | ||
| 267 | } | ||
| 268 | let scrobble_state = Arc::new(Mutex::new(ScrobbleState::new())); | ||
| 269 | |||
| 71 | // Save full config | 270 | // Save full config |
| 72 | config.save()?; | 271 | config.save()?; |
| 73 | 272 | ||
| @@ -98,6 +297,10 @@ pub fn start_daemon() -> Result<(), Box<dyn std::error::Error>> { | |||
| 98 | if audio_engine.poll_track_ended() | 297 | if audio_engine.poll_track_ended() |
| 99 | && let Ok(mut q) = queue.lock() | 298 | && let Ok(mut q) = queue.lock() |
| 100 | { | 299 | { |
| 300 | // Scrobble the track that just ended | ||
| 301 | if let Ok(mut s) = scrobble_state.lock() { | ||
| 302 | try_scrobble_current(&scrobbler, &mut s); | ||
| 303 | } | ||
| 101 | let original_position = q.position(); | 304 | let original_position = q.position(); |
| 102 | let mut started = false; | 305 | let mut started = false; |
| 103 | let mut had_next = false; | 306 | let mut had_next = false; |
| @@ -108,8 +311,14 @@ pub fn start_daemon() -> Result<(), Box<dyn std::error::Error>> { | |||
| 108 | audio_engine.play(next_track.clone()); | 311 | audio_engine.play(next_track.clone()); |
| 109 | if wait_for_start(&audio_engine, &next_track, 500) { | 312 | if wait_for_start(&audio_engine, &next_track, 500) { |
| 110 | started = true; | 313 | started = true; |
| 314 | let track = Track::from_path(next_track.clone()); | ||
| 315 | // Send now playing and record scrobble start | ||
| 316 | send_now_playing(&scrobbler, &track); | ||
| 317 | if let Ok(mut s) = scrobble_state.lock() { | ||
| 318 | s.start(track.clone()); | ||
| 319 | } | ||
| 111 | let fmt = Config::load().ok().and_then(|c| c.display_format.clone()); | 320 | let fmt = Config::load().ok().and_then(|c| c.display_format.clone()); |
| 112 | notification::notify_track(&Track::from_path(next_track), fmt.as_deref()); | 321 | notification::notify_track(&track, fmt.as_deref()); |
| 113 | break; | 322 | break; |
| 114 | } | 323 | } |
| 115 | } else { | 324 | } else { |
| @@ -132,8 +341,17 @@ pub fn start_daemon() -> Result<(), Box<dyn std::error::Error>> { | |||
| 132 | let audio_clone = Arc::clone(&audio_engine); | 341 | let audio_clone = Arc::clone(&audio_engine); |
| 133 | let shutdown_flag_clone = Arc::clone(&shutdown_flag); | 342 | let shutdown_flag_clone = Arc::clone(&shutdown_flag); |
| 134 | let queue_clone = Arc::clone(&queue); | 343 | let queue_clone = Arc::clone(&queue); |
| 344 | let scrobbler_clone = Arc::clone(&scrobbler); | ||
| 345 | let scrobble_state_clone = Arc::clone(&scrobble_state); | ||
| 135 | thread::spawn(move || { | 346 | thread::spawn(move || { |
| 136 | handle_client(stream, audio_clone, shutdown_flag_clone, queue_clone); | 347 | handle_client( |
| 348 | stream, | ||
| 349 | audio_clone, | ||
| 350 | shutdown_flag_clone, | ||
| 351 | queue_clone, | ||
| 352 | scrobbler_clone, | ||
| 353 | scrobble_state_clone, | ||
| 354 | ); | ||
| 137 | }); | 355 | }); |
| 138 | } | 356 | } |
| 139 | Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { | 357 | Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { |
| @@ -236,11 +454,20 @@ fn handle_client( | |||
| 236 | audio: Arc<AudioEngine>, | 454 | audio: Arc<AudioEngine>, |
| 237 | shutdown_flag: Arc<AtomicBool>, | 455 | shutdown_flag: Arc<AtomicBool>, |
| 238 | queue: Arc<Mutex<Queue>>, | 456 | queue: Arc<Mutex<Queue>>, |
| 457 | scrobbler: Arc<RwLock<Scrobbler>>, | ||
| 458 | scrobble_state: Arc<Mutex<ScrobbleState>>, | ||
| 239 | ) { | 459 | ) { |
| 240 | let mut buffer = [0; 1024]; | 460 | let mut buffer = [0; 1024]; |
| 241 | if let Ok(size) = stream.read(&mut buffer) { | 461 | if let Ok(size) = stream.read(&mut buffer) { |
| 242 | let cmd = String::from_utf8_lossy(&buffer[..size]); | 462 | let cmd = String::from_utf8_lossy(&buffer[..size]); |
| 243 | let response = process_command(cmd.trim(), &audio, &shutdown_flag, &queue); | 463 | let response = process_command( |
| 464 | cmd.trim(), | ||
| 465 | &audio, | ||
| 466 | &shutdown_flag, | ||
| 467 | &queue, | ||
| 468 | &scrobbler, | ||
| 469 | &scrobble_state, | ||
| 470 | ); | ||
| 244 | let _ = stream.write_all(response.as_bytes()); | 471 | let _ = stream.write_all(response.as_bytes()); |
| 245 | } | 472 | } |
| 246 | } | 473 | } |
| @@ -250,6 +477,8 @@ fn process_command( | |||
| 250 | audio: &AudioEngine, | 477 | audio: &AudioEngine, |
| 251 | shutdown_flag: &Arc<AtomicBool>, | 478 | shutdown_flag: &Arc<AtomicBool>, |
| 252 | queue: &Arc<Mutex<Queue>>, | 479 | queue: &Arc<Mutex<Queue>>, |
| 480 | scrobbler: &RwLock<Scrobbler>, | ||
| 481 | scrobble_state: &Arc<Mutex<ScrobbleState>>, | ||
| 253 | ) -> String { | 482 | ) -> String { |
| 254 | match cmd { | 483 | match cmd { |
| 255 | "status" => { | 484 | "status" => { |
| @@ -291,6 +520,9 @@ fn process_command( | |||
| 291 | } | 520 | } |
| 292 | } | 521 | } |
| 293 | "stop" => { | 522 | "stop" => { |
| 523 | if let Ok(mut s) = scrobble_state.lock() { | ||
| 524 | try_scrobble_current(scrobbler, &mut s); | ||
| 525 | } | ||
| 294 | audio.stop(); | 526 | audio.stop(); |
| 295 | if let Ok(mut q) = queue.lock() { | 527 | if let Ok(mut q) = queue.lock() { |
| 296 | q.clear(); | 528 | q.clear(); |
| @@ -299,19 +531,57 @@ fn process_command( | |||
| 299 | "Stopped\n".to_string() | 531 | "Stopped\n".to_string() |
| 300 | } | 532 | } |
| 301 | "stop_daemon" => { | 533 | "stop_daemon" => { |
| 302 | // Signal the daemon to shut down | ||
| 303 | shutdown_flag.store(true, Ordering::SeqCst); | 534 | shutdown_flag.store(true, Ordering::SeqCst); |
| 304 | "Shutting down\n".to_string() | 535 | "Shutting down\n".to_string() |
| 305 | } | 536 | } |
| 537 | "reload" | "validate" => { | ||
| 538 | if let Ok(config) = Config::load() { | ||
| 539 | let temp_scrobbler = Scrobbler::new( | ||
| 540 | config.lastfm_api_key.clone(), | ||
| 541 | config.lastfm_api_secret.clone(), | ||
| 542 | config.lastfm_session_key.clone(), | ||
| 543 | config.listenbrainz_token.clone(), | ||
| 544 | ); | ||
| 545 | let result = match temp_scrobbler.validate_listenbrainz_token() { | ||
| 546 | Ok(Some(user)) => { | ||
| 547 | notification::notify_change(&format!( | ||
| 548 | "Logged in to ListenBrainz as {}", | ||
| 549 | user | ||
| 550 | )); | ||
| 551 | format!("ListenBrainz token valid (user: {})\n", user) | ||
| 552 | } | ||
| 553 | Ok(None) => { | ||
| 554 | notification::notify_error("Invalid ListenBrainz token"); | ||
| 555 | "ListenBrainz token invalid\n".to_string() | ||
| 556 | } | ||
| 557 | Err(e) => { | ||
| 558 | notification::notify_error(&format!("ListenBrainz validation: {}", e)); | ||
| 559 | format!("ListenBrainz validation error: {}\n", e) | ||
| 560 | } | ||
| 561 | }; | ||
| 562 | // Replace shared scrobbler state (only on reload, both is fine) | ||
| 563 | if let Ok(mut s) = scrobbler.write() { | ||
| 564 | *s = temp_scrobbler; | ||
| 565 | } | ||
| 566 | result | ||
| 567 | } else { | ||
| 568 | "Failed to load config\n".to_string() | ||
| 569 | } | ||
| 570 | } | ||
| 306 | "next" => { | 571 | "next" => { |
| 572 | if let Ok(mut s) = scrobble_state.lock() { | ||
| 573 | try_scrobble_current(scrobbler, &mut s); | ||
| 574 | } | ||
| 307 | if let Ok(mut q) = queue.lock() { | 575 | if let Ok(mut q) = queue.lock() { |
| 308 | if let Some(next_track) = q.next() { | 576 | if let Some(next_track) = q.next() { |
| 309 | audio.play(next_track.clone()); | 577 | audio.play(next_track.clone()); |
| 578 | let track = Track::from_path(next_track.clone()); | ||
| 579 | send_now_playing(scrobbler, &track); | ||
| 580 | if let Ok(mut s) = scrobble_state.lock() { | ||
| 581 | s.start(track.clone()); | ||
| 582 | } | ||
| 310 | let fmt = Config::load().ok().and_then(|c| c.display_format.clone()); | 583 | let fmt = Config::load().ok().and_then(|c| c.display_format.clone()); |
| 311 | notification::notify_track( | 584 | notification::notify_track(&track, fmt.as_deref()); |
| 312 | &Track::from_path(next_track.clone()), | ||
| 313 | fmt.as_deref(), | ||
| 314 | ); | ||
| 315 | "Playing next track\n".to_string() | 585 | "Playing next track\n".to_string() |
| 316 | } else { | 586 | } else { |
| 317 | audio.stop(); | 587 | audio.stop(); |
| @@ -323,14 +593,19 @@ fn process_command( | |||
| 323 | } | 593 | } |
| 324 | } | 594 | } |
| 325 | "previous" => { | 595 | "previous" => { |
| 596 | if let Ok(mut s) = scrobble_state.lock() { | ||
| 597 | try_scrobble_current(scrobbler, &mut s); | ||
| 598 | } | ||
| 326 | if let Ok(mut q) = queue.lock() { | 599 | if let Ok(mut q) = queue.lock() { |
| 327 | if let Some(prev_track) = q.previous() { | 600 | if let Some(prev_track) = q.previous() { |
| 328 | audio.play(prev_track.clone()); | 601 | audio.play(prev_track.clone()); |
| 602 | let track = Track::from_path(prev_track.clone()); | ||
| 603 | send_now_playing(scrobbler, &track); | ||
| 604 | if let Ok(mut s) = scrobble_state.lock() { | ||
| 605 | s.start(track.clone()); | ||
| 606 | } | ||
| 329 | let fmt = Config::load().ok().and_then(|c| c.display_format.clone()); | 607 | let fmt = Config::load().ok().and_then(|c| c.display_format.clone()); |
| 330 | notification::notify_track( | 608 | notification::notify_track(&track, fmt.as_deref()); |
| 331 | &Track::from_path(prev_track.clone()), | ||
| 332 | fmt.as_deref(), | ||
| 333 | ); | ||
| 334 | "Playing previous track\n".to_string() | 609 | "Playing previous track\n".to_string() |
| 335 | } else { | 610 | } else { |
| 336 | "No previous track\n".to_string() | 611 | "No previous track\n".to_string() |
| @@ -408,6 +683,9 @@ fn process_command( | |||
| 408 | let was_empty = q.is_empty(); | 683 | let was_empty = q.is_empty(); |
| 409 | match position { | 684 | match position { |
| 410 | "now" => { | 685 | "now" => { |
| 686 | if let Ok(mut s) = scrobble_state.lock() { | ||
| 687 | try_scrobble_current(scrobbler, &mut s); | ||
| 688 | } | ||
| 411 | q.insert_after_current(path.clone()); | 689 | q.insert_after_current(path.clone()); |
| 412 | if !was_empty { | 690 | if !was_empty { |
| 413 | let new_pos = q.position() + 1; | 691 | let new_pos = q.position() + 1; |
| @@ -415,6 +693,10 @@ fn process_command( | |||
| 415 | } | 693 | } |
| 416 | if let Some(track) = q.track_at(q.position()) { | 694 | if let Some(track) = q.track_at(q.position()) { |
| 417 | audio.play(track.path.clone()); | 695 | audio.play(track.path.clone()); |
| 696 | send_now_playing(scrobbler, track); | ||
| 697 | if let Ok(mut s) = scrobble_state.lock() { | ||
| 698 | s.start(track.clone()); | ||
| 699 | } | ||
| 418 | let fmt = | 700 | let fmt = |
| 419 | Config::load().ok().and_then(|c| c.display_format.clone()); | 701 | Config::load().ok().and_then(|c| c.display_format.clone()); |
| 420 | notification::notify_track(track, fmt.as_deref()); | 702 | notification::notify_track(track, fmt.as_deref()); |
| @@ -432,6 +714,10 @@ fn process_command( | |||
| 432 | if was_empty && !was_playing { | 714 | if was_empty && !was_playing { |
| 433 | let track = Track::from_path(path.clone()); | 715 | let track = Track::from_path(path.clone()); |
| 434 | audio.play(path); | 716 | audio.play(path); |
| 717 | send_now_playing(scrobbler, &track); | ||
| 718 | if let Ok(mut s) = scrobble_state.lock() { | ||
| 719 | s.start(track.clone()); | ||
| 720 | } | ||
| 435 | let fmt = | 721 | let fmt = |
| 436 | Config::load().ok().and_then(|c| c.display_format.clone()); | 722 | Config::load().ok().and_then(|c| c.display_format.clone()); |
| 437 | notification::notify_track(&track, fmt.as_deref()); | 723 | notification::notify_track(&track, fmt.as_deref()); |
| @@ -451,6 +737,9 @@ fn process_command( | |||
| 451 | let was_empty = q.is_empty(); | 737 | let was_empty = q.is_empty(); |
| 452 | match position { | 738 | match position { |
| 453 | "now" => { | 739 | "now" => { |
| 740 | if let Ok(mut s) = scrobble_state.lock() { | ||
| 741 | try_scrobble_current(scrobbler, &mut s); | ||
| 742 | } | ||
| 454 | for file in files.iter().rev() { | 743 | for file in files.iter().rev() { |
| 455 | q.insert_after_current(file.clone()); | 744 | q.insert_after_current(file.clone()); |
| 456 | } | 745 | } |
| @@ -460,6 +749,10 @@ fn process_command( | |||
| 460 | } | 749 | } |
| 461 | if let Some(track) = q.track_at(q.position()) { | 750 | if let Some(track) = q.track_at(q.position()) { |
| 462 | audio.play(track.path.clone()); | 751 | audio.play(track.path.clone()); |
| 752 | send_now_playing(scrobbler, track); | ||
| 753 | if let Ok(mut s) = scrobble_state.lock() { | ||
| 754 | s.start(track.clone()); | ||
| 755 | } | ||
| 463 | let fmt = Config::load() | 756 | let fmt = Config::load() |
| 464 | .ok() | 757 | .ok() |
| 465 | .and_then(|c| c.display_format.clone()); | 758 | .and_then(|c| c.display_format.clone()); |
| @@ -491,6 +784,10 @@ fn process_command( | |||
| 491 | && let Some(track) = q.current() | 784 | && let Some(track) = q.current() |
| 492 | { | 785 | { |
| 493 | audio.play(track.path.clone()); | 786 | audio.play(track.path.clone()); |
| 787 | send_now_playing(scrobbler, track); | ||
| 788 | if let Ok(mut s) = scrobble_state.lock() { | ||
| 789 | s.start(track.clone()); | ||
| 790 | } | ||
| 494 | let fmt = Config::load() | 791 | let fmt = Config::load() |
| 495 | .ok() | 792 | .ok() |
| 496 | .and_then(|c| c.display_format.clone()); | 793 | .and_then(|c| c.display_format.clone()); |
diff --git a/src/queue.rs b/src/queue.rs index d42203f..8f98173 100644 --- a/src/queue.rs +++ b/src/queue.rs | |||
| @@ -13,6 +13,7 @@ pub struct AudioMetadata { | |||
| 13 | pub title: Option<String>, | 13 | pub title: Option<String>, |
| 14 | pub artist: Option<String>, | 14 | pub artist: Option<String>, |
| 15 | pub album: Option<String>, | 15 | pub album: Option<String>, |
| 16 | pub duration_secs: Option<f64>, | ||
| 16 | } | 17 | } |
| 17 | 18 | ||
| 18 | pub fn extract_metadata(path: &Path) -> Option<AudioMetadata> { | 19 | pub fn extract_metadata(path: &Path) -> Option<AudioMetadata> { |
| @@ -28,7 +29,22 @@ pub fn extract_metadata(path: &Path) -> Option<AudioMetadata> { | |||
| 28 | .format(&hint, mss, &Default::default(), &Default::default()) | 29 | .format(&hint, mss, &Default::default(), &Default::default()) |
| 29 | .ok()?; | 30 | .ok()?; |
| 30 | 31 | ||
| 31 | let mut metadata = AudioMetadata::default(); | 32 | // Extract duration from format track's codec parameters |
| 33 | let duration_secs = probed | ||
| 34 | .format | ||
| 35 | .tracks() | ||
| 36 | .iter() | ||
| 37 | .find(|t| t.codec_params.n_frames.is_some() && t.codec_params.time_base.is_some()) | ||
| 38 | .and_then(|t| { | ||
| 39 | let n_frames = t.codec_params.n_frames?; | ||
| 40 | let tb = t.codec_params.time_base?; | ||
| 41 | Some(n_frames as f64 * tb.numer as f64 / tb.denom as f64) | ||
| 42 | }); | ||
| 43 | |||
| 44 | let mut metadata = AudioMetadata { | ||
| 45 | duration_secs, | ||
| 46 | ..Default::default() | ||
| 47 | }; | ||
| 32 | 48 | ||
| 33 | if let Some(meta) = probed.metadata.get() | 49 | if let Some(meta) = probed.metadata.get() |
| 34 | && let Some(rev) = meta.current() | 50 | && let Some(rev) = meta.current() |
| @@ -82,6 +98,7 @@ pub struct Track { | |||
| 82 | pub title: Option<String>, | 98 | pub title: Option<String>, |
| 83 | pub artist: Option<String>, | 99 | pub artist: Option<String>, |
| 84 | pub album: Option<String>, | 100 | pub album: Option<String>, |
| 101 | pub duration_secs: Option<f64>, | ||
| 85 | } | 102 | } |
| 86 | 103 | ||
| 87 | impl Track { | 104 | impl Track { |
| @@ -93,6 +110,7 @@ impl Track { | |||
| 93 | title: metadata.and_then(|m| m.title.clone()), | 110 | title: metadata.and_then(|m| m.title.clone()), |
| 94 | artist: metadata.and_then(|m| m.artist.clone()), | 111 | artist: metadata.and_then(|m| m.artist.clone()), |
| 95 | album: metadata.and_then(|m| m.album.clone()), | 112 | album: metadata.and_then(|m| m.album.clone()), |
| 113 | duration_secs: metadata.and_then(|m| m.duration_secs), | ||
| 96 | } | 114 | } |
| 97 | } | 115 | } |
| 98 | 116 | ||
diff --git a/src/scrobbler.rs b/src/scrobbler.rs index ddcbed9..2e79933 100644 --- a/src/scrobbler.rs +++ b/src/scrobbler.rs | |||
| @@ -1,6 +1,7 @@ | |||
| 1 | use reqwest::Client; | 1 | use md5::{Digest, Md5}; |
| 2 | use reqwest::blocking::Client; | ||
| 3 | use std::collections::BTreeMap; | ||
| 2 | 4 | ||
| 3 | #[allow(dead_code)] | ||
| 4 | pub struct Scrobbler { | 5 | pub struct Scrobbler { |
| 5 | pub lastfm_api_key: Option<String>, | 6 | pub lastfm_api_key: Option<String>, |
| 6 | pub lastfm_api_secret: Option<String>, | 7 | pub lastfm_api_secret: Option<String>, |
| @@ -9,7 +10,10 @@ pub struct Scrobbler { | |||
| 9 | client: Client, | 10 | client: Client, |
| 10 | } | 11 | } |
| 11 | 12 | ||
| 12 | #[allow(dead_code)] | 13 | const LASTFM_API_URL: &str = "https://ws.audioscrobbler.com/2.0/"; |
| 14 | const LISTENBRAINZ_VALIDATE_URL: &str = "https://api.listenbrainz.org/1/validate-token"; | ||
| 15 | const LISTENBRAINZ_LISTENS_URL: &str = "https://api.listenbrainz.org/1/submit-listens"; | ||
| 16 | |||
| 13 | impl Scrobbler { | 17 | impl Scrobbler { |
| 14 | pub fn new( | 18 | pub fn new( |
| 15 | lastfm_api_key: Option<String>, | 19 | lastfm_api_key: Option<String>, |
| @@ -26,50 +30,303 @@ impl Scrobbler { | |||
| 26 | } | 30 | } |
| 27 | } | 31 | } |
| 28 | 32 | ||
| 33 | pub fn validate_listenbrainz_token( | ||
| 34 | &self, | ||
| 35 | ) -> Result<Option<String>, Box<dyn std::error::Error>> { | ||
| 36 | let Some(token) = &self.listenbrainz_token else { | ||
| 37 | return Ok(None); | ||
| 38 | }; | ||
| 39 | if token.is_empty() { | ||
| 40 | return Ok(None); | ||
| 41 | } | ||
| 42 | |||
| 43 | let resp = self | ||
| 44 | .client | ||
| 45 | .get(LISTENBRAINZ_VALIDATE_URL) | ||
| 46 | .header("Authorization", format!("Token {}", token)) | ||
| 47 | .send()?; | ||
| 48 | |||
| 49 | if !resp.status().is_success() { | ||
| 50 | eprintln!( | ||
| 51 | "scrobble: listenbrainz token validation failed: {}", | ||
| 52 | resp.status() | ||
| 53 | ); | ||
| 54 | return Ok(None); | ||
| 55 | } | ||
| 56 | |||
| 57 | let body: serde_json::Value = resp.json()?; | ||
| 58 | let valid = body.get("valid").and_then(|v| v.as_bool()).unwrap_or(false); | ||
| 59 | if valid { | ||
| 60 | let user = body | ||
| 61 | .get("user_name") | ||
| 62 | .and_then(|v| v.as_str()) | ||
| 63 | .map(|s| s.to_string()); | ||
| 64 | if let Some(ref u) = user { | ||
| 65 | eprintln!("scrobble: listenbrainz token valid (user: {})", u); | ||
| 66 | } else { | ||
| 67 | eprintln!("scrobble: listenbrainz token valid"); | ||
| 68 | } | ||
| 69 | Ok(user) | ||
| 70 | } else { | ||
| 71 | eprintln!("scrobble: listenbrainz token invalid — scrobbling will fail"); | ||
| 72 | Ok(None) | ||
| 73 | } | ||
| 74 | } | ||
| 75 | |||
| 29 | pub fn now_playing(&self, artist: &str, track: &str, album: Option<&str>) { | 76 | pub fn now_playing(&self, artist: &str, track: &str, album: Option<&str>) { |
| 30 | if let Some(api_key) = &self.lastfm_api_key { | 77 | if let (Some(api_key), Some(secret), Some(session_key)) = ( |
| 31 | let _ = self.send_lastfm_now_playing(api_key, artist, track, album); | 78 | &self.lastfm_api_key, |
| 79 | &self.lastfm_api_secret, | ||
| 80 | &self.lastfm_session_key, | ||
| 81 | ) && !api_key.is_empty() | ||
| 82 | && !secret.is_empty() | ||
| 83 | && !session_key.is_empty() | ||
| 84 | { | ||
| 85 | eprintln!("scrobble: now_playing -> lastfm '{} - {}'", artist, track); | ||
| 86 | let _ = | ||
| 87 | self.send_lastfm_now_playing(api_key, secret, session_key, artist, track, album); | ||
| 88 | } else { | ||
| 89 | eprintln!("scrobble: now_playing -> lastfm skipped (no credentials)"); | ||
| 90 | } | ||
| 91 | if let Some(token) = &self.listenbrainz_token | ||
| 92 | && !token.is_empty() | ||
| 93 | { | ||
| 94 | eprintln!( | ||
| 95 | "scrobble: now_playing -> listenbrainz '{} - {}'", | ||
| 96 | artist, track | ||
| 97 | ); | ||
| 98 | let _ = self.send_listenbrainz_now_playing(token, artist, track, album); | ||
| 99 | } else { | ||
| 100 | eprintln!("scrobble: now_playing -> listenbrainz skipped (no token)"); | ||
| 32 | } | 101 | } |
| 33 | } | 102 | } |
| 34 | 103 | ||
| 35 | pub fn scrobble(&self, artist: &str, track: &str, album: Option<&str>, timestamp: u64) { | 104 | pub fn scrobble(&self, artist: &str, track: &str, album: Option<&str>, timestamp: u64) { |
| 36 | if let Some(api_key) = &self.lastfm_api_key { | 105 | if let (Some(api_key), Some(secret), Some(session_key)) = ( |
| 37 | let _ = self.send_lastfm_scrobble(api_key, artist, track, album, timestamp); | 106 | &self.lastfm_api_key, |
| 107 | &self.lastfm_api_secret, | ||
| 108 | &self.lastfm_session_key, | ||
| 109 | ) && !api_key.is_empty() | ||
| 110 | && !secret.is_empty() | ||
| 111 | && !session_key.is_empty() | ||
| 112 | { | ||
| 113 | eprintln!( | ||
| 114 | "scrobble: scrobble -> lastfm '{} - {}' (ts={})", | ||
| 115 | artist, track, timestamp | ||
| 116 | ); | ||
| 117 | let _ = self.send_lastfm_scrobble( | ||
| 118 | api_key, | ||
| 119 | secret, | ||
| 120 | session_key, | ||
| 121 | artist, | ||
| 122 | track, | ||
| 123 | album, | ||
| 124 | timestamp, | ||
| 125 | ); | ||
| 126 | } else { | ||
| 127 | eprintln!("scrobble: scrobble -> lastfm skipped (no credentials)"); | ||
| 38 | } | 128 | } |
| 39 | if let Some(token) = &self.listenbrainz_token { | 129 | if let Some(token) = &self.listenbrainz_token |
| 130 | && !token.is_empty() | ||
| 131 | { | ||
| 132 | eprintln!( | ||
| 133 | "scrobble: scrobble -> listenbrainz '{} - {}' (ts={})", | ||
| 134 | artist, track, timestamp | ||
| 135 | ); | ||
| 40 | let _ = self.send_listenbrainz_scrobble(token, artist, track, album, timestamp); | 136 | let _ = self.send_listenbrainz_scrobble(token, artist, track, album, timestamp); |
| 137 | } else { | ||
| 138 | eprintln!("scrobble: scrobble -> listenbrainz skipped (no token)"); | ||
| 41 | } | 139 | } |
| 42 | } | 140 | } |
| 43 | 141 | ||
| 142 | fn sign_lastfm(params: &BTreeMap<&str, &str>, secret: &str) -> String { | ||
| 143 | let mut concat = String::new(); | ||
| 144 | for (key, value) in params { | ||
| 145 | concat.push_str(key); | ||
| 146 | concat.push_str(value); | ||
| 147 | } | ||
| 148 | concat.push_str(secret); | ||
| 149 | let mut hasher = Md5::new(); | ||
| 150 | hasher.update(concat.as_bytes()); | ||
| 151 | hex::encode(hasher.finalize()) | ||
| 152 | } | ||
| 153 | |||
| 154 | #[allow(clippy::too_many_arguments)] | ||
| 44 | fn send_lastfm_now_playing( | 155 | fn send_lastfm_now_playing( |
| 45 | &self, | 156 | &self, |
| 46 | _api_key: &str, | 157 | api_key: &str, |
| 47 | _artist: &str, | 158 | secret: &str, |
| 48 | _track: &str, | 159 | session_key: &str, |
| 49 | _album: Option<&str>, | 160 | artist: &str, |
| 161 | track: &str, | ||
| 162 | album: Option<&str>, | ||
| 50 | ) -> Result<(), Box<dyn std::error::Error>> { | 163 | ) -> Result<(), Box<dyn std::error::Error>> { |
| 164 | let mut params = BTreeMap::from([ | ||
| 165 | ("method", "track.updateNowPlaying"), | ||
| 166 | ("artist", artist), | ||
| 167 | ("track", track), | ||
| 168 | ("api_key", api_key), | ||
| 169 | ("sk", session_key), | ||
| 170 | ]); | ||
| 171 | if let Some(album) = album { | ||
| 172 | params.insert("album", album); | ||
| 173 | } | ||
| 174 | let api_sig = Self::sign_lastfm(¶ms, secret); | ||
| 175 | params.insert("api_sig", &api_sig); | ||
| 176 | params.insert("format", "json"); | ||
| 177 | |||
| 178 | let resp = self.client.post(LASTFM_API_URL).form(¶ms).send()?; | ||
| 179 | if resp.status().is_success() { | ||
| 180 | eprintln!("scrobble: lastfm now_playing ok"); | ||
| 181 | } else { | ||
| 182 | eprintln!( | ||
| 183 | "scrobble: lastfm now_playing error: {} {}", | ||
| 184 | resp.status(), | ||
| 185 | resp.text().unwrap_or_default() | ||
| 186 | ); | ||
| 187 | } | ||
| 51 | Ok(()) | 188 | Ok(()) |
| 52 | } | 189 | } |
| 53 | 190 | ||
| 191 | #[allow(clippy::too_many_arguments)] | ||
| 54 | fn send_lastfm_scrobble( | 192 | fn send_lastfm_scrobble( |
| 55 | &self, | 193 | &self, |
| 56 | _api_key: &str, | 194 | api_key: &str, |
| 57 | _artist: &str, | 195 | secret: &str, |
| 58 | _track: &str, | 196 | session_key: &str, |
| 59 | _album: Option<&str>, | 197 | artist: &str, |
| 60 | _timestamp: u64, | 198 | track: &str, |
| 199 | album: Option<&str>, | ||
| 200 | timestamp: u64, | ||
| 61 | ) -> Result<(), Box<dyn std::error::Error>> { | 201 | ) -> Result<(), Box<dyn std::error::Error>> { |
| 202 | let ts_str = timestamp.to_string(); | ||
| 203 | let mut params = BTreeMap::from([ | ||
| 204 | ("method", "track.scrobble"), | ||
| 205 | ("artist", artist), | ||
| 206 | ("track", track), | ||
| 207 | ("timestamp", &ts_str), | ||
| 208 | ("api_key", api_key), | ||
| 209 | ("sk", session_key), | ||
| 210 | ]); | ||
| 211 | if let Some(album) = album { | ||
| 212 | params.insert("album", album); | ||
| 213 | } | ||
| 214 | let api_sig = Self::sign_lastfm(¶ms, secret); | ||
| 215 | params.insert("api_sig", &api_sig); | ||
| 216 | params.insert("format", "json"); | ||
| 217 | |||
| 218 | let resp = self.client.post(LASTFM_API_URL).form(¶ms).send()?; | ||
| 219 | if resp.status().is_success() { | ||
| 220 | eprintln!("scrobble: lastfm scrobble ok"); | ||
| 221 | } else { | ||
| 222 | eprintln!( | ||
| 223 | "scrobble: lastfm scrobble error: {} {}", | ||
| 224 | resp.status(), | ||
| 225 | resp.text().unwrap_or_default() | ||
| 226 | ); | ||
| 227 | } | ||
| 228 | Ok(()) | ||
| 229 | } | ||
| 230 | |||
| 231 | fn send_listenbrainz_now_playing( | ||
| 232 | &self, | ||
| 233 | token: &str, | ||
| 234 | artist: &str, | ||
| 235 | track: &str, | ||
| 236 | album: Option<&str>, | ||
| 237 | ) -> Result<(), Box<dyn std::error::Error>> { | ||
| 238 | let mut track_metadata = serde_json::json!({ | ||
| 239 | "artist_name": artist, | ||
| 240 | "track_name": track, | ||
| 241 | }); | ||
| 242 | if let Some(album) = album { | ||
| 243 | track_metadata["release_name"] = serde_json::json!(album); | ||
| 244 | } | ||
| 245 | |||
| 246 | let payload = serde_json::json!({ | ||
| 247 | "listen_type": "playing_now", | ||
| 248 | "payload": [{ | ||
| 249 | "track_metadata": track_metadata | ||
| 250 | }] | ||
| 251 | }); | ||
| 252 | |||
| 253 | let resp = self | ||
| 254 | .client | ||
| 255 | .post(LISTENBRAINZ_LISTENS_URL) | ||
| 256 | .header("Authorization", format!("Token {}", token)) | ||
| 257 | .json(&payload) | ||
| 258 | .send()?; | ||
| 259 | if resp.status().is_success() { | ||
| 260 | eprintln!("scrobble: listenbrainz now_playing ok"); | ||
| 261 | } else { | ||
| 262 | eprintln!( | ||
| 263 | "scrobble: listenbrainz now_playing error: {} {}", | ||
| 264 | resp.status(), | ||
| 265 | resp.text().unwrap_or_default() | ||
| 266 | ); | ||
| 267 | } | ||
| 62 | Ok(()) | 268 | Ok(()) |
| 63 | } | 269 | } |
| 64 | 270 | ||
| 65 | fn send_listenbrainz_scrobble( | 271 | fn send_listenbrainz_scrobble( |
| 66 | &self, | 272 | &self, |
| 67 | _token: &str, | 273 | token: &str, |
| 68 | _artist: &str, | 274 | artist: &str, |
| 69 | _track: &str, | 275 | track: &str, |
| 70 | _album: Option<&str>, | 276 | album: Option<&str>, |
| 71 | _timestamp: u64, | 277 | timestamp: u64, |
| 72 | ) -> Result<(), Box<dyn std::error::Error>> { | 278 | ) -> Result<(), Box<dyn std::error::Error>> { |
| 279 | let mut track_metadata = serde_json::json!({ | ||
| 280 | "artist_name": artist, | ||
| 281 | "track_name": track, | ||
| 282 | }); | ||
| 283 | if let Some(album) = album { | ||
| 284 | track_metadata["release_name"] = serde_json::json!(album); | ||
| 285 | } | ||
| 286 | |||
| 287 | let payload = serde_json::json!({ | ||
| 288 | "listen_type": "single", | ||
| 289 | "payload": [{ | ||
| 290 | "listened_at": timestamp, | ||
| 291 | "track_metadata": track_metadata | ||
| 292 | }] | ||
| 293 | }); | ||
| 294 | |||
| 295 | let resp = self | ||
| 296 | .client | ||
| 297 | .post(LISTENBRAINZ_LISTENS_URL) | ||
| 298 | .header("Authorization", format!("Token {}", token)) | ||
| 299 | .json(&payload) | ||
| 300 | .send()?; | ||
| 301 | if resp.status().is_success() { | ||
| 302 | eprintln!("scrobble: listenbrainz scrobble ok"); | ||
| 303 | } else { | ||
| 304 | eprintln!( | ||
| 305 | "scrobble: listenbrainz scrobble error: {} {}", | ||
| 306 | resp.status(), | ||
| 307 | resp.text().unwrap_or_default() | ||
| 308 | ); | ||
| 309 | } | ||
| 73 | Ok(()) | 310 | Ok(()) |
| 74 | } | 311 | } |
| 75 | } | 312 | } |
| 313 | |||
| 314 | #[cfg(test)] | ||
| 315 | mod tests { | ||
| 316 | use super::*; | ||
| 317 | |||
| 318 | #[test] | ||
| 319 | fn test_sign_lastfm() { | ||
| 320 | let params = BTreeMap::from([ | ||
| 321 | ("api_key", "abc123"), | ||
| 322 | ("method", "track.scrobble"), | ||
| 323 | ("sk", "session_key"), | ||
| 324 | ("track", "Test Track"), | ||
| 325 | ("artist", "Test Artist"), | ||
| 326 | ("timestamp", "1234567890"), | ||
| 327 | ]); | ||
| 328 | let sig = Scrobbler::sign_lastfm(¶ms, "sekrit"); | ||
| 329 | assert_eq!(sig.len(), 32); | ||
| 330 | assert!(sig.chars().all(|c| c.is_ascii_hexdigit())); | ||
| 331 | } | ||
| 332 | } | ||
