From 863b4017714130f042f993e7c9d0f3f8adee22bf Mon Sep 17 00:00:00 2001 From: salmonik Date: Mon, 2 Nov 2026 13:12:48 +0100 Subject: state: persist the queue and pick up where playback stopped The queue, its shuffle order and whether playback was running are written to ~/.local/state/rsplayer/state.json whenever they change, and on shutdown, then restored at daemon start. Tracks that disappeared from disk are dropped instead of failing on play. `rsp resume`/`rsp toggle` now start the restored track when nothing is loaded into the engine yet, which is the state right after a restart with a queue that was paused. --- TODO.md | 4 +- src/daemon.rs | 99 +++++++++++++++++++++++++++-- src/lib.rs | 1 + src/main.rs | 1 + src/mpris.rs | 6 +- src/queue.rs | 56 +++++++++++++++- src/state.rs | 200 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 355 insertions(+), 12 deletions(-) create mode 100644 src/state.rs diff --git a/TODO.md b/TODO.md index 55f7d52..291a4f4 100644 --- a/TODO.md +++ b/TODO.md @@ -1,6 +1,8 @@ # TODO -- **Persist queue state** - Save queue to disk so it survives daemon restarts. +- [x] **Persist queue state** - Queue, shuffle order and play state are written to `~/.local/state/rsplayer/state.json` and restored on start; `rsp resume` picks up a session that was paused when the daemon stopped + +- **Resume mid-track** - Store the playback offset with the queue state and seek there on restore (needs `rsp seek`) - [x] **Dynamic autocompletion of artists, albums and tracks** - `rsp complete [prefix]` queries DB directly; zsh completion wrapper concatenated via install.sh diff --git a/src/daemon.rs b/src/daemon.rs index 41c21c6..8f470c1 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -10,6 +10,7 @@ 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; @@ -231,7 +232,24 @@ pub fn start_daemon() -> Result<(), Box> { if let Some(vol) = config.volume { audio_engine.set_volume(vol as f32 / 100.0); } - if let Some(ref shuffle_str) = config.shuffle + + // 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() { @@ -270,6 +288,16 @@ pub fn start_daemon() -> Result<(), Box> { } 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()?; @@ -308,6 +336,7 @@ pub fn start_daemon() -> Result<(), Box> { } false } + let mut last_state: Option<(usize, usize, ShuffleMode, bool)> = None; loop { // Check for shutdown signal if shutdown_flag.load(Ordering::SeqCst) { @@ -356,6 +385,26 @@ pub fn start_daemon() -> Result<(), Box> { } } + // 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); @@ -384,6 +433,14 @@ pub fn start_daemon() -> Result<(), Box> { 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"); @@ -871,6 +928,32 @@ fn stream_synced_lyrics( } } +/// 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 +} + fn process_command( cmd: &str, audio: &AudioEngine, @@ -903,19 +986,23 @@ fn process_command( "Paused\n".to_string() } "resume" => { - audio.resume(); - notification::notify_change("Resumed"); - "Resumed\n".to_string() + 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 { - audio.resume(); + } 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" => { diff --git a/src/lib.rs b/src/lib.rs index f08e8ea..189c376 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,3 +22,4 @@ pub mod notification; pub mod playback; pub mod queue; pub mod scrobbler; +pub mod state; diff --git a/src/main.rs b/src/main.rs index fd60bef..4bc4498 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,6 +15,7 @@ mod notification; mod playback; mod queue; mod scrobbler; +mod state; use clap::Parser; use cli::Cli; diff --git a/src/mpris.rs b/src/mpris.rs index f156f7f..3fc706d 100644 --- a/src/mpris.rs +++ b/src/mpris.rs @@ -378,13 +378,13 @@ impl MprisPlayer { #[zbus(name = "Shuffle")] fn set_shuffle(&self, value: bool) { if let Ok(mut q) = self.queue.lock() { - let current = q.shuffle_mode().clone(); + let current = *q.shuffle_mode(); let mode = if !value { ShuffleMode::None } else if current == ShuffleMode::None { ShuffleMode::All } else { - current.clone() + current }; if mode != current { @@ -550,7 +550,7 @@ pub fn start_mpris_server( let volume = poll_audio.get_volume() as f64 / 100.0; let shuffle = poll_queue .lock() - .map(|q| q.shuffle_mode().clone()) + .map(|q| *q.shuffle_mode()) .unwrap_or(ShuffleMode::None); let loop_status_value = poll_loop_status .lock() diff --git a/src/queue.rs b/src/queue.rs index 17f2143..c985755 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -8,9 +8,11 @@ use rand::SeedableRng; use rand::rngs::StdRng; use rand::seq::SliceRandom; +use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] pub enum ShuffleMode { None, All, @@ -94,7 +96,7 @@ pub fn extract_metadata(path: &Path) -> Option { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Track { pub path: PathBuf, pub title: Option, @@ -199,6 +201,18 @@ impl Track { } } +/// A queue captured for persistence. +/// +/// `shuffled_indices` carries the current play order, so restoring a shuffled +/// queue keeps the order it was played in instead of reshuffling the tail. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueueSnapshot { + pub tracks: Vec, + pub position: usize, + pub shuffle: ShuffleMode, + pub shuffled_indices: Vec, +} + pub struct Queue { tracks: Vec, position: usize, @@ -591,4 +605,42 @@ impl Queue { self.position = 0; self.shuffled_indices.clear(); } + + /// Everything needed to rebuild this queue exactly. + pub fn snapshot(&self) -> QueueSnapshot { + QueueSnapshot { + tracks: self.tracks.clone(), + position: self.position, + shuffle: self.shuffle, + shuffled_indices: self.shuffled_indices.clone(), + } + } + + pub fn from_snapshot(snapshot: QueueSnapshot) -> Self { + let mut queue = Queue::new(); + queue.tracks = snapshot.tracks; + queue.shuffle = snapshot.shuffle; + queue.position = if queue.tracks.is_empty() { + 0 + } else { + snapshot.position.min(queue.tracks.len() - 1) + }; + // Trust the saved play order only when it still describes this queue; + // otherwise derive a new one instead of playing stale indices. + if queue.shuffle != ShuffleMode::None + && snapshot.shuffled_indices.len() == queue.tracks.len() + && Self::is_permutation(&snapshot.shuffled_indices) + { + queue.shuffled_indices = snapshot.shuffled_indices; + } else { + queue.update_shuffle_indices(); + } + queue + } + + fn is_permutation(indices: &[usize]) -> bool { + let mut sorted = indices.to_vec(); + sorted.sort_unstable(); + sorted.iter().enumerate().all(|(i, &idx)| i == idx) + } } diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..f89ec85 --- /dev/null +++ b/src/state.rs @@ -0,0 +1,200 @@ +/* + * 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::config; +use crate::queue::{QueueSnapshot, ShuffleMode, Track}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +/// Shape of the on-disk state. A file written by an incompatible version is +/// ignored, so an older or newer daemon never restores a queue it cannot +/// interpret. +const STATE_VERSION: u32 = 1; + +/// What the daemon needs to pick up where the previous session left off. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SavedState { + pub version: u32, + pub tracks: Vec, + pub position: usize, + pub shuffle: ShuffleMode, + pub shuffled_indices: Vec, + /// Playback was running when this was written, so the next start should + /// continue the current track instead of sitting idle. + pub playing: bool, +} + +impl SavedState { + pub fn new(snapshot: QueueSnapshot, playing: bool) -> Self { + Self { + version: STATE_VERSION, + tracks: snapshot.tracks, + position: snapshot.position, + shuffle: snapshot.shuffle, + shuffled_indices: snapshot.shuffled_indices, + playing, + } + } + + /// Rebuild a queue snapshot, dropping tracks that are gone from disk. + /// + /// Files disappear between sessions (deleted albums, unmounted drives), and + /// a queue full of dead paths would just fail on every play. + pub fn snapshot(&self) -> QueueSnapshot { + if self.tracks.iter().all(|t| t.is_stream || t.path.exists()) { + return QueueSnapshot { + tracks: self.tracks.clone(), + position: self.position, + shuffle: self.shuffle, + shuffled_indices: self.shuffled_indices.clone(), + }; + } + let tracks: Vec = self + .tracks + .iter() + .filter(|t| t.is_stream || t.path.exists()) + .cloned() + .collect(); + let len = tracks.len(); + QueueSnapshot { + tracks, + position: self.position.min(len.saturating_sub(1)), + shuffle: self.shuffle, + // The saved order refers to indices that no longer line up, so let + // the queue derive a fresh one rather than play the wrong tracks. + shuffled_indices: Vec::new(), + } + } +} + +pub fn state_path() -> PathBuf { + config::runtime_dir().join("state.json") +} + +pub fn load_from(path: &Path) -> Option { + let data = std::fs::read(path).ok()?; + let state: SavedState = serde_json::from_slice(&data).ok()?; + if state.version != STATE_VERSION { + return None; + } + Some(state) +} + +/// Write the state atomically: an interrupted write must not leave a truncated +/// file that the next start fails to parse. +pub fn save_to(path: &Path, state: &SavedState) -> std::io::Result<()> { + let tmp = path.with_extension("json.tmp"); + let data = serde_json::to_vec(state) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + std::fs::write(&tmp, data)?; + std::fs::rename(&tmp, path) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::queue::Queue; + + fn temp_path(name: &str) -> PathBuf { + std::env::temp_dir().join(format!("rsp-state-{}-{}.json", std::process::id(), name)) + } + + /// A real file, because restoring drops queue entries that no longer exist. + fn temp_track(name: &str) -> PathBuf { + let path = + std::env::temp_dir().join(format!("rsp-track-{}-{}.mp3", std::process::id(), name)); + std::fs::write(&path, b"not audio").expect("write temp file"); + path + } + + #[test] + fn saves_and_restores_the_queue_exactly() { + let files: Vec = (0..3).map(|i| temp_track(&format!("a{i}"))).collect(); + let mut queue = Queue::with_seed(7); + for file in &files { + queue.push_track(Track::from_path(file.clone())); + } + queue.set_shuffle(ShuffleMode::All); + queue.set_position(1); + + let path = temp_path("roundtrip"); + save_to(&path, &SavedState::new(queue.snapshot(), true)).expect("save"); + + let loaded = load_from(&path).expect("state is readable"); + assert!(loaded.playing, "play state survives the round trip"); + + let restored = Queue::from_snapshot(loaded.snapshot()); + assert_eq!(restored.len(), 3); + assert_eq!(restored.position(), 1); + assert_eq!(restored.shuffle_mode(), &ShuffleMode::All); + assert_eq!(restored.shuffled_indices(), queue.shuffled_indices()); + assert_eq!( + restored.current().map(|t| t.path.clone()), + queue.current().map(|t| t.path.clone()), + "the current track is where playback left off" + ); + + std::fs::remove_file(&path).ok(); + for file in files { + std::fs::remove_file(file).ok(); + } + } + + #[test] + fn missing_tracks_are_dropped_on_restore() { + let kept = temp_track("kept"); + let mut queue = Queue::new(); + queue.push_track(Track::from_path(PathBuf::from("/nonexistent/gone.mp3"))); + queue.push_track(Track::from_path(kept.clone())); + queue.set_position(1); + + let restored = Queue::from_snapshot(SavedState::new(queue.snapshot(), false).snapshot()); + + assert_eq!(restored.len(), 1); + assert_eq!( + restored.current().map(|t| t.path.clone()), + Some(kept.clone()) + ); + + std::fs::remove_file(kept).ok(); + } + + #[test] + fn rebuilds_a_shuffle_order_that_no_longer_fits() { + let files: Vec = (0..3).map(|i| temp_track(&format!("b{i}"))).collect(); + let snapshot = QueueSnapshot { + tracks: files.iter().map(|f| Track::from_path(f.clone())).collect(), + position: 0, + shuffle: ShuffleMode::All, + shuffled_indices: vec![0, 0, 0], + }; + + let queue = Queue::from_snapshot(snapshot); + + let mut order = queue.shuffled_indices().to_vec(); + order.sort_unstable(); + assert_eq!(order, vec![0, 1, 2], "a valid permutation replaces it"); + + for file in files { + std::fs::remove_file(file).ok(); + } + } + + #[test] + fn ignores_states_from_another_version() { + let path = temp_path("version"); + std::fs::write( + &path, + br#"{"version":99,"tracks":[],"position":0,"shuffle":"none","shuffled_indices":[],"playing":false}"#, + ) + .expect("write state"); + + assert!(load_from(&path).is_none()); + + std::fs::remove_file(&path).ok(); + } +} -- cgit v1.2.3