aboutsummaryrefslogtreecommitdiffstats
path: root/src/scrobbler.rs
diff options
context:
space:
mode:
authorsalmonik <raisersoft@proton.me>2026-05-16 11:43:32 +0200
committersalmonik <raisersoft@proton.me>2026-05-16 11:43:32 +0200
commit04add15691578316ff3da5b5a07d05b314488370 (patch)
tree458b1066001697faa75466d51c7dfd9008ccac16 /src/scrobbler.rs
parentaesthetic changes (diff)
downloadrsplayer-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 'src/scrobbler.rs')
-rw-r--r--src/scrobbler.rs301
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 @@
1use reqwest::Client; 1use md5::{Digest, Md5};
2use reqwest::blocking::Client;
3use std::collections::BTreeMap;
2 4
3#[allow(dead_code)]
4pub struct Scrobbler { 5pub 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)] 13const LASTFM_API_URL: &str = "https://ws.audioscrobbler.com/2.0/";
14const LISTENBRAINZ_VALIDATE_URL: &str = "https://api.listenbrainz.org/1/validate-token";
15const LISTENBRAINZ_LISTENS_URL: &str = "https://api.listenbrainz.org/1/submit-listens";
16
13impl Scrobbler { 17impl 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(&params, secret);
175 params.insert("api_sig", &api_sig);
176 params.insert("format", "json");
177
178 let resp = self.client.post(LASTFM_API_URL).form(&params).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(&params, secret);
215 params.insert("api_sig", &api_sig);
216 params.insert("format", "json");
217
218 let resp = self.client.post(LASTFM_API_URL).form(&params).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)]
315mod 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(&params, "sekrit");
329 assert_eq!(sig.len(), 32);
330 assert!(sig.chars().all(|c| c.is_ascii_hexdigit()));
331 }
332}