1
0
mirror of synced 2026-05-22 14:43:19 +00:00

Add ID3v2 chapter support

This commit is contained in:
Borewit
2026-01-05 15:42:41 +01:00
committed by Borewit
parent 9809b7dcb0
commit 8c822709d3
9 changed files with 243 additions and 10 deletions
+97 -2
View File
@@ -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<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};
@@ -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;
+29
View File
@@ -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<IChapterInfo> = {
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,
};
}
};
+60 -2
View File
@@ -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<void> {
@@ -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<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
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 {
+1 -1
View File
@@ -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);
+32 -3
View File
@@ -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;
}
/**