ID3v2: Support writing ID3v2.3 tags

closes #62
This commit is contained in:
Serial 2024-06-30 21:26:41 -04:00 committed by Alex
parent ae94af1a88
commit a6b56c620d
22 changed files with 817 additions and 172 deletions

View file

@ -8,7 +8,7 @@ use crate::id3::v1::tag::Id3v1Tag;
use crate::id3::v2::read::parse_id3v2;
use crate::id3::v2::tag::Id3v2Tag;
use crate::id3::{find_id3v1, find_id3v2, find_lyrics3v2, FindId3v2Config, ID3FindResults};
use crate::macros::{decode_err, err};
use crate::macros::decode_err;
use std::io::{Read, Seek, SeekFrom};
@ -82,7 +82,7 @@ where
})?;
if &remaining[..4] != b"AGEX" {
err!(FakeTag)
decode_err!(@BAIL Ape, "Found incomplete APE tag");
}
let ape_header = read_ape_header(data, false)?;

View file

@ -9,6 +9,7 @@ pub struct WriteOptions {
pub(crate) remove_others: bool,
pub(crate) respect_read_only: bool,
pub(crate) uppercase_id3v2_chunk: bool,
pub(crate) use_id3v23: bool,
}
impl WriteOptions {
@ -32,6 +33,7 @@ impl WriteOptions {
remove_others: false,
respect_read_only: true,
uppercase_id3v2_chunk: true,
use_id3v23: false,
}
}
@ -148,6 +150,33 @@ impl WriteOptions {
self.uppercase_id3v2_chunk = uppercase_id3v2_chunk;
self
}
/// Whether or not to use ID3v2.3 when saving [`TagType::Id3v2`](crate::tag::TagType::Id3v2)
/// or [`Id3v2Tag`](crate::id3::v2::Id3v2Tag)
///
/// By default, Lofty will save ID3v2.4 tags. This option allows you to save ID3v2.3 tags instead.
///
/// # Examples
///
/// ```rust,no_run
/// use lofty::config::WriteOptions;
/// use lofty::prelude::*;
/// use lofty::tag::{Tag, TagType};
///
/// # fn main() -> lofty::error::Result<()> {
/// let mut id3v2_tag = Tag::new(TagType::Id3v2);
///
/// // ...
///
/// // I need to save ID3v2.3 tags to support older software
/// let options = WriteOptions::new().use_id3v23(true);
/// id3v2_tag.save_to_path("test.mp3", options)?;
/// # Ok(()) }
/// ```
pub fn use_id3v23(&mut self, use_id3v23: bool) -> Self {
self.use_id3v23 = use_id3v23;
*self
}
}
impl Default for WriteOptions {
@ -161,6 +190,7 @@ impl Default for WriteOptions {
/// remove_others: false,
/// respect_read_only: true,
/// uppercase_id3v2_chunk: true,
/// use_id3v23: false,
/// }
/// ```
fn default() -> Self {

View file

@ -36,13 +36,13 @@ pub(super) fn parse_content<R: Read>(
"OWNE" => OwnershipFrame::parse(reader, flags)?.map(Frame::Ownership),
"ETCO" => EventTimingCodesFrame::parse(reader, flags)?.map(Frame::EventTimingCodes),
"PRIV" => PrivateFrame::parse(reader, flags)?.map(Frame::Private),
"TDEN" | "TDOR" | "TDRC" | "TDRL" | "TDTG" => TimestampFrame::parse(reader, id, flags, parse_mode)?.map(Frame::Timestamp),
i if i.starts_with('T') => TextInformationFrame::parse(reader, id, flags, version)?.map(Frame::Text),
// Apple proprietary frames
// WFED (Podcast URL), GRP1 (Grouping), MVNM (Movement Name), MVIN (Movement Number)
"WFED" | "GRP1" | "MVNM" | "MVIN" => TextInformationFrame::parse(reader, id, flags, version)?.map(Frame::Text),
i if i.starts_with('W') => UrlLinkFrame::parse(reader, id, flags)?.map(Frame::Url),
"POPM" => Some(Frame::Popularimeter(PopularimeterFrame::parse(reader, flags)?)),
"TDEN" | "TDOR" | "TDRC" | "TDRL" | "TDTG" => TimestampFrame::parse(reader, id, flags, parse_mode)?.map(Frame::Timestamp),
// SYLT, GEOB, and any unknown frames
_ => {
Some(Frame::Binary(BinaryFrame::parse(reader, id, flags)?))

View file

@ -29,7 +29,7 @@ impl<'a> FrameHeader<'a> {
}
/// Get the ID of the frame
pub const fn id(&self) -> &FrameId<'a> {
pub const fn id(&'a self) -> &'a FrameId<'a> {
&self.id
}
}

View file

@ -5,6 +5,7 @@ use crate::id3::v2::util::upgrade::{upgrade_v2, upgrade_v3};
use crate::id3::v2::FrameId;
use crate::util::text::utf8_decode_str;
use crate::config::ParseOptions;
use std::borrow::Cow;
use std::io::Read;
@ -44,6 +45,7 @@ pub(crate) fn parse_header<R>(
reader: &mut R,
size: &mut u32,
synchsafe: bool,
parse_options: ParseOptions,
) -> Result<Option<(FrameId<'static>, FrameFlags)>>
where
R: Read,
@ -87,7 +89,7 @@ where
} else {
Cow::Owned(id_str.to_owned())
}
} else if !synchsafe {
} else if !synchsafe && parse_options.implicit_conversions {
upgrade_v3(id_str).map_or_else(|| Cow::Owned(id_str.to_owned()), Cow::Borrowed)
} else {
Cow::Owned(id_str.to_owned())
@ -95,37 +97,11 @@ where
let frame_id = FrameId::new_cow(id)?;
let flags = u16::from_be_bytes([header[8], header[9]]);
let flags = parse_flags(flags, synchsafe);
let flags = if synchsafe {
FrameFlags::parse_id3v24(flags)
} else {
FrameFlags::parse_id3v23(flags)
};
Ok(Some((frame_id, flags)))
}
pub(crate) fn parse_flags(flags: u16, v4: bool) -> FrameFlags {
FrameFlags {
tag_alter_preservation: if v4 {
flags & 0x4000 == 0x4000
} else {
flags & 0x8000 == 0x8000
},
file_alter_preservation: if v4 {
flags & 0x2000 == 0x2000
} else {
flags & 0x4000 == 0x4000
},
read_only: if v4 {
flags & 0x1000 == 0x1000
} else {
flags & 0x2000 == 0x2000
},
grouping_identity: ((v4 && flags & 0x0040 == 0x0040) || (flags & 0x0020 == 0x0020))
.then_some(0),
compression: if v4 {
flags & 0x0008 == 0x0008
} else {
flags & 0x0080 == 0x0080
},
encryption: ((v4 && flags & 0x0004 == 0x0004) || flags & 0x0040 == 0x0040).then_some(0),
unsynchronisation: if v4 { flags & 0x0002 == 0x0002 } else { false },
data_length_indicator: (v4 && flags & 0x0001 == 0x0001).then_some(0),
}
}

View file

@ -195,23 +195,31 @@ impl<'a> Frame<'a> {
}
impl<'a> Frame<'a> {
pub(super) fn as_bytes(&self) -> Result<Vec<u8>> {
pub(super) fn as_bytes(&self, is_id3v23: bool) -> Result<Vec<u8>> {
Ok(match self {
Frame::Comment(comment) => comment.as_bytes()?,
Frame::UnsynchronizedText(lf) => lf.as_bytes()?,
Frame::Text(tif) => tif.as_bytes(),
Frame::UserText(content) => content.as_bytes(),
Frame::UserUrl(content) => content.as_bytes(),
Frame::Comment(comment) => comment.as_bytes(is_id3v23)?,
Frame::UnsynchronizedText(lf) => lf.as_bytes(is_id3v23)?,
Frame::Text(tif) => tif.as_bytes(is_id3v23),
Frame::UserText(content) => content.as_bytes(is_id3v23),
Frame::UserUrl(content) => content.as_bytes(is_id3v23),
Frame::Url(link) => link.as_bytes(),
Frame::Picture(attached_picture) => attached_picture.as_bytes(Id3v2Version::V4)?,
Frame::Picture(attached_picture) => {
let version = if is_id3v23 {
Id3v2Version::V3
} else {
Id3v2Version::V4
};
attached_picture.as_bytes(version)?
},
Frame::Popularimeter(popularimeter) => popularimeter.as_bytes(),
Frame::KeyValue(content) => content.as_bytes(),
Frame::KeyValue(content) => content.as_bytes(is_id3v23),
Frame::RelativeVolumeAdjustment(frame) => frame.as_bytes(),
Frame::UniqueFileIdentifier(frame) => frame.as_bytes(),
Frame::Ownership(frame) => frame.as_bytes()?,
Frame::Ownership(frame) => frame.as_bytes(is_id3v23)?,
Frame::EventTimingCodes(frame) => frame.as_bytes(),
Frame::Private(frame) => frame.as_bytes(),
Frame::Timestamp(frame) => frame.as_bytes()?,
Frame::Timestamp(frame) => frame.as_bytes(is_id3v23)?,
Frame::Binary(frame) => frame.as_bytes(),
})
}
@ -281,6 +289,123 @@ pub struct FrameFlags {
pub data_length_indicator: Option<u32>,
}
impl FrameFlags {
/// Parse the flags from an ID3v2.4 frame
///
/// NOTE: If any of the following flags are set, they will be set to `Some(0)`:
/// * `grouping_identity`
/// * `encryption`
/// * `data_length_indicator`
pub fn parse_id3v24(flags: u16) -> Self {
FrameFlags {
tag_alter_preservation: flags & 0x4000 == 0x4000,
file_alter_preservation: flags & 0x2000 == 0x2000,
read_only: flags & 0x1000 == 0x1000,
grouping_identity: (flags & 0x0040 == 0x0040).then_some(0),
compression: flags & 0x0008 == 0x0008,
encryption: (flags & 0x0004 == 0x0004).then_some(0),
unsynchronisation: flags & 0x0002 == 0x0002,
data_length_indicator: (flags & 0x0001 == 0x0001).then_some(0),
}
}
/// Parse the flags from an ID3v2.3 frame
///
/// NOTE: If any of the following flags are set, they will be set to `Some(0)`:
/// * `grouping_identity`
/// * `encryption`
pub fn parse_id3v23(flags: u16) -> Self {
FrameFlags {
tag_alter_preservation: flags & 0x8000 == 0x8000,
file_alter_preservation: flags & 0x4000 == 0x4000,
read_only: flags & 0x2000 == 0x2000,
grouping_identity: (flags & 0x0020 == 0x0020).then_some(0),
compression: flags & 0x0080 == 0x0080,
encryption: (flags & 0x0040 == 0x0040).then_some(0),
unsynchronisation: false,
data_length_indicator: None,
}
}
/// Get the ID3v2.4 byte representation of the flags
pub fn as_id3v24_bytes(&self) -> u16 {
let mut flags = 0;
if *self == FrameFlags::default() {
return flags;
}
if self.tag_alter_preservation {
flags |= 0x4000
}
if self.file_alter_preservation {
flags |= 0x2000
}
if self.read_only {
flags |= 0x1000
}
if self.grouping_identity.is_some() {
flags |= 0x0040
}
if self.compression {
flags |= 0x0008
}
if self.encryption.is_some() {
flags |= 0x0004
}
if self.unsynchronisation {
flags |= 0x0002
}
if self.data_length_indicator.is_some() {
flags |= 0x0001
}
flags
}
/// Get the ID3v2.3 byte representation of the flags
pub fn as_id3v23_bytes(&self) -> u16 {
let mut flags = 0;
if *self == FrameFlags::default() {
return flags;
}
if self.tag_alter_preservation {
flags |= 0x8000
}
if self.file_alter_preservation {
flags |= 0x4000
}
if self.read_only {
flags |= 0x2000
}
if self.grouping_identity.is_some() {
flags |= 0x0020
}
if self.compression {
flags |= 0x0080
}
if self.encryption.is_some() {
flags |= 0x0040
}
flags
}
}
#[derive(Clone)]
pub(crate) struct FrameRef<'a>(pub(crate) Cow<'a, Frame<'a>>);

View file

@ -29,13 +29,12 @@ impl<'a> ParsedFrame<'a> {
R: Read,
{
let mut size = 0u32;
let parse_mode = parse_options.parsing_mode;
// The header will be upgraded to ID3v2.4 past this point, so they can all be treated the same
let parse_header_result = match version {
Id3v2Version::V2 => parse_v2_header(reader, &mut size),
Id3v2Version::V3 => parse_header(reader, &mut size, false),
Id3v2Version::V4 => parse_header(reader, &mut size, true),
Id3v2Version::V3 => parse_header(reader, &mut size, false, parse_options),
Id3v2Version::V4 => parse_header(reader, &mut size, true, parse_options),
};
let (id, mut flags) = match parse_header_result {
Ok(None) => {
@ -44,7 +43,7 @@ impl<'a> ParsedFrame<'a> {
},
Ok(Some(some)) => some,
Err(err) => {
match parse_mode {
match parse_options.parsing_mode {
ParsingMode::Strict => return Err(err),
ParsingMode::BestAttempt | ParsingMode::Relaxed => {
// Skip this frame and continue reading
@ -60,7 +59,7 @@ impl<'a> ParsedFrame<'a> {
}
if size == 0 {
if parse_mode == ParsingMode::Strict {
if parse_options.parsing_mode == ParsingMode::Strict {
return Err(Id3v2Error::new(Id3v2ErrorKind::EmptyFrame(id)).into());
}
@ -146,7 +145,7 @@ impl<'a> ParsedFrame<'a> {
id,
flags,
version,
parse_mode,
parse_options.parsing_mode,
);
}
@ -160,7 +159,7 @@ impl<'a> ParsedFrame<'a> {
id,
flags,
version,
parse_mode,
parse_options.parsing_mode,
);
},
// Possible combinations:
@ -182,7 +181,7 @@ impl<'a> ParsedFrame<'a> {
id,
flags,
version,
parse_mode,
parse_options.parsing_mode,
);
},
// Possible combinations:
@ -196,7 +195,14 @@ impl<'a> ParsedFrame<'a> {
},
// Everything else that doesn't have special flags
_ => {
return parse_frame(&mut reader, size, id, flags, version, parse_mode);
return parse_frame(
&mut reader,
size,
id,
flags,
version,
parse_options.parsing_mode,
);
},
}
}

View file

@ -40,6 +40,46 @@ pub struct Id3v2TagFlags {
pub restrictions: Option<TagRestrictions>,
}
impl Id3v2TagFlags {
/// Get the **ID3v2.4** byte representation of the flags
///
/// NOTE: This does not include the extended header flags
pub fn as_id3v24_byte(&self) -> u8 {
let mut byte = 0;
if self.unsynchronisation {
byte |= 0x80;
}
if self.experimental {
byte |= 0x20;
}
if self.footer {
byte |= 0x10;
}
byte
}
/// Get the **ID3v2.3** byte representation of the flags
///
/// NOTE: This does not include the extended header flags
pub fn as_id3v23_byte(&self) -> u8 {
let mut byte = 0;
if self.experimental {
byte |= 0x40;
}
if self.footer {
byte |= 0x10;
}
byte
}
}
#[derive(Copy, Clone, Debug)]
pub(crate) struct Id3v2Header {
pub version: Id3v2Version,

View file

@ -135,7 +135,12 @@ impl<'a> AttachedPictureFrame<'a> {
///
/// * The mimetype is not [`MimeType::Png`] or [`MimeType::Jpeg`]
pub fn as_bytes(&self, version: Id3v2Version) -> Result<Vec<u8>> {
let mut data = vec![self.encoding as u8];
let mut encoding = self.encoding;
if version != Id3v2Version::V4 {
encoding = encoding.to_id3v23();
}
let mut data = vec![encoding as u8];
let max_size = match version {
// ID3v2.2 uses a 24-bit number for sizes
@ -168,7 +173,7 @@ impl<'a> AttachedPictureFrame<'a> {
data.write_u8(self.picture.pic_type.as_u8())?;
match &self.picture.description {
Some(description) => data.write_all(&encode_text(description, self.encoding, true))?,
Some(description) => data.write_all(&encode_text(description, encoding, true))?,
None => data.write_u8(0)?,
}

View file

@ -165,11 +165,16 @@ impl<'a> ExtendedTextFrame<'a> {
}
/// Convert an [`ExtendedTextFrame`] to a byte vec
pub fn as_bytes(&self) -> Vec<u8> {
let mut bytes = vec![self.encoding as u8];
pub fn as_bytes(&self, is_id3v23: bool) -> Vec<u8> {
let mut encoding = self.encoding;
if is_id3v23 {
encoding = encoding.to_id3v23();
}
bytes.extend(encode_text(&self.description, self.encoding, true).iter());
bytes.extend(encode_text(&self.content, self.encoding, false));
let mut bytes = vec![encoding as u8];
bytes.extend(encode_text(&self.description, encoding, true).iter());
bytes.extend(encode_text(&self.content, encoding, false));
bytes
}

View file

@ -113,11 +113,16 @@ impl<'a> ExtendedUrlFrame<'a> {
}
/// Convert an [`ExtendedUrlFrame`] to a byte vec
pub fn as_bytes(&self) -> Vec<u8> {
let mut bytes = vec![self.encoding as u8];
pub fn as_bytes(&self, is_id3v23: bool) -> Vec<u8> {
let mut encoding = self.encoding;
if is_id3v23 {
encoding = encoding.to_id3v23();
}
bytes.extend(encode_text(&self.description, self.encoding, true).iter());
bytes.extend(encode_text(&self.content, self.encoding, false));
let mut bytes = vec![encoding as u8];
bytes.extend(encode_text(&self.description, encoding, true).iter());
bytes.extend(encode_text(&self.content, encoding, false));
bytes
}

View file

@ -114,12 +114,17 @@ impl<'a> KeyValueFrame<'a> {
}
/// Convert a [`KeyValueFrame`] to a byte vec
pub fn as_bytes(&self) -> Vec<u8> {
let mut content = vec![self.encoding as u8];
pub fn as_bytes(&self, is_id3v23: bool) -> Vec<u8> {
let mut encoding = self.encoding;
if is_id3v23 {
encoding = encoding.to_id3v23();
}
let mut content = vec![encoding as u8];
for (key, value) in &self.key_value_pairs {
content.append(&mut encode_text(key, self.encoding, true));
content.append(&mut encode_text(value, self.encoding, true));
content.append(&mut encode_text(key, encoding, true));
content.append(&mut encode_text(value, encoding, true));
}
content
}

View file

@ -51,11 +51,16 @@ impl LanguageFrame {
}
fn create_bytes(
encoding: TextEncoding,
mut encoding: TextEncoding,
language: [u8; 3],
description: &str,
content: &str,
is_id3v23: bool,
) -> Result<Vec<u8>> {
if is_id3v23 {
encoding = encoding.to_id3v23();
}
let mut bytes = vec![encoding as u8];
if language.len() != 3 || language.iter().any(|c| !c.is_ascii_alphabetic()) {
@ -175,12 +180,13 @@ impl<'a> CommentFrame<'a> {
///
/// * `language` is not exactly 3 bytes
/// * `language` contains invalid characters (Only `'a'..='z'` and `'A'..='Z'` allowed)
pub fn as_bytes(&self) -> Result<Vec<u8>> {
pub fn as_bytes(&self, is_id3v23: bool) -> Result<Vec<u8>> {
LanguageFrame::create_bytes(
self.encoding,
self.language,
&self.description,
&self.content,
is_id3v23,
)
}
}
@ -290,12 +296,13 @@ impl<'a> UnsynchronizedTextFrame<'a> {
///
/// * `language` is not exactly 3 bytes
/// * `language` contains invalid characters (Only `'a'..='z'` and `'A'..='Z'` allowed)
pub fn as_bytes(&self) -> Result<Vec<u8>> {
pub fn as_bytes(&self, is_id3v23: bool) -> Result<Vec<u8>> {
LanguageFrame::create_bytes(
self.encoding,
self.language,
&self.description,
&self.content,
is_id3v23,
)
}
}

View file

@ -116,8 +116,13 @@ impl<'a> OwnershipFrame<'a> {
/// # Errors
///
/// * `date_of_purchase` is not at least 8 characters (it will be truncated if greater)
pub fn as_bytes(&self) -> Result<Vec<u8>> {
let mut bytes = vec![self.encoding as u8];
pub fn as_bytes(&self, is_id3v23: bool) -> Result<Vec<u8>> {
let mut encoding = self.encoding;
if is_id3v23 {
encoding = encoding.to_id3v23();
}
let mut bytes = vec![encoding as u8];
bytes.extend(encode_text(&self.price_paid, TextEncoding::Latin1, true));
if self.date_of_purchase.len() < 8 {
@ -125,7 +130,7 @@ impl<'a> OwnershipFrame<'a> {
}
bytes.extend(self.date_of_purchase.as_bytes().iter().take(8));
bytes.extend(encode_text(&self.seller, self.encoding, false));
bytes.extend(encode_text(&self.seller, encoding, false));
Ok(bytes)
}
@ -161,7 +166,7 @@ mod tests {
#[test]
fn owne_encode() {
let encoded = expected().as_bytes().unwrap();
let encoded = expected().as_bytes(false).unwrap();
let expected_bytes =
crate::tag::utils::test_utils::read_path("tests/tags/assets/id3v2/test.owne");

View file

@ -93,10 +93,15 @@ impl<'a> TextInformationFrame<'a> {
}
/// Convert an [`TextInformationFrame`] to a byte vec
pub fn as_bytes(&self) -> Vec<u8> {
let mut content = encode_text(&self.value, self.encoding, false);
pub fn as_bytes(&self, is_id3v23: bool) -> Vec<u8> {
let mut encoding = self.encoding;
if is_id3v23 {
encoding = encoding.to_id3v23();
}
content.insert(0, self.encoding as u8);
let mut content = encode_text(&self.value, encoding, false);
content.insert(0, encoding as u8);
content
}
}

View file

@ -106,11 +106,16 @@ impl<'a> TimestampFrame<'a> {
///
/// * The timestamp is invalid
/// * Failure to write to the buffer
pub fn as_bytes(&self) -> Result<Vec<u8>> {
pub fn as_bytes(&self, is_id3v23: bool) -> Result<Vec<u8>> {
let mut encoding = self.encoding;
if is_id3v23 {
encoding = encoding.to_id3v23();
}
self.timestamp.verify()?;
let mut encoded_text = encode_text(&self.timestamp.to_string(), self.encoding, false);
encoded_text.insert(0, self.encoding as u8);
let mut encoded_text = encode_text(&self.timestamp.to_string(), encoding, false);
encoded_text.insert(0, encoding as u8);
Ok(encoded_text)
}

View file

@ -4,7 +4,10 @@ use super::tag::Id3v2Tag;
use crate::config::ParseOptions;
use crate::error::{Id3v2Error, Id3v2ErrorKind, Result};
use crate::id3::v2::util::synchsafe::UnsynchronizedStream;
use crate::id3::v2::{Frame, FrameId, Id3v2Version, TimestampFrame};
use crate::tag::items::Timestamp;
use std::borrow::Cow;
use std::io::Read;
pub(crate) fn parse_id3v2<R>(
@ -23,7 +26,7 @@ where
let mut tag_bytes = bytes.take(u64::from(header.size - header.extended_size));
let ret;
let mut ret;
if header.flags.unsynchronisation {
// Unsynchronize the entire tag
let mut unsynchronized_reader = UnsynchronizedStream::new(tag_bytes);
@ -37,9 +40,96 @@ where
// Throw away the rest of the tag (padding, bad frames)
std::io::copy(&mut tag_bytes, &mut std::io::sink())?;
// Construct TDRC frame from TYER, TDAT, and TIME frames
if parse_options.implicit_conversions && header.version == Id3v2Version::V3 {
construct_tdrc_from_v3(&mut ret);
}
Ok(ret)
}
fn construct_tdrc_from_v3(tag: &mut Id3v2Tag) {
const TDRC: FrameId<'_> = FrameId::Valid(Cow::Borrowed("TDRC"));
const TDAT: FrameId<'_> = FrameId::Valid(Cow::Borrowed("TDAT"));
const TIME: FrameId<'_> = FrameId::Valid(Cow::Borrowed("TIME"));
// Our TYER frame gets converted to TDRC earlier
let Some(year_frame) = tag.remove(&TDRC).next() else {
return;
};
let Frame::Timestamp(year_frame) = year_frame else {
log::warn!("TYER frame is not a timestamp frame, retaining.");
tag.insert(year_frame);
return;
};
// This is not a TYER frame
if year_frame.timestamp.month.is_some() {
return;
}
let date = tag.get_text(&TDAT);
let mut date_used = false;
let time = tag.get_text(&TIME);
let mut time_used = false;
let mut tdrc = Timestamp {
year: year_frame.timestamp.year,
..Timestamp::default()
};
'build: {
if let Some(date) = date {
if date.len() != 4 {
log::warn!("Invalid TDAT frame, retaining.");
break 'build;
}
let (Ok(month), Ok(day)) = (date[..2].parse::<u8>(), date[2..].parse::<u8>()) else {
log::warn!("Invalid TDAT frame, retaining.");
break 'build;
};
tdrc.month = Some(month);
tdrc.day = Some(day);
date_used = true;
if let Some(time) = time {
if time.len() != 4 {
log::warn!("Invalid TIME frame, retaining.");
break 'build;
}
let (Ok(hour), Ok(minute)) = (time[..2].parse::<u8>(), time[2..].parse::<u8>())
else {
log::warn!("Invalid TIME frame, retaining.");
break 'build;
};
tdrc.hour = Some(hour);
tdrc.minute = Some(minute);
time_used = true;
}
}
}
tag.insert(Frame::Timestamp(TimestampFrame::new(
FrameId::Valid(Cow::Borrowed("TDRC")),
year_frame.encoding,
tdrc,
)));
if date_used {
let _ = tag.remove(&TDAT);
}
if time_used {
let _ = tag.remove(&TIME);
}
}
fn skip_frame(reader: &mut impl Read, size: u32) -> Result<()> {
log::trace!("Skipping frame of size {}", size);

View file

@ -20,9 +20,7 @@ use crate::mp4::AdvisoryRating;
use crate::picture::{Picture, PictureType, TOMBSTONE_PICTURE};
use crate::tag::companion_tag::CompanionTag;
use crate::tag::items::{Lang, Timestamp, UNKNOWN_LANGUAGE};
use crate::tag::{
try_parse_year, Accessor, ItemKey, ItemValue, MergeTag, SplitTag, Tag, TagExt, TagItem, TagType,
};
use crate::tag::{Accessor, ItemKey, ItemValue, MergeTag, SplitTag, Tag, TagExt, TagItem, TagType};
use crate::util::flag_item;
use crate::util::io::{FileLike, Length, Truncate};
use crate::util::text::{decode_text, TextDecodeOptions, TextEncoding};
@ -569,7 +567,7 @@ impl Id3v2Tag {
}
}
struct GenresIter<'a> {
pub(crate) struct GenresIter<'a> {
value: &'a str,
pos: usize,
}
@ -817,9 +815,10 @@ impl Accessor for Id3v2Tag {
}
fn year(&self) -> Option<u32> {
if let Some(Frame::Text(TextInformationFrame { value, .. })) = self.get(&RECORDING_TIME_ID)
if let Some(Frame::Timestamp(TimestampFrame { timestamp, .. })) =
self.get(&RECORDING_TIME_ID)
{
return try_parse_year(value);
return Some(u32::from(timestamp.year));
}
None

View file

@ -17,12 +17,10 @@ const COMMENT_FRAME_ID: &str = "COMM";
fn read_tag(path: &str) -> Id3v2Tag {
let tag_bytes = read_path(path);
read_tag_raw(&tag_bytes)
}
fn read_tag_raw(bytes: &[u8]) -> Id3v2Tag {
let options = ParseOptions::new().parsing_mode(ParsingMode::Strict);
read_tag_with_options(bytes, options)
read_tag_with_options(
&tag_bytes,
ParseOptions::new().parsing_mode(ParsingMode::Strict),
)
}
fn read_tag_with_options(bytes: &[u8], parse_options: ParseOptions) -> Id3v2Tag {
@ -32,6 +30,17 @@ fn read_tag_with_options(bytes: &[u8], parse_options: ParseOptions) -> Id3v2Tag
crate::id3::v2::read::parse_id3v2(&mut reader, header, parse_options).unwrap()
}
fn dump_and_re_read(tag: &Id3v2Tag, write_options: WriteOptions) -> Id3v2Tag {
let mut tag_bytes = Vec::new();
let mut writer = Cursor::new(&mut tag_bytes);
tag.dump_to(&mut writer, write_options).unwrap();
read_tag_with_options(
&tag_bytes[..],
ParseOptions::new().parsing_mode(ParsingMode::Strict),
)
}
#[test]
fn parse_id3v2() {
let mut expected_tag = Id3v2Tag::default();
@ -63,10 +72,13 @@ fn parse_id3v2() {
String::from("Qux comment"),
)));
expected_tag.insert(Frame::Text(TextInformationFrame::new(
expected_tag.insert(Frame::Timestamp(TimestampFrame::new(
FrameId::Valid(Cow::Borrowed("TDRC")),
encoding,
String::from("1984"),
Timestamp {
year: 1984,
..Timestamp::default()
},
)));
expected_tag.insert(Frame::Text(TextInformationFrame::new(
@ -210,10 +222,13 @@ fn create_full_test_tag(version: Id3v2Version) -> Id3v2Tag {
String::from("Summer"),
)));
tag.insert(Frame::Text(TextInformationFrame::new(
tag.insert(Frame::Timestamp(TimestampFrame::new(
FrameId::Valid(Cow::Borrowed("TDRC")),
encoding,
String::from("2017"),
Timestamp {
year: 2017,
..Timestamp::default()
},
)));
tag.insert(Frame::Text(TextInformationFrame::new(
@ -251,9 +266,14 @@ fn id3v24_full() {
#[test]
fn id3v23_full() {
let tag = create_full_test_tag(Id3v2Version::V3);
let parsed_tag = read_tag("tests/tags/assets/id3v2/test_full.id3v23");
let mut tag = create_full_test_tag(Id3v2Version::V3);
let mut parsed_tag = read_tag("tests/tags/assets/id3v2/test_full.id3v23");
// Tags may change order after being read, due to the TDRC conversion
tag.frames.sort_by_key(|frame| frame.id_str().to_string());
parsed_tag
.frames
.sort_by_key(|frame| frame.id_str().to_string());
assert_eq!(tag, parsed_tag);
}
@ -1261,7 +1281,10 @@ fn special_items_roundtrip() {
tag.dump_to(&mut tag_bytes, WriteOptions::default())
.unwrap();
let mut tag_re_read = read_tag_raw(&tag_bytes[..]);
let mut tag_re_read = read_tag_with_options(
&tag_bytes[..],
ParseOptions::new().parsing_mode(ParsingMode::Strict),
);
// Ensure ordered comparison
tag.frames.sort_by_key(|frame| frame.id().to_string());
@ -1277,7 +1300,10 @@ fn special_items_roundtrip() {
tag.dump_to(&mut tag_bytes, WriteOptions::default())
.unwrap();
let mut generic_tag_re_read = read_tag_raw(&tag_bytes[..]);
let mut generic_tag_re_read = read_tag_with_options(
&tag_bytes[..],
ParseOptions::new().parsing_mode(ParsingMode::Strict),
);
generic_tag_re_read
.frames
@ -1350,3 +1376,161 @@ fn skip_reading_cover_art() {
assert_eq!(id3v2.len(), 1); // Artist, no picture
assert!(id3v2.artist().is_some());
}
#[test]
fn remove_id3v24_frames_on_id3v23_save() {
let mut tag = Id3v2Tag::new();
tag.insert(Frame::RelativeVolumeAdjustment(
RelativeVolumeAdjustmentFrame::new(
String::from("Foo RVA"),
HashMap::from([(
ChannelType::MasterVolume,
ChannelInformation {
channel_type: ChannelType::MasterVolume,
volume_adjustment: 30,
bits_representing_peak: 0,
peak_volume: None,
},
)]),
),
));
let tag_re_read = dump_and_re_read(&tag, WriteOptions::default().use_id3v23(true));
assert_eq!(tag_re_read.frames.len(), 0);
}
#[test]
fn change_text_encoding_on_id3v23_save() {
let mut tag = Id3v2Tag::new();
// UTF-16 BE is not supported in ID3v2.3
tag.insert(Frame::Text(TextInformationFrame::new(
FrameId::Valid(Cow::from("TFOO")),
TextEncoding::UTF16BE,
String::from("Foo"),
)));
let tag_re_read = dump_and_re_read(&tag, WriteOptions::default().use_id3v23(true));
let frame = tag_re_read
.get(&FrameId::Valid(Cow::Borrowed("TFOO")))
.unwrap();
match frame {
Frame::Text(frame) => {
assert_eq!(frame.encoding, TextEncoding::UTF16);
assert_eq!(frame.value, "Foo");
},
_ => panic!("Expected a TextInformationFrame"),
}
}
#[test]
fn split_tdor_on_id3v23_save() {
let mut tag = Id3v2Tag::new();
// ID3v2.3 ONLY supports the original release year.
// This will be written as a TORY frame. Lofty just automatically upgrades it to a TDOR
// when reading it back.
tag.insert(Frame::Timestamp(TimestampFrame::new(
FrameId::Valid(Cow::Borrowed("TDOR")),
TextEncoding::UTF8,
Timestamp {
year: 2024,
month: Some(6),
day: Some(3),
hour: Some(14),
minute: Some(8),
second: Some(49),
},
)));
let tag_re_read = dump_and_re_read(&tag, WriteOptions::default().use_id3v23(true));
let frame = tag_re_read
.get(&FrameId::Valid(Cow::Borrowed("TDOR")))
.unwrap();
match frame {
Frame::Timestamp(frame) => {
assert_eq!(frame.encoding, TextEncoding::UTF16);
assert_eq!(frame.timestamp.year, 2024);
assert_eq!(frame.timestamp.month, None);
assert_eq!(frame.timestamp.day, None);
assert_eq!(frame.timestamp.hour, None);
assert_eq!(frame.timestamp.minute, None);
assert_eq!(frame.timestamp.second, None);
},
_ => panic!("Expected a TimestampFrame"),
}
}
#[test]
fn split_tdrc_on_id3v23_save() {
let mut tag = Id3v2Tag::new();
// TDRC gets split into 3 frames in ID3v2.3:
//
// TYER: YYYY
// TDAT: DDMM
// TIME: HHMM
tag.insert(Frame::Timestamp(TimestampFrame::new(
FrameId::Valid(Cow::Borrowed("TDRC")),
TextEncoding::UTF8,
Timestamp {
year: 2024,
month: Some(6),
day: Some(3),
hour: Some(14),
minute: Some(8),
second: None, // Seconds are not supported in ID3v2.3 TIME
},
)));
let tag_re_read = dump_and_re_read(&tag, WriteOptions::default().use_id3v23(true));
// First, check the default behavior which should return the same TDRC frame
let frame = tag_re_read
.get(&FrameId::Valid(Cow::Borrowed("TDRC")))
.unwrap();
match frame {
Frame::Timestamp(frame) => {
assert_eq!(frame.encoding, TextEncoding::UTF16);
assert_eq!(frame.timestamp.year, 2024);
assert_eq!(frame.timestamp.month, Some(6));
assert_eq!(frame.timestamp.day, Some(3));
assert_eq!(frame.timestamp.hour, Some(14));
assert_eq!(frame.timestamp.minute, Some(8));
},
_ => panic!("Expected a TimestampFrame"),
}
// Now, re-read with implicit_conversions off, which retains the split frames
let mut bytes = Cursor::new(Vec::new());
tag_re_read
.dump_to(&mut bytes, WriteOptions::default().use_id3v23(true))
.unwrap();
let tag_re_read = read_tag_with_options(
&bytes.into_inner(),
ParseOptions::new()
.parsing_mode(ParsingMode::Strict)
.implicit_conversions(false),
);
let year = tag_re_read
.get_text(&FrameId::Valid(Cow::Borrowed("TYER")))
.expect("Expected TYER frame");
assert_eq!(year, "2024");
let date = tag_re_read
.get_text(&FrameId::Valid(Cow::Borrowed("TDAT")))
.expect("Expected TDAT frame");
assert_eq!(date, "0603");
let time = tag_re_read
.get_text(&FrameId::Valid(Cow::Borrowed("TIME")))
.expect("Expected TIME frame");
assert_eq!(time, "1408");
}

View file

@ -1,10 +1,12 @@
use crate::error::{Id3v2Error, Id3v2ErrorKind, Result};
use crate::id3::v2::frame::{FrameFlags, FrameRef};
use crate::id3::v2::tag::GenresIter;
use crate::id3::v2::util::synchsafe::SynchsafeInteger;
use crate::id3::v2::{Frame, FrameId, TextInformationFrame};
use crate::tag::items::Timestamp;
use std::io::Write;
use crate::id3::v2::Frame;
use byteorder::{BigEndian, WriteBytesExt};
pub(in crate::id3::v2) fn create_items<W>(
@ -14,11 +16,157 @@ pub(in crate::id3::v2) fn create_items<W>(
where
W: Write,
{
let is_id3v23 = false;
for frame in frames {
verify_frame(&frame)?;
let value = frame.as_bytes()?;
let value = frame.as_bytes(is_id3v23)?;
write_frame(writer, frame.id().as_str(), frame.flags(), &value)?;
write_frame(
writer,
frame.id().as_str(),
frame.flags(),
&value,
is_id3v23,
)?;
}
Ok(())
}
pub(in crate::id3::v2) fn create_items_v3<W>(
writer: &mut W,
frames: &mut dyn Iterator<Item = FrameRef<'_>>,
) -> Result<()>
where
W: Write,
{
// These are all frames from ID3v2.4
const FRAMES_TO_DISCARD: &[&str] = &[
"ASPI", "EQU2", "RVA2", "SEEK", "SIGN", "TDEN", "TDRL", "TDTG", "TIPL", "TMCL", "TMOO",
"TPRO", "TSOA", "TSOP", "TSOT", "TSST",
];
let is_id3v23 = true;
for mut frame in frames {
let id = frame.id_str();
if FRAMES_TO_DISCARD.contains(&id) {
log::warn!("Discarding frame: {}, not supported in ID3v2.3", id);
continue;
}
verify_frame(&frame)?;
match id {
// TORY (Original release year) is the only component of TDOR
// that is supported in ID3v2.3
//
// TDRC (Recording time) gets split into three frames: TYER, TDAT, and TIME
"TDOR" | "TDRC" => {
let mut value = frame.0.clone();
let Frame::Timestamp(ref mut f) = value.to_mut() else {
log::warn!("Discarding frame: {}, not supported in ID3v2.3", id);
continue;
};
if f.timestamp.verify().is_err() {
log::warn!("Discarding frame: {}, invalid timestamp", id);
continue;
}
if id == "TDOR" {
let year = f.timestamp.year;
f.timestamp = Timestamp {
year,
..Timestamp::default()
};
f.header.id = FrameId::Valid("TORY".into());
frame.0 = value;
} else {
let mut new_frames = Vec::with_capacity(3);
let timestamp = f.timestamp;
let year = timestamp.year;
new_frames.push(Frame::Text(TextInformationFrame::new(
FrameId::Valid("TYER".into()),
f.encoding.to_id3v23(),
year.to_string(),
)));
if let (Some(month), Some(day)) = (timestamp.month, timestamp.day) {
let date = format!("{:02}{:02}", month, day);
new_frames.push(Frame::Text(TextInformationFrame::new(
FrameId::Valid("TDAT".into()),
f.encoding.to_id3v23(),
date,
)));
}
if let (Some(hour), Some(minute)) = (timestamp.hour, timestamp.minute) {
let time = format!("{:02}{:02}", hour, minute);
new_frames.push(Frame::Text(TextInformationFrame::new(
FrameId::Valid("TIME".into()),
f.encoding.to_id3v23(),
time,
)));
}
for mut frame in new_frames {
frame.set_flags(f.header.flags);
let value = frame.as_bytes(is_id3v23)?;
write_frame(
writer,
frame.id().as_str(),
frame.flags(),
&value,
is_id3v23,
)?;
}
continue;
}
},
// TCON (Content type) cannot be separated by nulls, so we have to wrap its
// components in parentheses
"TCON" => {
let mut value = frame.0.clone();
let Frame::Text(ref mut f) = value.to_mut() else {
log::warn!("Discarding frame: {}, not supported in ID3v2.3", id);
continue;
};
let mut new_genre_string = String::new();
for genre in GenresIter::new(&f.value) {
match genre {
"Remix" => new_genre_string.push_str("(RX)"),
"Cover" => new_genre_string.push_str("(CR)"),
_ => {
new_genre_string.push_str(genre);
},
}
}
f.value = new_genre_string;
frame.0 = value;
},
_ => {},
}
let value = frame.as_bytes(is_id3v23)?;
write_frame(
writer,
frame.id().as_str(),
frame.flags(),
&value,
is_id3v23,
)?;
}
Ok(())
@ -49,12 +197,18 @@ fn verify_frame(frame: &FrameRef<'_>) -> Result<()> {
}
}
fn write_frame<W>(writer: &mut W, name: &str, flags: FrameFlags, value: &[u8]) -> Result<()>
fn write_frame<W>(
writer: &mut W,
name: &str,
flags: FrameFlags,
value: &[u8],
is_id3v23: bool,
) -> Result<()>
where
W: Write,
{
if flags.encryption.is_some() {
write_encrypted(writer, name, value, flags)?;
write_encrypted(writer, name, value, flags, is_id3v23)?;
return Ok(());
}
@ -66,6 +220,7 @@ where
name,
if is_grouping_identity { len + 1 } else { len },
flags,
is_id3v23,
)?;
if is_grouping_identity {
@ -78,7 +233,13 @@ where
Ok(())
}
fn write_encrypted<W>(writer: &mut W, name: &str, value: &[u8], flags: FrameFlags) -> Result<()>
fn write_encrypted<W>(
writer: &mut W,
name: &str,
value: &[u8],
flags: FrameFlags,
is_id3v23: bool,
) -> Result<()>
where
W: Write,
{
@ -93,7 +254,7 @@ where
if let Some(len) = flags.data_length_indicator {
if len > 0 {
write_frame_header(writer, name, (value.len() + 1) as u32, flags)?;
write_frame_header(writer, name, (value.len() + 1) as u32, flags, is_id3v23)?;
writer.write_u32::<BigEndian>(len.synch()?)?;
writer.write_u8(method_symbol)?;
writer.write_all(value)?;
@ -105,55 +266,25 @@ where
Err(Id3v2Error::new(Id3v2ErrorKind::MissingDataLengthIndicator).into())
}
fn write_frame_header<W>(writer: &mut W, name: &str, len: u32, flags: FrameFlags) -> Result<()>
fn write_frame_header<W>(
writer: &mut W,
name: &str,
len: u32,
flags: FrameFlags,
is_id3v23: bool,
) -> Result<()>
where
W: Write,
{
let flags = if is_id3v23 {
flags.as_id3v23_bytes()
} else {
flags.as_id3v24_bytes()
};
writer.write_all(name.as_bytes())?;
writer.write_u32::<BigEndian>(len.synch()?)?;
writer.write_u16::<BigEndian>(get_flags(flags))?;
writer.write_u16::<BigEndian>(flags)?;
Ok(())
}
fn get_flags(tag_flags: FrameFlags) -> u16 {
let mut flags = 0;
if tag_flags == FrameFlags::default() {
return flags;
}
if tag_flags.tag_alter_preservation {
flags |= 0x4000
}
if tag_flags.file_alter_preservation {
flags |= 0x2000
}
if tag_flags.read_only {
flags |= 0x1000
}
if tag_flags.grouping_identity.is_some() {
flags |= 0x0040
}
if tag_flags.compression {
flags |= 0x0008
}
if tag_flags.encryption.is_some() {
flags |= 0x0004
}
if tag_flags.unsynchronisation {
flags |= 0x0002
}
if tag_flags.data_length_indicator.is_some() {
flags |= 0x0001
}
flags
}

View file

@ -114,15 +114,24 @@ pub(super) fn create_tag<'a, I: Iterator<Item = FrameRef<'a>> + 'a>(
return Ok(Vec::new());
}
let is_id3v23 = write_options.use_id3v23;
if is_id3v23 {
log::debug!("Using ID3v2.3");
}
let has_footer = tag.flags.footer;
let needs_crc = tag.flags.crc;
let has_restrictions = tag.flags.restrictions.is_some();
let (mut id3v2, extended_header_len) = create_tag_header(tag.flags)?;
let (mut id3v2, extended_header_len) = create_tag_header(tag.flags, is_id3v23)?;
let header_len = id3v2.get_ref().len();
// Write the items
frame::create_items(&mut id3v2, &mut peek)?;
if is_id3v23 {
frame::create_items_v3(&mut id3v2, &mut peek)?;
} else {
frame::create_items(&mut id3v2, &mut peek)?;
}
let mut len = id3v2.get_ref().len() - header_len;
@ -190,29 +199,26 @@ pub(super) fn create_tag<'a, I: Iterator<Item = FrameRef<'a>> + 'a>(
Ok(id3v2.into_inner())
}
fn create_tag_header(flags: Id3v2TagFlags) -> Result<(Cursor<Vec<u8>>, u32)> {
fn create_tag_header(flags: Id3v2TagFlags, is_id3v23: bool) -> Result<(Cursor<Vec<u8>>, u32)> {
let mut header = Cursor::new(Vec::new());
header.write_all(&[b'I', b'D', b'3'])?;
let mut tag_flags = 0;
// Version 4, rev 0
header.write_all(&[4, 0])?;
if is_id3v23 {
// Version 3, rev 0
header.write_all(&[3, 0])?;
} else {
// Version 4, rev 0
header.write_all(&[4, 0])?;
}
let extended_header = flags.crc || flags.restrictions.is_some();
if flags.footer {
tag_flags |= 0x10
}
if flags.experimental {
tag_flags |= 0x20
}
if extended_header {
tag_flags |= 0x40
}
let tag_flags = if is_id3v23 {
flags.as_id3v23_byte()
} else {
flags.as_id3v24_byte()
};
header.write_u8(tag_flags)?;
header.write_u32::<BigEndian>(0)?;

View file

@ -34,6 +34,22 @@ impl TextEncoding {
pub(crate) fn verify_latin1(text: &str) -> bool {
text.chars().all(|c| c as u32 <= 255)
}
/// ID3v2.4 introduced two new text encodings.
///
/// When writing ID3v2.3, we just substitute with UTF-16.
pub(crate) fn to_id3v23(self) -> Self {
match self {
Self::UTF8 | Self::UTF16BE => {
log::warn!(
"Text encoding {:?} is not supported in ID3v2.3, substituting with UTF-16",
self
);
Self::UTF16
},
_ => self,
}
}
}
#[derive(Eq, PartialEq, Debug)]