1
0
mirror of synced 2026-05-22 14:43:19 +00:00
Files
music-metadata/lib/apev2/APEv2Parser.ts
T

225 lines
8.0 KiB
TypeScript

import initDebug from 'debug';
import * as strtok3 from 'strtok3';
import { StringType } from 'token-types';
import * as util from '../common/Util.js';
import type { IOptions, IApeHeader } from '../type.js';
import type { INativeMetadataCollector } from '../common/MetadataCollector.js';
import { BasicParser } from '../common/BasicParser.js';
import {
DataType,
DescriptorParser,
Header,
type IDescriptor,
type IFooter,
type IHeader, type ITagItemHeader,
TagFooter,
TagItemHeader
} from './APEv2Token.js';
import { makeUnexpectedFileContentError } from '../ParseError.js';
import type { IRandomAccessTokenizer } from 'strtok3';
import { TextDecoder } from '@exodus/bytes/encoding.js';
const debug = initDebug('music-metadata:parser:APEv2');
const tagFormat = 'APEv2';
interface IApeInfo {
descriptor?: IDescriptor,
header?: IHeader,
footer?: IFooter
}
const preamble = 'APETAGEX';
export class ApeContentError extends makeUnexpectedFileContentError('APEv2'){
}
export function tryParseApeHeader(metadata: INativeMetadataCollector, tokenizer: strtok3.ITokenizer, options: IOptions) {
const apeParser = new APEv2Parser(metadata, tokenizer, options);
return apeParser.tryParseApeHeader();
}
export class APEv2Parser extends BasicParser {
/**
* Calculate the media file duration
* @param ah ApeHeader
* @return {number} duration in seconds
*/
public static calculateDuration(ah: IHeader): number {
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
*/
public static async findApeFooterOffset(tokenizer: IRandomAccessTokenizer, offset: number): Promise<IApeHeader | undefined> {
// 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};
}
}
}
private static parseTagFooter(metadata: INativeMetadataCollector, buffer: Uint8Array, options: IOptions): Promise<void> {
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);
}
private ape: IApeInfo = {};
/**
* Parse APEv1 / APEv2 header if header signature found
*/
public async tryParseApeHeader(): Promise<void> {
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<IFooter>(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);
}
}
public async parse(): Promise<void> {
const descriptor = await this.tokenizer.readToken<IDescriptor>(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());
this.metadata.setAudioOnly();
await this.tokenizer.ignore(header.forwardBytes);
return this.tryParseApeHeader();
}
public async parseTags(footer: IFooter): Promise<void> {
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<ITagItemHeader>(TagItemHeader);
bytesRemaining -= TagItemHeader.len + tagItemHeader.size;
await this.tokenizer.peekBuffer(keyBuffer, {length: Math.min(keyBuffer.length, bytesRemaining)});
let zero = util.findZero(keyBuffer);
const key = await this.tokenizer.readToken<string>(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<string>(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);
const description = new TextDecoder('utf-8').decode(picData.subarray(0, zero));
const data = picData.subarray(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;
}
}
}
private async parseDescriptorExpansion(lenExp: number): Promise<{ forwardBytes: number }> {
await this.tokenizer.ignore(lenExp);
return this.parseHeader();
}
private async parseHeader(): Promise<{ forwardBytes: number }> {
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
};
}
}