aboutsummaryrefslogtreecommitdiffstats
path: root/src/daemon.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/daemon.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 '')
-rw-r--r--src/daemon.rs325
1 files changed, 311 insertions, 14 deletions
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());