diff options
| author | salmonik <salmonik@tuta.io> | 2026-11-02 13:12:48 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-11-02 13:12:48 +0100 |
| commit | 863b4017714130f042f993e7c9d0f3f8adee22bf (patch) | |
| tree | e5ae8094f6be4f0c0a5332a9ca4bb95c8bf87b18 /src/state.rs | |
| parent | queue: use let chains and tidy imports (diff) | |
| download | rsplayer-863b4017714130f042f993e7c9d0f3f8adee22bf.tar.gz rsplayer-863b4017714130f042f993e7c9d0f3f8adee22bf.zip | |
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.
Diffstat (limited to 'src/state.rs')
| -rw-r--r-- | src/state.rs | 200 |
1 files changed, 200 insertions, 0 deletions
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 @@ | |||
| 1 | /* | ||
| 2 | * This file is a part of RSPlayer | ||
| 3 | * Licensed under the GPL-3.0 License | ||
| 4 | * Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 5 | * https://codeberg.org/salmonik/RSPlayer | ||
| 6 | */ | ||
| 7 | |||
| 8 | use crate::config; | ||
| 9 | use crate::queue::{QueueSnapshot, ShuffleMode, Track}; | ||
| 10 | use serde::{Deserialize, Serialize}; | ||
| 11 | use std::path::{Path, PathBuf}; | ||
| 12 | |||
| 13 | /// Shape of the on-disk state. A file written by an incompatible version is | ||
| 14 | /// ignored, so an older or newer daemon never restores a queue it cannot | ||
| 15 | /// interpret. | ||
| 16 | const STATE_VERSION: u32 = 1; | ||
| 17 | |||
| 18 | /// What the daemon needs to pick up where the previous session left off. | ||
| 19 | #[derive(Debug, Clone, Serialize, Deserialize)] | ||
| 20 | pub struct SavedState { | ||
| 21 | pub version: u32, | ||
| 22 | pub tracks: Vec<Track>, | ||
| 23 | pub position: usize, | ||
| 24 | pub shuffle: ShuffleMode, | ||
| 25 | pub shuffled_indices: Vec<usize>, | ||
| 26 | /// Playback was running when this was written, so the next start should | ||
| 27 | /// continue the current track instead of sitting idle. | ||
| 28 | pub playing: bool, | ||
| 29 | } | ||
| 30 | |||
| 31 | impl SavedState { | ||
| 32 | pub fn new(snapshot: QueueSnapshot, playing: bool) -> Self { | ||
| 33 | Self { | ||
| 34 | version: STATE_VERSION, | ||
| 35 | tracks: snapshot.tracks, | ||
| 36 | position: snapshot.position, | ||
| 37 | shuffle: snapshot.shuffle, | ||
| 38 | shuffled_indices: snapshot.shuffled_indices, | ||
| 39 | playing, | ||
| 40 | } | ||
| 41 | } | ||
| 42 | |||
| 43 | /// Rebuild a queue snapshot, dropping tracks that are gone from disk. | ||
| 44 | /// | ||
| 45 | /// Files disappear between sessions (deleted albums, unmounted drives), and | ||
| 46 | /// a queue full of dead paths would just fail on every play. | ||
| 47 | pub fn snapshot(&self) -> QueueSnapshot { | ||
| 48 | if self.tracks.iter().all(|t| t.is_stream || t.path.exists()) { | ||
| 49 | return QueueSnapshot { | ||
| 50 | tracks: self.tracks.clone(), | ||
| 51 | position: self.position, | ||
| 52 | shuffle: self.shuffle, | ||
| 53 | shuffled_indices: self.shuffled_indices.clone(), | ||
| 54 | }; | ||
| 55 | } | ||
| 56 | let tracks: Vec<Track> = self | ||
| 57 | .tracks | ||
| 58 | .iter() | ||
| 59 | .filter(|t| t.is_stream || t.path.exists()) | ||
| 60 | .cloned() | ||
| 61 | .collect(); | ||
| 62 | let len = tracks.len(); | ||
| 63 | QueueSnapshot { | ||
| 64 | tracks, | ||
| 65 | position: self.position.min(len.saturating_sub(1)), | ||
| 66 | shuffle: self.shuffle, | ||
| 67 | // The saved order refers to indices that no longer line up, so let | ||
| 68 | // the queue derive a fresh one rather than play the wrong tracks. | ||
| 69 | shuffled_indices: Vec::new(), | ||
| 70 | } | ||
| 71 | } | ||
| 72 | } | ||
| 73 | |||
| 74 | pub fn state_path() -> PathBuf { | ||
| 75 | config::runtime_dir().join("state.json") | ||
| 76 | } | ||
| 77 | |||
| 78 | pub fn load_from(path: &Path) -> Option<SavedState> { | ||
| 79 | let data = std::fs::read(path).ok()?; | ||
| 80 | let state: SavedState = serde_json::from_slice(&data).ok()?; | ||
| 81 | if state.version != STATE_VERSION { | ||
| 82 | return None; | ||
| 83 | } | ||
| 84 | Some(state) | ||
| 85 | } | ||
| 86 | |||
| 87 | /// Write the state atomically: an interrupted write must not leave a truncated | ||
| 88 | /// file that the next start fails to parse. | ||
| 89 | pub fn save_to(path: &Path, state: &SavedState) -> std::io::Result<()> { | ||
| 90 | let tmp = path.with_extension("json.tmp"); | ||
| 91 | let data = serde_json::to_vec(state) | ||
| 92 | .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; | ||
| 93 | std::fs::write(&tmp, data)?; | ||
| 94 | std::fs::rename(&tmp, path) | ||
| 95 | } | ||
| 96 | |||
| 97 | #[cfg(test)] | ||
| 98 | mod tests { | ||
| 99 | use super::*; | ||
| 100 | use crate::queue::Queue; | ||
| 101 | |||
| 102 | fn temp_path(name: &str) -> PathBuf { | ||
| 103 | std::env::temp_dir().join(format!("rsp-state-{}-{}.json", std::process::id(), name)) | ||
| 104 | } | ||
| 105 | |||
| 106 | /// A real file, because restoring drops queue entries that no longer exist. | ||
| 107 | fn temp_track(name: &str) -> PathBuf { | ||
| 108 | let path = | ||
| 109 | std::env::temp_dir().join(format!("rsp-track-{}-{}.mp3", std::process::id(), name)); | ||
| 110 | std::fs::write(&path, b"not audio").expect("write temp file"); | ||
| 111 | path | ||
| 112 | } | ||
| 113 | |||
| 114 | #[test] | ||
| 115 | fn saves_and_restores_the_queue_exactly() { | ||
| 116 | let files: Vec<PathBuf> = (0..3).map(|i| temp_track(&format!("a{i}"))).collect(); | ||
| 117 | let mut queue = Queue::with_seed(7); | ||
| 118 | for file in &files { | ||
| 119 | queue.push_track(Track::from_path(file.clone())); | ||
| 120 | } | ||
| 121 | queue.set_shuffle(ShuffleMode::All); | ||
| 122 | queue.set_position(1); | ||
| 123 | |||
| 124 | let path = temp_path("roundtrip"); | ||
| 125 | save_to(&path, &SavedState::new(queue.snapshot(), true)).expect("save"); | ||
| 126 | |||
| 127 | let loaded = load_from(&path).expect("state is readable"); | ||
| 128 | assert!(loaded.playing, "play state survives the round trip"); | ||
| 129 | |||
| 130 | let restored = Queue::from_snapshot(loaded.snapshot()); | ||
| 131 | assert_eq!(restored.len(), 3); | ||
| 132 | assert_eq!(restored.position(), 1); | ||
| 133 | assert_eq!(restored.shuffle_mode(), &ShuffleMode::All); | ||
| 134 | assert_eq!(restored.shuffled_indices(), queue.shuffled_indices()); | ||
| 135 | assert_eq!( | ||
| 136 | restored.current().map(|t| t.path.clone()), | ||
| 137 | queue.current().map(|t| t.path.clone()), | ||
| 138 | "the current track is where playback left off" | ||
| 139 | ); | ||
| 140 | |||
| 141 | std::fs::remove_file(&path).ok(); | ||
| 142 | for file in files { | ||
| 143 | std::fs::remove_file(file).ok(); | ||
| 144 | } | ||
| 145 | } | ||
| 146 | |||
| 147 | #[test] | ||
| 148 | fn missing_tracks_are_dropped_on_restore() { | ||
| 149 | let kept = temp_track("kept"); | ||
| 150 | let mut queue = Queue::new(); | ||
| 151 | queue.push_track(Track::from_path(PathBuf::from("/nonexistent/gone.mp3"))); | ||
| 152 | queue.push_track(Track::from_path(kept.clone())); | ||
| 153 | queue.set_position(1); | ||
| 154 | |||
| 155 | let restored = Queue::from_snapshot(SavedState::new(queue.snapshot(), false).snapshot()); | ||
| 156 | |||
| 157 | assert_eq!(restored.len(), 1); | ||
| 158 | assert_eq!( | ||
| 159 | restored.current().map(|t| t.path.clone()), | ||
| 160 | Some(kept.clone()) | ||
| 161 | ); | ||
| 162 | |||
| 163 | std::fs::remove_file(kept).ok(); | ||
| 164 | } | ||
| 165 | |||
| 166 | #[test] | ||
| 167 | fn rebuilds_a_shuffle_order_that_no_longer_fits() { | ||
| 168 | let files: Vec<PathBuf> = (0..3).map(|i| temp_track(&format!("b{i}"))).collect(); | ||
| 169 | let snapshot = QueueSnapshot { | ||
| 170 | tracks: files.iter().map(|f| Track::from_path(f.clone())).collect(), | ||
| 171 | position: 0, | ||
| 172 | shuffle: ShuffleMode::All, | ||
| 173 | shuffled_indices: vec![0, 0, 0], | ||
| 174 | }; | ||
| 175 | |||
| 176 | let queue = Queue::from_snapshot(snapshot); | ||
| 177 | |||
| 178 | let mut order = queue.shuffled_indices().to_vec(); | ||
| 179 | order.sort_unstable(); | ||
| 180 | assert_eq!(order, vec![0, 1, 2], "a valid permutation replaces it"); | ||
| 181 | |||
| 182 | for file in files { | ||
| 183 | std::fs::remove_file(file).ok(); | ||
| 184 | } | ||
| 185 | } | ||
| 186 | |||
| 187 | #[test] | ||
| 188 | fn ignores_states_from_another_version() { | ||
| 189 | let path = temp_path("version"); | ||
| 190 | std::fs::write( | ||
| 191 | &path, | ||
| 192 | br#"{"version":99,"tracks":[],"position":0,"shuffle":"none","shuffled_indices":[],"playing":false}"#, | ||
| 193 | ) | ||
| 194 | .expect("write state"); | ||
| 195 | |||
| 196 | assert!(load_from(&path).is_none()); | ||
| 197 | |||
| 198 | std::fs::remove_file(&path).ok(); | ||
| 199 | } | ||
| 200 | } | ||
