ommp.git

ommp

~ Oh My Music Player — a standalone terminal music player built with Rust and ratatui
git clone https://github.com/debuman/ommp.git
commit c28ef671890883609ba3c7c50818c28c9b228f13
parent 33727f4edb996d630343dfee79d170bc3534b3bc
Author: sqzer-x <110329244+sqzer-x@users.noreply.github.com>
Date: Sat, 1 Aug 2026 15:27:39 +0900

Read album art and metadata from the audio file itself

Album art was looked up by scanning the track's directory for a jpg/png,
so files carrying an embedded cover in a directory without a loose image
showed nothing. Decode the picture out of the file's tags instead, keyed on
the track path rather than its directory, so per-track covers in a shared
directory also resolve.

Also surface the tag and stream fields that were already in the file but
unused: year, track/disc totals, comment, sample rate, bit depth, and
channel count. Fall back to the overall bitrate when a lossless format
reports no audio bitrate.

Diffstat:
Msrc/library/track.rs | 156+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Msrc/ui/widgets/info_pane.rs | 154+++++++++++++++++++++++++++++++++++++++++--------------------------------------
2 files changed, 199 insertion(+), 111 deletion(-)

diff --git a/src/library/track.rs b/src/library/track.rs
@@ -11,9 +11,17 @@ pub struct Track {
     pub album: String,
     pub album_artist: String,
     pub genre: String,
+    pub year: Option<u32>,
     pub track_number: Option<u32>,
+    pub track_total: Option<u32>,
+    pub disc_number: Option<u32>,
+    pub disc_total: Option<u32>,
+    pub comment: Option<String>,
     pub duration: Duration,
     pub bitrate: Option<u32>,
+    pub sample_rate: Option<u32>,
+    pub bit_depth: Option<u8>,
+    pub channels: Option<u8>,
     #[allow(dead_code)]
     pub lyrics: Option<String>,
 }
@@ -27,47 +35,45 @@ impl Track {
             .or_else(|| tagged_file.first_tag());
 
         let properties = tagged_file.properties();
-        let duration = properties.duration();
-        let bitrate = properties.audio_bitrate();
 
-        let (title, artist, album, album_artist, genre, track_number, lyrics) =
-            if let Some(tag) = tag {
-                let title_str: String = tag.title().map(|s| s.to_string()).unwrap_or_default();
-                let artist_str: String = tag.artist().map(|s| s.to_string()).unwrap_or_default();
-                let album_str: String = tag.album().map(|s| s.to_string()).unwrap_or_default();
-                let aa_str: String = tag
-                    .get_string(&ItemKey::AlbumArtist)
-                    .map(|s| s.to_string())
-                    .unwrap_or_default();
-                let genre_str: String = tag.genre().map(|s| s.to_string()).unwrap_or_default();
-                let track_num = tag.track();
-                let lyrics_str: Option<String> = tag
-                    .get_string(&ItemKey::Lyrics)
-                    .map(|s| s.to_string());
-                (title_str, artist_str, album_str, aa_str, genre_str, track_num, lyrics_str)
-            } else {
-                (String::new(), String::new(), String::new(), String::new(), String::new(), None, None)
-            };
-
-        let title = if title.is_empty() {
-            path.file_stem()
-                .map(|s| s.to_string_lossy().to_string())
-                .unwrap_or_else(|| "Unknown".to_string())
-        } else {
-            title
-        };
+        let title = tag
+            .and_then(|t| t.title())
+            .map(|s| s.to_string())
+            .filter(|s| !s.is_empty())
+            .unwrap_or_else(|| {
+                path.file_stem()
+                    .map(|s| s.to_string_lossy().to_string())
+                    .unwrap_or_else(|| "Unknown".to_string())
+            });
 
         Some(Self {
             path: path.to_path_buf(),
             title,
-            artist,
-            album,
-            album_artist,
-            genre,
-            track_number,
-            duration,
-            bitrate,
-            lyrics,
+            artist: tag.and_then(|t| t.artist()).map(|s| s.to_string()).unwrap_or_default(),
+            album: tag.and_then(|t| t.album()).map(|s| s.to_string()).unwrap_or_default(),
+            album_artist: tag
+                .and_then(|t| t.get_string(&ItemKey::AlbumArtist))
+                .map(|s| s.to_string())
+                .unwrap_or_default(),
+            genre: tag.and_then(|t| t.genre()).map(|s| s.to_string()).unwrap_or_default(),
+            year: tag.and_then(|t| t.year()),
+            track_number: tag.and_then(|t| t.track()),
+            track_total: tag.and_then(|t| t.track_total()),
+            disc_number: tag.and_then(|t| t.disk()),
+            disc_total: tag.and_then(|t| t.disk_total()),
+            comment: tag
+                .and_then(|t| t.comment())
+                .map(|s| s.to_string())
+                .filter(|s| !s.is_empty()),
+            duration: properties.duration(),
+            // FLAC and other lossless formats often only carry the overall rate.
+            bitrate: properties.audio_bitrate().or_else(|| properties.overall_bitrate()),
+            sample_rate: properties.sample_rate(),
+            bit_depth: properties.bit_depth(),
+            channels: properties.channels(),
+            lyrics: tag
+                .and_then(|t| t.get_string(&ItemKey::Lyrics))
+                .map(|s| s.to_string()),
         })
     }
 
