Add APE property reading, remove ape as a dependency

This commit is contained in:
Serial 2021-07-31 21:33:52 -04:00
parent a739dcd28e
commit b64091d649
15 changed files with 834 additions and 77 deletions

View file

@ -10,16 +10,16 @@ keywords = ["tags", "audio", "metadata"]
categories = ["accessibility", "multimedia::audio"]
[dependencies]
# Ape
ape = { git = "https://github.com/Serial-ATA/rust-ape", branch = "temporary", optional = true }
# Id3
id3 = {version = "0.6.4", optional = true} # De/Encoding
filepath = { version = "0.1.1", optional = true } # wav/aiff only supports paths for some reason
# Ogg
ogg_pager = { path = "ogg_pager", optional = true }
unicase = { version = "2.6.0", optional = true }
# Mp4
mp4ameta = {version = "0.11.0", optional = true}
# Case insensitive keys (APE/FLAC/Opus/Vorbis)
unicase = { version = "2.6.0", optional = true }
# Errors
thiserror = "1.0.26"
@ -35,7 +35,7 @@ format-mp4 = ["mp4ameta"]
format-flac = ["unicase"]
format-opus = ["ogg_pager", "unicase"]
format-vorbis = ["ogg_pager", "unicase"]
format-ape = ["ape"]
format-ape = ["unicase"]
format-id3 = ["id3", "filepath"]
format-aiff = []
format-riff = []

View file

