Skip to content

Commit

Permalink
feat: Added support for Table of contents frame (CTOC) (#116)
Browse files Browse the repository at this point in the history
* Added CTOC struct
* CTOC encode/decode implementation
* feat: Added support for Table of contents frame (CTOC)
  • Loading branch information
newfla authored Oct 24, 2023
1 parent cf212db commit abbca3c
Show file tree
Hide file tree
Showing 6 changed files with 215 additions and 5 deletions.
60 changes: 60 additions & 0 deletions src/frame/content.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ pub enum Content {
MpegLocationLookupTable(MpegLocationLookupTable),
/// A private frame (PRIV)
Private(Private),
/// A value containing the parsed contents of a table of contents frame (CTOC).
TableOfContents(TableOfContents),
/// 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 @@ -104,6 +106,9 @@ impl Content {
Cow::Borrowed(private.owner_identifier.as_bytes()),
Cow::Borrowed(private.private_data.as_slice()),
]),
Self::TableOfContents(table_of_contents) => {
Comparable(vec![Cow::Borrowed(table_of_contents.element_id.as_bytes())])
}
Self::Unknown(_) => Incomparable,
}
}
Expand Down Expand Up @@ -235,6 +240,14 @@ impl Content {
}
}

/// Returns the `TableOfContents` or None if the value is not `TableOfContents`.
pub fn table_of_contents(&self) -> Option<&TableOfContents> {
match self {
Content::TableOfContents(table_of_contents) => Some(table_of_contents),
_ => 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 @@ -277,6 +290,7 @@ impl fmt::Display for Content {
Content::Chapter(chapter) => write!(f, "{}", chapter),
Content::MpegLocationLookupTable(mpeg_table) => write!(f, "{}", mpeg_table),
Content::Private(private) => write!(f, "{}", private),
Content::TableOfContents(table_of_contents) => write!(f, "{}", table_of_contents),
Content::Unknown(unknown) => write!(f, "{}", unknown),
}
}
Expand Down Expand Up @@ -792,6 +806,52 @@ impl From<Private> for Frame {
}
}

#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
#[allow(missing_docs)]
pub struct TableOfContents {
pub element_id: String,
pub top_level: bool,
pub ordered: bool,
pub elements: Vec<String>,
pub frames: Vec<Frame>,
}

impl fmt::Display for TableOfContents {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let frames: Vec<&str> = self.frames.iter().map(|f| f.id()).collect();
write!(
f,
"isTopLevel:{top_level}, isOrdered:{ordered}, childList: []: {elements}, frames:{frames}",
top_level = self.top_level,
ordered = self.ordered,
elements = self.elements.join(", "),
frames = frames.join(", "),
)
}
}

impl Extend<Frame> for TableOfContents {
fn extend<I: IntoIterator<Item = Frame>>(&mut self, iter: I) {
self.frames.extend(iter)
}
}

impl TagLike for TableOfContents {
fn frames_vec(&self) -> &Vec<Frame> {
&self.frames
}

fn frames_vec_mut(&mut self) -> &mut Vec<Frame> {
&mut self.frames
}
}

impl From<TableOfContents> for Frame {
fn from(c: TableOfContents) -> Self {
Self::with_content("CTOC", Content::TableOfContents(c))
}
}

