diff --git a/src/id3/v2/frame/content.rs b/src/id3/v2/frame/content.rs index 81bea843c..c494827e6 100644 --- a/src/id3/v2/frame/content.rs +++ b/src/id3/v2/frame/content.rs @@ -1,6 +1,7 @@ use crate::error::{ID3v2Error, ID3v2ErrorKind, LoftyError, Result}; use crate::id3::v2::frame::FrameValue; use crate::id3::v2::items::encoded_text_frame::EncodedTextFrame; +use crate::id3::v2::items::identifier::UniqueFileIdentifierFrame; use crate::id3::v2::items::language_frame::LanguageFrame; use crate::id3::v2::items::popularimeter::Popularimeter; use crate::id3::v2::ID3v2Version; @@ -28,6 +29,7 @@ pub(super) fn parse_content( "TXXX" => parse_user_defined(content, false, version)?, "WXXX" => parse_user_defined(content, true, version)?, "COMM" | "USLT" => parse_text_language(content, id, version)?, + "UFID" => UniqueFileIdentifierFrame::decode_bytes(content)?.map(FrameValue::UniqueFileIdentifier), _ if id.starts_with('T') => parse_text(content, version)?, // 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 e3a3f9990..0d1d5e227 100644 --- a/src/id3/v2/frame/mod.rs +++ b/src/id3/v2/frame/mod.rs @@ -12,6 +12,7 @@ use crate::picture::Picture; use crate::tag::item::{ItemValue, TagItem}; use crate::tag::TagType; use crate::util::text::{encode_text, TextEncoding}; +use crate::ItemKey; use id::FrameID; use std::borrow::Cow; @@ -20,6 +21,10 @@ use crate::id3::v2::items::popularimeter::Popularimeter; use std::convert::{TryFrom, TryInto}; use std::hash::{Hash, Hasher}; +use super::items::identifier::UniqueFileIdentifierFrame; + +pub(super) const MUSICBRAINZ_UFID_OWNER: &str = "http://musicbrainz.org"; + /// Empty content descriptor in text frame /// /// Unspecific [`LanguageFrame`]s and [`EncodedTextFrame`] frames @@ -204,6 +209,8 @@ 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 From for FrameValue { @@ -236,6 +243,7 @@ impl FrameValue { }, FrameValue::Popularimeter(popularimeter) => popularimeter.as_bytes(), FrameValue::Binary(binary) => binary.clone(), + FrameValue::UniqueFileIdentifier(frame) => frame.as_bytes(), }) } } @@ -349,7 +357,22 @@ impl From for Option> { }, ItemValue::Binary(_) => return None, }, - None => return None, + None => match (input.item_key, input.item_value) { + (ItemKey::MusicBrainzRecordingId, ItemValue::Text(recording_id)) => { + if !recording_id.is_ascii() { + return None; + } + let frame = UniqueFileIdentifierFrame { + owner: MUSICBRAINZ_UFID_OWNER.to_owned(), + identifier: recording_id.into_bytes(), + }; + frame_id = FrameID::Valid(Cow::Borrowed("UFID")); + value = FrameValue::UniqueFileIdentifier(frame); + }, + _ => { + return None; + }, + }, }, } diff --git a/src/id3/v2/items/identifier.rs b/src/id3/v2/items/identifier.rs new file mode 100644 index 000000000..12511bb37 --- /dev/null +++ b/src/id3/v2/items/identifier.rs @@ -0,0 +1,55 @@ +use std::hash::{Hash, Hasher}; + +use crate::error::{ID3v2Error, ID3v2ErrorKind}; +use crate::util::text::{decode_text, encode_text}; +use crate::{Result, TextEncoding}; + +/// An `ID3v2` unique file identifier frame (UFID). +#[derive(Clone, Debug, Eq)] +pub struct UniqueFileIdentifierFrame { + /// The non-empty owner of the identifier. + pub owner: String, + /// The binary payload with up to 64 bytes of data. + pub identifier: Vec, +} + +impl UniqueFileIdentifierFrame { + /// Encode the frame contents as bytes. + pub fn as_bytes(&self) -> Vec { + let Self { owner, identifier } = self; + + let mut content = Vec::with_capacity(owner.len() + 1 + identifier.len()); + content.extend(encode_text(owner.as_str(), TextEncoding::Latin1, true)); + content.extend_from_slice(identifier); + + content + } + + /// Decode the frame contents from bytes. + pub fn decode_bytes(input: &mut &[u8]) -> Result> { + if input.is_empty() { + return Ok(None); + } + + let Some(owner) = decode_text(input, TextEncoding::Latin1, true)? else { + return Err(ID3v2Error::new(ID3v2ErrorKind::Other( + "Missing owner in UFID frame", + )).into()); + }; + let identifier = input.to_vec(); + + Ok(Some(Self { owner, identifier })) + } +} + +impl PartialEq for UniqueFileIdentifierFrame { + fn eq(&self, other: &Self) -> bool { + self.owner == other.owner + } +} + +impl Hash for UniqueFileIdentifierFrame { + fn hash(&self, state: &mut H) { + self.owner.hash(state); + } +} diff --git a/src/id3/v2/items/mod.rs b/src/id3/v2/items/mod.rs index e6a100e9c..055feb392 100644 --- a/src/id3/v2/items/mod.rs +++ b/src/id3/v2/items/mod.rs @@ -1,5 +1,6 @@ pub(super) mod encapsulated_object; pub(super) mod encoded_text_frame; +pub(super) mod identifier; pub(super) mod language_frame; pub(super) mod popularimeter; pub(super) mod sync_text; diff --git a/src/id3/v2/tag.rs b/src/id3/v2/tag.rs index e370cf9c4..0062bcc8d 100644 --- a/src/id3/v2/tag.rs +++ b/src/id3/v2/tag.rs @@ -3,20 +3,21 @@ use super::frame::id::FrameID; use super::frame::{Frame, FrameFlags, FrameValue, EMPTY_CONTENT_DESCRIPTOR, UNKNOWN_LANGUAGE}; use super::ID3v2Version; use crate::error::{LoftyError, Result}; -use crate::id3::v2::frame::FrameRef; +use crate::id3::v2::frame::{FrameRef, MUSICBRAINZ_UFID_OWNER}; use crate::id3::v2::items::encoded_text_frame::EncodedTextFrame; +use crate::id3::v2::items::identifier::UniqueFileIdentifierFrame; use crate::id3::v2::items::language_frame::LanguageFrame; use crate::picture::{Picture, PictureType, TOMBSTONE_PICTURE}; use crate::tag::item::{ItemKey, ItemValue, TagItem}; use crate::tag::{try_parse_year, Tag, TagType}; use crate::traits::{Accessor, MergeTag, SplitTag, TagExt}; -use crate::util::text::TextEncoding; +use crate::util::text::{decode_text, TextEncoding}; use std::borrow::Cow; use std::convert::TryInto; use std::fmt::Display; use std::fs::{File, OpenOptions}; -use std::io::Write; +use std::io::{Cursor, Write}; use std::ops::Deref; use std::path::Path; @@ -741,6 +742,29 @@ impl SplitTag for ID3v2Tag { } false // Frame consumed }, + ( + "UFID", + FrameValue::UniqueFileIdentifier(UniqueFileIdentifierFrame { + ref owner, + ref identifier, + .. + }), + ) => { + if owner == MUSICBRAINZ_UFID_OWNER { + let mut identifier = Cursor::new(identifier); + let Ok(recording_id) = decode_text(&mut identifier, TextEncoding::Latin1, false) else { + return true; // Keep frame + }; + tag.items.push(TagItem::new( + ItemKey::MusicBrainzRecordingId, + ItemValue::Text(recording_id.unwrap_or_default()), + )); + false // Frame consumed + } else { + // Unsupported owner + true // Keep frame + } + }, (id, value) => { let item_key = ItemKey::from_key(TagType::ID3v2, id); @@ -798,6 +822,9 @@ impl SplitTag for ID3v2Tag { ItemValue::Binary(popularimeter.as_bytes()) }, FrameValue::Binary(binary) => ItemValue::Binary(std::mem::take(binary)), + FrameValue::UniqueFileIdentifier(_) => { + return true; // Keep unsupported frame + }, }; tag.items.push(TagItem::new(item_key, item_value)); @@ -1036,6 +1063,8 @@ impl<'a, I: Iterator> + Clone + 'a> Id3v2TagRef<'a, I> { mod tests { use std::borrow::Cow; + use crate::id3::v2::frame::MUSICBRAINZ_UFID_OWNER; + use crate::id3::v2::items::identifier::UniqueFileIdentifierFrame; use crate::id3::v2::items::popularimeter::Popularimeter; use crate::id3::v2::tag::{ filter_comment_frame_by_description, new_text_frame, DEFAULT_NUMBER_IN_PAIR, @@ -1741,9 +1770,9 @@ mod tests { Some(&Frame { id: FrameID::Valid(Cow::Borrowed("TXXX")), value: FrameValue::UserText(EncodedTextFrame { - encoding: TextEncoding::UTF8, description: String::from("FOO_BAR"), - content: String::from("foo content"), + encoding: TextEncoding::UTF8, // Not considered by PartialEq! + content: Default::default(), // Not considered by PartialEq! }), flags: FrameFlags::default(), }) @@ -2041,4 +2070,78 @@ mod tests { assert_invalid("1//2"); assert_invalid("0x1/0x2"); } + + #[test] + fn ufid_frame_with_musicbrainz_record_id() { + let mut id3v2 = ID3v2Tag::default(); + let unknown_ufid_frame = UniqueFileIdentifierFrame { + owner: "other".to_owned(), + identifier: b"0123456789".to_vec(), + }; + id3v2.insert( + Frame::new( + "UFID", + FrameValue::UniqueFileIdentifier(unknown_ufid_frame.clone()), + FrameFlags::default(), + ) + .unwrap(), + ); + let musicbrainz_recording_id = b"189002e7-3285-4e2e-92a3-7f6c30d407a2"; + let musicbrainz_recording_id_frame = UniqueFileIdentifierFrame { + owner: MUSICBRAINZ_UFID_OWNER.to_owned(), + identifier: musicbrainz_recording_id.to_vec(), + }; + id3v2.insert( + Frame::new( + "UFID", + FrameValue::UniqueFileIdentifier(musicbrainz_recording_id_frame.clone()), + FrameFlags::default(), + ) + .unwrap(), + ); + assert_eq!(2, id3v2.len()); + + let (split_remainder, split_tag) = id3v2.split_tag(); + assert_eq!(split_remainder.0.len(), 1); + assert_eq!(split_tag.len(), 1); + assert_eq!( + ItemValue::Text(String::from_utf8(musicbrainz_recording_id.to_vec()).unwrap()), + *split_tag + .get_items(&ItemKey::MusicBrainzRecordingId) + .next() + .unwrap() + .value() + ); + + let id3v2 = split_remainder.merge_tag(split_tag); + assert_eq!(2, id3v2.len()); + match &id3v2.frames[..] { + [Frame { + id: _, + value: + FrameValue::UniqueFileIdentifier(UniqueFileIdentifierFrame { + owner: first_owner, + identifier: first_identifier, + }), + flags: _, + }, Frame { + id: _, + value: + FrameValue::UniqueFileIdentifier(UniqueFileIdentifierFrame { + owner: second_owner, + identifier: second_identifier, + }), + flags: _, + }] => { + assert_eq!(&unknown_ufid_frame.owner, first_owner); + assert_eq!(&unknown_ufid_frame.identifier, first_identifier); + assert_eq!(&musicbrainz_recording_id_frame.owner, second_owner); + assert_eq!( + &musicbrainz_recording_id_frame.identifier, + second_identifier + ); + }, + _ => unreachable!(), + } + } } diff --git a/src/id3/v2/write/frame.rs b/src/id3/v2/write/frame.rs index 2522883a4..2dc21a43b 100644 --- a/src/id3/v2/write/frame.rs +++ b/src/id3/v2/write/frame.rs @@ -31,6 +31,7 @@ fn verify_frame(frame: &FrameRef<'_>) -> Result<()> { | ("TXXX", FrameValue::UserText(_)) | ("WXXX", FrameValue::UserURL(_)) | (_, FrameValue::Binary(_)) + | ("UFID", FrameValue::UniqueFileIdentifier(_)) | ("WFED" | "GRP1" | "MVNM" | "MVIN", FrameValue::Text { .. }) => Ok(()), (id, FrameValue::Text { .. }) if id.starts_with('T') => Ok(()), (id, FrameValue::URL(_)) if id.starts_with('W') => Ok(()), @@ -46,6 +47,7 @@ fn verify_frame(frame: &FrameRef<'_>) -> Result<()> { FrameValue::Picture { .. } => "Picture", FrameValue::Popularimeter(_) => "Popularimeter", FrameValue::Binary(_) => "Binary", + FrameValue::UniqueFileIdentifier(_) => "UniqueFileIdentifier", }, )) .into()),