@@ -93,4 +99,82 @@ impl Track {
         let secs = secs % 60;
         format!("{}:{:02}", mins, secs)
     }
+
+    /// "1/12", or just "1" when the total is unknown.
+    pub fn format_index(n: Option<u32>, total: Option<u32>) -> String {
+        match (n, total) {
+            (Some(n), Some(total)) => format!("{}/{}", n, total),
+            (Some(n), None) => n.to_string(),
+            _ => "N/A".to_string(),
+        }
+    }
+
+    /// "44.1 kHz · 16-bit · Stereo" from whichever parts the file reports.
+    pub fn format_quality(&self) -> String {
+        let mut parts = Vec::new();
+        if let Some(hz) = self.sample_rate {
+            parts.push(format!("{:.1} kHz", hz as f64 / 1000.0));
+        }
+        if let Some(bits) = self.bit_depth {
+            parts.push(format!("{}-bit", bits));
+        }
+        match self.channels {
+            Some(1) => parts.push("Mono".to_string()),
+            Some(2) => parts.push("Stereo".to_string()),
+            Some(n) => parts.push(format!("{} ch", n)),
+            None => {}
+        }
+        if parts.is_empty() {
+            "N/A".to_string()
+        } else {
+            parts.join(" \u{00B7} ")
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn track(sample_rate: Option<u32>, bit_depth: Option<u8>, channels: Option<u8>) -> Track {
+        Track {
+            path: PathBuf::new(),
+            title: String::new(),
+            artist: String::new(),
+            album: String::new(),
+            album_artist: String::new(),
+            genre: String::new(),
+            year: None,
+            track_number: None,
+            track_total: None,
+            disc_number: None,
+            disc_total: None,
+            comment: None,
+            duration: Duration::ZERO,
+            bitrate: None,
+            sample_rate,
+            bit_depth,
+            channels,
+            lyrics: None,
+        }
+    }
+
+    #[test]
+    fn index_uses_total_only_when_present() {
+        assert_eq!(Track::format_index(Some(1), Some(12)), "1/12");
+        assert_eq!(Track::format_index(Some(1), None), "1");
+        assert_eq!(Track::format_index(None, Some(12)), "N/A");
+        assert_eq!(Track::format_index(None, None), "N/A");
+    }
+
+    #[test]
+    fn quality_skips_parts_the_file_does_not_report() {
+        assert_eq!(
+            track(Some(44100), Some(16), Some(2)).format_quality(),
+            "44.1 kHz \u{00B7} 16-bit \u{00B7} Stereo"
+        );
+        assert_eq!(track(Some(48000), None, Some(1)).format_quality(), "48.0 kHz \u{00B7} Mono");
+        assert_eq!(track(None, None, Some(6)).format_quality(), "6 ch");
+        assert_eq!(track(None, None, None).format_quality(), "N/A");
+    }
 }
diff --git a/src/ui/widgets/info_pane.rs b/src/ui/widgets/info_pane.rs
@@ -7,18 +7,21 @@ use ratatui::text::{Line, Span};
 use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
 use ratatui::Frame;
 
+use lofty::file::TaggedFileExt;
+use lofty::picture::{Picture, PictureType};
 use ratatui_image::picker::Picker;
 use ratatui_image::protocol::StatefulProtocol;
 use ratatui_image::StatefulImage;
 
 use crate::app::state::InfoView;
 use crate::app::App;
+use crate::library::track::Track;
 use crate::ui::theme::Theme;
 
 // ── AlbumArtCache ────────────────────────────────────────────────────────
 
 pub struct AlbumArtCache {
-    track_dir: Option<PathBuf>,
+    track_path: Option<PathBuf>,
     picker: Picker,
     protocol: Option<StatefulProtocol>,
 }
@@ -26,87 +29,46 @@ pub struct AlbumArtCache {
 impl AlbumArtCache {
     pub fn new(picker: Picker) -> Self {
         Self {
-            track_dir: None,
+            track_path: None,
             picker,
             protocol: None,
         }
     }
 
-    fn needs_reload(&self, dir: Option<&Path>) -> bool {
-        match (&self.track_dir, dir) {
-            (Some(a), Some(b)) => a != b,
-            (None, None) => false,
-            _ => true,
-        }
+    fn needs_reload(&self, path: Option<&Path>) -> bool {
+        self.track_path.as_deref() != path
     }
 
-    fn load(&mut self, dir: Option<&Path>) {
-        self.track_dir = dir.map(|d| d.to_path_buf());
+    fn load(&mut self, path: Option<&Path>) {
+        self.track_path = path.map(|p| p.to_path_buf());
         self.protocol = None;
 
-        let dir = match dir {
-            Some(d) => d,
+        let img = match path.and_then(embedded_cover) {
+            Some(i) => i,
             None => return,
         };
 
-        let cover_path = match find_cover_image(dir) {
-            Some(p) => p,
-            None => return,
-        };
-
-        let img = match image::open(&cover_path) {
-            Ok(i) => i,
-            Err(_) => return,
-        };
-
         // StatefulProtocol handles resizing automatically per-frame
         self.protocol = Some(self.picker.new_resize_protocol(img));
     }
 }
 
-fn find_cover_image(dir: &Path) -> Option<PathBuf> {
-    let entries = std::fs::read_dir(dir).ok()?;
-    for entry in entries.flatten() {
-        let path = entry.path();
-        if !path.is_file() {
-            continue;
-        }
-        // Check extension first (fast path)
-        if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
-            let l = ext.to_ascii_lowercase();
-            if l == "jpg" || l == "jpeg" || l == "png" {
-                return Some(path);
-            }
-        }
-        // No extension or unknown ext — check magic bytes
-        if is_image_by_magic(&path) {
-            return Some(path);
-        }
-    }
-    None
+/// Decode the cover art embedded in the audio file itself.
+fn embedded_cover(path: &Path) -> Option<image::DynamicImage> {
+    let tagged_file = lofty::read_from_path(path).ok()?;
+    let tag = tagged_file
+        .primary_tag()
+        .or_else(|| tagged_file.first_tag())?;
+    let picture = pick_cover(tag.pictures())?;
+    image::load_from_memory(picture.data()).ok()
 }
 
-/// Check file header bytes to detect JPEG/PNG regardless of extension.
-fn is_image_by_magic(path: &Path) -> bool {
-    use std::fs::File;
-    use std::io::Read;
-    let mut f = match File::open(path) {
-        Ok(f) => f,
-        Err(_) => return false,
-    };
-    let mut buf = [0u8; 8];
-    if f.read_exact(&mut buf).is_err() {
-        return false;
-    }
-    // JPEG: FF D8 FF
-    if buf[0] == 0xFF && buf[1] == 0xD8 && buf[2] == 0xFF {
-        return true;
-    }
-    // PNG: 89 50 4E 47 0D 0A 1A 0A
-    if buf == [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A] {
-        return true;
-    }
-    false
+/// Prefer the front cover; fall back to whatever picture the file carries.
+fn pick_cover(pictures: &[Picture]) -> Option<&Picture> {
+    pictures
+        .iter()
+        .find(|p| p.pic_type() == PictureType::CoverFront)
+        .or_else(|| pictures.first())
 }
 
 // ── Public render function ───────────────────────────────────────────────
@@ -245,10 +207,10 @@ fn render_album_art(frame: &mut Frame, area: Rect, app: &App, cache: &mut AlbumA
         return;
     }
 
-    let track_dir = app.current_track().and_then(|t| t.path.parent().map(|p| p.to_path_buf()));
+    let track_path = app.current_track().map(|t| t.path.clone());
 
-    if cache.needs_reload(track_dir.as_deref()) {
-        cache.load(track_dir.as_deref());
+    if cache.needs_reload(track_path.as_deref()) {
+        cache.load(track_path.as_deref());
     }
 
     match cache.protocol {
@@ -326,16 +288,21 @@ pub fn render_track_info(frame: &mut Frame, area: Rect, app: &App, theme: &Theme
         .map(|b| format!("{} kbps", b))
         .unwrap_or_else(|| "N/A".to_string());
 
-    let track_num_str = track
-        .track_number
-        .map(|n| n.to_string())
-        .unwrap_or_else(|| "N/A".to_string());
+    let track_num_str = Track::format_index(track.track_number, track.track_total);
+    let disc_str = Track::format_index(track.disc_number, track.disc_total);
 
     let duration_str = track.format_duration();
 
+    let na = |s: &str| {
+        if s.is_empty() {
+            "N/A".to_string()
+        } else {
+            s.to_string()
+        }
+    };
 
     // Each field has a unique label color and value style
-    let fields: Vec<(&str, String, Color, Style)> = vec![
+    let mut fields: Vec<(&str, String, Color, Style)> = vec![
         ("Title", track.title.clone(),
             Color::Rgb(100, 180, 255),
             Style::default().fg(Color::Rgb(100, 220, 255)).add_modifier(Modifier::BOLD)),
@@ -345,20 +312,28 @@ pub fn render_track_info(frame: &mut Frame, area: Rect, app: &App, theme: &Theme
         ("Album", track.display_album().to_string(),
             Color::Rgb(200, 130, 255),
             Style::default().fg(Color::Rgb(220, 170, 255))),
-        ("Album Artist",
-            if track.album_artist.is_empty() { "N/A".to_string() } else { track.album_artist.clone() },
+        ("Album Artist", na(&track.album_artist),
             Color::Rgb(200, 130, 255),
             Style::default().fg(theme.fg)),
-        ("Genre",
-            if track.genre.is_empty() { "N/A".to_string() } else { track.genre.clone() },
+        ("Genre", na(&track.genre),
             Color::Rgb(255, 120, 150),
             Style::default().fg(Color::Rgb(255, 170, 190))),
+        ("Year",
+            track.year.map(|y| y.to_string()).unwrap_or_else(|| "N/A".to_string()),
+            Color::Rgb(255, 120, 150),
+            Style::default().fg(theme.fg)),
         ("Track #", track_num_str,
             Color::Rgb(120, 220, 180),
             Style::default().fg(theme.fg)),
+        ("Disc #", disc_str,
+            Color::Rgb(120, 220, 180),
+            Style::default().fg(theme.fg)),
         ("Duration", duration_str,
             Color::Rgb(120, 220, 180),
             Style::default().fg(theme.fg)),
+        ("Quality", track.format_quality(),
+            Color::Rgb(255, 220, 100),
+            Style::default().fg(theme.fg)),
         ("Bitrate", bitrate_str,
             Color::Rgb(255, 220, 100),
             Style::default().fg(theme.fg)),
@@ -367,6 +342,12 @@ pub fn render_track_info(frame: &mut Frame, area: Rect, app: &App, theme: &Theme
             Style::default().fg(theme.fg)),
     ];
 
+    if let Some(comment) = &track.comment {
+        fields.push(("Comment", comment.clone(),
+            Color::Rgb(160, 160, 160),
+            Style::default().fg(Color::Rgb(150, 150, 150))));
+    }
+
     let lines: Vec<Line> = fields
         .iter()
         .map(|(label, value, label_color, val_style)| {
@@ -380,3 +361,26 @@ pub fn render_track_info(frame: &mut Frame, area: Rect, app: &App, theme: &Theme
     let para = Paragraph::new(lines).wrap(Wrap { trim: false });
     frame.render_widget(para, area);
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use lofty::picture::MimeType;
+
+    fn pic(pic_type: PictureType, data: u8) -> Picture {
+        Picture::new_unchecked(pic_type, Some(MimeType::Jpeg), None, vec![data])
+    }
+
+    #[test]
+    fn cover_front_wins_over_earlier_pictures() {
+        let pictures = [pic(PictureType::CoverBack, 1), pic(PictureType::CoverFront, 2)];
+        assert_eq!(pick_cover(&pictures).unwrap().data(), &[2]);
+    }
+
+    #[test]
+    fn falls_back_to_first_picture_and_handles_none() {
+        let pictures = [pic(PictureType::Artist, 1), pic(PictureType::Band, 2)];
+        assert_eq!(pick_cover(&pictures).unwrap().data(), &[1]);
+        assert!(pick_cover(&[]).is_none());
+    }
+}