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/scrobbler.rs | 301 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 279 insertions(+), 22 deletions(-) (limited to 'src/scrobbler.rs') 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