use crate::audio::AudioEngine; use crate::config::Config; use crate::notification; use crate::queue::{Queue, ShuffleMode, Track}; use crate::scrobbler::Scrobbler; use std::io::{Read, Write}; use std::os::unix::net::{UnixListener, UnixStream}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, RwLock}; use std::thread; use std::time::{Duration, Instant, SystemTime}; struct ScrobbleState { track: Option, start_time: Option, timestamp: Option, } impl ScrobbleState { fn new() -> Self { ScrobbleState { track: None, start_time: None, timestamp: None, } } fn start(&mut self, track: Track) { let ts = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .unwrap_or_default() .as_secs(); let title = track.title.clone().unwrap_or_default(); let artist = track.artist.clone().unwrap_or_default(); eprintln!( "scrobble: tracking start for '{} - {}' (ts={})", artist, title, ts ); self.track = Some(track); self.start_time = Some(Instant::now()); self.timestamp = Some(ts); } fn should_scrobble(&self) -> bool { let (Some(track), Some(start)) = (&self.track, self.start_time) else { eprintln!("scrobble: should_scrobble false — no track or start_time"); return false; }; let elapsed = start.elapsed().as_secs(); let result = Self::should_scrobble_with_params(elapsed, track.duration_secs); let threshold = track .duration_secs .map(|d| (d / 2.0) as u64) .unwrap_or(u64::MAX) .min(240); eprintln!( "scrobble: should_scrobble {} (elapsed={}s threshold={}s duration={:?})", result, elapsed, threshold, track.duration_secs ); result } fn should_scrobble_with_params(elapsed: u64, duration_secs: Option) -> bool { if elapsed < 30 { return false; } let threshold = duration_secs .map(|d| (d / 2.0) as u64) .unwrap_or(u64::MAX) .min(240); elapsed >= threshold } fn clear(&mut self) { self.track = None; self.start_time = None; self.timestamp = None; } } #[cfg(test)] mod tests { use super::*; #[test] fn test_scrobble_state_new() { let s = ScrobbleState::new(); assert!(s.track.is_none()); assert!(s.start_time.is_none()); assert!(s.timestamp.is_none()); } #[test] fn test_scrobble_state_clear() { let mut s = ScrobbleState::new(); let t = Track { path: PathBuf::from("/test/song.mp3"), title: Some("Test".into()), artist: Some("Artist".into()), album: None, duration_secs: Some(200.0), }; s.start(t); assert!(s.track.is_some()); s.clear(); assert!(s.track.is_none()); assert!(s.start_time.is_none()); assert!(s.timestamp.is_none()); } #[test] fn test_should_scrobble_without_track_returns_false() { assert!(!ScrobbleState::new().should_scrobble()); } #[test] fn test_should_scrobble_threshold_below_30_seconds() { assert!(!ScrobbleState::should_scrobble_with_params(29, Some(200.0))); } #[test] fn test_should_scrobble_threshold_half_duration() { assert!(ScrobbleState::should_scrobble_with_params(100, Some(200.0))); } #[test] fn test_should_scrobble_threshold_below_half_duration() { assert!(!ScrobbleState::should_scrobble_with_params(80, Some(200.0))); } #[test] fn test_should_scrobble_threshold_capped_at_4min() { assert!(ScrobbleState::should_scrobble_with_params(240, Some(600.0))); assert!(!ScrobbleState::should_scrobble_with_params( 239, Some(600.0) )); } #[test] fn test_should_scrobble_no_duration_requires_4min() { assert!(ScrobbleState::should_scrobble_with_params(240, None)); assert!(!ScrobbleState::should_scrobble_with_params(239, None)); } #[test] fn test_should_scrobble_short_song() { assert!(ScrobbleState::should_scrobble_with_params(30, Some(60.0))); assert!(!ScrobbleState::should_scrobble_with_params(15, Some(60.0))); } } fn try_scrobble_current(scrobbler: &RwLock, state: &mut ScrobbleState) { if state.should_scrobble() && let (Some(track), Some(ts)) = (&state.track, state.timestamp) { let artist = track.artist.as_deref().unwrap_or("(unknown artist)"); let title = track.title.as_deref().unwrap_or("(unknown title)"); let album = track.album.as_deref(); eprintln!( "scrobble: sending scrobble for '{} - {}' at ts={}", artist, title, ts ); if let Ok(s) = scrobbler.read() { s.scrobble(artist, title, album, ts); } } else { let title = state .track .as_ref() .and_then(|t| t.title.as_deref()) .unwrap_or("(no track)"); eprintln!( "scrobble: skipping scrobble for '{}' — not enough play time", title ); } state.clear(); } fn send_now_playing(scrobbler: &RwLock, track: &Track) { let artist = track.artist.as_deref().unwrap_or("(unknown artist)"); let title = track.title.as_deref().unwrap_or("(unknown title)"); let album = track.album.as_deref(); eprintln!("scrobble: sending now_playing for '{} - {}'", artist, title); if let Ok(s) = scrobbler.read() { s.now_playing(artist, title, album); } } pub fn start_daemon() -> Result<(), Box> { let config = Config::load()?; let socket_path = Config::get_socket_path(); let pid_path = get_pid_path(&config); // Check if daemon is already running if let Ok(pid) = check_running_daemon(&pid_path) { return Err(format!("Daemon already running with PID {}", pid).into()); } // Clean up stale socket file if it exists if socket_path.exists() { std::fs::remove_file(&socket_path).ok(); } // Create runtime directory if let Some(parent) = socket_path.parent() { std::fs::create_dir_all(parent)?; } if let Some(parent) = pid_path.parent() { std::fs::create_dir_all(parent)?; } // Write PID file let pid = std::process::id(); std::fs::write(&pid_path, pid.to_string())?; println!("Daemon started with PID {}", pid); // Shared shutdown flag - can be triggered by signal or command let shutdown_flag = Arc::new(AtomicBool::new(false)); let shutdown_flag_signal = Arc::clone(&shutdown_flag); // Handle SIGTERM and SIGINT ctrlc::set_handler(move || { println!("Received shutdown signal"); shutdown_flag_signal.store(true, Ordering::SeqCst); })?; let listener = UnixListener::bind(&socket_path)?; println!("Listening on {:?}", socket_path); let audio_engine = Arc::new(AudioEngine::new()); let queue = Arc::new(Mutex::new(Queue::new())); // Load and apply saved settings if let Some(vol) = config.volume { audio_engine.set_volume(vol as f32 / 100.0); } if let Some(ref shuffle_str) = config.shuffle && let Ok(mut q) = queue.lock() { match shuffle_str.as_str() { "on" | "all" => q.set_shuffle(ShuffleMode::All), "album" => q.set_shuffle(ShuffleMode::Album), _ => {} } } // Create scrobbler from config let scrobbler = Arc::new(RwLock::new(Scrobbler::new( config.lastfm_api_key.clone(), config.lastfm_api_secret.clone(), config.lastfm_session_key.clone(), config.listenbrainz_token.clone(), ))); // Validate ListenBrainz token at startup if let Some(token) = &config.listenbrainz_token && !token.is_empty() && let Ok(s) = scrobbler.read() { match s.validate_listenbrainz_token() { Ok(Some(user)) => eprintln!("scrobble: listenbrainz token valid (user: {})", user), Ok(None) => eprintln!("scrobble: listenbrainz token invalid, scrobbling disabled"), Err(e) => eprintln!("scrobble: listenbrainz validation error: {}", e), } } let scrobble_state = Arc::new(Mutex::new(ScrobbleState::new())); // Save full config config.save()?; // Accept connections until shutdown listener.set_nonblocking(true)?; // Helper: poll until a specific track actually starts playing or timeout fn wait_for_start(audio: &AudioEngine, target: &PathBuf, timeout_ms: u64) -> bool { use std::time::{Duration, Instant}; let start = Instant::now(); while start.elapsed() < Duration::from_millis(timeout_ms) { if audio.is_playing() && let Some(cur) = audio.current_track() && &cur == target { return true; } std::thread::sleep(Duration::from_millis(10)); } false } loop { // Check for shutdown signal if shutdown_flag.load(Ordering::SeqCst) { break; } // Check if track ended and play next (robust with startup feedback) if audio_engine.poll_track_ended() && let Ok(mut q) = queue.lock() { // Scrobble the track that just ended if let Ok(mut s) = scrobble_state.lock() { try_scrobble_current(&scrobbler, &mut s); } let original_position = q.position(); let mut started = false; let mut had_next = false; let max_trials = 4usize; for _ in 0..max_trials { if let Some(next_track) = q.next() { had_next = true; audio_engine.play(next_track.clone()); if wait_for_start(&audio_engine, &next_track, 500) { started = true; let track = Track::from_path(next_track.clone()); // Send now playing and record scrobble start send_now_playing(&scrobbler, &track); if let Ok(mut s) = scrobble_state.lock() { s.start(track.clone()); } let fmt = Config::load().ok().and_then(|c| c.display_format.clone()); notification::notify_track(&track, fmt.as_deref()); break; } } else { break; } } if !started { q.set_position(original_position); audio_engine.stop(); if had_next { notification::notify_error("Playback failed"); } else { notification::notify_queue_ended(); } } } match listener.accept() { Ok((stream, _)) => { let audio_clone = Arc::clone(&audio_engine); let shutdown_flag_clone = Arc::clone(&shutdown_flag); let queue_clone = Arc::clone(&queue); let scrobbler_clone = Arc::clone(&scrobbler); let scrobble_state_clone = Arc::clone(&scrobble_state); thread::spawn(move || { handle_client( stream, audio_clone, shutdown_flag_clone, queue_clone, scrobbler_clone, scrobble_state_clone, ); }); } Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { thread::sleep(Duration::from_millis(50)); continue; } Err(e) => eprintln!("Connection failed: {}", e), } thread::sleep(Duration::from_millis(50)); } // Cleanup cleanup_daemon(&socket_path, &pid_path); println!("Daemon stopped"); Ok(()) } pub fn stop_daemon() -> Result<(), Box> { let config = Config::load()?; let pid_path = get_pid_path(&config); match check_running_daemon(&pid_path) { Ok(pid) => { // Try to send stop command first for graceful shutdown if let Ok(()) = send_command_raw("stop_daemon".to_string()) { // Wait for daemon to stop for _ in 0..50 { thread::sleep(Duration::from_millis(100)); if !is_process_running(pid) { break; } } } // Force kill if still running if is_process_running(pid) { unsafe { libc::kill(pid as i32, libc::SIGTERM); } thread::sleep(Duration::from_millis(500)); // Force kill if still alive if is_process_running(pid) { unsafe { libc::kill(pid as i32, libc::SIGKILL); } } } // Clean up files let socket_path = Config::get_socket_path(); std::fs::remove_file(&socket_path).ok(); std::fs::remove_file(&pid_path).ok(); println!("Daemon stopped"); Ok(()) } Err(e) => Err(format!("Daemon not running: {}", e).into()), } } pub fn restart_daemon() -> Result<(), Box> { // Stop existing daemon if let Ok(()) = stop_daemon() { // Wait a bit for cleanup thread::sleep(Duration::from_millis(500)); } // Start new daemon in background let current_exe = std::env::current_exe()?; std::process::Command::new(current_exe) .arg("start-daemon") .spawn()?; println!("Daemon restarted"); Ok(()) } pub fn send_command(cmd: String) -> Result<(), Box> { send_command_raw(cmd)?; Ok(()) } fn send_command_raw(cmd: String) -> Result<(), Box> { let socket_path = Config::get_socket_path(); let mut stream = UnixStream::connect(socket_path)?; stream.write_all(cmd.as_bytes())?; let mut response = String::new(); stream.read_to_string(&mut response)?; print!("{}", response); Ok(()) } fn handle_client( mut stream: UnixStream, audio: Arc, shutdown_flag: Arc, queue: Arc>, scrobbler: Arc>, scrobble_state: Arc>, ) { let mut buffer = [0; 1024]; if let Ok(size) = stream.read(&mut buffer) { let cmd = String::from_utf8_lossy(&buffer[..size]); let response = process_command( cmd.trim(), &audio, &shutdown_flag, &queue, &scrobbler, &scrobble_state, ); let _ = stream.write_all(response.as_bytes()); } } fn process_command( cmd: &str, audio: &AudioEngine, shutdown_flag: &Arc, queue: &Arc>, scrobbler: &RwLock, scrobble_state: &Arc>, ) -> String { match cmd { "status" => { if audio.is_playing() { if let Some(track_path) = audio.current_track() { let track = Track::from_path(track_path); let fmt = Config::load().ok().and_then(|c| c.display_format.clone()); let track_str = match fmt.as_deref() { Some(f) => track.format(f), None => track.display_string(0, 1), }; format!("Playing\nTrack: {}\n", track_str) } else { "Playing\n".to_string() } } else { "Stopped\n".to_string() } } "pause" => { audio.pause(); notification::notify_change("Paused"); "Paused\n".to_string() } "resume" => { audio.resume(); notification::notify_change("Resumed"); "Resumed\n".to_string() } "toggle" => { if audio.is_playing() { audio.pause(); notification::notify_change("Paused"); "Paused\n".to_string() } else { audio.resume(); notification::notify_change("Resumed"); "Resumed\n".to_string() } } "stop" => { if let Ok(mut s) = scrobble_state.lock() { try_scrobble_current(scrobbler, &mut s); } audio.stop(); if let Ok(mut q) = queue.lock() { q.clear(); } notification::notify_change("Stopped"); "Stopped\n".to_string() } "stop_daemon" => { shutdown_flag.store(true, Ordering::SeqCst); "Shutting down\n".to_string() } "reload" | "validate" => { if let Ok(config) = Config::load() { let temp_scrobbler = Scrobbler::new( config.lastfm_api_key.clone(), config.lastfm_api_secret.clone(), config.lastfm_session_key.clone(), config.listenbrainz_token.clone(), ); let result = match temp_scrobbler.validate_listenbrainz_token() { Ok(Some(user)) => { notification::notify_change(&format!( "Logged in to ListenBrainz as {}", user )); format!("ListenBrainz token valid (user: {})\n", user) } Ok(None) => { notification::notify_error("Invalid ListenBrainz token"); "ListenBrainz token invalid\n".to_string() } Err(e) => { notification::notify_error(&format!("ListenBrainz validation: {}", e)); format!("ListenBrainz validation error: {}\n", e) } }; // Replace shared scrobbler state (only on reload, both is fine) if let Ok(mut s) = scrobbler.write() { *s = temp_scrobbler; } result } else { "Failed to load config\n".to_string() } } "next" => { if let Ok(mut s) = scrobble_state.lock() { try_scrobble_current(scrobbler, &mut s); } if let Ok(mut q) = queue.lock() { if let Some(next_track) = q.next() { audio.play(next_track.clone()); let track = Track::from_path(next_track.clone()); send_now_playing(scrobbler, &track); if let Ok(mut s) = scrobble_state.lock() { s.start(track.clone()); } let fmt = Config::load().ok().and_then(|c| c.display_format.clone()); notification::notify_track(&track, fmt.as_deref()); "Playing next track\n".to_string() } else { audio.stop(); notification::notify_queue_ended(); "Queue empty, stopped\n".to_string() } } else { "Error accessing queue\n".to_string() } } "previous" => { if let Ok(mut s) = scrobble_state.lock() { try_scrobble_current(scrobbler, &mut s); } if let Ok(mut q) = queue.lock() { if let Some(prev_track) = q.previous() { audio.play(prev_track.clone()); let track = Track::from_path(prev_track.clone()); send_now_playing(scrobbler, &track); if let Ok(mut s) = scrobble_state.lock() { s.start(track.clone()); } let fmt = Config::load().ok().and_then(|c| c.display_format.clone()); notification::notify_track(&track, fmt.as_deref()); "Playing previous track\n".to_string() } else { "No previous track\n".to_string() } } else { "Error accessing queue\n".to_string() } } "shuffle" => { if let Ok(q) = queue.lock() { match q.shuffle_mode() { ShuffleMode::None => "Shuffle: off\n".to_string(), ShuffleMode::All => "Shuffle: on (all)\n".to_string(), ShuffleMode::Album => "Shuffle: on (album)\n".to_string(), } } else { "Error accessing queue\n".to_string() } } _ if cmd.starts_with("shuffle ") => { let mode_str = cmd[8..].trim(); if let Ok(mut q) = queue.lock() { let result = if mode_str == "on" || mode_str == "all" { q.set_shuffle(ShuffleMode::All); "Shuffle: on (all)\n".to_string() } else if mode_str == "album" { q.set_shuffle(ShuffleMode::Album); "Shuffle: on (album)\n".to_string() } else if mode_str == "off" || mode_str == "none" { q.set_shuffle(ShuffleMode::None); "Shuffle: off\n".to_string() } else { "Usage: shuffle [on|off|album]\n".to_string() }; // Save to config if let Ok(mut config) = Config::load() { if mode_str == "off" || mode_str == "none" { config.shuffle = None; } else { config.shuffle = Some(mode_str.to_string()); } let _ = config.save(); } result } else { "Error accessing queue\n".to_string() } } _ if cmd.starts_with("play ") => { let rest = &cmd[5..].trim(); let (position, path_str): (&str, &str) = if let Some((pos, p)) = rest.split_once(' ') { if pos == "now" || pos == "next" || pos == "last" { (pos, p) } else { ("last", rest) } } else { ("last", rest) }; let path_str = if path_str.starts_with("~/") { std::env::var("HOME") .map(|h| path_str.replacen("~", &h, 1)) .unwrap_or_else(|_| path_str.to_string()) } else { path_str.to_string() }; if path_str.is_empty() { return "Usage: play [now|next|last] \n".to_string(); } let path = std::path::PathBuf::from(path_str.clone()); if path.is_file() { if let Ok(mut q) = queue.lock() { let was_empty = q.is_empty(); match position { "now" => { if let Ok(mut s) = scrobble_state.lock() { try_scrobble_current(scrobbler, &mut s); } q.insert_after_current(path.clone()); if !was_empty { let new_pos = q.position() + 1; q.set_position(new_pos); } if let Some(track) = q.track_at(q.position()) { audio.play(track.path.clone()); send_now_playing(scrobbler, track); if let Ok(mut s) = scrobble_state.lock() { s.start(track.clone()); } let fmt = Config::load().ok().and_then(|c| c.display_format.clone()); notification::notify_track(track, fmt.as_deref()); } format!("Playing now: {}\n", path_str) } "next" => { q.insert_after_current(path.clone()); format!("Queued next: {}\n", path_str) } _ => { let was_empty = q.is_empty(); let was_playing = audio.is_playing(); q.add_track(path.clone()); if was_empty && !was_playing { let track = Track::from_path(path.clone()); audio.play(path); send_now_playing(scrobbler, &track); if let Ok(mut s) = scrobble_state.lock() { s.start(track.clone()); } let fmt = Config::load().ok().and_then(|c| c.display_format.clone()); notification::notify_track(&track, fmt.as_deref()); format!("Playing: {}\n", path_str) } else { format!("Queued: {}\n", path_str) } } } } else { "Error accessing queue\n".to_string() } } else if path.is_dir() { match find_audio_in_dir(&path) { Some(files) if !files.is_empty() => { if let Ok(mut q) = queue.lock() { let was_empty = q.is_empty(); match position { "now" => { if let Ok(mut s) = scrobble_state.lock() { try_scrobble_current(scrobbler, &mut s); } for file in files.iter().rev() { q.insert_after_current(file.clone()); } if !was_empty { let new_pos = q.position() + 1; q.set_position(new_pos); } if let Some(track) = q.track_at(q.position()) { audio.play(track.path.clone()); send_now_playing(scrobbler, track); if let Ok(mut s) = scrobble_state.lock() { s.start(track.clone()); } let fmt = Config::load() .ok() .and_then(|c| c.display_format.clone()); notification::notify_track(track, fmt.as_deref()); } format!( "Playing now: {} tracks from {}\n", files.len(), path_str ) } "next" => { for file in files.iter().rev() { q.insert_after_current(file.clone()); } format!( "Queued next: {} tracks from {}\n", files.len(), path_str ) } _ => { let was_playing = audio.is_playing(); for file in &files { q.add_track(file.clone()); } if was_empty && !was_playing && let Some(track) = q.current() { audio.play(track.path.clone()); send_now_playing(scrobbler, track); if let Ok(mut s) = scrobble_state.lock() { s.start(track.clone()); } let fmt = Config::load() .ok() .and_then(|c| c.display_format.clone()); notification::notify_track(track, fmt.as_deref()); } format!("Queued {} tracks from: {}\n", files.len(), path_str) } } } else { "Error accessing queue\n".to_string() } } _ => format!("Error: No audio files found in '{}'\n", path_str), } } else { format!("File not found: {}\n", path_str) } } _ if cmd == "volume" => { let vol = audio.get_volume(); format!("Volume: {}\n", vol) } _ if cmd.starts_with("volume ") => { if let Ok(level) = cmd[7..].trim().parse::() { audio.set_volume(level as f32 / 100.0); // Save to config if let Ok(mut config) = Config::load() { config.volume = Some(level); let _ = config.save(); } format!("Volume set to {}\n", level) } else { "Usage: volume <0-100>\n".to_string() } } _ if cmd.starts_with("queue") => { if let Ok(q) = queue.lock() { if q.is_empty() { "Queue is empty\n".to_string() } else { let mut output = String::new(); let current_position = q.position(); let playing_track_idx = match q.shuffle_mode() { ShuffleMode::None => current_position, _ => q .shuffled_indices() .get(current_position) .copied() .unwrap_or(0), }; let indices: Vec = if *q.shuffle_mode() == ShuffleMode::None { (0..q.len()).collect() } else { q.shuffled_indices().to_vec() }; let is_playing = audio.is_playing(); let fmt = Config::load().ok().and_then(|c| c.display_format.clone()); for (display_idx, &track_idx) in indices.iter().enumerate() { if let Some(track) = q.track_at(track_idx) { let track_str = match fmt.as_deref() { Some(f) => track.format(f), None => track.display_string(display_idx, q.len()), }; if track_idx == playing_track_idx && is_playing { output.push_str(&format!("> {} (playing)\n", track_str)); } else if track_idx == playing_track_idx && !is_playing { output.push_str(&format!("> {} (paused)\n", track_str)); } else { output.push_str(&format!(" {}\n", track_str)); } } } // Always return full output - client can pipe to pager if needed output } } else { "Error accessing queue\n".to_string() } } _ => "Unknown command\n".to_string(), } } fn find_audio_in_dir(dir: &Path) -> Option> { use std::ffi::OsStr; let mut results = Vec::new(); if let Ok(entries) = std::fs::read_dir(dir) { let mut entries: Vec<_> = entries.filter_map(|e| e.ok()).collect(); entries.sort_by_key(|e| e.file_name()); for entry in entries { let path = entry.path(); if path.is_file() { if let Some(ext) = path.extension().and_then(OsStr::to_str) && matches!( ext.to_lowercase().as_str(), "mp3" | "flac" | "ogg" | "m4a" | "wav" | "opus" ) { results.push(path); } } else if path.is_dir() && let Some(mut found) = find_audio_in_dir(&path) { results.append(&mut found); } } } if results.is_empty() { None } else { Some(results) } } /// Get the PID file path for the daemon fn get_pid_path(_config: &Config) -> std::path::PathBuf { let runtime_dir = std::env::var("XDG_RUNTIME_DIR") .map(std::path::PathBuf::from) .unwrap_or_else(|_| std::path::PathBuf::from("/tmp")); runtime_dir.join("rsplayer").join("rsplayer.pid") } /// Check if a process with the given PID is running fn is_process_running(pid: u32) -> bool { unsafe { // send signal 0 to check if process exists libc::kill(pid as i32, 0) == 0 } } /// Check if daemon is running by reading PID file fn check_running_daemon(pid_path: &std::path::Path) -> Result> { if !pid_path.exists() { return Err("PID file not found".into()); } let contents = std::fs::read_to_string(pid_path)?; let pid = contents.trim().parse::()?; if is_process_running(pid) { Ok(pid) } else { // Stale PID file, clean it up std::fs::remove_file(pid_path).ok(); Err("Stale PID file, daemon not running".into()) } } /// Clean up daemon resources (socket and PID file) fn cleanup_daemon(socket_path: &std::path::Path, pid_path: &std::path::Path) { std::fs::remove_file(socket_path).ok(); std::fs::remove_file(pid_path).ok(); }