From 6db6e6f325673178ec99a9ed8ecb1559836f349c Mon Sep 17 00:00:00 2001 From: Tom Almeida Date: Sat, 8 Oct 2022 11:47:30 +0800 Subject: [PATCH] meta: Add support for id3v2 CHAP and CTOC frames These were standardised in https://id3.org/id3v2-chapters-1.0. Something I initially considered (and did) was to use `Cue`s and `CuePoint`s instead of creating new types, but decided against it because: - `Cue` is tied to the format, whereas these are tied to the metadata. - According to the spec, chapters can overlap, and so must have a duration. - It is unclear to me what the index should be. - `CuePoint`s cannot have another `CuePoint` within them, which may be an issue for multi-level tables of content. - Chapters and tables of contents would end up mixed as `Cue`s despite being different types. Resolves: #16 --- CONTRIBUTORS | 3 +- symphonia-core/src/meta.rs | 74 +++++++++- symphonia-metadata/src/id3v2/frames.rs | 55 +++++++- symphonia-metadata/src/id3v2/mod.rs | 185 ++++++++++++++++++++++++- 4 files changed, 306 insertions(+), 11 deletions(-) diff --git a/CONTRIBUTORS b/CONTRIBUTORS index 1160292d..665b889d 100644 --- a/CONTRIBUTORS +++ b/CONTRIBUTORS @@ -23,4 +23,5 @@ FelixMcFelix [https://github.com/FelixMcFelix] Herohtar [https://github.com/herohtar] nicholaswyoung [https://github.com/nicholaswyoung] richardmitic [https://github.com/richardmitic] -terrorfisch [https://github.com/terrorfisch] \ No newline at end of file +terrorfisch [https://github.com/terrorfisch] +Tommoa [https://github.com/Tommoa] diff --git a/symphonia-core/src/meta.rs b/symphonia-core/src/meta.rs index b1594a5c..8efd90e1 100644 --- a/symphonia-core/src/meta.rs +++ b/symphonia-core/src/meta.rs @@ -104,6 +104,7 @@ pub enum StandardTagKey { Arranger, Artist, Bpm, + Chapter, Comment, Compilation, Composer, @@ -186,6 +187,7 @@ pub enum StandardTagKey { SortArtist, SortComposer, SortTrackTitle, + TableOfContents, TaggingDate, TrackNumber, TrackSubtitle, @@ -331,6 +333,48 @@ impl fmt::Display for Tag { } } +/// A description of a single chapter within an audio file. +#[derive(Clone, Debug, Default)] +pub struct Chapter { + /// The millisecond offset from the beginning of the file to the start of the chapter. + pub start_ms: u32, + /// The millisecond offset from the beginning of the file to the end of the chapter. + pub end_ms: u32, + /// The zero-based count of bytes from the beginning of the file to the first byte of the first + /// audio frame in the chapter. If these bytes are all set to 0xFF then the value should be + /// ignored and the start time value should be utilized. + pub start_byte: u32, + /// The zero-based count of bytes from the beginning of the file to the first byte of the audio + /// frame following the end of the chapter. If these bytes are all set to 0xFF then the value + /// should be ignored and the end time value should be utilized. + pub end_byte: u32, + /// The tags associated with the chapter. + pub tags: Vec, +} + +/// An item that belongs to a `TableOfContents`. +#[derive(Clone, Debug)] +pub enum TableOfContentsItem { + /// A `Chapter`. + Chapter(Chapter), + /// A sub-`TableOfContents`. + TableOfContents(TableOfContents), +} + +/// `TableOfContents` describes different sections and chapters of an audio stream. +#[derive(Clone, Debug, Default)] +pub struct TableOfContents { + /// `ordered` should be set to `true` if the entries in the `items` list are ordered or set to + /// `false` if they not are ordered. This provides a hint as to whether the entries should be + /// played as a continuous ordered sequence or played individually. + pub ordered: bool, + /// The items that belong to this `TableOfContents`. They may be either a `Chapter` or a + /// sub-`TableOfContents`. + pub items: Vec, + /// The tags associated with this `TableOfContents`. + pub tags: Vec, +} + /// A 2 dimensional (width and height) size type. #[derive(Copy, Clone, Debug, Default)] pub struct Size { @@ -392,6 +436,8 @@ pub struct VendorData { #[derive(Clone, Debug, Default)] pub struct MetadataRevision { tags: Vec, + table_of_contents: Option, + extra_chapters: Vec, visuals: Vec, vendor_data: Vec, } @@ -402,6 +448,19 @@ impl MetadataRevision { &self.tags } + /// Gets an immutable reference to the base `TableOfContents` in this revision. + /// If there is more than one base `TableOfContents`, this returns the one that was most + /// recently parsed. + pub fn table_of_contents(&self) -> Option<&TableOfContents> { + self.table_of_contents.as_ref() + } + + /// Gets an immutable slice to the `Chapters`s that are not related to any `TableOfContents` in + /// this revision. + pub fn extra_chapters(&self) -> &[Chapter] { + &self.extra_chapters + } + /// Gets an immutable slice to the `Visual`s in this revision. pub fn visuals(&self) -> &[Visual] { &self.visuals @@ -431,6 +490,18 @@ impl MetadataBuilder { self } + /// Add a `Chapter` to the metadata. + pub fn add_chapter(&mut self, chapter: Chapter) -> &mut Self { + self.metadata.extra_chapters.push(chapter); + self + } + + /// Add a `TableOfContents` to the metadata. + pub fn add_table_of_contents(&mut self, toc: TableOfContents) -> &mut Self { + self.metadata.table_of_contents = Some(toc); + self + } + /// Add a `Visual` to the metadata. pub fn add_visual(&mut self, visual: Visual) -> &mut Self { self.metadata.visuals.push(visual); @@ -483,8 +554,7 @@ impl<'a> Metadata<'a> { pub fn pop(&mut self) -> Option { if self.revisions.len() > 1 { self.revisions.pop_front() - } - else { + } else { None } } diff --git a/symphonia-metadata/src/id3v2/frames.rs b/symphonia-metadata/src/id3v2/frames.rs index 0ffbfc2c..1ae6eb00 100644 --- a/symphonia-metadata/src/id3v2/frames.rs +++ b/symphonia-metadata/src/id3v2/frames.rs @@ -36,8 +36,10 @@ use super::util; // CRM Encrypted meta frame // x PIC APIC Attached picture // ASPI Audio seek point index +// x CHAP Chapter Chapter frame // x COM COMM Comment Comments // COMR Commercial frame +// x CTOC TableOfContents Chapter table-of-contents frame // ENCR Encryption method registration // EQU EQUA Equalisation // EQU2 Equalisation (2) @@ -255,8 +257,10 @@ lazy_static! { // m.insert(b"AENC", read_null_frame); m.insert(b"APIC", (read_apic_frame as FrameParser, None)); // m.insert(b"ASPI", read_null_frame); + m.insert(b"CHAP", (read_chap_frame, Some(StandardTagKey::Chapter))); m.insert(b"COMM", (read_comm_uslt_frame, Some(StandardTagKey::Comment))); // m.insert(b"COMR", read_null_frame); + m.insert(b"CTOC", (read_ctoc_frame, Some(StandardTagKey::TableOfContents))); // m.insert(b"ENCR", read_null_frame); // m.insert(b"EQU2", read_null_frame); // m.insert(b"EQUA", read_null_frame); @@ -657,8 +661,7 @@ fn read_text_frame( let text = scan_text(reader, encoding, len)?; tags.push(Tag::new(std_key, id, Value::from(text))); - } - else { + } else { break; } } @@ -699,8 +702,7 @@ fn read_txxx_frame( if len > 0 { let text = scan_text(reader, encoding, len)?; tags.push(Tag::new(std_key, &key, Value::from(text))); - } - else { + } else { break; } } @@ -765,6 +767,48 @@ fn read_priv_frame( Ok(FrameResult::Tag(tag)) } +/// Reads a `CHAP` (chapter) +fn read_chap_frame( + reader: &mut BufReader<'_>, + std_key: Option, + _id: &str, +) -> Result { + // Scan for the element_id + let element_id = format!( + "CHAP:{}", + scan_text(reader, Encoding::Iso8859_1, reader.bytes_available() as usize)? + ); + + // There is other useful chapter data, but we parse it elsewhere + let data_buf = reader.read_buf_bytes_ref(reader.bytes_available() as usize)?; + + // Create a Tag. + let tag = Tag::new(std_key, &element_id, Value::from(data_buf)); + + Ok(FrameResult::Tag(tag)) +} + +/// Reads a `CTOC` (table of contents) +fn read_ctoc_frame( + reader: &mut BufReader<'_>, + std_key: Option, + _id: &str, +) -> Result { + // Scan for the element_id + let element_id = format!( + "CTOC:{}", + scan_text(reader, Encoding::Iso8859_1, reader.bytes_available() as usize)? + ); + + // There is other useful table of contents data, but we parse it elsewhere + let data_buf = reader.read_buf_bytes_ref(reader.bytes_available() as usize)?; + + // Create a Tag. + let tag = Tag::new(std_key, &element_id, Value::from(data_buf)); + + Ok(FrameResult::Tag(tag)) +} + /// Reads a `COMM` (comment) or `USLT` (unsynchronized comment) frame. fn read_comm_uslt_frame( reader: &mut BufReader<'_>, @@ -785,8 +829,7 @@ fn read_comm_uslt_frame( // an error would break far too many files to be worth it. let key = if validate_lang_code(lang) { format!("{}!{}", id, as_ascii_str(&lang)) - } - else { + } else { id.to_string() }; diff --git a/symphonia-metadata/src/id3v2/mod.rs b/symphonia-metadata/src/id3v2/mod.rs index 23e8ebaf..f923107c 100644 --- a/symphonia-metadata/src/id3v2/mod.rs +++ b/symphonia-metadata/src/id3v2/mod.rs @@ -7,9 +7,14 @@ //! An ID3v2 metadata reader. +use std::collections::HashMap; + use symphonia_core::errors::{decode_error, unsupported_error, Result}; use symphonia_core::io::*; -use symphonia_core::meta::{MetadataBuilder, MetadataOptions, MetadataReader, MetadataRevision}; +use symphonia_core::meta::{ + Chapter, MetadataBuilder, MetadataOptions, MetadataReader, MetadataRevision, StandardTagKey, + TableOfContents, TableOfContentsItem, Value, +}; use symphonia_core::probe::{Descriptor, Instantiate, QueryDescriptor}; use symphonia_core::support_metadata; @@ -293,6 +298,10 @@ fn read_id3v2_body( _ => unreachable!(), }; + let mut chapters = HashMap::new(); + let mut tables_of_content = HashMap::new(); + let mut table_of_content = None; + loop { // Read frames based on the major version of the tag. let frame = match header.major_version { @@ -307,7 +316,104 @@ fn read_id3v2_body( FrameResult::Padding => break, // A frame was parsed into a tag, add it to the tag collection. FrameResult::Tag(tag) => { - metadata.add_tag(tag); + match tag.std_key { + Some(StandardTagKey::TableOfContents) => { + info!("Getting table of contents {}", tag.key); + if let Value::Binary(value) = tag.value { + let mut reader = BufReader::new(value.as_ref()); + let reader: &mut BufReader<'_> = &mut reader; + // the flags + // - bit 0 is the "ordered" bit + // - bit 1 is the "top-level" bit + let flags = reader.read_u8()?; + // The number of items in this table of contents + let entry_count = reader.read_u8()?; + + let mut items = vec![]; + for _ in 0..entry_count { + let data = reader.scan_bytes_aligned_ref( + &[0x00], + 1, + reader.bytes_available() as usize, + )?; + let name: String = data + .iter() + .filter(|&b| *b > 0x1f) + .map(|&b| b as char) + .collect(); + items.push(name); + } + + let mut tags = vec![]; + while reader.bytes_available() > min_frame_size { + let frame = match header.major_version { + 2 => read_id3v2p2_frame(reader), + 3 => read_id3v2p3_frame(reader), + 4 => read_id3v2p4_frame(reader), + _ => break, + }?; + match frame { + FrameResult::MultipleTags(tag_list) => { + tags.extend(tag_list.into_iter()) + } + FrameResult::Tag(tag) => tags.push(tag), + _ => {} + } + } + let toc = ( + TableOfContents { items: vec![], ordered: flags & 1 == 1, tags }, + items, + ); + if (flags >> 1) & 1 == 1 { + table_of_content = Some(toc); + } else { + tables_of_content.insert(tag.key[5..].to_string(), toc); + } + } else { + unreachable!() + } + } + Some(StandardTagKey::Chapter) => { + info!("Getting chapter {}", tag.key); + if let Value::Binary(value) = tag.value { + let mut reader = BufReader::new(value.as_ref()); + // start_time in ms + let start_ms = reader.read_be_u32()?; + // end_time in ms + let end_ms = reader.read_be_u32()?; + // start_byte + let start_byte = reader.read_be_u32()?; + // end_byte + let end_byte = reader.read_be_u32()?; + + let mut tags = vec![]; + while reader.bytes_available() > min_frame_size { + let frame = match header.major_version { + 2 => read_id3v2p2_frame(&mut reader), + 3 => read_id3v2p3_frame(&mut reader), + 4 => read_id3v2p4_frame(&mut reader), + _ => break, + }?; + match frame { + FrameResult::MultipleTags(tag_list) => { + tags.extend(tag_list.into_iter()) + } + FrameResult::Tag(tag) => tags.push(tag), + _ => {} + } + } + chapters.insert( + tag.key[5..].to_string(), + Chapter { start_ms, end_ms, start_byte, end_byte, tags }, + ); + } else { + unreachable!() + } + } + _ => { + metadata.add_tag(tag); + } + }; } // A frame was parsed into multiple tags, add them all to the tag collection. FrameResult::MultipleTags(multi_tags) => { @@ -335,6 +441,81 @@ fn read_id3v2_body( } } + // We need to fill out the table of contents items + // Do a depth-first search of the table of contents, rooted at the base table of contents + fn dfs_table_items( + items: &Vec, + target: &mut Vec, + tables_of_content: &mut HashMap)>, + chapters: &mut HashMap, + ) -> Vec { + // According to https://id3.org/id3v2-chapters-1.0#Notes there may be chapters that don't + // have a related table of contents. + // We can determine which chapters are not in any table of contents by keeping track of + // those that _are_ in any given table of contents and removing it from the total list of + // chapters later. + let mut to_remove = vec![]; + for item in items { + info!("Looking for item {}", item); + if let Some(chapter) = chapters.get(item) { + to_remove.push(item.clone()); + target.push(TableOfContentsItem::Chapter(chapter.clone())); + continue; + } + // We remove to prevent loops in tables of content + // As an example of something that we don't want to happen: + // Toc1(root): + // - Chap1 + // - Toc2 + // - Toc3 + // Toc2: + // - Chap2 + // - Toc3 + // - Chap3 + // Toc3: + // - Chap5 + // - Toc2 + // - Chap4 + // There's nothing in the spec itself that says that such a layout isn't valid, but the + // issue of parsing becomes much more difficult, and we'd have to move to using `Arc` + // or `Rc` instead of a vector. + // + // With removal, the above tree becomes the following, as duplicate tocs are removed: + // Toc1(root): + // - Chap1 + // - Toc2 + // Toc2: + // - Chap2 + // - Toc3 + // - Chap3 + // Toc3: + // - Chap5 + // - Chap4 + if let Some((mut toc, sub_items)) = tables_of_content.remove(item) { + to_remove.extend(dfs_table_items( + &sub_items, + &mut toc.items, + tables_of_content, + chapters, + )); + target.push(TableOfContentsItem::TableOfContents(toc)); + continue; + } + } + to_remove + } + if let Some((mut toc, items)) = table_of_content { + let to_remove = + dfs_table_items(&items, &mut toc.items, &mut tables_of_content, &mut chapters); + metadata.add_table_of_contents(toc); + for chap in to_remove.into_iter() { + chapters.remove(&chap); + } + } + for chapter in chapters.into_values() { + metadata.add_chapter(chapter); + } + Ok(()) }