diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bbe99023..6f6a074fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## Added +- **ID3v2**: Support for "RVA2", "OWNE", "ETCO", and "PRIV" frames through + `id3::v2::{RelativeVolumeAdjustmentFrame, OwnershipFrame, EventTimingCodesFrame, PrivateFrame}` + +## Changed +- **ID3v2**: For spec compliance, `Id3v2Tag::insert` will now check for frames that are only meant to appear + in a tag once and remove them. Those frames are: "MCDI", "ETCO", "MLLT", "SYTC", "RVRB", "PCNT", "RBUF", "POSS", "OWNE", "SEEK", and "ASPI". + ## [0.15.0] - 2023-07-11 ## Added diff --git a/src/error.rs b/src/error.rs index abd768f08..e58e787a3 100644 --- a/src/error.rs +++ b/src/error.rs @@ -105,6 +105,10 @@ pub enum Id3v2ErrorKind { BadSyncText, /// Arises when decoding a [`UniqueFileIdentifierFrame`](crate::id3::v2::UniqueFileIdentifierFrame) with no owner MissingUfidOwner, + /// Arises when decoding a [`RelativeVolumeAdjustmentFrame`](crate::id3::v2::RelativeVolumeAdjustmentFrame) with an invalid channel type + BadRva2ChannelType, + /// Arises when decoding a [`TimestampFormat`](crate::id3::v2::TimestampFormat) with an invalid type + BadTimestampFormat, // Compression #[cfg(feature = "id3v2_compression_support")] @@ -159,6 +163,11 @@ impl Display for Id3v2ErrorKind { }, Self::BadSyncText => write!(f, "Encountered invalid data in SYLT frame"), Self::MissingUfidOwner => write!(f, "Missing owner in UFID frame"), + Self::BadRva2ChannelType => write!(f, "Encountered invalid channel type in RVA2 frame"), + Self::BadTimestampFormat => write!( + f, + "Encountered an invalid timestamp format in a synchronized frame" + ), // Compression #[cfg(feature = "id3v2_compression_support")] diff --git a/src/id3/v2/frame/content.rs b/src/id3/v2/frame/content.rs index e0dae12c1..9f8d5a634 100644 --- a/src/id3/v2/frame/content.rs +++ b/src/id3/v2/frame/content.rs @@ -1,9 +1,9 @@ use crate::error::{Id3v2Error, Id3v2ErrorKind, Result}; use crate::id3::v2::frame::FrameValue; use crate::id3::v2::items::{ - AttachedPictureFrame, CommentFrame, ExtendedTextFrame, ExtendedUrlFrame, KeyValueFrame, - Popularimeter, TextInformationFrame, UniqueFileIdentifierFrame, UnsynchronizedTextFrame, - UrlLinkFrame, + AttachedPictureFrame, CommentFrame, EventTimingCodesFrame, ExtendedTextFrame, ExtendedUrlFrame, + KeyValueFrame, OwnershipFrame, Popularimeter, PrivateFrame, RelativeVolumeAdjustmentFrame, + TextInformationFrame, UniqueFileIdentifierFrame, UnsynchronizedTextFrame, UrlLinkFrame, }; use crate::id3::v2::Id3v2Version; use crate::macros::err; @@ -31,6 +31,10 @@ pub(super) fn parse_content( "USLT" => UnsynchronizedTextFrame::parse(reader, version)?.map(FrameValue::UnsynchronizedText), "TIPL" | "TMCL" => KeyValueFrame::parse(reader, version)?.map(FrameValue::KeyValue), "UFID" => UniqueFileIdentifierFrame::parse(reader, parse_mode)?.map(FrameValue::UniqueFileIdentifier), + "RVA2" => RelativeVolumeAdjustmentFrame::parse(reader, parse_mode)?.map(FrameValue::RelativeVolumeAdjustment), + "OWNE" => OwnershipFrame::parse(reader)?.map(FrameValue::Ownership), + "ETCO" => EventTimingCodesFrame::parse(reader)?.map(FrameValue::EventTimingCodes), + "PRIV" => PrivateFrame::parse(reader)?.map(FrameValue::Private), _ if id.starts_with('T') => TextInformationFrame::parse(reader, version)?.map(FrameValue::Text), // Apple proprietary frames // WFED (Podcast URL), GRP1 (Grouping), MVNM (Movement Name), MVIN (Movement Number) diff --git a/src/id3/v2/frame/mod.rs b/src/id3/v2/frame/mod.rs index 3d5ba2102..b203157e1 100644 --- a/src/id3/v2/frame/mod.rs +++ b/src/id3/v2/frame/mod.rs @@ -4,9 +4,9 @@ pub(super) mod id; pub(super) mod read; use super::items::{ - AttachedPictureFrame, CommentFrame, ExtendedTextFrame, ExtendedUrlFrame, KeyValueFrame, - Popularimeter, TextInformationFrame, UniqueFileIdentifierFrame, UnsynchronizedTextFrame, - UrlLinkFrame, + AttachedPictureFrame, CommentFrame, EventTimingCodesFrame, ExtendedTextFrame, ExtendedUrlFrame, + KeyValueFrame, OwnershipFrame, Popularimeter, PrivateFrame, RelativeVolumeAdjustmentFrame, + TextInformationFrame, UniqueFileIdentifierFrame, UnsynchronizedTextFrame, UrlLinkFrame, }; use super::util::upgrade::{upgrade_v2, upgrade_v3}; use super::Id3v2Version; @@ -181,6 +181,16 @@ pub enum FrameValue { Popularimeter(Popularimeter), /// Represents an "IPLS" or "TPIL" frame KeyValue(KeyValueFrame), + /// Represents an "RVA2" frame + RelativeVolumeAdjustment(RelativeVolumeAdjustmentFrame), + /// Unique file identifier + UniqueFileIdentifier(UniqueFileIdentifierFrame), + /// Represents an "OWNE" frame + Ownership(OwnershipFrame), + /// Represents an "ETCO" frame + EventTimingCodes(EventTimingCodesFrame), + /// Represents a "PRIV" frame + Private(PrivateFrame), /// Binary data /// /// NOTES: @@ -190,8 +200,6 @@ pub enum FrameValue { /// * This is used for **all** frames with an ID of [`FrameId::Outdated`] /// * This is used for unknown frames Binary(Vec), - /// Unique file identifier - UniqueFileIdentifier(UniqueFileIdentifierFrame), } impl TryFrom for FrameValue { @@ -271,12 +279,36 @@ impl From for FrameValue { } } +impl From for FrameValue { + fn from(value: RelativeVolumeAdjustmentFrame) -> Self { + Self::RelativeVolumeAdjustment(value) + } +} + impl From for FrameValue { fn from(value: UniqueFileIdentifierFrame) -> Self { Self::UniqueFileIdentifier(value) } } +impl From for FrameValue { + fn from(value: OwnershipFrame) -> Self { + Self::Ownership(value) + } +} + +impl From for FrameValue { + fn from(value: EventTimingCodesFrame) -> Self { + Self::EventTimingCodes(value) + } +} + +impl From for FrameValue { + fn from(value: PrivateFrame) -> Self { + Self::Private(value) + } +} + impl FrameValue { pub(super) fn as_bytes(&self) -> Result> { Ok(match self { @@ -289,10 +321,35 @@ impl FrameValue { FrameValue::Picture(attached_picture) => attached_picture.as_bytes(Id3v2Version::V4)?, FrameValue::Popularimeter(popularimeter) => popularimeter.as_bytes(), FrameValue::KeyValue(content) => content.as_bytes(), - FrameValue::Binary(binary) => binary.clone(), + FrameValue::RelativeVolumeAdjustment(frame) => frame.as_bytes(), FrameValue::UniqueFileIdentifier(frame) => frame.as_bytes(), + FrameValue::Ownership(frame) => frame.as_bytes()?, + FrameValue::EventTimingCodes(frame) => frame.as_bytes(), + FrameValue::Private(frame) => frame.as_bytes(), + FrameValue::Binary(binary) => binary.clone(), }) } + + /// Used for errors in write::frame::verify_frame + pub(super) fn name(&self) -> &'static str { + match self { + FrameValue::Comment(_) => "Comment", + FrameValue::UnsynchronizedText(_) => "UnsynchronizedText", + FrameValue::Text { .. } => "Text", + FrameValue::UserText(_) => "UserText", + FrameValue::Url(_) => "Url", + FrameValue::UserUrl(_) => "UserUrl", + FrameValue::Picture { .. } => "Picture", + FrameValue::Popularimeter(_) => "Popularimeter", + FrameValue::KeyValue(_) => "KeyValue", + FrameValue::UniqueFileIdentifier(_) => "UniqueFileIdentifier", + FrameValue::RelativeVolumeAdjustment(_) => "RelativeVolumeAdjustment", + FrameValue::Ownership(_) => "Ownership", + FrameValue::EventTimingCodes(_) => "EventTimingCodes", + FrameValue::Private(_) => "Private", + FrameValue::Binary(_) => "Binary", + } + } } /// Various flags to describe the content of an item diff --git a/src/id3/v2/items/event_timing_codes_frame.rs b/src/id3/v2/items/event_timing_codes_frame.rs new file mode 100644 index 000000000..b451776ac --- /dev/null +++ b/src/id3/v2/items/event_timing_codes_frame.rs @@ -0,0 +1,293 @@ +use crate::error::{Id3v2Error, Id3v2ErrorKind, Result}; +use crate::id3::v2::TimestampFormat; + +use std::cmp::Ordering; +use std::hash::Hash; +use std::io::Read; + +use byteorder::{BigEndian, ReadBytesExt}; + +/// The type of events that can occur in an [`EventTimingCodesFrame`] +/// +/// This is used in [`Event`]. +/// +/// Note from [the spec](https://mutagen-specs.readthedocs.io/en/latest/id3/id3v2.4.0-frames.html#event-timing-codes): +/// +/// >>> Terminating the start events such as “intro start” is OPTIONAL. +/// The ‘Not predefined synch’s ($E0-EF) are for user events. +/// You might want to synchronise your music to something, +/// like setting off an explosion on-stage, activating a screensaver etc. +#[repr(u8)] +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Debug, Hash)] +#[allow(missing_docs)] +pub enum EventType { + Padding = 0x00, + EndOfInitialSilence = 0x01, + IntroStart = 0x02, + MainPartStart = 0x03, + OutroStart = 0x04, + OutroEnd = 0x05, + VerseStart = 0x06, + RefrainStart = 0x07, + InterludeStart = 0x08, + ThemeStart = 0x09, + VariationStart = 0x0A, + KeyChange = 0x0B, + TimeChange = 0x0C, + MomentaryUnwantedNoise = 0x0D, + SustainedNoise = 0x0E, + SustainedNoiseEnd = 0x0F, + IntroEnd = 0x10, + MainPartEnd = 0x11, + VerseEnd = 0x12, + RefrainEnd = 0x13, + ThemeEnd = 0x14, + Profanity = 0x15, + ProfanityEnd = 0x16, + + // User-defined events + NotPredefinedSynch0 = 0xE0, + NotPredefinedSynch1 = 0xE1, + NotPredefinedSynch2 = 0xE2, + NotPredefinedSynch3 = 0xE3, + NotPredefinedSynch4 = 0xE4, + NotPredefinedSynch5 = 0xE5, + NotPredefinedSynch6 = 0xE6, + NotPredefinedSynch7 = 0xE7, + NotPredefinedSynch8 = 0xE8, + NotPredefinedSynch9 = 0xE9, + NotPredefinedSynchA = 0xEA, + NotPredefinedSynchB = 0xEB, + NotPredefinedSynchC = 0xEC, + NotPredefinedSynchD = 0xED, + NotPredefinedSynchE = 0xEE, + NotPredefinedSynchF = 0xEF, + + AudioEnd = 0xFD, + AudioFileEnds = 0xFE, + + /// 0x17..=0xDF and 0xF0..=0xFC + Reserved, +} + +impl EventType { + /// Get a [`EventType`] from a `u8` + /// + /// NOTE: 0x17..=0xDF and 0xF0..=0xFC map to [`EventType::Reserved`] + /// + /// # Examples + /// + /// ```rust + /// use lofty::id3::v2::EventType; + /// + /// let valid_byte = 1; + /// assert_eq!( + /// EventType::from_u8(valid_byte), + /// EventType::EndOfInitialSilence + /// ); + /// + /// // This is in the undefined range + /// let invalid_byte = 0x17; + /// assert_eq!(EventType::from_u8(invalid_byte), EventType::Reserved); + /// ``` + pub fn from_u8(byte: u8) -> Self { + match byte { + 0x00 => Self::Padding, + 0x01 => Self::EndOfInitialSilence, + 0x02 => Self::IntroStart, + 0x03 => Self::MainPartStart, + 0x04 => Self::OutroStart, + 0x05 => Self::OutroEnd, + 0x06 => Self::VerseStart, + 0x07 => Self::RefrainStart, + 0x08 => Self::InterludeStart, + 0x09 => Self::ThemeStart, + 0x0A => Self::VariationStart, + 0x0B => Self::KeyChange, + 0x0C => Self::TimeChange, + 0x0D => Self::MomentaryUnwantedNoise, + 0x0E => Self::SustainedNoise, + 0x0F => Self::SustainedNoiseEnd, + 0x10 => Self::IntroEnd, + 0x11 => Self::MainPartEnd, + 0x12 => Self::VerseEnd, + 0x13 => Self::RefrainEnd, + 0x14 => Self::ThemeEnd, + 0x15 => Self::Profanity, + 0x16 => Self::ProfanityEnd, + + // User-defined events + 0xE0 => Self::NotPredefinedSynch0, + 0xE1 => Self::NotPredefinedSynch1, + 0xE2 => Self::NotPredefinedSynch2, + 0xE3 => Self::NotPredefinedSynch3, + 0xE4 => Self::NotPredefinedSynch4, + 0xE5 => Self::NotPredefinedSynch5, + 0xE6 => Self::NotPredefinedSynch6, + 0xE7 => Self::NotPredefinedSynch7, + 0xE8 => Self::NotPredefinedSynch8, + 0xE9 => Self::NotPredefinedSynch9, + 0xEA => Self::NotPredefinedSynchA, + 0xEB => Self::NotPredefinedSynchB, + 0xEC => Self::NotPredefinedSynchC, + 0xED => Self::NotPredefinedSynchD, + 0xEE => Self::NotPredefinedSynchE, + 0xEF => Self::NotPredefinedSynchF, + + 0xFD => Self::AudioEnd, + 0xFE => Self::AudioFileEnds, + + // 0x17..=0xDF and 0xF0..=0xFC + _ => Self::Reserved, + } + } +} + +/// An event for an [`EventTimingCodesFrame`] +/// +/// NOTE: The `Ord` implementation only looks at timestamps, as events must be sorted in chronological +/// order. +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub struct Event { + /// The event type + pub event_type: EventType, + /// The timestamp according to the [`TimestampFormat`] + pub timestamp: u32, +} + +impl PartialOrd for Event { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Event { + fn cmp(&self, other: &Self) -> Ordering { + self.timestamp.cmp(&other.timestamp) + } +} + +/// An `ID3v2` event timing codes frame +/// +/// This frame defines a list of different types of events and the timestamps at which they occur. +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub struct EventTimingCodesFrame { + /// The format of the timestamps + pub timestamp_format: TimestampFormat, + /// The events + /// + /// Events are guaranteed to be sorted by their timestamps when read. They can be inserted in + /// arbitrary order after the fact, and will be sorted again prior to writing. + pub events: Vec, +} + +impl EventTimingCodesFrame { + /// Read an [`EventTimingCodesFrame`] + /// + /// NOTE: This expects the frame header to have already been skipped + /// + /// # Errors + /// + /// * Invalid timestamp format + pub fn parse(reader: &mut R) -> Result> + where + R: Read, + { + let Ok(timestamp_format_byte) = reader.read_u8() else { + return Ok(None); + }; + + let timestamp_format = TimestampFormat::from_u8(timestamp_format_byte) + .ok_or_else(|| Id3v2Error::new(Id3v2ErrorKind::BadTimestampFormat))?; + + let mut events = Vec::new(); + while let Ok(event_type_byte) = reader.read_u8() { + let event_type = EventType::from_u8(event_type_byte); + let timestamp = reader.read_u32::()?; + + events.push(Event { + event_type, + timestamp, + }) + } + + // Order is important, can't use sort_unstable + events.sort(); + + Ok(Some(EventTimingCodesFrame { + timestamp_format, + events, + })) + } + + /// Convert an [`EventTimingCodesFrame`] to a byte vec + /// + /// NOTE: This will sort all events according to their timestamps + pub fn as_bytes(&self) -> Vec { + let mut content = vec![self.timestamp_format as u8]; + + let mut sorted_events = Vec::from_iter(self.events.iter()); + sorted_events.sort(); + + for event in sorted_events { + content.push(event.event_type as u8); + content.extend(event.timestamp.to_be_bytes()) + } + + content + } +} + +#[cfg(test)] +mod tests { + use crate::id3::v2::{Event, EventTimingCodesFrame, EventType, TimestampFormat}; + + fn expected() -> EventTimingCodesFrame { + EventTimingCodesFrame { + timestamp_format: TimestampFormat::MS, + events: vec![ + Event { + event_type: EventType::IntroStart, + timestamp: 1500, + }, + Event { + event_type: EventType::IntroEnd, + timestamp: 5000, + }, + Event { + event_type: EventType::MainPartStart, + timestamp: 7500, + }, + Event { + event_type: EventType::MainPartEnd, + timestamp: 900_000, + }, + Event { + event_type: EventType::AudioFileEnds, + timestamp: 750_000_000, + }, + ], + } + } + + #[test] + fn etco_decode() { + let cont = crate::tag::utils::test_utils::read_path("tests/tags/assets/id3v2/test.etco"); + + let parsed_etco = EventTimingCodesFrame::parse(&mut &cont[..]) + .unwrap() + .unwrap(); + + assert_eq!(parsed_etco, expected()); + } + + #[test] + fn etco_encode() { + let encoded = expected().as_bytes(); + + let expected_bytes = + crate::tag::utils::test_utils::read_path("tests/tags/assets/id3v2/test.etco"); + + assert_eq!(encoded, expected_bytes); + } +} diff --git a/src/id3/v2/items/mod.rs b/src/id3/v2/items/mod.rs index f027c08ab..af5636fa9 100644 --- a/src/id3/v2/items/mod.rs +++ b/src/id3/v2/items/mod.rs @@ -1,25 +1,35 @@ mod attached_picture_frame; mod audio_text_frame; mod encapsulated_object; +mod event_timing_codes_frame; mod extended_text_frame; mod extended_url_frame; -mod identifier; mod key_value_frame; pub(in crate::id3::v2) mod language_frame; +mod ownership_frame; mod popularimeter; +mod private_frame; +mod relative_volume_adjustment_frame; mod sync_text; mod text_information_frame; +mod unique_file_identifier; mod url_link_frame; pub use attached_picture_frame::AttachedPictureFrame; pub use audio_text_frame::{scramble, AudioTextFrame, AudioTextFrameFlags}; pub use encapsulated_object::GeneralEncapsulatedObject; +pub use event_timing_codes_frame::{Event, EventTimingCodesFrame, EventType}; pub use extended_text_frame::ExtendedTextFrame; pub use extended_url_frame::ExtendedUrlFrame; -pub use identifier::UniqueFileIdentifierFrame; pub use key_value_frame::KeyValueFrame; pub use language_frame::{CommentFrame, UnsynchronizedTextFrame}; +pub use ownership_frame::OwnershipFrame; pub use popularimeter::Popularimeter; +pub use private_frame::PrivateFrame; +pub use relative_volume_adjustment_frame::{ + ChannelInformation, ChannelType, RelativeVolumeAdjustmentFrame, +}; pub use sync_text::{SyncTextContentType, SynchronizedText, TimestampFormat}; pub use text_information_frame::TextInformationFrame; +pub use unique_file_identifier::UniqueFileIdentifierFrame; pub use url_link_frame::UrlLinkFrame; diff --git a/src/id3/v2/items/ownership_frame.rs b/src/id3/v2/items/ownership_frame.rs new file mode 100644 index 000000000..8e53e23de --- /dev/null +++ b/src/id3/v2/items/ownership_frame.rs @@ -0,0 +1,119 @@ +use crate::error::{ErrorKind, Id3v2Error, Id3v2ErrorKind, LoftyError, Result}; +use crate::util::text::{decode_text, encode_text, TextEncoding}; + +use std::hash::Hash; +use std::io::Read; + +use byteorder::ReadBytesExt; + +/// An `ID3v2` ownership frame +/// +/// This is used to mark a transaction, and is recommended to be used +/// in addition to the USER and TOWN frames. +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub struct OwnershipFrame { + /// The encoding of the seller string + pub encoding: TextEncoding, + /// The price paid + /// + /// The first three characters of this field contains the currency used for the transaction, + /// encoded according to ISO 4217 alphabetic currency code. Concatenated to this is the actual price paid, + /// as a numerical string using ”.” as the decimal separator. + pub price_paid: String, + /// The date of purchase as an 8 character date string (YYYYMMDD) + pub date_of_purchase: String, + /// The seller name + pub seller: String, +} + +impl OwnershipFrame { + /// Read an [`OwnershipFrame`] + /// + /// NOTE: This expects the frame header to have already been skipped + /// + /// # Errors + /// + /// * Invalid text encoding + /// * Not enough data + pub fn parse(reader: &mut R) -> Result> + where + R: Read, + { + let Ok(encoding_byte) = reader.read_u8() else { + return Ok(None); + }; + + let encoding = TextEncoding::from_u8(encoding_byte) + .ok_or_else(|| LoftyError::new(ErrorKind::TextDecode("Found invalid encoding")))?; + let price_paid = decode_text(reader, TextEncoding::Latin1, true)?.content; + + let mut date_bytes = vec![0u8; 8]; + reader.read_exact(&mut date_bytes)?; + + let date_of_purchase = String::from_utf8(date_bytes)?; + + let seller = decode_text(reader, encoding, false)?.content; + + Ok(Some(OwnershipFrame { + encoding, + price_paid, + date_of_purchase, + seller, + })) + } + + /// Convert an [`OwnershipFrame`] to a byte vec + /// + /// NOTE: The caller must verify that the `price_paid` field is a valid Latin-1 encoded string + /// + /// # Errors + /// + /// * `date_of_purchase` is not at least 8 characters (it will be truncated if greater) + pub fn as_bytes(&self) -> Result> { + let mut bytes = vec![self.encoding as u8]; + + bytes.extend(encode_text(&self.price_paid, TextEncoding::Latin1, true)); + if self.date_of_purchase.len() < 8 { + return Err(Id3v2Error::new(Id3v2ErrorKind::BadFrameLength).into()); + } + + bytes.extend(self.date_of_purchase.as_bytes().iter().take(8)); + bytes.extend(encode_text(&self.seller, self.encoding, false)); + + Ok(bytes) + } +} + +#[cfg(test)] +mod tests { + use crate::id3::v2::OwnershipFrame; + use crate::TextEncoding; + + fn expected() -> OwnershipFrame { + OwnershipFrame { + encoding: TextEncoding::Latin1, + price_paid: String::from("USD1000"), + date_of_purchase: String::from("19840407"), + seller: String::from("FooBar"), + } + } + + #[test] + fn owne_decode() { + let cont = crate::tag::utils::test_utils::read_path("tests/tags/assets/id3v2/test.owne"); + + let parsed_owne = OwnershipFrame::parse(&mut &cont[..]).unwrap().unwrap(); + + assert_eq!(parsed_owne, expected()); + } + + #[test] + fn owne_encode() { + let encoded = expected().as_bytes().unwrap(); + + let expected_bytes = + crate::tag::utils::test_utils::read_path("tests/tags/assets/id3v2/test.owne"); + + assert_eq!(encoded, expected_bytes); + } +} diff --git a/src/id3/v2/items/private_frame.rs b/src/id3/v2/items/private_frame.rs new file mode 100644 index 000000000..808233b9a --- /dev/null +++ b/src/id3/v2/items/private_frame.rs @@ -0,0 +1,90 @@ +use crate::error::Result; +use crate::util::text::{decode_text, encode_text, TextEncoding}; + +use std::io::Read; + +/// An `ID3v2` private frame +/// +/// This frame is used to contain information from a software producer that +/// its program uses and does not fit into the other frames. +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub struct PrivateFrame { + /// A URL containing an email address, or a link to a location where an email can be found, + /// that belongs to the organisation responsible for the frame + pub owner: String, + /// Binary data + pub private_data: Vec, +} + +impl PrivateFrame { + /// Read an [`PrivateFrame`] + /// + /// NOTE: This expects the frame header to have already been skipped + /// + /// # Errors + /// + /// * Failure to read from `reader` + pub fn parse(reader: &mut R) -> Result> + where + R: Read, + { + let Ok(owner) = decode_text(reader, TextEncoding::Latin1, true) else { + return Ok(None); + }; + + let owner = owner.content; + + let mut private_data = Vec::new(); + reader.read_to_end(&mut private_data)?; + + Ok(Some(PrivateFrame { + owner, + private_data, + })) + } + + /// Convert an [`PrivateFrame`] to a byte vec + pub fn as_bytes(&self) -> Vec { + let Self { + owner, + private_data, + } = self; + + let mut content = Vec::with_capacity(owner.len() + private_data.len()); + content.extend(encode_text(owner.as_str(), TextEncoding::Latin1, true)); + content.extend_from_slice(private_data); + + content + } +} + +#[cfg(test)] +mod tests { + use crate::id3::v2::PrivateFrame; + + fn expected() -> PrivateFrame { + PrivateFrame { + owner: String::from("foo@bar.com"), + private_data: String::from("some data").into_bytes(), + } + } + + #[test] + fn priv_decode() { + let cont = crate::tag::utils::test_utils::read_path("tests/tags/assets/id3v2/test.priv"); + + let parsed_priv = PrivateFrame::parse(&mut &cont[..]).unwrap().unwrap(); + + assert_eq!(parsed_priv, expected()); + } + + #[test] + fn priv_encode() { + let encoded = expected().as_bytes(); + + let expected_bytes = + crate::tag::utils::test_utils::read_path("tests/tags/assets/id3v2/test.priv"); + + assert_eq!(encoded, expected_bytes); + } +} diff --git a/src/id3/v2/items/relative_volume_adjustment_frame.rs b/src/id3/v2/items/relative_volume_adjustment_frame.rs new file mode 100644 index 000000000..012f7e6a6 --- /dev/null +++ b/src/id3/v2/items/relative_volume_adjustment_frame.rs @@ -0,0 +1,306 @@ +use crate::error::{Id3v2Error, Id3v2ErrorKind, Result}; +use crate::macros::try_vec; +use crate::probe::ParsingMode; +use crate::util::text::{decode_text, encode_text, TextEncoding}; + +use std::collections::HashMap; +use std::hash::{Hash, Hasher}; +use std::io::Read; + +use byteorder::{BigEndian, ReadBytesExt}; + +/// A channel identifier used in the RVA2 frame +#[repr(u8)] +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Debug, Hash)] +#[allow(missing_docs)] +pub enum ChannelType { + Other = 0, + MasterVolume = 1, + FrontRight = 2, + FrontLeft = 3, + BackRight = 4, + BackLeft = 5, + FrontCentre = 6, + BackCentre = 7, + Subwoofer = 8, +} + +impl ChannelType { + /// Get a [`ChannelType`] from a `u8` + /// + /// # Examples + /// + /// ```rust + /// use lofty::id3::v2::ChannelType; + /// + /// let valid_byte = 1; + /// assert_eq!( + /// ChannelType::from_u8(valid_byte), + /// Some(ChannelType::MasterVolume) + /// ); + /// + /// // The valid range is 0..=8 + /// let invalid_byte = 10; + /// assert_eq!(ChannelType::from_u8(invalid_byte), None); + /// ``` + pub fn from_u8(byte: u8) -> Option { + match byte { + 0 => Some(Self::Other), + 1 => Some(Self::MasterVolume), + 2 => Some(Self::FrontRight), + 3 => Some(Self::FrontLeft), + 4 => Some(Self::BackRight), + 5 => Some(Self::BackLeft), + 6 => Some(Self::FrontCentre), + 7 => Some(Self::BackCentre), + 8 => Some(Self::Subwoofer), + _ => None, + } + } +} + +/// Volume adjustment information for a specific channel +/// +/// This is used in the RVA2 frame through [`RelativeVolumeAdjustmentFrame`] +#[derive(Clone, Eq, PartialEq, Debug)] +pub struct ChannelInformation { + /// The type of channel this describes + pub channel_type: ChannelType, + /// A fixed point decibel value representing (adjustment*512), giving +/- 64 dB with a precision of 0.001953125 dB. + pub volume_adjustment: i16, + /// The number of bits the peak volume field occupies, with 0 meaning there is no peak volume. + pub bits_representing_peak: u8, + /// An optional peak volume + pub peak_volume: Option>, +} + +/// An `ID3v2` RVA2 frame +/// +/// NOTE: The `Eq` and `Hash` implementations depend solely on the `identification` field. +#[derive(Clone, Debug, Eq)] +pub struct RelativeVolumeAdjustmentFrame { + /// The identifier used to identify the situation and/or device where this adjustment should apply + pub identification: String, + /// The information for each channel described in the frame + pub channels: HashMap, +} + +impl PartialEq for RelativeVolumeAdjustmentFrame { + fn eq(&self, other: &Self) -> bool { + self.identification == other.identification + } +} + +impl Hash for RelativeVolumeAdjustmentFrame { + fn hash(&self, state: &mut H) { + self.identification.hash(state) + } +} + +impl RelativeVolumeAdjustmentFrame { + /// Read an [`RelativeVolumeAdjustmentFrame`] + /// + /// NOTE: This expects the frame header to have already been skipped + /// + /// # Errors + /// + /// * Bad channel type (See [Id3v2ErrorKind::BadRva2ChannelType]) + /// * Not enough data + pub fn parse(reader: &mut R, parse_mode: ParsingMode) -> Result> + where + R: Read, + { + let identification = decode_text(reader, TextEncoding::Latin1, true)?.content; + + let mut channels = HashMap::new(); + while let Ok(channel_type_byte) = reader.read_u8() { + let channel_type; + match ChannelType::from_u8(channel_type_byte) { + Some(channel_ty) => channel_type = channel_ty, + None if parse_mode == ParsingMode::BestAttempt => channel_type = ChannelType::Other, + _ => return Err(Id3v2Error::new(Id3v2ErrorKind::BadRva2ChannelType).into()), + } + + let volume_adjustment = reader.read_i16::()?; + + let bits_representing_peak = reader.read_u8()?; + + let mut peak_volume = None; + if bits_representing_peak > 0 { + let bytes_representing_peak = (bits_representing_peak + 7) >> 3; + + let mut peak_volume_bytes = try_vec![0; bytes_representing_peak as usize]; + reader.read_exact(&mut peak_volume_bytes)?; + peak_volume = Some(peak_volume_bytes); + } + + channels.insert( + channel_type, + ChannelInformation { + channel_type, + volume_adjustment, + bits_representing_peak, + peak_volume, + }, + ); + } + + Ok(Some(Self { + identification, + channels, + })) + } + + /// Convert a [`RelativeVolumeAdjustmentFrame`] to a byte vec + pub fn as_bytes(&self) -> Vec { + let mut content = Vec::new(); + + content.extend(encode_text( + &self.identification, + TextEncoding::Latin1, + true, + )); + + for (channel_type, info) in &self.channels { + let mut bits_representing_peak = info.bits_representing_peak; + let expected_peak_byte_length = (bits_representing_peak + 7) >> 3; + + content.push(*channel_type as u8); + content.extend(info.volume_adjustment.to_be_bytes()); + + if info.peak_volume.is_none() { + // Easiest path, no peak + content.push(0); + continue; + } + + if let Some(peak) = &info.peak_volume { + if peak.len() > expected_peak_byte_length as usize { + // Recalculate bits representing peak + bits_representing_peak = 0; + + // Max out at 255 bits + for b in peak.iter().copied().take(31) { + bits_representing_peak += b.leading_ones() as u8; + } + } + + content.push(bits_representing_peak); + content.extend(peak.iter().take(31)); + } + } + + content + } +} + +#[cfg(test)] +mod tests { + use crate::id3::v2::{ChannelInformation, ChannelType, RelativeVolumeAdjustmentFrame}; + use crate::ParsingMode; + + use std::collections::HashMap; + use std::io::Read; + + fn expected() -> RelativeVolumeAdjustmentFrame { + let mut channels = HashMap::new(); + + channels.insert( + ChannelType::MasterVolume, + ChannelInformation { + channel_type: ChannelType::MasterVolume, + volume_adjustment: 15, + bits_representing_peak: 4, + peak_volume: Some(vec![4]), + }, + ); + + channels.insert( + ChannelType::FrontLeft, + ChannelInformation { + channel_type: ChannelType::FrontLeft, + volume_adjustment: 21, + bits_representing_peak: 0, + peak_volume: None, + }, + ); + + channels.insert( + ChannelType::Subwoofer, + ChannelInformation { + channel_type: ChannelType::Subwoofer, + volume_adjustment: 30, + bits_representing_peak: 11, + peak_volume: Some(vec![0xFF, 0x07]), + }, + ); + + RelativeVolumeAdjustmentFrame { + identification: String::from("Surround sound"), + channels, + } + } + + #[test] + fn rva2_decode() { + let cont = crate::tag::utils::test_utils::read_path("tests/tags/assets/id3v2/test.rva2"); + + let parsed_rva2 = RelativeVolumeAdjustmentFrame::parse(&mut &cont[..], ParsingMode::Strict) + .unwrap() + .unwrap(); + + assert_eq!(parsed_rva2, expected()); + } + + #[test] + #[allow(unstable_name_collisions)] + fn rva2_encode() { + let encoded = expected().as_bytes(); + + let expected_bytes = + crate::tag::utils::test_utils::read_path("tests/tags/assets/id3v2/test.rva2"); + + // We have to check the output in fragments, as the order of channels is not guaranteed. + assert_eq!(encoded.len(), expected_bytes.len()); + + let mut needles = vec![ + &[1, 0, 15, 4, 4][..], // Master volume configuration + &[8, 0, 30, 11, 255, 7][..], // Front left configuration + &[3, 0, 21, 0][..], // Subwoofer configuration + ]; + + let encoded_reader = &mut &encoded[..]; + + let mut ident = [0; 15]; + encoded_reader.read_exact(&mut ident).unwrap(); + assert_eq!(ident, b"Surround sound\0"[..]); + + loop { + if needles.is_empty() { + break; + } + + let mut remove_idx = None; + for (idx, needle) in needles.iter().enumerate() { + if encoded_reader.starts_with(needle) { + std::io::copy( + &mut encoded_reader.take(needle.len() as u64), + &mut std::io::sink(), + ) + .unwrap(); + + remove_idx = Some(idx); + break; + } + } + + let Some(remove_idx) = remove_idx else { + unreachable!("Unexpected data in RVA2 frame: {:?}", &encoded); + }; + + needles.remove(remove_idx); + } + + assert!(needles.is_empty()); + } +} diff --git a/src/id3/v2/items/sync_text.rs b/src/id3/v2/items/sync_text.rs index e9ad0c267..3e6ea7c79 100644 --- a/src/id3/v2/items/sync_text.rs +++ b/src/id3/v2/items/sync_text.rs @@ -99,7 +99,7 @@ impl SynchronizedText { return Err(Id3v2Error::new(Id3v2ErrorKind::BadSyncText).into()); } let timestamp_format = TimestampFormat::from_u8(data[4]) - .ok_or_else(|| Id3v2Error::new(Id3v2ErrorKind::BadSyncText))?; + .ok_or_else(|| Id3v2Error::new(Id3v2ErrorKind::BadTimestampFormat))?; let content_type = SyncTextContentType::from_u8(data[5]) .ok_or_else(|| Id3v2Error::new(Id3v2ErrorKind::BadSyncText))?; diff --git a/src/id3/v2/items/identifier.rs b/src/id3/v2/items/unique_file_identifier.rs similarity index 100% rename from src/id3/v2/items/identifier.rs rename to src/id3/v2/items/unique_file_identifier.rs diff --git a/src/id3/v2/tag.rs b/src/id3/v2/tag.rs index f821a7d51..68464fe38 100644 --- a/src/id3/v2/tag.rs +++ b/src/id3/v2/tag.rs @@ -48,7 +48,7 @@ macro_rules! impl_accessor { } fn [](&mut self) { - self.remove($id) + let _ = self.remove($id); } )+ } @@ -273,6 +273,17 @@ impl Id3v2Tag { /// /// This will replace any frame of the same id (**or description!** See [`ExtendedTextFrame`]) pub fn insert(&mut self, frame: Frame<'static>) -> Option> { + // Some frames can only appear once in a tag, handle them separately + const ONE_PER_TAG: [&str; 11] = [ + "MCDI", "ETCO", "MLLT", "SYTC", "RVRB", "PCNT", "RBUF", "POSS", "OWNE", "SEEK", "ASPI", + ]; + + if ONE_PER_TAG.contains(&frame.id_str()) { + let ret = self.remove(frame.id_str()).next(); + self.frames.push(frame); + return ret; + } + let replaced = self .frames .iter() @@ -320,8 +331,18 @@ impl Id3v2Tag { } /// Removes a [`Frame`] by id - pub fn remove(&mut self, id: &str) { - self.frames.retain(|f| f.id_str() != id) + pub fn remove(&mut self, id: &str) -> impl Iterator> + '_ { + // TODO: drain_filter + let mut split_idx = 0_usize; + + for read_idx in 0..self.frames.len() { + if self.frames[read_idx].id_str().eq_ignore_ascii_case(id) { + self.frames.swap(split_idx, read_idx); + split_idx += 1; + } + } + + self.frames.drain(..split_idx) } /// Retains [`Frame`]s by evaluating the predicate @@ -525,7 +546,7 @@ impl Accessor for Id3v2Tag { } fn remove_track(&mut self) { - self.remove("TRCK"); + let _ = self.remove("TRCK"); } fn track_total(&self) -> Option { @@ -538,7 +559,7 @@ impl Accessor for Id3v2Tag { fn remove_track_total(&mut self) { let existing_track_number = self.track(); - self.remove("TRCK"); + let _ = self.remove("TRCK"); if let Some(track) = existing_track_number { self.insert(Frame::text(Cow::Borrowed("TRCK"), track.to_string())); @@ -554,7 +575,7 @@ impl Accessor for Id3v2Tag { } fn remove_disk(&mut self) { - self.remove("TPOS"); + let _ = self.remove("TPOS"); } fn disk_total(&self) -> Option { @@ -567,7 +588,7 @@ impl Accessor for Id3v2Tag { fn remove_disk_total(&mut self) { let existing_track_number = self.track(); - self.remove("TPOS"); + let _ = self.remove("TPOS"); if let Some(track) = existing_track_number { self.insert(Frame::text(Cow::Borrowed("TPOS"), track.to_string())); @@ -591,7 +612,7 @@ impl Accessor for Id3v2Tag { } fn remove_year(&mut self) { - self.remove("TDRC"); + let _ = self.remove("TDRC"); } fn comment(&self) -> Option> { @@ -904,7 +925,12 @@ impl SplitTag for Id3v2Tag { ItemValue::Binary(popularimeter.as_bytes()) }, FrameValue::Binary(binary) => ItemValue::Binary(std::mem::take(binary)), - FrameValue::KeyValue(_) | FrameValue::UniqueFileIdentifier(_) => { + FrameValue::KeyValue(_) + | FrameValue::UniqueFileIdentifier(_) + | FrameValue::RelativeVolumeAdjustment(_) + | FrameValue::Ownership(_) + | FrameValue::EventTimingCodes(_) + | FrameValue::Private(_) => { return true; // Keep unsupported frame }, }; diff --git a/src/id3/v2/write/frame.rs b/src/id3/v2/write/frame.rs index c30b108ef..3e405867d 100644 --- a/src/id3/v2/write/frame.rs +++ b/src/id3/v2/write/frame.rs @@ -39,19 +39,7 @@ fn verify_frame(frame: &FrameRef<'_>) -> Result<()> { (id, FrameValue::Url(_)) if id.starts_with('W') => Ok(()), (id, frame_value) => Err(Id3v2Error::new(Id3v2ErrorKind::BadFrame( id.to_string(), - match frame_value { - FrameValue::Comment(_) => "Comment", - FrameValue::UnsynchronizedText(_) => "UnsynchronizedText", - FrameValue::Text { .. } => "Text", - FrameValue::UserText(_) => "UserText", - FrameValue::Url(_) => "Url", - FrameValue::UserUrl(_) => "UserUrl", - FrameValue::Picture { .. } => "Picture", - FrameValue::Popularimeter(_) => "Popularimeter", - FrameValue::KeyValue(_) => "KeyValue", - FrameValue::Binary(_) => "Binary", - FrameValue::UniqueFileIdentifier(_) => "UniqueFileIdentifier", - }, + frame_value.name(), )) .into()), } diff --git a/src/lib.rs b/src/lib.rs index 14532d228..335e9d198 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -138,6 +138,7 @@ clippy::field_reassign_with_default, clippy::manual_range_patterns, /* This is not at all clearer as it suggests */ clippy::explicit_iter_loop, + clippy::from_iter_instead_of_collect )] #![cfg_attr(docsrs, feature(doc_auto_cfg))] diff --git a/tests/tags/assets/id3v2/test.etco b/tests/tags/assets/id3v2/test.etco new file mode 100644 index 000000000..bbc67783f Binary files /dev/null and b/tests/tags/assets/id3v2/test.etco differ diff --git a/tests/tags/assets/id3v2/test.owne b/tests/tags/assets/id3v2/test.owne new file mode 100644 index 000000000..b6a0ede0d Binary files /dev/null and b/tests/tags/assets/id3v2/test.owne differ diff --git a/tests/tags/assets/id3v2/test.priv b/tests/tags/assets/id3v2/test.priv new file mode 100644 index 000000000..5c42461f8 Binary files /dev/null and b/tests/tags/assets/id3v2/test.priv differ diff --git a/tests/tags/assets/id3v2/test.rva2 b/tests/tags/assets/id3v2/test.rva2 new file mode 100644 index 000000000..cd1d1d44b Binary files /dev/null and b/tests/tags/assets/id3v2/test.rva2 differ