From 1b767e8f7ca6962b6e221b1dfbec3ef584b545a8 Mon Sep 17 00:00:00 2001 From: Jan Holthuis Date: Mon, 11 Nov 2024 20:39:36 +0100 Subject: [PATCH] feat: Add support for IPLS (v2.3) and TIPL/TMCL (v2.4) frames --- src/frame/content.rs | 63 +++++++++++++ src/frame/mod.rs | 10 ++- src/stream/frame/content.rs | 172 +++++++++++++++++++++++++++++++++++- 3 files changed, 237 insertions(+), 8 deletions(-) diff --git a/src/frame/content.rs b/src/frame/content.rs index cb3a53fe4..3c8fad278 100644 --- a/src/frame/content.rs +++ b/src/frame/content.rs @@ -55,6 +55,8 @@ pub enum Content { TableOfContents(TableOfContents), /// A value containing the parsed contents of a unique file identifier frame (UFID). UniqueFileIdentifier(UniqueFileIdentifier), + /// A value containing the parsed contents of an involved people list IPLS + InvolvedPeopleList(InvolvedPeopleList), /// A value containing the bytes of a currently unknown frame type. /// /// Users that wish to write custom decoders must use [`Content::to_unknown`] instead of @@ -114,6 +116,19 @@ impl Content { Self::UniqueFileIdentifier(unique_file_identifier) => Comparable(vec![Cow::Borrowed( unique_file_identifier.owner_identifier.as_bytes(), )]), + Self::InvolvedPeopleList(involved_people_list) => Comparable( + involved_people_list + .items + .iter() + .flat_map(|item| { + [ + Cow::Borrowed(item.involvement.as_bytes()), + Cow::Borrowed(item.involvee.as_bytes()), + ] + .into_iter() + }) + .collect(), + ), Self::Unknown(_) => Incomparable, } } @@ -262,6 +277,14 @@ impl Content { } } + /// Returns the `InvolvedPeopleList` or None if the value is not `IPLS` + pub fn involved_people_list(&self) -> Option<&InvolvedPeopleList> { + match self { + Content::InvolvedPeopleList(involved_people_list) => Some(involved_people_list), + _ => None, + } + } + /// Returns the `Unknown` or None if the value is not `Unknown`. #[deprecated(note = "Use to_unknown")] pub fn unknown(&self) -> Option<&[u8]> { @@ -308,6 +331,9 @@ impl fmt::Display for Content { Content::UniqueFileIdentifier(unique_file_identifier) => { write!(f, "{}", unique_file_identifier) } + Content::InvolvedPeopleList(involved_people_list) => { + write!(f, "{}", involved_people_list) + } Content::Unknown(unknown) => write!(f, "{}", unknown), } } @@ -850,6 +876,43 @@ impl From for Frame { } } +/// The parsed contents of an IPLS frame. +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct InvolvedPeopleList { + /// Items in the People List. + pub items: Vec, +} + +/// The parsed contents of an IPLS frame. +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct InvolvedPeopleListItem { + /// Role of the involved person. + pub involvement: String, + /// Name of the involved person. + pub involvee: String, +} + +impl fmt::Display for InvolvedPeopleList { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let item_count = self.items.len(); + for (i, item) in self.items.iter().enumerate() { + if i == 0 && item_count > 1 { + write!(f, "{}: {} / ", item.involvement, item.involvee)?; + } else { + write!(f, "{}: {}", item.involvement, item.involvee)?; + } + } + + Ok(()) + } +} + +impl From for Frame { + fn from(c: InvolvedPeopleList) -> Self { + Self::with_content("IPLS", Content::InvolvedPeopleList(c)) + } +} + #[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] #[allow(missing_docs)] pub struct TableOfContents { diff --git a/src/frame/mod.rs b/src/frame/mod.rs index 4a58bf017..dd5fb494a 100644 --- a/src/frame/mod.rs +++ b/src/frame/mod.rs @@ -5,10 +5,10 @@ use std::fmt; use std::str; pub use self::content::{ - Chapter, Comment, Content, EncapsulatedObject, ExtendedLink, ExtendedText, Lyrics, - MpegLocationLookupTable, MpegLocationLookupTableReference, Picture, PictureType, Popularimeter, - Private, SynchronisedLyrics, SynchronisedLyricsType, TableOfContents, TimestampFormat, - UniqueFileIdentifier, Unknown, + Chapter, Comment, Content, EncapsulatedObject, ExtendedLink, ExtendedText, InvolvedPeopleList, + InvolvedPeopleListItem, Lyrics, MpegLocationLookupTable, MpegLocationLookupTableReference, + Picture, PictureType, Popularimeter, Private, SynchronisedLyrics, SynchronisedLyricsType, + TableOfContents, TimestampFormat, UniqueFileIdentifier, Unknown, }; pub use self::timestamp::Timestamp; @@ -84,6 +84,7 @@ impl Frame { ("APIC", Content::Picture(_)) => Ok(()), ("CHAP", Content::Chapter(_)) => Ok(()), ("MLLT", Content::MpegLocationLookupTable(_)) => Ok(()), + ("IPLS", Content::InvolvedPeopleList(_)) => Ok(()), ("PRIV", Content::Private(_)) => Ok(()), ("CTOC", Content::TableOfContents(_)) => Ok(()), ("UFID", Content::UniqueFileIdentifier(_)) => Ok(()), @@ -105,6 +106,7 @@ impl Frame { Content::Private(_) => "PrivateFrame", Content::TableOfContents(_) => "TableOfContents", Content::UniqueFileIdentifier(_) => "UFID", + Content::InvolvedPeopleList(_) => "InvolvedPeopleList", Content::Unknown(_) => "Unknown", }; Err(Error::new( diff --git a/src/stream/frame/content.rs b/src/stream/frame/content.rs index ac700e462..938c16b4f 100644 --- a/src/stream/frame/content.rs +++ b/src/stream/frame/content.rs @@ -1,8 +1,8 @@ use crate::frame::{ - Chapter, Comment, Content, EncapsulatedObject, ExtendedLink, ExtendedText, Lyrics, - MpegLocationLookupTable, MpegLocationLookupTableReference, Picture, PictureType, Popularimeter, - Private, SynchronisedLyrics, SynchronisedLyricsType, TableOfContents, TimestampFormat, - UniqueFileIdentifier, Unknown, + Chapter, Comment, Content, EncapsulatedObject, ExtendedLink, ExtendedText, InvolvedPeopleList, + InvolvedPeopleListItem, Lyrics, MpegLocationLookupTable, MpegLocationLookupTableReference, + Picture, PictureType, Popularimeter, Private, SynchronisedLyrics, SynchronisedLyricsType, + TableOfContents, TimestampFormat, UniqueFileIdentifier, Unknown, }; use crate::stream::encoding::Encoding; use crate::stream::frame; @@ -306,6 +306,17 @@ impl Encoder { Ok(()) } + fn involved_people_list(&mut self, content: &InvolvedPeopleList) -> crate::Result<()> { + self.encoding()?; + for item in &content.items { + self.string(&item.involvement)?; + self.delim()?; + self.string(&item.involvee)?; + self.delim()?; + } + Ok(()) + } + fn table_of_contents_content(&mut self, content: &TableOfContents) -> crate::Result<()> { self.string_with_other_encoding(Encoding::Latin1, &content.element_id)?; self.byte(0)?; @@ -361,6 +372,7 @@ pub fn encode( Content::Private(c) => encoder.private_content(c)?, Content::TableOfContents(c) => encoder.table_of_contents_content(c)?, Content::UniqueFileIdentifier(c) => encoder.unique_file_identifier_content(c)?, + Content::InvolvedPeopleList(c) => encoder.involved_people_list(c)?, Content::Unknown(c) => encoder.bytes(&c.data)?, }; @@ -411,6 +423,7 @@ pub fn decode( encoding = Some(enc); Ok(content) } + "IPLS" | "IPL" | "TMCL" | "TIPL" => decoder.involved_people_list(), id if id.starts_with('T') => decoder.text_content(), id if id.starts_with('W') => decoder.link_content(), "GRP1" => decoder.text_content(), @@ -513,6 +526,62 @@ impl<'a> Decoder<'a> { Ok(Content::Text(text)) } + fn involved_people_list(mut self) -> crate::Result { + let encoding = self.encoding()?; + let end = match self.version { + Version::Id3v23 | Version::Id3v24 => find_closing_delim(encoding, self.r), + _ => find_delim(encoding, self.r, 0), + }.unwrap_or(self.r.len()); + + let data = self.bytes(end)?; + + let mut pos = 0; + let items = iter::repeat_with(|| { + find_delim(encoding, data, pos).map(|next_pos| { + let substr = encoding.decode(&data[pos..next_pos]); + pos = next_pos + delim_len(encoding); + substr + }).or_else(|| if pos < data.len() { + let substr = encoding.decode(&data[pos..]); + pos = data.len(); + Some(substr) + } else { + None + }) + }) + .scan(None, |last_string, string| match (&last_string, string) { + (None, Some(string)) => { + *last_string = Some(string); + Some(Ok(None)) + } + (Some(_), Some(second)) => { + let first = last_string.take().expect("option must be some"); + let result = first.and_then(|involvement| { + second.map(|involvee| { + Some(InvolvedPeopleListItem { + involvement, + involvee, + }) + }) + }); + Some(result) + } + (Some(_), None) => { + // This can only happen if there is an uneven number of elements. + *last_string = None; + Some(Err(Error::new( + ErrorKind::Parsing, + "uneven number of IPLS strings", + ))) + } + (None, None) => None, + }) + .filter_map(|item| item.transpose()) + .collect::>>()?; + + Ok(Content::InvolvedPeopleList(InvolvedPeopleList { items })) + } + fn link_content(self) -> crate::Result { Ok(Content::Link(String::from_utf8(self.r.to_vec())?)) } @@ -1510,6 +1579,101 @@ mod tests { } } + #[test] + fn test_ipls() { + check_involved_people_list("IPLS", Version::Id3v23); + } + + #[test] + fn test_tmcl() { + check_involved_people_list("TMCL", Version::Id3v24); + } + + #[test] + fn test_tipl() { + check_involved_people_list("TIPL", Version::Id3v24); + } + + fn check_involved_people_list(frame_id: &str, version: Version) { + assert!(decode(frame_id, version, &[][..]).is_err()); + + println!("valid"); + for people_list in &[ + vec![], + vec![("involvement", "involvee")], + vec![ + ("double bass", "Israel Crosby"), + ("drums (drum set)", "Vernell Fournier"), + ("piano", "Ahmad Jamal"), + ("producer", "Dave Usher"), + ], + ] { + for encoding in &[ + Encoding::Latin1, + Encoding::UTF8, + Encoding::UTF16, + Encoding::UTF16BE, + ] { + println!("`{:?}`, `{:?}`", people_list, encoding); + let mut data = Vec::new(); + data.push(*encoding as u8); + for (involvement, involvee) in people_list { + data.extend(bytes_for_encoding(&involvement, *encoding).into_iter()); + data.extend(delim_for_encoding(*encoding).into_iter()); + data.extend(bytes_for_encoding(&involvee, *encoding).into_iter()); + data.extend(delim_for_encoding(*encoding).into_iter()); + } + + let content = frame::InvolvedPeopleList { + items: people_list + .iter() + .map(|(involvement, involvee)| InvolvedPeopleListItem { + involvement: involvement.to_string(), + involvee: involvee.to_string(), + }) + .collect(), + }; + assert_eq!( + *decode(frame_id, version, &data[..]) + .unwrap() + .0 + .involved_people_list() + .unwrap(), + content + ); + let mut data_out = Vec::new(); + encode( + &mut data_out, + &&Content::InvolvedPeopleList(content), + Version::Id3v23, + *encoding, + ) + .unwrap(); + assert_eq!(data, data_out); + } + } + + println!("invalid"); + for encoding in &[ + Encoding::Latin1, + Encoding::UTF8, + Encoding::UTF16, + Encoding::UTF16BE, + ] { + println!("`{:?}`", encoding); + let mut data = Vec::new(); + data.push(*encoding as u8); + data.extend(bytes_for_encoding("involvement", *encoding).into_iter()); + data.extend(delim_for_encoding(*encoding).into_iter()); + data.extend(bytes_for_encoding("involvee", *encoding).into_iter()); + data.extend(delim_for_encoding(*encoding).into_iter()); + data.extend(bytes_for_encoding("other involvement", *encoding).into_iter()); + data.extend(delim_for_encoding(*encoding).into_iter()); + // involveee missing here + assert!(decode(frame_id, version, &data[..]).is_err()); + } + } + #[test] fn test_mllt_4_4() { let mllt = Content::MpegLocationLookupTable(MpegLocationLookupTable {