diff --git a/lib/id3v2/FrameParser.ts b/lib/id3v2/FrameParser.ts index c74b2243f..ced302470 100644 --- a/lib/id3v2/FrameParser.ts +++ b/lib/id3v2/FrameParser.ts @@ -9,6 +9,8 @@ import type { IWarningCollector } from '../common/MetadataCollector.js'; import type { IComment, ILyricsTag } from '../type.js'; import { makeUnexpectedFileContentError } from '../ParseError.js'; import { decodeUintBE } from '../common/Util.js'; +import { ChapterInfo, type IChapterInfo } from './ID3v2ChapterToken.js'; +import { getFrameHeaderLength, readFrameHeader } from './FrameHeader.js'; const debug = initDebug('music-metadata:id3v2:frame-parser'); @@ -50,6 +52,24 @@ export interface IGeneralEncapsulatedObject { data: Uint8Array; } +export type Chapter = { + label: string; + info: IChapterInfo; + frames: Map, +} + +export type TableOfContents = { + label: string; + flags: { + /** If set, this is the top-level table of contents */ + topLevel: boolean; + /** If set, the child element IDs are in a defined order */ + ordered: boolean; + }; + childElementIds: string[]; + frames: Map; +} + const defaultEnc = 'latin1'; // latin1 == iso-8859-1; const urlEnc: ITextEncoding = {encoding: defaultEnc, bom: false}; @@ -156,6 +176,9 @@ export class FrameParser { case 'TRK': case 'TRCK': case 'TPOS': + case 'TIT1': + case 'TIT2': + case 'TIT3': output = text; break; case 'TCOM': @@ -394,6 +417,79 @@ export class FrameParser { break; } + // ID3v2 Chapters 1.0 + // https://mutagen-specs.readthedocs.io/en/latest/id3/id3v2-chapters-1.0.html#chapter-frame + case 'CHAP': { // // Chapter frame + debug("Reading CHAP"); + fzero = util.findZero(uint8Array, defaultEnc); + + const chapter: Chapter = { + label: util.decodeString(uint8Array.subarray(0, fzero), defaultEnc), + info: ChapterInfo.get(uint8Array, offset), + frames: new Map() + }; + offset += fzero + 1 + ChapterInfo.len; + + while (offset < length) { + const subFrame = readFrameHeader(uint8Array.subarray(offset), this.major, this.warningCollector); + const headerSize = getFrameHeaderLength(this.major); + offset += headerSize; + const subOutput = this.readData(uint8Array.subarray(offset, offset + subFrame.length), subFrame.id, includeCovers); + offset += subFrame.length; + + chapter.frames.set(subFrame.id, subOutput); + } + output = chapter; + break; + } + + // ID3v2 Chapters 1.0 + // https://mutagen-specs.readthedocs.io/en/latest/id3/id3v2-chapters-1.0.html#table-of-contents-frame + case 'CTOC': { // Table of contents frame + debug('Reading CTOC'); + + // Element ID (null-terminated latin1) + const idEnd = util.findZero(uint8Array, defaultEnc); + const label = util.decodeString(uint8Array.subarray(0, idEnd), defaultEnc); + offset = idEnd + 1; + + // Flags + const flags = uint8Array[offset++]; + const topLevel = (flags & 0x02) !== 0; + const ordered = (flags & 0x01) !== 0; + + // Child element IDs + const entryCount = uint8Array[offset++]; + const childElementIds: string[] = []; + for (let i = 0; i < entryCount && offset < length; i++) { + const end = util.findZero(uint8Array.subarray(offset), defaultEnc); + const childId = util.decodeString(uint8Array.subarray(offset, offset + end), defaultEnc); + childElementIds.push(childId); + offset += end + 1; + } + + const toc: TableOfContents = { + label, + flags: { topLevel, ordered }, + childElementIds, + frames: new Map() + }; + + // Optional embedded sub-frames (e.g. TIT2) follow after the child list + while (offset < length) { + const subFrame = readFrameHeader(uint8Array.subarray(offset), this.major, this.warningCollector); + const headerSize = getFrameHeaderLength(this.major); + offset += headerSize; + const subOutput = this.readData(uint8Array.subarray(offset, offset + subFrame.length), subFrame.id, includeCovers); + offset += subFrame.length; + + toc.frames.set(subFrame.id, subOutput); + } + + output = toc; + break; + } + default: debug(`Warning: unsupported id3v2-tag-type: ${type}`); break; diff --git a/lib/id3v2/ID3v2ChapterToken.ts b/lib/id3v2/ID3v2ChapterToken.ts new file mode 100644 index 000000000..0b7965878 --- /dev/null +++ b/lib/id3v2/ID3v2ChapterToken.ts @@ -0,0 +1,25 @@ +import type { IGetToken } from 'strtok3'; +import * as Token from 'token-types'; + +export interface IChapterInfo { + startTime: number; + endTime: number; + startOffset: number; + endOffset: number; +} + +/** + * Data portion of `CHAP` sub frame + */ +export const ChapterInfo: IGetToken = { + len: 16, + + get: (buf: Uint8Array, off: number): IChapterInfo => { + return { + startTime: Token.UINT32_BE.get(buf, off), + endTime: Token.UINT32_BE.get(buf, off + 4), + startOffset: Token.UINT32_BE.get(buf, off + 8), + endOffset: Token.UINT32_BE.get(buf, off + 12) + }; + } +}; diff --git a/lib/id3v2/ID3v2Parser.ts b/lib/id3v2/ID3v2Parser.ts index 2f09e9227..3116879d7 100644 --- a/lib/id3v2/ID3v2Parser.ts +++ b/lib/id3v2/ID3v2Parser.ts @@ -5,7 +5,7 @@ import type { TagType } from '../common/GenericTagTypes.js'; import { FrameParser, Id3v2ContentError, type ITextTag } from './FrameParser.js'; import { ExtendedHeader, ID3v2Header, type ID3v2MajorVersion, type IID3v2header } from './ID3v2Token.js'; -import type { ITag, IOptions, AnyTagValue } from '../type.js'; +import type { ITag, IOptions, AnyTagValue, IChapter } from '../type.js'; import type { INativeMetadataCollector, IWarningCollector } from '../common/MetadataCollector.js'; import { getFrameHeaderLength, readFrameHeader } from './FrameHeader.js'; @@ -101,7 +101,11 @@ export class ID3v2Parser { this.headerType = (`ID3v2.${id3Header.version.major}`) as TagType; - return id3Header.flags.isExtendedHeader ? this.parseExtendedHeader() : this.parseId3Data(id3Header.size); + await (id3Header.flags.isExtendedHeader ? this.parseExtendedHeader() : this.parseId3Data(id3Header.size)); + + // Post process + const chapters = ID3v2Parser.mapId3v2Chapters(this.metadata.native[this.headerType], this.metadata.format.sampleRate); + this.metadata.setFormat('chapters', chapters); } public async parseExtendedHeader(): Promise { @@ -168,6 +172,68 @@ export class ID3v2Parser { return tags; } + /** + * Convert parsed ID3v2 chapter frames (CHAP / CTOC) to generic `format.chapters`. + * + * This function expects the `native` tags already to contain parsed `CHAP` and `CTOC` frame values, + * as produced by `FrameParser.readData`. + */ + private static mapId3v2Chapters( + id3Tags: ITag[], + sampleRate?: number + ): IChapter[] | undefined { + + const chapFrames = id3Tags.filter(t => t.id === 'CHAP') as any[] | undefined; + if (!chapFrames?.length) return; + + const tocFrames = id3Tags.filter(t => t.id === 'CTOC') as any[] | undefined; + const topLevelToc = tocFrames?.find(t => t.value.flags?.topLevel); + + const chapterById = new Map(); + for (const chap of chapFrames) { + chapterById.set(chap.value.label, chap.value); + } + + const orderedIds: string[] | undefined = + topLevelToc?.value.childElementIds; + + const chapters: IChapter[] = []; + + const source = orderedIds ?? [...chapterById.keys()]; + + for (const id of source) { + const chap = chapterById.get(id); + if (!chap) continue; + + const frames = chap.frames; + const title = frames.get('TIT2'); + if (!title) continue; // title is required + const image = frames.get('APIC'); + + const start = chap.info.startTime; + const timeScale = 1000; + + const sampleOffset = sampleRate + ? Math.round((start / timeScale) * sampleRate) + : 0; + + chapters.push({ + id, + title, + start, + timeScale, + sampleOffset, + image + }); + } + + // If no ordered CTOC, sort by time + if (!orderedIds) { + chapters.sort((a, b) => a.start - b.start); + } + + return chapters.length ? chapters : undefined; + } } function makeUnexpectedMajorVersionError(majorVer: number): never { diff --git a/lib/id3v2/ID3v2Token.ts b/lib/id3v2/ID3v2Token.ts index 1e0b8136e..6fa20df18 100644 --- a/lib/id3v2/ID3v2Token.ts +++ b/lib/id3v2/ID3v2Token.ts @@ -68,7 +68,7 @@ export type TimestampFormat = typeof TimestampFormat[keyof typeof TimestampForma * 28 bits (representing up to 256MB) integer, the msb is 0 to avoid 'false syncsignals'. * 4 * %0xxxxxxx */ -export const UINT32SYNCSAFE = { +export const UINT32SYNCSAFE: IGetToken = { get: (buf: Uint8Array, off: number): number => { return buf[off + 3] & 0x7f | ((buf[off + 2]) << 7) | ((buf[off + 1]) << 14) | ((buf[off]) << 21); diff --git a/lib/type.ts b/lib/type.ts index 97fe9e329..8954025a1 100644 --- a/lib/type.ts +++ b/lib/type.ts @@ -543,10 +543,17 @@ export interface ITag { } export interface IChapter { + + /** + * Internal chapter reference + */ + id?: string; + /** * Chapter title */ title: string; + /** * Audio offset in sample number, 0 is the first sample. * Duration offset is sampleOffset / format.sampleRate @@ -558,9 +565,14 @@ export interface IChapter { */ start: number; /** - * Time value that indicates the time scale for chapter tracks, the number of time units that pass per second in its time coordinate system. + * Time value that indicates the timescale for chapter tracks, the number of time units that pass per second in its time coordinate system. */ timeScale: number; + + /** + * Picture + */ + image?: IPicture; } /** diff --git a/test/samples/mp3/chapters.mp3 b/test/samples/mp3/chapters.mp3 new file mode 100644 index 000000000..01e3c0355 Binary files /dev/null and b/test/samples/mp3/chapters.mp3 differ diff --git a/test/test-file-mp3.ts b/test/test-file-mp3.ts index c69dcef30..88d3f8b16 100644 --- a/test/test-file-mp3.ts +++ b/test/test-file-mp3.ts @@ -377,4 +377,21 @@ describe('Parse MP3 files', () => { }); }); + it('should parse id3v2 Chapters', async () => { + const filePath = path.join(mp3SamplePath, 'chapters.mp3'); + + const {format, common} = await mm.parseFile(filePath, {includeChapters: true}); + + assert.strictEqual(format.container, 'MPEG'); + assert.strictEqual(format.codec, 'MPEG 2 Layer 3'); + assert.strictEqual(common.artist, 'Borewit', 'common.artist'); + + assert.isDefined(format.chapters, 'format.chapters'); + assert.strictEqual(format.chapters.length, 3, 'format.chapters.length'); + const chapter1 = format.chapters[0]; + assert.strictEqual(chapter1.title, 'Introduction','format.chapters[0].title'); + assert.isDefined(chapter1.image,'format.chapters[0].image,'); + assert.strictEqual(chapter1.image.data.length,689, 'format.chapters[0].image.data.length'); + }); + });