Skip to content

Commit 123a300

Browse files
committed
Implement ID3v2 chapter support
1 parent 81dca61 commit 123a300

File tree

5 files changed

+203
-4
lines changed

5 files changed

+203
-4
lines changed

lib/id3v2/FrameParser.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import type { IWarningCollector } from '../common/MetadataCollector.js';
99
import type { IComment, ILyricsTag } from '../type.js';
1010
import { makeUnexpectedFileContentError } from '../ParseError.js';
1111
import { decodeUintBE } from '../common/Util.js';
12+
import { ChapterInfo, type IChapterInfo } from './ID3v2ChapterToken.js';
13+
import { getFrameHeaderLength, readFrameHeader } from './FrameHeader.js';
1214

1315
const debug = initDebug('music-metadata:id3v2:frame-parser');
1416

@@ -50,6 +52,24 @@ export interface IGeneralEncapsulatedObject {
5052
data: Uint8Array;
5153
}
5254

55+
export type Chapter = {
56+
label: string;
57+
info: IChapterInfo;
58+
frames: Map<string, unknown>,
59+
}
60+
61+
export type TableOfContents = {
62+
label: string;
63+
flags: {
64+
/** If set, this is the top-level table of contents */
65+
topLevel: boolean;
66+
/** If set, the child element IDs are in a defined order */
67+
ordered: boolean;
68+
};
69+
childElementIds: string[];
70+
frames: Map<string, unknown>;
71+
}
72+
5373
const defaultEnc = 'latin1'; // latin1 == iso-8859-1;
5474
const urlEnc: ITextEncoding = {encoding: defaultEnc, bom: false};
5575

