From 8c822709d348136cdaeb720d81d13ec1c19c3e14 Mon Sep 17 00:00:00 2001 From: Borewit Date: Mon, 5 Jan 2026 15:42:41 +0100 Subject: [PATCH] Add ID3v2 chapter support --- README.md | 2 +- lib/id3v2/FrameParser.ts | 99 ++++++++++++++++++++++++++++++++- lib/id3v2/ID3v2ChapterToken.ts | 29 ++++++++++ lib/id3v2/ID3v2Parser.ts | 62 ++++++++++++++++++++- lib/id3v2/ID3v2Token.ts | 2 +- lib/type.ts | 35 +++++++++++- package.json | 4 +- test/samples/mp3/chapters.mp3 | Bin 0 -> 15046 bytes test/test-file-mp3.ts | 20 +++++++ 9 files changed, 243 insertions(+), 10 deletions(-) create mode 100644 lib/id3v2/ID3v2ChapterToken.ts create mode 100644 test/samples/mp3/chapters.mp3 diff --git a/README.md b/README.md index 9f718f18..aceed8c2 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ Following tag header formats are supported: - [APE](https://wikipedia.org/wiki/APE_tag) - [ASF](https://wikipedia.org/wiki/Advanced_Systems_Format) - EXIF 2.3 -- [ID3](https://wikipedia.org/wiki/ID3): ID3v1, ID3v1.1, ID3v2.2, [ID3v2.3](http://id3.org/id3v2.3.0) & [ID3v2.4](http://id3.org/id3v2.4.0-frames) +- [ID3](https://wikipedia.org/wiki/ID3): ID3v1, ID3v1.1, ID3v2.2, [ID3v2.3](http://id3.org/id3v2.3.0), [ID3v2.4](http://id3.org/id3v2.4.0-frames) and [ID3v2 Chapters 1.0](https://mutagen-specs.readthedocs.io/en/latest/id3/id3v2-chapters-1.0.html) - [iTunes](https://github.com/sergiomb2/libmp4v2/wiki/iTunesMetadata) - [RIFF](https://wikipedia.org/wiki/Resource_Interchange_File_Format)/INFO - [Vorbis comment](https://wikipedia.org/wiki/Vorbis_comment) diff --git a/lib/id3v2/FrameParser.ts b/lib/id3v2/FrameParser.ts index c74b2243..3c34e8a3 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': @@ -185,11 +208,10 @@ export class FrameParser { case 'TXXX': { const idAndData = FrameParser.readIdentifierAndData(uint8Array.subarray(1), encoding); - const textTag = { + output = { description: idAndData.id, text: this.splitValue(type, util.decodeString(idAndData.data, encoding).replace(/\x00+$/, '')) }; - output = textTag; break; } @@ -394,6 +416,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, fzero + 1), + 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 00000000..99888f90 --- /dev/null +++ b/lib/id3v2/ID3v2ChapterToken.ts @@ -0,0 +1,29 @@ +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 => { + + const startOffset = Token.UINT32_BE.get(buf, off + 8); + const endOffset = Token.UINT32_BE.get(buf, off + 12); + + return { + startTime: Token.UINT32_BE.get(buf, off), + endTime: Token.UINT32_BE.get(buf, off + 4), + startOffset: startOffset === 0xFFFFFFFF ? undefined : startOffset, + endOffset: endOffset === 0xFFFFFFFF ? undefined : endOffset, + }; + } +}; diff --git a/lib/id3v2/ID3v2Parser.ts b/lib/id3v2/ID3v2Parser.ts index 2f09e922..8f89c4d6 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,60 @@ 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 + + chapters.push({ + id, + title, + url: frames.get('WXXX'), + start: chap.info.startTime / 1000, + end: chap.info.endTime / 1000, + image: frames.get('APIC') + }); + } + + // 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 1e0b8136..6fa20df1 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 97fe9e32..b654aff1 100644 --- a/lib/type.ts +++ b/lib/type.ts @@ -542,25 +542,54 @@ export interface ITag { value: AnyTagValue; } +export interface IUrl { + url: string; + description: string; +} + export interface IChapter { + + /** + * Internal chapter reference + */ + id?: string; + /** * Chapter title */ title: string; + + /** + * URL + */ + url?: IUrl; + /** * Audio offset in sample number, 0 is the first sample. * Duration offset is sampleOffset / format.sampleRate */ - sampleOffset: number; + sampleOffset?: number; /** * Timestamp where the chapter starts * Chapter timestamp is start/timeScale in seconds. */ 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. + * Timestamp where the chapter end + * Chapter timestamp is start/timeScale in seconds. */ - timeScale: number; + end?: number; + + /** + * 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/package.json b/package.json index b2312287..d8d134da 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,9 @@ "parser", "bwf", "slt", - "lyrics" + "lyrics", + "Chapters", + "ID3v2 Chapters" ], "scripts": { "clean": "del-cli 'lib/**/*.js' 'lib/**/*.js.map' 'lib/**/*.d.ts' 'test/**/*.js' 'test/**/*.js.map' 'test/**/*.js' 'test/**/*.js.map' 'doc-gen/**/*.js' 'doc-gen/**/*.js.map'", diff --git a/test/samples/mp3/chapters.mp3 b/test/samples/mp3/chapters.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..1184b7336caa58833dbdb77f702d68e94e5a5278 GIT binary patch literal 15046 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 AR{#J2 literal 0 HcmV?d00001 diff --git a/test/test-file-mp3.ts b/test/test-file-mp3.ts index c69dcef3..0ffcefc0 100644 --- a/test/test-file-mp3.ts +++ b/test/test-file-mp3.ts @@ -377,4 +377,24 @@ describe('Parse MP3 files', () => { }); }); + it('should parse id3v2 Chapters', async () => { + const filePath = path.join(mp3SamplePath, 'chapters.mp3'); + + const {format, common, native} = 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'); + assert.strictEqual(format.chapters[0].title, 'Introduction','format.chapters[0].title'); + assert.deepEqual(format.chapters[0].url, {url: 'https://github.com/Borewit/music-metadata', description: ''}, 'format.chapters[0].url'); + assert.isDefined(format.chapters[0].image,'format.chapters[0].image,'); + assert.strictEqual(format.chapters[0].image.data.length,689, 'format.chapters[0].image.data.length'); + assert.strictEqual(format.chapters[0].start, 0,'format.chapters[0].start'); + assert.strictEqual(format.chapters[0].end, 1.0,'format.chapters[0].end'); + assert.isUndefined(format.chapters[0].sampleOffset,'format.chapters[0].sampleOffset'); + }); + });