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

src/library/track.rs (6366B)

      1 use lofty::config::ParseOptions;
      2 use lofty::file::AudioFile;
      3 use lofty::prelude::*;
      4 use lofty::probe::Probe;
      5 use serde::{Deserialize, Serialize};
      6 use std::path::{Path, PathBuf};
      7 use std::time::Duration;
      8 
      9 /// Serializable so the scan cache can store parsed tags verbatim; every field
     10 /// here came out of the file, so restoring one is equivalent to re-reading it.
     11 #[derive(Debug, Clone, Serialize, Deserialize)]
     12 pub struct Track {
     13     pub path: PathBuf,
     14     pub title: String,
     15     pub artist: String,
     16     pub album: String,
     17     pub album_artist: String,
     18     pub genre: String,
     19     pub year: Option<u32>,
     20     pub track_number: Option<u32>,
     21     pub track_total: Option<u32>,
     22     pub disc_number: Option<u32>,
     23     pub disc_total: Option<u32>,
     24     pub comment: Option<String>,
     25     pub duration: Duration,
     26     pub bitrate: Option<u32>,
     27     pub sample_rate: Option<u32>,
     28     pub bit_depth: Option<u8>,
     29     pub channels: Option<u8>,
     30 }
     31 
     32 impl Track {
     33     pub fn from_path(path: &Path) -> Option<Self> {
     34         // Skip cover art: scanning never keeps it, and the album art view reads
     35         // the picture straight from the file when it actually needs one. lofty
     36         // otherwise decodes and allocates every embedded image — around a
     37         // megabyte per track — only to have it dropped.
     38         let tagged_file = Probe::open(path)
     39             .ok()?
     40             .options(ParseOptions::new().read_cover_art(false))
     41             .read()
     42             .ok()?;
     43 
     44         let tag = tagged_file
     45             .primary_tag()
     46             .or_else(|| tagged_file.first_tag());
     47 
     48         let properties = tagged_file.properties();
     49 
     50         let title = tag
     51             .and_then(|t| t.title())
     52             .map(|s| s.to_string())
     53             .filter(|s| !s.is_empty())
     54             .unwrap_or_else(|| {
     55                 path.file_stem()
     56                     .map(|s| s.to_string_lossy().to_string())
     57                     .unwrap_or_else(|| "Unknown".to_string())
     58             });
     59 
     60         Some(Self {
     61             path: path.to_path_buf(),
     62             title,
     63             artist: tag.and_then(|t| t.artist()).map(|s| s.to_string()).unwrap_or_default(),
     64             album: tag.and_then(|t| t.album()).map(|s| s.to_string()).unwrap_or_default(),
     65             album_artist: tag
     66                 .and_then(|t| t.get_string(&ItemKey::AlbumArtist))
     67                 .map(|s| s.to_string())
     68                 .unwrap_or_default(),
     69             genre: tag.and_then(|t| t.genre()).map(|s| s.to_string()).unwrap_or_default(),
     70             year: tag.and_then(|t| t.year()),
     71             track_number: tag.and_then(|t| t.track()),
     72             track_total: tag.and_then(|t| t.track_total()),
     73             disc_number: tag.and_then(|t| t.disk()),
     74             disc_total: tag.and_then(|t| t.disk_total()),
     75             comment: tag
     76                 .and_then(|t| t.comment())
     77                 .map(|s| s.to_string())
     78                 .filter(|s| !s.is_empty()),
     79             duration: properties.duration(),
     80             // FLAC and other lossless formats often only carry the overall rate.
     81             bitrate: properties.audio_bitrate().or_else(|| properties.overall_bitrate()),
     82             sample_rate: properties.sample_rate(),
     83             bit_depth: properties.bit_depth(),
     84             channels: properties.channels(),
     85         })
     86     }
     87 
     88     pub fn display_artist(&self) -> &str {
     89         if self.artist.is_empty() {
     90             "Unknown Artist"
     91         } else {
     92             &self.artist
     93         }
     94     }
     95 
     96     pub fn display_album(&self) -> &str {
     97         if self.album.is_empty() {
     98             "Unknown Album"
     99         } else {
    100             &self.album
    101         }
    102     }
    103 
    104     pub fn format_duration(&self) -> String {
    105         let secs = self.duration.as_secs();
    106         let mins = secs / 60;
    107         let secs = secs % 60;
    108         format!("{}:{:02}", mins, secs)
    109     }
    110 
    111     /// "1/12", or just "1" when the total is unknown.
    112     pub fn format_index(n: Option<u32>, total: Option<u32>) -> String {
    113         match (n, total) {
    114             (Some(n), Some(total)) => format!("{}/{}", n, total),
    115             (Some(n), None) => n.to_string(),
    116             _ => "N/A".to_string(),
    117         }
    118     }
    119 
    120     /// "44.1 kHz · 16-bit · Stereo" from whichever parts the file reports.
    121     pub fn format_quality(&self) -> String {
    122         let mut parts = Vec::new();
    123         if let Some(hz) = self.sample_rate {
    124             parts.push(format!("{:.1} kHz", hz as f64 / 1000.0));
    125         }
    126         if let Some(bits) = self.bit_depth {
    127             parts.push(format!("{}-bit", bits));
    128         }
    129         match self.channels {
    130             Some(1) => parts.push("Mono".to_string()),
    131             Some(2) => parts.push("Stereo".to_string()),
    132             Some(n) => parts.push(format!("{} ch", n)),
    133             None => {}
    134         }
    135         if parts.is_empty() {
    136             "N/A".to_string()
    137         } else {
    138             parts.join(" \u{00B7} ")
    139         }
    140     }
    141 }
    142 
    143 #[cfg(test)]
    144 mod tests {
    145     use super::*;
    146 
    147     fn track(sample_rate: Option<u32>, bit_depth: Option<u8>, channels: Option<u8>) -> Track {
    148         Track {
    149             path: PathBuf::new(),
    150             title: String::new(),
    151             artist: String::new(),
    152             album: String::new(),
    153             album_artist: String::new(),
    154             genre: String::new(),
    155             year: None,
    156             track_number: None,
    157             track_total: None,
    158             disc_number: None,
    159             disc_total: None,
    160             comment: None,
    161             duration: Duration::ZERO,
    162             bitrate: None,
    163             sample_rate,
    164             bit_depth,
    165             channels,
    166         }
    167     }
    168 
    169     #[test]
    170     fn index_uses_total_only_when_present() {
    171         assert_eq!(Track::format_index(Some(1), Some(12)), "1/12");
    172         assert_eq!(Track::format_index(Some(1), None), "1");
    173         assert_eq!(Track::format_index(None, Some(12)), "N/A");
    174         assert_eq!(Track::format_index(None, None), "N/A");
    175     }
    176 
    177     #[test]
    178     fn quality_skips_parts_the_file_does_not_report() {
    179         assert_eq!(
    180             track(Some(44100), Some(16), Some(2)).format_quality(),
    181             "44.1 kHz \u{00B7} 16-bit \u{00B7} Stereo"
    182         );
    183         assert_eq!(track(Some(48000), None, Some(1)).format_quality(), "48.0 kHz \u{00B7} Mono");
    184         assert_eq!(track(None, None, Some(6)).format_quality(), "6 ch");
    185         assert_eq!(track(None, None, None).format_quality(), "N/A");
    186     }
    187 }