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); }, };