From 04add15691578316ff3da5b5a07d05b314488370 Mon Sep 17 00:00:00 2001 From: salmonik Date: Sat, 16 May 2026 11:43:32 +0200 Subject: Add scrobbling support (Last.fm + ListenBrainz) and rsp settings reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- src/cli.rs | 8 ++ src/daemon.rs | 325 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- src/queue.rs | 20 +++- src/scrobbler.rs | 301 +++++++++++++++++++++++++++++++++++++++++++++++---- 4 files changed, 617 insertions(+), 37 deletions(-) (limited to 'src') diff --git a/src/cli.rs b/src/cli.rs index 7d56de6..f9a3c6f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -105,6 +105,8 @@ pub enum SettingsCommands { #[arg(value_name = "KEY")] key: String, }, + /// Reload config from disk (re-reads config file, re-validates scrobbling tokens) + Reload, } impl Cli { @@ -199,6 +201,9 @@ impl Cli { SettingsCommands::Set { key, value } => { config.set(key, value)?; config.save()?; + if key == "listenbrainz_token" { + let _ = crate::daemon::send_command("validate".to_string()); + } } SettingsCommands::List => { config.list(); @@ -207,6 +212,9 @@ impl Cli { config.unset(key)?; config.save()?; } + SettingsCommands::Reload => { + let _ = crate::daemon::send_command("reload".to_string()); + } } } 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; use crate::config::Config; use crate::notification; use crate::queue::{Queue, ShuffleMode, Track}; +use crate::scrobbler::Scrobbler; use std::io::{Read, Write}; use std::os::unix::net::{UnixListener, UnixStream}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, RwLock}; use std::thread; -use std::time::Duration; +use std::time::{Duration, Instant, SystemTime}; + +struct ScrobbleState { + track: Option, + start_time: Option, + timestamp: Option, +} + +impl ScrobbleState { + fn new() -> Self { + ScrobbleState { + track: None, + start_time: None, + timestamp: None, + } + } + + fn start(&mut self, track: Track) { + let ts = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let title = track.title.clone().unwrap_or_default(); + let artist = track.artist.clone().unwrap_or_default(); + eprintln!( + "scrobble: tracking start for '{} - {}' (ts={})", + artist, title, ts + ); + self.track = Some(track); + self.start_time = Some(Instant::now()); + self.timestamp = Some(ts); + } + + fn should_scrobble(&self) -> bool { + let (Some(track), Some(start)) = (&self.track, self.start_time) else { + eprintln!("scrobble: should_scrobble false — no track or start_time"); + return false; + }; + let elapsed = start.elapsed().as_secs(); + let result = Self::should_scrobble_with_params(elapsed, track.duration_secs); + let threshold = track + .duration_secs + .map(|d| (d / 2.0) as u64) + .unwrap_or(u64::MAX) + .min(240); + eprintln!( + "scrobble: should_scrobble {} (elapsed={}s threshold={}s duration={:?})", + result, elapsed, threshold, track.duration_secs + ); + result + } + + fn should_scrobble_with_params(elapsed: u64, duration_secs: Option) -> bool { + if elapsed < 30 { + return false; + } + let threshold = duration_secs + .map(|d| (d / 2.0) as u64) + .unwrap_or(u64::MAX) + .min(240); + elapsed >= threshold + } + + fn clear(&mut self) { + self.track = None; + self.start_time = None; + self.timestamp = None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_scrobble_state_new() { + let s = ScrobbleState::new(); + assert!(s.track.is_none()); + assert!(s.start_time.is_none()); + assert!(s.timestamp.is_none()); + } + + #[test] + fn test_scrobble_state_clear() { + let mut s = ScrobbleState::new(); + let t = Track { + path: PathBuf::from("/test/song.mp3"), + title: Some("Test".into()), + artist: Some("Artist".into()), + album: None, + duration_secs: Some(200.0), + }; + s.start(t); + assert!(s.track.is_some()); + s.clear(); + assert!(s.track.is_none()); + assert!(s.start_time.is_none()); + assert!(s.timestamp.is_none()); + } + + #[test] + fn test_should_scrobble_without_track_returns_false() { + assert!(!ScrobbleState::new().should_scrobble()); + } + + #[test] + fn test_should_scrobble_threshold_below_30_seconds() { + assert!(!ScrobbleState::should_scrobble_with_params(29, Some(200.0))); + } + + #[test] + fn test_should_scrobble_threshold_half_duration() { + assert!(ScrobbleState::should_scrobble_with_params(100, Some(200.0))); + } + + #[test] + fn test_should_scrobble_threshold_below_half_duration() { + assert!(!ScrobbleState::should_scrobble_with_params(80, Some(200.0))); + } + + #[test] + fn test_should_scrobble_threshold_capped_at_4min() { + assert!(ScrobbleState::should_scrobble_with_params(240, Some(600.0))); + assert!(!ScrobbleState::should_scrobble_with_params( + 239, + Some(600.0) + )); + } + + #[test] + fn test_should_scrobble_no_duration_requires_4min() { + assert!(ScrobbleState::should_scrobble_with_params(240, None)); + assert!(!ScrobbleState::should_scrobble_with_params(239, None)); + } + + #[test] + fn test_should_scrobble_short_song() { + assert!(ScrobbleState::should_scrobble_with_params(30, Some(60.0))); + assert!(!ScrobbleState::should_scrobble_with_params(15, Some(60.0))); + } +} + +fn try_scrobble_current(scrobbler: &RwLock, state: &mut ScrobbleState) { + if state.should_scrobble() + && let (Some(track), Some(ts)) = (&state.track, state.timestamp) + { + let artist = track.artist.as_deref().unwrap_or("(unknown artist)"); + let title = track.title.as_deref().unwrap_or("(unknown title)"); + let album = track.album.as_deref(); + eprintln!( + "scrobble: sending scrobble for '{} - {}' at ts={}", + artist, title, ts + ); + if let Ok(s) = scrobbler.read() { + s.scrobble(artist, title, album, ts); + } + } else { + let title = state + .track + .as_ref() + .and_then(|t| t.title.as_deref()) + .unwrap_or("(no track)"); + eprintln!( + "scrobble: skipping scrobble for '{}' — not enough play time", + title + ); + } + state.clear(); +} + +fn send_now_playing(scrobbler: &RwLock, track: &Track) { + let artist = track.artist.as_deref().unwrap_or("(unknown artist)"); + let title = track.title.as_deref().unwrap_or("(unknown title)"); + let album = track.album.as_deref(); + eprintln!("scrobble: sending now_playing for '{} - {}'", artist, title); + if let Ok(s) = scrobbler.read() { + s.now_playing(artist, title, album); + } +} pub fn start_daemon() -> Result<(), Box> { let config = Config::load()?; @@ -68,6 +247,26 @@ pub fn start_daemon() -> Result<(), Box> { } } + // Create scrobbler from config + let scrobbler = Arc::new(RwLock::new(Scrobbler::new( + config.lastfm_api_key.clone(), + config.lastfm_api_secret.clone(), + config.lastfm_session_key.clone(), + config.listenbrainz_token.clone(), + ))); + // Validate ListenBrainz token at startup + if let Some(token) = &config.listenbrainz_token + && !token.is_empty() + && let Ok(s) = scrobbler.read() + { + match s.validate_listenbrainz_token() { + Ok(Some(user)) => eprintln!("scrobble: listenbrainz token valid (user: {})", user), + Ok(None) => eprintln!("scrobble: listenbrainz token invalid, scrobbling disabled"), + Err(e) => eprintln!("scrobble: listenbrainz validation error: {}", e), + } + } + let scrobble_state = Arc::new(Mutex::new(ScrobbleState::new())); + // Save full config config.save()?; @@ -98,6 +297,10 @@ pub fn start_daemon() -> Result<(), Box> { if audio_engine.poll_track_ended() && let Ok(mut q) = queue.lock() { + // Scrobble the track that just ended + if let Ok(mut s) = scrobble_state.lock() { + try_scrobble_current(&scrobbler, &mut s); + } let original_position = q.position(); let mut started = false; let mut had_next = false; @@ -108,8 +311,14 @@ pub fn start_daemon() -> Result<(), Box> { audio_engine.play(next_track.clone()); if wait_for_start(&audio_engine, &next_track, 500) { started = true; + let track = Track::from_path(next_track.clone()); + // Send now playing and record scrobble start + send_now_playing(&scrobbler, &track); + if let Ok(mut s) = scrobble_state.lock() { + s.start(track.clone()); + } let fmt = Config::load().ok().and_then(|c| c.display_format.clone()); - notification::notify_track(&Track::from_path(next_track), fmt.as_deref()); + notification::notify_track(&track, fmt.as_deref()); break; } } else { @@ -132,8 +341,17 @@ pub fn start_daemon() -> Result<(), Box> { let audio_clone = Arc::clone(&audio_engine); let shutdown_flag_clone = Arc::clone(&shutdown_flag); let queue_clone = Arc::clone(&queue); + let scrobbler_clone = Arc::clone(&scrobbler); + let scrobble_state_clone = Arc::clone(&scrobble_state); thread::spawn(move || { - handle_client(stream, audio_clone, shutdown_flag_clone, queue_clone); + handle_client( + stream, + audio_clone, + shutdown_flag_clone, + queue_clone, + scrobbler_clone, + scrobble_state_clone, + ); }); } Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { @@ -236,11 +454,20 @@ fn handle_client( audio: Arc, shutdown_flag: Arc, queue: Arc>, + scrobbler: Arc>, + scrobble_state: Arc>, ) { let mut buffer = [0; 1024]; if let Ok(size) = stream.read(&mut buffer) { let cmd = String::from_utf8_lossy(&buffer[..size]); - let response = process_command(cmd.trim(), &audio, &shutdown_flag, &queue); + let response = process_command( + cmd.trim(), + &audio, + &shutdown_flag, + &queue, + &scrobbler, + &scrobble_state, + ); let _ = stream.write_all(response.as_bytes()); } } @@ -250,6 +477,8 @@ fn process_command( audio: &AudioEngine, shutdown_flag: &Arc, queue: &Arc>, + scrobbler: &RwLock, + scrobble_state: &Arc>, ) -> String { match cmd { "status" => { @@ -291,6 +520,9 @@ fn process_command( } } "stop" => { + if let Ok(mut s) = scrobble_state.lock() { + try_scrobble_current(scrobbler, &mut s); + } audio.stop(); if let Ok(mut q) = queue.lock() { q.clear(); @@ -299,19 +531,57 @@ fn process_command( "Stopped\n".to_string() } "stop_daemon" => { - // Signal the daemon to shut down shutdown_flag.store(true, Ordering::SeqCst); "Shutting down\n".to_string() } + "reload" | "validate" => { + if let Ok(config) = Config::load() { + let temp_scrobbler = Scrobbler::new( + config.lastfm_api_key.clone(), + config.lastfm_api_secret.clone(), + config.lastfm_session_key.clone(), + config.listenbrainz_token.clone(), + ); + let result = match temp_scrobbler.validate_listenbrainz_token() { + Ok(Some(user)) => { + notification::notify_change(&format!( + "Logged in to ListenBrainz as {}", + user + )); + format!("ListenBrainz token valid (user: {})\n", user) + } + Ok(None) => { + notification::notify_error("Invalid ListenBrainz token"); + "ListenBrainz token invalid\n".to_string() + } + Err(e) => { + notification::notify_error(&format!("ListenBrainz validation: {}", e)); + format!("ListenBrainz validation error: {}\n", e) + } + }; + // Replace shared scrobbler state (only on reload, both is fine) + if let Ok(mut s) = scrobbler.write() { + *s = temp_scrobbler; + } + result + } else { + "Failed to load config\n".to_string() + } + } "next" => { + if let Ok(mut s) = scrobble_state.lock() { + try_scrobble_current(scrobbler, &mut s); + } if let Ok(mut q) = queue.lock() { if let Some(next_track) = q.next() { audio.play(next_track.clone()); + let track = Track::from_path(next_track.clone()); + send_now_playing(scrobbler, &track); + if let Ok(mut s) = scrobble_state.lock() { + s.start(track.clone()); + } let fmt = Config::load().ok().and_then(|c| c.display_format.clone()); - notification::notify_track( - &Track::from_path(next_track.clone()), - fmt.as_deref(), - ); + notification::notify_track(&track, fmt.as_deref()); "Playing next track\n".to_string() } else { audio.stop(); @@ -323,14 +593,19 @@ fn process_command( } } "previous" => { + if let Ok(mut s) = scrobble_state.lock() { + try_scrobble_current(scrobbler, &mut s); + } if let Ok(mut q) = queue.lock() { if let Some(prev_track) = q.previous() { audio.play(prev_track.clone()); + let track = Track::from_path(prev_track.clone()); + send_now_playing(scrobbler, &track); + if let Ok(mut s) = scrobble_state.lock() { + s.start(track.clone()); + } let fmt = Config::load().ok().and_then(|c| c.display_format.clone()); - notification::notify_track( - &Track::from_path(prev_track.clone()), - fmt.as_deref(), - ); + notification::notify_track(&track, fmt.as_deref()); "Playing previous track\n".to_string() } else { "No previous track\n".to_string() @@ -408,6 +683,9 @@ fn process_command( let was_empty = q.is_empty(); match position { "now" => { + if let Ok(mut s) = scrobble_state.lock() { + try_scrobble_current(scrobbler, &mut s); + } q.insert_after_current(path.clone()); if !was_empty { let new_pos = q.position() + 1; @@ -415,6 +693,10 @@ fn process_command( } if let Some(track) = q.track_at(q.position()) { audio.play(track.path.clone()); + send_now_playing(scrobbler, track); + if let Ok(mut s) = scrobble_state.lock() { + s.start(track.clone()); + } let fmt = Config::load().ok().and_then(|c| c.display_format.clone()); notification::notify_track(track, fmt.as_deref()); @@ -432,6 +714,10 @@ fn process_command( if was_empty && !was_playing { let track = Track::from_path(path.clone()); audio.play(path); + send_now_playing(scrobbler, &track); + if let Ok(mut s) = scrobble_state.lock() { + s.start(track.clone()); + } let fmt = Config::load().ok().and_then(|c| c.display_format.clone()); notification::notify_track(&track, fmt.as_deref()); @@ -451,6 +737,9 @@ fn process_command( let was_empty = q.is_empty(); match position { "now" => { + if let Ok(mut s) = scrobble_state.lock() { + try_scrobble_current(scrobbler, &mut s); + } for file in files.iter().rev() { q.insert_after_current(file.clone()); } @@ -460,6 +749,10 @@ fn process_command( } if let Some(track) = q.track_at(q.position()) { audio.play(track.path.clone()); + send_now_playing(scrobbler, track); + if let Ok(mut s) = scrobble_state.lock() { + s.start(track.clone()); + } let fmt = Config::load() .ok() .and_then(|c| c.display_format.clone()); @@ -491,6 +784,10 @@ fn process_command( && let Some(track) = q.current() { audio.play(track.path.clone()); + send_now_playing(scrobbler, track); + if let Ok(mut s) = scrobble_state.lock() { + s.start(track.clone()); + } let fmt = Config::load() .ok() .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 { pub title: Option, pub artist: Option, pub album: Option, + pub duration_secs: Option, } pub fn extract_metadata(path: &Path) -> Option { @@ -28,7 +29,22 @@ pub fn extract_metadata(path: &Path) -> Option { .format(&hint, mss, &Default::default(), &Default::default()) .ok()?; - let mut metadata = AudioMetadata::default(); + // Extract duration from format track's codec parameters + let duration_secs = probed + .format + .tracks() + .iter() + .find(|t| t.codec_params.n_frames.is_some() && t.codec_params.time_base.is_some()) + .and_then(|t| { + let n_frames = t.codec_params.n_frames?; + let tb = t.codec_params.time_base?; + Some(n_frames as f64 * tb.numer as f64 / tb.denom as f64) + }); + + let mut metadata = AudioMetadata { + duration_secs, + ..Default::default() + }; if let Some(meta) = probed.metadata.get() && let Some(rev) = meta.current() @@ -82,6 +98,7 @@ pub struct Track { pub title: Option, pub artist: Option, pub album: Option, + pub duration_secs: Option, } impl Track { @@ -93,6 +110,7 @@ impl Track { title: metadata.and_then(|m| m.title.clone()), artist: metadata.and_then(|m| m.artist.clone()), album: metadata.and_then(|m| m.album.clone()), + duration_secs: metadata.and_then(|m| m.duration_secs), } } 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 @@ -use reqwest::Client; +use md5::{Digest, Md5}; +use reqwest::blocking::Client; +use std::collections::BTreeMap; -#[allow(dead_code)] pub struct Scrobbler { pub lastfm_api_key: Option, pub lastfm_api_secret: Option, @@ -9,7 +10,10 @@ pub struct Scrobbler { client: Client, } -#[allow(dead_code)] +const LASTFM_API_URL: &str = "https://ws.audioscrobbler.com/2.0/"; +const LISTENBRAINZ_VALIDATE_URL: &str = "https://api.listenbrainz.org/1/validate-token"; +const LISTENBRAINZ_LISTENS_URL: &str = "https://api.listenbrainz.org/1/submit-listens"; + impl Scrobbler { pub fn new( lastfm_api_key: Option, @@ -26,50 +30,303 @@ impl Scrobbler { } } + pub fn validate_listenbrainz_token( + &self, + ) -> Result, Box> { + let Some(token) = &self.listenbrainz_token else { + return Ok(None); + }; + if token.is_empty() { + return Ok(None); + } + + let resp = self + .client + .get(LISTENBRAINZ_VALIDATE_URL) + .header("Authorization", format!("Token {}", token)) + .send()?; + + if !resp.status().is_success() { + eprintln!( + "scrobble: listenbrainz token validation failed: {}", + resp.status() + ); + return Ok(None); + } + + let body: serde_json::Value = resp.json()?; + let valid = body.get("valid").and_then(|v| v.as_bool()).unwrap_or(false); + if valid { + let user = body + .get("user_name") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + if let Some(ref u) = user { + eprintln!("scrobble: listenbrainz token valid (user: {})", u); + } else { + eprintln!("scrobble: listenbrainz token valid"); + } + Ok(user) + } else { + eprintln!("scrobble: listenbrainz token invalid — scrobbling will fail"); + Ok(None) + } + } + pub fn now_playing(&self, artist: &str, track: &str, album: Option<&str>) { - if let Some(api_key) = &self.lastfm_api_key { - let _ = self.send_lastfm_now_playing(api_key, artist, track, album); + if let (Some(api_key), Some(secret), Some(session_key)) = ( + &self.lastfm_api_key, + &self.lastfm_api_secret, + &self.lastfm_session_key, + ) && !api_key.is_empty() + && !secret.is_empty() + && !session_key.is_empty() + { + eprintln!("scrobble: now_playing -> lastfm '{} - {}'", artist, track); + let _ = + self.send_lastfm_now_playing(api_key, secret, session_key, artist, track, album); + } else { + eprintln!("scrobble: now_playing -> lastfm skipped (no credentials)"); + } + if let Some(token) = &self.listenbrainz_token + && !token.is_empty() + { + eprintln!( + "scrobble: now_playing -> listenbrainz '{} - {}'", + artist, track + ); + let _ = self.send_listenbrainz_now_playing(token, artist, track, album); + } else { + eprintln!("scrobble: now_playing -> listenbrainz skipped (no token)"); } } pub fn scrobble(&self, artist: &str, track: &str, album: Option<&str>, timestamp: u64) { - if let Some(api_key) = &self.lastfm_api_key { - let _ = self.send_lastfm_scrobble(api_key, artist, track, album, timestamp); + if let (Some(api_key), Some(secret), Some(session_key)) = ( + &self.lastfm_api_key, + &self.lastfm_api_secret, + &self.lastfm_session_key, + ) && !api_key.is_empty() + && !secret.is_empty() + && !session_key.is_empty() + { + eprintln!( + "scrobble: scrobble -> lastfm '{} - {}' (ts={})", + artist, track, timestamp + ); + let _ = self.send_lastfm_scrobble( + api_key, + secret, + session_key, + artist, + track, + album, + timestamp, + ); + } else { + eprintln!("scrobble: scrobble -> lastfm skipped (no credentials)"); } - if let Some(token) = &self.listenbrainz_token { + if let Some(token) = &self.listenbrainz_token + && !token.is_empty() + { + eprintln!( + "scrobble: scrobble -> listenbrainz '{} - {}' (ts={})", + artist, track, timestamp + ); let _ = self.send_listenbrainz_scrobble(token, artist, track, album, timestamp); + } else { + eprintln!("scrobble: scrobble -> listenbrainz skipped (no token)"); } } + fn sign_lastfm(params: &BTreeMap<&str, &str>, secret: &str) -> String { + let mut concat = String::new(); + for (key, value) in params { + concat.push_str(key); + concat.push_str(value); + } + concat.push_str(secret); + let mut hasher = Md5::new(); + hasher.update(concat.as_bytes()); + hex::encode(hasher.finalize()) + } + + #[allow(clippy::too_many_arguments)] fn send_lastfm_now_playing( &self, - _api_key: &str, - _artist: &str, - _track: &str, - _album: Option<&str>, + api_key: &str, + secret: &str, + session_key: &str, + artist: &str, + track: &str, + album: Option<&str>, ) -> Result<(), Box> { + let mut params = BTreeMap::from([ + ("method", "track.updateNowPlaying"), + ("artist", artist), + ("track", track), + ("api_key", api_key), + ("sk", session_key), + ]); + if let Some(album) = album { + params.insert("album", album); + } + let api_sig = Self::sign_lastfm(¶ms, secret); + params.insert("api_sig", &api_sig); + params.insert("format", "json"); + + let resp = self.client.post(LASTFM_API_URL).form(¶ms).send()?; + if resp.status().is_success() { + eprintln!("scrobble: lastfm now_playing ok"); + } else { + eprintln!( + "scrobble: lastfm now_playing error: {} {}", + resp.status(), + resp.text().unwrap_or_default() + ); + } Ok(()) } + #[allow(clippy::too_many_arguments)] fn send_lastfm_scrobble( &self, - _api_key: &str, - _artist: &str, - _track: &str, - _album: Option<&str>, - _timestamp: u64, + api_key: &str, + secret: &str, + session_key: &str, + artist: &str, + track: &str, + album: Option<&str>, + timestamp: u64, ) -> Result<(), Box> { + let ts_str = timestamp.to_string(); + let mut params = BTreeMap::from([ + ("method", "track.scrobble"), + ("artist", artist), + ("track", track), + ("timestamp", &ts_str), + ("api_key", api_key), + ("sk", session_key), + ]); + if let Some(album) = album { + params.insert("album", album); + } + let api_sig = Self::sign_lastfm(¶ms, secret); + params.insert("api_sig", &api_sig); + params.insert("format", "json"); + + let resp = self.client.post(LASTFM_API_URL).form(¶ms).send()?; + if resp.status().is_success() { + eprintln!("scrobble: lastfm scrobble ok"); + } else { + eprintln!( + "scrobble: lastfm scrobble error: {} {}", + resp.status(), + resp.text().unwrap_or_default() + ); + } + Ok(()) + } + + fn send_listenbrainz_now_playing( + &self, + token: &str, + artist: &str, + track: &str, + album: Option<&str>, + ) -> Result<(), Box> { + let mut track_metadata = serde_json::json!({ + "artist_name": artist, + "track_name": track, + }); + if let Some(album) = album { + track_metadata["release_name"] = serde_json::json!(album); + } + + let payload = serde_json::json!({ + "listen_type": "playing_now", + "payload": [{ + "track_metadata": track_metadata + }] + }); + + let resp = self + .client + .post(LISTENBRAINZ_LISTENS_URL) + .header("Authorization", format!("Token {}", token)) + .json(&payload) + .send()?; + if resp.status().is_success() { + eprintln!("scrobble: listenbrainz now_playing ok"); + } else { + eprintln!( + "scrobble: listenbrainz now_playing error: {} {}", + resp.status(), + resp.text().unwrap_or_default() + ); + } Ok(()) } fn send_listenbrainz_scrobble( &self, - _token: &str, - _artist: &str, - _track: &str, - _album: Option<&str>, - _timestamp: u64, + token: &str, + artist: &str, + track: &str, + album: Option<&str>, + timestamp: u64, ) -> Result<(), Box> { + let mut track_metadata = serde_json::json!({ + "artist_name": artist, + "track_name": track, + }); + if let Some(album) = album { + track_metadata["release_name"] = serde_json::json!(album); + } + + let payload = serde_json::json!({ + "listen_type": "single", + "payload": [{ + "listened_at": timestamp, + "track_metadata": track_metadata + }] + }); + + let resp = self + .client + .post(LISTENBRAINZ_LISTENS_URL) + .header("Authorization", format!("Token {}", token)) + .json(&payload) + .send()?; + if resp.status().is_success() { + eprintln!("scrobble: listenbrainz scrobble ok"); + } else { + eprintln!( + "scrobble: listenbrainz scrobble error: {} {}", + resp.status(), + resp.text().unwrap_or_default() + ); + } Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sign_lastfm() { + let params = BTreeMap::from([ + ("api_key", "abc123"), + ("method", "track.scrobble"), + ("sk", "session_key"), + ("track", "Test Track"), + ("artist", "Test Artist"), + ("timestamp", "1234567890"), + ]); + let sig = Scrobbler::sign_lastfm(¶ms, "sekrit"); + assert_eq!(sig.len(), 32); + assert!(sig.chars().all(|c| c.is_ascii_hexdigit())); + } +} -- cgit v1.2.3