aboutsummaryrefslogtreecommitdiffstats
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
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.
-rw-r--r--TODO.md4
-rw-r--r--src/daemon.rs99
-rw-r--r--src/lib.rs1
-rw-r--r--src/main.rs1
-rw-r--r--src/mpris.rs6
-rw-r--r--src/queue.rs56
-rw-r--r--src/state.rs200
7 files changed, 355 insertions, 12 deletions
diff --git a/TODO.md b/TODO.md
index 55f7d52..291a4f4 100644
--- a/TODO.md
+++ b/TODO.md
@@ -1,6 +1,8 @@
1# TODO 1# TODO
2 2
3- **Persist queue state** - Save queue to disk so it survives daemon restarts. 3- [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
4
5- **Resume mid-track** - Store the playback offset with the queue state and seek there on restore (needs `rsp seek`)
4 6
5- [x] **Dynamic autocompletion of artists, albums and tracks** - `rsp complete <entity> [prefix]` queries DB directly; zsh completion wrapper concatenated via install.sh 7- [x] **Dynamic autocompletion of artists, albums and tracks** - `rsp complete <entity> [prefix]` queries DB directly; zsh completion wrapper concatenated via install.sh
6 8
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;
10use crate::notification; 10use crate::notification;
11use crate::queue::{Queue, ShuffleMode, Track}; 11use crate::queue::{Queue, ShuffleMode, Track};
12use crate::scrobbler::Scrobbler; 12use crate::scrobbler::Scrobbler;
13use crate::state;
13use reqwest::blocking::Client; 14use reqwest::blocking::Client;
14use serde::Deserialize; 15use serde::Deserialize;
15use std::error::Error; 16use std::error::Error;
@@ -231,7 +232,24 @@ pub fn start_daemon() -> Result<(), Box<dyn std::error::Error>> {
231 if let Some(vol) = config.volume { 232 if let Some(vol) = config.volume {
232 audio_engine.set_volume(vol as f32 / 100.0); 233 audio_engine.set_volume(vol as f32 / 100.0);
233 } 234 }
234 if let Some(ref shuffle_str) = config.shuffle 235
236 // Restore the previous session's queue. It beats the config default for
237 // shuffle because it is the newer choice, and it is what makes a restart
238 // (or a reboot) pick up where playback stopped.
239 let mut restored_queue = false;
240 let mut resume_track: Option<Track> = None;
241 if let Some(saved) = state::load_from(&state::state_path())
242 && let Ok(mut q) = queue.lock()
243 && !saved.tracks.is_empty()
244 {
245 *q = Queue::from_snapshot(saved.snapshot());
246 restored_queue = true;
247 if saved.playing {
248 resume_track = q.current().cloned();
249 }
250 }
251 if !restored_queue
252 && let Some(ref shuffle_str) = config.shuffle
235 && let Ok(mut q) = queue.lock() 253 && let Ok(mut q) = queue.lock()
236 { 254 {
237 match shuffle_str.as_str() { 255 match shuffle_str.as_str() {
@@ -270,6 +288,16 @@ pub fn start_daemon() -> Result<(), Box<dyn std::error::Error>> {
270 } 288 }
271 let scrobble_state = Arc::new(Mutex::new(ScrobbleState::new())); 289 let scrobble_state = Arc::new(Mutex::new(ScrobbleState::new()));
272 290
291 // Pick the restored track back up. No notification: this happens on daemon
292 // start (usually at login), nobody asked for a popup there.
293 if let Some(track) = resume_track {
294 audio_engine.play(track.path.clone());
295 spawn_send_now_playing(&scrobbler, &track);
296 if let Ok(mut s) = scrobble_state.lock() {
297 s.start(track);
298 }
299 }
300
273 // Save full config 301 // Save full config
274 config.save()?; 302 config.save()?;
275 303
@@ -308,6 +336,7 @@ pub fn start_daemon() -> Result<(), Box<dyn std::error::Error>> {
308 } 336 }
309 false 337 false
310 } 338 }
339 let mut last_state: Option<(usize, usize, ShuffleMode, bool)> = None;
311 loop { 340 loop {
312 // Check for shutdown signal 341 // Check for shutdown signal
313 if shutdown_flag.load(Ordering::SeqCst) { 342 if shutdown_flag.load(Ordering::SeqCst) {
@@ -356,6 +385,26 @@ pub fn start_daemon() -> Result<(), Box<dyn std::error::Error>> {
356 } 385 }
357 } 386 }
358 387
388 // Keep the saved state in step with the queue so a restart resumes
389 // here. The file is small and only rewritten when something moved.
390 let pending_save = if let Ok(q) = queue.lock() {
391 let playing = audio_engine.is_playing();
392 let fingerprint = (q.len(), q.position(), *q.shuffle_mode(), playing);
393 if last_state == Some(fingerprint) {
394 None
395 } else {
396 last_state = Some(fingerprint);
397 Some(state::SavedState::new(q.snapshot(), playing))
398 }
399 } else {
400 None
401 };
402 if let Some(saved) = pending_save
403 && let Err(e) = state::save_to(&state::state_path(), &saved)
404 {
405 eprintln!("Failed to save queue state: {}", e);
406 }
407
359 match listener.accept() { 408 match listener.accept() {
360 Ok((stream, _)) => { 409 Ok((stream, _)) => {
361 let audio_clone = Arc::clone(&audio_engine); 410 let audio_clone = Arc::clone(&audio_engine);
@@ -384,6 +433,14 @@ pub fn start_daemon() -> Result<(), Box<dyn std::error::Error>> {
384 thread::sleep(Duration::from_millis(50)); 433 thread::sleep(Duration::from_millis(50));
385 } 434 }
386 435
436 // Final write so a shutdown while paused keeps the current queue.
437 if let Ok(q) = queue.lock() {
438 let saved = state::SavedState::new(q.snapshot(), audio_engine.is_playing());
439 if let Err(e) = state::save_to(&state::state_path(), &saved) {
440 eprintln!("Failed to save queue state: {}", e);
441 }
442 }
443
387 // Cleanup 444 // Cleanup
388 cleanup_daemon(&socket_path, &pid_path); 445 cleanup_daemon(&socket_path, &pid_path);
389 println!("Daemon stopped"); 446 println!("Daemon stopped");
@@ -871,6 +928,32 @@ fn stream_synced_lyrics(
871 } 928 }
872} 929}
873 930
931/// Unpauses the stream, or starts the queue's current track when nothing is
932/// loaded in the engine yet — which is the state right after a daemon start
933/// with a restored queue, where a plain `resume` would otherwise do nothing.
934///
935/// Returns whether there was anything to resume.
936fn resume_queue_track(
937 audio: &AudioEngine,
938 queue: &Arc<Mutex<Queue>>,
939 scrobbler: &Arc<RwLock<Scrobbler>>,
940 scrobble_state: &Arc<Mutex<ScrobbleState>>,
941) -> bool {
942 if audio.current_track().is_some() {
943 audio.resume();
944 return true;
945 }
946 let Some(track) = queue.lock().ok().and_then(|q| q.current().cloned()) else {
947 return false;
948 };
949 audio.play(track.path.clone());
950 spawn_send_now_playing(scrobbler, &track);
951 if let Ok(mut s) = scrobble_state.lock() {
952 s.start(track);
953 }
954 true
955}
956
874fn process_command( 957fn process_command(
875 cmd: &str, 958 cmd: &str,
876 audio: &AudioEngine, 959 audio: &AudioEngine,
@@ -903,19 +986,23 @@ fn process_command(
903 "Paused\n".to_string() 986 "Paused\n".to_string()
904 } 987 }
905 "resume" => { 988 "resume" => {
906 audio.resume(); 989 if resume_queue_track(audio, queue, scrobbler, scrobble_state) {
907 notification::notify_change("Resumed"); 990 notification::notify_change("Resumed");
908 "Resumed\n".to_string() 991 "Resumed\n".to_string()
992 } else {
993 "Nothing to resume\n".to_string()
994 }
909 } 995 }
910 "toggle" => { 996 "toggle" => {
911 if audio.is_playing() { 997 if audio.is_playing() {
912 audio.pause(); 998 audio.pause();
913 notification::notify_change("Paused"); 999 notification::notify_change("Paused");
914 "Paused\n".to_string() 1000 "Paused\n".to_string()
915 } else { 1001 } else if resume_queue_track(audio, queue, scrobbler, scrobble_state) {
916 audio.resume();
917 notification::notify_change("Resumed"); 1002 notification::notify_change("Resumed");
918 "Resumed\n".to_string() 1003 "Resumed\n".to_string()
1004 } else {
1005 "Nothing to resume\n".to_string()
919 } 1006 }
920 } 1007 }
921 "stop" => { 1008 "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;
22pub mod playback; 22pub mod playback;
23pub mod queue; 23pub mod queue;
24pub mod scrobbler; 24pub mod scrobbler;
25pub 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;
15mod playback; 15mod playback;
16mod queue; 16mod queue;
17mod scrobbler; 17mod scrobbler;
18mod state;
18 19
19use clap::Parser; 20use clap::Parser;
20use cli::Cli; 21use 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 {
378 #[zbus(name = "Shuffle")] 378 #[zbus(name = "Shuffle")]
379 fn set_shuffle(&self, value: bool) { 379 fn set_shuffle(&self, value: bool) {
380 if let Ok(mut q) = self.queue.lock() { 380 if let Ok(mut q) = self.queue.lock() {
381 let current = q.shuffle_mode().clone(); 381 let current = *q.shuffle_mode();
382 let mode = if !value { 382 let mode = if !value {
383 ShuffleMode::None 383 ShuffleMode::None
384 } else if current == ShuffleMode::None { 384 } else if current == ShuffleMode::None {
385 ShuffleMode::All 385 ShuffleMode::All
386 } else { 386 } else {
387 current.clone() 387 current
388 }; 388 };
389 389
390 if mode != current { 390 if mode != current {
@@ -550,7 +550,7 @@ pub fn start_mpris_server(
550 let volume = poll_audio.get_volume() as f64 / 100.0; 550 let volume = poll_audio.get_volume() as f64 / 100.0;
551 let shuffle = poll_queue 551 let shuffle = poll_queue
552 .lock() 552 .lock()
553 .map(|q| q.shuffle_mode().clone()) 553 .map(|q| *q.shuffle_mode())
554 .unwrap_or(ShuffleMode::None); 554 .unwrap_or(ShuffleMode::None);
555 let loop_status_value = poll_loop_status 555 let loop_status_value = poll_loop_status
556 .lock() 556 .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 @@
8use rand::SeedableRng; 8use rand::SeedableRng;
9use rand::rngs::StdRng; 9use rand::rngs::StdRng;
10use rand::seq::SliceRandom; 10use rand::seq::SliceRandom;
11use serde::{Deserialize, Serialize};
11use std::path::{Path, PathBuf}; 12use std::path::{Path, PathBuf};
12 13
13#[derive(Debug, Clone, PartialEq)] 14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
15#[serde(rename_all = "lowercase")]
14pub enum ShuffleMode { 16pub enum ShuffleMode {
15 None, 17 None,
16 All, 18 All,
@@ -94,7 +96,7 @@ pub fn extract_metadata(path: &Path) -> Option<AudioMetadata> {
94 } 96 }
95} 97}
96 98
97#[derive(Debug, Clone)] 99#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct Track { 100pub struct Track {
99 pub path: PathBuf, 101 pub path: PathBuf,
100 pub title: Option<String>, 102 pub title: Option<String>,
@@ -199,6 +201,18 @@ impl Track {
199 } 201 }
200} 202}
201 203
204/// A queue captured for persistence.
205///
206/// `shuffled_indices` carries the current play order, so restoring a shuffled
207/// queue keeps the order it was played in instead of reshuffling the tail.
208#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct QueueSnapshot {
210 pub tracks: Vec<Track>,
211 pub position: usize,
212 pub shuffle: ShuffleMode,
213 pub shuffled_indices: Vec<usize>,
214}
215
202pub struct Queue { 216pub struct Queue {
203 tracks: Vec<Track>, 217 tracks: Vec<Track>,
204 position: usize, 218 position: usize,
@@ -591,4 +605,42 @@ impl Queue {
591 self.position = 0; 605 self.position = 0;
592 self.shuffled_indices.clear(); 606 self.shuffled_indices.clear();
593 } 607 }
608
609 /// Everything needed to rebuild this queue exactly.
610 pub fn snapshot(&self) -> QueueSnapshot {
611 QueueSnapshot {
612 tracks: self.tracks.clone(),
613 position: self.position,
614 shuffle: self.shuffle,
615 shuffled_indices: self.shuffled_indices.clone(),
616 }
617 }
618
619 pub fn from_snapshot(snapshot: QueueSnapshot) -> Self {
620 let mut queue = Queue::new();
621 queue.tracks = snapshot.tracks;
622 queue.shuffle = snapshot.shuffle;
623 queue.position = if queue.tracks.is_empty() {
624 0
625 } else {
626 snapshot.position.min(queue.tracks.len() - 1)
627 };
628 // Trust the saved play order only when it still describes this queue;
629 // otherwise derive a new one instead of playing stale indices.
630 if queue.shuffle != ShuffleMode::None
631 && snapshot.shuffled_indices.len() == queue.tracks.len()
632 && Self::is_permutation(&snapshot.shuffled_indices)
633 {
634 queue.shuffled_indices = snapshot.shuffled_indices;
635 } else {
636 queue.update_shuffle_indices();
637 }
638 queue
639 }
640
641 fn is_permutation(indices: &[usize]) -> bool {
642 let mut sorted = indices.to_vec();
643 sorted.sort_unstable();
644 sorted.iter().enumerate().all(|(i, &idx)| i == idx)
645 }
594} 646}
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}