Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions lib/id3v2/FrameParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -50,6 +52,24 @@ export interface IGeneralEncapsulatedObject {
data: Uint8Array;
}

export type Chapter = {
label: string;
info: IChapterInfo;
frames: Map<string, unknown>,
}

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<string, unknown>;
}

const defaultEnc = 'latin1'; // latin1 == iso-8859-1;
const urlEnc: ITextEncoding = {encoding: defaultEnc, bom: false};

Expand Down Expand Up @@ -156,6 +176,9 @@ export class FrameParser {
case 'TRK':
case 'TRCK':
case 'TPOS':
case 'TIT1':
case 'TIT2':
case 'TIT3':
output = text;
break;
case 'TCOM':
Expand Down Expand Up @@ -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),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be ChapterInfo. get(uint8Array, fzero + 1) instead of offset. At this point offset is still 0, but ChapterInfo starts after the null-terminated label.

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;
Expand Down
25 changes: 25 additions & 0 deletions lib/id3v2/ID3v2ChapterToken.ts
Original file line number Diff line number Diff line change
@@ -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<IChapterInfo> = {
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)
};
}
};
70 changes: 68 additions & 2 deletions lib/id3v2/ID3v2Parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<void> {
Expand Down Expand Up @@ -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<string, any>();
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');
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Support WXXX (User defined URL link frame) in chapter metadata


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 {
Expand Down
2 changes: 1 addition & 1 deletion lib/id3v2/ID3v2Token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> = {
get: (buf: Uint8Array, off: number): number => {
return buf[off + 3] & 0x7f | ((buf[off + 2]) << 7) |
((buf[off + 1]) << 14) | ((buf[off]) << 21);
Expand Down
14 changes: 13 additions & 1 deletion lib/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
}

/**
Expand Down
Binary file added test/samples/mp3/chapters.mp3
Binary file not shown.
17 changes: 17 additions & 0 deletions test/test-file-mp3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});

});
Loading