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. --- src/state.rs | 200 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 src/state.rs (limited to 'src/state.rs') 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