/// The contents of a frame for which no decoder is currently implemented.
#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
pub struct Unknown {
Expand Down
4 changes: 3 additions & 1 deletion src/frame/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use std::str;
pub use self::content::{
Chapter, Comment, Content, EncapsulatedObject, ExtendedLink, ExtendedText, Lyrics,
MpegLocationLookupTable, MpegLocationLookupTableReference, Picture, PictureType, Popularimeter,
Private, SynchronisedLyrics, SynchronisedLyricsType, TimestampFormat, Unknown,
Private, SynchronisedLyrics, SynchronisedLyricsType, TableOfContents, TimestampFormat, Unknown,
};
pub use self::timestamp::Timestamp;

Expand Down Expand Up @@ -84,6 +84,7 @@ impl Frame {
("CHAP", Content::Chapter(_)) => Ok(()),
("MLLT", Content::MpegLocationLookupTable(_)) => Ok(()),
("PRIV", Content::Private(_)) => Ok(()),
("CTOC", Content::TableOfContents(_)) => Ok(()),
(_, Content::Unknown(_)) => Ok(()),
(id, content) => {
let content_kind = match content {
Expand All @@ -100,6 +101,7 @@ impl Frame {
Content::Chapter(_) => "Chapter",
Content::MpegLocationLookupTable(_) => "MpegLocationLookupTable",
Content::Private(_) => "PrivateFrame",
Content::TableOfContents(_) => "TableOfContents",
Content::Unknown(_) => "Unknown",
};
Err(Error::new(
Expand Down
51 changes: 50 additions & 1 deletion src/stream/frame/content.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use crate::frame::{
Chapter, Comment, Content, EncapsulatedObject, ExtendedLink, ExtendedText, Lyrics,
MpegLocationLookupTable, MpegLocationLookupTableReference, Picture, PictureType, Popularimeter,
Private, SynchronisedLyrics, SynchronisedLyricsType, TimestampFormat, Unknown,
Private, SynchronisedLyrics, SynchronisedLyricsType, TableOfContents, TimestampFormat, Unknown,
};
use crate::stream::encoding::Encoding;
use crate::stream::frame;
Expand Down Expand Up @@ -293,6 +293,31 @@ impl<W: io::Write> Encoder<W> {
self.bytes(content.private_data.as_slice())?;
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)?;
let top_level_flag = match content.top_level {
true => 2,
false => 0,
};

let ordered_flag = match content.ordered {
true => 1,
false => 0,
};
self.byte(top_level_flag | ordered_flag)?;
self.byte(content.elements.len() as u8)?;

for element in &content.elements {
self.string_with_other_encoding(Encoding::Latin1, element)?;
self.byte(0)?;
}
for frame in &content.frames {
frame::encode(&mut self.w, frame, self.version, false)?;
}
Ok(())
}
}

pub fn encode(
Expand Down Expand Up @@ -322,6 +347,7 @@ pub fn encode(
Content::Chapter(c) => encoder.chapter_content(c)?,
Content::MpegLocationLookupTable(c) => encoder.mpeg_location_lookup_table_content(c)?,
Content::Private(c) => encoder.private_content(c)?,
Content::TableOfContents(c) => encoder.table_of_contents_content(c)?,
Content::Unknown(c) => encoder.bytes(&c.data)?,
};

Expand Down Expand Up @@ -378,6 +404,7 @@ pub fn decode(
"CHAP" => decoder.chapter_content(),
"MLLT" => decoder.mpeg_location_lookup_table_content(),
"PRIV" => decoder.private_content(),
"CTOC" => decoder.table_of_contents_content(),
_ => Ok(Content::Unknown(Unknown { data, version })),
}?;
Ok((content, encoding))
Expand Down Expand Up @@ -777,6 +804,28 @@ impl<'a> Decoder<'a> {
private_data,
}))
}
fn table_of_contents_content(mut self) -> crate::Result<Content> {
let element_id = self.string_delimited(Encoding::Latin1)?;
let flags = self.byte()?;
let top_level = matches!(!!(flags & 2), 2);
let ordered = matches!(!!(flags & 1), 1);
let element_count = self.byte()?;
let mut elements = Vec::new();
for _ in 0..element_count {
elements.push(self.string_delimited(Encoding::Latin1)?);
}
let mut frames = Vec::new();
while let Some((_advance, frame)) = frame::decode(&mut self.r, self.version)? {
frames.push(frame);
}
Ok(Content::TableOfContents(TableOfContents {
element_id,
top_level,
ordered,
elements,
frames,
}))
}
}

/// Returns the index of the first delimiter for the specified encoding.
Expand Down
31 changes: 30 additions & 1 deletion src/stream/tag.rs
Original file line number Diff line number Diff line change
Expand Up @@ -470,7 +470,7 @@ mod tests {
use crate::frame::{
Chapter, Content, EncapsulatedObject, Frame, MpegLocationLookupTable,
MpegLocationLookupTableReference, Picture, PictureType, Popularimeter, SynchronisedLyrics,
SynchronisedLyricsType, TimestampFormat, Unknown,
SynchronisedLyricsType, TableOfContents, TimestampFormat, Unknown,
};
use std::fs;
use std::io::{self, Read};
Expand Down Expand Up @@ -531,6 +531,13 @@ mod tests {
Frame::with_content("TCON", Content::Text("Baz".to_string())),
],
});
tag.add_frame(TableOfContents {
element_id: "table01".to_string(),
top_level: true,
ordered: true,
elements: vec!["01".to_string()],
frames: Vec::new(),
});
tag.add_frame(MpegLocationLookupTable {
frames_between_reference: 1,
bytes_between_reference: 418,
Expand Down Expand Up @@ -695,6 +702,28 @@ mod tests {
);
}

#[test]
fn read_id3v23_ctoc() {
let mut file = fs::File::open("testdata/id3v23_chap.id3").unwrap();
let tag = decode(&mut file).unwrap();
assert_eq!(tag.tables_of_contents().count(), 1);

for x in tag.tables_of_contents() {
println!("{:?}", x);
}

let ctoc = tag.tables_of_contents().last().unwrap();

assert_eq!(ctoc.element_id, "toc");
assert!(ctoc.top_level);
assert!(ctoc.ordered);
assert_eq!(
ctoc.elements,
&["chp0", "chp1", "chp2", "chp3", "chp4", "chp5", "chp6"]
);
assert!(ctoc.frames.is_empty());
}

#[test]
fn read_id3v24() {
let mut file = fs::File::open("testdata/id3v24.id3").unwrap();
Expand Down
39 changes: 38 additions & 1 deletion src/tag.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use crate::chunk;
use crate::frame::{
Chapter, Comment, EncapsulatedObject, ExtendedLink, ExtendedText, Frame, Lyrics, Picture,
SynchronisedLyrics,
SynchronisedLyrics, TableOfContents,
};
use crate::storage::{PlainStorage, Storage};
use crate::stream;
Expand Down Expand Up @@ -385,6 +385,43 @@ impl<'a> Tag {
pub fn chapters(&self) -> impl Iterator<Item = &Chapter> {
self.frames().filter_map(|frame| frame.content().chapter())
}

/// Returns an iterator over all tables of contents (CTOC) in the tag.
///
/// # Example
/// ```
/// use id3::{Tag, TagLike};
/// use id3::frame::{Chapter, TableOfContents, Content, Frame};
///
/// let mut tag = Tag::new();
/// tag.add_frame(Chapter{
/// element_id: "chap01".to_string(),
/// start_time: 1000,
/// end_time: 2000,
/// start_offset: 0xff,
/// end_offset: 0xff,
/// frames: Vec::new(),
/// });
/// tag.add_frame(TableOfContents{
/// element_id: "internalTable01".to_string(),
/// top_level: false,
/// ordered: false,
/// elements: Vec::new(),
/// frames: Vec::new(),
/// });
/// tag.add_frame(TableOfContents{
/// element_id: "01".to_string(),
/// top_level: true,
/// ordered: true,
/// elements: vec!["internalTable01".to_string(),"chap01".to_string()],
/// frames: Vec::new(),
/// });
/// assert_eq!(2, tag.tables_of_contents().count());
/// ```
pub fn tables_of_contents(&self) -> impl Iterator<Item = &TableOfContents> {
self.frames()
.filter_map(|frame| frame.content().table_of_contents())
}
}

impl PartialEq for Tag {
Expand Down
35 changes: 34 additions & 1 deletion src/taglike.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1391,7 +1391,7 @@ pub trait TagLike: private::Sealed {
self.remove("SYLT");
}

/// Adds a single chapter (CHAP) to the farme.
/// /// Removes all chapters (CHAP) frames from the tag.
///
/// # Example
/// ```
Expand All @@ -1414,15 +1414,48 @@ pub trait TagLike: private::Sealed {
fn remove_all_chapters(&mut self) {
self.remove("CHAP");
}

/// /// Removes all tables of contents (CTOC) frames from the tag.
///
/// # Example
/// ```
/// use id3::{Tag, TagLike};
/// use id3::frame::{Chapter, TableOfContents, Content, Frame};
///
/// let mut tag = Tag::new();
/// tag.add_frame(Chapter{
/// element_id: "chap01".to_string(),
/// start_time: 1000,
/// end_time: 2000,
/// start_offset: 0xff,
/// end_offset: 0xff,
/// frames: Vec::new(),
/// });
/// tag.add_frame(TableOfContents{
/// element_id: "01".to_string(),
/// top_level: true,
/// ordered: true,
/// elements: vec!["chap01".to_string()],
/// frames: Vec::new(),
/// });
/// assert_eq!(1, tag.tables_of_contents().count());
/// tag.remove_all_tables_of_contents();
/// assert_eq!(0, tag.tables_of_contents().count());
/// ```
fn remove_all_tables_of_contents(&mut self) {
self.remove("CTOC");
}
}

// https://rust-lang.github.io/api-guidelines/future-proofing.html#c-sealed
mod private {
use crate::frame::Chapter;
use crate::frame::TableOfContents;
use crate::tag::Tag;

pub trait Sealed {}

impl Sealed for Tag {}
impl Sealed for Chapter {}
impl Sealed for TableOfContents {}
}

0 comments on commit abbca3c

Please sign in to comment.