tcdweb/js/metadata.js
2026-07-05 00:44:08 +02:00

318 lines
11 KiB
JavaScript

const Metadata = {
parse(arrayBuffer, fileName) {
const dv = new DataView(arrayBuffer);
const size = arrayBuffer.byteLength;
const tags = [];
if (size >= 10 && this._str(dv, 0, 3) === 'ID3')
this._id3v2(dv, size, tags);
if (size >= 4 && this._str(dv, 0, 4) === 'fLaC')
this._flac(dv, size, tags);
if (size >= 32 && this._str(dv, 0, 8) === 'APETAGEX')
this._ape(dv, size, 0, tags);
if (size >= 128) {
const off = size - 128;
if (this._str(dv, off, 3) === 'TAG')
this._id3v1(dv, off, tags);
}
return tags;
},
_str(dv, off, len) {
const b = new Uint8Array(dv.buffer, off, len);
const end = b.indexOf(0);
return new TextDecoder('latin1').decode(end < 0 ? b : b.subarray(0, end));
},
_utf16(dv, off, max) {
if (max < 2) return '';
const bom = dv.getUint16(off, false);
const le = bom !== 0xFEFF;
let s = '', i = off + 2;
while (i + 1 < off + max) {
const c = dv.getUint16(i, le);
if (c === 0) break;
if (c >= 0xD800 && c <= 0xDFFF && i + 3 < off + max) {
const c2 = dv.getUint16(i + 2, le);
s += String.fromCodePoint(0x10000 + ((c - 0xD800) << 10) + (c2 - 0xDC00));
i += 4;
} else {
s += String.fromCharCode(c);
i += 2;
}
}
return s;
},
_latin1(dv, off, max) {
const b = new Uint8Array(dv.buffer, off, max);
const end = b.indexOf(0);
return new TextDecoder('latin1').decode(end < 0 ? b : b.subarray(0, end));
},
_ssint(dv, off) {
return (dv.getUint8(off) << 21) | (dv.getUint8(off + 1) << 14) |
(dv.getUint8(off + 2) << 7) | dv.getUint8(off + 3);
},
_fid(id) {
const m = {
TIT2: 'Title', TPE1: 'Artist', TPE2: 'Album Artist', TALB: 'Album',
TYER: 'Year', TDRC: 'Year', TDRL: 'Release Time', TRCK: 'Track',
TPOS: 'Disc', TCON: 'Genre', COMM: 'Comment', TCOP: 'Copyright',
TPUB: 'Publisher', TENC: 'Encoded By', TSSE: 'Encoder',
TCOM: 'Composer', TEXT: 'Lyricist', TLAN: 'Language', TBPM: 'BPM',
TKEY: 'Initial Key', USLT: 'Lyrics',
TT2: 'Title', TP1: 'Artist', TP2: 'Album Artist', TAL: 'Album',
TYE: 'Year', TRK: 'Track', TPA: 'Disc', TCO: 'Genre',
COM: 'Comment', TCR: 'Copyright', TPB: 'Publisher',
TEN: 'Encoded By', TSS: 'Encoder', TCM: 'Composer',
};
return m[id] || id;
},
_rdFrm(dv, off, id, sz) {
const enc = dv.getUint8(off);
if (id === 'COMM' || id === 'COM') {
let p = off + 4;
if (enc === 0 || enc === 3) {
while (p < off + sz && dv.getUint8(p) !== 0) p++;
p++;
return enc === 0
? this._latin1(dv, p, off + sz - p)
: this._str(dv, p, off + sz - p);
}
while (p + 1 < off + sz) {
if (dv.getUint16(p, enc === 2) === 0) { p += 2; break; }
p += 2;
}
return this._utf16(dv, p, off + sz - p);
}
if (id === 'WXXX') {
let p = off + 1;
if (enc === 0 || enc === 3) {
while (p < off + sz && dv.getUint8(p) !== 0) p++;
return this._str(dv, p + 1, off + sz - p - 1);
}
while (p + 1 < off + sz) {
if (dv.getUint16(p, enc === 2) === 0) { p += 2; break; }
p += 2;
}
return this._str(dv, p, off + sz - p);
}
if (id.startsWith('T') || (id.length === 3 && id.startsWith('T'))) {
if (enc === 0) return this._latin1(dv, off + 1, sz - 1);
if (enc === 3) return this._str(dv, off + 1, sz - 1);
return this._utf16(dv, off + 1, sz - 1);
}
if (id === 'APIC' || id === 'PIC') return null;
return '';
},
_id3v2(dv, size, tags) {
try {
const ver = dv.getUint8(3);
const fl = dv.getUint8(5);
const tagSz = this._ssint(dv, 6);
let off = 10, end = off + tagSz;
if (end > size) return;
const vs = `ID3v2.${ver}.${dv.getUint8(4)}`;
if (fl & 0x40) {
off += ver >= 4 ? this._ssint(dv, off) : dv.getUint32(off, false);
}
while (off + (ver === 2 ? 6 : 10) <= end) {
const fid = this._str(dv, off, ver === 2 ? 3 : 4);
if (fid.length < (ver === 2 ? 3 : 4) || fid.indexOf('\x00') >= 0) break;
let fsz;
if (ver === 2) {
fsz = (dv.getUint8(off + 3) << 16) | (dv.getUint8(off + 4) << 8) | dv.getUint8(off + 5);
off += 6;
} else {
fsz = ver >= 4 ? this._ssint(dv, off + 4) : dv.getUint32(off + 4, false);
off += 10;
}
if (fsz === 0 || off + fsz > end) break;
const val = this._rdFrm(dv, off, fid, fsz);
if (val) tags.push({ source: vs, key: this._fid(fid), value: val });
off += fsz;
}
} catch (_) {}
},
_id3v1(dv, off, tags) {
try {
const src = 'ID3v1';
const title = this._str(dv, off + 3, 30).trim();
const artist = this._str(dv, off + 33, 30).trim();
const album = this._str(dv, off + 63, 30).trim();
const year = this._str(dv, off + 93, 4).trim();
const comment = this._str(dv, off + 97, 30).trim();
const genre = dv.getUint8(off + 127);
const genres = [
'Blues','Classic Rock','Country','Dance','Disco','Funk','Grunge','Hip-Hop',
'Jazz','Metal','New Age','Oldies','Other','Pop','R&B','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','Alt 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 Rap',
'Top 40','Christian Rap','Pop/Funk','Jungle','Native American','Cabaret',
'New Wave','Psychedelic','Rave','Showtunes','Trailer','Lo-Fi','Tribal',
'Acid Punk','Acid Jazz','Polka','Retro','Musical','Rock & Roll','Hard Rock',
'Folk','Folk/Rock','National Folk','Swing','Fast-Fusion','Bebob','Latin',
'Revival','Celtic','Bluegrass','Avantgarde','Gothic Rock','Progressive Rock',
'Psychedelic Rock','Symphonic Rock','Slow Rock','Big Band','Chorus',
'Easy Listening','Acoustic','Humour','Speech','Chanson','Opera','Chamber Music',
'Sonata','Symphony','Booty Bass','Primus','Porn Groove','Satire','Slow Jam',
'Club','Tango','Samba','Folklore','Ballad','Power Ballad','Rhythmic Soul',
'Freestyle','Duet','Punk Rock','Drum Solo','A Cappella','Euro-House','Dance Hall',
'Goa','Drum & Bass','Club-House','Hardcore','Terror','Indie','BritPop',
'Negerpunk','Polsk Punk','Beat','Christian Gangsta Rap','Heavy Metal','Black Metal',
'Crossover','Contemporary Christian','Christian Rock','Merengue','Salsa',
'Thrash Metal','Anime','JPop','Synthpop'
];
if (title) tags.push({ source: src, key: 'Title', value: title });
if (artist) tags.push({ source: src, key: 'Artist', value: artist });
if (album) tags.push({ source: src, key: 'Album', value: album });
if (year) tags.push({ source: src, key: 'Year', value: year });
if (comment) tags.push({ source: src, key: 'Comment', value: comment });
if (genre >= 0 && genre < genres.length)
tags.push({ source: src, key: 'Genre', value: genres[genre] });
} catch (_) {}
},
_flac(dv, size, tags) {
try {
let off = 4;
let last = 0;
while (!last && off + 4 <= size) {
last = dv.getUint8(off) >> 7;
const type = dv.getUint8(off) & 0x7F;
const blen = (dv.getUint8(off + 1) << 16) | (dv.getUint8(off + 2) << 8) | dv.getUint8(off + 3);
off += 4;
if (off + blen > size) break;
if (type === 0) {
const bps = ((dv.getUint16(off + 13) & 0xFF) >> 4) + 1;
const sr = (dv.getUint16(off + 10) & 0xFFFF) | ((dv.getUint16(off + 9) & 0x0F) << 16);
const ch = ((dv.getUint16(off + 12) & 0x0E) >> 1) + 1;
tags.push({ source: 'FLAC', key: 'Sample Rate', value: `${sr} Hz` });
tags.push({ source: 'FLAC', key: 'Channels', value: `${ch}` });
tags.push({ source: 'FLAC', key: 'Bit Depth', value: `${bps}` });
}
if (type === 4) {
const vlen = dv.getUint32(off, true);
off += 4 + vlen;
const ntags = dv.getUint32(off, true);
off += 4;
for (let i = 0; i < ntags && off < size; i++) {
const tlen = dv.getUint32(off, true);
off += 4;
if (tlen === 0 || off + tlen > size) break;
const raw = this._str(dv, off, tlen);
const eq = raw.indexOf('=');
if (eq > 0) {
const k = raw.substring(0, eq);
const v = raw.substring(eq + 1);
tags.push({ source: 'FLAC', key: k, value: v });
}
off += tlen;
}
} else {
off += blen;
}
}
} catch (_) {}
},
_ape(dv, size, off, tags) {
try {
const ver = dv.getUint32(off + 8, false);
const tagSz = dv.getUint32(off + 12, false);
const nItems = dv.getUint32(off + 16, false);
const flags = dv.getUint32(off + 20, false);
let p = off + (ver >= 2000 ? 32 : 26);
const src = `APE${ver}`;
for (let i = 0; i < nItems && p + 8 <= size; i++) {
const valSz = dv.getUint32(p, true);
const itemFlags = dv.getUint32(p + 4, false);
p += 8;
if (valSz === 0 || p + valSz > size) break;
const keyEnd = p;
let kp = keyEnd;
while (kp < size && dv.getUint8(kp) !== 0) kp++;
const key = this._str(dv, keyEnd, kp - keyEnd);
if (!key) break;
kp++;
const val = this._str(dv, kp, valSz);
tags.push({ source: src, key, value: val });
p = kp + valSz;
}
} catch (_) {}
},
_mp3Sync(dv, off, size) {
while (off + 4 <= size) {
if (dv.getUint8(off) === 0xFF && (dv.getUint8(off + 1) & 0xE0) === 0xE0) return off;
off++;
}
return -1;
},
getFormatInfo(arrayBuffer, codecName) {
const info = { bitrateMode: null, stereoMode: null };
if (codecName !== 'MP3') return info;
try {
const dv = new DataView(arrayBuffer);
const size = arrayBuffer.byteLength;
let off = 0;
const tagEnd = this._id3v2Size(dv, size);
if (tagEnd > 0) off = tagEnd;
const sync = this._mp3Sync(dv, off, size);
if (sync < 0) return info;
const h = dv.getUint16(sync + 2, false);
const chMode = (h >> 6) & 3;
const chNames = ['Stereo', 'Joint Stereo', 'Dual Channel', 'Mono'];
info.stereoMode = chNames[chMode] || null;
const scanEnd = Math.min(sync + 200, size - 4);
for (let p = sync + 4; p < scanEnd; p++) {
const tag = this._str(dv, p, 4);
if (tag === 'Xing' || tag === 'Info') {
const flags = dv.getUint32(p + 4, false);
info.bitrateMode = (flags & 1) ? 'VBR' : 'CBR';
return info;
}
}
info.bitrateMode = 'CBR';
} catch (_) {}
return info;
},
_id3v2Size(dv, size) {
if (size < 10 || this._str(dv, 0, 3) !== 'ID3') return 0;
return 10 + this._ssint(dv, 6);
},
};