aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmon <salmonik@tuta.io>2026-08-12 02:56:18 +0200
committersalmon <salmonik@tuta.io>2026-08-12 02:56:18 +0200
commit8ec85d39c55769c564298782a51c6ec25dd6ba9f (patch)
treea55bda29f2f12afbb64eb5ef05a920fa19ca4d3e
parentclean up readme and make install.sh activate rc.d service (diff)
downloadrsplayer-8ec85d39c55769c564298782a51c6ec25dd6ba9f.tar.gz
rsplayer-8ec85d39c55769c564298782a51c6ec25dd6ba9f.zip
fix a bug where adding track using `now` when shuffle is on does not work as expected
-rw-r--r--src/daemon.rs14
-rw-r--r--src/queue.rs87
-rw-r--r--tests/queue.rs77
3 files changed, 149 insertions, 29 deletions
diff --git a/src/daemon.rs b/src/daemon.rs
index 21f1a3b..bdee362 100644
--- a/src/daemon.rs
+++ b/src/daemon.rs
@@ -1075,7 +1075,7 @@ fn process_command(
1075 let new_pos = q.position() + 1; 1075 let new_pos = q.position() + 1;
1076 q.set_position(new_pos); 1076 q.set_position(new_pos);
1077 } 1077 }
1078 if let Some(track) = q.track_at(q.position()) { 1078 if let Some(track) = q.current() {
1079 audio.play(track.path.clone()); 1079 audio.play(track.path.clone());
1080 spawn_send_now_playing(scrobbler, track); 1080 spawn_send_now_playing(scrobbler, track);
1081 if let Ok(mut s) = scrobble_state.lock() { 1081 if let Ok(mut s) = scrobble_state.lock() {
@@ -1181,12 +1181,12 @@ fn process_command(
1181 match position { 1181 match position {
1182 "now" => { 1182 "now" => {
1183 spawn_try_scrobble_current(scrobbler, scrobble_state); 1183 spawn_try_scrobble_current(scrobbler, scrobble_state);
1184 q.push_track(track.clone()); 1184 q.insert_after_current(PathBuf::from(path_str.clone()));
1185 if !was_empty { 1185 if !was_empty {
1186 let new_pos = q.position() + 1; 1186 let new_pos = q.position() + 1;
1187 q.set_position(new_pos); 1187 q.set_position(new_pos);
1188 } 1188 }
1189 if let Some(track) = q.track_at(q.position()) { 1189 if let Some(track) = q.current() {
1190 audio.play(track.path.clone()); 1190 audio.play(track.path.clone());
1191 spawn_send_now_playing(scrobbler, track); 1191 spawn_send_now_playing(scrobbler, track);
1192 if let Ok(mut s) = scrobble_state.lock() { 1192 if let Ok(mut s) = scrobble_state.lock() {
@@ -1199,7 +1199,7 @@ fn process_command(
1199 format!("Playing now: {}\n", path_str) 1199 format!("Playing now: {}\n", path_str)
1200 } 1200 }
1201 "next" => { 1201 "next" => {
1202 q.push_track(track); 1202 q.insert_after_current(PathBuf::from(path_str.clone()));
1203 format!("Queued next: {}\n", path_str) 1203 format!("Queued next: {}\n", path_str)
1204 } 1204 }
1205 _ => { 1205 _ => {
@@ -1237,7 +1237,7 @@ fn process_command(
1237 let new_pos = q.position() + 1; 1237 let new_pos = q.position() + 1;
1238 q.set_position(new_pos); 1238 q.set_position(new_pos);
1239 } 1239 }
1240 if let Some(track) = q.track_at(q.position()) { 1240 if let Some(track) = q.current() {
1241 audio.play(track.path.clone()); 1241 audio.play(track.path.clone());
1242 spawn_send_now_playing(scrobbler, track); 1242 spawn_send_now_playing(scrobbler, track);
1243 if let Ok(mut s) = scrobble_state.lock() { 1243 if let Ok(mut s) = scrobble_state.lock() {
@@ -1291,7 +1291,7 @@ fn process_command(
1291 let new_pos = q.position() + 1; 1291 let new_pos = q.position() + 1;
1292 q.set_position(new_pos); 1292 q.set_position(new_pos);
1293 } 1293 }
1294 if let Some(track) = q.track_at(q.position()) { 1294 if let Some(track) = q.current() {
1295 audio.play(track.path.clone()); 1295 audio.play(track.path.clone());
1296 spawn_send_now_playing(scrobbler, track); 1296 spawn_send_now_playing(scrobbler, track);
1297 if let Ok(mut s) = scrobble_state.lock() { 1297 if let Ok(mut s) = scrobble_state.lock() {
@@ -1389,7 +1389,7 @@ fn process_command(
1389 let new_pos = q.position() + 1; 1389 let new_pos = q.position() + 1;
1390 q.set_position(new_pos); 1390 q.set_position(new_pos);
1391 } 1391 }
1392 if let Some(track) = q.track_at(q.position()) { 1392 if let Some(track) = q.current() {
1393 audio.play(track.path.clone()); 1393 audio.play(track.path.clone());
1394 spawn_send_now_playing(scrobbler, track); 1394 spawn_send_now_playing(scrobbler, track);
1395 if let Ok(mut s) = scrobble_state.lock() { 1395 if let Ok(mut s) = scrobble_state.lock() {
diff --git a/src/queue.rs b/src/queue.rs
index c3f8dbe..da79441 100644
--- a/src/queue.rs
+++ b/src/queue.rs
@@ -5,7 +5,9 @@
5 * https://codeberg.org/salmonik/RSPlayer 5 * https://codeberg.org/salmonik/RSPlayer
6*/ 6*/
7 7
8use rand::rngs::StdRng;
8use rand::seq::SliceRandom; 9use rand::seq::SliceRandom;
10use rand::SeedableRng;
9use std::path::{Path, PathBuf}; 11use std::path::{Path, PathBuf};
10 12
11#[derive(Debug, Clone, PartialEq)] 13#[derive(Debug, Clone, PartialEq)]
@@ -202,6 +204,7 @@ pub struct Queue {
202 position: usize, 204 position: usize,
203 shuffle: ShuffleMode, 205 shuffle: ShuffleMode,
204 shuffled_indices: Vec<usize>, 206 shuffled_indices: Vec<usize>,
207 rng: StdRng,
205} 208}
206 209
207impl Default for Queue { 210impl Default for Queue {
@@ -217,6 +220,20 @@ impl Queue {
217 position: 0, 220 position: 0,
218 shuffle: ShuffleMode::None, 221 shuffle: ShuffleMode::None,
219 shuffled_indices: Vec::new(), 222 shuffled_indices: Vec::new(),
223 rng: rand::make_rng(),
224 }
225 }
226
227 /// Construct a queue with a fixed RNG seed, making shuffle order fully
228 /// reproducible. Used by tests and handy for debugging a specific order.
229 #[allow(dead_code)] // used only by integration tests (bin crate does not link the lib)
230 pub fn with_seed(seed: u64) -> Self {
231 Queue {
232 tracks: Vec::new(),
233 position: 0,
234 shuffle: ShuffleMode::None,
235 shuffled_indices: Vec::new(),
236 rng: StdRng::seed_from_u64(seed),
220 } 237 }
221 } 238 }
222 239
@@ -225,7 +242,6 @@ impl Queue {
225 self.update_shuffle_indices(); 242 self.update_shuffle_indices();
226 } 243 }
227 244
228 #[allow(dead_code)]
229 pub fn push_track(&mut self, track: Track) { 245 pub fn push_track(&mut self, track: Track) {
230 self.tracks.push(track); 246 self.tracks.push(track);
231 self.update_shuffle_indices(); 247 self.update_shuffle_indices();
@@ -234,11 +250,27 @@ impl Queue {
234 pub fn insert_after_current(&mut self, path: PathBuf) { 250 pub fn insert_after_current(&mut self, path: PathBuf) {
235 if self.tracks.is_empty() { 251 if self.tracks.is_empty() {
236 self.tracks.push(Track::from_path(path)); 252 self.tracks.push(Track::from_path(path));
237 } else { 253 self.update_shuffle_indices();
238 let insert_idx = self.position + 1; 254 return;
239 self.tracks.insert(insert_idx, Track::from_path(path));
240 } 255 }
241 self.update_shuffle_indices(); 256 let insert_idx = self.position + 1;
257 self.tracks.insert(insert_idx, Track::from_path(path));
258 if self.shuffle == ShuffleMode::None {
259 self.update_shuffle_indices();
260 return;
261 }
262 // Under shuffle, splice the new entry into the play order right after
263 // the current slot instead of re-shuffling the tail: the inserted
264 // track deterministically occupies slot `position + 1`, so "play
265 // now/next" is meaningful and `current()` agrees with what the daemon
266 // starts playing. The remaining play order is preserved (indices that
267 // shifted right in `tracks` are bumped), not re-randomized.
268 for v in self.shuffled_indices.iter_mut() {
269 if *v >= insert_idx {
270 *v += 1;
271 }
272 }
273 self.shuffled_indices.insert(self.position + 1, insert_idx);
242 } 274 }
243 275
244 pub fn track_at(&self, idx: usize) -> Option<&Track> { 276 pub fn track_at(&self, idx: usize) -> Option<&Track> {
@@ -309,10 +341,9 @@ impl Queue {
309 self.shuffled_indices = (0..len).collect(); 341 self.shuffled_indices = (0..len).collect();
310 match self.shuffle { 342 match self.shuffle {
311 ShuffleMode::All => { 343 ShuffleMode::All => {
312 let mut rng = rand::rng();
313 let pos = self.position.min(len.saturating_sub(1)); 344 let pos = self.position.min(len.saturating_sub(1));
314 if pos + 1 < len { 345 if pos + 1 < len {
315 self.shuffled_indices[pos + 1..].shuffle(&mut rng); 346 self.shuffled_indices[pos + 1..].shuffle(&mut self.rng);
316 } 347 }
317 } 348 }
318 ShuffleMode::Album => { 349 ShuffleMode::Album => {
@@ -330,10 +361,14 @@ impl Queue {
330 self.shuffled_indices = (0..len).collect(); 361 self.shuffled_indices = (0..len).collect();
331 362
332 if len > 1 { 363 if len > 1 {
333 let mut rng = rand::rng();
334 let pos = self.position.min(len.saturating_sub(1)); 364 let pos = self.position.min(len.saturating_sub(1));
335 if pos + 1 < len { 365 if pos + 1 < len {
336 self.shuffle_album_range(pos + 1..len, &mut rng); 366 Self::shuffle_album_range(
367 &self.tracks,
368 &mut self.shuffled_indices,
369 pos + 1..len,
370 &mut self.rng,
371 );
337 } 372 }
338 } 373 }
339 } 374 }
@@ -352,7 +387,6 @@ impl Queue {
352 } 387 }
353 388
354 let range_start = pos + 1; 389 let range_start = pos + 1;
355 let mut rng = rand::rng();
356 390
357 // Build album groups within the range, shuffling tracks within each group 391 // Build album groups within the range, shuffling tracks within each group
358 let mut groups: Vec<Vec<usize>> = Vec::new(); 392 let mut groups: Vec<Vec<usize>> = Vec::new();
@@ -368,7 +402,7 @@ impl Queue {
368 } else if album != current_album { 402 } else if album != current_album {
369 // Shuffle tracks within this album group before adding it 403 // Shuffle tracks within this album group before adding it
370 if current_group.len() > 1 { 404 if current_group.len() > 1 {
371 current_group.shuffle(&mut rng); 405 current_group.shuffle(&mut self.rng);
372 } 406 }
373 groups.push(std::mem::take(&mut current_group)); 407 groups.push(std::mem::take(&mut current_group));
374 current_group.push(i); 408 current_group.push(i);
@@ -379,13 +413,13 @@ impl Queue {
379 } 413 }
380 if !current_group.is_empty() { 414 if !current_group.is_empty() {
381 if current_group.len() > 1 { 415 if current_group.len() > 1 {
382 current_group.shuffle(&mut rng); 416 current_group.shuffle(&mut self.rng);
383 } 417 }
384 groups.push(current_group); 418 groups.push(current_group);
385 } 419 }
386 420
387 // Shuffle the album group order 421 // Shuffle the album group order
388 groups.shuffle(&mut rng); 422 groups.shuffle(&mut self.rng);
389 423
390 let mut new_indices: Vec<usize> = self.shuffled_indices[..=pos].to_vec(); 424 let mut new_indices: Vec<usize> = self.shuffled_indices[..=pos].to_vec();
391 for group in &groups { 425 for group in &groups {
@@ -394,7 +428,12 @@ impl Queue {
394 self.shuffled_indices = new_indices; 428 self.shuffled_indices = new_indices;
395 } 429 }
396 430
397 fn shuffle_album_range(&mut self, range: std::ops::Range<usize>, rng: &mut impl rand::Rng) { 431 fn shuffle_album_range(
432 tracks: &[Track],
433 indices: &mut [usize],
434 range: std::ops::Range<usize>,
435 rng: &mut impl rand::Rng,
436 ) {
398 if range.start >= range.end { 437 if range.start >= range.end {
399 return; 438 return;
400 } 439 }
@@ -404,7 +443,7 @@ impl Queue {
404 let mut range_start: Option<usize> = None; 443 let mut range_start: Option<usize> = None;
405 444
406 for i in range.clone() { 445 for i in range.clone() {
407 let album = self.tracks.get(i).and_then(|t| t.album.as_deref()); 446 let album = tracks.get(i).and_then(|t| t.album.as_deref());
408 match (current_album, range_start) { 447 match (current_album, range_start) {
409 (None, None) => { 448 (None, None) => {
410 current_album = album; 449 current_album = album;
@@ -424,7 +463,7 @@ impl Queue {
424 463
425 for (start, end) in album_ranges { 464 for (start, end) in album_ranges {
426 if start + 1 < end { 465 if start + 1 < end {
427 self.shuffled_indices[start..end].shuffle(rng); 466 indices[start..end].shuffle(rng);
428 } 467 }
429 } 468 }
430 } 469 }
@@ -498,13 +537,17 @@ impl Queue {
498 if self.position != 0 || self.tracks.len() <= 1 { 537 if self.position != 0 || self.tracks.len() <= 1 {
499 return; 538 return;
500 } 539 }
501 let mut rng = rand::rng();
502 match self.shuffle { 540 match self.shuffle {
503 ShuffleMode::All => { 541 ShuffleMode::All => {
504 self.shuffled_indices.shuffle(&mut rng); 542 self.shuffled_indices.shuffle(&mut self.rng);
505 } 543 }
506 ShuffleMode::Album => { 544 ShuffleMode::Album => {
507 self.shuffle_album_range(0..self.tracks.len(), &mut rng); 545 Self::shuffle_album_range(
546 &self.tracks,
547 &mut self.shuffled_indices,
548 0..self.tracks.len(),
549 &mut self.rng,
550 );
508 } 551 }
509 ShuffleMode::Albums => { 552 ShuffleMode::Albums => {
510 let mut groups: Vec<Vec<usize>> = Vec::new(); 553 let mut groups: Vec<Vec<usize>> = Vec::new();
@@ -519,7 +562,7 @@ impl Queue {
519 current_album = album; 562 current_album = album;
520 } else if album != current_album { 563 } else if album != current_album {
521 if current_group.len() > 1 { 564 if current_group.len() > 1 {
522 current_group.shuffle(&mut rng); 565 current_group.shuffle(&mut self.rng);
523 } 566 }
524 groups.push(std::mem::take(&mut current_group)); 567 groups.push(std::mem::take(&mut current_group));
525 current_group.push(i); 568 current_group.push(i);
@@ -530,12 +573,12 @@ impl Queue {
530 } 573 }
531 if !current_group.is_empty() { 574 if !current_group.is_empty() {
532 if current_group.len() > 1 { 575 if current_group.len() > 1 {
533 current_group.shuffle(&mut rng); 576 current_group.shuffle(&mut self.rng);
534 } 577 }
535 groups.push(current_group); 578 groups.push(current_group);
536 } 579 }
537 580
538 groups.shuffle(&mut rng); 581 groups.shuffle(&mut self.rng);
539 582
540 self.shuffled_indices = groups.concat(); 583 self.shuffled_indices = groups.concat();
541 } 584 }
diff --git a/tests/queue.rs b/tests/queue.rs
index d338f2a..67cbabf 100644
--- a/tests/queue.rs
+++ b/tests/queue.rs
@@ -567,6 +567,83 @@ fn test_shuffle_off_keeps_unplayed_duplicate_entries() {
567 assert_eq!(q.position(), 1); 567 assert_eq!(q.position(), 1);
568} 568}
569 569
570#[test]
571fn test_shuffle_play_now_current_is_inserted_track() {
572 // BUG: under shuffle, `insert_after_current` re-shuffles the tail
573 // (`update_shuffle_indices`), so the inserted track's play-order slot is a
574 // random draw instead of `position + 1`. The daemon's "now" flow (insert →
575 // `set_position(pos+1)` → play `track_at(position)`, src/daemon.rs) then
576 // starts the right file while `current()`/`next()`/the queue display
577 // follow a *different* track, and auto-advance can replay or skip the
578 // inserted track entirely.
579 //
580 // Contract: after the "now" sequence, `current()` must be the inserted
581 // track. The seed sweep keeps the test deterministic — for a fixed seed the
582 // outcome never varies between runs, and pre-fix a given seed satisfies the
583 // invariant with probability 1/(tail size) = 1/3, so the chance all 40
584 // seeds accidentally pass is ~3e-19. The fix makes the invariant hold
585 // structurally (insert into the play order at `position+1`), independent
586 // of the seed.
587 let inserted = PathBuf::from("/music/new.mp3");
588 for seed in 1..=40u64 {
589 let mut q = Queue::with_seed(seed);
590 add_track(&mut q, "/music/a.mp3");
591 add_track(&mut q, "/music/b.mp3");
592 add_track(&mut q, "/music/c.mp3");
593 q.set_shuffle(ShuffleMode::All);
594
595 // `rsp play --now new.mp3` with a non-empty queue: insert after the
596 // current track and move the cursor onto it.
597 q.insert_after_current(inserted.clone());
598 let new_pos = q.position() + 1;
599 q.set_position(new_pos);
600
601 assert_eq!(q.position(), 1);
602 let cur = q.current().expect("queue is not empty");
603 assert_eq!(
604 cur.path, inserted,
605 "seed {}: current() after play-now is {:?}, expected {:?}; shuffled_indices={:?}",
606 seed,
607 cur.path,
608 inserted,
609 q.shuffled_indices()
610 );
611 }
612}
613
614// ---------- Seedable RNG ----------
615// The queue uses a seeded PRNG (rand::rngs::StdRng) instead of the OS-seeded
616// thread RNG, so shuffle order is reproducible via Queue::with_seed. Shuffle
617// is not a security domain — a PRNG suffices, and deterministic tests need a
618// fixed seed.
619
620#[test]
621fn test_same_seed_produces_same_shuffle_order() {
622 let mut q1 = Queue::with_seed(0x5EED);
623 let mut q2 = Queue::with_seed(0x5EED);
624 for i in 0..10 {
625 add_track(&mut q1, &format!("/music/t{}.mp3", i));
626 add_track(&mut q2, &format!("/music/t{}.mp3", i));
627 }
628 q1.set_shuffle(ShuffleMode::All);
629 q2.set_shuffle(ShuffleMode::All);
630 assert_eq!(q1.shuffled_indices(), q2.shuffled_indices());
631}
632
633#[test]
634fn test_different_seed_produces_different_shuffle_order() {
635 let mut q1 = Queue::with_seed(1);
636 let mut q2 = Queue::with_seed(2);
637 for i in 0..20 {
638 add_track(&mut q1, &format!("/music/t{}.mp3", i));
639 add_track(&mut q2, &format!("/music/t{}.mp3", i));
640 }
641 q1.set_shuffle(ShuffleMode::All);
642 q2.set_shuffle(ShuffleMode::All);
643 // 20! permutations — identical order for two seeds is practically impossible.
644 assert_ne!(q1.shuffled_indices(), q2.shuffled_indices());
645}
646
570// ---------- Track struct ---------- 647// ---------- Track struct ----------
571 648
572#[test] 649#[test]