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/daemon.rs | 325 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 311 insertions(+), 14 deletions(-) (limited to 'src/daemon.rs') 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()); -- cgit v1.2.3