@@ -156,6 +176,9 @@ export class FrameParser {
156176
case 'TRK':
157177
case 'TRCK':
158178
case 'TPOS':
179+
case 'TIT1':
180+
case 'TIT2':
181+
case 'TIT3':
159182
output = text;
160183
break;
161184
case 'TCOM':
@@ -394,6 +417,79 @@ export class FrameParser {
394417
break;
395418
}
396419

420+
// ID3v2 Chapters 1.0
421+
// https://mutagen-specs.readthedocs.io/en/latest/id3/id3v2-chapters-1.0.html#chapter-frame
422+
case 'CHAP': { // // Chapter frame
423+
debug("Reading CHAP");
424+
fzero = util.findZero(uint8Array, defaultEnc);
425+
426+
const chapter: Chapter = {
427+
label: util.decodeString(uint8Array.subarray(0, fzero), defaultEnc),
428+
info: ChapterInfo.get(uint8Array, offset),
429+
frames: new Map()
430+
};
431+
offset += fzero + 1 + ChapterInfo.len;
432+
433+
while (offset < length) {
434+
const subFrame = readFrameHeader(uint8Array.subarray(offset), this.major, this.warningCollector);
435+
const headerSize = getFrameHeaderLength(this.major);
436+
offset += headerSize;
437+
const subOutput = this.readData(uint8Array.subarray(offset, offset + subFrame.length), subFrame.id, includeCovers);
438+
offset += subFrame.length;
439+
440+
chapter.frames.set(subFrame.id, subOutput);
441+
}
442+
output = chapter;
443+
break;
444+
}
445+
446+
// ID3v2 Chapters 1.0
447+
// https://mutagen-specs.readthedocs.io/en/latest/id3/id3v2-chapters-1.0.html#table-of-contents-frame
448+
case 'CTOC': { // Table of contents frame
449+
debug('Reading CTOC');
450+
451+
// Element ID (null-terminated latin1)
452+
const idEnd = util.findZero(uint8Array, defaultEnc);
453+
const label = util.decodeString(uint8Array.subarray(0, idEnd), defaultEnc);
454+
offset = idEnd + 1;
455+
456+
// Flags
457+
const flags = uint8Array[offset++];
458+
const topLevel = (flags & 0x02) !== 0;
459+
const ordered = (flags & 0x01) !== 0;
460+
461+
// Child element IDs
462+
const entryCount = uint8Array[offset++];
463+
const childElementIds: string[] = [];
464+
for (let i = 0; i < entryCount && offset < length; i++) {
465+
const end = util.findZero(uint8Array.subarray(offset), defaultEnc);
466+
const childId = util.decodeString(uint8Array.subarray(offset, offset + end), defaultEnc);
467+
childElementIds.push(childId);
468+
offset += end + 1;
469+
}
470+
471+
const toc: TableOfContents = {
472+
label,
473+
flags: { topLevel, ordered },
474+
childElementIds,
475+
frames: new Map()
476+
};
477+
478+
// Optional embedded sub-frames (e.g. TIT2) follow after the child list
479+
while (offset < length) {
480+
const subFrame = readFrameHeader(uint8Array.subarray(offset), this.major, this.warningCollector);
481+
const headerSize = getFrameHeaderLength(this.major);
482+
offset += headerSize;
483+
const subOutput = this.readData(uint8Array.subarray(offset, offset + subFrame.length), subFrame.id, includeCovers);
484+
offset += subFrame.length;
485+
486+
toc.frames.set(subFrame.id, subOutput);
487+
}
488+
489+
output = toc;
490+
break;
491+
}
492+
397493
default:
398494
debug(`Warning: unsupported id3v2-tag-type: ${type}`);
399495
break;

lib/id3v2/ID3v2ChapterToken.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { IGetToken } from 'strtok3';
2+
import * as Token from 'token-types';
3+
4+
export interface IChapterInfo {
5+
startTime: number;
6+
endTime: number;
7+
startOffset: number;
8+
endOffset: number;
9+
}
10+
11+
/**
12+
* Data portion of `CHAP` sub frame
13+
*/
14+
export const ChapterInfo: IGetToken<IChapterInfo> = {
15+
len: 16,
16+
17+
get: (buf: Uint8Array, off: number): IChapterInfo => {
18+
return {
19+
startTime: Token.UINT32_BE.get(buf, off),
20+
endTime: Token.UINT32_BE.get(buf, off + 4),
21+
startOffset: Token.UINT32_BE.get(buf, off + 8),
22+
endOffset: Token.UINT32_BE.get(buf, off + 12)
23+
};
24+
}
25+
};

lib/id3v2/ID3v2Parser.ts

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { TagType } from '../common/GenericTagTypes.js';
55
import { FrameParser, Id3v2ContentError, type ITextTag } from './FrameParser.js';
66
import { ExtendedHeader, ID3v2Header, type ID3v2MajorVersion, type IID3v2header } from './ID3v2Token.js';
77

8-
import type { ITag, IOptions, AnyTagValue } from '../type.js';
8+
import type { ITag, IOptions, AnyTagValue, IChapter } from '../type.js';
99
import type { INativeMetadataCollector, IWarningCollector } from '../common/MetadataCollector.js';
1010

1111
import { getFrameHeaderLength, readFrameHeader } from './FrameHeader.js';
@@ -101,7 +101,11 @@ export class ID3v2Parser {
101101

102102
this.headerType = (`ID3v2.${id3Header.version.major}`) as TagType;
103103

104-
return id3Header.flags.isExtendedHeader ? this.parseExtendedHeader() : this.parseId3Data(id3Header.size);
104+
await (id3Header.flags.isExtendedHeader ? this.parseExtendedHeader() : this.parseId3Data(id3Header.size));
105+
106+
// Post process
107+
const chapters = ID3v2Parser.mapId3v2Chapters(this.metadata.native[this.headerType], this.metadata.format.sampleRate);
108+
this.metadata.setFormat('chapters', chapters);
105109
}
106110

107111
public async parseExtendedHeader(): Promise<void> {
@@ -168,6 +172,68 @@ export class ID3v2Parser {
168172
return tags;
169173
}
170174

175+
/**
176+
* Convert parsed ID3v2 chapter frames (CHAP / CTOC) to generic `format.chapters`.
177+
*
178+
* This function expects the `native` tags already to contain parsed `CHAP` and `CTOC` frame values,
179+
* as produced by `FrameParser.readData`.
180+
*/
181+
private static mapId3v2Chapters(
182+
id3Tags: ITag[],
183+
sampleRate?: number
184+
): IChapter[] | undefined {
185+
186+
const chapFrames = id3Tags.filter(t => t.id === 'CHAP') as any[] | undefined;
187+
if (!chapFrames?.length) return;
188+
189+
const tocFrames = id3Tags.filter(t => t.id === 'CTOC') as any[] | undefined;
190+
const topLevelToc = tocFrames?.find(t => t.value.flags?.topLevel);
191+
192+
const chapterById = new Map<string, any>();
193+
for (const chap of chapFrames) {
194+
chapterById.set(chap.value.label, chap.value);
195+
}
196+
197+
const orderedIds: string[] | undefined =
198+
topLevelToc?.value.childElementIds;
199+
200+
const chapters: IChapter[] = [];
201+
202+
const source = orderedIds ?? [...chapterById.keys()];
203+
204+
for (const id of source) {
205+
const chap = chapterById.get(id);
206+
if (!chap) continue;
207+
208+
const frames = chap.frames;
209+
const title = frames.get('TIT2');
210+
if (!title) continue; // title is required
211+
const image = frames.get('APIC');
212+
213+
const start = chap.info.startTime;
214+
const timeScale = 1000;
215+
216+
const sampleOffset = sampleRate
217+
? Math.round((start / timeScale) * sampleRate)
218+
: 0;
219+
220+
chapters.push({
221+
id,
222+
title,
223+
start,
224+
timeScale,
225+
sampleOffset,
226+
image
227+
});
228+
}
229+
230+
// If no ordered CTOC, sort by time
231+
if (!orderedIds) {
232+
chapters.sort((a, b) => a.start - b.start);
233+
}
234+
235+
return chapters.length ? chapters : undefined;
236+
}
171237
}
172238

173239
function makeUnexpectedMajorVersionError(majorVer: number): never {

lib/id3v2/ID3v2Token.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ export type TimestampFormat = typeof TimestampFormat[keyof typeof TimestampForma
6868
* 28 bits (representing up to 256MB) integer, the msb is 0 to avoid 'false syncsignals'.
6969
* 4 * %0xxxxxxx
7070
*/
71-
export const UINT32SYNCSAFE = {
71+
export const UINT32SYNCSAFE: IGetToken<number> = {
7272
get: (buf: Uint8Array, off: number): number => {
7373
return buf[off + 3] & 0x7f | ((buf[off + 2]) << 7) |
7474
((buf[off + 1]) << 14) | ((buf[off]) << 21);

lib/type.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -543,10 +543,17 @@ export interface ITag {
543543
}
544544

545545
export interface IChapter {
546+
547+
/**
548+
* Internal chapter reference
549+
*/
550+
id?: string;
551+
546552
/**
547553
* Chapter title
548554
*/
549555
title: string;
556+
550557
/**
551558
* Audio offset in sample number, 0 is the first sample.
552559
* Duration offset is sampleOffset / format.sampleRate
@@ -558,9 +565,14 @@ export interface IChapter {
558565
*/
559566
start: number;
560567
/**
561-
* Time value that indicates the time scale for chapter tracks, the number of time units that pass per second in its time coordinate system.
568+
* Time value that indicates the timescale for chapter tracks, the number of time units that pass per second in its time coordinate system.
562569
*/
563570
timeScale: number;
571+
572+
/**
573+
* Picture
574+
*/
575+
image?: IPicture;
564576
}
565577

566578
/**

0 commit comments

Comments
 (0)