mirror of
https://github.com/Serial-ATA/lofty-rs
synced 2024-11-10 06:34:18 +00:00
Add APE property reading, remove ape as a dependency
This commit is contained in:
parent
a739dcd28e
commit
b64091d649
15 changed files with 834 additions and 77 deletions
|
@ -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 = []
|
||||
|
|
23
README.md
23
README.md
|
@ -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)
|
||||
|
||||
|
|
2
src/components/logic/ape/constants.rs
Normal file
2
src/components/logic/ape/constants.rs
Normal file
|
@ -0,0 +1,2 @@
|
|||
pub const INVALID_KEYS: [&str; 4] = ["ID3", "TAG", "OGGS", "MP+"];
|
||||
pub const APE_PREAMBLE: &[u8; 8] = b"APETAGEX";
|
27
src/components/logic/ape/mod.rs
Normal file
27
src/components/logic/ape/mod.rs
Normal 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),
|
||||
}
|
161
src/components/logic/ape/properties.rs
Normal file
161
src/components/logic/ape/properties.rs
Normal 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)
|
||||
}
|
||||
}
|
137
src/components/logic/ape/read.rs
Normal file
137
src/components/logic/ape/read.rs
Normal 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)
|
||||
}
|
103
src/components/logic/ape/tag.rs
Normal file
103
src/components/logic/ape/tag.rs
Normal 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))
|
||||
}
|
215
src/components/logic/ape/write.rs
Normal file
215
src/components/logic/ape/write.rs
Normal 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,
|
||||
}
|
||||
});
|
||||
}
|
99
src/components/logic/id3/mod.rs
Normal file
99
src/components/logic/id3/mod.rs
Normal 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))
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Reference in a new issue