jamp3
Version:
mp3, id3v1, id3v2 - reader & writer
283 lines (265 loc) • 8.26 kB
text/typescript
import {Readable} from 'stream';
import fse from 'fs-extra';
import {IMP3} from './mp3.types';
import {ID3v1Reader} from '../id3v1/id3v1.reader';
import {ID3v2Reader} from '../id3v2/id3v2.reader';
import {collapseRawHeader, MPEGFrameReader} from './mp3.mpeg.frame';
import {BufferUtils} from '../common/buffer';
import {getBestMPEGChain} from './mp3.mpeg.chain';
import {ReaderStream} from '../common/stream-reader';
export interface MP3ReaderOptions extends IMP3.ReadOptions {
streamSize?: number;
}
export class MP3Reader {
private options: MP3ReaderOptions = {};
private layout: IMP3.RawLayout = {
frameheaders: [],
headframes: [],
tags: [],
size: 0
};
private id3v2reader = new ID3v2Reader();
private id3v1reader = new ID3v1Reader();
private mpegFramereader = new MPEGFrameReader();
private stream: ReaderStream = new ReaderStream();
private scanMpeg = true;
private scanid3v1 = true;
private scanid3v2 = true;
private scanMPEGFrame = true;
private hasMPEGHeadFrame = false;
private readFullMPEGFrame(chunk: Buffer, pos: number, header: IMP3.FrameRawHeader): boolean {
if (this.demandData(chunk, pos)) {
return true;
}
header.offset = this.stream.pos - chunk.length + pos;
const a = this.mpegFramereader.readFrame(chunk, pos, header);
if (a.frame) {
if (a.frame.vbri || a.frame.xing) {
this.layout.headframes.push(a.frame);
}
this.layout.frameheaders.push(a.frame.header);
if (this.options.mpegQuick) {
this.hasMPEGHeadFrame = this.hasMPEGHeadFrame || !!a.frame.mode;
if (this.layout.frameheaders.length % 50 === 0) {
if (this.hasMPEGHeadFrame) {
this.scanMpeg = false;
} else {
const chain = getBestMPEGChain(this.layout.frameheaders, 20);
if (chain && chain.count >= 10) {
this.scanMpeg = false;
}
}
}
}
}
return false;
}
private readMPEGFrame(chunk: Buffer, pos: number): boolean {
if (this.demandData(chunk, pos)) {
return true;
}
const header = this.mpegFramereader.readMPEGFrameHeader(chunk, pos);
if (header) {
this.scanid3v2 = false; // no more scanning for id3v2 after audio start
if (!this.scanMPEGFrame) {
header.offset = this.stream.pos - chunk.length + pos;
this.layout.frameheaders.push(collapseRawHeader(header));
} else {
return this.readFullMPEGFrame(chunk, pos, header);
}
}
return false;
}
private readID3V1(chunk: Buffer, pos: number): boolean {
if (this.demandData(chunk, pos)) {
return true;
}
const tag = this.id3v1reader.readTag(chunk.slice(pos, pos + 128));
if (!tag) {
return false;
}
tag.start = this.stream.pos - chunk.length + pos;
tag.end = tag.start + 128;
this.layout.tags.push(tag);
if (!this.stream.end || chunk.length - 128 - pos > 0) {
// we need to rewind and scan, there are several unfortunate other tags which may be detected as valid td3v1, e.g. "APETAGEX", "TAG+", "CUSTOMTAG" or just a equal looking stream position
this.stream.unshift(chunk.slice(pos + 1));
} else {
this.stream.unshift(chunk.slice(pos + 128));
}
return true;
}
private async readID3V2(chunk: Buffer, pos: number): Promise<boolean> {
if (this.demandData(chunk, pos)) {
return true;
}
const id3Header = this.id3v2reader.headerReader.readID3v2Header(chunk, pos);
if (!id3Header || !id3Header.valid) {
return false;
}
const start = this.stream.pos - chunk.length + pos;
this.stream.unshift(chunk.slice(pos));
const result = await this.id3v2reader.readReaderStream(this.stream);
if (result) {
let rest = result.rest || BufferUtils.zeroBuffer(0);
if (result.tag && result.tag.head.valid) {
this.layout.tags.push(result.tag);
result.tag.start = start;
result.tag.end = this.stream.pos - rest.length;
if (!this.options.detectDuplicateID3v2) {
this.scanid3v2 = false;
}
if (this.options.id3v1IfNotID3v2) {
this.scanid3v1 = false;
}
} else {
rest = rest.slice(1);
}
this.stream.unshift(rest);
return true;
}
return false;
}
private async processChunkToEnd(chunk: Buffer): Promise<boolean> {
if (this.options.streamSize !== undefined) {
return false;
}
// we are done here, but scroll to end to get full stream size
// TODO: better way to get the stream size?
await this.stream.consumeToEnd();
return false;
}
private async processChunkID3v1(chunk: Buffer): Promise<boolean> {
let pos = 0;
if (!this.stream.end && (this.stream.buffersLength > 200)) {
this.stream.skip(this.stream.buffersLength - 200);
chunk = this.stream.get(200);
pos = 0;
}
while (chunk.length - pos >= 4) {
const c1 = chunk[pos];
const c2 = chunk[pos + 1];
const c3 = chunk[pos + 2];
if ((c1 === 84) && (c2 === 65) && (c3 === 71) && this.readID3V1(chunk, pos)) {
return true;
}
pos++;
}
return false;
}
private async processChunkID3v1AndID3v2AndMpeg(chunk: Buffer): Promise<boolean> {
let pos = 0;
while (chunk.length - pos >= 4) {
const c1 = chunk[pos];
const c2 = chunk[pos + 1];
const c3 = chunk[pos + 2];
if (this.scanid3v2 && c1 === 73 && c2 === 68 && c3 === 51 && (await this.readID3V2(chunk, pos))) {
return true;
} else if (this.scanMpeg && c1 === 255 && this.readMPEGFrame(chunk, pos)) {
return true;
} else if (this.scanid3v1 && c1 === 84 && c2 === 65 && c3 === 71 && this.readID3V1(chunk, pos)) {
return true;
}
pos++;
}
return false;
}
private async processChunkID3v1AndID3v2(chunk: Buffer): Promise<boolean> {
let pos = 0;
while (chunk.length - pos >= 4) {
const c1 = chunk[pos];
const c2 = chunk[pos + 1];
const c3 = chunk[pos + 2];
if ((c1 === 73 && c2 === 68 && c3 === 51) && (await this.readID3V2(chunk, pos))) {
return true;
} else if ((c1 === 84 && c2 === 65 && c3 === 71) && this.readID3V1(chunk, pos)) {
return true;
}
pos++;
}
return false;
}
private demandData(chunk: Buffer, pos: number): boolean {
if (!this.stream.end && (chunk.length - pos) < 200) {
// check if enough in chunk to read the frame header
this.stream.unshift(chunk.slice(pos));
return true;
}
return false;
}
private async processChunk(chunk: Buffer): Promise<boolean> {
if (this.demandData(chunk, 0)) {
return true;
}
if (!this.scanMpeg && !this.scanid3v2 && !this.scanid3v1) {
return this.processChunkToEnd(chunk);
} else if (!this.scanMpeg && !this.scanid3v2) {
if (await this.processChunkID3v1(chunk)) {
return true;
}
} else if (!this.scanMpeg) {
if (await this.processChunkID3v1AndID3v2(chunk)) {
return true;
}
} else if (await this.processChunkID3v1AndID3v2AndMpeg(chunk)) {
return true;
}
if (chunk.length > 3) {
this.stream.unshift(chunk.slice(chunk.length - 3));
}
return true;
}
private async scan(): Promise<void> {
if (this.stream.end) {
return;
}
const requestChunkLength = 20000;
let go = true;
while (go) {
const data = await this.stream.read(requestChunkLength);
if (!data || (data.length === 0)) {
go = false;
break;
}
try {
go = await this.processChunk(data);
} catch (e: any) {
return Promise.reject(e);
}
}
this.layout.size = (this.options.streamSize !== undefined) ? this.options.streamSize : this.stream.pos;
}
private setOptions(options: MP3ReaderOptions): void {
this.options = options || {};
this.scanMpeg = options.mpeg || options.mpegQuick || false;
this.scanid3v1 = options.id3v1 || options.id3v1IfNotID3v2 || false;
this.scanid3v2 = options.id3v2 || options.id3v1IfNotID3v2 || false;
this.layout = {
headframes: [],
frameheaders: [],
tags: [],
size: 0
};
}
async read(filename: string, options: MP3ReaderOptions): Promise<IMP3.RawLayout> {
this.setOptions(options);
if (!options.streamSize) {
options.streamSize = (await fse.stat(filename)).size;
}
await this.stream.open(filename);
try {
await this.scan();
this.stream.close();
} catch (e: any) {
this.stream.close();
return Promise.reject(e);
}
return this.layout;
}
async readStream(stream: Readable, options: MP3ReaderOptions): Promise<IMP3.RawLayout> {
this.setOptions(options);
await this.stream.openStream(stream);
await this.scan();
return this.layout;
}
}