Add ID3v2 chapter support
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user