Skip to content

Commit

Permalink
feat: Add support for IPLS (v2.3) and TIPL/TMCL (v2.4) frames
Browse files Browse the repository at this point in the history
  • Loading branch information
Holzhaus committed Nov 12, 2024
1 parent d3f4ac4 commit 1b767e8
Show file tree
Hide file tree
Showing 3 changed files with 237 additions and 8 deletions.
63 changes: 63 additions & 0 deletions src/frame/content.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
}
}
Expand Down Expand Up @@ -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]> {
Expand Down Expand Up @@ -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),
}
}
Expand Down Expand Up @@ -850,6 +876,43 @@ impl From<UniqueFileIdentifier> 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<InvolvedPeopleListItem>,
}

/// 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<InvolvedPeopleList> 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 {
Expand Down
10 changes: 6 additions & 4 deletions src/frame/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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(()),
Expand All @@ -105,6 +106,7 @@ impl Frame {
Content::Private(_) => "PrivateFrame",
Content::TableOfContents(_) => "TableOfContents",
Content::UniqueFileIdentifier(_) => "UFID",
Content::InvolvedPeopleList(_) => "InvolvedPeopleList",
Content::Unknown(_) => "Unknown",
};
Err(Error::new(
Expand Down
172 changes: 168 additions & 4 deletions src/stream/frame/content.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -306,6 +306,17 @@ impl<W: io::Write> Encoder<W> {
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)?;
Expand Down Expand Up @@ -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)?,
};

Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -513,6 +526,62 @@ impl<'a> Decoder<'a> {
Ok(Content::Text(text))
}

fn involved_people_list(mut self) -> crate::Result<Content> {
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::<crate::Result<Vec<InvolvedPeopleListItem>>>()?;

Ok(Content::InvolvedPeopleList(InvolvedPeopleList { items }))
}

fn link_content(self) -> crate::Result<Content> {
Ok(Content::Link(String::from_utf8(self.r.to_vec())?))
}
Expand Down Expand Up @@ -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 {
Expand Down

0 comments on commit 1b767e8

Please sign in to comment.