/* * 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(); } }