id3v2
Version:
Reads ID3v2 metadata
390 lines (360 loc) • 9.04 kB
text/typescript
import { readFileSync } from 'fs';
import * as iconv from 'iconv-lite';
/*
* Reading of ID3 tag based on http://id3.org/
*/
const ID3v1Genres = [
'Blues',
'Classic Rock',
'Country',
'Dance',
'Disco',
'Funk',
'Grunge',
'Hip-Hop',
'Jazz',
'Metal',
'New Age',
'Oldies',
'Other',
'Pop',
'Rhythm and Blues',
'Rap',
'Reggae',
'Rock',
'Techno',
'Industrial',
'Alternative',
'Ska',
'Death Metal',
'Pranks',
'Soundtrack',
'Euro-Techno',
'Ambient',
'Trip-Hop',
'Vocal',
'Jazz & Funk',
'Fusion',
'Trance',
'Classical',
'Instrumental',
'Acid',
'House',
'Game',
'Sound Clip',
'Gospel',
'Noise',
'Alternative Rock',
'Bass',
'Soul',
'Punk',
'Space',
'Meditative',
'Instrumental Pop',
'Instrumental Rock',
'Ethnic',
'Gothic',
'Darkwave',
'Techno-Industrial',
'Electronic',
'Pop-Folk',
'Eurodance',
'Dream',
'Southern Rock',
'Comedy',
'Cult',
'Gangsta',
'Top 40',
'Christian Rap',
'Pop/Funk',
'Jungle',
'Native US',
'Cabaret',
'New Wave',
'Psychedelic',
'Rave',
'Showtunes',
'Trailer',
'Lo-Fi',
'Tribal',
'Acid Punk',
'Acid Jazz',
'Polka',
'Retro',
'Musical',
'Rock ’n’ Roll',
'Hard Rock'
];
enum ID3HeaderOffsets {
MAGIC = 0,
MAJOR_VERSION = MAGIC + 3,
MINOR_VERSION = MAGIC + 4,
FLAGS = MAGIC + 5,
SIZE = MAGIC + 6,
END_OF_HEADER = 10
}
enum ID3HeaderFlags {
Unsynchronisation = 128, // 10000000
Extended = 64, // 01000000
Experimental = 32, // 00100000
Footer = 16, // 00010000
Others = 15 // 00001111
}
enum ID3ExtendedHeaderOffsets {
SIZE = 0,
NUMBER_OF_FLAGS = SIZE + 4,
FLAGS = SIZE + 5
// END_OF_HEADER depends on NUMBER_OF_FLAGS
}
enum ID3FrameOffsets {
ID = 0,
SIZE = ID + 4,
FLAGS = ID + 8,
END_OF_HEADER = 10
}
enum ID3FrameFlags {
Grouping = 64, // 00000000 01000000
Compression = 8, // 00000000 00001000
Encryption = 4, // 00000000 00000100
Unsynchronisation = 2, // 00000000 00000010
DataLengthIndicator = 1 // 00000000 00000001
}
const hasFlag = (flags: number, flag: number) => (flags & flag) === flag;
const unsyncedLength = (input: number) =>
(input & 127) +
((input & (127 << 8)) >> 1) +
((input & (127 << 16)) >> 1) +
((input & (127 << 24)) >>> 1);
const isID3 = (buffer: Buffer) =>
buffer
.slice(ID3HeaderOffsets.MAGIC, ID3HeaderOffsets.MAGIC + 3)
.toString() === 'ID3';
const isID3v24 = (buffer: Buffer) =>
buffer.readIntBE(ID3HeaderOffsets.MAJOR_VERSION, 1) <= 4;
const getID3HeaderFlags = (buffer: Buffer): HeaderFlags => {
const flags = buffer.readIntBE(ID3HeaderOffsets.FLAGS, 1);
return {
unsynchronisation: hasFlag(flags, ID3HeaderFlags.Unsynchronisation),
extendedHeader: hasFlag(flags, ID3HeaderFlags.Extended),
experimental: hasFlag(flags, ID3HeaderFlags.Experimental),
footer: hasFlag(flags, ID3HeaderFlags.Footer),
others: (flags & ID3HeaderFlags.Others) !== 0
};
};
const isPadding = (buffer: Buffer) => buffer.readUInt32BE(0) === 0;
const getID3FrameFlags = (buffer: Buffer) => {
const flags = buffer.readInt16BE(ID3FrameOffsets.FLAGS);
return {
grouping: hasFlag(flags, ID3FrameFlags.Grouping),
compression: hasFlag(flags, ID3FrameFlags.Compression),
encryption: hasFlag(flags, ID3FrameFlags.Encryption),
unsynchronisation: hasFlag(flags, ID3FrameFlags.Unsynchronisation),
dataLengthIndicator: hasFlag(flags, ID3FrameFlags.DataLengthIndicator)
};
};
// tslint:disable-next-line cyclomatic-complexity
const getFrameData = (buffer: Buffer) => {
const name = buffer
.slice(ID3FrameOffsets.ID, ID3FrameOffsets.ID + 4)
.toString();
const length = unsyncedLength(buffer.readInt32BE(ID3FrameOffsets.SIZE));
const encoding = (() => {
const value = buffer.readInt8(ID3FrameOffsets.END_OF_HEADER);
switch (value) {
case 0:
return 'ISO-8859-1';
case 1:
return 'UTF-16';
case 2:
return 'UTF-16';
case 3:
return 'UTF-8';
}
return '';
})();
let data: any;
switch (name) {
case 'TXXX':
{
const idx = buffer.indexOf(0, ID3FrameOffsets.END_OF_HEADER + 1);
const description = iconv.decode(
buffer.slice(ID3FrameOffsets.END_OF_HEADER + 1, idx),
encoding
);
data = {
description,
value: iconv.decode(
buffer.slice(
ID3FrameOffsets.END_OF_HEADER + 1 + description.length + 1,
ID3FrameOffsets.END_OF_HEADER + length
),
encoding
)
};
}
break;
case 'TPOS':
case 'TLEN':
case 'TYER':
case 'TSSE':
case 'TCON':
case 'TRCK':
case 'TALB':
case 'TIT2':
case 'TDRC':
case 'TPE1':
case 'TPE2':
data = iconv.decode(
buffer.slice(
ID3FrameOffsets.END_OF_HEADER + 1,
ID3FrameOffsets.END_OF_HEADER + length
),
encoding
);
break;
case 'POPM':
{
const idx = buffer.indexOf(0, ID3FrameOffsets.END_OF_HEADER);
const email = buffer
.slice(ID3FrameOffsets.END_OF_HEADER, idx)
.toString();
const rating = buffer.readUInt8(idx + 1);
// TODO: counter
data = {
email,
rating,
counter: undefined
};
}
break;
case 'UFID':
{
const idx = buffer.indexOf(0, ID3FrameOffsets.END_OF_HEADER);
const ownerIdentifier = buffer
.slice(ID3FrameOffsets.END_OF_HEADER, idx)
.toString();
data = {
ownerIdentifier,
identifier: buffer.slice(
ID3FrameOffsets.END_OF_HEADER + ownerIdentifier.length,
length
)
};
}
break;
}
return {
name,
length,
flags: getID3FrameFlags(buffer),
data
};
};
interface HeaderFlags {
unsynchronisation: boolean;
extendedHeader: boolean;
experimental: boolean;
footer: boolean;
others: boolean;
}
interface FrameData {
name: string;
length: number;
flags: {
grouping: boolean;
compression: boolean;
encryption: boolean;
unsynchronisation: boolean;
dataLengthIndicator: boolean;
};
data: any;
}
export class ID3v2 {
private readonly flags?: HeaderFlags;
private readonly frames: { [name: string]: FrameData | FrameData[] } = {};
// tslint:disable-next-line cyclomatic-complexity
constructor(path: string) {
const buffer = readFileSync(path);
if (!isID3(buffer) || !isID3v24(buffer)) {
return;
}
this.flags = getID3HeaderFlags(buffer);
if (this.flags.others) {
return;
}
const size = unsyncedLength(buffer.readInt32BE(ID3HeaderOffsets.SIZE));
let startOfFrame = ID3HeaderOffsets.END_OF_HEADER;
if (this.flags.extendedHeader) {
const extendedHeaderBuffer = buffer.slice(ID3HeaderOffsets.END_OF_HEADER);
const extendedHeaderSize = unsyncedLength(
extendedHeaderBuffer.readInt32BE(ID3ExtendedHeaderOffsets.SIZE)
);
startOfFrame += extendedHeaderSize;
}
while (startOfFrame < size) {
const frameBuffer = buffer.slice(startOfFrame);
if (isPadding(frameBuffer)) {
break;
}
const frame = getFrameData(frameBuffer);
if (this.frames[frame.name]) {
if (!Array.isArray(this.frames[frame.name])) {
this.frames[frame.name] = [this.frames[frame.name] as FrameData];
}
(this.frames[frame.name] as FrameData[]).push(frame);
} else {
this.frames[frame.name] = frame;
}
startOfFrame += ID3FrameOffsets.END_OF_HEADER + frame.length;
}
}
private getFrameData(name: string): any {
const frame = this.frames[name];
if (Array.isArray(frame)) {
return frame.map(entry => entry.data);
}
return frame ? frame.data : undefined;
}
get ufid(): { ownerIdentifier: string; identifier: Buffer } {
return this.getFrameData('UFID');
}
get genre(): string {
let genre = this.getFrameData('TCON') as string;
if (genre) {
const idx = parseInt(genre.replace(/[\(\)]/g, ''), 10);
if (idx <= ID3v1Genres.length) {
genre = ID3v1Genres[idx];
}
}
return genre;
}
get track(): string {
return this.getFrameData('TRCK');
}
get album(): string {
return this.getFrameData('TALB');
}
get title(): string {
return this.getFrameData('TIT2');
}
get year(): string {
return this.getFrameData('TDRC') || this.getFrameData('TYER');
}
get artist(): string {
return this.getFrameData('TPE1');
}
get popularimeter(): { email: string; rating: number; counter?: number } {
return this.getFrameData('POPM');
}
get length(): string {
return this.getFrameData('TLEN');
}
get set(): string {
return this.getFrameData('TPOS');
}
get text():
| { description: string; value: string }
| { description: string; value: string }[] {
return this.getFrameData('TXXX');
}
}