//! Audio file metadata extraction using lofty. //! //! This module provides utilities to extract both technical audio properties //! (sample rate, channels, bit depth) and artistic tags (title, artist, album) //! from audio files in various formats. //! //! # Examples //! //! ```no_run //! use pmoflac::AudioFileMetadata; //! use std::path::Path; //! //! # fn main() -> Result<(), Box> { //! let metadata = AudioFileMetadata::from_file(Path::new("audio.flac"))?; //! //! println!("Title: {:?}", metadata.title); //! println!("Artist: {:?}", metadata.artist); //! println!("Sample rate: {:?} Hz", metadata.sample_rate); //! println!("Duration: {:?} seconds", metadata.duration_secs); //! # Ok(()) //! # } //! ``` use lofty::{config::ParseOptions, prelude::*, probe::Probe}; use std::{io::Cursor, path::Path}; /// Comprehensive audio file metadata including both technical properties and artistic tags. /// /// This struct is populated from audio file tags using the lofty library, /// which supports FLAC, MP3, Ogg Vorbis, Opus, WAV, AIFF, and other formats. #[derive(Debug, Clone, Default)] pub struct AudioFileMetadata { // Artistic tags /// Track title pub title: Option, /// Artist name pub artist: Option, /// Album name pub album: Option, /// Year of release pub year: Option, /// Music genre pub genre: Option, /// Track number in the album pub track_number: Option, /// Total number of tracks in the album pub track_total: Option, /// Disc number (for multi-disc albums) pub disc_number: Option, /// Total number of discs pub disc_total: Option, // Technical audio properties /// Duration in seconds pub duration_secs: Option, /// Sample rate in Hz (e.g., 44100, 48000) pub sample_rate: Option, /// Number of audio channels (1 = mono, 2 = stereo, etc.) pub channels: Option, /// Bitrate in bits per second pub bitrate: Option, } impl AudioFileMetadata { /// Extracts metadata from an audio file on disk. /// /// # Arguments /// /// * `path` - Path to the audio file /// /// # Returns /// /// Returns `AudioFileMetadata` on success, or a `lofty::error::LoftyError` if /// the file cannot be read or parsed. /// /// # Examples /// /// ```no_run /// use pmoflac::AudioFileMetadata; /// use std::path::Path; /// /// # fn main() -> Result<(), Box> { /// let metadata = AudioFileMetadata::from_file(Path::new("song.flac"))?; /// println!("Duration: {:?}s", metadata.duration_secs); /// # Ok(()) /// # } /// ``` pub fn from_file(path: &Path) -> Result { tracing::debug!(path = %path.display(), "Extracting metadata from file"); let tagged_file = Probe::open(path)?.options(ParseOptions::new()).read()?; let metadata = Self::from_tagged_file(tagged_file); tracing::debug!( path = %path.display(), title = ?metadata.title, artist = ?metadata.artist, duration_secs = ?metadata.duration_secs, "Metadata extracted successfully" ); Ok(metadata) } /// Extracts metadata from audio file bytes in memory. /// /// # Arguments /// /// * `data` - Audio file data as a byte slice /// /// # Returns /// /// Returns `AudioFileMetadata` on success, or a `lofty::error::LoftyError` if /// the data cannot be parsed. /// /// # Examples /// /// ```no_run /// use pmoflac::AudioFileMetadata; /// /// # fn main() -> Result<(), Box> { /// let audio_bytes: &[u8] = &[/* ... */]; /// let metadata = AudioFileMetadata::from_bytes(audio_bytes)?; /// # Ok(()) /// # } /// ``` pub fn from_bytes(data: &[u8]) -> Result { tracing::debug!(size = data.len(), "Extracting metadata from bytes"); let cursor = Cursor::new(data); let tagged_file = Probe::new(cursor) .guess_file_type()? .options(ParseOptions::new()) .read()?; Ok(Self::from_tagged_file(tagged_file)) } /// Internal helper to extract metadata from a lofty `TaggedFile`. fn from_tagged_file(tagged_file: lofty::file::TaggedFile) -> Self { let properties = tagged_file.properties(); // Try to get the primary tag, or fall back to the first available tag let tag = tagged_file .primary_tag() .or_else(|| tagged_file.first_tag()); // Initialize with technical properties let mut metadata = Self { title: None, artist: None, album: None, year: None, genre: None, track_number: None, track_total: None, disc_number: None, disc_total: None, duration_secs: Some(properties.duration().as_secs()), sample_rate: properties.sample_rate(), channels: properties.channels(), bitrate: properties.audio_bitrate(), }; // Extract artistic tags if available if let Some(tag) = tag { metadata.title = tag.title().map(|s| s.to_string()); metadata.artist = tag.artist().map(|s| s.to_string()); metadata.album = tag.album().map(|s| s.to_string()); metadata.year = tag.year(); metadata.genre = tag.genre().map(|s| s.to_string()); metadata.track_number = tag.track(); metadata.track_total = tag.track_total(); metadata.disc_number = tag.disk(); metadata.disc_total = tag.disk_total(); } else { tracing::warn!("No tags found in audio file"); } metadata } /// Returns a formatted duration string in H:MM:SS format (DIDL-Lite compatible). /// /// # Examples /// /// ``` /// use pmoflac::AudioFileMetadata; /// /// let mut metadata = AudioFileMetadata { /// duration_secs: Some(3665), // 1 hour, 1 minute, 5 seconds /// title: None, /// artist: None, /// album: None, /// year: None, /// genre: None, /// track_number: None, /// track_total: None, /// disc_number: None, /// disc_total: None, /// sample_rate: None, /// channels: None, /// bitrate: None, /// }; /// /// assert_eq!(metadata.duration_formatted(), "1:01:05"); /// ``` pub fn duration_formatted(&self) -> String { match self.duration_secs { Some(secs) => { let hours = secs / 3600; let minutes = (secs % 3600) / 60; let seconds = secs % 60; format!("{}:{:02}:{:02}", hours, minutes, seconds) } None => "0:00:00".to_string(), } } /// Returns a collection key in the format "artist:album" for grouping tracks. /// /// This is useful for organizing tracks into albums in a music library. /// /// # Examples /// /// ``` /// use pmoflac::AudioFileMetadata; /// /// let metadata = AudioFileMetadata { /// artist: Some("Pink Floyd".to_string()), /// album: Some("The Dark Side of the Moon".to_string()), /// title: None, /// year: None, /// genre: None, /// track_number: None, /// track_total: None, /// disc_number: None, /// disc_total: None, /// duration_secs: None, /// sample_rate: None, /// channels: None, /// bitrate: None, /// }; /// /// assert_eq!(metadata.collection_key(), "Pink Floyd:The Dark Side of the Moon"); /// ``` pub fn collection_key(&self) -> String { match (&self.artist, &self.album) { (Some(artist), Some(album)) => format!("{}:{}", artist, album), (Some(artist), None) => artist.clone(), (None, Some(album)) => album.clone(), (None, None) => "Unknown".to_string(), } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_duration_formatted() { let mut meta = AudioFileMetadata::default(); meta.duration_secs = Some(65); assert_eq!(meta.duration_formatted(), "0:01:05"); meta.duration_secs = Some(3665); assert_eq!(meta.duration_formatted(), "1:01:05"); meta.duration_secs = Some(0); assert_eq!(meta.duration_formatted(), "0:00:00"); meta.duration_secs = None; assert_eq!(meta.duration_formatted(), "0:00:00"); } #[test] fn test_collection_key() { let mut meta = AudioFileMetadata::default(); meta.artist = Some("Artist".to_string()); meta.album = Some("Album".to_string()); assert_eq!(meta.collection_key(), "Artist:Album"); meta.album = None; assert_eq!(meta.collection_key(), "Artist"); meta.artist = None; meta.album = Some("Album".to_string()); assert_eq!(meta.collection_key(), "Album"); meta.album = None; assert_eq!(meta.collection_key(), "Unknown"); } }