diff options
| author | salmonik <raisersoft@proton.me> | 2026-05-05 22:28:13 +0200 |
|---|---|---|
| committer | salmonik <raisersoft@proton.me> | 2026-05-05 22:28:13 +0200 |
| commit | b2ec417db4c0503a1079fe64edab07a035719fe7 (patch) | |
| tree | 1af4c68286787841a78ec3ea02df57730ef86a8f | |
| parent | fix(audio): resolve deadlock preventing auto-advance to next track (diff) | |
| download | rsplayer-b2ec417db4c0503a1079fe64edab07a035719fe7.tar.gz rsplayer-b2ec417db4c0503a1079fe64edab07a035719fe7.zip | |
Add metadata extraction and display for folder playback
Extract ID3/metadata tags from audio files using symphonia and display
as 'INDEX. ARTIST - TITLE' format, falling back to filename. Only shows
the track number before any slash (e.g., '15' from '15/19').
Diffstat (limited to '')
| -rw-r--r-- | AGENTS.md | 1 | ||||
| -rw-r--r-- | TODO.md | 2 | ||||
| -rw-r--r-- | src/daemon.rs | 137 |
3 files changed, 129 insertions, 11 deletions
| @@ -44,6 +44,7 @@ Run these before completing any task: | |||
| 44 | - [x] Fixed audio buffer underrun issues by using device default sample rate | 44 | - [x] Fixed audio buffer underrun issues by using device default sample rate |
| 45 | - [x] Queue display with pager support (`rsplayer queue`) | 45 | - [x] Queue display with pager support (`rsplayer queue`) |
| 46 | - [x] Auto-play next track when current track ends | 46 | - [x] Auto-play next track when current track ends |
| 47 | - [x] Metadata extraction and display (INDEX. ARTIST - TITLE format, falls back to filename) | ||
| 47 | 48 | ||
| 48 | ## Remaining Work | 49 | ## Remaining Work |
| 49 | - Album shuffle mode (`ShuffleMode::Album`) | 50 | - Album shuffle mode (`ShuffleMode::Album`) |
| @@ -32,6 +32,8 @@ | |||
| 32 | 32 | ||
| 33 | - [x] **Album shuffle mode structure** - `ShuffleMode::Album` added to enum and queue logic (behaves like `All` until database ready). | 33 | - [x] **Album shuffle mode structure** - `ShuffleMode::Album` added to enum and queue logic (behaves like `All` until database ready). |
| 34 | 34 | ||
| 35 | - [x] **Metadata extraction and display** - Extract ID3/metadata tags from audio files and display as "INDEX. ARTIST - TITLE", falls back to filename. Uses symphonia for metadata extraction. | ||
| 36 | |||
| 35 | ## Potential Improvements | 37 | ## Potential Improvements |
| 36 | 38 | ||
| 37 | - [ ] **Add error handling for audio playback** - Consider more robust error handling in the audio thread. | 39 | - [ ] **Add error handling for audio playback** - Consider more robust error handling in the audio thread. |
diff --git a/src/daemon.rs b/src/daemon.rs index 9fa5cea..4122c37 100644 --- a/src/daemon.rs +++ b/src/daemon.rs | |||
| @@ -15,8 +15,120 @@ enum ShuffleMode { | |||
| 15 | Album, | 15 | Album, |
| 16 | } | 16 | } |
| 17 | 17 | ||
| 18 | #[derive(Debug, Clone)] | ||
| 19 | struct Track { | ||
| 20 | path: PathBuf, | ||
| 21 | title: Option<String>, | ||
| 22 | artist: Option<String>, | ||
| 23 | album: Option<String>, | ||
| 24 | } | ||
| 25 | |||
| 26 | impl 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)] | ||
| 61 | struct AudioMetadata { | ||
| 62 | title: Option<String>, | ||
| 63 | artist: Option<String>, | ||
| 64 | album: Option<String>, | ||
| 65 | } | ||
| 66 | |||
| 67 | fn 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 | |||
| 18 | struct Queue { | 130 | struct Queue { |
| 19 | tracks: Vec<PathBuf>, | 131 | tracks: Vec<Track>, |
| 20 | position: usize, | 132 | position: usize, |
| 21 | shuffle: ShuffleMode, | 133 | shuffle: ShuffleMode, |
| 22 | shuffled_indices: Vec<usize>, | 134 | shuffled_indices: Vec<usize>, |
| @@ -33,7 +145,7 @@ impl Queue { | |||
| 33 | } | 145 | } |
| 34 | 146 | ||
| 35 | fn add_track(&mut self, path: PathBuf) { | 147 | fn add_track(&mut self, path: PathBuf) { |
| 36 | self.tracks.push(path); | 148 | self.tracks.push(Track::from_path(path)); |
| 37 | self.update_shuffle_indices(); | 149 | self.update_shuffle_indices(); |
| 38 | } | 150 | } |
| 39 | 151 | ||
| @@ -51,7 +163,7 @@ impl Queue { | |||
| 51 | } | 163 | } |
| 52 | } | 164 | } |
| 53 | 165 | ||
| 54 | fn current(&self) -> Option<&PathBuf> { | 166 | fn current(&self) -> Option<&Track> { |
| 55 | if self.tracks.is_empty() { | 167 | if self.tracks.is_empty() { |
| 56 | None | 168 | None |
| 57 | } else if let ShuffleMode::All = self.shuffle { | 169 | } else if let ShuffleMode::All = self.shuffle { |
| @@ -73,7 +185,7 @@ impl Queue { | |||
| 73 | let max_pos = self.tracks.len().saturating_sub(1); | 185 | let max_pos = self.tracks.len().saturating_sub(1); |
| 74 | if self.position < max_pos { | 186 | if self.position < max_pos { |
| 75 | self.position += 1; | 187 | self.position += 1; |
| 76 | self.current().cloned() | 188 | self.current().map(|t| t.path.clone()) |
| 77 | } else { | 189 | } else { |
| 78 | None | 190 | None |
| 79 | } | 191 | } |
| @@ -82,7 +194,7 @@ impl Queue { | |||
| 82 | fn previous(&mut self) -> Option<PathBuf> { | 194 | fn previous(&mut self) -> Option<PathBuf> { |
| 83 | if self.position > 0 { | 195 | if self.position > 0 { |
| 84 | self.position -= 1; | 196 | self.position -= 1; |
| 85 | self.current().cloned() | 197 | self.current().map(|t| t.path.clone()) |
| 86 | } else { | 198 | } else { |
| 87 | None | 199 | None |
| 88 | } | 200 | } |
| @@ -334,8 +446,10 @@ fn process_command( | |||
| 334 | match cmd { | 446 | match cmd { |
| 335 | "status" => { | 447 | "status" => { |
| 336 | if audio.is_playing() { | 448 | if audio.is_playing() { |
| 337 | if let Some(track) = audio.current_track() { | 449 | if let Some(track_path) = audio.current_track() { |
| 338 | format!("Playing\nTrack: {}\n", track.display()) | 450 | let track = Track::from_path(track_path); |
| 451 | let track_str = track.display_string(0); | ||
| 452 | format!("Playing\nTrack: {}\n", track_str) | ||
| 339 | } else { | 453 | } else { |
| 340 | "Playing\n".to_string() | 454 | "Playing\n".to_string() |
| 341 | } | 455 | } |
| @@ -450,8 +564,8 @@ fn process_command( | |||
| 450 | } | 564 | } |
| 451 | // If shuffle is on, shuffled_indices is already set | 565 | // If shuffle is on, shuffled_indices is already set |
| 452 | // Play the first track from the shuffled list | 566 | // Play the first track from the shuffled list |
| 453 | if let Some(track) = q.current().cloned() { | 567 | if let Some(track) = q.current() { |
| 454 | audio.play(track); | 568 | audio.play(track.path.clone()); |
| 455 | } | 569 | } |
| 456 | } | 570 | } |
| 457 | format!("Queued {} tracks from: {}\n", files.len(), path_str) | 571 | format!("Queued {} tracks from: {}\n", files.len(), path_str) |
| @@ -506,10 +620,11 @@ fn process_command( | |||
| 506 | }; | 620 | }; |
| 507 | for (display_idx, &track_idx) in indices.iter().enumerate() { | 621 | for (display_idx, &track_idx) in indices.iter().enumerate() { |
| 508 | if let Some(track) = q.tracks.get(track_idx) { | 622 | if let Some(track) = q.tracks.get(track_idx) { |
| 623 | let track_str = track.display_string(display_idx); | ||
| 509 | if display_idx == current_position { | 624 | if display_idx == current_position { |
| 510 | output.push_str(&format!("> {} (playing)\n", track.display())); | 625 | output.push_str(&format!("> {} (playing)\n", track_str)); |
| 511 | } else { | 626 | } else { |
| 512 | output.push_str(&format!(" {}\n", track.display())); | 627 | output.push_str(&format!(" {}\n", track_str)); |
| 513 | } | 628 | } |
| 514 | } | 629 | } |
| 515 | } | 630 | } |
