1
0
mirror of synced 2026-05-22 22:53:20 +00:00
Files

191 lines
6.4 KiB
TypeScript

import { fileTypeFromBuffer } from 'file-type';
import ContentType from 'content-type';
import { type MediaType, parse as mimeTypeParse } from 'media-typer';
import initDebug from 'debug';
import { type INativeMetadataCollector, MetadataCollector } from './common/MetadataCollector.js';
import { type IAudioMetadata, type IOptions, type ParserType, TrackType } from './type.js';
import type { IRandomAccessTokenizer, ITokenizer } from 'strtok3';
import { mpegParserLoader } from './mpeg/MpegLoader.js';
import { CouldNotDetermineFileTypeError, UnsupportedFileTypeError } from './ParseError.js';
import { apeParserLoader } from './apev2/Apev2Loader.js';
import { asfParserLoader } from './asf/AsfLoader.js';
import { dsdiffParserLoader } from './dsdiff/DsdiffLoader.js';
import { aiffParserLoader } from './aiff/AiffLoader.js';
import { dsfParserLoader } from './dsf/DsfLoader.js';
import { flacParserLoader } from './flac/FlacLoader.js';
import { matroskaParserLoader } from './matroska/MatroskaLoader.js';
import { mp4ParserLoader } from './mp4/Mp4Loader.js';
import { musepackParserLoader } from './musepack/MusepackLoader.js';
import { oggParserLoader } from './ogg/OggLoader.js';
import { wavpackParserLoader } from './wavpack/WavPackLoader.js';
import { riffParserLoader } from './wav/WaveLoader.js';
import { scanAppendingHeaders } from './core.js';
const debug = initDebug('music-metadata:parser:factory');
export interface IParserLoader {
/**
* Returns a list of supported file extensions
*/
extensions: string[]
mimeTypes: string[]
parserType: ParserType;
/**
* Lazy load the parser implementation class.
*/
load(): Promise<new (metadata: INativeMetadataCollector, tokenizer: ITokenizer, options: IOptions) => ITokenParser>;
}
export interface ITokenParser {
/**
* Parse audio track.
* Called after init(...).
* @returns Promise
*/
parse(): Promise<void>;
}
interface IContentType extends MediaType {
parameters: { [id: string]: string; };
}
export function parseHttpContentType(contentType: string): IContentType {
const type = ContentType.parse(contentType);
const mime = mimeTypeParse(type.type);
return {
type: mime.type,
subtype: mime.subtype,
suffix: mime.suffix,
parameters: type.parameters
} as IContentType;
}
export class ParserFactory {
parsers: IParserLoader[] = [];
constructor() {
[
flacParserLoader,
mpegParserLoader,
apeParserLoader,
mp4ParserLoader,
matroskaParserLoader,
riffParserLoader,
oggParserLoader,
asfParserLoader,
aiffParserLoader,
wavpackParserLoader,
musepackParserLoader,
dsfParserLoader,
dsdiffParserLoader
].forEach(parser => {this.registerParser(parser)});
}
registerParser(parser: IParserLoader): void {
this.parsers.push(parser);
}
async parse(tokenizer: ITokenizer, parserLoader: IParserLoader | undefined, opts?: IOptions): Promise<IAudioMetadata> {
if (tokenizer.supportsRandomAccess()) {
debug('tokenizer supports random-access, scanning for appending headers');
await scanAppendingHeaders(tokenizer as IRandomAccessTokenizer, opts);
} else {
debug('tokenizer does not support random-access, cannot scan for appending headers');
}
if (!parserLoader) {
const buf = new Uint8Array(4100);
if (tokenizer.fileInfo.mimeType) {
parserLoader = this.findLoaderForContentType(tokenizer.fileInfo.mimeType);
}
if (!parserLoader && tokenizer.fileInfo.path) {
parserLoader = this.findLoaderForExtension(tokenizer.fileInfo.path);
}
if (!parserLoader) {
// Parser could not be determined on MIME-type or extension
debug('Guess parser on content...');
await tokenizer.peekBuffer(buf, {mayBeLess: true});
const guessedType = await fileTypeFromBuffer(buf, {mpegOffsetTolerance: 10});
if (!guessedType || !guessedType.mime) {
throw new CouldNotDetermineFileTypeError('Failed to determine audio format');
}
debug(`Guessed file type is mime=${guessedType.mime}, extension=${guessedType.ext}`);
parserLoader = this.findLoaderForContentType(guessedType.mime);
if (!parserLoader) {
throw new UnsupportedFileTypeError(`Guessed MIME-type not supported: ${guessedType.mime}`);
}
}
}
// Parser found, execute parser
debug(`Loading ${parserLoader.parserType} parser...`);
const metadata = new MetadataCollector(opts);
const ParserImpl = await parserLoader.load();
const parser = new ParserImpl(metadata, tokenizer, opts ?? {});
debug(`Parser ${parserLoader.parserType} loaded`);
await parser.parse();
if (metadata.format.trackInfo) {
if (metadata.format.hasAudio === undefined) {
metadata.setFormat('hasAudio', !!metadata.format.trackInfo.find(track => track.type === TrackType.audio));
}
if (metadata.format.hasVideo === undefined) {
metadata.setFormat('hasVideo', !!metadata.format.trackInfo.find(track => track.type === TrackType.video));
}
}
return metadata.toCommonMetadata();
}
/**
* @param filePath - Path, filename or extension to audio file
* @return Parser submodule name
*/
findLoaderForExtension(filePath: string | undefined): IParserLoader | undefined {
if (!filePath)
return;
const extension = getExtension(filePath).toLocaleLowerCase() || filePath;
return this.parsers.find(parser => parser.extensions.indexOf(extension) !== -1);
}
findLoaderForContentType(httpContentType: string): IParserLoader | undefined {
let mime: IContentType;
if (!httpContentType) return;
try {
mime = parseHttpContentType(httpContentType);
} catch (_err) {
debug(`Invalid HTTP Content-Type header value: ${httpContentType}`);
return;
}
const subType = mime.subtype.indexOf('x-') === 0 ? mime.subtype.substring(2) : mime.subtype;
return this.parsers.find(parser => parser.mimeTypes.find(loader => loader.indexOf(`${mime.type}/${subType}`) !== -1));
}
public getSupportedMimeTypes(): string[] {
const mimeTypeSet = new Set<string>();
this.parsers.forEach(loader => {
loader.mimeTypes.forEach(mimeType => {
mimeTypeSet.add(mimeType);
mimeTypeSet.add(mimeType.replace('/', '/x-'));
});
});
return Array.from(mimeTypeSet);
}
}
function getExtension(fname: string): string {
const i = fname.lastIndexOf('.');
return i === -1 ? '' : fname.substring(i);
}