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 | |
| 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.
| -rw-r--r-- | TODO.md | 4 | ||||
| -rw-r--r-- | src/daemon.rs | 99 | ||||
| -rw-r--r-- | src/lib.rs | 1 | ||||
| -rw-r--r-- | src/main.rs | 1 | ||||
| -rw-r--r-- | src/mpris.rs | 6 | ||||
| -rw-r--r-- | src/queue.rs | 56 | ||||
| -rw-r--r-- | src/state.rs | 200 |
7 files changed, 355 insertions, 12 deletions
| @@ -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; | |||
| 10 | use crate::notification; | 10 | use crate::notification; |
| 11 | use crate::queue::{Queue, ShuffleMode, Track}; | 11 | use crate::queue::{Queue, ShuffleMode, Track}; |
| 12 | use crate::scrobbler::Scrobbler; | 12 | use crate::scrobbler::Scrobbler; |
| 13 | use crate::state; | ||
| 13 | use reqwest::blocking::Client; | 14 | use reqwest::blocking::Client; |
| 14 | use serde::Deserialize; | 15 | use serde::Deserialize; |
| 15 | use std::error::Error; | 16 | use 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. | ||
| 936 | fn 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 | |||
| 874 | fn process_command( | 957 | fn 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" => { |
| @@ -22,3 +22,4 @@ pub mod notification; | |||
| 22 | pub mod playback; | 22 | pub mod playback; |
| 23 | pub mod queue; | 23 | pub mod queue; |
| 24 | pub mod scrobbler; | 24 | pub mod scrobbler; |
| 25 | pub 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; | |||
| 15 | mod playback; | 15 | mod playback; |
| 16 | mod queue; | 16 | mod queue; |
| 17 | mod scrobbler; | 17 | mod scrobbler; |
| 18 | mod state; | ||
| 18 | 19 | ||
| 19 | use clap::Parser; | 20 | use clap::Parser; |
| 20 | use cli::Cli; | 21 | use 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 @@ | |||
| 8 | use rand::SeedableRng; | 8 | use rand::SeedableRng; |
| 9 | use rand::rngs::StdRng; | 9 | use rand::rngs::StdRng; |
| 10 | use rand::seq::SliceRandom; | 10 | use rand::seq::SliceRandom; |
| 11 | use serde::{Deserialize, Serialize}; | ||
| 11 | use std::path::{Path, PathBuf}; | 12 | use 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")] | ||
| 14 | pub enum ShuffleMode { | 16 | pub 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)] |
| 98 | pub struct Track { | 100 | pub 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)] | ||
| 209 | pub struct QueueSnapshot { | ||
| 210 | pub tracks: Vec<Track>, | ||
| 211 | pub position: usize, | ||
| 212 | pub shuffle: ShuffleMode, | ||
| 213 | pub shuffled_indices: Vec<usize>, | ||
| 214 | } | ||
| 215 | |||
| 202 | pub struct Queue { | 216 | pub 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 | |||
| 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 | } | ||
