music-metadata
Version:
Music metadata parser for Node.js, supporting virtual any audio and tag format.
176 lines • 8.36 kB
JavaScript
import initDebug from 'debug';
import * as strtok3 from 'strtok3';
import { StringType } from 'token-types';
import { uint8ArrayToString } from 'uint8array-extras';
import * as util from '../common/Util.js';
import { BasicParser } from '../common/BasicParser.js';
import { DataType, DescriptorParser, Header, TagFooter, TagItemHeader } from './APEv2Token.js';
import { makeUnexpectedFileContentError } from '../ParseError.js';
const debug = initDebug('music-metadata:parser:APEv2');
const tagFormat = 'APEv2';
const preamble = 'APETAGEX';
export class ApeContentError extends makeUnexpectedFileContentError('APEv2') {
}
export class APEv2Parser extends BasicParser {
constructor() {
super(...arguments);
this.ape = {};
}
static tryParseApeHeader(metadata, tokenizer, options) {
const apeParser = new APEv2Parser(metadata, tokenizer, options);
return apeParser.tryParseApeHeader();
}
/**
* Calculate the media file duration
* @param ah ApeHeader
* @return {number} duration in seconds
*/
static calculateDuration(ah) {
let duration = ah.totalFrames > 1 ? ah.blocksPerFrame * (ah.totalFrames - 1) : 0;
duration += ah.finalFrameBlocks;
return duration / ah.sampleRate;
}
/**
* Calculates the APEv1 / APEv2 first field offset
* @param tokenizer
* @param offset
*/
static async findApeFooterOffset(tokenizer, offset) {
// Search for APE footer header at the end of the file
const apeBuf = new Uint8Array(TagFooter.len);
const position = tokenizer.position;
if (offset <= TagFooter.len) {
debug(`Offset is too small to read APE footer: offset=${offset}`);
return undefined;
}
if (offset > TagFooter.len) {
await tokenizer.readBuffer(apeBuf, { position: offset - TagFooter.len });
tokenizer.setPosition(position);
const tagFooter = TagFooter.get(apeBuf, 0);
if (tagFooter.ID === 'APETAGEX') {
if (tagFooter.flags.isHeader) {
debug(`APE Header found at offset=${offset - TagFooter.len}`);
}
else {
debug(`APE Footer found at offset=${offset - TagFooter.len}`);
offset -= tagFooter.size;
}
return { footer: tagFooter, offset };
}
}
}
static parseTagFooter(metadata, buffer, options) {
const footer = TagFooter.get(buffer, buffer.length - TagFooter.len);
if (footer.ID !== preamble)
throw new ApeContentError('Unexpected APEv2 Footer ID preamble value');
strtok3.fromBuffer(buffer);
const apeParser = new APEv2Parser(metadata, strtok3.fromBuffer(buffer), options);
return apeParser.parseTags(footer);
}
/**
* Parse APEv1 / APEv2 header if header signature found
*/
async tryParseApeHeader() {
if (this.tokenizer.fileInfo.size && this.tokenizer.fileInfo.size - this.tokenizer.position < TagFooter.len) {
debug("No APEv2 header found, end-of-file reached");
return;
}
const footer = await this.tokenizer.peekToken(TagFooter);
if (footer.ID === preamble) {
await this.tokenizer.ignore(TagFooter.len);
return this.parseTags(footer);
}
debug(`APEv2 header not found at offset=${this.tokenizer.position}`);
if (this.tokenizer.fileInfo.size) {
// Try to read the APEv2 header using just the footer-header
const remaining = this.tokenizer.fileInfo.size - this.tokenizer.position; // ToDo: take ID3v1 into account
const buffer = new Uint8Array(remaining);
await this.tokenizer.readBuffer(buffer);
return APEv2Parser.parseTagFooter(this.metadata, buffer, this.options);
}
}
async parse() {
const descriptor = await this.tokenizer.readToken(DescriptorParser);
if (descriptor.ID !== 'MAC ')
throw new ApeContentError('Unexpected descriptor ID');
this.ape.descriptor = descriptor;
const lenExp = descriptor.descriptorBytes - DescriptorParser.len;
const header = await (lenExp > 0 ? this.parseDescriptorExpansion(lenExp) : this.parseHeader());
await this.tokenizer.ignore(header.forwardBytes);
return this.tryParseApeHeader();
}
async parseTags(footer) {
const keyBuffer = new Uint8Array(256); // maximum tag key length
let bytesRemaining = footer.size - TagFooter.len;
debug(`Parse APE tags at offset=${this.tokenizer.position}, size=${bytesRemaining}`);
for (let i = 0; i < footer.fields; i++) {
if (bytesRemaining < TagItemHeader.len) {
this.metadata.addWarning(`APEv2 Tag-header: ${footer.fields - i} items remaining, but no more tag data to read.`);
break;
}
// Only APEv2 tag has tag item headers
const tagItemHeader = await this.tokenizer.readToken(TagItemHeader);
bytesRemaining -= TagItemHeader.len + tagItemHeader.size;
await this.tokenizer.peekBuffer(keyBuffer, { length: Math.min(keyBuffer.length, bytesRemaining) });
let zero = util.findZero(keyBuffer, 0, keyBuffer.length);
const key = await this.tokenizer.readToken(new StringType(zero, 'ascii'));
await this.tokenizer.ignore(1);
bytesRemaining -= key.length + 1;
switch (tagItemHeader.flags.dataType) {
case DataType.text_utf8: { // utf-8 text-string
const value = await this.tokenizer.readToken(new StringType(tagItemHeader.size, 'utf8'));
const values = value.split(/\x00/g);
await Promise.all(values.map(val => this.metadata.addTag(tagFormat, key, val)));
break;
}
case DataType.binary: // binary (probably artwork)
if (this.options.skipCovers) {
await this.tokenizer.ignore(tagItemHeader.size);
}
else {
const picData = new Uint8Array(tagItemHeader.size);
await this.tokenizer.readBuffer(picData);
zero = util.findZero(picData, 0, picData.length);
const description = uint8ArrayToString(picData.slice(0, zero));
const data = picData.slice(zero + 1);
await this.metadata.addTag(tagFormat, key, {
description,
data
});
}
break;
case DataType.external_info:
debug(`Ignore external info ${key}`);
await this.tokenizer.ignore(tagItemHeader.size);
break;
case DataType.reserved:
debug(`Ignore external info ${key}`);
this.metadata.addWarning(`APEv2 header declares a reserved datatype for "${key}"`);
await this.tokenizer.ignore(tagItemHeader.size);
break;
}
}
}
async parseDescriptorExpansion(lenExp) {
await this.tokenizer.ignore(lenExp);
return this.parseHeader();
}
async parseHeader() {
const header = await this.tokenizer.readToken(Header);
// ToDo before
this.metadata.setFormat('lossless', true);
this.metadata.setFormat('container', 'Monkey\'s Audio');
this.metadata.setFormat('bitsPerSample', header.bitsPerSample);
this.metadata.setFormat('sampleRate', header.sampleRate);
this.metadata.setFormat('numberOfChannels', header.channel);
this.metadata.setFormat('duration', APEv2Parser.calculateDuration(header));
if (!this.ape.descriptor) {
throw new ApeContentError('Missing APE descriptor');
}
return {
forwardBytes: this.ape.descriptor.seekTableBytes + this.ape.descriptor.headerDataBytes +
this.ape.descriptor.apeFrameDataBytes + this.ape.descriptor.terminatingDataBytes
};
}
}
//# sourceMappingURL=APEv2Parser.js.map