@ -10,16 +10,16 @@ Parse, convert, and write metadata to various audio formats.
## Supported Formats
| File Format | Extensions | Read | Write | Metadata Format(s) |
|-------------|-------------------------------------------|------|-------|-----------------------|
| Ape | `ape` |**X** |**X** |`APEv2` |
| AIFF | `aiff`, `aif` |**X** |**X** |`ID3v2`, `Text Chunks` |
| FLAC | `flac` |**X** |**X** |`Vorbis Comments` |
| MP3 | `mp3` |**X** |**X** |`ID3v2` |
| MP4 | `mp4`, `m4a`, `m4b`, `m4p`, `m4v`, `isom` |**X** |**X** |`Vorbis Comments` |
| Opus | `opus` |**X** |**X** |`Vorbis Comments` |
| Ogg | `ogg`, `oga` |**X** |**X** |`Vorbis Comments` |
| WAV | `wav`, `wave` |**X** |**X** |`ID3v2`, `RIFF INFO` |
| File Format | Extensions | Read | Write | Metadata Format(s) |
|-------------|-------------------------------------------|------|-------|----------------------------------------------------|
| Ape | `ape` |**X** |**X** |`APEv2`, `APEv1`, `ID3v2` (Not officially), `ID3v1` |
| AIFF | `aiff`, `aif` |**X** |**X** |`ID3v2`, `Text Chunks` |
| FLAC | `flac` |**X** |**X** |`Vorbis Comments` |
| MP3 | `mp3` |**X** |**X** |`ID3v2`, `ID3v1`, `APEv2`, `APEv1` |
| MP4 | `mp4`, `m4a`, `m4b`, `m4p`, `m4v`, `isom` |**X** |**X** |`Vorbis Comments` |
| Opus | `opus` |**X** |**X** |`Vorbis Comments` |
| Ogg | `ogg`, `oga` |**X** |**X** |`Vorbis Comments` |
| WAV | `wav`, `wave` |**X** |**X** |`ID3v2`, `RIFF INFO` |
## Documentation
@ -27,9 +27,8 @@ Available [here](https://docs.rs/lofty)
## Thanks
All these great projects helped make this crate possible. (*Sorted alphabetically*)
These great projects helped make this crate possible.
* [**ape**](https://github.com/rossnomann/rust-ape)
* [**id3**](https://github.com/polyfloyd/rust-id3)
* [**mp4ameta**](https://github.com/Saecki/rust-mp4ameta)

View file

@ -0,0 +1,2 @@
pub const INVALID_KEYS: [&str; 4] = ["ID3", "TAG", "OGGS", "MP+"];
pub const APE_PREAMBLE: &[u8; 8] = b"APETAGEX";

View file

@ -0,0 +1,27 @@
mod constants;
mod properties;
pub(crate) mod read;
mod tag;
pub(crate) mod write;
use crate::FileProperties;
use std::collections::HashMap;
use unicase::UniCase;
#[allow(dead_code)]
pub(crate) struct ApeData {
pub id3v1: Option<[u8; 128]>, // TODO
pub id3v2: Option<Vec<u8>>,
pub ape: Option<HashMap<UniCase<String>, ItemType>>,
pub properties: FileProperties,
}
#[derive(Clone)]
pub(crate) enum ItemType {
// The bool indicates if the value is read only
String(String, bool),
Locator(String, bool), // TODO: figure out some way to expose
Binary(Vec<u8>, bool),
}

View file

@ -0,0 +1,161 @@
use crate::{FileProperties, LoftyError, Result};
use std::convert::TryInto;
use std::io::{Read, Seek, SeekFrom};
use std::time::Duration;
use byteorder::{LittleEndian, ReadBytesExt};
pub fn properties_gt_3980<R>(data: &mut R, stream_len: u64) -> Result<FileProperties>
where
R: Read + Seek,
{
// First read the file descriptor
let mut descriptor = [0; 46];
data.read_exact(&mut descriptor)
.map_err(|_| LoftyError::Ape("Not enough data left in reader to finish file descriptor"))?;
// The only piece of information we need from the file descriptor
let descriptor_len = u32::from_le_bytes(
descriptor[2..6]
.try_into()
.map_err(|_| LoftyError::Ape("Unreachable error"))?,
);
// The descriptor should be 52 bytes long (including ['M', 'A', 'C', ' ']
// Anything extra is unknown, and just gets skipped
if descriptor_len > 52 {
data.seek(SeekFrom::Current(i64::from(descriptor_len - 52)))?;
}
// Move on to the header
let mut header = [0; 24];
data.read_exact(&mut header)
.map_err(|_| LoftyError::Ape("Not enough data left in reader to finish MAC header"))?;
// Skip the first 4 bytes of the header
// Compression type (2)
// Format flags (2)
let header_read = &mut &header[4..];
let blocks_per_frame = header_read.read_u32::<LittleEndian>()?;
let final_frame_blocks = header_read.read_u32::<LittleEndian>()?;
let total_frames = header_read.read_u32::<LittleEndian>()?;
if total_frames == 0 {
return Err(LoftyError::Ape("File contains no frames"));
}
// Unused
let _bits_per_sample = header_read.read_u16::<LittleEndian>()?;
let channels = header_read.read_u16::<LittleEndian>()?;
if !(1..=32).contains(&channels) {
return Err(LoftyError::Ape(
"File has an invalid channel count (must be between 1 and 32 inclusive)",
));
}
let sample_rate = header_read.read_u32::<LittleEndian>()?;
let (duration, bitrate) = get_duration_bitrate(
total_frames,
final_frame_blocks,
blocks_per_frame,
sample_rate,
stream_len,
);
Ok(FileProperties::new(
duration,
Some(bitrate),
Some(sample_rate),
Some(channels as u8),
))
}
pub fn properties_lt_3980<R>(data: &mut R, version: u16, stream_len: u64) -> Result<FileProperties>
where
R: Read + Seek,
{
// Versions < 3980 don't have a descriptor
let mut header = [0; 26];
data.read_exact(&mut header)
.map_err(|_| LoftyError::Ape("Not enough data left in reader to finish MAC header"))?;
// We don't need all the header data, so just make 2 slices
let header_first = &mut &header[..8];
// Skipping 8 bytes
// WAV header length (4)
// WAV tail length (4)
let header_second = &mut &header[18..];
let compression_level = header_first.read_u16::<LittleEndian>()?;
// Unused
let _format_flags = header_first.read_u16::<LittleEndian>()?;
let blocks_per_frame = match version {
_ if version >= 3950 => 73728 * 4,
_ if version >= 3900 || (version >= 3800 && compression_level >= 4000) => 73728,
_ => 9216,
};
let channels = header_first.read_u16::<LittleEndian>()?;
if !(1..=32).contains(&channels) {
return Err(LoftyError::Ape(
"File has an invalid channel count (must be between 1 and 32 inclusive)",
));
}
let sample_rate = header_first.read_u32::<LittleEndian>()?;
// Move on the second part of header
let total_frames = header_second.read_u32::<LittleEndian>()?;
if total_frames == 0 {
return Err(LoftyError::Ape("File contains no frames"));
}
let final_frame_blocks = data.read_u32::<LittleEndian>()?;
let (duration, bitrate) = get_duration_bitrate(
total_frames,
final_frame_blocks,
blocks_per_frame,
sample_rate,
stream_len,
);
Ok(FileProperties::new(
duration,
Some(bitrate),
Some(sample_rate),
Some(channels as u8),
))
}
fn get_duration_bitrate(
total_frames: u32,
final_frame_blocks: u32,
blocks_per_frame: u32,
sample_rate: u32,
stream_len: u64,
) -> (Duration, u32) {
let mut total_samples = u64::from(final_frame_blocks);
if total_samples > 1 {
total_samples += u64::from(blocks_per_frame) * u64::from(total_frames - 1)
}
if sample_rate > 0 {
let length = (total_samples * 1000) / u64::from(sample_rate);
let bitrate = ((stream_len * 8) / length) as u32;
(Duration::from_millis(length), bitrate)
} else {
(Duration::ZERO, 0)
}
}

View file

@ -0,0 +1,137 @@
use super::constants::APE_PREAMBLE;
use super::properties::{properties_gt_3980, properties_lt_3980};
use super::tag::read_ape_tag;
use super::ApeData;
use crate::components::logic::id3::{find_id3v1, find_id3v2, find_lyrics3v2};
use crate::{FileProperties, LoftyError, Result};
use std::io::{Read, Seek, SeekFrom};
use byteorder::{LittleEndian, ReadBytesExt};
fn read_properties<R>(data: &mut R, stream_len: u64) -> Result<FileProperties>
where
R: Read + Seek,
{
let version = data
.read_u16::<LittleEndian>()
.map_err(|_| LoftyError::Ape("Unable to read version"))?;
// Property reading differs between versions
if version >= 3980 {
properties_gt_3980(data, stream_len)
} else {
properties_lt_3980(data, version, stream_len)
}
}
pub(crate) fn read_from<R>(data: &mut R) -> Result<ApeData>
where
R: Read + Seek,
{
let start = data.seek(SeekFrom::Current(0))?;
let end = data.seek(SeekFrom::End(0))?;
data.seek(SeekFrom::Start(start))?;
let mut stream_len = end - start;
let mut ape_data = ApeData {
id3v1: None,
id3v2: None,
ape: None,
properties: FileProperties::default(),
};
let mut found_mac = false;
let mut mac_start = 0;
// ID3v2 tags are unsupported in APE files, but still possible
if let Some(id3v2) = find_id3v2(data, true)? {
stream_len -= id3v2.len() as u64;
ape_data.id3v2 = Some(id3v2)
}
let mut header = [0; 4];
data.read_exact(&mut header)?;
while !found_mac {
match &header {
b"MAC " => {
mac_start = data.seek(SeekFrom::Current(0))?;
found_mac = true;
},
// An APE tag at the beginning of the file goes against the spec, but is still possible.
// This only allows for v2 tags though, since it relies on the header.
b"APET" => {
// Get the remaining part of the ape tag
let mut remaining = [0; 4];
data.read_exact(&mut remaining).map_err(|_| {
LoftyError::Ape(
"Found partial APE tag, but there isn't enough data left in the reader",
)
})?;
if &remaining[..4] != b"AGEX" {
return Err(LoftyError::Ape("Found incomplete APE tag"));
}
let (ape_tag, size) = read_ape_tag(data, false)?;
stream_len -= u64::from(size);
ape_data.ape = Some(ape_tag)
},
_ => {
return Err(LoftyError::Ape(
"Invalid data found while reading header, expected any of [\"MAC \", \"ID3 \
\", \"APETAGEX\"]",
))
},
}
}
// This function does 2 things.
//
// First see if there's a ID3v1 tag
//
// Starts with ['T', 'A', 'G']
// Exactly 128 bytes long (including the identifier)
let (found_id3v1, id3v1) = find_id3v1(data, true)?;
if found_id3v1 {
stream_len -= 128;
ape_data.id3v1 = id3v1;
}
// Next, check for a Lyrics3v2 tag, and skip over it, as it's no use to us
let (found_lyrics3v1, lyrics3v2_size) = find_lyrics3v2(data)?;
if found_lyrics3v1 {
stream_len -= u64::from(lyrics3v2_size)
}
// 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
data.seek(SeekFrom::Current(-32))?;
let mut ape_preamble = [0; 8];
data.read_exact(&mut ape_preamble)?;
if &ape_preamble == APE_PREAMBLE {
let (ape_tag, size) = read_ape_tag(data, true)?;
stream_len -= u64::from(size);
ape_data.ape = Some(ape_tag)
}
// Go back to the MAC header to read properties
data.seek(SeekFrom::Start(mac_start))?;
ape_data.properties = read_properties(data, stream_len)?;
Ok(ape_data)
}

View file

@ -0,0 +1,103 @@
use super::constants::INVALID_KEYS;
use super::ItemType;
use crate::{LoftyError, Result};
use std::collections::HashMap;
use std::io::{Read, Seek, SeekFrom};
use std::ops::Neg;
use byteorder::{LittleEndian, ReadBytesExt};
use unicase::UniCase;
pub(crate) fn read_ape_tag<R>(
data: &mut R,
footer: bool,
) -> Result<(HashMap<UniCase<String>, ItemType>, u32)>
where
R: Read + Seek,
{
let version = data.read_u32::<LittleEndian>()?;
let mut size = data.read_u32::<LittleEndian>()?;
if size < 32 {
// If the size is < 32, something went wrong during encoding
// The size includes the footer and all items
return Err(LoftyError::Ape("Tag has an invalid size (< 32)"));
}
let item_count = data.read_u32::<LittleEndian>()?;
if footer {
// No point in reading the rest of the footer, just seek back to the end of the header
data.seek(SeekFrom::Current(i64::from(size - 12).neg()))?;
} else {
// There are 12 bytes remaining in the header
// Flags (4)
// Reserved (8)
data.seek(SeekFrom::Current(12))?;
}
let mut items = HashMap::<UniCase<String>, ItemType>::new();
for _ in 0..item_count {
let value_size = data.read_u32::<LittleEndian>()?;
if value_size == 0 {
return Err(LoftyError::Ape("Tag item value has an invalid size (0)"));
}
let flags = data.read_u32::<LittleEndian>()?;
let mut key = Vec::new();
let mut key_char = data.read_u8()?;
while key_char != 0 {
key.push(key_char);
key_char = data.read_u8()?;
}
let key = String::from_utf8(key)
.map_err(|_| LoftyError::Ape("Tag item contains a non UTF-8 key"))?;
if INVALID_KEYS.contains(&&*key.to_uppercase()) {
return Err(LoftyError::Ape("Tag item contains an illegal key"));
}
if key.chars().any(|c| !c.is_ascii()) {
return Err(LoftyError::Ape("Tag item contains a non ASCII key"));
}
let read_only = (flags & 1) == 1;
let item_type = (flags & 6) >> 1;
let mut value = vec![0; value_size as usize];
data.read_exact(&mut value)?;
let parsed_value = match item_type {
0 => ItemType::String(
String::from_utf8(value).map_err(|_| {
LoftyError::Ape("Expected a string value based on flags, found binary data")
})?,
read_only,
),
1 => ItemType::Binary(value, read_only),
2 => ItemType::Locator(
String::from_utf8(value).map_err(|_| {
LoftyError::Ape("Failed to convert locator item into a UTF-8 string")
})?,
read_only,
),
_ => return Err(LoftyError::Ape("Tag item contains an invalid item type")),
};
items.insert(UniCase::new(key), parsed_value);
}
// Version 1 doesn't include a header
if version == 2000 {
size += 32
}
Ok((items, size))
}

View file

@ -0,0 +1,215 @@
use super::tag::read_ape_tag;
use super::ItemType;
use crate::components::logic::ape::constants::APE_PREAMBLE;
use crate::components::logic::id3::{find_id3v1, find_id3v2, find_lyrics3v2};
use crate::{LoftyError, Result};
use std::collections::HashMap;
use std::fs::File;
use std::io::{Cursor, Read, Seek, SeekFrom, Write};
use byteorder::{LittleEndian, WriteBytesExt};
use unicase::UniCase;
pub(crate) fn write_to(
data: &mut File,
metadata: &HashMap<UniCase<String>, ItemType>,
) -> Result<()> {
// We don't actually need the ID3v2 tag, but reading it will seek to the end of it if it exists
find_id3v2(data, false)?;
let mut ape_preamble = [0; 8];
data.read_exact(&mut ape_preamble)?;
// We have to check the APE tag for any read only items first
let mut read_only_metadata = HashMap::<UniCase<String>, ItemType>::new();
// An APE tag in the beginning of a file is against the spec
// If one is found, it'll be removed and rewritten at the bottom, where it should be
let mut header_ape_tag = (false, (0, 0));
if &ape_preamble == APE_PREAMBLE {
let start = data.seek(SeekFrom::Current(-8))?;
data.seek(SeekFrom::Current(8))?;
let (mut existing_metadata, size) = read_ape_tag(data, false)?;
// Only keep metadata around that's marked read only
retain_read_only(&mut existing_metadata);
read_only_metadata = existing_metadata;
header_ape_tag = (true, (start, start + u64::from(size)))
} else {
data.seek(SeekFrom::Current(-8))?;
}
// Skip over ID3v1 and Lyrics3v2 tags
find_id3v1(data, false)?;
find_lyrics3v2(data)?;
// In case there's no ape tag already, this is the spot it belongs
let ape_position = data.seek(SeekFrom::Current(0))?;
// Now search for an APE tag at the end
data.seek(SeekFrom::Current(-32))?;
data.read_exact(&mut ape_preamble)?;
let mut ape_tag_location = None;
// Also check this tag for any read only items
if &ape_preamble == APE_PREAMBLE {
let start = data.seek(SeekFrom::Current(0))? as usize + 24;
let (mut existing_metadata, size) = read_ape_tag(data, true)?;
retain_read_only(&mut existing_metadata);
read_only_metadata = existing_metadata;
// Since the "start" was really at the end of the tag, this sanity check seems necessary
if let Some(start) = start.checked_sub(size as usize) {
ape_tag_location = Some(start..start + size as usize);
} else {
return Err(LoftyError::Ape("File has a tag with an invalid size"));
}
}
// Preserve any metadata marked as read only
// If there is any read only metadata, we will have to clone the HashMap
let tag = if read_only_metadata.is_empty() {
create_ape_tag(metadata)?
} else {
let mut metadata = metadata.clone();
for (k, v) in read_only_metadata {
metadata.insert(k, v);
}
create_ape_tag(&metadata)?
};
data.seek(SeekFrom::Start(0))?;
let mut file_bytes = Vec::new();
data.read_to_end(&mut file_bytes)?;
// Write the tag in the appropriate place
if let Some(range) = ape_tag_location {
file_bytes.splice(range, tag);
} else {
file_bytes.splice(ape_position as usize..ape_position as usize, tag);
}
// Now, if there was a tag at the beginning, remove it
if header_ape_tag.0 {
file_bytes.drain(header_ape_tag.1 .0 as usize..header_ape_tag.1 .1 as usize);
}
data.seek(SeekFrom::Start(0))?;
data.set_len(0)?;
data.write_all(&*file_bytes)?;
Ok(())
}
fn create_ape_tag(metadata: &HashMap<UniCase<String>, ItemType>) -> Result<Vec<u8>> {
// Unnecessary to write anything if there's no metadata
if metadata.is_empty() {
Ok(Vec::<u8>::new())
} else {
let mut tag = Cursor::new(Vec::<u8>::new());
let item_count = metadata.len() as u32;
for (k, v) in metadata {
let (size, flags, value) = match v {
ItemType::Binary(value, ro) => {
let mut flags = 1_u32 << 1;
if *ro {
flags |= 1_u32
}
(value.len() as u32, flags, value.as_slice())
},
ItemType::String(value, ro) => {
let value = value.as_bytes();
let mut flags = 0_u32;
if *ro {
flags |= 1_u32
}
(value.len() as u32, flags, value)
},
ItemType::Locator(value, ro) => {
let mut flags = 2_u32 << 1;
if *ro {
flags |= 1_u32
}
(value.len() as u32, flags, value.as_bytes())
},
};
tag.write_u32::<LittleEndian>(size)?;
tag.write_u32::<LittleEndian>(flags)?;
tag.write_all(k.as_bytes())?;
tag.write_u8(0)?;
tag.write_all(value)?;
}
let size = tag.get_ref().len();
if size as u64 + 32 > u64::from(u32::MAX) {
return Err(LoftyError::TooMuchData);
}
let mut footer = [0_u8; 32];
let mut footer = Cursor::new(&mut footer[..]);
footer.write_all(APE_PREAMBLE)?;
// This is the APE tag version
// Even if we read a v1 tag, we end up adding a header anyway
footer.write_u32::<LittleEndian>(2000)?;
// The total size includes the 32 bytes of the footer
footer.write_u32::<LittleEndian>((size + 32) as u32)?;
footer.write_u32::<LittleEndian>(item_count)?;
// Bit 29 unset: this is the footer
// Bit 30 set: tag contains a footer
// Bit 31 set: tag contains a header
footer.write_u32::<LittleEndian>((1_u32 << 30) | (1_u32 << 31))?;
// The header/footer must end in 8 bytes of zeros
footer.write_u64::<LittleEndian>(0)?;
tag.write_all(footer.get_ref())?;
let mut tag = tag.into_inner();
// The header is exactly the same as the footer, except for the flags
// Just reuse the footer and overwrite the flags
footer.seek(SeekFrom::Current(-12))?;
// Bit 29 set: this is the header
// Bit 30 set: tag contains a footer
// Bit 31 set: tag contains a header
footer.write_u32::<LittleEndian>((1_u32 << 29) | (1_u32 << 30) | (1_u32 << 31))?;
let header = footer.into_inner();
tag.splice(0..0, header.to_vec());
Ok(tag)
}
}
fn retain_read_only(existing_metadata: &mut HashMap<UniCase<String>, ItemType>) {
existing_metadata.retain(|_, ty| *{
match ty {
ItemType::String(_, ro) | ItemType::Binary(_, ro) | ItemType::Locator(_, ro) => ro,
}
});
}

View file

@ -0,0 +1,99 @@
use crate::{LoftyError, Result};
use std::io::{Read, Seek, SeekFrom};
use std::ops::Neg;
use byteorder::{BigEndian, ByteOrder};
// https://github.com/polyfloyd/rust-id3/blob/e142ec656bf70a8153f6e5b34a37f26df144c3c1/src/stream/unsynch.rs#L18-L20
pub(crate) fn decode_u32(n: u32) -> u32 {
n & 0xFF | (n & 0xFF00) >> 1 | (n & 0xFF_0000) >> 2 | (n & 0xFF00_0000) >> 3
}
pub(crate) fn find_id3v2<R>(data: &mut R, read: bool) -> Result<Option<Vec<u8>>>
where
R: Read + Seek,
{
let mut id3v2 = None;
let mut id3_header = [0; 10];
data.read_exact(&mut id3_header)?;
data.seek(SeekFrom::Current(-10))?;
if &id3_header[..4] == b"ID3 " {
let size = decode_u32(BigEndian::read_u32(&id3_header[6..]));
if read {
let mut tag = vec![0; size as usize];
data.read_exact(&mut tag)?;
id3v2 = Some(tag)
}
data.seek(SeekFrom::Current(i64::from(size)))?;
}
Ok(id3v2)
}
pub(crate) fn find_id3v1<R>(data: &mut R, read: bool) -> Result<(bool, Option<[u8; 128]>)>
where
R: Read + Seek,
{
let mut id3v1 = None;
let mut exists = false;
data.seek(SeekFrom::End(-128))?;
let mut id3v1_header = [0; 3];
data.read_exact(&mut id3v1_header)?;
data.seek(SeekFrom::Current(-3))?;
if &id3v1_header == b"TAG" {
exists = true;
if read {
let mut id3v1_tag = [0; 128];
data.read_exact(&mut id3v1_tag)?;
data.seek(SeekFrom::End(-128))?;
id3v1 = Some(id3v1_tag)
}
} else {
// No ID3v1 tag found
data.seek(SeekFrom::End(0))?;
}
Ok((exists, id3v1))
}
pub(crate) fn find_lyrics3v2<R>(data: &mut R) -> Result<(bool, u32)>
where
R: Read + Seek,
{
let mut exists = false;
let mut size = 0_u32;
data.seek(SeekFrom::Current(-15))?;
let mut lyrics3v2 = [0; 15];
data.read_exact(&mut lyrics3v2)?;
if &lyrics3v2[7..] == b"LYRICS200" {
exists = true;
let lyrics_size = String::from_utf8(lyrics3v2[..7].to_vec())?;
let lyrics_size = lyrics_size
.parse::<u32>()
.map_err(|_| LoftyError::Ape("Lyrics3v2 tag has an invalid size string"))?;
size += lyrics_size;
data.seek(SeekFrom::Current(i64::from(lyrics_size + 15).neg()))?;
}
Ok((exists, size))
}

View file

@ -14,3 +14,9 @@ pub(crate) mod iff;
#[cfg(feature = "format-id3")] // TODO: new feature?
pub(crate) mod mpeg;
#[cfg(feature = "format-ape")]
pub(crate) mod ape;
#[cfg(any(feature = "format-id3", feature = "format-ape"))]
pub(crate) mod id3;

View file

@ -1,4 +1,5 @@
use super::header::{Header, XingHeader};
use crate::components::logic::id3::decode_u32;
use crate::components::logic::mpeg::MpegData;
use crate::{FileProperties, LoftyError, Result};
@ -8,11 +9,6 @@ use std::time::Duration;
use byteorder::{BigEndian, ByteOrder, ReadBytesExt};
// https://github.com/polyfloyd/rust-id3/blob/e142ec656bf70a8153f6e5b34a37f26df144c3c1/src/stream/unsynch.rs#L18-L20
pub(crate) fn decode_u32(n: u32) -> u32 {
n & 0xFF | (n & 0xFF00) >> 1 | (n & 0xFF_0000) >> 2 | (n & 0xFF00_0000) >> 3
}
fn read_properties(
first_frame: (Header, u64),
last_frame: (Header, u64),

View file

@ -1,3 +1,4 @@
use crate::components::logic::ape::{self, ItemType};
use crate::types::picture::{PicType, APE_PICTYPES};
use crate::{
Album, AnyTag, AudioTag, AudioTagEdit, AudioTagWrite, FileProperties, Picture, Result, TagType,
@ -5,12 +6,17 @@ use crate::{
};
use std::borrow::Cow;
use std::collections::HashMap;
use std::fs::File;
use std::io::{Read, Seek};
use ape::Item;
pub use ape::Tag as ApeInnerTag;
use lofty_attr::{get_set_methods, LoftyTag};
use unicase::UniCase;
#[derive(Default)]
struct ApeInnerTag {
data: HashMap<UniCase<String>, ItemType>,
}
#[derive(LoftyTag)]
/// Represents an APEv2 tag
@ -27,35 +33,40 @@ impl ApeTag {
where
R: Read + Seek,
{
let data = ape::read::read_from(reader)?;
Ok(Self {
inner: ape::read_from(reader).unwrap_or_else(|_| ape::Tag::new()),
properties: FileProperties::default(), // TODO
inner: ApeInnerTag {
data: data.ape.unwrap_or_default(),
},
properties: data.properties,
_format: TagType::Ape,
})
}
#[allow(missing_docs, clippy::missing_errors_doc)]
pub fn remove_from(file: &mut File) -> Result<()> {
ape::remove_from(file)?;
ape::write::write_to(file, &HashMap::<UniCase<String>, ItemType>::new())?;
Ok(())
}
}
impl ApeTag {
fn get_value(&self, key: &str) -> Option<&str> {
if let Some(item) = self.inner.item(key) {
if let ape::ItemValue::Text(val) = &item.value {
return Some(&*val);
}
if let Some(ItemType::String(val, _)) = self.inner.data.get(&UniCase::new(key.to_string()))
{
return Some(val.as_str());
}
None
}
#[allow(clippy::unused_self)]
fn get_picture(&self, item: &Item) -> Option<Picture> {
if let ape::ItemValue::Binary(bin) = &item.value {
if let Ok(pic) = Picture::from_ape_bytes(&item.key, bin) {
fn get_picture(&self, key: &str) -> Option<Picture> {
if let Some(ItemType::Binary(picture_data, _)) =
self.inner.data.get(&UniCase::new(key.to_string()))
{
if let Ok(pic) = Picture::from_ape_bytes(key, &*picture_data) {
return Some(pic);
}
}
@ -67,14 +78,25 @@ impl ApeTag {
where
V: Into<String>,
{
self.inner.set_item(ape::Item {
key: key.to_string(),
value: ape::ItemValue::Text(val.into()),
})
if let Some(ItemType::String(_, read_only)) =
self.inner.data.get(&UniCase::new(key.to_string()))
{
if !read_only {
self.inner.data.insert(
UniCase::new(key.to_string()),
ItemType::String(val.into(), false),
);
}
} else {
self.inner.data.insert(
UniCase::new(key.to_string()),
ItemType::String(val.into(), false),
);
}
}
fn remove_key(&mut self, key: &str) {
self.inner.remove_item(key);
self.inner.data.remove(&UniCase::new(key.to_string()));
}
}
@ -131,36 +153,30 @@ impl AudioTagEdit for ApeTag {
}
fn front_cover(&self) -> Option<Picture> {
if let Some(val) = self.inner.item("Cover Art (Front)") {
return self.get_picture(val);
}
None
self.get_picture("Cover Art (Front)")
}
fn set_front_cover(&mut self, cover: Picture) {
self.remove_front_cover();
if let Ok(item) = ape::Item::from_binary("Cover Art (Front)", cover.as_ape_bytes()) {
self.inner.set_item(item)
}
self.inner.data.insert(
UniCase::new("Cover Art (Front)".to_string()),
ItemType::Binary(cover.as_ape_bytes(), false),
);
}
fn remove_front_cover(&mut self) {
self.remove_key("Cover Art (Front)")
}
fn back_cover(&self) -> Option<Picture> {
if let Some(val) = self.inner.item("Cover Art (Back)") {
return self.get_picture(val);
}
None
self.get_picture("Cover Art (Back)")
}
fn set_back_cover(&mut self, cover: Picture) {
self.remove_back_cover();
if let Ok(item) = ape::Item::from_binary("Cover Art (Back)", cover.as_ape_bytes()) {
self.inner.set_item(item)
}
self.inner.data.insert(
UniCase::new("Cover Art (Back)".to_string()),
ItemType::Binary(cover.as_ape_bytes(), false),
);
}
fn remove_back_cover(&mut self) {
self.remove_key("Cover Art (Back)")
@ -170,18 +186,12 @@ impl AudioTagEdit for ApeTag {
let mut pics = Vec::new();
for pic_type in &APE_PICTYPES {
if let Some(item) = self.inner.item(pic_type) {
if let Some(pic) = self.get_picture(item) {
pics.push(pic)
}
if let Some(pic) = self.get_picture(*pic_type) {
pics.push(pic)
}
}
if pics.is_empty() {
None
} else {
Some(Cow::from(pics))
}
(!pics.is_empty()).then(|| Cow::from(pics))
}
fn set_pictures(&mut self, pictures: Vec<Picture>) {
self.remove_pictures();
@ -189,14 +199,15 @@ impl AudioTagEdit for ApeTag {
for p in pictures {
let key = p.pic_type.as_ape_key();
if let Ok(item) = ape::Item::from_binary(key, p.as_ape_bytes()) {
self.inner.set_item(item)
}
self.inner.data.insert(
UniCase::new(key.to_string()),
ItemType::Binary(p.as_ape_bytes(), false),
);
}
}
fn remove_pictures(&mut self) {
for key in &APE_PICTYPES {
self.inner.remove_item(key);
self.inner.data.remove(&UniCase::new((*key).to_string()));
}
}
@ -307,11 +318,7 @@ impl AudioTagEdit for ApeTag {
impl AudioTagWrite for ApeTag {
fn write_to(&self, file: &mut File) -> Result<()> {
ape::write_to(&self.inner, file)?;
Ok(())
}
fn write_to_path(&self, path: &str) -> Result<()> {
ape::write_to_path(&self.inner, path)?;
ape::write::write_to(file, &self.inner.data)?;
Ok(())
}
}

View file

@ -29,10 +29,6 @@ pub enum LoftyError {
NotAPicture,
// Tag related errors
#[cfg(feature = "format-ape")]
/// Any error from [`ape`]
#[error(transparent)]
ApeTag(#[from] ape::Error),
#[cfg(feature = "format-id3")]
/// Any error from [`id3`]
#[error(transparent)]
@ -81,6 +77,10 @@ pub enum LoftyError {
/// Errors that arise while reading/writing to MPEG files
#[error("MPEG: {0}")]
Mpeg(&'static str),
#[cfg(feature = "format-ape")]
/// Errors that arise while reading/writing to APE files
#[error("APE: {0}")]
Ape(&'static str),
// Conversions for std Errors
/// Unable to convert bytes to a String

View file

@ -249,7 +249,7 @@ impl TagType {
R: Read + Seek,
{
#[cfg(feature = "format-id3")]
use crate::components::logic::mpeg::{header::verify_frame_sync, read::decode_u32};
use crate::components::logic::{id3::decode_u32, mpeg::header::verify_frame_sync};
if data.seek(SeekFrom::End(0))? == 0 {
return Err(LoftyError::EmptyFile);

View file

@ -35,6 +35,9 @@ const MP3_PROPERTIES: FileProperties =
const MP4_PROPERTIES: FileProperties =
FileProperties::new(Duration::from_millis(1450), Some(129), Some(48000), Some(2));
const APE_PROPERTIES: FileProperties =
FileProperties::new(Duration::from_millis(1428), Some(360), Some(48000), Some(2));
macro_rules! properties_test {
($function:ident, $path:expr, $expected:ident) => {
#[test]
@ -62,3 +65,5 @@ properties_test!(test_wav_info, "tests/assets/a.wav", RIFF_PROPERTIES);
properties_test!(test_mp3, "tests/assets/a.mp3", MP3_PROPERTIES);
properties_test!(test_mp4, "tests/assets/a.m4a", MP4_PROPERTIES);
properties_test!(test_ape, "tests/assets/a.ape", APE_PROPERTIES);