596 lines
19 KiB
TypeScript
596 lines
19 KiB
TypeScript
import initDebug from 'debug';
|
|
import * as Token from 'token-types';
|
|
|
|
import * as util from '../common/Util.js';
|
|
import { AttachedPictureType, type ID3v2MajorVersion, type ITextEncoding, SyncTextHeader, TextEncodingToken, TextHeader } from './ID3v2Token.js';
|
|
import { Genres } from '../id3v1/ID3v1Parser.js';
|
|
|
|
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');
|
|
|
|
interface IPicture {
|
|
type?: string,
|
|
description?: string;
|
|
format?: string,
|
|
data?: Uint8Array;
|
|
}
|
|
|
|
interface ICustomTag {
|
|
owner_identifier: string;
|
|
}
|
|
|
|
export interface ICustomDataTag extends ICustomTag {
|
|
data: Uint8Array;
|
|
}
|
|
|
|
export interface IIdentifierTag extends ICustomTag {
|
|
identifier: Uint8Array;
|
|
}
|
|
|
|
export interface ITextTag {
|
|
description: string;
|
|
text: string[];
|
|
}
|
|
|
|
export interface IPopularimeter {
|
|
email: string;
|
|
rating: number;
|
|
counter: number;
|
|
}
|
|
|
|
|
|
export interface IGeneralEncapsulatedObject {
|
|
type: string;
|
|
filename: string;
|
|
description: string;
|
|
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};
|
|
|
|
export function parseGenre(origVal: string): string[] {
|
|
// match everything inside parentheses
|
|
const genres = [];
|
|
let code: string | undefined;
|
|
let word: string | undefined = '';
|
|
for (const c of origVal) {
|
|
if (typeof code === 'string') {
|
|
if (c === '(' && code === '') {
|
|
word += '(';
|
|
code = undefined;
|
|
} else if (c === ')') {
|
|
if (word !== '') {
|
|
genres.push(word);
|
|
word = '';
|
|
}
|
|
const genre = parseGenreCode(code);
|
|
if (genre) {
|
|
genres.push(genre);
|
|
}
|
|
code = undefined;
|
|
} else code += c;
|
|
} else if (c === '(') {
|
|
code = '';
|
|
} else {
|
|
word += c;
|
|
}
|
|
}
|
|
if (word) {
|
|
if (genres.length === 0 && word.match(/^\d*$/)) {
|
|
word = parseGenreCode(word);
|
|
}
|
|
if (word) {
|
|
genres.push(word);
|
|
}
|
|
}
|
|
return genres;
|
|
}
|
|
|
|
function parseGenreCode(code: string): string | undefined {
|
|
if (code === 'RX')
|
|
return 'Remix';
|
|
if (code === 'CR')
|
|
return 'Cover';
|
|
if (code.match(/^\d*$/)) {
|
|
return Genres[Number.parseInt(code, 10)];
|
|
}
|
|
}
|
|
|
|
export class FrameParser {
|
|
private major: ID3v2MajorVersion;
|
|
|
|
private warningCollector: IWarningCollector;
|
|
|
|
/**
|
|
* Create id3v2 frame parser
|
|
* @param major - Major version, e.g. (4) for id3v2.4
|
|
* @param warningCollector - Used to collect decode issue
|
|
*/
|
|
constructor(major: ID3v2MajorVersion, warningCollector: IWarningCollector) {
|
|
this.major = major;
|
|
this.warningCollector = warningCollector;
|
|
}
|
|
|
|
public readData(uint8Array: Uint8Array, type: string, includeCovers: boolean) {
|
|
if (uint8Array.length === 0) {
|
|
this.warningCollector.addWarning(`id3v2.${this.major} header has empty tag type=${type}`);
|
|
return;
|
|
}
|
|
const {encoding, bom} = TextEncodingToken.get(uint8Array, 0);
|
|
const length = uint8Array.length;
|
|
let offset = 0;
|
|
let output: unknown = []; // ToDo
|
|
const nullTerminatorLength = FrameParser.getNullTerminatorLength(encoding);
|
|
let fzero: number;
|
|
|
|
debug(`Parsing tag type=${type}, encoding=${encoding}, bom=${bom}`);
|
|
switch (type !== 'TXXX' && type[0] === 'T' ? 'T*' : type) {
|
|
case 'T*': // 4.2.1. Text information frames - details
|
|
case 'GRP1': // iTunes-specific ID3v2 grouping field
|
|
case 'GP1': // iTunes-specific ID3v2.2 grouping field
|
|
case 'IPLS': // v2.3: Involved people list
|
|
case 'MVIN':
|
|
case 'MVNM':
|
|
case 'PCS':
|
|
case 'PCST': {
|
|
let text: string;
|
|
try {
|
|
text = FrameParser.trimNullPadding(util.decodeString(uint8Array.subarray(1), encoding));
|
|
} catch (error) {
|
|
if (error instanceof Error) {
|
|
this.warningCollector.addWarning(`id3v2.${this.major} type=${type} header has invalid string value: ${error.message}`);
|
|
break;
|
|
}
|
|
throw error;
|
|
}
|
|
switch (type) {
|
|
case 'TMCL': // Musician credits list
|
|
case 'TIPL': // Involved people list
|
|
case 'IPLS': // Involved people list
|
|
output = FrameParser.functionList(this.splitValue(type, text));
|
|
break;
|
|
case 'TRK':
|
|
case 'TRCK':
|
|
case 'TPOS':
|
|
case 'TIT1':
|
|
case 'TIT2':
|
|
case 'TIT3':
|
|
output = text;
|
|
break;
|
|
case 'TCOM':
|
|
case 'TEXT':
|
|
case 'TOLY':
|
|
case 'TOPE':
|
|
case 'TPE1':
|
|
case 'TSRC':
|
|
// id3v2.3 defines that TCOM, TEXT, TOLY, TOPE & TPE1 values are separated by /
|
|
output = this.splitValue(type, text);
|
|
break;
|
|
case 'TCO':
|
|
case 'TCON':
|
|
output = this.splitValue(type, text).map(v => parseGenre(v)).reduce((acc, val) => acc.concat(val), []);
|
|
break;
|
|
case 'PCS':
|
|
case 'PCST':
|
|
// TODO: Why `default` not results `1` but `''`?
|
|
output = this.major >= 4 ? this.splitValue(type, text) : [text];
|
|
output = (Array.isArray(output) && output[0] === '') ? 1 : 0;
|
|
break;
|
|
default:
|
|
output = this.major >= 4 ? this.splitValue(type, text) : [text];
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'TXXX': {
|
|
const idAndData = FrameParser.readIdentifierAndData(uint8Array.subarray(1), encoding);
|
|
output = {
|
|
description: idAndData.id,
|
|
text: this.splitValue(type, util.decodeString(idAndData.data, encoding).replace(/\x00+$/, ''))
|
|
};
|
|
break;
|
|
}
|
|
|
|
case 'PIC':
|
|
case 'APIC':
|
|
if (includeCovers) {
|
|
const pic: IPicture = {};
|
|
|
|
uint8Array = uint8Array.subarray(1);
|
|
|
|
switch (this.major) {
|
|
case 2:
|
|
pic.format = util.decodeString(uint8Array.subarray(0, 3), 'latin1'); // 'latin1'; // latin1 == iso-8859-1;
|
|
uint8Array = uint8Array.subarray(3);
|
|
break;
|
|
case 3:
|
|
case 4:
|
|
fzero = util.findZero(uint8Array, defaultEnc);
|
|
pic.format = util.decodeString(uint8Array.subarray(0, fzero), defaultEnc);
|
|
uint8Array = uint8Array.subarray(fzero + 1);
|
|
break;
|
|
|
|
default:
|
|
throw makeUnexpectedMajorVersionError(this.major);
|
|
}
|
|
|
|
pic.format = FrameParser.fixPictureMimeType(pic.format);
|
|
|
|
pic.type = AttachedPictureType[uint8Array[0] as keyof typeof AttachedPictureType];
|
|
uint8Array = uint8Array.subarray(1);
|
|
|
|
fzero = util.findZero(uint8Array, encoding);
|
|
pic.description = util.decodeString(uint8Array.subarray(0, fzero), encoding);
|
|
uint8Array = uint8Array.subarray(fzero + nullTerminatorLength);
|
|
pic.data = uint8Array;
|
|
output = pic;
|
|
}
|
|
break;
|
|
|
|
case 'CNT':
|
|
case 'PCNT':
|
|
output = decodeUintBE(uint8Array);
|
|
break;
|
|
|
|
case 'SYLT': {
|
|
const syltHeader = SyncTextHeader.get(uint8Array, 0);
|
|
uint8Array = uint8Array.subarray(SyncTextHeader.len);
|
|
|
|
const result: ILyricsTag = {
|
|
descriptor: '',
|
|
language: syltHeader.language,
|
|
contentType: syltHeader.contentType,
|
|
timeStampFormat: syltHeader.timeStampFormat,
|
|
syncText: []
|
|
};
|
|
|
|
let readSyllables = false;
|
|
while (uint8Array.length > 0) {
|
|
|
|
const nullStr = FrameParser.readNullTerminatedString(uint8Array, syltHeader.encoding);
|
|
uint8Array = uint8Array.subarray(nullStr.len);
|
|
|
|
if (readSyllables) {
|
|
const timestamp = Token.UINT32_BE.get(uint8Array, 0);
|
|
uint8Array = uint8Array.subarray(Token.UINT32_BE.len);
|
|
result.syncText.push({
|
|
text: nullStr.text,
|
|
timestamp
|
|
});
|
|
} else {
|
|
result.descriptor = nullStr.text;
|
|
readSyllables = true;
|
|
}
|
|
}
|
|
output = result;
|
|
break;
|
|
}
|
|
|
|
case 'ULT':
|
|
case 'USLT':
|
|
case 'COM':
|
|
case 'COMM': {
|
|
|
|
const textHeader = TextHeader.get(uint8Array, offset);
|
|
offset += TextHeader.len;
|
|
|
|
const descriptorStr = FrameParser.readNullTerminatedString(uint8Array.subarray(offset), textHeader.encoding);
|
|
offset += descriptorStr.len;
|
|
|
|
const textStr = FrameParser.readNullTerminatedString(uint8Array.subarray(offset), textHeader.encoding);
|
|
|
|
const comment: IComment = {
|
|
language: textHeader.language,
|
|
descriptor: descriptorStr.text,
|
|
text: textStr.text
|
|
};
|
|
|
|
output = comment;
|
|
break;
|
|
}
|
|
|
|
case 'UFID': {
|
|
const ufid = FrameParser.readIdentifierAndData(uint8Array, defaultEnc);
|
|
output = {owner_identifier: ufid.id, identifier: ufid.data} as IIdentifierTag;
|
|
break;
|
|
}
|
|
|
|
case 'PRIV': { // private frame
|
|
const priv = FrameParser.readIdentifierAndData(uint8Array, defaultEnc);
|
|
output = {owner_identifier: priv.id, data: priv.data} as ICustomDataTag;
|
|
break;
|
|
}
|
|
|
|
case 'POPM': { // Popularimeter
|
|
uint8Array = uint8Array.subarray(offset);
|
|
|
|
const emailStr = FrameParser.readNullTerminatedString(uint8Array, urlEnc);
|
|
const email = emailStr.text;
|
|
uint8Array = uint8Array.subarray(emailStr.len);
|
|
|
|
if (uint8Array.length === 0) {
|
|
this.warningCollector.addWarning(`id3v2.${this.major} type=${type} POPM frame missing rating byte`);
|
|
output = {email, rating: 0, counter: undefined};
|
|
break;
|
|
}
|
|
|
|
const rating = Token.UINT8.get(uint8Array, 0);
|
|
const counterBytes = uint8Array.subarray(Token.UINT8.len);
|
|
output = {
|
|
email,
|
|
rating,
|
|
counter: counterBytes.length > 0 ? decodeUintBE(counterBytes) : undefined
|
|
};
|
|
break;
|
|
}
|
|
|
|
case 'GEOB': { // General encapsulated object
|
|
// [encoding] <MIME> 0x00 <filename> 0x00/0x00 0x00 <description> 0x00/0x00 0x00 <data>
|
|
const encoding = TextEncodingToken.get(uint8Array, 0);
|
|
uint8Array = uint8Array.subarray(1);
|
|
|
|
const mimeTypeStr = FrameParser.readNullTerminatedString(uint8Array, urlEnc);
|
|
const mimeType = mimeTypeStr.text;
|
|
uint8Array = uint8Array.subarray(mimeTypeStr.len);
|
|
|
|
const filenameStr = FrameParser.readNullTerminatedString(uint8Array, encoding);
|
|
const filename = filenameStr.text;
|
|
uint8Array = uint8Array.subarray(filenameStr.len);
|
|
|
|
const descriptionStr = FrameParser.readNullTerminatedString(uint8Array, encoding);
|
|
const description = descriptionStr.text;
|
|
uint8Array = uint8Array.subarray(descriptionStr.len);
|
|
|
|
const geob: IGeneralEncapsulatedObject = {
|
|
type: mimeType,
|
|
filename,
|
|
description,
|
|
data: uint8Array
|
|
};
|
|
output = geob;
|
|
break;
|
|
}
|
|
|
|
// W-Frames:
|
|
case 'WCOM':
|
|
case 'WCOP':
|
|
case 'WOAF':
|
|
case 'WOAR':
|
|
case 'WOAS':
|
|
case 'WORS':
|
|
case 'WPAY':
|
|
case 'WPUB':
|
|
// Decode URL
|
|
output = FrameParser.readNullTerminatedString(uint8Array, urlEnc).text;
|
|
break;
|
|
|
|
case 'WXXX': {
|
|
// [encoding] <description> 0x00/0x00 0x00 <url>
|
|
const encoding = TextEncodingToken.get(uint8Array, 0);
|
|
uint8Array = uint8Array.subarray(1);
|
|
|
|
const descriptionStr = FrameParser.readNullTerminatedString(uint8Array, encoding);
|
|
const description = descriptionStr.text;
|
|
uint8Array = uint8Array.subarray(descriptionStr.len);
|
|
|
|
// URL is always ISO-8859-1
|
|
output = {description, url: FrameParser.trimNullPadding(util.decodeString(uint8Array, defaultEnc))};
|
|
break;
|
|
}
|
|
|
|
case 'WFD':
|
|
case 'WFED': {
|
|
const encoding = TextEncodingToken.get(uint8Array, 0);
|
|
uint8Array = uint8Array.subarray(1);
|
|
output = FrameParser.readNullTerminatedString(uint8Array, encoding).text;
|
|
break;
|
|
}
|
|
|
|
case 'MCDI': {
|
|
// Music CD identifier
|
|
output = uint8Array.subarray(0, length);
|
|
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;
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
protected static readNullTerminatedString(uint8Array: Uint8Array, encoding: ITextEncoding): { text: string, len: number } {
|
|
const bomSize = encoding.bom ? 2 : 0;
|
|
const originalLen = uint8Array.length;
|
|
const valueArray = uint8Array.subarray(bomSize);
|
|
|
|
const zeroIndex = util.findZero(valueArray, encoding.encoding);
|
|
if (zeroIndex >= valueArray.length) {
|
|
// No terminator found, decode full buffer remainder
|
|
return {
|
|
text: util.decodeString(valueArray, encoding.encoding),
|
|
len: originalLen
|
|
};
|
|
}
|
|
|
|
const txt = valueArray.subarray(0, zeroIndex);
|
|
return {
|
|
text: util.decodeString(txt, encoding.encoding),
|
|
len: bomSize + zeroIndex + FrameParser.getNullTerminatorLength(encoding.encoding)
|
|
};
|
|
}
|
|
|
|
protected static fixPictureMimeType(pictureType: string): string {
|
|
pictureType = pictureType.toLocaleLowerCase();
|
|
switch (pictureType) {
|
|
case 'jpg':
|
|
return 'image/jpeg';
|
|
case 'png':
|
|
return 'image/png';
|
|
}
|
|
return pictureType;
|
|
}
|
|
|
|
/**
|
|
* Converts TMCL (Musician credits list) or TIPL (Involved people list)
|
|
* @param entries
|
|
*/
|
|
private static functionList(entries: string[]): { [index: string]: string[] } {
|
|
const res: { [index: string]: string[] } = {};
|
|
for (let i = 0; i + 1 < entries.length; i += 2) {
|
|
const names: string[] = entries[i + 1].split(',');
|
|
res[entries[i]] = res[entries[i]] ? res[entries[i]].concat(names) : names;
|
|
}
|
|
return res;
|
|
}
|
|
|
|
/**
|
|
* id3v2.4 defines that multiple T* values are separated by 0x00
|
|
* id3v2.3 defines that TCOM, TEXT, TOLY, TOPE & TPE1 values are separated by /
|
|
* @param tag - Tag name
|
|
* @param text - Concatenated tag value
|
|
* @returns Split tag value
|
|
*/
|
|
private splitValue(tag: string, text: string): string[] {
|
|
let values: string[];
|
|
if (this.major < 4) {
|
|
values = text.split(/\x00/g);
|
|
if (values.length > 1) {
|
|
this.warningCollector.addWarning(`ID3v2.${this.major} ${tag} uses non standard null-separator.`);
|
|
} else {
|
|
values = text.split(/\//g);
|
|
}
|
|
} else {
|
|
values = text.split(/\x00/g);
|
|
}
|
|
return FrameParser.trimArray(values);
|
|
}
|
|
|
|
private static trimArray(values: string[]): string[] {
|
|
return values.map(value => FrameParser.trimNullPadding(value).trim());
|
|
}
|
|
|
|
private static trimNullPadding(value: string): string {
|
|
let end = value.length;
|
|
while (end > 0 && value.charCodeAt(end - 1) === 0) {
|
|
end--;
|
|
}
|
|
return end === value.length ? value : value.slice(0, end);
|
|
}
|
|
|
|
private static readIdentifierAndData(uint8Array: Uint8Array, encoding: util.StringEncoding): { id: string, data: Uint8Array } {
|
|
const idStr = FrameParser.readNullTerminatedString(uint8Array, {encoding, bom: false});
|
|
return {id: idStr.text, data: uint8Array.subarray(idStr.len)};
|
|
}
|
|
|
|
private static getNullTerminatorLength(enc: util.StringEncoding): number {
|
|
return enc.startsWith('utf-16') ? 2 : 1;
|
|
}
|
|
}
|
|
|
|
export class Id3v2ContentError extends makeUnexpectedFileContentError('id3v2') {
|
|
}
|
|
|
|
function makeUnexpectedMajorVersionError(majorVer: number) {
|
|
throw new Id3v2ContentError(`Unexpected majorVer: ${majorVer}`);
|
|
}
|