aboutsummaryrefslogtreecommitdiffstats
path: root/src/state.rs
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-02 13:12:48 +0100
committersalmonik <salmonik@tuta.io>2026-11-02 13:12:48 +0100
commit863b4017714130f042f993e7c9d0f3f8adee22bf (patch)
treee5ae8094f6be4f0c0a5332a9ca4bb95c8bf87b18 /src/state.rs
parentqueue: use let chains and tidy imports (diff)
downloadrsplayer-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.rs200
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
8use crate::config;
9use crate::queue::{QueueSnapshot, ShuffleMode, Track};
10use serde::{Deserialize, Serialize};
11use 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.
16const 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)]
20pub 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
31impl 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
74pub fn state_path() -> PathBuf {
75 config::runtime_dir().join("state.json")
76}
77
78pub 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.
89pub 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)]
98mod 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}