aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <raisersoft@proton.me>2026-05-22 17:57:08 +0200
committersalmonik <raisersoft@proton.me>2026-05-22 17:57:08 +0200
commit956e3be3808f918923aef3259ea5b28bfc055b03 (patch)
treea49e441366c28960cbe0cd6c26c60fd866295fe5
parentfix for new symphony API and less debug info in release (diff)
downloadrsplayer-956e3be3808f918923aef3259ea5b28bfc055b03.tar.gz
rsplayer-956e3be3808f918923aef3259ea5b28bfc055b03.zip
Fix metadata extraction for symphonia 0.6 and add Albums shuffle mode
-rw-r--r--Cargo.toml2
-rw-r--r--completions/rsp.bash3
-rw-r--r--completions/rsp.fish1
-rw-r--r--completions/rsp.zsh2
-rw-r--r--src/config.rs2
-rw-r--r--src/daemon.rs7
-rw-r--r--src/playback.rs1
-rw-r--r--src/queue.rs130
-rw-r--r--tests/playback.rs2
-rw-r--r--tests/queue.rs105
10 files changed, 227 insertions, 28 deletions
diff --git a/Cargo.toml b/Cargo.toml
index 86a9b54..62acef4 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -23,7 +23,7 @@ directories = "6.0"
23serde = { version = "1.0.228", features = ["derive"] } 23serde = { version = "1.0.228", features = ["derive"] }
24toml = "1.1.2" 24toml = "1.1.2"
25reqwest = { version = "0.13.3", features = ["json", "form", "blocking"] } 25reqwest = { version = "0.13.3", features = ["json", "form", "blocking"] }
26symphonia = { version = "0.6", default-features = false, features = ["mp3", "flac", "vorbis", "wav", "ogg", "opt-simd"] } 26symphonia = { version = "0.6", default-features = false, features = ["mp3", "flac", "vorbis", "wav", "ogg", "opt-simd", "id3v1", "id3v2", "ape"] }
27cpal = "0.17.3" 27cpal = "0.17.3"
28chrono = "0.4.44" 28chrono = "0.4.44"
29serde_json = "1.0.149" 29serde_json = "1.0.149"
diff --git a/completions/rsp.bash b/completions/rsp.bash
index c4e295f..f5c87dc 100644
--- a/completions/rsp.bash
+++ b/completions/rsp.bash
@@ -766,7 +766,8 @@ _rsp() {
766 fi 766 fi
767 case "${prev}" in 767 case "${prev}" in
768 *) 768 *)
769 COMPREPLY=() 769 COMPREPLY=( $(compgen -W "on off all none album albums" -- "${cur}") )
770 return 0
770 ;; 771 ;;
771 esac 772 esac
772 COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 773 COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
diff --git a/completions/rsp.fish b/completions/rsp.fish
index 8764bb9..56f3766 100644
--- a/completions/rsp.fish
+++ b/completions/rsp.fish
@@ -56,6 +56,7 @@ complete -c rsp -n "__fish_rsp_using_subcommand next" -s h -l help -d 'Print hel
56complete -c rsp -n "__fish_rsp_using_subcommand previous" -s h -l help -d 'Print help' 56complete -c rsp -n "__fish_rsp_using_subcommand previous" -s h -l help -d 'Print help'
57complete -c rsp -n "__fish_rsp_using_subcommand volume" -s h -l help -d 'Print help' 57complete -c rsp -n "__fish_rsp_using_subcommand volume" -s h -l help -d 'Print help'
58complete -c rsp -n "__fish_rsp_using_subcommand shuffle" -s h -l help -d 'Print help' 58complete -c rsp -n "__fish_rsp_using_subcommand shuffle" -s h -l help -d 'Print help'
59complete -c rsp -n "__fish_rsp_using_subcommand shuffle" -f -a "on off all none album albums" -d 'Shuffle mode'
59complete -c rsp -n "__fish_rsp_using_subcommand queue" -s h -l help -d 'Print help' 60complete -c rsp -n "__fish_rsp_using_subcommand queue" -s h -l help -d 'Print help'
60complete -c rsp -n "__fish_rsp_using_subcommand status" -s h -l help -d 'Print help' 61complete -c rsp -n "__fish_rsp_using_subcommand status" -s h -l help -d 'Print help'
61complete -c rsp -n "__fish_rsp_using_subcommand scan" -l paths -r -F 62complete -c rsp -n "__fish_rsp_using_subcommand scan" -l paths -r -F
diff --git a/completions/rsp.zsh b/completions/rsp.zsh
index dd24572..87fbfcd 100644
--- a/completions/rsp.zsh
+++ b/completions/rsp.zsh
@@ -102,7 +102,7 @@ _arguments "${_arguments_options[@]}" : \
102_arguments "${_arguments_options[@]}" : \ 102_arguments "${_arguments_options[@]}" : \
103'-h[Print help]' \ 103'-h[Print help]' \
104'--help[Print help]' \ 104'--help[Print help]' \
105'::mode:_default' \ 105'::mode:(on off all none album albums)' \
106&& ret=0 106&& ret=0
107;; 107;;
108(queue) 108(queue)
diff --git a/src/config.rs b/src/config.rs
index 5fd2f1b..63b0164 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -200,7 +200,7 @@ impl Config {
200 self.volume = Some(value.parse()?); 200 self.volume = Some(value.parse()?);
201 } 201 }
202 "shuffle" => { 202 "shuffle" => {
203 if value == "on" || value == "all" || value == "album" { 203 if value == "on" || value == "all" || value == "album" || value == "albums" {
204 self.shuffle = Some(value.to_string()); 204 self.shuffle = Some(value.to_string());
205 } else if value == "off" || value == "none" { 205 } else if value == "off" || value == "none" {
206 self.shuffle = None; 206 self.shuffle = None;
diff --git a/src/daemon.rs b/src/daemon.rs
index 8b74357..b18ecc9 100644
--- a/src/daemon.rs
+++ b/src/daemon.rs
@@ -248,6 +248,7 @@ pub fn start_daemon() -> Result<(), Box<dyn std::error::Error>> {
248 match shuffle_str.as_str() { 248 match shuffle_str.as_str() {
249 "on" | "all" => q.set_shuffle(ShuffleMode::All), 249 "on" | "all" => q.set_shuffle(ShuffleMode::All),
250 "album" => q.set_shuffle(ShuffleMode::Album), 250 "album" => q.set_shuffle(ShuffleMode::Album),
251 "albums" => q.set_shuffle(ShuffleMode::Albums),
251 _ => {} 252 _ => {}
252 } 253 }
253 } 254 }
@@ -633,6 +634,7 @@ fn process_command(
633 ShuffleMode::None => "Shuffle: off\n".to_string(), 634 ShuffleMode::None => "Shuffle: off\n".to_string(),
634 ShuffleMode::All => "Shuffle: on (all)\n".to_string(), 635 ShuffleMode::All => "Shuffle: on (all)\n".to_string(),
635 ShuffleMode::Album => "Shuffle: on (album)\n".to_string(), 636 ShuffleMode::Album => "Shuffle: on (album)\n".to_string(),
637 ShuffleMode::Albums => "Shuffle: on (albums)\n".to_string(),
636 } 638 }
637 } else { 639 } else {
638 "Error accessing queue\n".to_string() 640 "Error accessing queue\n".to_string()
@@ -647,11 +649,14 @@ fn process_command(
647 } else if mode_str == "album" { 649 } else if mode_str == "album" {
648 q.set_shuffle(ShuffleMode::Album); 650 q.set_shuffle(ShuffleMode::Album);
649 "Shuffle: on (album)\n".to_string() 651 "Shuffle: on (album)\n".to_string()
652 } else if mode_str == "albums" {
653 q.set_shuffle(ShuffleMode::Albums);
654 "Shuffle: on (albums)\n".to_string()
650 } else if mode_str == "off" || mode_str == "none" { 655 } else if mode_str == "off" || mode_str == "none" {
651 q.set_shuffle(ShuffleMode::None); 656 q.set_shuffle(ShuffleMode::None);
652 "Shuffle: off\n".to_string() 657 "Shuffle: off\n".to_string()
653 } else { 658 } else {
654 "Usage: shuffle [on|off|album]\n".to_string() 659 "Usage: shuffle [on|off|album|albums]\n".to_string()
655 }; 660 };
656 // Save to config 661 // Save to config
657 if let Ok(mut config) = Config::load() { 662 if let Ok(mut config) = Config::load() {
diff --git a/src/playback.rs b/src/playback.rs
index d9dd1f9..f16bf8f 100644
--- a/src/playback.rs
+++ b/src/playback.rs
@@ -24,6 +24,7 @@ pub enum ShuffleMode {
24 None, 24 None,
25 All, 25 All,
26 Album, 26 Album,
27 Albums,
27} 28}
28 29
29#[allow(dead_code)] 30#[allow(dead_code)]
diff --git a/src/queue.rs b/src/queue.rs
index b0acf5f..9d09fec 100644
--- a/src/queue.rs
+++ b/src/queue.rs
@@ -6,6 +6,7 @@ pub enum ShuffleMode {
6 None, 6 None,
7 All, 7 All,
8 Album, 8 Album,
9 Albums,
9} 10}
10 11
11#[derive(Debug, Default, Clone)] 12#[derive(Debug, Default, Clone)]
@@ -46,28 +47,8 @@ pub fn extract_metadata(path: &Path) -> Option<AudioMetadata> {
46 }; 47 };
47 48
48 { 49 {
49 let meta = probed.metadata(); 50 let mut meta = probed.metadata();
50 if let Some(rev) = meta.current() { 51 if let Some(rev) = meta.skip_to_latest() {
51 for tag in &rev.media.tags {
52 match &tag.std {
53 Some(StandardTag::TrackTitle(value)) => {
54 metadata.title = Some(value.to_string());
55 }
56 Some(StandardTag::Artist(value)) => {
57 metadata.artist = Some(value.to_string());
58 }
59 Some(StandardTag::Album(value)) => {
60 metadata.album = Some(value.to_string());
61 }
62 _ => {}
63 }
64 }
65 }
66 }
67
68 if metadata.title.is_none() && metadata.artist.is_none() && metadata.album.is_none() {
69 let meta = probed.metadata();
70 if let Some(rev) = meta.current() {
71 for tag in &rev.media.tags { 52 for tag in &rev.media.tags {
72 match &tag.std { 53 match &tag.std {
73 Some(StandardTag::TrackTitle(value)) => { 54 Some(StandardTag::TrackTitle(value)) => {
@@ -183,6 +164,12 @@ impl Queue {
183 self.update_shuffle_indices(); 164 self.update_shuffle_indices();
184 } 165 }
185 166
167 #[allow(dead_code)]
168 pub fn push_track(&mut self, track: Track) {
169 self.tracks.push(track);
170 self.update_shuffle_indices();
171 }
172
186 pub fn insert_after_current(&mut self, path: PathBuf) { 173 pub fn insert_after_current(&mut self, path: PathBuf) {
187 if self.tracks.is_empty() { 174 if self.tracks.is_empty() {
188 self.tracks.push(Track::from_path(path)); 175 self.tracks.push(Track::from_path(path));
@@ -213,6 +200,11 @@ impl Queue {
213 self.shuffle = ShuffleMode::Album; 200 self.shuffle = ShuffleMode::Album;
214 self.update_shuffle_indices_album(); 201 self.update_shuffle_indices_album();
215 } 202 }
203 ShuffleMode::Albums => {
204 self.original_tracks = self.tracks.clone();
205 self.shuffle = ShuffleMode::Albums;
206 self.update_shuffle_indices_albums();
207 }
216 } 208 }
217 } 209 }
218 210
@@ -268,6 +260,9 @@ impl Queue {
268 ShuffleMode::Album => { 260 ShuffleMode::Album => {
269 self.update_shuffle_indices_album(); 261 self.update_shuffle_indices_album();
270 } 262 }
263 ShuffleMode::Albums => {
264 self.update_shuffle_indices_albums();
265 }
271 ShuffleMode::None => {} 266 ShuffleMode::None => {}
272 } 267 }
273 } 268 }
@@ -285,6 +280,62 @@ impl Queue {
285 } 280 }
286 } 281 }
287 282
283 fn update_shuffle_indices_albums(&mut self) {
284 let len = self.tracks.len();
285 self.shuffled_indices = (0..len).collect();
286
287 if len <= 1 {
288 return;
289 }
290
291 let pos = self.position.min(len.saturating_sub(1));
292 if pos + 1 >= len {
293 return;
294 }
295
296 let range_start = pos + 1;
297 let mut rng = rand::rng();
298
299 // Build album groups within the range, shuffling tracks within each group
300 let mut groups: Vec<Vec<usize>> = Vec::new();
301 let mut current_group: Vec<usize> = Vec::new();
302 let mut current_album: Option<&str> = None;
303
304 for i in range_start..len {
305 let album = self.tracks.get(i).and_then(|t| t.album.as_deref());
306
307 if current_group.is_empty() {
308 current_group.push(i);
309 current_album = album;
310 } else if album != current_album {
311 // Shuffle tracks within this album group before adding it
312 if current_group.len() > 1 {
313 current_group.shuffle(&mut rng);
314 }
315 groups.push(std::mem::take(&mut current_group));
316 current_group.push(i);
317 current_album = album;
318 } else {
319 current_group.push(i);
320 }
321 }
322 if !current_group.is_empty() {
323 if current_group.len() > 1 {
324 current_group.shuffle(&mut rng);
325 }
326 groups.push(current_group);
327 }
328
329 // Shuffle the album group order
330 groups.shuffle(&mut rng);
331
332 let mut new_indices: Vec<usize> = self.shuffled_indices[..=pos].to_vec();
333 for group in &groups {
334 new_indices.extend(group);
335 }
336 self.shuffled_indices = new_indices;
337 }
338
288 fn shuffle_album_range(&mut self, range: std::ops::Range<usize>, rng: &mut impl rand::Rng) { 339 fn shuffle_album_range(&mut self, range: std::ops::Range<usize>, rng: &mut impl rand::Rng) {
289 if range.start >= range.end { 340 if range.start >= range.end {
290 return; 341 return;
@@ -327,7 +378,7 @@ impl Queue {
327 self.shuffled_indices 378 self.shuffled_indices
328 .get(self.position) 379 .get(self.position)
329 .and_then(|&idx| self.tracks.get(idx)) 380 .and_then(|&idx| self.tracks.get(idx))
330 } else if self.shuffle == ShuffleMode::Album { 381 } else if self.shuffle == ShuffleMode::Album || self.shuffle == ShuffleMode::Albums {
331 self.shuffled_indices 382 self.shuffled_indices
332 .get(self.position) 383 .get(self.position)
333 .and_then(|&idx| self.tracks.get(idx)) 384 .and_then(|&idx| self.tracks.get(idx))
@@ -397,6 +448,39 @@ impl Queue {
397 ShuffleMode::Album => { 448 ShuffleMode::Album => {
398 self.shuffle_album_range(0..self.tracks.len(), &mut rng); 449 self.shuffle_album_range(0..self.tracks.len(), &mut rng);
399 } 450 }
451 ShuffleMode::Albums => {
452 let mut groups: Vec<Vec<usize>> = Vec::new();
453 let mut current_group: Vec<usize> = Vec::new();
454 let mut current_album: Option<&str> = None;
455
456 for i in 0..self.tracks.len() {
457 let album = self.tracks.get(i).and_then(|t| t.album.as_deref());
458
459 if current_group.is_empty() {
460 current_group.push(i);
461 current_album = album;
462 } else if album != current_album {
463 if current_group.len() > 1 {
464 current_group.shuffle(&mut rng);
465 }
466 groups.push(std::mem::take(&mut current_group));
467 current_group.push(i);
468 current_album = album;
469 } else {
470 current_group.push(i);
471 }
472 }
473 if !current_group.is_empty() {
474 if current_group.len() > 1 {
475 current_group.shuffle(&mut rng);
476 }
477 groups.push(current_group);
478 }
479
480 groups.shuffle(&mut rng);
481
482 self.shuffled_indices = groups.concat();
483 }
400 ShuffleMode::None => {} 484 ShuffleMode::None => {}
401 } 485 }
402 } 486 }
diff --git a/tests/playback.rs b/tests/playback.rs
index 80ecf58..1513a4d 100644
--- a/tests/playback.rs
+++ b/tests/playback.rs
@@ -127,6 +127,8 @@ fn test_player_set_shuffle() {
127 let mut player = Player::new(); 127 let mut player = Player::new();
128 player.set_shuffle(ShuffleMode::All); 128 player.set_shuffle(ShuffleMode::All);
129 assert_eq!(player.shuffle, ShuffleMode::All); 129 assert_eq!(player.shuffle, ShuffleMode::All);
130 player.set_shuffle(ShuffleMode::Albums);
131 assert_eq!(player.shuffle, ShuffleMode::Albums);
130} 132}
131 133
132#[test] 134#[test]
diff --git a/tests/queue.rs b/tests/queue.rs
index ccc6e54..2dd9f8c 100644
--- a/tests/queue.rs
+++ b/tests/queue.rs
@@ -286,6 +286,8 @@ fn test_shuffle_modes() {
286 assert_eq!(*q.shuffle_mode(), ShuffleMode::All); 286 assert_eq!(*q.shuffle_mode(), ShuffleMode::All);
287 q.set_shuffle(ShuffleMode::Album); 287 q.set_shuffle(ShuffleMode::Album);
288 assert_eq!(*q.shuffle_mode(), ShuffleMode::Album); 288 assert_eq!(*q.shuffle_mode(), ShuffleMode::Album);
289 q.set_shuffle(ShuffleMode::Albums);
290 assert_eq!(*q.shuffle_mode(), ShuffleMode::Albums);
289 q.set_shuffle(ShuffleMode::None); 291 q.set_shuffle(ShuffleMode::None);
290 assert_eq!(*q.shuffle_mode(), ShuffleMode::None); 292 assert_eq!(*q.shuffle_mode(), ShuffleMode::None);
291} 293}
@@ -486,6 +488,109 @@ fn test_track_no_metadata_fields() {
486 assert!(t.album.is_none()); 488 assert!(t.album.is_none());
487} 489}
488 490
491// ---------- Shuffle albums ----------
492
493#[test]
494fn test_shuffle_albums_groups_tracks_by_album() {
495 let mut q = Queue::new();
496 q.push_track(track(
497 "/music/a1.mp3",
498 Some("A1"),
499 Some("Artist"),
500 Some("Album A"),
501 ));
502 q.push_track(track(
503 "/music/a2.mp3",
504 Some("A2"),
505 Some("Artist"),
506 Some("Album A"),
507 ));
508 q.push_track(track(
509 "/music/b1.mp3",
510 Some("B1"),
511 Some("Artist"),
512 Some("Album B"),
513 ));
514 q.push_track(track(
515 "/music/b2.mp3",
516 Some("B2"),
517 Some("Artist"),
518 Some("Album B"),
519 ));
520 q.push_track(track(
521 "/music/c1.mp3",
522 Some("C1"),
523 Some("Artist"),
524 Some("Album C"),
525 ));
526 // Queue: Album A (a1, a2), Album B (b1, b2), Album C (c1)
527
528 q.set_shuffle(ShuffleMode::Albums);
529 q.shuffle_initial(); // full shuffle of everything (as happens at play start)
530
531 let shuffled = q.shuffled_indices().to_vec();
532 assert_eq!(shuffled.len(), 5);
533
534 // Tracks from the same album must appear as a contiguous block
535 let shuffled_tracks: Vec<_> = shuffled.iter().map(|&i| q.track_at(i).unwrap()).collect();
536
537 // Find positions of all tracks
538 let a_positions: Vec<_> = shuffled_tracks
539 .iter()
540 .enumerate()
541 .filter(|(_, t)| t.album.as_deref() == Some("Album A"))
542 .map(|(i, _)| i)
543 .collect();
544 let b_positions: Vec<_> = shuffled_tracks
545 .iter()
546 .enumerate()
547 .filter(|(_, t)| t.album.as_deref() == Some("Album B"))
548 .map(|(i, _)| i)
549 .collect();
550
551 assert_eq!(a_positions.len(), 2, "Album A should have 2 tracks");
552 assert_eq!(b_positions.len(), 2, "Album B should have 2 tracks");
553 assert_eq!(
554 a_positions[1] - a_positions[0],
555 1,
556 "Album A tracks should be contiguous"
557 );
558 assert_eq!(
559 b_positions[1] - b_positions[0],
560 1,
561 "Album B tracks should be contiguous"
562 );
563}
564
565#[test]
566fn test_shuffle_albums_solo_track_forms_own_group() {
567 let mut q = Queue::new();
568 q.push_track(track("/music/x.mp3", Some("X"), None, None));
569 q.push_track(track("/music/y.mp3", Some("Y"), None, Some("Album Y")));
570 q.push_track(track("/music/z.mp3", Some("Z"), None, Some("Album Y")));
571
572 q.set_shuffle(ShuffleMode::Albums);
573 let shuffled = q.shuffled_indices().to_vec();
574 assert_eq!(shuffled.len(), 3);
575
576 // Track without album should form its own group of size 1
577 // Tracks y and z share Album Y and should be adjacent
578 let shuffled_tracks: Vec<_> = shuffled.iter().map(|&i| q.track_at(i).unwrap()).collect();
579 let y_pos = shuffled_tracks
580 .iter()
581 .position(|t| t.title.as_deref() == Some("Y"))
582 .unwrap();
583 let z_pos = shuffled_tracks
584 .iter()
585 .position(|t| t.title.as_deref() == Some("Z"))
586 .unwrap();
587 assert_eq!(
588 (y_pos as isize - z_pos as isize).abs(),
589 1,
590 "tracks from same album must be adjacent"
591 );
592}
593
489// ---------- Shuffle restore ---------- 594// ---------- Shuffle restore ----------
490 595
491#[test] 596#[test]