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/scrobbler.rs | |
| 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 '')
| -rw-r--r-- | src/scrobbler.rs | 301 |
1 files changed, 279 insertions, 22 deletions
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 | } | ||
