aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <raisersoft@proton.me>2026-05-08 00:40:40 +0200
committersalmonik <raisersoft@proton.me>2026-05-08 00:40:40 +0200
commitcfd0781ebae76447cc8072fd60883aabf8158d32 (patch)
tree9cb5bf7da4b1c4070cdb827cd0387968e09161eb
parentFix queue bugs: play -i on empty queue, stale track replay after queue ends, ... (diff)
downloadrsplayer-cfd0781ebae76447cc8072fd60883aabf8158d32.tar.gz
rsplayer-cfd0781ebae76447cc8072fd60883aabf8158d32.zip
Extract queue module, add comprehensive tests, fix queue/play bugs
• Moved Queue, Track, ShuffleMode, AudioMetadata from daemon.rs into new src/queue.rs module with public API\n• Fixed rsp play <file> interrupting active playback (now checks is_playing)\n• Fixed directory play -n/-i inserting files in reverse order (now uses .iter().rev())\n• Removed dead duplicate shuffle handler in process_command\n• Fixed queue display showing (playing) when playback is stopped\n• Added 34 tests covering navigation, shuffle, insert ordering, edge cases, display
Diffstat (limited to '')
-rw-r--r--TODO.md8
-rw-r--r--src/daemon.rs380
-rw-r--r--src/lib.rs1
-rw-r--r--src/main.rs1
-rw-r--r--src/queue.rs250
-rw-r--r--tests/queue.rs459
6 files changed, 793 insertions, 306 deletions
diff --git a/TODO.md b/TODO.md
index 68a0cf0..945f244 100644
--- a/TODO.md
+++ b/TODO.md
@@ -48,12 +48,16 @@
48 48
49- [x] **Shell completions** - Generate shell completions with `rsp completions bash|elvish|fish|powershell|zsh`. 49- [x] **Shell completions** - Generate shell completions with `rsp completions bash|elvish|fish|powershell|zsh`.
50 50
51- [x] **Extract queue module** - Moved `Queue`, `Track`, `ShuffleMode`, `AudioMetadata` from daemon into `src/queue.rs` with public API and accessor methods.
52
53- [x] **Comprehensive queue tests** - 34 tests covering navigation, shuffle, insert ordering, edge cases, and display.
54
55- [x] **Fix queue/play bugs** - `rsp play <file>` no longer interrupts playback when something is already playing; directory `play -n`/`play -i` now inserts in correct order; removed dead duplicate shuffle handler; queue display no longer shows `(playing)` when playback is stopped.
56
51## Remaining Work 57## Remaining Work
52 58
53- [ ] **Persist queue state** - Save queue to disk so it survives daemon restarts. 59- [ ] **Persist queue state** - Save queue to disk so it survives daemon restarts.
54 60
55- [ ] **Scrobbling (Last.fm/ListenBrainz)** - Add scrobbling support via configured credentials. 61- [ ] **Scrobbling (Last.fm/ListenBrainz)** - Add scrobbling support via configured credentials.
56 62
57- [ ] **Shell completion scripts** - Generate bash/zsh/fish completions using clap.
58
59- [ ] **Systemd service unit** - Create systemd service file for auto-start. \ No newline at end of file 63- [ ] **Systemd service unit** - Create systemd service file for auto-start. \ No newline at end of file
diff --git a/src/daemon.rs b/src/daemon.rs
index 9e02e3f..0921e11 100644
--- a/src/daemon.rs
+++ b/src/daemon.rs
@@ -1,5 +1,6 @@
1use crate::audio::AudioEngine; 1use crate::audio::AudioEngine;
2use crate::config::Config; 2use crate::config::Config;
3use crate::queue::{Queue, ShuffleMode, Track};
3use std::io::{Read, Write}; 4use std::io::{Read, Write};
4use std::os::unix::net::{UnixListener, UnixStream}; 5use std::os::unix::net::{UnixListener, UnixStream};
5use std::path::{Path, PathBuf}; 6use std::path::{Path, PathBuf};
@@ -8,217 +9,6 @@ use std::sync::{Arc, Mutex};
8use std::thread; 9use std::thread;
9use std::time::Duration; 10use std::time::Duration;
10 11
11#[derive(Debug, Clone)]
12enum ShuffleMode {
13 None,
14 All,
15 Album,
16}
17
18#[derive(Debug, Clone)]
19struct Track {
20 path: PathBuf,
21 title: Option<String>,
22 artist: Option<String>,
23 album: Option<String>,
24}
25
26impl Track {
27 fn from_path(path: PathBuf) -> Self {
28 let metadata = extract_metadata(&path);
29 let metadata = metadata.as_ref();
30 Track {
31 path,
32 title: metadata.and_then(|m| m.title.clone()),
33 artist: metadata.and_then(|m| m.artist.clone()),
34 album: metadata.and_then(|m| m.album.clone()),
35 }
36 }
37
38 fn display_string(&self, index: usize) -> String {
39 let album_artist = self
40 .artist
41 .clone()
42 .or_else(|| self.album.clone())
43 .unwrap_or_default();
44 let title = self.title.clone().unwrap_or_else(|| {
45 self.path
46 .file_name()
47 .and_then(|n| n.to_str())
48 .unwrap_or("Unknown")
49 .to_string()
50 });
51
52 if album_artist.is_empty() {
53 format!("{}. {}", index + 1, title)
54 } else {
55 format!("{}. {} - {}", index + 1, album_artist, title)
56 }
57 }
58}
59
60#[derive(Debug, Default, Clone)]
61struct AudioMetadata {
62 title: Option<String>,
63 artist: Option<String>,
64 album: Option<String>,
65}
66
67fn extract_metadata(path: &Path) -> Option<AudioMetadata> {
68 use symphonia::core::io::MediaSourceStream;
69 use symphonia::core::meta::StandardTagKey;
70 use symphonia::core::probe::Hint;
71
72 let file = std::fs::File::open(path).ok()?;
73 let mss = MediaSourceStream::new(Box::new(file), Default::default());
74
75 let hint = Hint::new();
76 let mut probed = symphonia::default::get_probe()
77 .format(&hint, mss, &Default::default(), &Default::default())
78 .ok()?;
79
80 let mut metadata = AudioMetadata::default();
81
82 // First check probed metadata (e.g., ID3v2 headers before container)
83 if let Some(meta) = probed.metadata.get() {
84 if let Some(rev) = meta.current() {
85 for tag in rev.tags() {
86 match tag.std_key {
87 Some(StandardTagKey::TrackTitle) => {
88 metadata.title = Some(tag.value.to_string());
89 }
90 Some(StandardTagKey::Artist) => {
91 metadata.artist = Some(tag.value.to_string());
92 }
93 Some(StandardTagKey::Album) => {
94 metadata.album = Some(tag.value.to_string());
95 }
96 _ => {}
97 }
98 }
99 }
100 }
101
102 // If nothing found, try container metadata
103 if metadata.title.is_none() && metadata.artist.is_none() && metadata.album.is_none() {
104 let mut format = probed.format;
105 if let Some(rev) = format.metadata().current() {
106 for tag in rev.tags() {
107 match tag.std_key {
108 Some(StandardTagKey::TrackTitle) => {
109 metadata.title = Some(tag.value.to_string());
110 }
111 Some(StandardTagKey::Artist) => {
112 metadata.artist = Some(tag.value.to_string());
113 }
114 Some(StandardTagKey::Album) => {
115 metadata.album = Some(tag.value.to_string());
116 }
117 _ => {}
118 }
119 }
120 }
121 }
122
123 if metadata.title.is_some() || metadata.artist.is_some() || metadata.album.is_some() {
124 Some(metadata)
125 } else {
126 None
127 }
128}
129
130struct Queue {
131 tracks: Vec<Track>,
132 position: usize,
133 shuffle: ShuffleMode,
134 shuffled_indices: Vec<usize>,
135}
136
137impl Queue {
138 fn new() -> Self {
139 Queue {
140 tracks: Vec::new(),
141 position: 0,
142 shuffle: ShuffleMode::None,
143 shuffled_indices: Vec::new(),
144 }
145 }
146
147 fn add_track(&mut self, path: PathBuf) {
148 self.tracks.push(Track::from_path(path));
149 self.update_shuffle_indices();
150 }
151
152 fn insert_after_current(&mut self, path: PathBuf) {
153 if self.tracks.is_empty() {
154 self.tracks.push(Track::from_path(path));
155 } else {
156 let insert_idx = self.position + 1;
157 self.tracks.insert(insert_idx, Track::from_path(path));
158 }
159 self.update_shuffle_indices();
160 }
161
162 fn track_at(&self, idx: usize) -> Option<&Track> {
163 self.tracks.get(idx)
164 }
165
166 fn set_shuffle(&mut self, mode: ShuffleMode) {
167 self.shuffle = mode.clone();
168 self.update_shuffle_indices();
169 }
170
171 fn update_shuffle_indices(&mut self) {
172 let len = self.tracks.len();
173 self.shuffled_indices = (0..len).collect();
174 if let ShuffleMode::All = self.shuffle {
175 use rand::seq::SliceRandom;
176 let mut rng = rand::rng();
177 let pos = self.position.min(len.saturating_sub(1));
178 if pos + 1 < len {
179 self.shuffled_indices[pos + 1..].shuffle(&mut rng);
180 }
181 }
182 }
183
184 fn current(&self) -> Option<&Track> {
185 if self.tracks.is_empty() {
186 None
187 } else if let ShuffleMode::All = self.shuffle {
188 self.shuffled_indices
189 .get(self.position)
190 .and_then(|&idx| self.tracks.get(idx))
191 } else if let ShuffleMode::Album = self.shuffle {
192 // TODO: When database is ready, only shuffle tracks in same album/folder
193 // For now, behave like All
194 self.shuffled_indices
195 .get(self.position)
196 .and_then(|&idx| self.tracks.get(idx))
197 } else {
198 self.tracks.get(self.position)
199 }
200 }
201
202 fn next(&mut self) -> Option<PathBuf> {
203 let max_pos = self.tracks.len().saturating_sub(1);
204 if self.position < max_pos {
205 self.position += 1;
206 self.current().map(|t| t.path.clone())
207 } else {
208 None
209 }
210 }
211
212 fn previous(&mut self) -> Option<PathBuf> {
213 if self.position > 0 {
214 self.position -= 1;
215 self.current().map(|t| t.path.clone())
216 } else {
217 None
218 }
219 }
220}
221
222pub fn start_daemon() -> Result<(), Box<dyn std::error::Error>> { 12pub fn start_daemon() -> Result<(), Box<dyn std::error::Error>> {
223 let config = Config::load()?; 13 let config = Config::load()?;
224 let socket_path = config.get_socket_path(); 14 let socket_path = config.get_socket_path();
@@ -268,13 +58,13 @@ pub fn start_daemon() -> Result<(), Box<dyn std::error::Error>> {
268 if let Some(vol) = config.volume { 58 if let Some(vol) = config.volume {
269 audio_engine.set_volume(vol as f32 / 100.0); 59 audio_engine.set_volume(vol as f32 / 100.0);
270 } 60 }
271 if let Some(ref shuffle_str) = config.shuffle { 61 if let Some(ref shuffle_str) = config.shuffle
272 if let Ok(mut q) = queue.lock() { 62 && let Ok(mut q) = queue.lock()
273 match shuffle_str.as_str() { 63 {
274 "on" | "all" => q.set_shuffle(ShuffleMode::All), 64 match shuffle_str.as_str() {
275 "album" => q.set_shuffle(ShuffleMode::Album), 65 "on" | "all" => q.set_shuffle(ShuffleMode::All),
276 _ => {} 66 "album" => q.set_shuffle(ShuffleMode::Album),
277 } 67 _ => {}
278 } 68 }
279 } 69 }
280 70
@@ -285,13 +75,11 @@ pub fn start_daemon() -> Result<(), Box<dyn std::error::Error>> {
285 use std::time::{Duration, Instant}; 75 use std::time::{Duration, Instant};
286 let start = Instant::now(); 76 let start = Instant::now();
287 while start.elapsed() < Duration::from_millis(timeout_ms) { 77 while start.elapsed() < Duration::from_millis(timeout_ms) {
288 // If playing and the current track matches, assume started 78 if audio.is_playing()
289 if audio.is_playing() { 79 && let Some(cur) = audio.current_track()
290 if let Some(cur) = audio.current_track() { 80 && &cur == target
291 if &cur == target { 81 {
292 return true; 82 return true;
293 }
294 }
295 } 83 }
296 std::thread::sleep(Duration::from_millis(10)); 84 std::thread::sleep(Duration::from_millis(10));
297 } 85 }
@@ -304,36 +92,27 @@ pub fn start_daemon() -> Result<(), Box<dyn std::error::Error>> {
304 } 92 }
305 93
306 // Check if track ended and play next (robust with startup feedback) 94 // Check if track ended and play next (robust with startup feedback)
307 if audio_engine.poll_track_ended() { 95 if audio_engine.poll_track_ended()
308 if let Ok(mut q) = queue.lock() { 96 && let Ok(mut q) = queue.lock()
309 // Save the current position in case we need to revert 97 {
310 let original_position = q.position; 98 let original_position = q.position();
311 // Try a handful of next candidates until one starts 99 let mut started = false;
312 let mut started = false; 100 let max_trials = 4usize;
313 // We'll attempt up to 4 candidates ahead 101 for _ in 0..max_trials {
314 let max_trials = 4usize; 102 if let Some(next_track) = q.next() {
315 for _ in 0..max_trials { 103 audio_engine.play(next_track.clone());
316 if let Some(next_track) = q.next() { 104 if wait_for_start(&audio_engine, &next_track, 500) {
317 audio_engine.play(next_track.clone()); 105 started = true;
318 // Wait a short time to see if playback actually starts
319 if wait_for_start(&audio_engine, &next_track, 500) {
320 started = true;
321 break;
322 } else {
323 // Try next candidate - q.next() already advanced position
324 continue;
325 }
326 } else {
327 break; 106 break;
328 } 107 }
329 } 108 } else {
330 if !started { 109 break;
331 // Revert position since we couldn't start any track
332 q.position = original_position;
333 // No candidate started; stop playback safely
334 audio_engine.stop();
335 } 110 }
336 } 111 }
112 if !started {
113 q.set_position(original_position);
114 audio_engine.stop();
115 }
337 } 116 }
338 117
339 match listener.accept() { 118 match listener.accept() {
@@ -494,10 +273,8 @@ fn process_command(
494 } 273 }
495 "stop" => { 274 "stop" => {
496 audio.stop(); 275 audio.stop();
497 // Clear queue on stop
498 if let Ok(mut q) = queue.lock() { 276 if let Ok(mut q) = queue.lock() {
499 q.tracks.clear(); 277 q.clear();
500 q.position = 0;
501 } 278 }
502 "Stopped\n".to_string() 279 "Stopped\n".to_string()
503 } 280 }
@@ -533,7 +310,7 @@ fn process_command(
533 } 310 }
534 "shuffle" => { 311 "shuffle" => {
535 if let Ok(q) = queue.lock() { 312 if let Ok(q) = queue.lock() {
536 match q.shuffle { 313 match q.shuffle_mode() {
537 ShuffleMode::None => "Shuffle: off\n".to_string(), 314 ShuffleMode::None => "Shuffle: off\n".to_string(),
538 ShuffleMode::All => "Shuffle: on (all)\n".to_string(), 315 ShuffleMode::All => "Shuffle: on (all)\n".to_string(),
539 ShuffleMode::Album => "Shuffle: on (album)\n".to_string(), 316 ShuffleMode::Album => "Shuffle: on (album)\n".to_string(),
@@ -591,14 +368,15 @@ fn process_command(
591 let path = std::path::PathBuf::from(path_str.clone()); 368 let path = std::path::PathBuf::from(path_str.clone());
592 if path.is_file() { 369 if path.is_file() {
593 if let Ok(mut q) = queue.lock() { 370 if let Ok(mut q) = queue.lock() {
594 let was_empty = q.tracks.is_empty(); 371 let was_empty = q.is_empty();
595 match position { 372 match position {
596 "now" => { 373 "now" => {
597 q.insert_after_current(path.clone()); 374 q.insert_after_current(path.clone());
598 if !was_empty { 375 if !was_empty {
599 q.position += 1; 376 let new_pos = q.position() + 1;
377 q.set_position(new_pos);
600 } 378 }
601 if let Some(track) = q.track_at(q.position) { 379 if let Some(track) = q.track_at(q.position()) {
602 audio.play(track.path.clone()); 380 audio.play(track.path.clone());
603 } 381 }
604 format!("Playing now: {}\n", path_str) 382 format!("Playing now: {}\n", path_str)
@@ -608,9 +386,14 @@ fn process_command(
608 format!("Queued next: {}\n", path_str) 386 format!("Queued next: {}\n", path_str)
609 } 387 }
610 _ => { 388 _ => {
389 let was_playing = audio.is_playing();
611 q.add_track(path.clone()); 390 q.add_track(path.clone());
612 audio.play(path); 391 if !was_playing {
613 format!("Playing: {}\n", path_str) 392 audio.play(path);
393 format!("Playing: {}\n", path_str)
394 } else {
395 format!("Queued: {}\n", path_str)
396 }
614 } 397 }
615 } 398 }
616 } else { 399 } else {
@@ -620,16 +403,17 @@ fn process_command(
620 match find_audio_in_dir(&path) { 403 match find_audio_in_dir(&path) {
621 Some(files) if !files.is_empty() => { 404 Some(files) if !files.is_empty() => {
622 if let Ok(mut q) = queue.lock() { 405 if let Ok(mut q) = queue.lock() {
623 let was_empty = q.tracks.is_empty(); 406 let was_empty = q.is_empty();
624 match position { 407 match position {
625 "now" => { 408 "now" => {
626 for file in &files { 409 for file in files.iter().rev() {
627 q.insert_after_current(file.clone()); 410 q.insert_after_current(file.clone());
628 } 411 }
629 if !was_empty { 412 if !was_empty {
630 q.position += 1; 413 let new_pos = q.position() + 1;
414 q.set_position(new_pos);
631 } 415 }
632 if let Some(track) = q.track_at(q.position) { 416 if let Some(track) = q.track_at(q.position()) {
633 audio.play(track.path.clone()); 417 audio.play(track.path.clone());
634 } 418 }
635 format!( 419 format!(
@@ -639,7 +423,7 @@ fn process_command(
639 ) 423 )
640 } 424 }
641 "next" => { 425 "next" => {
642 for file in &files { 426 for file in files.iter().rev() {
643 q.insert_after_current(file.clone()); 427 q.insert_after_current(file.clone());
644 } 428 }
645 format!( 429 format!(
@@ -652,10 +436,10 @@ fn process_command(
652 for file in &files { 436 for file in &files {
653 q.add_track(file.clone()); 437 q.add_track(file.clone());
654 } 438 }
655 if !audio.is_playing() { 439 if !audio.is_playing()
656 if let Some(track) = files.first() { 440 && let Some(track) = files.first()
657 audio.play(track.clone()); 441 {
658 } 442 audio.play(track.clone());
659 } 443 }
660 format!("Queued {} tracks from: {}\n", files.len(), path_str) 444 format!("Queued {} tracks from: {}\n", files.len(), path_str)
661 } 445 }
@@ -687,43 +471,31 @@ fn process_command(
687 "Usage: volume <0-100>\n".to_string() 471 "Usage: volume <0-100>\n".to_string()
688 } 472 }
689 } 473 }
690 _ if cmd.starts_with("shuffle ") => {
691 let mode_str = cmd[8..].trim();
692 if let Ok(mut q) = queue.lock() {
693 if mode_str == "on" || mode_str == "all" {
694 q.set_shuffle(ShuffleMode::All);
695 "Shuffle: on\n".to_string()
696 } else if mode_str == "off" || mode_str == "none" {
697 q.set_shuffle(ShuffleMode::None);
698 "Shuffle: off\n".to_string()
699 } else {
700 "Usage: shuffle [on|off]\n".to_string()
701 }
702 } else {
703 "Error accessing queue\n".to_string()
704 }
705 }
706 _ if cmd.starts_with("queue") => { 474 _ if cmd.starts_with("queue") => {
707 if let Ok(q) = queue.lock() { 475 if let Ok(q) = queue.lock() {
708 if q.tracks.is_empty() { 476 if q.is_empty() {
709 "Queue is empty\n".to_string() 477 "Queue is empty\n".to_string()
710 } else { 478 } else {
711 let mut output = String::new(); 479 let mut output = String::new();
712 let current_position = q.position; 480 let current_position = q.position();
713 let playing_track_idx = match q.shuffle { 481 let playing_track_idx = match q.shuffle_mode() {
714 ShuffleMode::None => current_position, 482 ShuffleMode::None => current_position,
715 _ => *q.shuffled_indices.get(current_position).unwrap_or(&0), 483 _ => q
484 .shuffled_indices()
485 .get(current_position)
486 .copied()
487 .unwrap_or(0),
716 }; 488 };
717 // Use shuffled_indices for display order if shuffle is on 489 let indices: Vec<usize> = if *q.shuffle_mode() == ShuffleMode::None {
718 let indices: Vec<usize> = if let ShuffleMode::None = q.shuffle { 490 (0..q.len()).collect()
719 (0..q.tracks.len()).collect()
720 } else { 491 } else {
721 q.shuffled_indices.clone() 492 q.shuffled_indices().to_vec()
722 }; 493 };
494 let is_playing = audio.is_playing();
723 for (display_idx, &track_idx) in indices.iter().enumerate() { 495 for (display_idx, &track_idx) in indices.iter().enumerate() {
724 if let Some(track) = q.tracks.get(track_idx) { 496 if let Some(track) = q.track_at(track_idx) {
725 let track_str = track.display_string(display_idx); 497 let track_str = track.display_string(display_idx);
726 if track_idx == playing_track_idx { 498 if track_idx == playing_track_idx && is_playing {
727 output.push_str(&format!("> {} (playing)\n", track_str)); 499 output.push_str(&format!("> {} (playing)\n", track_str));
728 } else { 500 } else {
729 output.push_str(&format!(" {}\n", track_str)); 501 output.push_str(&format!(" {}\n", track_str));
@@ -766,18 +538,18 @@ fn find_audio_in_dir(dir: &Path) -> Option<Vec<std::path::PathBuf>> {
766 for entry in entries { 538 for entry in entries {
767 let path = entry.path(); 539 let path = entry.path();
768 if path.is_file() { 540 if path.is_file() {
769 if let Some(ext) = path.extension().and_then(OsStr::to_str) { 541 if let Some(ext) = path.extension().and_then(OsStr::to_str)
770 if matches!( 542 && matches!(
771 ext.to_lowercase().as_str(), 543 ext.to_lowercase().as_str(),
772 "mp3" | "flac" | "ogg" | "m4a" | "wav" | "opus" 544 "mp3" | "flac" | "ogg" | "m4a" | "wav" | "opus"
773 ) { 545 )
774 results.push(path); 546 {
775 } 547 results.push(path);
776 }
777 } else if path.is_dir() {
778 if let Some(mut found) = find_audio_in_dir(&path) {
779 results.append(&mut found);
780 } 548 }
549 } else if path.is_dir()
550 && let Some(mut found) = find_audio_in_dir(&path)
551 {
552 results.append(&mut found);
781 } 553 }
782 } 554 }
783 } 555 }
diff --git a/src/lib.rs b/src/lib.rs
index 9317bcd..cc5d7bf 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -4,4 +4,5 @@ pub mod config;
4pub mod daemon; 4pub mod daemon;
5pub mod database; 5pub mod database;
6pub mod playback; 6pub mod playback;
7pub mod queue;
7pub mod scrobbler; 8pub mod scrobbler;
diff --git a/src/main.rs b/src/main.rs
index 2b88c44..aac80f0 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -4,6 +4,7 @@ mod config;
4mod daemon; 4mod daemon;
5mod database; 5mod database;
6mod playback; 6mod playback;
7mod queue;
7mod scrobbler; 8mod scrobbler;
8 9
9use clap::Parser; 10use clap::Parser;
diff --git a/src/queue.rs b/src/queue.rs
new file mode 100644
index 0000000..53b104f
--- /dev/null
+++ b/src/queue.rs
@@ -0,0 +1,250 @@
1use rand::seq::SliceRandom;
2use std::path::{Path, PathBuf};
3
4#[derive(Debug, Clone, PartialEq)]
5pub enum ShuffleMode {
6 None,
7 All,
8 Album,
9}
10
11#[derive(Debug, Default, Clone)]
12pub struct AudioMetadata {
13 pub title: Option<String>,
14 pub artist: Option<String>,
15 pub album: Option<String>,
16}
17
18pub fn extract_metadata(path: &Path) -> Option<AudioMetadata> {
19 use symphonia::core::io::MediaSourceStream;
20 use symphonia::core::meta::StandardTagKey;
21 use symphonia::core::probe::Hint;
22
23 let file = std::fs::File::open(path).ok()?;
24 let mss = MediaSourceStream::new(Box::new(file), Default::default());
25
26 let hint = Hint::new();
27 let mut probed = symphonia::default::get_probe()
28 .format(&hint, mss, &Default::default(), &Default::default())
29 .ok()?;
30
31 let mut metadata = AudioMetadata::default();
32
33 if let Some(meta) = probed.metadata.get()
34 && let Some(rev) = meta.current()
35 {
36 for tag in rev.tags() {
37 match tag.std_key {
38 Some(StandardTagKey::TrackTitle) => {
39 metadata.title = Some(tag.value.to_string());
40 }
41 Some(StandardTagKey::Artist) => {
42 metadata.artist = Some(tag.value.to_string());
43 }
44 Some(StandardTagKey::Album) => {
45 metadata.album = Some(tag.value.to_string());
46 }
47 _ => {}
48 }
49 }
50 }
51
52 if metadata.title.is_none() && metadata.artist.is_none() && metadata.album.is_none() {
53 let mut format = probed.format;
54 if let Some(rev) = format.metadata().current() {
55 for tag in rev.tags() {
56 match tag.std_key {
57 Some(StandardTagKey::TrackTitle) => {
58 metadata.title = Some(tag.value.to_string());
59 }
60 Some(StandardTagKey::Artist) => {
61 metadata.artist = Some(tag.value.to_string());
62 }
63 Some(StandardTagKey::Album) => {
64 metadata.album = Some(tag.value.to_string());
65 }
66 _ => {}
67 }
68 }
69 }
70 }
71
72 if metadata.title.is_some() || metadata.artist.is_some() || metadata.album.is_some() {
73 Some(metadata)
74 } else {
75 None
76 }
77}
78
79#[derive(Debug, Clone)]
80pub struct Track {
81 pub path: PathBuf,
82 pub title: Option<String>,
83 pub artist: Option<String>,
84 pub album: Option<String>,
85}
86
87impl Track {
88 pub fn from_path(path: PathBuf) -> Self {
89 let metadata = extract_metadata(&path);
90 let metadata = metadata.as_ref();
91 Track {
92 path,
93 title: metadata.and_then(|m| m.title.clone()),
94 artist: metadata.and_then(|m| m.artist.clone()),
95 album: metadata.and_then(|m| m.album.clone()),
96 }
97 }
98
99 pub fn display_string(&self, index: usize) -> String {
100 let album_artist = self
101 .artist
102 .clone()
103 .or_else(|| self.album.clone())
104 .unwrap_or_default();
105 let title = self.title.clone().unwrap_or_else(|| {
106 self.path
107 .file_name()
108 .and_then(|n| n.to_str())
109 .unwrap_or("Unknown")
110 .to_string()
111 });
112
113 if album_artist.is_empty() {
114 format!("{}. {}", index + 1, title)
115 } else {
116 format!("{}. {} - {}", index + 1, album_artist, title)
117 }
118 }
119}
120
121pub struct Queue {
122 tracks: Vec<Track>,
123 position: usize,
124 shuffle: ShuffleMode,
125 shuffled_indices: Vec<usize>,
126}
127
128impl Default for Queue {
129 fn default() -> Self {
130 Self::new()
131 }
132}
133
134impl Queue {
135 pub fn new() -> Self {
136 Queue {
137 tracks: Vec::new(),
138 position: 0,
139 shuffle: ShuffleMode::None,
140 shuffled_indices: Vec::new(),
141 }
142 }
143
144 pub fn add_track(&mut self, path: PathBuf) {
145 self.tracks.push(Track::from_path(path));
146 self.update_shuffle_indices();
147 }
148
149 pub fn insert_after_current(&mut self, path: PathBuf) {
150 if self.tracks.is_empty() {
151 self.tracks.push(Track::from_path(path));
152 } else {
153 let insert_idx = self.position + 1;
154 self.tracks.insert(insert_idx, Track::from_path(path));
155 }
156 self.update_shuffle_indices();
157 }
158
159 pub fn track_at(&self, idx: usize) -> Option<&Track> {
160 self.tracks.get(idx)
161 }
162
163 pub fn set_shuffle(&mut self, mode: ShuffleMode) {
164 self.shuffle = mode;
165 self.update_shuffle_indices();
166 }
167
168 fn update_shuffle_indices(&mut self) {
169 let len = self.tracks.len();
170 self.shuffled_indices = (0..len).collect();
171 if let ShuffleMode::All = self.shuffle {
172 let mut rng = rand::rng();
173 let pos = self.position.min(len.saturating_sub(1));
174 if pos + 1 < len {
175 self.shuffled_indices[pos + 1..].shuffle(&mut rng);
176 }
177 }
178 }
179
180 pub fn current(&self) -> Option<&Track> {
181 if self.tracks.is_empty() {
182 None
183 } else if self.shuffle == ShuffleMode::All {
184 self.shuffled_indices
185 .get(self.position)
186 .and_then(|&idx| self.tracks.get(idx))
187 } else if self.shuffle == ShuffleMode::Album {
188 self.shuffled_indices
189 .get(self.position)
190 .and_then(|&idx| self.tracks.get(idx))
191 } else {
192 self.tracks.get(self.position)
193 }
194 }
195
196 #[allow(clippy::should_implement_trait)]
197 pub fn next(&mut self) -> Option<PathBuf> {
198 let max_pos = self.tracks.len().saturating_sub(1);
199 if self.position < max_pos {
200 self.position += 1;
201 self.current().map(|t| t.path.clone())
202 } else {
203 None
204 }
205 }
206
207 pub fn previous(&mut self) -> Option<PathBuf> {
208 if self.position > 0 {
209 self.position -= 1;
210 self.current().map(|t| t.path.clone())
211 } else {
212 None
213 }
214 }
215
216 pub fn is_empty(&self) -> bool {
217 self.tracks.is_empty()
218 }
219
220 pub fn len(&self) -> usize {
221 self.tracks.len()
222 }
223
224 pub fn position(&self) -> usize {
225 self.position
226 }
227
228 pub fn set_position(&mut self, pos: usize) {
229 self.position = pos;
230 }
231
232 #[allow(dead_code)]
233 pub fn tracks(&self) -> &[Track] {
234 &self.tracks
235 }
236
237 pub fn shuffle_mode(&self) -> &ShuffleMode {
238 &self.shuffle
239 }
240
241 pub fn shuffled_indices(&self) -> &[usize] {
242 &self.shuffled_indices
243 }
244
245 pub fn clear(&mut self) {
246 self.tracks.clear();
247 self.position = 0;
248 self.shuffled_indices.clear();
249 }
250}
diff --git a/tests/queue.rs b/tests/queue.rs
new file mode 100644
index 0000000..0ceaba0
--- /dev/null
+++ b/tests/queue.rs
@@ -0,0 +1,459 @@
1use rsp::queue::{Queue, ShuffleMode, Track};
2use std::path::PathBuf;
3
4fn track(path: &str, title: Option<&str>, artist: Option<&str>, album: Option<&str>) -> Track {
5 Track {
6 path: PathBuf::from(path),
7 title: title.map(|s| s.to_string()),
8 artist: artist.map(|s| s.to_string()),
9 album: album.map(|s| s.to_string()),
10 }
11}
12
13fn track_no_meta(path: &str) -> Track {
14 Track {
15 path: PathBuf::from(path),
16 title: None,
17 artist: None,
18 album: None,
19 }
20}
21
22fn add_track(q: &mut Queue, path: &str) {
23 q.add_track(PathBuf::from(path));
24}
25
26// ---------- Queue basics ----------
27
28#[test]
29fn test_new_queue_is_empty() {
30 let q = Queue::new();
31 assert!(q.is_empty());
32 assert_eq!(q.len(), 0);
33 assert!(q.current().is_none());
34 assert_eq!(q.position(), 0);
35}
36
37#[test]
38fn test_add_track_and_current() {
39 let mut q = Queue::new();
40 add_track(&mut q, "/music/song1.mp3");
41 assert!(!q.is_empty());
42 assert_eq!(q.len(), 1);
43 let cur = q.current();
44 assert!(cur.is_some());
45 assert_eq!(cur.unwrap().path, PathBuf::from("/music/song1.mp3"));
46}
47
48#[test]
49fn test_current_on_empty_queue() {
50 let q = Queue::new();
51 assert!(q.current().is_none());
52}
53
54#[test]
55fn test_next_advances_position() {
56 let mut q = Queue::new();
57 add_track(&mut q, "/music/song1.mp3");
58 add_track(&mut q, "/music/song2.mp3");
59 assert_eq!(q.position(), 0);
60 let next = q.next();
61 assert!(next.is_some());
62 assert_eq!(next.unwrap(), PathBuf::from("/music/song2.mp3"));
63 assert_eq!(q.position(), 1);
64 assert_eq!(q.current().unwrap().path, PathBuf::from("/music/song2.mp3"));
65}
66
67#[test]
68fn test_next_at_end_returns_none() {
69 let mut q = Queue::new();
70 add_track(&mut q, "/music/song1.mp3");
71 assert!(q.next().is_none());
72 assert_eq!(q.position(), 0);
73}
74
75#[test]
76fn test_next_from_last_track_returns_none() {
77 let mut q = Queue::new();
78 add_track(&mut q, "/music/song1.mp3");
79 add_track(&mut q, "/music/song2.mp3");
80 assert!(q.next().is_some());
81 assert!(q.next().is_none());
82 assert_eq!(q.position(), 1);
83}
84
85#[test]
86fn test_previous_goes_back() {
87 let mut q = Queue::new();
88 add_track(&mut q, "/music/song1.mp3");
89 add_track(&mut q, "/music/song2.mp3");
90 add_track(&mut q, "/music/song3.mp3");
91 q.next();
92 q.next();
93 assert_eq!(q.position(), 2);
94 let prev = q.previous();
95 assert!(prev.is_some());
96 assert_eq!(prev.unwrap(), PathBuf::from("/music/song2.mp3"));
97 assert_eq!(q.position(), 1);
98}
99
100#[test]
101fn test_previous_at_start_returns_none() {
102 let mut q = Queue::new();
103 add_track(&mut q, "/music/song1.mp3");
104 assert!(q.previous().is_none());
105 assert_eq!(q.position(), 0);
106}
107
108#[test]
109fn test_previous_multiple_steps() {
110 let mut q = Queue::new();
111 add_track(&mut q, "/music/song1.mp3");
112 add_track(&mut q, "/music/song2.mp3");
113 add_track(&mut q, "/music/song3.mp3");
114 q.next();
115 q.next();
116 q.previous();
117 assert_eq!(q.position(), 1);
118 q.previous();
119 assert_eq!(q.position(), 0);
120 assert!(q.previous().is_none());
121 assert_eq!(q.position(), 0);
122}
123
124// ---------- Clear ----------
125
126#[test]
127fn test_clear_queue() {
128 let mut q = Queue::new();
129 add_track(&mut q, "/music/song1.mp3");
130 add_track(&mut q, "/music/song2.mp3");
131 q.next();
132 q.clear();
133 assert!(q.is_empty());
134 assert_eq!(q.len(), 0);
135 assert_eq!(q.position(), 0);
136 assert!(q.current().is_none());
137}
138
139#[test]
140fn test_clear_empty_queue() {
141 let mut q = Queue::new();
142 q.clear();
143 assert!(q.is_empty());
144}
145
146// ---------- Insert after current ----------
147
148#[test]
149fn test_insert_after_current_empty_queue() {
150 let mut q = Queue::new();
151 q.insert_after_current(PathBuf::from("/music/song1.mp3"));
152 assert_eq!(q.len(), 1);
153 assert_eq!(q.current().unwrap().path, PathBuf::from("/music/song1.mp3"));
154}
155
156#[test]
157fn test_insert_after_current_middle() {
158 let mut q = Queue::new();
159 add_track(&mut q, "/music/song1.mp3");
160 add_track(&mut q, "/music/song3.mp3");
161 // Currently at position 0 (song1)
162 q.insert_after_current(PathBuf::from("/music/song2.mp3"));
163 assert_eq!(q.len(), 3);
164 assert_eq!(q.current().unwrap().path, PathBuf::from("/music/song1.mp3"));
165 q.next();
166 assert_eq!(q.current().unwrap().path, PathBuf::from("/music/song2.mp3"));
167 q.next();
168 assert_eq!(q.current().unwrap().path, PathBuf::from("/music/song3.mp3"));
169}
170
171#[test]
172fn test_insert_after_current_at_end() {
173 let mut q = Queue::new();
174 add_track(&mut q, "/music/song1.mp3");
175 add_track(&mut q, "/music/song2.mp3");
176 q.next();
177 q.insert_after_current(PathBuf::from("/music/song3.mp3"));
178 assert_eq!(q.len(), 3);
179 q.next();
180 assert_eq!(q.current().unwrap().path, PathBuf::from("/music/song3.mp3"));
181}
182
183#[test]
184fn test_insert_after_current_multiple_ordering() {
185 let mut q = Queue::new();
186 add_track(&mut q, "/music/song1.mp3");
187 add_track(&mut q, "/music/song4.mp3");
188 // Queue: [song1, song4], pos=0
189 // Insert song2, then song3 after current
190 q.insert_after_current(PathBuf::from("/music/song2.mp3"));
191 q.insert_after_current(PathBuf::from("/music/song3.mp3"));
192 // Expected: [song1, song3, song2, song4]
193 // Each insert goes at pos+1, so song3 ends up right after song1, pushing song2 right
194 assert_eq!(q.len(), 4);
195 q.next();
196 assert_eq!(
197 q.current().unwrap().path,
198 PathBuf::from("/music/song3.mp3"),
199 "last insert goes right after current"
200 );
201 q.next();
202 assert_eq!(q.current().unwrap().path, PathBuf::from("/music/song2.mp3"));
203 q.next();
204 assert_eq!(q.current().unwrap().path, PathBuf::from("/music/song4.mp3"));
205}
206
207// ---------- Track display ----------
208
209#[test]
210fn test_track_display_with_metadata() {
211 let t = track(
212 "/music/test.mp3",
213 Some("Test Title"),
214 Some("Test Artist"),
215 Some("Test Album"),
216 );
217 let display = t.display_string(0);
218 assert_eq!(display, "1. Test Artist - Test Title");
219}
220
221#[test]
222fn test_track_display_artist_fallback_to_album() {
223 let t = track(
224 "/music/test.mp3",
225 Some("Test Title"),
226 None,
227 Some("Test Album"),
228 );
229 let display = t.display_string(0);
230 assert_eq!(display, "1. Test Album - Test Title");
231}
232
233#[test]
234fn test_track_display_no_metadata() {
235 let t = track_no_meta("/music/song.mp3");
236 let display = t.display_string(0);
237 assert_eq!(display, "1. song.mp3");
238}
239
240#[test]
241fn test_track_display_index_offset() {
242 let t = track(
243 "/music/test.mp3",
244 Some("Title"),
245 Some("Artist"),
246 Some("Album"),
247 );
248 assert_eq!(t.display_string(4), "5. Artist - Title");
249}
250
251// ---------- Shuffle ----------
252
253#[test]
254fn test_shuffle_modes() {
255 let mut q = Queue::new();
256 assert_eq!(*q.shuffle_mode(), ShuffleMode::None);
257 q.set_shuffle(ShuffleMode::All);
258 assert_eq!(*q.shuffle_mode(), ShuffleMode::All);
259 q.set_shuffle(ShuffleMode::Album);
260 assert_eq!(*q.shuffle_mode(), ShuffleMode::Album);
261 q.set_shuffle(ShuffleMode::None);
262 assert_eq!(*q.shuffle_mode(), ShuffleMode::None);
263}
264
265#[test]
266fn test_shuffle_all_navigates_in_shuffled_order() {
267 let mut q = Queue::new();
268 for i in 0..10 {
269 add_track(&mut q, &format!("/music/song{}.mp3", i));
270 }
271 q.set_shuffle(ShuffleMode::All);
272 // Current should still return the first track
273 assert_eq!(q.current().unwrap().path, PathBuf::from("/music/song0.mp3"));
274 // Navigate through all tracks, should visit all 10 (in shuffled order)
275 let mut visited = std::collections::HashSet::new();
276 visited.insert(q.current().unwrap().path.clone());
277 while let Some(next_path) = q.next() {
278 assert!(
279 visited.insert(next_path),
280 "duplicate track in shuffle navigation"
281 );
282 }
283 assert_eq!(visited.len(), 10, "should visit all 10 tracks");
284}
285
286#[test]
287fn test_shuffle_all_current_stays_at_position_zero() {
288 let mut q = Queue::new();
289 for i in 0..5 {
290 add_track(&mut q, &format!("/music/song{}.mp3", i));
291 }
292 q.set_shuffle(ShuffleMode::All);
293 assert_eq!(q.position(), 0);
294 assert_eq!(q.shuffled_indices()[0], 0);
295}
296
297#[test]
298fn test_set_shuffle_regenerates_indices() {
299 let mut q = Queue::new();
300 for i in 0..5 {
301 add_track(&mut q, &format!("/music/song{}.mp3", i));
302 }
303 q.set_shuffle(ShuffleMode::All);
304 let indices1 = q.shuffled_indices().to_vec();
305 // Each set_shuffle should regenerate (small chance of same order)
306 q.set_shuffle(ShuffleMode::All);
307 let indices2 = q.shuffled_indices().to_vec();
308 // Check that all indices are still valid (0..5)
309 let mut sorted1 = indices1.clone();
310 sorted1.sort();
311 let mut sorted2 = indices2.clone();
312 sorted2.sort();
313 assert_eq!(sorted1, vec![0, 1, 2, 3, 4]);
314 assert_eq!(sorted2, vec![0, 1, 2, 3, 4]);
315}
316
317#[test]
318fn test_shuffle_none_linear_navigation() {
319 let mut q = Queue::new();
320 for i in 0..5 {
321 add_track(&mut q, &format!("/music/song{}.mp3", i));
322 }
323 assert_eq!(*q.shuffle_mode(), ShuffleMode::None);
324 assert_eq!(q.shuffled_indices(), &[0, 1, 2, 3, 4]);
325 assert_eq!(q.current().unwrap().path, PathBuf::from("/music/song0.mp3"));
326 q.next();
327 assert_eq!(q.current().unwrap().path, PathBuf::from("/music/song1.mp3"));
328 q.next();
329 assert_eq!(q.current().unwrap().path, PathBuf::from("/music/song2.mp3"));
330}
331
332// ---------- Edge cases ----------
333
334#[test]
335fn test_single_track_queue() {
336 let mut q = Queue::new();
337 add_track(&mut q, "/music/only.mp3");
338 assert_eq!(q.len(), 1);
339 assert!(q.current().is_some());
340 assert!(q.next().is_none());
341 assert_eq!(q.position(), 0);
342 assert!(q.previous().is_none());
343 assert_eq!(q.position(), 0);
344}
345
346#[test]
347fn test_set_position() {
348 let mut q = Queue::new();
349 add_track(&mut q, "/music/song0.mp3");
350 add_track(&mut q, "/music/song1.mp3");
351 add_track(&mut q, "/music/song2.mp3");
352 q.set_position(2);
353 assert_eq!(q.position(), 2);
354 assert_eq!(q.current().unwrap().path, PathBuf::from("/music/song2.mp3"));
355 q.set_position(0);
356 assert_eq!(q.position(), 0);
357 assert_eq!(q.current().unwrap().path, PathBuf::from("/music/song0.mp3"));
358}
359
360#[test]
361fn test_set_position_beyond_end() {
362 let mut q = Queue::new();
363 add_track(&mut q, "/music/song0.mp3");
364 add_track(&mut q, "/music/song1.mp3");
365 q.set_position(5);
366 // current() should handle out-of-bounds position gracefully
367 let cur = q.current();
368 assert!(cur.is_none());
369 assert!(q.next().is_none());
370}
371
372#[test]
373fn test_len_and_is_empty() {
374 let mut q = Queue::new();
375 assert_eq!(q.len(), 0);
376 assert!(q.is_empty());
377 add_track(&mut q, "/music/a.mp3");
378 assert_eq!(q.len(), 1);
379 assert!(!q.is_empty());
380 add_track(&mut q, "/music/b.mp3");
381 assert_eq!(q.len(), 2);
382 q.clear();
383 assert_eq!(q.len(), 0);
384 assert!(q.is_empty());
385}
386
387#[test]
388fn test_track_at() {
389 let mut q = Queue::new();
390 add_track(&mut q, "/music/song0.mp3");
391 add_track(&mut q, "/music/song1.mp3");
392 assert_eq!(
393 q.track_at(0).unwrap().path,
394 PathBuf::from("/music/song0.mp3")
395 );
396 assert_eq!(
397 q.track_at(1).unwrap().path,
398 PathBuf::from("/music/song1.mp3")
399 );
400 assert!(q.track_at(2).is_none());
401}
402
403// ---------- Shuffle + add interaction ----------
404
405#[test]
406fn test_add_track_updates_shuffle_indices() {
407 let mut q = Queue::new();
408 q.set_shuffle(ShuffleMode::All);
409 add_track(&mut q, "/music/a.mp3");
410 assert_eq!(q.shuffled_indices().len(), 1);
411 add_track(&mut q, "/music/b.mp3");
412 assert_eq!(q.shuffled_indices().len(), 2);
413 add_track(&mut q, "/music/c.mp3");
414 assert_eq!(q.shuffled_indices().len(), 3);
415}
416
417#[test]
418fn test_insert_after_current_updates_shuffle_indices() {
419 let mut q = Queue::new();
420 q.set_shuffle(ShuffleMode::All);
421 add_track(&mut q, "/music/a.mp3");
422 q.insert_after_current(PathBuf::from("/music/b.mp3"));
423 assert_eq!(q.shuffled_indices().len(), 2);
424}
425
426#[test]
427fn test_clear_resets_shuffle_indices() {
428 let mut q = Queue::new();
429 q.set_shuffle(ShuffleMode::All);
430 add_track(&mut q, "/music/a.mp3");
431 add_track(&mut q, "/music/b.mp3");
432 q.clear();
433 assert!(q.shuffled_indices().is_empty());
434}
435
436// ---------- Track struct ----------
437
438#[test]
439fn test_track_fields() {
440 let t = track(
441 "/path/to/file.flac",
442 Some("Title"),
443 Some("Artist"),
444 Some("Album"),
445 );
446 assert_eq!(t.path, PathBuf::from("/path/to/file.flac"));
447 assert_eq!(t.title.unwrap(), "Title");
448 assert_eq!(t.artist.unwrap(), "Artist");
449 assert_eq!(t.album.unwrap(), "Album");
450}
451
452#[test]
453fn test_track_no_metadata_fields() {
454 let t = track_no_meta("/path/to/file.flac");
455 assert_eq!(t.path, PathBuf::from("/path/to/file.flac"));
456 assert!(t.title.is_none());
457 assert!(t.artist.is_none());
458 assert!(t.album.is_none());
459}