From 3402b2b4347a259c534e0c7345f1c90300115053 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sun, 2 Nov 2025 08:18:50 +0100 Subject: [PATCH] Corrections mineurs sur pmoflac --- Cargo.lock | 3 + pmoflac/Cargo.toml | 9 +- pmoflac/src/autodetect.rs | 44 +---- pmoflac/src/lib.rs | 3 + pmoflac/src/metadata.rs | 297 +++++++++++++++++++++++++++++++++ pmoflac/src/pcm.rs | 18 +- pmoflac/src/prefixed_reader.rs | 64 +++++++ pmoflac/src/transcode.rs | 40 +---- 8 files changed, 388 insertions(+), 90 deletions(-) create mode 100644 pmoflac/src/metadata.rs create mode 100644 pmoflac/src/prefixed_reader.rs diff --git a/Cargo.lock b/Cargo.lock index a25dac29..fb7a43c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2734,11 +2734,14 @@ dependencies = [ "lewton", "libc", "libflac-sys", + "lofty", "minimp3", "opus", + "pmometadata", "tempfile", "thiserror 1.0.69", "tokio", + "tracing", ] [[package]] diff --git a/pmoflac/Cargo.toml b/pmoflac/Cargo.toml index b90bac28..f22df6e0 100644 --- a/pmoflac/Cargo.toml +++ b/pmoflac/Cargo.toml @@ -13,13 +13,16 @@ path = "src/lib.rs" [dependencies] bytes = "1.6" claxon = "0.4" +lewton = "0.10" libc = "0.2" libflac-sys = { version = "0.3.3", default-features = false, features = ["build-flac"] } +lofty = "0.22" +minimp3 = "0.5" +opus = "0.3" +pmometadata = { path = "../pmometadata" } thiserror = "1.0" tokio = { version = "1.37", features = ["rt", "rt-multi-thread", "macros", "sync", "io-util", "fs"] } -minimp3 = "0.5" -lewton = "0.10" -opus = "0.3" +tracing = "0.1" [dev-dependencies] tempfile = "3.10" diff --git a/pmoflac/src/autodetect.rs b/pmoflac/src/autodetect.rs index 2bda9060..b7b6dcb0 100644 --- a/pmoflac/src/autodetect.rs +++ b/pmoflac/src/autodetect.rs @@ -1,5 +1,5 @@ use std::{ - cmp, io, + io, pin::Pin, task::{Context, Poll}, }; @@ -8,9 +8,9 @@ use tokio::io::{AsyncRead, AsyncReadExt, ReadBuf}; use crate::{ decode_aiff_stream, decode_flac_stream, decode_mp3_stream, decode_ogg_opus_stream, - decode_ogg_vorbis_stream, decode_wav_stream, pcm::StreamInfo, AiffDecodedStream, AiffError, - FlacDecodedStream, FlacError, Mp3DecodedStream, Mp3Error, OggDecodedStream, OggError, - OggOpusDecodedStream, OggOpusError, WavDecodedStream, WavError, + decode_ogg_vorbis_stream, decode_wav_stream, pcm::StreamInfo, prefixed_reader::PrefixedReader, + AiffDecodedStream, AiffError, FlacDecodedStream, FlacError, Mp3DecodedStream, Mp3Error, + OggDecodedStream, OggError, OggOpusDecodedStream, OggOpusError, WavDecodedStream, WavError, }; const MAX_SNIFF_BYTES: usize = 64 * 1024; @@ -290,39 +290,3 @@ enum DetectedFormat { Wav, Aiff, } - -struct PrefixedReader { - prefix: Vec, - position: usize, - reader: R, -} - -impl PrefixedReader { - fn new(prefix: Vec, reader: R) -> Self { - Self { - prefix, - position: 0, - reader, - } - } -} - -impl AsyncRead for PrefixedReader { - fn poll_read( - mut self: Pin<&mut Self>, - cx: &mut Context<'_>, - buf: &mut ReadBuf<'_>, - ) -> Poll> { - if self.position < self.prefix.len() && buf.remaining() > 0 { - let remaining = self.prefix.len() - self.position; - let to_copy = cmp::min(remaining, buf.remaining()); - buf.put_slice(&self.prefix[self.position..self.position + to_copy]); - self.position += to_copy; - return Poll::Ready(Ok(())); - } - Pin::new(&mut self.reader).poll_read(cx, buf) - } -} - -impl Unpin for PrefixedReader {} -unsafe impl Send for PrefixedReader {} diff --git a/pmoflac/src/lib.rs b/pmoflac/src/lib.rs index c8dbe5f9..b88e9787 100644 --- a/pmoflac/src/lib.rs +++ b/pmoflac/src/lib.rs @@ -102,11 +102,13 @@ pub mod decoder; mod decoder_common; pub mod encoder; pub mod error; +pub mod metadata; pub mod mp3; pub mod ogg; mod ogg_common; pub mod opus; mod pcm; +mod prefixed_reader; mod stream; pub mod transcode; mod util; @@ -117,6 +119,7 @@ pub use autodetect::{decode_audio_stream, DecodeAudioError, DecodedAudioStream, pub use decoder::{decode_flac_stream, FlacDecodedStream}; pub use encoder::{encode_flac_stream, EncoderOptions, FlacEncodedStream}; pub use error::FlacError; +pub use metadata::AudioFileMetadata; pub use mp3::{decode_mp3_stream, Mp3DecodedStream, Mp3Error}; pub use ogg::{decode_ogg_vorbis_stream, OggDecodedStream, OggError}; pub use opus::{decode_ogg_opus_stream, OggOpusDecodedStream, OggOpusError}; diff --git a/pmoflac/src/metadata.rs b/pmoflac/src/metadata.rs new file mode 100644 index 00000000..ae1333c3 --- /dev/null +++ b/pmoflac/src/metadata.rs @@ -0,0 +1,297 @@ +//! 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"); + } +} diff --git a/pmoflac/src/pcm.rs b/pmoflac/src/pcm.rs index a7d2c2a4..45d04559 100644 --- a/pmoflac/src/pcm.rs +++ b/pmoflac/src/pcm.rs @@ -66,9 +66,9 @@ impl PcmFormat { 384000, ]; if !STANDARD_RATES.contains(&self.sample_rate) { - eprintln!( - "Warning: non-standard sample rate {} Hz (valid but unusual)", - self.sample_rate + tracing::warn!( + sample_rate = %self.sample_rate, + "non-standard sample rate (valid but unusual)" ); } @@ -79,18 +79,18 @@ impl PcmFormat { // FLAC officially supports 4-32 bits/sample if self.bits_per_sample < 4 { - eprintln!( - "Warning: bits_per_sample={} is less than 4 (unusual for FLAC)", - self.bits_per_sample + tracing::warn!( + bits_per_sample = %self.bits_per_sample, + "bits_per_sample is less than 4 (unusual for FLAC)" ); } // Warn about common bit depths const COMMON_BIT_DEPTHS: &[u8] = &[8, 16, 24, 32]; if !COMMON_BIT_DEPTHS.contains(&self.bits_per_sample) { - eprintln!( - "Warning: non-standard bit depth {} (valid but unusual)", - self.bits_per_sample + tracing::warn!( + bits_per_sample = %self.bits_per_sample, + "non-standard bit depth (valid but unusual)" ); } diff --git a/pmoflac/src/prefixed_reader.rs b/pmoflac/src/prefixed_reader.rs new file mode 100644 index 00000000..1771fa0c --- /dev/null +++ b/pmoflac/src/prefixed_reader.rs @@ -0,0 +1,64 @@ +//! Utility for reading from a prefix buffer followed by an underlying reader. +//! +//! This module provides `PrefixedReader`, which allows reading from an in-memory +//! buffer first, then seamlessly continuing with an underlying async reader. +//! +//! This is particularly useful for format detection where we need to peek at the +//! beginning of a stream before processing it. + +use std::{ + cmp, io, + pin::Pin, + task::{Context, Poll}, +}; + +use tokio::io::{AsyncRead, ReadBuf}; + +/// An async reader that reads from a prefix buffer before delegating to an underlying reader. +/// +/// This is useful when you've already consumed some bytes from a stream (e.g., for format +/// detection) and need to replay them before continuing with the rest of the stream. +pub(crate) struct PrefixedReader { + prefix: Vec, + position: usize, + reader: R, +} + +impl PrefixedReader { + /// Creates a new `PrefixedReader` with the given prefix and underlying reader. + /// + /// # Arguments + /// + /// * `prefix` - Bytes to read first before delegating to the reader + /// * `reader` - The underlying async reader to use after the prefix is exhausted + pub fn new(prefix: Vec, reader: R) -> Self { + Self { + prefix, + position: 0, + reader, + } + } +} + +impl AsyncRead for PrefixedReader { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + // If we still have bytes in the prefix, read from there first + if self.position < self.prefix.len() && buf.remaining() > 0 { + let remaining = self.prefix.len() - self.position; + let to_copy = cmp::min(remaining, buf.remaining()); + buf.put_slice(&self.prefix[self.position..self.position + to_copy]); + self.position += to_copy; + return Poll::Ready(Ok(())); + } + + // Prefix exhausted, delegate to underlying reader + Pin::new(&mut self.reader).poll_read(cx, buf) + } +} + +impl Unpin for PrefixedReader {} +unsafe impl Send for PrefixedReader {} diff --git a/pmoflac/src/transcode.rs b/pmoflac/src/transcode.rs index 0a67e0bd..345f70cd 100644 --- a/pmoflac/src/transcode.rs +++ b/pmoflac/src/transcode.rs @@ -15,7 +15,8 @@ use tokio::io::{AsyncRead, AsyncReadExt, BufReader, ReadBuf}; use crate::{ autodetect::{decode_audio_stream, DecodeAudioError, DecodedAudioStream}, - encode_flac_stream, EncoderOptions, FlacEncodedStream, FlacError, PcmFormat, StreamInfo, + encode_flac_stream, prefixed_reader::PrefixedReader, EncoderOptions, FlacEncodedStream, + FlacError, PcmFormat, StreamInfo, }; const READ_CHUNK: usize = 4096; @@ -306,43 +307,6 @@ fn parse_flac_stream_info(block: &[u8]) -> Option { }) } -struct PrefixedReader { - prefix: Vec, - position: usize, - reader: R, -} - -impl PrefixedReader { - fn new(prefix: Vec, reader: R) -> Self { - Self { - prefix, - position: 0, - reader, - } - } -} - -unsafe impl Send for PrefixedReader {} -impl Unpin for PrefixedReader {} - -impl AsyncRead for PrefixedReader { - fn poll_read( - mut self: Pin<&mut Self>, - cx: &mut Context<'_>, - buf: &mut ReadBuf<'_>, - ) -> Poll> { - if self.position < self.prefix.len() && buf.remaining() > 0 { - let remaining = self.prefix.len() - self.position; - let to_copy = remaining.min(buf.remaining()); - buf.put_slice(&self.prefix[self.position..self.position + to_copy]); - self.position += to_copy; - return Poll::Ready(Ok(())); - } - - Pin::new(&mut self.reader).poll_read(cx, buf) - } -} - #[cfg(test)] mod tests { use super::*;