diff --git a/src/file.rs b/src/file.rs index 20bc4bc2..8d3a1878 100644 --- a/src/file.rs +++ b/src/file.rs @@ -210,6 +210,7 @@ pub enum FileType { Vorbis, Speex, WAV, + WavPack, } impl FileType { @@ -219,7 +220,7 @@ impl FileType { /// | [`FileType`] | [`TagType`] | /// |--------------------------|------------------| /// | `AIFF`, `MP3`, `WAV` | `Id3v2` | - /// | `APE` | `Ape` | + /// | `APE` , `WavPack` | `Ape` | /// | `FLAC`, `Opus`, `Vorbis` | `VorbisComments` | /// | `MP4` | `Mp4Ilst` | pub fn primary_tag_type(&self) -> TagType { @@ -234,8 +235,8 @@ impl FileType { FileType::MP3 => TagType::Ape, FileType::AIFF | FileType::MP3 | FileType::WAV => TagType::Id3v2, #[cfg(all(not(feature = "ape"), feature = "id3v1"))] - FileType::MP3 => TagType::Id3v1, - FileType::APE => TagType::Ape, + FileType::MP3 | FileType::WavPack => TagType::Id3v1, + FileType::APE | FileType::WavPack => TagType::Ape, FileType::FLAC | FileType::Opus | FileType::Vorbis | FileType::Speex => { TagType::VorbisComments }, @@ -255,9 +256,9 @@ impl FileType { #[cfg(feature = "aiff_text_chunks")] FileType::AIFF if tag_type == TagType::AiffText => true, #[cfg(feature = "id3v1")] - FileType::APE | FileType::MP3 if tag_type == TagType::Id3v1 => true, + FileType::APE | FileType::MP3 | FileType::WavPack if tag_type == TagType::Id3v1 => true, #[cfg(feature = "ape")] - FileType::APE | FileType::MP3 if tag_type == TagType::Ape => true, + FileType::APE | FileType::MP3 | FileType::WavPack if tag_type == TagType::Ape => true, #[cfg(feature = "vorbis_comments")] FileType::Opus | FileType::FLAC | FileType::Vorbis | FileType::Speex => { tag_type == TagType::VorbisComments @@ -282,6 +283,7 @@ impl FileType { "aiff" | "aif" | "afc" | "aifc" => Some(Self::AIFF), "mp3" => Some(Self::MP3), "wav" | "wave" => Some(Self::WAV), + "wv" => Some(Self::WavPack), "opus" => Some(Self::Opus), "flac" => Some(Self::FLAC), "ogg" => Some(Self::Vorbis), @@ -386,6 +388,7 @@ impl FileType { None }, + 82 if buf.len() >= 4 && &buf[..4] == b"wvpk" => Some(Self::WavPack), _ if buf.len() >= 8 && &buf[4..8] == b"ftyp" => Some(Self::MP4), _ => None, } diff --git a/src/lib.rs b/src/lib.rs index 6f36fb40..d362f7bb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -182,6 +182,7 @@ mod probe; pub(crate) mod properties; pub(crate) mod tag; mod traits; +pub mod wavpack; pub use crate::error::{LoftyError, Result}; diff --git a/src/probe.rs b/src/probe.rs index 5c6ff23b..aad6b41d 100644 --- a/src/probe.rs +++ b/src/probe.rs @@ -10,6 +10,7 @@ use crate::mp4::Mp4File; use crate::ogg::opus::OpusFile; use crate::ogg::speex::SpeexFile; use crate::ogg::vorbis::VorbisFile; +use crate::wavpack::WavPackFile; use std::fs::File; use std::io::{BufReader, Cursor, Read, Seek, SeekFrom}; @@ -227,6 +228,7 @@ impl Probe { FileType::WAV => WavFile::read_from(reader, read_properties)?.into(), FileType::MP4 => Mp4File::read_from(reader, read_properties)?.into(), FileType::Speex => SpeexFile::read_from(reader, read_properties)?.into(), + FileType::WavPack => WavPackFile::read_from(reader, read_properties)?.into(), }), None => Err(LoftyError::new(ErrorKind::UnknownFormat)), } @@ -293,7 +295,7 @@ mod tests { let data: Vec = data.into_iter().flatten().copied().collect(); let data = std::io::Cursor::new(&data); let probe = Probe::new(data).guess_file_type().unwrap(); - assert_eq!(probe.file_type(), Some(crate::FileType::MP3)); + assert_eq!(probe.file_type(), Some(FileType::MP3)); } fn test_probe(path: &str, expected_file_type_guess: FileType) { diff --git a/src/tag/mod.rs b/src/tag/mod.rs index ddfeeeaf..e3c8a962 100644 --- a/src/tag/mod.rs +++ b/src/tag/mod.rs @@ -408,6 +408,7 @@ impl TagExt for Tag { } } +// TODO: Properly capitalize these /// The tag's format #[derive(Copy, Clone, Debug, PartialEq)] #[non_exhaustive] diff --git a/src/wavpack/mod.rs b/src/wavpack/mod.rs new file mode 100644 index 00000000..8db65853 --- /dev/null +++ b/src/wavpack/mod.rs @@ -0,0 +1,93 @@ +//! WavPack specific items +mod properties; +mod read; + +#[cfg(feature = "ape")] +use crate::ape::tag::ApeTag; +use crate::error::Result; +use crate::file::{AudioFile, FileType, TaggedFile}; +#[cfg(feature = "id3v1")] +use crate::id3::v1::tag::Id3v1Tag; +use crate::properties::FileProperties; +use crate::tag::{Tag, TagType}; + +use std::io::{Read, Seek}; + +// Exports +pub use properties::WavPackProperties; + +/// A WavPack file +#[derive(Default)] +pub struct WavPackFile { + #[cfg(feature = "id3v1")] + /// An ID3v1 tag + pub(crate) id3v1_tag: Option, + #[cfg(feature = "ape")] + /// An APEv1/v2 tag + pub(crate) ape_tag: Option, + /// The file's audio properties + pub(crate) properties: WavPackProperties, +} + +impl From for TaggedFile { + #[allow(clippy::vec_init_then_push, unused_mut)] + fn from(input: WavPackFile) -> Self { + let mut tags = Vec::>::with_capacity(2); + + #[cfg(feature = "id3v1")] + tags.push(input.id3v1_tag.map(Into::into)); + #[cfg(feature = "ape")] + tags.push(input.ape_tag.map(Into::into)); + + Self { + ty: FileType::WavPack, + properties: FileProperties::from(input.properties), + tags: tags.into_iter().flatten().collect(), + } + } +} + +impl AudioFile for WavPackFile { + type Properties = WavPackProperties; + + fn read_from(reader: &mut R, read_properties: bool) -> Result + where + R: Read + Seek, + { + read::read_from(reader, read_properties) + } + + fn properties(&self) -> &Self::Properties { + &self.properties + } + + #[allow(unreachable_code)] + fn contains_tag(&self) -> bool { + #[cfg(feature = "id3v1")] + return self.id3v1_tag.is_some(); + #[cfg(feature = "ape")] + return self.ape_tag.is_some(); + + false + } + + fn contains_tag_type(&self, tag_type: TagType) -> bool { + match tag_type { + #[cfg(feature = "ape")] + TagType::Ape => self.ape_tag.is_some(), + #[cfg(feature = "id3v1")] + TagType::Id3v1 => self.id3v1_tag.is_some(), + _ => false, + } + } +} + +impl WavPackFile { + crate::macros::tag_methods! { + #[cfg(feature = "id3v1")] + id3v1_tag, Id3v1Tag; + + #[cfg(feature = "ape")] + ape_tag, ApeTag + } +} diff --git a/src/wavpack/properties.rs b/src/wavpack/properties.rs new file mode 100644 index 00000000..5e24048a --- /dev/null +++ b/src/wavpack/properties.rs @@ -0,0 +1,67 @@ +use crate::properties::FileProperties; + +use std::time::Duration; + +#[derive(Debug, Clone, Copy, PartialEq, Default)] +#[non_exhaustive] +/// A WavPack file's audio properties +pub struct WavPackProperties { + pub(crate) version: u16, + pub(crate) duration: Duration, + pub(crate) overall_bitrate: u32, + pub(crate) audio_bitrate: u32, + pub(crate) sample_rate: u32, + pub(crate) channels: u8, + pub(crate) bit_depth: u8, + pub(crate) lossless: bool, +} + +impl From for FileProperties { + fn from(input: WavPackProperties) -> Self { + Self { + duration: input.duration, + overall_bitrate: Some(input.overall_bitrate), + audio_bitrate: Some(input.audio_bitrate), + sample_rate: Some(input.sample_rate), + bit_depth: Some(input.bit_depth), + channels: Some(input.channels), + } + } +} + +impl WavPackProperties { + /// Duration + pub fn duration(&self) -> Duration { + self.duration + } + + /// Overall bitrate (kbps) + pub fn overall_bitrate(&self) -> u32 { + self.overall_bitrate + } + + /// Audio bitrate (kbps) + pub fn audio_bitrate(&self) -> u32 { + self.audio_bitrate + } + + /// Sample rate (Hz) + pub fn sample_rate(&self) -> u32 { + self.sample_rate + } + + /// Channel count + pub fn channels(&self) -> u8 { + self.channels + } + + /// WavPack version + pub fn version(&self) -> u16 { + self.version + } + + /// Whether the audio is lossless + pub fn is_lossless(&self) -> bool { + self.lossless + } +} diff --git a/src/wavpack/read.rs b/src/wavpack/read.rs new file mode 100644 index 00000000..2bda1bdd --- /dev/null +++ b/src/wavpack/read.rs @@ -0,0 +1,62 @@ +use super::properties::WavPackProperties; +use super::WavPackFile; +use crate::ape::constants::APE_PREAMBLE; +use crate::ape::header::read_ape_header; +use crate::ape::tag::read::read_ape_tag; +use crate::error::Result; +use crate::id3::{find_id3v1, find_lyrics3v2, ID3FindResults}; + +use std::io::{Read, Seek, SeekFrom}; + +pub(super) fn read_from(reader: &mut R, _read_properties: bool) -> Result +where + R: Read + Seek, +{ + #[cfg(feature = "id3v1")] + let mut id3v1_tag = None; + #[cfg(feature = "ape")] + let mut ape_tag = None; + + let ID3FindResults(id3v1_header, id3v1) = find_id3v1(reader, true)?; + + if id3v1_header.is_some() { + #[cfg(feature = "id3v1")] + { + id3v1_tag = id3v1; + } + } + + // Next, check for a Lyrics3v2 tag, and skip over it, as it's no use to us + let ID3FindResults(_lyrics3_header, _lyrics3v2_size) = find_lyrics3v2(reader)?; + + // Next, search for an APE tag footer + // + // Starts with ['A', 'P', 'E', 'T', 'A', 'G', 'E', 'X'] + // Exactly 32 bytes long + // Strongly recommended to be at the end of the file + reader.seek(SeekFrom::Current(-32))?; + + let mut ape_preamble = [0; 8]; + reader.read_exact(&mut ape_preamble)?; + + if &ape_preamble == APE_PREAMBLE { + let ape_header = read_ape_header(reader, true)?; + + #[cfg(feature = "ape")] + { + let ape = read_ape_tag(reader, ape_header)?; + ape_tag = Some(ape) + } + + #[cfg(not(feature = "ape"))] + data.seek(SeekFrom::Current(ape_header.size as i64))?; + } + + Ok(WavPackFile { + #[cfg(feature = "id3v1")] + id3v1_tag, + #[cfg(feature = "ape")] + ape_tag, + properties: WavPackProperties::default(), + }) +} diff --git a/tests/files/assets/minimal/full_test.wv b/tests/files/assets/minimal/full_test.wv new file mode 100644 index 00000000..15f31bb3 Binary files /dev/null and b/tests/files/assets/minimal/full_test.wv differ diff --git a/tests/files/main.rs b/tests/files/main.rs index d3b029cf..a453bb8e 100644 --- a/tests/files/main.rs +++ b/tests/files/main.rs @@ -5,4 +5,5 @@ mod mpeg; mod ogg; pub(crate) mod util; mod wav; +mod wavpack; mod zero_sized; diff --git a/tests/files/wavpack.rs b/tests/files/wavpack.rs new file mode 100644 index 00000000..7ec22467 --- /dev/null +++ b/tests/files/wavpack.rs @@ -0,0 +1,55 @@ +use crate::{set_artist, temp_file, verify_artist}; +use lofty::{FileType, ItemKey, ItemValue, TagExt, TagItem, TagType}; +use std::io::{Seek, Write}; + +#[test] +fn read() { + // Here we have a WacPack file with both an ID3v1 tag and an APE tag + let file = lofty::read_from_path("tests/files/assets/minimal/full_test.wv", false).unwrap(); + + assert_eq!(file.file_type(), FileType::WavPack); + + // Verify the APE tag first + crate::verify_artist!(file, primary_tag, "Foo artist", 1); + + // Now verify the ID3v1 tag + crate::verify_artist!(file, tag, TagType::Id3v1, "Bar artist", 1); +} + +// TODO + +#[test] +#[ignore] +fn write() { + let mut file = temp_file!("tests/files/assets/minimal/full_test.wv"); + + let mut tagged_file = lofty::read_from(&mut file, false).unwrap(); + + assert_eq!(tagged_file.file_type(), FileType::WavPack); + + // APE + crate::set_artist!(tagged_file, primary_tag_mut, "Foo artist", 1 => file, "Bar artist"); + + // ID3v1 + crate::set_artist!(tagged_file, tag_mut, TagType::Id3v1, "Bar artist", 1 => file, "Baz artist"); + + // Now reread the file + file.rewind().unwrap(); + let mut tagged_file = lofty::read_from(&mut file, false).unwrap(); + + crate::set_artist!(tagged_file, primary_tag_mut, "Bar artist", 1 => file, "Foo artist"); + + crate::set_artist!(tagged_file, tag_mut, TagType::Id3v1, "Baz artist", 1 => file, "Bar artist"); +} + +#[test] +#[ignore] +fn remove_id3v1() { + crate::remove_tag!("tests/files/assets/minimal/full_test.wv", TagType::Id3v1); +} + +#[test] +#[ignore] +fn remove_ape() { + crate::remove_tag!("tests/files/assets/minimal/full_test.wv", TagType::Ape); +}