aboutsummaryrefslogtreecommitdiffstats
path: root/src
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
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 '')
-rw-r--r--src/cli.rs8
-rw-r--r--src/daemon.rs325
-rw-r--r--src/queue.rs20
-rw-r--r--src/scrobbler.rs301
4 files changed, 617 insertions, 37 deletions
diff --git a/src/cli.rs b/src/cli.rs
index 7d56de6..f9a3c6f 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -105,6 +105,8 @@ pub enum SettingsCommands {
105 #[arg(value_name = "KEY")] 105 #[arg(value_name = "KEY")]
106 key: String, 106 key: String,
107 }, 107 },
108 /// Reload config from disk (re-reads config file, re-validates scrobbling tokens)
109 Reload,
108} 110}
109 111
110impl Cli { 112impl Cli {
@@ -199,6 +201,9 @@ impl Cli {
199 SettingsCommands::Set { key, value } => { 201 SettingsCommands::Set { key, value } => {
200 config.set(key, value)?; 202 config.set(key, value)?;
201 config.save()?; 203 config.save()?;
204 if key == "listenbrainz_token" {
205 let _ = crate::daemon::send_command("validate".to_string());
206 }
202 } 207 }
203 SettingsCommands::List => { 208 SettingsCommands::List => {
204 config.list(); 209 config.list();
@@ -207,6 +212,9 @@ impl Cli {
207 config.unset(key)?; 212 config.unset(key)?;
208 config.save()?; 213 config.save()?;
209 } 214 }
215 SettingsCommands::Reload => {
216 let _ = crate::daemon::send_command("reload".to_string());
217 }
210 } 218 }
211 } 219 }
212 Commands::Completions { shell } => { 220 Commands::Completions { shell } => {
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;
2use crate::config::Config; 2use crate::config::Config;
3use crate::notification; 3use crate::notification;
4use crate::queue::{Queue, ShuffleMode, Track}; 4use crate::queue::{Queue, ShuffleMode, Track};
5use crate::scrobbler::Scrobbler;
5use std::io::{Read, Write}; 6use std::io::{Read, Write};
6use std::os::unix::net::{UnixListener, UnixStream}; 7use std::os::unix::net::{UnixListener, UnixStream};
7use std::path::{Path, PathBuf}; 8use std::path::{Path, PathBuf};
8use std::sync::atomic::{AtomicBool, Ordering}; 9use std::sync::atomic::{AtomicBool, Ordering};
9use std::sync::{Arc, Mutex}; 10use std::sync::{Arc, Mutex, RwLock};
10use std::thread; 11use std::thread;
11use std::time::Duration; 12use std::time::{Duration, Instant, SystemTime};
13
14struct ScrobbleState {
15 track: Option<Track>,
16 start_time: Option<Instant>,
17 timestamp: Option<u64>,
18}
19
20impl ScrobbleState {
21 fn new() -> Self {
22 ScrobbleState {
23 track: None,
24 start_time: None,
25 timestamp: None,
26 }
27 }
28
29 fn start(&mut self, track: Track) {
30 let ts = SystemTime::now()
31 .duration_since(SystemTime::UNIX_EPOCH)
32 .unwrap_or_default()
33 .as_secs();
34 let title = track.title.clone().unwrap_or_default();
35 let artist = track.artist.clone().unwrap_or_default();
36 eprintln!(
37 "scrobble: tracking start for '{} - {}' (ts={})",
38 artist, title, ts
39 );
40 self.track = Some(track);
41 self.start_time = Some(Instant::now());
42 self.timestamp = Some(ts);
43 }
44
45 fn should_scrobble(&self) -> bool {
46 let (Some(track), Some(start)) = (&self.track, self.start_time) else {
47 eprintln!("scrobble: should_scrobble false — no track or start_time");
48 return false;
49 };
50 let elapsed = start.elapsed().as_secs();
51 let result = Self::should_scrobble_with_params(elapsed, track.duration_secs);
52 let threshold = track
53 .duration_secs
54 .map(|d| (d / 2.0) as u64)
55 .unwrap_or(u64::MAX)
56 .min(240);
57 eprintln!(
58 "scrobble: should_scrobble {} (elapsed={}s threshold={}s duration={:?})",
59 result, elapsed, threshold, track.duration_secs
60 );
61 result
62 }
63
64 fn should_scrobble_with_params(elapsed: u64, duration_secs: Option<f64>) -> bool {
65 if elapsed < 30 {
66 return false;
67 }
68 let threshold = duration_secs
69 .map(|d| (d / 2.0) as u64)
70 .unwrap_or(u64::MAX)
71 .min(240);
72 elapsed >= threshold
73 }
74
75 fn clear(&mut self) {
76 self.track = None;
77 self.start_time = None;
78 self.timestamp = None;
79 }
80}
81
82#[cfg(test)]
83mod tests {
84 use super::*;
85
86 #[test]
87 fn test_scrobble_state_new() {
88 let s = ScrobbleState::new();
89 assert!(s.track.is_none());
90 assert!(s.start_time.is_none());
91 assert!(s.timestamp.is_none());
92 }
93
94 #[test]
95 fn test_scrobble_state_clear() {
96 let mut s = ScrobbleState::new();
97 let t = Track {
98 path: PathBuf::from("/test/song.mp3"),
99 title: Some("Test".into()),
100 artist: Some("Artist".into()),
101 album: None,
102 duration_secs: Some(200.0),
103 };
104 s.start(t);
105 assert!(s.track.is_some());
106 s.clear();
107 assert!(s.track.is_none());
108 assert!(s.start_time.is_none());
109 assert!(s.timestamp.is_none());
110 }
111
112 #[test]
113 fn test_should_scrobble_without_track_returns_false() {
114 assert!(!ScrobbleState::new().should_scrobble());
115 }
116
117 #[test]
118 fn test_should_scrobble_threshold_below_30_seconds() {
119 assert!(!ScrobbleState::should_scrobble_with_params(29, Some(200.0)));
120 }
121
122 #[test]
123 fn test_should_scrobble_threshold_half_duration() {
124 assert!(ScrobbleState::should_scrobble_with_params(100, Some(200.0)));
125 }
126
127 #[test]
128 fn test_should_scrobble_threshold_below_half_duration() {
129 assert!(!ScrobbleState::should_scrobble_with_params(80, Some(200.0)));
130 }
131
132 #[test]
133 fn test_should_scrobble_threshold_capped_at_4min() {
134 assert!(ScrobbleState::should_scrobble_with_params(240, Some(600.0)));
135 assert!(!ScrobbleState::should_scrobble_with_params(
136 239,
137 Some(600.0)
138 ));
139 }
140
141 #[test]
142 fn test_should_scrobble_no_duration_requires_4min() {
143 assert!(ScrobbleState::should_scrobble_with_params(240, None));
144 assert!(!ScrobbleState::should_scrobble_with_params(239, None));
145 }
146
147 #[test]
148 fn test_should_scrobble_short_song() {
149 assert!(ScrobbleState::should_scrobble_with_params(30, Some(60.0)));
150 assert!(!ScrobbleState::should_scrobble_with_params(15, Some(60.0)));
151 }
152}
153
154fn try_scrobble_current(scrobbler: &RwLock<Scrobbler>, state: &mut ScrobbleState) {
155 if state.should_scrobble()
156 && let (Some(track), Some(ts)) = (&state.track, state.timestamp)
157 {
158 let artist = track.artist.as_deref().unwrap_or("(unknown artist)");
159 let title = track.title.as_deref().unwrap_or("(unknown title)");
160 let album = track.album.as_deref();
161 eprintln!(
162 "scrobble: sending scrobble for '{} - {}' at ts={}",
163 artist, title, ts
164 );
165 if let Ok(s) = scrobbler.read() {
166 s.scrobble(artist, title, album, ts);
167 }
168 } else {
169 let title = state
170 .track
171 .as_ref()
172 .and_then(|t| t.title.as_deref())
173 .unwrap_or("(no track)");
174 eprintln!(
175 "scrobble: skipping scrobble for '{}' — not enough play time",
176 title
177 );
178 }
179 state.clear();
180}
181
182fn send_now_playing(scrobbler: &RwLock<Scrobbler>, track: &Track) {
183 let artist = track.artist.as_deref().unwrap_or("(unknown artist)");
184 let title = track.title.as_deref().unwrap_or("(unknown title)");
185 let album = track.album.as_deref();
186 eprintln!("scrobble: sending now_playing for '{} - {}'", artist, title);
187 if let Ok(s) = scrobbler.read() {
188 s.now_playing(artist, title, album);
189 }
190}
12 191
13pub fn start_daemon() -> Result<(), Box<dyn std::error::Error>> { 192pub fn start_daemon() -> Result<(), Box<dyn std::error::Error>> {
14 let config = Config::load()?; 193 let config = Config::load()?;
@@ -68,6 +247,26 @@ pub fn start_daemon() -> Result<(), Box<dyn std::error::Error>> {
68 } 247 }
69 } 248 }
70 249
250 // Create scrobbler from config
251 let scrobbler = Arc::new(RwLock::new(Scrobbler::new(
252 config.lastfm_api_key.clone(),
253 config.lastfm_api_secret.clone(),
254 config.lastfm_session_key.clone(),
255 config.listenbrainz_token.clone(),
256 )));
257 // Validate ListenBrainz token at startup
258 if let Some(token) = &config.listenbrainz_token
259 && !token.is_empty()
260 && let Ok(s) = scrobbler.read()
261 {
262 match s.validate_listenbrainz_token() {
263 Ok(Some(user)) => eprintln!("scrobble: listenbrainz token valid (user: {})", user),
264 Ok(None) => eprintln!("scrobble: listenbrainz token invalid, scrobbling disabled"),
265 Err(e) => eprintln!("scrobble: listenbrainz validation error: {}", e),
266 }
267 }
268 let scrobble_state = Arc::new(Mutex::new(ScrobbleState::new()));
269
71 // Save full config 270 // Save full config
72 config.save()?; 271 config.save()?;
73 272
@@ -98,6 +297,10 @@ pub fn start_daemon() -> Result<(), Box<dyn std::error::Error>> {
98 if audio_engine.poll_track_ended() 297 if audio_engine.poll_track_ended()
99 && let Ok(mut q) = queue.lock() 298 && let Ok(mut q) = queue.lock()
100 { 299 {
300 // Scrobble the track that just ended
301 if let Ok(mut s) = scrobble_state.lock() {
302 try_scrobble_current(&scrobbler, &mut s);
303 }
101 let original_position = q.position(); 304 let original_position = q.position();
102 let mut started = false; 305 let mut started = false;
103 let mut had_next = false; 306 let mut had_next = false;
@@ -108,8 +311,14 @@ pub fn start_daemon() -> Result<(), Box<dyn std::error::Error>> {
108 audio_engine.play(next_track.clone()); 311 audio_engine.play(next_track.clone());
109 if wait_for_start(&audio_engine, &next_track, 500) { 312 if wait_for_start(&audio_engine, &next_track, 500) {
110 started = true; 313 started = true;
314 let track = Track::from_path(next_track.clone());
315 // Send now playing and record scrobble start
316 send_now_playing(&scrobbler, &track);
317 if let Ok(mut s) = scrobble_state.lock() {
318 s.start(track.clone());
319 }
111 let fmt = Config::load().ok().and_then(|c| c.display_format.clone()); 320 let fmt = Config::load().ok().and_then(|c| c.display_format.clone());
112 notification::notify_track(&Track::from_path(next_track), fmt.as_deref()); 321 notification::notify_track(&track, fmt.as_deref());
113 break; 322 break;
114 } 323 }
115 } else { 324 } else {
@@ -132,8 +341,17 @@ pub fn start_daemon() -> Result<(), Box<dyn std::error::Error>> {
132 let audio_clone = Arc::clone(&audio_engine); 341 let audio_clone = Arc::clone(&audio_engine);
133 let shutdown_flag_clone = Arc::clone(&shutdown_flag); 342 let shutdown_flag_clone = Arc::clone(&shutdown_flag);
134 let queue_clone = Arc::clone(&queue); 343 let queue_clone = Arc::clone(&queue);
344 let scrobbler_clone = Arc::clone(&scrobbler);
345 let scrobble_state_clone = Arc::clone(&scrobble_state);
135 thread::spawn(move || { 346 thread::spawn(move || {
136 handle_client(stream, audio_clone, shutdown_flag_clone, queue_clone); 347 handle_client(
348 stream,
349 audio_clone,
350 shutdown_flag_clone,
351 queue_clone,
352 scrobbler_clone,
353 scrobble_state_clone,
354 );
137 }); 355 });
138 } 356 }
139 Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { 357 Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
@@ -236,11 +454,20 @@ fn handle_client(
236 audio: Arc<AudioEngine>, 454 audio: Arc<AudioEngine>,
237 shutdown_flag: Arc<AtomicBool>, 455 shutdown_flag: Arc<AtomicBool>,
238 queue: Arc<Mutex<Queue>>, 456 queue: Arc<Mutex<Queue>>,
457 scrobbler: Arc<RwLock<Scrobbler>>,
458 scrobble_state: Arc<Mutex<ScrobbleState>>,
239) { 459) {
240 let mut buffer = [0; 1024]; 460 let mut buffer = [0; 1024];
241 if let Ok(size) = stream.read(&mut buffer) { 461 if let Ok(size) = stream.read(&mut buffer) {
242 let cmd = String::from_utf8_lossy(&buffer[..size]); 462 let cmd = String::from_utf8_lossy(&buffer[..size]);
243 let response = process_command(cmd.trim(), &audio, &shutdown_flag, &queue); 463 let response = process_command(
464 cmd.trim(),
465 &audio,
466 &shutdown_flag,
467 &queue,
468 &scrobbler,
469 &scrobble_state,
470 );
244 let _ = stream.write_all(response.as_bytes()); 471 let _ = stream.write_all(response.as_bytes());
245 } 472 }
246} 473}
@@ -250,6 +477,8 @@ fn process_command(
250 audio: &AudioEngine, 477 audio: &AudioEngine,
251 shutdown_flag: &Arc<AtomicBool>, 478 shutdown_flag: &Arc<AtomicBool>,
252 queue: &Arc<Mutex<Queue>>, 479 queue: &Arc<Mutex<Queue>>,
480 scrobbler: &RwLock<Scrobbler>,
481 scrobble_state: &Arc<Mutex<ScrobbleState>>,
253) -> String { 482) -> String {
254 match cmd { 483 match cmd {
255 "status" => { 484 "status" => {
@@ -291,6 +520,9 @@ fn process_command(
291 } 520 }
292 } 521 }
293 "stop" => { 522 "stop" => {
523 if let Ok(mut s) = scrobble_state.lock() {
524 try_scrobble_current(scrobbler, &mut s);
525 }
294 audio.stop(); 526 audio.stop();
295 if let Ok(mut q) = queue.lock() { 527 if let Ok(mut q) = queue.lock() {
296 q.clear(); 528 q.clear();
@@ -299,19 +531,57 @@ fn process_command(
299 "Stopped\n".to_string() 531 "Stopped\n".to_string()
300 } 532 }
301 "stop_daemon" => { 533 "stop_daemon" => {
302 // Signal the daemon to shut down
303 shutdown_flag.store(true, Ordering::SeqCst); 534 shutdown_flag.store(true, Ordering::SeqCst);
304 "Shutting down\n".to_string() 535 "Shutting down\n".to_string()
305 } 536 }
537 "reload" | "validate" => {
538 if let Ok(config) = Config::load() {
539 let temp_scrobbler = Scrobbler::new(
540 config.lastfm_api_key.clone(),
541 config.lastfm_api_secret.clone(),
542 config.lastfm_session_key.clone(),
543 config.listenbrainz_token.clone(),
544 );
545 let result = match temp_scrobbler.validate_listenbrainz_token() {
546 Ok(Some(user)) => {
547 notification::notify_change(&format!(
548 "Logged in to ListenBrainz as {}",
549 user
550 ));
551 format!("ListenBrainz token valid (user: {})\n", user)
552 }
553 Ok(None) => {
554 notification::notify_error("Invalid ListenBrainz token");
555 "ListenBrainz token invalid\n".to_string()
556 }
557 Err(e) => {
558 notification::notify_error(&format!("ListenBrainz validation: {}", e));
559 format!("ListenBrainz validation error: {}\n", e)
560 }
561 };
562 // Replace shared scrobbler state (only on reload, both is fine)
563 if let Ok(mut s) = scrobbler.write() {
564 *s = temp_scrobbler;
565 }
566 result
567 } else {
568 "Failed to load config\n".to_string()
569 }
570 }
306 "next" => { 571 "next" => {
572 if let Ok(mut s) = scrobble_state.lock() {
573 try_scrobble_current(scrobbler, &mut s);
574 }
307 if let Ok(mut q) = queue.lock() { 575 if let Ok(mut q) = queue.lock() {
308 if let Some(next_track) = q.next() { 576 if let Some(next_track) = q.next() {
309 audio.play(next_track.clone()); 577 audio.play(next_track.clone());
578 let track = Track::from_path(next_track.clone());
579 send_now_playing(scrobbler, &track);
580 if let Ok(mut s) = scrobble_state.lock() {
581 s.start(track.clone());
582 }
310 let fmt = Config::load().ok().and_then(|c| c.display_format.clone()); 583 let fmt = Config::load().ok().and_then(|c| c.display_format.clone());
311 notification::notify_track( 584 notification::notify_track(&track, fmt.as_deref());
312 &Track::from_path(next_track.clone()),
313 fmt.as_deref(),
314 );
315 "Playing next track\n".to_string() 585 "Playing next track\n".to_string()
316 } else { 586 } else {
317 audio.stop(); 587 audio.stop();
@@ -323,14 +593,19 @@ fn process_command(
323 } 593 }
324 } 594 }
325 "previous" => { 595 "previous" => {
596 if let Ok(mut s) = scrobble_state.lock() {
597 try_scrobble_current(scrobbler, &mut s);
598 }
326 if let Ok(mut q) = queue.lock() { 599 if let Ok(mut q) = queue.lock() {
327 if let Some(prev_track) = q.previous() { 600 if let Some(prev_track) = q.previous() {
328 audio.play(prev_track.clone()); 601 audio.play(prev_track.clone());
602 let track = Track::from_path(prev_track.clone());
603 send_now_playing(scrobbler, &track);
604 if let Ok(mut s) = scrobble_state.lock() {
605 s.start(track.clone());
606 }
329 let fmt = Config::load().ok().and_then(|c| c.display_format.clone()); 607 let fmt = Config::load().ok().and_then(|c| c.display_format.clone());
330 notification::notify_track( 608 notification::notify_track(&track, fmt.as_deref());
331 &Track::from_path(prev_track.clone()),
332 fmt.as_deref(),
333 );
334 "Playing previous track\n".to_string() 609 "Playing previous track\n".to_string()
335 } else { 610 } else {
336 "No previous track\n".to_string() 611 "No previous track\n".to_string()
@@ -408,6 +683,9 @@ fn process_command(
408 let was_empty = q.is_empty(); 683 let was_empty = q.is_empty();
409 match position { 684 match position {
410 "now" => { 685 "now" => {
686 if let Ok(mut s) = scrobble_state.lock() {
687 try_scrobble_current(scrobbler, &mut s);
688 }
411 q.insert_after_current(path.clone()); 689 q.insert_after_current(path.clone());
412 if !was_empty { 690 if !was_empty {
413 let new_pos = q.position() + 1; 691 let new_pos = q.position() + 1;
@@ -415,6 +693,10 @@ fn process_command(
415 } 693 }
416 if let Some(track) = q.track_at(q.position()) { 694 if let Some(track) = q.track_at(q.position()) {
417 audio.play(track.path.clone()); 695 audio.play(track.path.clone());
696 send_now_playing(scrobbler, track);
697 if let Ok(mut s) = scrobble_state.lock() {
698 s.start(track.clone());
699 }
418 let fmt = 700 let fmt =
419 Config::load().ok().and_then(|c| c.display_format.clone()); 701 Config::load().ok().and_then(|c| c.display_format.clone());
420 notification::notify_track(track, fmt.as_deref()); 702 notification::notify_track(track, fmt.as_deref());
@@ -432,6 +714,10 @@ fn process_command(
432 if was_empty && !was_playing { 714 if was_empty && !was_playing {
433 let track = Track::from_path(path.clone()); 715 let track = Track::from_path(path.clone());
434 audio.play(path); 716 audio.play(path);
717 send_now_playing(scrobbler, &track);
718 if let Ok(mut s) = scrobble_state.lock() {
719 s.start(track.clone());
720 }
435 let fmt = 721 let fmt =
436 Config::load().ok().and_then(|c| c.display_format.clone()); 722 Config::load().ok().and_then(|c| c.display_format.clone());
437 notification::notify_track(&track, fmt.as_deref()); 723 notification::notify_track(&track, fmt.as_deref());
@@ -451,6 +737,9 @@ fn process_command(
451 let was_empty = q.is_empty(); 737 let was_empty = q.is_empty();
452 match position { 738 match position {
453 "now" => { 739 "now" => {
740 if let Ok(mut s) = scrobble_state.lock() {
741 try_scrobble_current(scrobbler, &mut s);
742 }
454 for file in files.iter().rev() { 743 for file in files.iter().rev() {
455 q.insert_after_current(file.clone()); 744 q.insert_after_current(file.clone());
456 } 745 }
@@ -460,6 +749,10 @@ fn process_command(
460 } 749 }
461 if let Some(track) = q.track_at(q.position()) { 750 if let Some(track) = q.track_at(q.position()) {
462 audio.play(track.path.clone()); 751 audio.play(track.path.clone());
752 send_now_playing(scrobbler, track);
753 if let Ok(mut s) = scrobble_state.lock() {
754 s.start(track.clone());
755 }
463 let fmt = Config::load() 756 let fmt = Config::load()
464 .ok() 757 .ok()
465 .and_then(|c| c.display_format.clone()); 758 .and_then(|c| c.display_format.clone());
@@ -491,6 +784,10 @@ fn process_command(
491 && let Some(track) = q.current() 784 && let Some(track) = q.current()
492 { 785 {
493 audio.play(track.path.clone()); 786 audio.play(track.path.clone());
787 send_now_playing(scrobbler, track);
788 if let Ok(mut s) = scrobble_state.lock() {
789 s.start(track.clone());
790 }
494 let fmt = Config::load() 791 let fmt = Config::load()
495 .ok() 792 .ok()
496 .and_then(|c| c.display_format.clone()); 793 .and_then(|c| c.display_format.clone());
diff --git a/src/queue.rs b/src/queue.rs
index d42203f..8f98173 100644
--- a/src/queue.rs
+++ b/src/queue.rs
@@ -13,6 +13,7 @@ pub struct AudioMetadata {
13 pub title: Option<String>, 13 pub title: Option<String>,
14 pub artist: Option<String>, 14 pub artist: Option<String>,
15 pub album: Option<String>, 15 pub album: Option<String>,
16 pub duration_secs: Option<f64>,
16} 17}
17 18
18pub fn extract_metadata(path: &Path) -> Option<AudioMetadata> { 19pub fn extract_metadata(path: &Path) -> Option<AudioMetadata> {
@@ -28,7 +29,22 @@ pub fn extract_metadata(path: &Path) -> Option<AudioMetadata> {
28 .format(&hint, mss, &Default::default(), &Default::default()) 29 .format(&hint, mss, &Default::default(), &Default::default())
29 .ok()?; 30 .ok()?;
30 31
31 let mut metadata = AudioMetadata::default(); 32 // Extract duration from format track's codec parameters
33 let duration_secs = probed
34 .format
35 .tracks()
36 .iter()
37 .find(|t| t.codec_params.n_frames.is_some() && t.codec_params.time_base.is_some())
38 .and_then(|t| {
39 let n_frames = t.codec_params.n_frames?;
40 let tb = t.codec_params.time_base?;
41 Some(n_frames as f64 * tb.numer as f64 / tb.denom as f64)
42 });
43
44 let mut metadata = AudioMetadata {
45 duration_secs,
46 ..Default::default()
47 };
32 48
33 if let Some(meta) = probed.metadata.get() 49 if let Some(meta) = probed.metadata.get()
34 && let Some(rev) = meta.current() 50 && let Some(rev) = meta.current()
@@ -82,6 +98,7 @@ pub struct Track {
82 pub title: Option<String>, 98 pub title: Option<String>,
83 pub artist: Option<String>, 99 pub artist: Option<String>,
84 pub album: Option<String>, 100 pub album: Option<String>,
101 pub duration_secs: Option<f64>,
85} 102}
86 103
87impl Track { 104impl Track {
@@ -93,6 +110,7 @@ impl Track {
93 title: metadata.and_then(|m| m.title.clone()), 110 title: metadata.and_then(|m| m.title.clone()),
94 artist: metadata.and_then(|m| m.artist.clone()), 111 artist: metadata.and_then(|m| m.artist.clone()),
95 album: metadata.and_then(|m| m.album.clone()), 112 album: metadata.and_then(|m| m.album.clone()),
113 duration_secs: metadata.and_then(|m| m.duration_secs),
96 } 114 }
97 } 115 }
98 116
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}