Initial working version

This commit is contained in:
Samuel Kent
2022-12-22 20:22:22 +11:00
parent ce9675a1cc
commit ced7fa5092
902 changed files with 150252 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
import { ITokenizer } from 'strtok3/lib/core';
import { BasicParser } from '../common/BasicParser';
/**
* Abstract parser which tries take ID3v2 and ID3v1 headers.
*/
export declare abstract class AbstractID3Parser extends BasicParser {
static startsWithID3v2Header(tokenizer: ITokenizer): Promise<boolean>;
private id3parser;
parse(): Promise<void>;
/**
* Called after ID3v2 headers are parsed
*/
abstract _parse(): Promise<void>;
protected finalize(): void;
private parseID3v2;
private tryReadId3v2Headers;
}
+60
View File
@@ -0,0 +1,60 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.AbstractID3Parser = void 0;
const core_1 = require("strtok3/lib/core");
const ID3v2Token_1 = require("./ID3v2Token");
const ID3v2Parser_1 = require("./ID3v2Parser");
const ID3v1Parser_1 = require("../id3v1/ID3v1Parser");
const _debug = require("debug");
const BasicParser_1 = require("../common/BasicParser");
const debug = _debug('music-metadata:parser:ID3');
/**
* Abstract parser which tries take ID3v2 and ID3v1 headers.
*/
class AbstractID3Parser extends BasicParser_1.BasicParser {
constructor() {
super(...arguments);
this.id3parser = new ID3v2Parser_1.ID3v2Parser();
}
static async startsWithID3v2Header(tokenizer) {
return (await tokenizer.peekToken(ID3v2Token_1.ID3v2Header)).fileIdentifier === 'ID3';
}
async parse() {
try {
await this.parseID3v2();
}
catch (err) {
if (err instanceof core_1.EndOfStreamError) {
debug(`End-of-stream`);
}
else {
throw err;
}
}
}
finalize() {
return;
}
async parseID3v2() {
await this.tryReadId3v2Headers();
debug("End of ID3v2 header, go to MPEG-parser: pos=%s", this.tokenizer.position);
await this._parse();
if (this.options.skipPostHeaders && this.metadata.hasAny()) {
this.finalize();
}
else {
const id3v1parser = new ID3v1Parser_1.ID3v1Parser();
await id3v1parser.init(this.metadata, this.tokenizer, this.options).parse();
this.finalize();
}
}
async tryReadId3v2Headers() {
const id3Header = await this.tokenizer.peekToken(ID3v2Token_1.ID3v2Header);
if (id3Header.fileIdentifier === "ID3") {
debug("Found ID3v2 header, pos=%s", this.tokenizer.position);
await this.id3parser.parse(this.metadata, this.tokenizer, this.options);
return this.tryReadId3v2Headers();
}
}
}
exports.AbstractID3Parser = AbstractID3Parser;
+32
View File
@@ -0,0 +1,32 @@
/// <reference types="node" />
import { ID3v2MajorVersion } from './ID3v2Token';
import { IWarningCollector } from '../common/MetadataCollector';
export declare function parseGenre(origVal: string): string[];
export declare class FrameParser {
private major;
private warningCollector;
/**
* Create id3v2 frame parser
* @param major - Major version, e.g. (4) for id3v2.4
* @param warningCollector - Used to collect decode issue
*/
constructor(major: ID3v2MajorVersion, warningCollector: IWarningCollector);
readData(b: Buffer, type: string, includeCovers: boolean): any;
protected static fixPictureMimeType(pictureType: string): string;
/**
* Converts TMCL (Musician credits list) or TIPL (Involved people list)
* @param entries
*/
private static functionList;
/**
* id3v2.4 defines that multiple T* values are separated by 0x00
* id3v2.3 defines that TCOM, TEXT, TOLY, TOPE & TPE1 values are separated by /
* @param tag - Tag name
* @param text - Concatenated tag value
* @returns Split tag value
*/
private splitValue;
private static trimArray;
private static readIdentifierAndData;
private static getNullTerminatorLength;
}
+323
View File
@@ -0,0 +1,323 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.FrameParser = exports.parseGenre = void 0;
const initDebug = require("debug");
const Token = require("token-types");
const util = require("../common/Util");
const ID3v2Token_1 = require("./ID3v2Token");
const ID3v1Parser_1 = require("../id3v1/ID3v1Parser");
const debug = initDebug('music-metadata:id3v2:frame-parser');
const defaultEnc = 'latin1'; // latin1 == iso-8859-1;
function parseGenre(origVal) {
// match everything inside parentheses
const genres = [];
let code;
let word = '';
for (const c of origVal) {
if (typeof code === 'string') {
if (c === '(' && code === '') {
word += '(';
code = undefined;
}
else if (c === ')') {
if (word !== '') {
genres.push(word);
word = '';
}
const genre = parseGenreCode(code);
if (genre) {
genres.push(genre);
}
code = undefined;
}
else
code += c;
}
else if (c === '(') {
code = '';
}
else {
word += c;
}
}
if (word) {
if (genres.length === 0 && word.match(/^\d*$/)) {
word = ID3v1Parser_1.Genres[word];
}
genres.push(word);
}
return genres;
}
exports.parseGenre = parseGenre;
function parseGenreCode(code) {
if (code === 'RX')
return 'Remix';
if (code === 'CR')
return 'Cover';
if (code.match(/^\d*$/)) {
return ID3v1Parser_1.Genres[code];
}
}
class FrameParser {
/**
* Create id3v2 frame parser
* @param major - Major version, e.g. (4) for id3v2.4
* @param warningCollector - Used to collect decode issue
*/
constructor(major, warningCollector) {
this.major = major;
this.warningCollector = warningCollector;
}
readData(b, type, includeCovers) {
if (b.length === 0) {
this.warningCollector.addWarning(`id3v2.${this.major} header has empty tag type=${type}`);
return;
}
const { encoding, bom } = ID3v2Token_1.TextEncodingToken.get(b, 0);
const length = b.length;
let offset = 0;
let output = []; // ToDo
const nullTerminatorLength = FrameParser.getNullTerminatorLength(encoding);
let fzero;
const out = {};
debug(`Parsing tag type=${type}, encoding=${encoding}, bom=${bom}`);
switch (type !== 'TXXX' && type[0] === 'T' ? 'T*' : type) {
case 'T*': // 4.2.1. Text information frames - details
case 'IPLS': // v2.3: Involved people list
case 'MVIN':
case 'MVNM':
case 'PCS':
case 'PCST':
const text = util.decodeString(b.slice(1), encoding).replace(/\x00+$/, '');
switch (type) {
case 'TMCL': // Musician credits list
case 'TIPL': // Involved people list
case 'IPLS': // Involved people list
output = this.splitValue(type, text);
output = FrameParser.functionList(output);
break;
case 'TRK':
case 'TRCK':
case 'TPOS':
output = text;
break;
case 'TCOM':
case 'TEXT':
case 'TOLY':
case 'TOPE':
case 'TPE1':
case 'TSRC':
// id3v2.3 defines that TCOM, TEXT, TOLY, TOPE & TPE1 values are separated by /
output = this.splitValue(type, text);
break;
case 'TCO':
case 'TCON':
output = this.splitValue(type, text).map(v => parseGenre(v)).reduce((acc, val) => acc.concat(val), []);
break;
case 'PCS':
case 'PCST':
// TODO: Why `default` not results `1` but `''`?
output = this.major >= 4 ? this.splitValue(type, text) : [text];
output = (Array.isArray(output) && output[0] === '') ? 1 : 0;
break;
default:
output = this.major >= 4 ? this.splitValue(type, text) : [text];
}
break;
case 'TXXX':
output = FrameParser.readIdentifierAndData(b, offset + 1, length, encoding);
output = {
description: output.id,
text: this.splitValue(type, util.decodeString(output.data, encoding).replace(/\x00+$/, ''))
};
break;
case 'PIC':
case 'APIC':
if (includeCovers) {
const pic = {};
offset += 1;
switch (this.major) {
case 2:
pic.format = util.decodeString(b.slice(offset, offset + 3), 'latin1'); // 'latin1'; // latin1 == iso-8859-1;
offset += 3;
break;
case 3:
case 4:
fzero = util.findZero(b, offset, length, defaultEnc);
pic.format = util.decodeString(b.slice(offset, fzero), defaultEnc);
offset = fzero + 1;
break;
default:
throw new Error('Warning: unexpected major versionIndex: ' + this.major);
}
pic.format = FrameParser.fixPictureMimeType(pic.format);
pic.type = ID3v2Token_1.AttachedPictureType[b[offset]];
offset += 1;
fzero = util.findZero(b, offset, length, encoding);
pic.description = util.decodeString(b.slice(offset, fzero), encoding);
offset = fzero + nullTerminatorLength;
pic.data = Buffer.from(b.slice(offset, length));
output = pic;
}
break;
case 'CNT':
case 'PCNT':
output = Token.UINT32_BE.get(b, 0);
break;
case 'SYLT':
// skip text encoding (1 byte),
// language (3 bytes),
// time stamp format (1 byte),
// content tagTypes (1 byte),
// content descriptor (1 byte)
offset += 7;
output = [];
while (offset < length) {
const txt = b.slice(offset, offset = util.findZero(b, offset, length, encoding));
offset += 5; // push offset forward one + 4 byte timestamp
output.push(util.decodeString(txt, encoding));
}
break;
case 'ULT':
case 'USLT':
case 'COM':
case 'COMM':
offset += 1;
out.language = util.decodeString(b.slice(offset, offset + 3), defaultEnc);
offset += 3;
fzero = util.findZero(b, offset, length, encoding);
out.description = util.decodeString(b.slice(offset, fzero), encoding);
offset = fzero + nullTerminatorLength;
out.text = util.decodeString(b.slice(offset, length), encoding).replace(/\x00+$/, '');
output = [out];
break;
case 'UFID':
output = FrameParser.readIdentifierAndData(b, offset, length, defaultEnc);
output = { owner_identifier: output.id, identifier: output.data };
break;
case 'PRIV': // private frame
output = FrameParser.readIdentifierAndData(b, offset, length, defaultEnc);
output = { owner_identifier: output.id, data: output.data };
break;
case 'POPM': // Popularimeter
fzero = util.findZero(b, offset, length, defaultEnc);
const email = util.decodeString(b.slice(offset, fzero), defaultEnc);
offset = fzero + 1;
const dataLen = length - offset;
output = {
email,
rating: b.readUInt8(offset),
counter: dataLen >= 5 ? b.readUInt32BE(offset + 1) : undefined
};
break;
case 'GEOB': { // General encapsulated object
fzero = util.findZero(b, offset + 1, length, encoding);
const mimeType = util.decodeString(b.slice(offset + 1, fzero), defaultEnc);
offset = fzero + 1;
fzero = util.findZero(b, offset, length - offset, encoding);
const filename = util.decodeString(b.slice(offset, fzero), defaultEnc);
offset = fzero + 1;
fzero = util.findZero(b, offset, length - offset, encoding);
const description = util.decodeString(b.slice(offset, fzero), defaultEnc);
output = {
type: mimeType,
filename,
description,
data: b.slice(offset + 1, length)
};
break;
}
// W-Frames:
case 'WCOM':
case 'WCOP':
case 'WOAF':
case 'WOAR':
case 'WOAS':
case 'WORS':
case 'WPAY':
case 'WPUB':
// Decode URL
output = util.decodeString(b.slice(offset, fzero), defaultEnc);
break;
case 'WXXX': {
// Decode URL
fzero = util.findZero(b, offset + 1, length, encoding);
const description = util.decodeString(b.slice(offset + 1, fzero), encoding);
offset = fzero + (encoding === 'utf16le' ? 2 : 1);
output = { description, url: util.decodeString(b.slice(offset, length), defaultEnc) };
break;
}
case 'WFD':
case 'WFED':
output = util.decodeString(b.slice(offset + 1, util.findZero(b, offset + 1, length, encoding)), encoding);
break;
case 'MCDI': {
// Music CD identifier
output = b.slice(0, length);
break;
}
default:
debug('Warning: unsupported id3v2-tag-type: ' + type);
break;
}
return output;
}
static fixPictureMimeType(pictureType) {
pictureType = pictureType.toLocaleLowerCase();
switch (pictureType) {
case 'jpg':
return 'image/jpeg';
case 'png':
return 'image/png';
}
return pictureType;
}
/**
* Converts TMCL (Musician credits list) or TIPL (Involved people list)
* @param entries
*/
static functionList(entries) {
const res = {};
for (let i = 0; i + 1 < entries.length; i += 2) {
const names = entries[i + 1].split(',');
res[entries[i]] = res.hasOwnProperty(entries[i]) ? res[entries[i]].concat(names) : names;
}
return res;
}
/**
* id3v2.4 defines that multiple T* values are separated by 0x00
* id3v2.3 defines that TCOM, TEXT, TOLY, TOPE & TPE1 values are separated by /
* @param tag - Tag name
* @param text - Concatenated tag value
* @returns Split tag value
*/
splitValue(tag, text) {
let values;
if (this.major < 4) {
values = text.split(/\x00/g);
if (values.length > 1) {
this.warningCollector.addWarning(`ID3v2.${this.major} ${tag} uses non standard null-separator.`);
}
else {
values = text.split(/\//g);
}
}
else {
values = text.split(/\x00/g);
}
return FrameParser.trimArray(values);
}
static trimArray(values) {
return values.map(value => value.replace(/\x00+$/, '').trim());
}
static readIdentifierAndData(b, offset, length, encoding) {
const fzero = util.findZero(b, offset, length, encoding);
const id = util.decodeString(b.slice(offset, fzero), encoding);
offset = fzero + FrameParser.getNullTerminatorLength(encoding);
return { id, data: b.slice(offset, length) };
}
static getNullTerminatorLength(enc) {
return enc === 'utf16le' ? 2 : 1;
}
}
exports.FrameParser = FrameParser;
+9
View File
@@ -0,0 +1,9 @@
import { INativeTagMap } from '../common/GenericTagTypes';
import { CaseInsensitiveTagMap } from '../common/CaseInsensitiveTagMap';
/**
* ID3v2.2 tag mappings
*/
export declare const id3v22TagMap: INativeTagMap;
export declare class ID3v22TagMapper extends CaseInsensitiveTagMap {
constructor();
}
+54
View File
@@ -0,0 +1,54 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ID3v22TagMapper = exports.id3v22TagMap = void 0;
const CaseInsensitiveTagMap_1 = require("../common/CaseInsensitiveTagMap");
/**
* ID3v2.2 tag mappings
*/
exports.id3v22TagMap = {
TT2: 'title',
TP1: 'artist',
TP2: 'albumartist',
TAL: 'album',
TYE: 'year',
COM: 'comment',
TRK: 'track',
TPA: 'disk',
TCO: 'genre',
PIC: 'picture',
TCM: 'composer',
TOR: 'originaldate',
TOT: 'originalalbum',
TXT: 'lyricist',
TP3: 'conductor',
TPB: 'label',
TT1: 'grouping',
TT3: 'subtitle',
TLA: 'language',
TCR: 'copyright',
WCP: 'license',
TEN: 'encodedby',
TSS: 'encodersettings',
WAR: 'website',
'COM:iTunPGAP': 'gapless'
/* ToDo: iTunes tags:
'COM:iTunNORM': ,
'COM:iTunSMPB': 'encoder delay',
'COM:iTunes_CDDB_IDs'
*/ ,
PCS: 'podcast',
TCP: "compilation",
TDR: 'date',
TS2: 'albumartistsort',
TSA: 'albumsort',
TSC: 'composersort',
TSP: 'artistsort',
TST: 'titlesort',
WFD: 'podcasturl'
};
class ID3v22TagMapper extends CaseInsensitiveTagMap_1.CaseInsensitiveTagMap {
constructor() {
super(['ID3v2.2'], exports.id3v22TagMap);
}
}
exports.ID3v22TagMapper = ID3v22TagMapper;
+14
View File
@@ -0,0 +1,14 @@
import { IRating, ITag } from '../type';
import { INativeMetadataCollector } from '../common/MetadataCollector';
import { CaseInsensitiveTagMap } from '../common/CaseInsensitiveTagMap';
export declare class ID3v24TagMapper extends CaseInsensitiveTagMap {
static toRating(popm: any): IRating;
constructor();
/**
* Handle post mapping exceptions / correction
* @param {string} tag to post map
* @param warnings USed to register warnings
* @return Common value e.g. "Buena Vista Social Club"
*/
protected postMap(tag: ITag, warnings: INativeMetadataCollector): void;
}
+193
View File
@@ -0,0 +1,193 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ID3v24TagMapper = void 0;
const GenericTagMapper_1 = require("../common/GenericTagMapper");
const util = require("../common/Util");
const CaseInsensitiveTagMap_1 = require("../common/CaseInsensitiveTagMap");
/**
* ID3v2.3/ID3v2.4 tag mappings
*/
const id3v24TagMap = {
// id3v2.3
TIT2: 'title',
TPE1: 'artist',
'TXXX:Artists': 'artists',
TPE2: 'albumartist',
TALB: 'album',
TDRV: 'date',
/**
* Original release year
*/
TORY: 'originalyear',
TPOS: 'disk',
TCON: 'genre',
APIC: 'picture',
TCOM: 'composer',
'USLT:description': 'lyrics',
TSOA: 'albumsort',
TSOT: 'titlesort',
TOAL: 'originalalbum',
TSOP: 'artistsort',
TSO2: 'albumartistsort',
TSOC: 'composersort',
TEXT: 'lyricist',
'TXXX:Writer': 'writer',
TPE3: 'conductor',
// 'IPLS:instrument': 'performer:instrument', // ToDo
TPE4: 'remixer',
'IPLS:arranger': 'arranger',
'IPLS:engineer': 'engineer',
'IPLS:producer': 'producer',
'IPLS:DJ-mix': 'djmixer',
'IPLS:mix': 'mixer',
TPUB: 'label',
TIT1: 'grouping',
TIT3: 'subtitle',
TRCK: 'track',
TCMP: 'compilation',
POPM: 'rating',
TBPM: 'bpm',
TMED: 'media',
'TXXX:CATALOGNUMBER': 'catalognumber',
'TXXX:MusicBrainz Album Status': 'releasestatus',
'TXXX:MusicBrainz Album Type': 'releasetype',
/**
* Release country as documented: https://picard.musicbrainz.org/docs/mappings/#cite_note-0
*/
'TXXX:MusicBrainz Album Release Country': 'releasecountry',
/**
* Release country as implemented // ToDo: report
*/
'TXXX:RELEASECOUNTRY': 'releasecountry',
'TXXX:SCRIPT': 'script',
TLAN: 'language',
TCOP: 'copyright',
WCOP: 'license',
TENC: 'encodedby',
TSSE: 'encodersettings',
'TXXX:BARCODE': 'barcode',
'TXXX:ISRC': 'isrc',
TSRC: 'isrc',
'TXXX:ASIN': 'asin',
'TXXX:originalyear': 'originalyear',
'UFID:http://musicbrainz.org': 'musicbrainz_recordingid',
'TXXX:MusicBrainz Release Track Id': 'musicbrainz_trackid',
'TXXX:MusicBrainz Album Id': 'musicbrainz_albumid',
'TXXX:MusicBrainz Artist Id': 'musicbrainz_artistid',
'TXXX:MusicBrainz Album Artist Id': 'musicbrainz_albumartistid',
'TXXX:MusicBrainz Release Group Id': 'musicbrainz_releasegroupid',
'TXXX:MusicBrainz Work Id': 'musicbrainz_workid',
'TXXX:MusicBrainz TRM Id': 'musicbrainz_trmid',
'TXXX:MusicBrainz Disc Id': 'musicbrainz_discid',
'TXXX:ACOUSTID_ID': 'acoustid_id',
'TXXX:Acoustid Id': 'acoustid_id',
'TXXX:Acoustid Fingerprint': 'acoustid_fingerprint',
'TXXX:MusicIP PUID': 'musicip_puid',
'TXXX:MusicMagic Fingerprint': 'musicip_fingerprint',
WOAR: 'website',
// id3v2.4
// ToDo: In same sequence as defined at http://id3.org/id3v2.4.0-frames
TDRC: 'date',
TYER: 'year',
TDOR: 'originaldate',
// 'TMCL:instrument': 'performer:instrument',
'TIPL:arranger': 'arranger',
'TIPL:engineer': 'engineer',
'TIPL:producer': 'producer',
'TIPL:DJ-mix': 'djmixer',
'TIPL:mix': 'mixer',
TMOO: 'mood',
// additional mappings:
SYLT: 'lyrics',
TSST: 'discsubtitle',
TKEY: 'key',
COMM: 'comment',
TOPE: 'originalartist',
// Windows Media Player
'PRIV:AverageLevel': 'averageLevel',
'PRIV:PeakLevel': 'peakLevel',
// Discogs
'TXXX:DISCOGS_ARTIST_ID': 'discogs_artist_id',
'TXXX:DISCOGS_ARTISTS': 'artists',
'TXXX:DISCOGS_ARTIST_NAME': 'artists',
'TXXX:DISCOGS_ALBUM_ARTISTS': 'albumartist',
'TXXX:DISCOGS_CATALOG': 'catalognumber',
'TXXX:DISCOGS_COUNTRY': 'releasecountry',
'TXXX:DISCOGS_DATE': 'originaldate',
'TXXX:DISCOGS_LABEL': 'label',
'TXXX:DISCOGS_LABEL_ID': 'discogs_label_id',
'TXXX:DISCOGS_MASTER_RELEASE_ID': 'discogs_master_release_id',
'TXXX:DISCOGS_RATING': 'discogs_rating',
'TXXX:DISCOGS_RELEASED': 'date',
'TXXX:DISCOGS_RELEASE_ID': 'discogs_release_id',
'TXXX:DISCOGS_VOTES': 'discogs_votes',
'TXXX:CATALOGID': 'catalognumber',
'TXXX:STYLE': 'genre',
'TXXX:REPLAYGAIN_TRACK_PEAK': 'replaygain_track_peak',
'TXXX:REPLAYGAIN_TRACK_GAIN': 'replaygain_track_gain',
'TXXX:REPLAYGAIN_ALBUM_PEAK': 'replaygain_album_peak',
'TXXX:REPLAYGAIN_ALBUM_GAIN': 'replaygain_album_gain',
'TXXX:MP3GAIN_MINMAX': 'replaygain_track_minmax',
'TXXX:MP3GAIN_ALBUM_MINMAX': 'replaygain_album_minmax',
'TXXX:MP3GAIN_UNDO': 'replaygain_undo',
MVNM: 'movement',
MVIN: 'movementIndex',
PCST: 'podcast',
TCAT: 'category',
TDES: 'description',
TDRL: 'date',
TGID: 'podcastId',
TKWD: 'keywords',
WFED: 'podcasturl'
};
class ID3v24TagMapper extends CaseInsensitiveTagMap_1.CaseInsensitiveTagMap {
static toRating(popm) {
return {
source: popm.email,
rating: popm.rating > 0 ? (popm.rating - 1) / 254 * GenericTagMapper_1.CommonTagMapper.maxRatingScore : undefined
};
}
constructor() {
super(['ID3v2.3', 'ID3v2.4'], id3v24TagMap);
}
/**
* Handle post mapping exceptions / correction
* @param {string} tag to post map
* @param warnings USed to register warnings
* @return Common value e.g. "Buena Vista Social Club"
*/
postMap(tag, warnings) {
switch (tag.id) {
case 'UFID': // decode MusicBrainz Recording Id
if (tag.value.owner_identifier === 'http://musicbrainz.org') {
tag.id += ':' + tag.value.owner_identifier;
tag.value = util.decodeString(tag.value.identifier, 'latin1'); // latin1 == iso-8859-1
}
break;
case 'PRIV':
switch (tag.value.owner_identifier) {
// decode Windows Media Player
case 'AverageLevel':
case 'PeakValue':
tag.id += ':' + tag.value.owner_identifier;
tag.value = tag.value.data.length === 4 ? tag.value.data.readUInt32LE(0) : null;
if (tag.value === null) {
warnings.addWarning(`Failed to parse PRIV:PeakValue`);
}
break;
default:
warnings.addWarning(`Unknown PRIV owner-identifier: ${tag.value.owner_identifier}`);
}
break;
case 'COMM':
tag.value = tag.value ? tag.value.text : null;
break;
case 'POPM':
tag.value = ID3v24TagMapper.toRating(tag.value);
break;
default:
break;
}
}
}
exports.ID3v24TagMapper = ID3v24TagMapper;
+29
View File
@@ -0,0 +1,29 @@
/// <reference types="node" />
import { ITokenizer } from 'strtok3/lib/core';
import { IOptions } from '../type';
import { INativeMetadataCollector } from '../common/MetadataCollector';
export declare class ID3v2Parser {
static removeUnsyncBytes(buffer: Buffer): Buffer;
private static getFrameHeaderLength;
private static readFrameFlags;
private static readFrameData;
/**
* Create a combined tag key, of tag & description
* @param {string} tag e.g.: COM
* @param {string} description e.g. iTunPGAP
* @returns {string} e.g. COM:iTunPGAP
*/
private static makeDescriptionTagName;
private tokenizer;
private id3Header;
private metadata;
private headerType;
private options;
parse(metadata: INativeMetadataCollector, tokenizer: ITokenizer, options: IOptions): Promise<void>;
parseExtendedHeader(): Promise<void>;
parseExtendedHeaderData(dataRemaining: number, extendedHeaderSize: number): Promise<void>;
parseId3Data(dataLen: number): Promise<void>;
private addTag;
private parseMetadata;
private readFrameHeader;
}
+194
View File
@@ -0,0 +1,194 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ID3v2Parser = void 0;
const Token = require("token-types");
const util = require("../common/Util");
const FrameParser_1 = require("./FrameParser");
const ID3v2Token_1 = require("./ID3v2Token");
class ID3v2Parser {
static removeUnsyncBytes(buffer) {
let readI = 0;
let writeI = 0;
while (readI < buffer.length - 1) {
if (readI !== writeI) {
buffer[writeI] = buffer[readI];
}
readI += (buffer[readI] === 0xFF && buffer[readI + 1] === 0) ? 2 : 1;
writeI++;
}
if (readI < buffer.length) {
buffer[writeI++] = buffer[readI];
}
return buffer.slice(0, writeI);
}
static getFrameHeaderLength(majorVer) {
switch (majorVer) {
case 2:
return 6;
case 3:
case 4:
return 10;
default:
throw new Error('header versionIndex is incorrect');
}
}
static readFrameFlags(b) {
return {
status: {
tag_alter_preservation: util.getBit(b, 0, 6),
file_alter_preservation: util.getBit(b, 0, 5),
read_only: util.getBit(b, 0, 4)
},
format: {
grouping_identity: util.getBit(b, 1, 7),
compression: util.getBit(b, 1, 3),
encryption: util.getBit(b, 1, 2),
unsynchronisation: util.getBit(b, 1, 1),
data_length_indicator: util.getBit(b, 1, 0)
}
};
}
static readFrameData(buf, frameHeader, majorVer, includeCovers, warningCollector) {
const frameParser = new FrameParser_1.FrameParser(majorVer, warningCollector);
switch (majorVer) {
case 2:
return frameParser.readData(buf, frameHeader.id, includeCovers);
case 3:
case 4:
if (frameHeader.flags.format.unsynchronisation) {
buf = ID3v2Parser.removeUnsyncBytes(buf);
}
if (frameHeader.flags.format.data_length_indicator) {
buf = buf.slice(4, buf.length);
}
return frameParser.readData(buf, frameHeader.id, includeCovers);
default:
throw new Error('Unexpected majorVer: ' + majorVer);
}
}
/**
* Create a combined tag key, of tag & description
* @param {string} tag e.g.: COM
* @param {string} description e.g. iTunPGAP
* @returns {string} e.g. COM:iTunPGAP
*/
static makeDescriptionTagName(tag, description) {
return tag + (description ? ':' + description : '');
}
async parse(metadata, tokenizer, options) {
this.tokenizer = tokenizer;
this.metadata = metadata;
this.options = options;
const id3Header = await this.tokenizer.readToken(ID3v2Token_1.ID3v2Header);
if (id3Header.fileIdentifier !== 'ID3') {
throw new Error('expected ID3-header file-identifier \'ID3\' was not found');
}
this.id3Header = id3Header;
this.headerType = ('ID3v2.' + id3Header.version.major);
if (id3Header.flags.isExtendedHeader) {
return this.parseExtendedHeader();
}
else {
return this.parseId3Data(id3Header.size);
}
}
async parseExtendedHeader() {
const extendedHeader = await this.tokenizer.readToken(ID3v2Token_1.ExtendedHeader);
const dataRemaining = extendedHeader.size - ID3v2Token_1.ExtendedHeader.len;
if (dataRemaining > 0) {
return this.parseExtendedHeaderData(dataRemaining, extendedHeader.size);
}
else {
return this.parseId3Data(this.id3Header.size - extendedHeader.size);
}
}
async parseExtendedHeaderData(dataRemaining, extendedHeaderSize) {
const buffer = Buffer.alloc(dataRemaining);
await this.tokenizer.readBuffer(buffer, { length: dataRemaining });
return this.parseId3Data(this.id3Header.size - extendedHeaderSize);
}
async parseId3Data(dataLen) {
const buffer = Buffer.alloc(dataLen);
await this.tokenizer.readBuffer(buffer, { length: dataLen });
for (const tag of this.parseMetadata(buffer)) {
if (tag.id === 'TXXX') {
if (tag.value) {
for (const text of tag.value.text) {
this.addTag(ID3v2Parser.makeDescriptionTagName(tag.id, tag.value.description), text);
}
}
}
else if (tag.id === 'COM') {
for (const value of tag.value) {
this.addTag(ID3v2Parser.makeDescriptionTagName(tag.id, value.description), value.text);
}
}
else if (tag.id === 'COMM') {
for (const value of tag.value) {
this.addTag(ID3v2Parser.makeDescriptionTagName(tag.id, value.description), value);
}
}
else if (Array.isArray(tag.value)) {
for (const value of tag.value) {
this.addTag(tag.id, value);
}
}
else {
this.addTag(tag.id, tag.value);
}
}
}
addTag(id, value) {
this.metadata.addTag(this.headerType, id, value);
}
parseMetadata(data) {
let offset = 0;
const tags = [];
while (true) {
if (offset === data.length)
break;
const frameHeaderLength = ID3v2Parser.getFrameHeaderLength(this.id3Header.version.major);
if (offset + frameHeaderLength > data.length) {
this.metadata.addWarning('Illegal ID3v2 tag length');
break;
}
const frameHeaderBytes = data.slice(offset, offset += frameHeaderLength);
const frameHeader = this.readFrameHeader(frameHeaderBytes, this.id3Header.version.major);
const frameDataBytes = data.slice(offset, offset += frameHeader.length);
const values = ID3v2Parser.readFrameData(frameDataBytes, frameHeader, this.id3Header.version.major, !this.options.skipCovers, this.metadata);
if (values) {
tags.push({ id: frameHeader.id, value: values });
}
}
return tags;
}
readFrameHeader(v, majorVer) {
let header;
switch (majorVer) {
case 2:
header = {
id: v.toString('ascii', 0, 3),
length: Token.UINT24_BE.get(v, 3)
};
if (!header.id.match(/[A-Z0-9]{3}/g)) {
this.metadata.addWarning(`Invalid ID3v2.${this.id3Header.version.major} frame-header-ID: ${header.id}`);
}
break;
case 3:
case 4:
header = {
id: v.toString('ascii', 0, 4),
length: (majorVer === 4 ? ID3v2Token_1.UINT32SYNCSAFE : Token.UINT32_BE).get(v, 4),
flags: ID3v2Parser.readFrameFlags(v.slice(8, 10))
};
if (!header.id.match(/[A-Z0-9]{4}/g)) {
this.metadata.addWarning(`Invalid ID3v2.${this.id3Header.version.major} frame-header-ID: ${header.id}`);
}
break;
default:
throw new Error('Unexpected majorVer: ' + majorVer);
}
return header;
}
}
exports.ID3v2Parser = ID3v2Parser;
+73
View File
@@ -0,0 +1,73 @@
import * as util from '../common/Util';
import { IGetToken } from 'strtok3/lib/core';
/**
* The picture type according to the ID3v2 APIC frame
* Ref: http://id3.org/id3v2.3.0#Attached_picture
*/
export declare enum AttachedPictureType {
'Other' = 0,
"32x32 pixels 'file icon' (PNG only)" = 1,
'Other file icon' = 2,
'Cover (front)' = 3,
'Cover (back)' = 4,
'Leaflet page' = 5,
'Media (e.g. label side of CD)' = 6,
'Lead artist/lead performer/soloist' = 7,
'Artist/performer' = 8,
'Conductor' = 9,
'Band/Orchestra' = 10,
'Composer' = 11,
'Lyricist/text writer' = 12,
'Recording Location' = 13,
'During recording' = 14,
'During performance' = 15,
'Movie/video screen capture' = 16,
'A bright coloured fish' = 17,
'Illustration' = 18,
'Band/artist logotype' = 19,
'Publisher/Studio logotype' = 20
}
export declare type ID3v2MajorVersion = 2 | 3 | 4;
export interface IExtendedHeader {
size: number;
extendedFlags: number;
sizeOfPadding: number;
crcDataPresent: boolean;
}
/**
* 28 bits (representing up to 256MB) integer, the msb is 0 to avoid 'false syncsignals'.
* 4 * %0xxxxxxx
*/
export declare const UINT32SYNCSAFE: {
get: (buf: Uint8Array, off: number) => number;
len: number;
};
/**
* ID3v2 tag header
*/
export interface IID3v2header {
fileIdentifier: string;
version: {
major: ID3v2MajorVersion;
revision: number;
};
flags: {
unsynchronisation: boolean;
isExtendedHeader: boolean;
expIndicator: boolean;
footer: boolean;
};
size: number;
}
/**
* ID3v2 header
* Ref: http://id3.org/id3v2.3.0#ID3v2_header
* ToDo
*/
export declare const ID3v2Header: IGetToken<IID3v2header>;
export declare const ExtendedHeader: IGetToken<IExtendedHeader>;
export interface ITextEncoding {
encoding: util.StringEncoding;
bom?: boolean;
}
export declare const TextEncodingToken: IGetToken<ITextEncoding>;
+106
View File
@@ -0,0 +1,106 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.TextEncodingToken = exports.ExtendedHeader = exports.ID3v2Header = exports.UINT32SYNCSAFE = exports.AttachedPictureType = void 0;
const Token = require("token-types");
const util = require("../common/Util");
/**
* The picture type according to the ID3v2 APIC frame
* Ref: http://id3.org/id3v2.3.0#Attached_picture
*/
var AttachedPictureType;
(function (AttachedPictureType) {
AttachedPictureType[AttachedPictureType["Other"] = 0] = "Other";
AttachedPictureType[AttachedPictureType["32x32 pixels 'file icon' (PNG only)"] = 1] = "32x32 pixels 'file icon' (PNG only)";
AttachedPictureType[AttachedPictureType["Other file icon"] = 2] = "Other file icon";
AttachedPictureType[AttachedPictureType["Cover (front)"] = 3] = "Cover (front)";
AttachedPictureType[AttachedPictureType["Cover (back)"] = 4] = "Cover (back)";
AttachedPictureType[AttachedPictureType["Leaflet page"] = 5] = "Leaflet page";
AttachedPictureType[AttachedPictureType["Media (e.g. label side of CD)"] = 6] = "Media (e.g. label side of CD)";
AttachedPictureType[AttachedPictureType["Lead artist/lead performer/soloist"] = 7] = "Lead artist/lead performer/soloist";
AttachedPictureType[AttachedPictureType["Artist/performer"] = 8] = "Artist/performer";
AttachedPictureType[AttachedPictureType["Conductor"] = 9] = "Conductor";
AttachedPictureType[AttachedPictureType["Band/Orchestra"] = 10] = "Band/Orchestra";
AttachedPictureType[AttachedPictureType["Composer"] = 11] = "Composer";
AttachedPictureType[AttachedPictureType["Lyricist/text writer"] = 12] = "Lyricist/text writer";
AttachedPictureType[AttachedPictureType["Recording Location"] = 13] = "Recording Location";
AttachedPictureType[AttachedPictureType["During recording"] = 14] = "During recording";
AttachedPictureType[AttachedPictureType["During performance"] = 15] = "During performance";
AttachedPictureType[AttachedPictureType["Movie/video screen capture"] = 16] = "Movie/video screen capture";
AttachedPictureType[AttachedPictureType["A bright coloured fish"] = 17] = "A bright coloured fish";
AttachedPictureType[AttachedPictureType["Illustration"] = 18] = "Illustration";
AttachedPictureType[AttachedPictureType["Band/artist logotype"] = 19] = "Band/artist logotype";
AttachedPictureType[AttachedPictureType["Publisher/Studio logotype"] = 20] = "Publisher/Studio logotype";
})(AttachedPictureType = exports.AttachedPictureType || (exports.AttachedPictureType = {}));
/**
* 28 bits (representing up to 256MB) integer, the msb is 0 to avoid 'false syncsignals'.
* 4 * %0xxxxxxx
*/
exports.UINT32SYNCSAFE = {
get: (buf, off) => {
return buf[off + 3] & 0x7f | ((buf[off + 2]) << 7) |
((buf[off + 1]) << 14) | ((buf[off]) << 21);
},
len: 4
};
/**
* ID3v2 header
* Ref: http://id3.org/id3v2.3.0#ID3v2_header
* ToDo
*/
exports.ID3v2Header = {
len: 10,
get: (buf, off) => {
return {
// ID3v2/file identifier "ID3"
fileIdentifier: new Token.StringType(3, 'ascii').get(buf, off),
// ID3v2 versionIndex
version: {
major: Token.INT8.get(buf, off + 3),
revision: Token.INT8.get(buf, off + 4)
},
// ID3v2 flags
flags: {
// Unsynchronisation
unsynchronisation: util.getBit(buf, off + 5, 7),
// Extended header
isExtendedHeader: util.getBit(buf, off + 5, 6),
// Experimental indicator
expIndicator: util.getBit(buf, off + 5, 5),
footer: util.getBit(buf, off + 5, 4)
},
size: exports.UINT32SYNCSAFE.get(buf, off + 6)
};
}
};
exports.ExtendedHeader = {
len: 10,
get: (buf, off) => {
return {
// Extended header size
size: Token.UINT32_BE.get(buf, off),
// Extended Flags
extendedFlags: Token.UINT16_BE.get(buf, off + 4),
// Size of padding
sizeOfPadding: Token.UINT32_BE.get(buf, off + 6),
// CRC data present
crcDataPresent: util.getBit(buf, off + 4, 31)
};
}
};
exports.TextEncodingToken = {
len: 1,
get: (buf, off) => {
switch (buf.readUInt8(off)) {
case 0x00:
return { encoding: 'latin1' }; // binary
case 0x01:
return { encoding: 'utf16le', bom: true };
case 0x02:
return { encoding: 'utf16le', bom: false };
case 0x03:
return { encoding: 'utf8', bom: false };
default:
return { encoding: 'utf8', bom: false };
}
}
};