/* * This file is a part of RSPlayer * Licensed under the GPL-3.0 License * Copyright (C) 2026 Salmonik @ salmonik@tuta.io * https://codeberg.org/salmonik/RSPlayer */ use md5::{Digest, Md5}; use reqwest::blocking::Client; use std::collections::BTreeMap; use std::time::Duration; pub struct Scrobbler { pub lastfm_api_key: Option, pub lastfm_api_secret: Option, pub lastfm_session_key: Option, pub listenbrainz_token: Option, client: Client, } 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, lastfm_api_secret: Option, lastfm_session_key: Option, listenbrainz_token: Option, ) -> Self { Scrobbler { lastfm_api_key, lastfm_api_secret, lastfm_session_key, listenbrainz_token, client: Client::builder() .connect_timeout(Duration::from_secs(10)) .timeout(Duration::from_secs(30)) .build() .expect("valid reqwest Client"), } } 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() { crate::debug_eprintln(format_args!( "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 { crate::debug_eprintln(format_args!( "scrobble: listenbrainz token valid (user: {})", u )); } else { crate::debug_eprintln(format_args!("scrobble: listenbrainz token valid")); } Ok(user) } else { crate::debug_eprintln(format_args!( "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), 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() { crate::debug_eprintln(format_args!( "scrobble: now_playing -> lastfm '{} - {}'", artist, track )); let _ = self.send_lastfm_now_playing(api_key, secret, session_key, artist, track, album); } else { crate::debug_eprintln(format_args!( "scrobble: now_playing -> lastfm skipped (no credentials)" )); } if let Some(token) = &self.listenbrainz_token && !token.is_empty() { crate::debug_eprintln(format_args!( "scrobble: now_playing -> listenbrainz '{} - {}'", artist, track )); let _ = self.send_listenbrainz_now_playing(token, artist, track, album); } else { crate::debug_eprintln(format_args!( "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), 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() { crate::debug_eprintln(format_args!( "scrobble: scrobble -> lastfm '{} - {}' (ts={})", artist, track, timestamp )); let _ = self.send_lastfm_scrobble( api_key, secret, session_key, artist, track, album, timestamp, ); } else { crate::debug_eprintln(format_args!( "scrobble: scrobble -> lastfm skipped (no credentials)" )); } if let Some(token) = &self.listenbrainz_token && !token.is_empty() { crate::debug_eprintln(format_args!( "scrobble: scrobble -> listenbrainz '{} - {}' (ts={})", artist, track, timestamp )); let _ = self.send_listenbrainz_scrobble(token, artist, track, album, timestamp); } else { crate::debug_eprintln(format_args!( "scrobble: scrobble -> listenbrainz skipped (no token)" )); } } pub 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, 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() { crate::debug_eprintln(format_args!("scrobble: lastfm now_playing ok")); } else { crate::debug_eprintln(format_args!( "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, 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() { crate::debug_eprintln(format_args!("scrobble: lastfm scrobble ok")); } else { crate::debug_eprintln(format_args!( "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() { crate::debug_eprintln(format_args!("scrobble: listenbrainz now_playing ok")); } else { crate::debug_eprintln(format_args!( "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, ) -> 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() { crate::debug_eprintln(format_args!("scrobble: listenbrainz scrobble ok")); } else { crate::debug_eprintln(format_args!( "scrobble: listenbrainz scrobble error: {} {}", resp.status(), resp.text().unwrap_or_default() )); } Ok(()) } }