/* * This file is a part of RSPlayer * Licensed under the GPL-3.0 License * Copyright (C) 2026 Salmonik @ salmonik@tuta.io * https://codeberg.org/salmonik/RSPlayer */ use crate::audio::AudioEngine; use crate::config::Config; use crate::notification; use crate::queue::{Queue, ShuffleMode, Track}; use crate::scrobbler::Scrobbler; use crate::state; use reqwest::blocking::Client; use serde::Deserialize; use std::error::Error; 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::time::{Duration, Instant, SystemTime}; use std::{fs, thread}; pub struct ScrobbleState { pub track: Option, pub start_time: Option, pub timestamp: Option, } impl Default for ScrobbleState { fn default() -> Self { Self::new() } } impl ScrobbleState { pub fn new() -> Self { ScrobbleState { track: None, start_time: None, timestamp: None, } } pub fn start(&mut self, track: Track) { if track.is_stream { crate::debug_eprintln(format_args!( "scrobble: not tracking stream '{}'", track.path.display() )); self.clear(); return; } 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(); crate::debug_eprintln(format_args!( "scrobble: tracking start for '{} - {}' (ts={})", artist, title, ts )); self.track = Some(track); self.start_time = Some(Instant::now()); self.timestamp = Some(ts); } pub fn should_scrobble(&self) -> bool { let (Some(track), Some(start)) = (&self.track, self.start_time) else { crate::debug_eprintln(format_args!( "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); crate::debug_eprintln(format_args!( "scrobble: should_scrobble {} (elapsed={}s threshold={}s duration={:?})", result, elapsed, threshold, track.duration_secs )); result } pub 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 } pub fn clear(&mut self) { self.track = None; self.start_time = None; self.timestamp = None; } } /// Fire-and-forget version of `send_now_playing`. /// Spawns the scrobble HTTP call on a background thread so the caller /// (e.g. a Unix socket handler) is never blocked by network latency. pub fn spawn_send_now_playing(scrobbler: &Arc>, track: &Track) { if track.is_stream { return; } let s = Arc::clone(scrobbler); let artist = track .artist .clone() .unwrap_or_else(|| "(unknown artist)".into()); let title = track .title .clone() .unwrap_or_else(|| "(unknown title)".into()); let album = track.album.clone(); thread::spawn(move || { crate::debug_eprintln(format_args!( "scrobble: sending now_playing for '{} - {}'", artist, title )); if let Ok(s) = s.read() { s.now_playing(&artist, &title, album.as_deref()); } }); } /// Fire-and-forget version of `try_scrobble_current`. /// Extracts the scrobble decision and track data under the state lock, /// then spawns the HTTP call on a background thread. pub fn spawn_try_scrobble_current( scrobbler: &Arc>, state: &Arc>, ) { let snapshot = { let mut s = match state.lock() { Ok(g) => g, Err(_) => return, }; if let Some(ref track) = s.track && track.is_stream { crate::debug_eprintln(format_args!("scrobble: skipping scrobble for stream track")); s.clear(); return; } if s.should_scrobble() { let track = s.track.take(); let ts = s.timestamp.take(); s.start_time = None; (track, ts) } else { let title = s .track .as_ref() .and_then(|t| t.title.as_deref()) .unwrap_or("(no track)"); crate::debug_eprintln(format_args!( "scrobble: skipping scrobble for '{}' — not enough play time", title )); s.clear(); (None, None) } }; if let (Some(track), Some(ts)) = snapshot { let s = Arc::clone(scrobbler); thread::spawn(move || { 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(); crate::debug_eprintln(format_args!( "scrobble: sending scrobble for '{} - {}' at ts={}", artist, title, ts )); if let Ok(s) = s.read() { s.scrobble(artist, title, album, ts); } }); } } 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 (private, 0700) crate::config::ensure_runtime_dir()?; // 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); } // Restore the previous session's queue. It beats the config default for // shuffle because it is the newer choice, and it is what makes a restart // (or a reboot) pick up where playback stopped. let mut restored_queue = false; let mut resume_track: Option = None; if let Some(saved) = state::load_from(&state::state_path()) && let Ok(mut q) = queue.lock() && !saved.tracks.is_empty() { *q = Queue::from_snapshot(saved.snapshot()); restored_queue = true; if saved.playing { resume_track = q.current().cloned(); } } if !restored_queue && 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), "albums" => q.set_shuffle(ShuffleMode::Albums), _ => {} } } // 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)) => crate::debug_eprintln(format_args!( "scrobble: listenbrainz token valid (user: {})", user )), Ok(None) => crate::debug_eprintln(format_args!( "scrobble: listenbrainz token invalid, scrobbling disabled" )), Err(e) => crate::debug_eprintln(format_args!( "scrobble: listenbrainz validation error: {}", e )), } } let scrobble_state = Arc::new(Mutex::new(ScrobbleState::new())); // Pick the restored track back up. No notification: this happens on daemon // start (usually at login), nobody asked for a popup there. if let Some(track) = resume_track { audio_engine.play(track.path.clone()); spawn_send_now_playing(&scrobbler, &track); if let Ok(mut s) = scrobble_state.lock() { s.start(track); } } // Save full config config.save()?; // Warm up D-Bus session connection so the first notification // doesn't pay the full connection-establishment cost. if let Err(e) = zbus::blocking::Connection::session() { crate::debug_eprintln(format_args!( "daemon: failed to connect to D-Bus session bus: {}", e )); } // Start MPRIS D-Bus server for external control (playerctl, etc.) crate::mpris::start_mpris_server( Arc::clone(&audio_engine), Arc::clone(&queue), Arc::clone(&scrobbler), Arc::clone(&scrobble_state), Arc::clone(&shutdown_flag), ); // 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 } let mut last_state: Option<(usize, usize, ShuffleMode, bool)> = None; 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 (fire-and-forget) spawn_try_scrobble_current(&scrobbler, &scrobble_state); 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 (fire-and-forget) spawn_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(); q.clear(); } } } // Keep the saved state in step with the queue so a restart resumes // here. The file is small and only rewritten when something moved. let pending_save = if let Ok(q) = queue.lock() { let playing = audio_engine.is_playing(); let fingerprint = (q.len(), q.position(), *q.shuffle_mode(), playing); if last_state == Some(fingerprint) { None } else { last_state = Some(fingerprint); Some(state::SavedState::new(q.snapshot(), playing)) } } else { None }; if let Some(saved) = pending_save && let Err(e) = state::save_to(&state::state_path(), &saved) { eprintln!("Failed to save queue state: {}", e); } 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)); } // Final write so a shutdown while paused keeps the current queue. if let Ok(q) = queue.lock() { let saved = state::SavedState::new(q.snapshot(), audio_engine.is_playing()); if let Err(e) = state::save_to(&state::state_path(), &saved) { eprintln!("Failed to save queue state: {}", e); } } // 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 = match UnixStream::connect(&socket_path) { Ok(s) => s, Err(e) => { if e.kind() == std::io::ErrorKind::NotFound { return Err(format!( "RSPlayer daemon is not running (no socket at {}).\n\ Start it with `rsp start-daemon` or `rsp restart`.", socket_path.display() ) .into()); } if e.kind() == std::io::ErrorKind::ConnectionRefused { return Err(format!( "RSPlayer daemon is not responding (stale socket at {}).\n\ Run `rsp restart` to clean up and start a fresh daemon.", socket_path.display() ) .into()); } return Err(e.into()); } }; stream.write_all(cmd.as_bytes())?; let mut buf = [0u8; 4096]; loop { match stream.read(&mut buf) { Ok(0) => break, Ok(n) => { if let Ok(s) = std::str::from_utf8(&buf[..n]) { print!("{}", s); } } Err(_) => break, } } let _ = std::io::stdout().flush(); 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 trimmed = cmd.trim(); if trimmed == "lyrics" { handle_lyrics_stream(&mut stream, &audio); } else { let response = process_command( trimmed, &audio, &shutdown_flag, &queue, &scrobbler, &scrobble_state, ); let _ = stream.write_all(response.as_bytes()); } } } struct LrcLine { time: f64, text: String, } fn parse_lrc(content: &str) -> Vec { let mut lines: Vec = Vec::new(); for line in content.lines() { let line = line.trim(); if line.is_empty() { continue; } if let Some(rest) = line.strip_prefix('[') && let Some(idx) = rest.find(']') { let ts = &rest[..idx]; let text = rest[idx + 1..].trim().to_string(); if let Some(secs) = parse_lrc_timestamp(ts) { lines.push(LrcLine { time: secs, text }); } } } lines.sort_by(|a, b| { a.time .partial_cmp(&b.time) .unwrap_or(std::cmp::Ordering::Equal) }); lines } fn parse_lrc_timestamp(ts: &str) -> Option { let ts = ts.trim(); if let Some((mm, rest)) = ts.split_once(':') { let mm: f64 = mm.parse().ok()?; let ss: f64 = rest.parse().ok()?; Some(mm * 60.0 + ss) } else { None } } #[derive(Deserialize)] struct LrclibResponse { #[serde(rename = "syncedLyrics", default)] synced_lyrics: Option, #[serde(rename = "plainLyrics", default)] plain_lyrics: Option, } fn url_encode(s: &str) -> String { let mut result = String::with_capacity(s.len()); for &byte in s.as_bytes() { match byte { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { result.push(byte as char); } b' ' => result.push_str("%20"), _ => { result.push_str(&format!("%{:02X}", byte)); } } } result } fn fetch_lrclib_lyrics( artist: &str, title: &str, album: Option<&str>, ) -> Result { let client = Client::builder() .user_agent("RSPlayer/0.9.2") .connect_timeout(Duration::from_secs(10)) .timeout(Duration::from_secs(30)) .build() .map_err(|e| format!("HTTP client error: {}", e))?; let mut url = format!( "https://lrclib.net/api/get?artist_name={}&track_name={}", url_encode(artist), url_encode(title), ); if let Some(a) = album { url.push_str(&format!("&album_name={}", url_encode(a))); } let resp = client.get(&url).send().map_err(format_error_chain)?; let status = resp.status(); if !status.is_success() { if status.as_u16() == 404 { return Err("Lyrics not found on LRClib".to_string()); } let body = resp.text().unwrap_or_default(); return Err(format!("LRClib returned HTTP {}: {}", status, body)); } resp.json::() .map_err(|e| format!("Failed to parse LRClib response: {}", e)) } /// Format a reqwest error and its full source chain into a single string. fn format_error_chain(e: reqwest::Error) -> String { let mut msg = format!("{}", e); if let Some(url) = e.url() { msg.push_str(&format!("\n url: {}", url)); } let mut source = e.source(); while let Some(s) = source { msg.push_str(&format!("\n caused by: {}", s)); source = s.source(); } msg } enum LoadedLyrics { Synced(Vec), Plain(String), Unavailable(String), } /// Build the "Now playing" header shown above a track's lyrics. fn lyrics_header(track: &Track) -> String { let name = match Config::load() .ok() .and_then(|c| c.display_format.clone()) .as_deref() { Some(fmt) => track.format(fmt), None => { let artist = track.artist.clone().unwrap_or_default(); let title = track.title.clone().unwrap_or_else(|| { track .path .file_name() .and_then(|n| n.to_str()) .unwrap_or("Unknown") .to_string() }); if artist.is_empty() { title } else { format!("{} - {}", artist, title) } } }; format!("\x1b[2KNow playing: {}\n\n", name) } /// Load lyrics for a track: local .lrc (synced), then local .txt (plain), /// then LRClib as a last resort (caching the result next to the file). fn load_lyrics(track_path: &Path) -> LoadedLyrics { let mut lrc_path = track_path.to_path_buf(); lrc_path.set_extension("lrc"); let mut txt_path = track_path.to_path_buf(); txt_path.set_extension("txt"); // Try local .lrc first (synced), then local .txt (plain), then LRClib if lrc_path.exists() && let Ok(content) = fs::read_to_string(&lrc_path) { let lyrics = parse_lrc(&content); if !lyrics.is_empty() { return LoadedLyrics::Synced(lyrics); } } if txt_path.exists() && let Ok(content) = fs::read_to_string(&txt_path) && !content.trim().is_empty() { return LoadedLyrics::Plain(content); } // No local lyrics — fetch from LRClib let track = Track::from_path(track_path.to_path_buf()); let artist = track.artist.as_deref().unwrap_or(""); let title = track.title.as_deref().unwrap_or(""); let album = track.album.as_deref(); if artist.is_empty() && title.is_empty() { return LoadedLyrics::Unavailable( "No lyrics available and no metadata to query LRClib".to_string(), ); } match fetch_lrclib_lyrics(artist, title, album) { Ok(lyrics) => { if let Some(ref synced) = lyrics.synced_lyrics && !synced.is_empty() { let _ = fs::write(&lrc_path, synced); let parsed = parse_lrc(synced); if !parsed.is_empty() { LoadedLyrics::Synced(parsed) } else { LoadedLyrics::Unavailable("No synced lyrics found on LRClib".to_string()) } } else if let Some(ref plain) = lyrics.plain_lyrics && !plain.is_empty() { let _ = fs::write(&txt_path, plain); LoadedLyrics::Plain(plain.clone()) } else { LoadedLyrics::Unavailable("No lyrics found on LRClib".to_string()) } } Err(e) => LoadedLyrics::Unavailable(format!("Failed to fetch lyrics: {}", e)), } } /// Stream lyrics for the currently playing track, following track changes: /// when the song changes, reload and show the new song's lyrics in place. fn handle_lyrics_stream(stream: &mut UnixStream, audio: &Arc) { let mut shown_path: Option = None; let mut first_page = true; loop { match audio.current_track() { Some(track_path) => { if shown_path.as_deref() == Some(track_path.as_path()) { // Same track still playing — wait for a change. thread::sleep(Duration::from_millis(100)); continue; } // New track: start a fresh full-screen page (kept in place on // the very first invocation so the user's prompt isn't wiped). if !first_page && stream.write_all(b"\x1b[2J\x1b[H").is_err() { return; } first_page = false; shown_path = Some(track_path.clone()); let track = Track::from_path(track_path.clone()); if stream.write_all(lyrics_header(&track).as_bytes()).is_err() || stream.flush().is_err() { return; } match load_lyrics(&track_path) { LoadedLyrics::Synced(lines) => { stream_synced_lyrics(stream, audio, &lines, &track_path); } LoadedLyrics::Plain(text) | LoadedLyrics::Unavailable(text) => { if stream.write_all(text.as_bytes()).is_err() || stream.write_all(b"\n").is_err() || stream.flush().is_err() { return; } } } // Synced lyrics return here on track change; plain/unavailable // lyrics fall through and wait for the change above. } None => { if shown_path.is_some() { let _ = stream.write_all(b"\nPlayback stopped\n"); let _ = stream.flush(); shown_path = None; } else if first_page { // Nothing was ever playing — exit like before. return; } thread::sleep(Duration::from_millis(100)); } } } } /// Stream synced (LRC) lyrics in real time, tracking audio playback position. /// Returns when the playing track changes (the caller reloads lyrics) or the /// client disconnects. fn stream_synced_lyrics( stream: &mut UnixStream, audio: &AudioEngine, lyrics: &[LrcLine], track_path: &Path, ) { let mut last_idx = None; let mut prev_line_count = 0; loop { match audio.current_track().as_deref() { Some(p) if p == track_path => {} Some(_) => { // Track changed: clear this track's display region so the // next track starts on a clean screen. if prev_line_count > 0 { let _ = stream.write_all(format!("\x1b[{}A\x1b[J", prev_line_count).as_bytes()); let _ = stream.flush(); } return; } None => { // Playback stopped; leave the final frame visible. return; } } let pos = audio.position(); let current_idx = lyrics.iter().rposition(|l| l.time <= pos).unwrap_or(0); if last_idx == Some(current_idx) { thread::sleep(Duration::from_millis(100)); continue; } last_idx = Some(current_idx); let start = current_idx.saturating_sub(1); let end = (current_idx + 3).min(lyrics.len()); let mut output = String::new(); for (idx, l) in lyrics.iter().enumerate().take(end).skip(start) { if idx == current_idx { output.push_str(&format!(" > {}\n", l.text)); } else { output.push_str(&format!(" {}\n", l.text)); } } let line_count = output.lines().count(); let mut frame = String::new(); if prev_line_count > 0 { // Move back to the top of the previous frame and erase the region // before rewriting, so frames that shrink don't leave stale text. frame.push_str(&format!("\x1b[{}A\x1b[J", prev_line_count)); } else { frame.push_str("\x1b[2K"); } frame.push_str(&output); if stream.write_all(frame.as_bytes()).is_err() { return; } if stream.flush().is_err() { return; } prev_line_count = line_count; thread::sleep(Duration::from_millis(100)); } } /// Unpauses the stream, or starts the queue's current track when nothing is /// loaded in the engine yet — which is the state right after a daemon start /// with a restored queue, where a plain `resume` would otherwise do nothing. /// /// Returns whether there was anything to resume. fn resume_queue_track( audio: &AudioEngine, queue: &Arc>, scrobbler: &Arc>, scrobble_state: &Arc>, ) -> bool { if audio.current_track().is_some() { audio.resume(); return true; } let Some(track) = queue.lock().ok().and_then(|q| q.current().cloned()) else { return false; }; audio.play(track.path.clone()); spawn_send_now_playing(scrobbler, &track); if let Ok(mut s) = scrobble_state.lock() { s.start(track); } true } /// Parse a seek argument into an absolute position in seconds. /// /// Accepts `90`, `1:30`, `1:02:03`, and relative offsets `+15` / `-30`. fn parse_seek(arg: &str, current: f64) -> Option { let (sign, body) = match arg.as_bytes().first()? { b'+' => (1.0, &arg[1..]), b'-' => (-1.0, &arg[1..]), _ => (0.0, arg), }; let value = parse_time(body)?; Some(if sign == 0.0 { value } else { (current + sign * value).max(0.0) }) } /// Parse `SS`, `MM:SS`, or `HH:MM:SS` (fractions allowed) into seconds. fn parse_time(text: &str) -> Option { let mut total = 0.0f64; let mut parts = 0usize; for part in text.split(':') { let value: f64 = part.trim().parse().ok()?; if value < 0.0 { return None; } total = total * 60.0 + value; parts += 1; } (1..=3).contains(&parts).then_some(total) } /// `83.4` -> `1:23`, `3723` -> `1:02:03` fn format_time(seconds: f64) -> String { let secs = seconds.max(0.0) as u64; let (hours, minutes, secs) = (secs / 3600, (secs % 3600) / 60, secs % 60); if hours > 0 { format!("{}:{:02}:{:02}", hours, minutes, secs) } else { format!("{}:{:02}", minutes, secs) } } fn process_command( cmd: &str, audio: &AudioEngine, shutdown_flag: &Arc, queue: &Arc>, scrobbler: &Arc>, scrobble_state: &Arc>, ) -> String { match cmd { "status" => { let current = audio.current_track().or_else(|| { queue .lock() .ok() .and_then(|q| q.current().map(|t| t.path.clone())) }); let Some(track_path) = current else { return "Stopped\n".to_string(); }; let state = if audio.is_playing() { "Playing" } else { "Paused" }; 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), }; let position = format_time(audio.position()); match track.duration_secs { Some(duration) if duration > 0.0 => { format!( "{}\nTrack: {}\nPosition: {} / {}\n", state, track_str, position, format_time(duration) ) } _ => format!("{}\nTrack: {}\nPosition: {}\n", state, track_str, position), } } "pause" => { audio.pause(); notification::notify_change("Paused"); "Paused\n".to_string() } "resume" => { if resume_queue_track(audio, queue, scrobbler, scrobble_state) { notification::notify_change("Resumed"); "Resumed\n".to_string() } else { "Nothing to resume\n".to_string() } } "toggle" => { if audio.is_playing() { audio.pause(); notification::notify_change("Paused"); "Paused\n".to_string() } else if resume_queue_track(audio, queue, scrobbler, scrobble_state) { notification::notify_change("Resumed"); "Resumed\n".to_string() } else { "Nothing to resume\n".to_string() } } "stop" => { spawn_try_scrobble_current(scrobbler, scrobble_state); audio.stop(); if let Ok(mut q) = queue.lock() { q.clear(); } notification::notify_change("Stopped"); "Stopped\n".to_string() } _ if cmd == "seek" || cmd.starts_with("seek ") => { let current = audio.position(); let arg = cmd[4..].trim(); if arg.is_empty() { return format!("Position: {}\n", format_time(current)); } match parse_seek(arg, current) { Some(target) => { audio.seek(target); format!("Seeking to {}\n", format_time(target)) } None => "Usage: rsp seek \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( "RSPlayer scrobbler login", &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" => { spawn_try_scrobble_current(scrobbler, scrobble_state); 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()); spawn_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(); q.clear(); "Queue empty, stopped\n".to_string() } } else { "Error accessing queue\n".to_string() } } "previous" => { spawn_try_scrobble_current(scrobbler, scrobble_state); 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()); spawn_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(), ShuffleMode::Albums => "Shuffle: on (albums)\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 == "albums" { q.set_shuffle(ShuffleMode::Albums); "Shuffle: on (albums)\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|albums]\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-album ") => { let rest = &cmd[11..].trim(); let (position, query_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) }; if query_str.is_empty() { return "Usage: play-album [now|next|last] \n".to_string(); } let path = std::path::PathBuf::from(query_str); // Resolve the full album containing the requested track, ordered by // track_number. The album is rotated so playback starts at the // requested track while the *entire* album is queued (auto-advance // wraps through any earlier tracks). If the argument is an existing // file, derive the album from its metadata; otherwise query the DB // by hierarchical path or free-text search. let (album_tracks, display_name): (Vec, String) = if path.is_file() { let canon = std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone()); let (artist, album, _, _, _) = crate::database::extract_metadata(&canon); let full: Vec = match Config::load() { Ok(config) => match crate::database::Database::open(&config) { Ok(db) => db .find_tracks_by_album(&artist, &album) .unwrap_or_default() .into_iter() .map(|t| t.path) .collect(), Err(_) => Vec::new(), }, Err(_) => Vec::new(), }; let start_idx = full.iter().position(|p| *p == canon).unwrap_or(0); let mut rotated: Vec = full[start_idx..].to_vec(); rotated.extend_from_slice(&full[..start_idx]); if rotated.is_empty() { rotated.push(path.clone()); } let name = path .file_name() .and_then(|n| n.to_str()) .unwrap_or("unknown") .to_string(); (rotated, name) } else { match Config::load() { Ok(config) => match crate::database::Database::open(&config) { Ok(db) => { let tracks = match db.resolve_path(query_str) { Ok(Some(t)) => t, Ok(None) => match db.search_tracks(query_str) { Ok(t) => t, Err(e) => return format!("Database error: {}\n", e), }, Err(e) => return format!("Database error: {}\n", e), }; if tracks.is_empty() { return format!("No tracks found matching '{}'\n", query_str); } let (full, display): (Vec, String) = if tracks.len() > 1 { // resolve_path returned the full album // ("Artist/Album" query) — keep order. let d = format!( "{} - {}", tracks[0].artist_name.as_deref().unwrap_or("unknown"), tracks[0].title, ); (tracks, d) } else { // Single track from "Artist/Album/Track" // or a search result — fetch the whole // album and rotate so the requested // track plays first. let db_track = &tracks[0]; let full = match db.find_tracks_by_album( db_track.artist_name.as_deref().unwrap_or(""), db_track.album_title.as_deref().unwrap_or(""), ) { Ok(f) if !f.is_empty() => f, _ => vec![db_track.clone()], }; let start_idx = full .iter() .position(|t| t.path == db_track.path) .unwrap_or(0); let mut rotated: Vec = full[start_idx..].to_vec(); rotated.extend_from_slice(&full[..start_idx]); let d = format!( "{} - {}", db_track.artist_name.as_deref().unwrap_or("unknown"), db_track.title, ); (rotated, d) }; let album_tracks: Vec = full.iter().map(|t| t.path.clone()).collect(); (album_tracks, display) } Err(e) => return format!("Database error: {}\n", e), }, Err(e) => return format!("Config error: {}\n", e), } }; if let Ok(mut q) = queue.lock() { let was_empty = q.is_empty(); match position { "now" => { spawn_try_scrobble_current(scrobbler, scrobble_state); // album_tracks[0] is the track to start at; insert the // whole block in correct order. if was_empty { for t in &album_tracks { q.add_track(t.clone()); } } else { for t in album_tracks.iter().rev() { q.insert_after_current(t.clone()); } let new_pos = q.position() + 1; q.set_position(new_pos); } if let Some(track) = q.current() { audio.play(track.path.clone()); spawn_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 {} with {} album track(s)\n", display_name, album_tracks.len().saturating_sub(1) ) } "next" => { if was_empty { for t in &album_tracks { q.add_track(t.clone()); } } else { for t in album_tracks.iter().rev() { q.insert_after_current(t.clone()); } } format!( "Queued next: {} with {} album track(s)\n", display_name, album_tracks.len().saturating_sub(1) ) } _ => { for t in &album_tracks { q.add_track(t.clone()); } if was_empty && !audio.is_playing() && let Some(track) = q.current() { audio.play(track.path.clone()); spawn_send_now_playing(scrobbler, track); if let Ok(mut s) = scrobble_state.lock() { s.start(track.clone()); } } format!( "Queued: {} with {} album track(s)\n", display_name, album_tracks.len().saturating_sub(1) ) } } } 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() }; // Strip trailing slash so autocompleted Artist/Album/ resolves cleanly let path_str = path_str.trim_end_matches('/').to_string(); // Unescape backslash sequences (e.g., \! → !) so shell escaping works let path_str = { let mut unescaped = String::with_capacity(path_str.len()); let mut chars = path_str.chars(); while let Some(c) = chars.next() { if c == '\\' { if let Some(next) = chars.next() { unescaped.push(next); } } else { unescaped.push(c); } } unescaped }; if path_str.is_empty() { return "Usage: play [now|next|last] \n".to_string(); } let is_url = path_str.starts_with("http://") || path_str.starts_with("https://"); if is_url { if let Ok(mut q) = queue.lock() { let was_empty = q.is_empty(); let track = Track::from_url(&path_str); match position { "now" => { spawn_try_scrobble_current(scrobbler, scrobble_state); q.insert_after_current(PathBuf::from(path_str.clone())); if !was_empty { let new_pos = q.position() + 1; q.set_position(new_pos); } if let Some(track) = q.current() { audio.play(track.path.clone()); spawn_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(PathBuf::from(path_str.clone())); format!("Queued next: {}\n", path_str) } _ => { let was_playing = audio.is_playing(); q.push_track(track.clone()); if was_empty && !was_playing && let Some(track) = q.current() { audio.play(track.path.clone()); spawn_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: {}\n", path_str) } } } else { "Error accessing queue\n".to_string() } } else { 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" => { spawn_try_scrobble_current(scrobbler, scrobble_state); 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.current() { audio.play(track.path.clone()); spawn_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); spawn_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" => { spawn_try_scrobble_current(scrobbler, scrobble_state); 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.current() { audio.play(track.path.clone()); spawn_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 { q.shuffle_initial(); if let Some(track) = q.current() { audio.play(track.path.clone()); spawn_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 { match Config::load() { Ok(config) => match crate::database::Database::open(&config) { Ok(db) => { // Collect tracks: try hierarchical path first, fallback to search let tracks = match db.resolve_path(&path_str) { Ok(Some(t)) => t, Ok(None) => { let query = path_str .rsplit_once('/') .and_then(|(_, after)| { (!after.is_empty()).then_some(after) }) .unwrap_or(&path_str); match db.search_tracks(query) { Ok(t) => t, Err(e) => return format!("Database error: {}\n", e), } } Err(e) => return format!("Database error: {}\n", e), }; if tracks.is_empty() { return format!("No tracks found matching '{}'\n", path_str); } let is_album_play = tracks.len() > 1; if let Ok(mut q) = queue.lock() { let was_empty = q.is_empty(); match position { "now" => { spawn_try_scrobble_current(scrobbler, scrobble_state); for t in tracks.iter().rev() { q.insert_after_current(t.path.clone()); } if !was_empty { let new_pos = q.position() + 1; q.set_position(new_pos); } if let Some(track) = q.current() { audio.play(track.path.clone()); spawn_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()); } if is_album_play { format!( "Playing now: {} tracks from {}\n", tracks.len(), path_str ) } else { format!("Playing now: {}\n", path_str) } } "next" => { for t in tracks.iter().rev() { q.insert_after_current(t.path.clone()); } if is_album_play { format!( "Queued next: {} tracks from {}\n", tracks.len(), path_str ) } else { format!("Queued next: {}\n", path_str) } } _ => { let was_playing = audio.is_playing(); for t in &tracks { q.add_track(t.path.clone()); } if was_empty && !was_playing { if is_album_play { q.shuffle_initial(); } if let Some(track) = q.current() { audio.play(track.path.clone()); spawn_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(), ); } if is_album_play { format!( "Playing: {} tracks from {}\n", tracks.len(), path_str ) } else { format!("Playing: {}\n", path_str) } } else if is_album_play { format!( "Queued {} tracks from: {}\n", tracks.len(), path_str ) } else { format!("Queued: {}\n", path_str) } } } } else { "Error accessing queue\n".to_string() } } Err(e) => format!("Database error: {}\n", e), }, Err(e) => format!("Config error: {}\n", e), } } } } _ if cmd == "volume" => { let vol = audio.get_volume(); format!("Volume: {}\n", vol) } _ if cmd.starts_with("volume ") => { let arg = cmd[7..].trim(); // Try relative: +N, -N, N+, N- let rel_parse = arg .strip_prefix('+') .or_else(|| arg.strip_prefix('-')) .map(|n| (n, arg.starts_with('+'))) .or_else(|| { arg.rfind(['+', '-']) .filter(|&i| i + 1 == arg.len()) .map(|i| (&arg[..i], arg.as_bytes()[i] == b'+')) }); if let Some((rest, is_plus)) = rel_parse && let Ok(delta) = rest.parse::() { let sign = if is_plus { 1i8 } else { -1i8 }; let current = audio.get_volume() as i8; let new_level = (current + sign * delta).clamp(0, 100) as u8; audio.set_volume(new_level as f32 / 100.0); if let Ok(mut config) = Config::load() { config.volume = Some(new_level); let _ = config.save(); } return format!("Volume set to {}\n", new_level); } // Try absolute value if let Ok(level) = arg.parse::() { audio.set_volume(level as f32 / 100.0); 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, -N, N+, N-\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 { crate::config::runtime_dir().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(); } #[cfg(test)] mod tests { use super::*; #[test] fn parses_absolute_seek_positions() { assert_eq!(parse_seek("90", 10.0), Some(90.0)); assert_eq!(parse_seek("1:30", 10.0), Some(90.0)); assert_eq!(parse_seek("1:02:03", 0.0), Some(3723.0)); assert_eq!(parse_seek("83.5", 0.0), Some(83.5)); } #[test] fn parses_relative_seek_offsets() { assert_eq!(parse_seek("+15", 60.0), Some(75.0)); assert_eq!(parse_seek("-15", 60.0), Some(45.0)); assert_eq!( parse_seek("-60", 30.0), Some(0.0), "relative seeks never go below the start" ); } #[test] fn rejects_bad_seek_positions() { assert_eq!(parse_seek("abc", 0.0), None); assert_eq!(parse_seek("", 0.0), None); assert_eq!(parse_seek("-", 0.0), None); assert_eq!(parse_seek("1:2:3:4", 0.0), None, "at most hours:min:sec"); } #[test] fn formats_positions_for_status() { assert_eq!(format_time(0.0), "0:00"); assert_eq!(format_time(83.4), "1:23"); assert_eq!(format_time(3723.0), "1:02:03"); } }