diff --git a/README.md b/README.md index a5b3ba9df..99f1ba373 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ A library for reading and writing ID3 metadata. * Compression * MPEG Location Lookup Table frames * Unique File Identifier frames +* Involved People List frames * Tag and File Alter Preservation bits ## Examples diff --git a/src/frame/content.rs b/src/frame/content.rs index cb3a53fe4..3bc967eb9 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 frame (IPLS/TIPL/TMCL) + 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,7 @@ impl Content { Self::UniqueFileIdentifier(unique_file_identifier) => Comparable(vec![Cow::Borrowed( unique_file_identifier.owner_identifier.as_bytes(), )]), + Self::InvolvedPeopleList(_) => Same, Self::Unknown(_) => Incomparable, } } @@ -262,6 +265,14 @@ impl Content { } } + /// Returns the `InvolvedPeopleList` or None if the value is not `IPLS`/`TIPL`/`TMCL` + 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 +319,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 +864,43 @@ impl From for Frame { } } +/// The parsed contents of an `IPLS` (ID3v2.3) or `TIPL`/`TMCL` (ID3v2.4) frame. +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct InvolvedPeopleList { + /// Items in the People List. + pub items: Vec, +} + +/// A entry inside the list in an `IPLS` (ID3v2.3) or `TIPL`/`TMCL` (ID3v2.4) 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("TIPL", 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..e349a4f49 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; @@ -72,7 +72,9 @@ impl Frame { // The matching groups must match the decoding groups of stream/frame/content.rs:decode(). match (id.as_str(), &self.content) { ("GRP1", Content::Text(_)) => Ok(()), - (id, Content::Text(_)) if id.starts_with('T') => Ok(()), + (id, Content::Text(_)) if id.starts_with('T') && !matches!(id, "TIPL" | "TMCL") => { + Ok(()) + } ("TXXX", Content::ExtendedText(_)) => Ok(()), (id, Content::Link(_)) if id.starts_with('W') => Ok(()), ("WXXX", Content::ExtendedLink(_)) => Ok(()), @@ -84,6 +86,7 @@ impl Frame { ("APIC", Content::Picture(_)) => Ok(()), ("CHAP", Content::Chapter(_)) => Ok(()), ("MLLT", Content::MpegLocationLookupTable(_)) => Ok(()), + ("IPLS" | "TIPL" | "TMCL", Content::InvolvedPeopleList(_)) => Ok(()), ("PRIV", Content::Private(_)) => Ok(()), ("CTOC", Content::TableOfContents(_)) => Ok(()), ("UFID", Content::UniqueFileIdentifier(_)) => Ok(()), @@ -105,6 +108,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 b9afdf4c5..a8860bb0c 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,67 @@ 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 +1584,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 { diff --git a/src/tag.rs b/src/tag.rs index a9a63b7f8..cf887a9ee 100644 --- a/src/tag.rs +++ b/src/tag.rs @@ -1,7 +1,7 @@ use crate::chunk; use crate::frame::{ - Chapter, Comment, EncapsulatedObject, ExtendedLink, ExtendedText, Frame, Lyrics, Picture, - SynchronisedLyrics, TableOfContents, UniqueFileIdentifier, + Chapter, Comment, EncapsulatedObject, ExtendedLink, ExtendedText, Frame, InvolvedPeopleList, + Lyrics, Picture, SynchronisedLyrics, TableOfContents, UniqueFileIdentifier, }; use crate::storage::{plain::PlainStorage, Format, Storage}; use crate::stream; @@ -496,6 +496,95 @@ impl<'a> Tag { self.frames() .filter_map(|frame| frame.content().table_of_contents()) } + + /// Returns an iterator over all involved people lists (`IPLS` in ID3v2.3, `TIPL` and `TMCL` in + /// ID3v2.4) in the tag. + /// + /// # Examples + /// + /// ## `IPLS` frame (ID3v2.3) + /// + /// ``` + /// use id3::{Frame, Tag, TagLike, Version}; + /// use id3::frame::{Content, InvolvedPeopleList, InvolvedPeopleListItem}; + /// + /// let mut tag = Tag::with_version(Version::Id3v23); + /// + /// let frame = Frame::with_content("IPLS", Content::InvolvedPeopleList(InvolvedPeopleList { + /// items: vec![ + /// InvolvedPeopleListItem { + /// involvement: "drums (drum set)".to_string(), + /// involvee: "Gene Krupa".to_string(), + /// }, + /// InvolvedPeopleListItem { + /// involvement: "piano".to_string(), + /// involvee: "Hank Jones".to_string(), + /// }, + /// InvolvedPeopleListItem { + /// involvement: "tenor saxophone".to_string(), + /// involvee: "Frank Socolow".to_string(), + /// }, + /// InvolvedPeopleListItem { + /// involvement: "tenor saxophone".to_string(), + /// involvee: "Eddie Wasserman".to_string(), + /// }, + /// ], + /// })); + /// tag.add_frame(frame); + /// assert_eq!(1, tag.involved_people_lists().count()); + /// assert_eq!(4, tag.involved_people_lists().flat_map(|list| list.items.iter()).count()); + /// ``` + /// + /// ## `TIPL`/`TMCL` frames (ID3v2.4) + /// ``` + /// use id3::{Frame, Tag, TagLike, Version}; + /// use id3::frame::{Content, InvolvedPeopleList, InvolvedPeopleListItem}; + /// + /// let mut tag = Tag::with_version(Version::Id3v24); + /// + /// let frame = Frame::with_content("TMCL", Content::InvolvedPeopleList(InvolvedPeopleList { + /// items: vec![ + /// InvolvedPeopleListItem { + /// involvement: "drums (drum set)".to_string(), + /// involvee: "Gene Krupa".to_string(), + /// }, + /// InvolvedPeopleListItem { + /// involvement: "piano".to_string(), + /// involvee: "Hank Jones".to_string(), + /// }, + /// InvolvedPeopleListItem { + /// involvement: "tenor saxophone".to_string(), + /// involvee: "Frank Socolow".to_string(), + /// }, + /// InvolvedPeopleListItem { + /// involvement: "tenor saxophone".to_string(), + /// involvee: "Eddie Wasserman".to_string(), + /// }, + /// ], + /// })); + /// tag.add_frame(frame); + /// + /// let frame = Frame::with_content("TIPL", Content::InvolvedPeopleList(InvolvedPeopleList { + /// items: vec![ + /// InvolvedPeopleListItem { + /// involvement: "executive producer".to_string(), + /// involvee: "Ken Druker".to_string(), + /// }, + /// InvolvedPeopleListItem { + /// involvement: "arranger".to_string(), + /// involvee: "Gerry Mulligan".to_string(), + /// }, + /// ], + /// })); + /// tag.add_frame(frame); + /// assert_eq!(2, tag.involved_people_lists().count()); + /// assert_eq!(6, tag.involved_people_lists().flat_map(|list| list.items.iter()).count()); + /// + /// ``` + pub fn involved_people_lists(&self) -> impl Iterator { + self.frames() + .filter_map(|frame| frame.content().involved_people_list()) + } } impl PartialEq for Tag { @@ -946,4 +1035,117 @@ mod tests { let tag = Tag::read_from_path("testdata/geob_serato.id3").unwrap(); assert_eq!(count, tag.encapsulated_objects().count()); } + + /// Read an IPLS frame with UTF-16 encording in an ID3v2.3 tag written by MusicBrainz Picard + /// 2.12.3. + #[test] + fn test_ipls_id3v23_utf16() { + let tag = Tag::read_from_path("testdata/picard-2.12.3-id3v23-utf16.id3").unwrap(); + assert_eq!(tag.version(), Version::Id3v23); + let count = tag.involved_people_lists().count(); + assert_eq!(count, 1); + let ipls = tag.get("IPLS").unwrap(); + let involved_people = ipls + .content() + .involved_people_list() + .unwrap() + .items + .iter() + .map(|item| (item.involvement.as_str(), item.involvee.as_str())) + .collect::>(); + assert_eq!( + &involved_people, + &[ + ("double bass", "Israel Crosby"), + ("drums (drum set)", "Vernell Fournier"), + ("piano", "Ahmad Jamal"), + ("producer", "Dave Usher") + ] + ); + + // Now write the tag. Then check if it can be parsed and results in the same input. + let mut buffer = Vec::new(); + tag.write_to(&mut buffer, Version::Id3v23).unwrap(); + let new_tag = Tag::read_from2(io::Cursor::new(&buffer)).unwrap(); + + let new_involved_people = new_tag + .get("IPLS") + .unwrap() + .content() + .involved_people_list() + .unwrap() + .items + .iter() + .map(|item| (item.involvement.as_str(), item.involvee.as_str())) + .collect::>(); + assert_eq!(&involved_people, &new_involved_people,); + } + + /// Read `TIPL` and `TMCL` frames with UTF-8 encording in an ID3v2.4 tag written by MusicBrainz + /// Picard 2.12.3. + #[test] + fn test_ipls_id3v24_utf8() { + let tag = Tag::read_from_path("testdata/picard-2.12.3-id3v24-utf8.id3").unwrap(); + assert_eq!(tag.version(), Version::Id3v24); + let count = tag.involved_people_lists().count(); + assert_eq!(count, 2); + + let tipl = tag.get("TIPL").unwrap(); + let involved_people = tipl + .content() + .involved_people_list() + .unwrap() + .items + .iter() + .map(|item| (item.involvement.as_str(), item.involvee.as_str())) + .collect::>(); + assert_eq!(&involved_people, &[("producer", "Dave Usher")]); + + let tmcl = tag.get("TMCL").unwrap(); + let musician_credits = tmcl + .content() + .involved_people_list() + .unwrap() + .items + .iter() + .map(|item| (item.involvement.as_str(), item.involvee.as_str())) + .collect::>(); + assert_eq!( + &musician_credits, + &[ + ("double bass", "Israel Crosby"), + ("drums (drum set)", "Vernell Fournier"), + ("piano", "Ahmad Jamal") + ] + ); + + // Now write the tag. Then check if it can be parsed and results in the same input. + let mut buffer = Vec::new(); + tag.write_to(&mut buffer, Version::Id3v24).unwrap(); + let new_tag = Tag::read_from2(io::Cursor::new(&buffer)).unwrap(); + + let new_involved_people = new_tag + .get("TIPL") + .unwrap() + .content() + .involved_people_list() + .unwrap() + .items + .iter() + .map(|item| (item.involvement.as_str(), item.involvee.as_str())) + .collect::>(); + assert_eq!(&involved_people, &new_involved_people,); + + let new_musician_credits = new_tag + .get("TMCL") + .unwrap() + .content() + .involved_people_list() + .unwrap() + .items + .iter() + .map(|item| (item.involvement.as_str(), item.involvee.as_str())) + .collect::>(); + assert_eq!(&musician_credits, &new_musician_credits,); + } } diff --git a/testdata/picard-2.12.3-id3v23-utf16.id3 b/testdata/picard-2.12.3-id3v23-utf16.id3 new file mode 100644 index 000000000..525392e4d Binary files /dev/null and b/testdata/picard-2.12.3-id3v23-utf16.id3 differ diff --git a/testdata/picard-2.12.3-id3v24-utf8.id3 b/testdata/picard-2.12.3-id3v24-utf8.id3 new file mode 100644 index 000000000..1beecdffa Binary files /dev/null and b/testdata/picard-2.12.3-id3v24-utf8.id3 differ