diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index d9f43df..bce804e 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -15,7 +15,7 @@ jobs: strategy: matrix: - node-version: [16.x, 18.x, 20.x] + node-version: [16.x, 18.x, 20.x, 22.x] steps: - uses: actions/checkout@v2 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index bece10e..1142ee2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,23 +4,23 @@ on: release: types: [published] +permissions: + id-token: write + contents: read + jobs: - build: + publish: runs-on: ubuntu-latest - permissions: - contents: read - id-token: write steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: '20.x' + node-version: '22.x' registry-url: 'https://registry.npmjs.org' + - run: npm install -g npm@latest - run: npm ci - run: npm run build env: NODE_ENV: production - - run: npm publish --provenance --access public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - run: npm publish --access public diff --git a/README.md b/README.md index 5a9d6a6..c9eb069 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ The website is also open-sourced and can be viewed at the * Read and write ID3v2 tags in MP4/M4A/M4V/MOV containers (via ID32 box) * Read and write ID3v2 tags in AIFF/AIFC files (via ID3 chunk) * Read and write ID3v2 tags in AAC/ADTS streams (prepended ID3v2) +* Partial reading from Blob/File without loading entire file into memory * Supports unsynchronisation * Standards compliant. See ~~[id3.org](http://id3.org)~~ [mutagen-specs.readthedocs.io](https://mutagen-specs.readthedocs.io/en/latest/id3/index.html) @@ -130,6 +131,50 @@ async function main () { main() ``` +### Partial reading with `readBlob` + +For read-only scenarios, you can read the metadata without loading the entire +file into memory. `MP3Tag.readBlob()` accepts a +[Blob](https://developer.mozilla.org/en-US/docs/Web/API/Blob) (or `File`) and +uses `blob.slice()` to read only the bytes it needs. + +In the browser: +```html + + +``` + +In Node.js (requires Node 19.8+): +```javascript +import { openAsBlob } from 'node:fs' +import MP3Tag from 'mp3tag.js' + +const blob = await openAsBlob('/path/to/large-file.mp3') +const tags = await MP3Tag.readBlob(blob) +console.log(tags.title, tags.artist) +``` + +`readBlob` accepts the same options as `read`: +```javascript +const tags = await MP3Tag.readBlob(blob, { + id3v1: true, + id3v2: true, + mp4: true, + aiff: true, + aac: true, + unsupported: false +}) +``` + ### MP4/M4A Support mp3tag.js supports reading and writing ID3v2 tags embedded in MP4/M4A containers diff --git a/package-lock.json b/package-lock.json index 22d7cb3..1e15c62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mp3tag.js", - "version": "3.14.1", + "version": "3.16.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "mp3tag.js", - "version": "3.14.1", + "version": "3.16.0", "license": "MIT", "devDependencies": { "@babel/core": "^7.12.3", @@ -2505,9 +2505,9 @@ } }, "node_modules/@rollup/plugin-commonjs/node_modules/minimatch": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.0.tgz", - "integrity": "sha512-9TPBGGak4nHfGZsPBohm9AWg6NoT7QTCehS3BIJABslyZbzxfV78QM2Y6+i741OPZIafFAaiiEMh5OyIrJPgtg==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "dev": true, "dependencies": { "brace-expansion": "^2.0.1" @@ -5407,9 +5407,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "dependencies": { "brace-expansion": "^1.1.7" @@ -5527,11 +5527,10 @@ } }, "node_modules/mocha/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "dev": true, - "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -6356,9 +6355,9 @@ } }, "node_modules/rollup": { - "version": "2.79.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", - "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", + "version": "2.80.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", + "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", "dev": true, "bin": { "rollup": "dist/bin/rollup" @@ -9125,9 +9124,9 @@ } }, "minimatch": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.0.tgz", - "integrity": "sha512-9TPBGGak4nHfGZsPBohm9AWg6NoT7QTCehS3BIJABslyZbzxfV78QM2Y6+i741OPZIafFAaiiEMh5OyIrJPgtg==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "dev": true, "requires": { "brace-expansion": "^2.0.1" @@ -11242,9 +11241,9 @@ "dev": true }, "minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "requires": { "brace-expansion": "^1.1.7" @@ -11330,9 +11329,9 @@ "dev": true }, "minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "dev": true, "requires": { "brace-expansion": "^2.0.1" @@ -11946,9 +11945,9 @@ } }, "rollup": { - "version": "2.79.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", - "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", + "version": "2.80.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", + "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", "dev": true, "requires": { "fsevents": "~2.3.2" diff --git a/package.json b/package.json index e7fad86..8c66702 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mp3tag.js", - "version": "3.14.1", + "version": "3.16.0", "description": "MP3 tagging library written in pure JavaScript", "keywords": [ "node", diff --git a/src/id3v2/parse.mjs b/src/id3v2/parse.mjs index d90f1a6..fb5763e 100644 --- a/src/id3v2/parse.mjs +++ b/src/id3v2/parse.mjs @@ -325,12 +325,28 @@ export function etcoFrame (buffer, version) { const view = new BufferView(buffer) const format = view.getUint8(0) const raw = view.getUint8(1, view.byteLength - 1) - const rview = new BufferView(Array.isArray(raw) ? raw : [raw]) + const bytes = Array.isArray(raw) ? raw : [raw] + const rview = new BufferView(bytes) const data = [] - for (let i = 0; i < raw.length; i += 5) { - const event = rview.getUint8(i) - const time = rview.getUint32(i + 1) + let i = 0 + while (i + 5 <= bytes.length) { + // Per ID3v2.4 §4.5: $FF means "one more byte of events follows" + // and "all the following bytes with the value $FF have the same + // function". So an event type may span multiple bytes: any number + // of leading $FF bytes followed by a single terminating byte + // (0x00–0xFE), then a 4-byte timestamp. + let typeEnd = i + while (typeEnd < bytes.length && bytes[typeEnd] === 0xff) typeEnd++ + if (typeEnd >= bytes.length || typeEnd + 4 >= bytes.length) break + let event + if (typeEnd === i) { + event = rview.getUint8(i) + } else { + event = bytes.slice(i, typeEnd + 1) + } + const time = rview.getUint32(typeEnd + 1) data.push({ event, time }) + i = typeEnd + 5 } return { format, diff --git a/src/id3v2/validate.mjs b/src/id3v2/validate.mjs index ba236ea..1dcb6e5 100644 --- a/src/id3v2/validate.mjs +++ b/src/id3v2/validate.mjs @@ -542,10 +542,41 @@ export function etcoFrame (value, version, strict) { throw new Error('Invalid timestamp format (should be 1 or 2)') } for (const { event, time } of value.data) { - if (typeof event !== 'number') { - throw new Error('Event is not a number') - } else if (event > 255 || event < 0) { - throw new Error('Event should be in range of 0 - 255') + // Per ID3v2.4 §4.5: events are either a single byte (0x00–0xFE) + // or, when 0xFF is used as escape prefix, a sequence of one or + // more 0xFF bytes followed by a single terminating byte. Accept + // either a number or an Array of bytes. + // Structural checks (always on, including non-strict mode) — these + // rules are what make the byte stream parseable per §4.5. Emitting a + // bare 0xFF as a single-byte event, or an extended-event array whose + // prefix bytes aren't 0xFF, produces a malformed frame that no + // compliant reader can decode. + if (typeof event === 'number') { + if (event > 255 || event < 0) { + throw new Error('Event should be in range of 0 - 255') + } + if (event === 0xff) { + throw new Error('Event 0xFF is the escape byte (§4.5); use an array of bytes for extended events') + } + } else if (Array.isArray(event)) { + if (event.length === 0) { + throw new Error('Event array must not be empty') + } + for (const byte of event) { + if (typeof byte !== 'number' || byte > 255 || byte < 0) { + throw new Error('Event byte should be a number in range of 0 - 255') + } + } + for (let i = 0; i < event.length - 1; i++) { + if (event[i] !== 0xff) { + throw new Error('Extended event prefix bytes must all be 0xFF (§4.5)') + } + } + if (event[event.length - 1] === 0xff) { + throw new Error('Extended event terminator byte must not be 0xFF (§4.5)') + } + } else { + throw new Error('Event is not a number or an array of bytes') } if (typeof time !== 'number') { diff --git a/src/id3v2/write.mjs b/src/id3v2/write.mjs index 578cdb7..7f410dc 100644 --- a/src/id3v2/write.mjs +++ b/src/id3v2/write.mjs @@ -548,7 +548,13 @@ export function sytcFrame (value, options) { export function etcoFrame (value, options) { const { id, version, unsynch } = options - const array = value.data.flatMap(({ event, time }) => [event, ...timeBytes(time)]) + // Per ID3v2.4 §4.5, event types may be multi-byte: any number of + // leading $FF bytes followed by a single terminating byte. Accept + // either a number (single byte) or an array of bytes. + const array = value.data.flatMap(({ event, time }) => { + const bytes = Array.isArray(event) ? event : [event] + return [...bytes, ...timeBytes(time)] + }) let data = mergeBytes(value.format, array) if (unsynch) data = unsynchData(data, version) diff --git a/src/mp3tag.mjs b/src/mp3tag.mjs index 424e7de..1e5de5b 100644 --- a/src/mp3tag.mjs +++ b/src/mp3tag.mjs @@ -7,16 +7,17 @@ import * as MP4 from './mp4/index.mjs' import * as AIFF from './aiff/index.mjs' import * as AAC from './aac/index.mjs' -import { mergeBytes } from './utils/bytes.mjs' +import { mergeBytes, decodeSynch } from './utils/bytes.mjs' import { overwriteDefault } from './utils/objects.mjs' import { isBuffer } from './utils/types.mjs' +import { findBox, decodeLanguage } from './mp4/boxes.mjs' import { encoding2Index } from './utils/strings.mjs' export default class MP3Tag { get name () { return 'MP3Tag' } set name (value) { throw new Error('Unable to set this property') } - get version () { return '3.14.1' } + get version () { return '3.16.0' } set version (value) { throw new Error('Unable to set this property') } constructor (buffer, verbose = false) { @@ -505,6 +506,391 @@ export default class MP3Tag { return tags } + static async readBlob (blob, options = {}) { + options = overwriteDefault(options, { + id3v1: true, + id3v2: true, + mp4: true, + aiff: true, + aac: true, + unsupported: false, + encoding: 'utf-8' + }) + + const size = blob.size + const tags = {} + async function read (offset, length) { + return blob.slice(offset, offset + length).arrayBuffer() + } + + const containerProps = { + title: { + get: function () { + return (this.v2 && (this.v2.TIT2 || this.v2.TT2)) || '' + }, + set: function (value) { + if (this.v2) { + const version = this.v2Details.version[0] + this.v2[version === 2 ? 'TT2' : 'TIT2'] = value + } + } + }, + artist: { + get: function () { + return (this.v2 && (this.v2.TPE1 || this.v2.TP1)) || '' + }, + set: function (value) { + if (this.v2) { + const version = this.v2Details.version[0] + this.v2[version === 2 ? 'TP1' : 'TPE1'] = value + } + } + }, + album: { + get: function () { + return (this.v2 && (this.v2.TALB || this.v2.TAL)) || '' + }, + set: function (value) { + if (this.v2) { + const version = this.v2Details.version[0] + this.v2[version === 2 ? 'TAL' : 'TALB'] = value + } + } + }, + year: { + get: function () { + return (this.v2 && (this.v2.TYER || this.v2.TDRC || this.v2.TYE)) || '' + }, + set: function (value) { + if (this.v2) { + const version = this.v2Details.version[0] + if (version === 2) this.v2.TYE = value + else if (version === 3) this.v2.TYER = value + else if (version === 4) this.v2.TDRC = value + } + } + }, + comment: { + get: function () { + let text = '' + if (this.v2 && (this.v2.COMM || this.v2.COM)) { + const comm = this.v2.COMM || this.v2.COM + if (Array.isArray(comm) && comm.length > 0) text = comm[0].text + } + return text + }, + set: function (value) { + if (this.v2) { + const version = this.v2Details.version[0] + this.v2[version === 2 ? 'COM' : 'COMM'] = [{ + language: 'eng', + descriptor: '', + text: value + }] + } + } + }, + track: { + get: function () { + return (this.v2 && ( + (this.v2.TRCK && this.v2.TRCK.split('/')[0]) || + (this.v2.TRK && this.v2.TRK.split('/')[0]) + )) || '' + }, + set: function (value) { + if (this.v2 && value !== '') { + const version = this.v2Details.version[0] + this.v2[version === 2 ? 'TRK' : 'TRCK'] = value + } + } + }, + genre: { + get: function () { + return (this.v2 && (this.v2.TCON || this.v2.TCO)) || '' + }, + set: function (value) { + if (this.v2) { + const version = this.v2Details.version[0] + this.v2[version === 2 ? 'TCO' : 'TCON'] = value + } + } + } + } + + const mp3Props = { + title: { + get: function () { + return (this.v2 && (this.v2.TIT2 || this.v2.TT2)) || + (this.v1 && this.v1.title) || '' + }, + set: function (value) { + if (this.v2) { + const version = this.v2Details.version[0] + this.v2[version === 2 ? 'TT2' : 'TIT2'] = value + } + if (this.v1) this.v1.title = value + } + }, + artist: { + get: function () { + return (this.v2 && (this.v2.TPE1 || this.v2.TP1)) || + (this.v1 && this.v1.artist) || '' + }, + set: function (value) { + if (this.v2) { + const version = this.v2Details.version[0] + this.v2[version === 2 ? 'TP1' : 'TPE1'] = value + } + if (this.v1) this.v1.artist = value + } + }, + album: { + get: function () { + return (this.v2 && (this.v2.TALB || this.v2.TAL)) || + (this.v1 && this.v1.album) || '' + }, + set: function (value) { + if (this.v2) { + const version = this.v2Details.version[0] + this.v2[version === 2 ? 'TAL' : 'TALB'] = value + } + if (this.v1) this.v1.album = value + } + }, + year: { + get: function () { + return (this.v2 && (this.v2.TYER || this.v2.TDRC || this.v2.TYE)) || + (this.v1 && this.v1.year) || '' + }, + set: function (value) { + if (this.v2) { + const version = this.v2Details.version[0] + if (version === 2) this.v2.TYE = value + else if (version === 3) this.v2.TYER = value + else if (version === 4) this.v2.TDRC = value + } + if (this.v1) this.v1.year = value + } + }, + comment: { + get: function () { + let text = '' + if (this.v2 && (this.v2.COMM || this.v2.COM)) { + const comm = this.v2.COMM || this.v2.COM + if (Array.isArray(comm) && comm.length > 0) text = comm[0].text + } else if (this.v1 && this.v1.comment) text = this.v1.comment + return text + }, + set: function (value) { + if (this.v2) { + const version = this.v2Details.version[0] + this.v2[version === 2 ? 'COM' : 'COMM'] = [{ + language: 'eng', + descriptor: '', + text: value + }] + } + if (this.v1) this.v1.comment = value + } + }, + track: { + get: function () { + return (this.v2 && ( + (this.v2.TRCK && this.v2.TRCK.split('/')[0]) || + (this.v2.TRK && this.v2.TRK.split('/')[0]) + )) || (this.v1 && this.v1.track) || '' + }, + set: function (value) { + if (this.v2 && value !== '') { + const version = this.v2Details.version[0] + this.v2[version === 2 ? 'TRK' : 'TRCK'] = value + } + if (this.v1) this.v1.track = value + } + }, + genre: { + get: function () { + return (this.v2 && (this.v2.TCON || this.v2.TCO)) || + (this.v1 && this.v1.genre) || '' + }, + set: function (value) { + if (this.v2) { + const version = this.v2Details.version[0] + this.v2[version === 2 ? 'TCO' : 'TCON'] = value + } + if (this.v1) this.v1.genre = value + } + } + } + + const headerLen = Math.min(12, size) + if (headerLen < 4) { + Object.defineProperties(tags, mp3Props) + return tags + } + + const header = await read(0, headerLen) + const hv = new BufferView(header) + + // AIFF + if (options.aiff && headerLen >= 12 && hv.getUint8String(0, 4) === 'FORM') { + const formType = hv.getUint8String(8, 4) + + if (formType === 'AIFF' || formType === 'AIFC') { + // Walk AIFF chunks to find ID3 + let chunkOffset = 12 + let id3ChunkOffset = -1 + let id3ChunkSize = 0 + + while (chunkOffset + 8 <= size) { + const chunkHeader = await read(chunkOffset, 8) + const cv = new BufferView(chunkHeader) + const chunkId = cv.getUint8String(0, 4) + const chunkSize = cv.getUint32(4) + + if (chunkId === 'ID3 ') { + id3ChunkOffset = chunkOffset + id3ChunkSize = chunkSize + break + } + + chunkOffset += 8 + chunkSize + if (chunkSize % 2 !== 0) chunkOffset += 1 + } + + if (id3ChunkOffset >= 0) { + const id3Buffer = await read(id3ChunkOffset + 8, id3ChunkSize) + + if (ID3v2.hasID3v2(id3Buffer)) { + const { tags: v2Tags, details } = ID3v2.decode(id3Buffer, 0, options.unsupported) + details.aiff = { + chunkOffset: id3ChunkOffset, + chunkSize: id3ChunkSize + } + tags.v2 = { ...v2Tags } + tags.v2Details = details + } + } + + Object.defineProperties(tags, containerProps) + return tags + } + } + + // MP4 + if (options.mp4 && headerLen >= 8 && hv.getUint8String(4, 4) === 'ftyp') { + // Walk top-level boxes to find moov + let boxOffset = 0 + let moovOffset = -1 + let moovSize = 0 + + while (boxOffset + 8 <= size) { + const boxHeader = await read(boxOffset, 8) + const bv = new BufferView(boxHeader) + let bSize = bv.getUint32(0) + const bType = bv.getUint8String(4, 4) + + if (bSize === 0) bSize = size - boxOffset + if (bSize < 8) break + + if (bType === 'moov') { + moovOffset = boxOffset + moovSize = bSize + break + } + + boxOffset += bSize + } + + if (moovOffset >= 0) { + const moovBuffer = await read(moovOffset, moovSize) + const mv = new BufferView(moovBuffer) + + // Navigate moov > udta > meta > ID32 + const udta = findBox(mv, 8, moovSize, 'udta') + const meta = udta ? findBox(mv, udta.dataStart, udta.end, 'meta') : null + const id32 = meta ? findBox(mv, meta.dataStart + 4, meta.end, 'ID32') : null + + if (id32) { + const languageBytes = mv.getUint8(id32.dataStart + 4, 2) + const langValue = (languageBytes[0] << 8) | languageBytes[1] + const language = decodeLanguage(langValue) + + const id3Start = id32.dataStart + 4 + 2 + const id3Size = id32.end - id3Start + const id3Buffer = moovBuffer.slice(id3Start, id3Start + id3Size) + + if (ID3v2.hasID3v2(id3Buffer)) { + const { tags: v2Tags, details } = ID3v2.decode(id3Buffer, 0, options.unsupported) + details.mp4 = { + language, + id32Offset: moovOffset + id32.offset, + id32Size: id32.size + } + tags.v2 = { ...v2Tags } + tags.v2Details = details + } + } + } + + Object.defineProperties(tags, containerProps) + return tags + } + + // ID3v2 at byte 0 (MP3 or AAC) + if (options.id3v2 && headerLen >= 10 && hv.getUint8String(0, 3) === 'ID3') { + const tagSize = decodeSynch(hv.getUint32(6)) + const fullSize = 10 + tagSize + + const readLen = Math.min(fullSize + 4, size) + const tagBuffer = await read(0, readLen) + + const { tags: v2Tags, details } = ID3v2.decode(tagBuffer, 0, options.unsupported) + tags.v2 = { ...v2Tags } + tags.v2Details = details + + let isAAC = false + if (options.aac && readLen >= fullSize + 2) { + const tv = new BufferView(tagBuffer) + const b0 = tv.getUint8(fullSize) + const b1 = tv.getUint8(fullSize + 1) + const syncWord = (b0 << 4) | (b1 >> 4) + if (syncWord === 0xFFF) { + const layer = (b1 >> 1) & 0x03 + if (layer === 0) { + isAAC = true + details.aac = { format: 'ADTS' } + } + } + } + + if (!isAAC && options.id3v1 && size >= 128) { + const tail = await read(size - 128, 128) + if (ID3v1.hasID3v1(tail)) { + const { tags: v1Tags, details: v1Details } = ID3v1.decode(tail, options.encoding) + tags.v1 = { ...v1Tags } + tags.v1Details = v1Details + } + } + + Object.defineProperties(tags, isAAC ? containerProps : mp3Props) + return tags + } + + // No ID3v2: check for ID3v1 + if (options.id3v1 && size >= 128) { + const tail = await read(size - 128, 128) + if (ID3v1.hasID3v1(tail)) { + const { tags: v1Tags, details } = ID3v1.decode(tail, options.encoding) + tags.v1 = { ...v1Tags } + tags.v1Details = details + } + } + + Object.defineProperties(tags, mp3Props) + return tags + } + read (options = {}) { this.tags = {} this.error = '' diff --git a/test/id3v2/index.cjs b/test/id3v2/index.cjs index d518379..6e13c13 100644 --- a/test/id3v2/index.cjs +++ b/test/id3v2/index.cjs @@ -178,6 +178,58 @@ describe('ID3v2', function () { }) }) + it('Write ETCO with extended events (0xFF prefix)', function () { + // Per ID3v2.4 §4.5, an event type may be a multi-byte sequence: + // one or more leading 0xFF escape bytes followed by a single + // terminating byte (0x00–0xFE). Verify round-trip for mixed + // single-byte and extended events. + this.mp3tag.tags.v2.ETCO = { + format: 2, + data: [ + { event: 0x02, time: 100 }, + { event: [0xff, 0x12], time: 5000 }, + { event: [0xff, 0x20], time: 10000 }, + { event: 0x10, time: 12000 }, + { event: [0xff, 0xff, 0x07], time: 20000 } + ] + } + this.mp3tag.save({ strict: true }) + if (this.mp3tag.error !== '') throw new Error(this.mp3tag.error) + + this.mp3tag.read() + if (this.mp3tag.error !== '') throw new Error(this.mp3tag.error) + + assert.deepStrictEqual(this.mp3tag.tags.v2.ETCO, { + format: 2, + data: [ + { event: 0x02, time: 100 }, + { event: [0xff, 0x12], time: 5000 }, + { event: [0xff, 0x20], time: 10000 }, + { event: 0x10, time: 12000 }, + { event: [0xff, 0xff, 0x07], time: 20000 } + ] + }) + }) + + it('Rejects malformed extended ETCO events (structural, non-strict too)', function () { + // Per ID3v2.4 §4.5 these byte layouts produce unparseable frames, + // so they are rejected unconditionally — not only under strict + // mode. A silent acceptance here would write a tag that no + // compliant reader (including this library's own parser) can + // decode correctly. + const cases = [ + { event: 0xff, time: 100 }, // bare escape byte + { event: [0xff, 0xff], time: 100 }, // terminator is 0xFF + { event: [0x12, 0x34], time: 100 } // non-0xFF prefix + ] + for (const bad of cases) { + this.mp3tag.tags.v2.ETCO = { format: 2, data: [bad] } + this.mp3tag.save() // NOT strict + assert.notStrictEqual(this.mp3tag.error, '', + `expected error for event=${JSON.stringify(bad.event)}`) + } + }) + it('Write complex multi tag', function () { this.mp3tag.tags.v2.SYLT = [ { diff --git a/test/index.cjs b/test/index.cjs index e5655f2..b73f503 100644 --- a/test/index.cjs +++ b/test/index.cjs @@ -34,7 +34,7 @@ describe('MP3Tag', function () { }) after(function () { - const extendTests = ['id3v1/index.cjs', 'id3v2/index.cjs', 'id3/index.cjs', 'mp4/index.cjs', 'aiff/index.cjs', 'aac/index.cjs'] + const extendTests = ['id3v1/index.cjs', 'id3v2/index.cjs', 'id3/index.cjs', 'mp4/index.cjs', 'aiff/index.cjs', 'aac/index.cjs', 'readBlob/index.cjs'] const tests = this.test.parent.tests let failed = false diff --git a/test/readBlob/index.cjs b/test/readBlob/index.cjs new file mode 100644 index 0000000..1858cb8 --- /dev/null +++ b/test/readBlob/index.cjs @@ -0,0 +1,347 @@ +/* eslint-env mocha */ +/* global Blob */ + +const assert = require('assert') +const MP3Tag = require('../../dist/mp3tag.js') +const { bytes } = require('../globals.cjs') + +/** + * Create a Blob from a Uint8Array fixture + */ +function createBlob (uint8) { + return new Blob([uint8]) +} + +/** + * Create a minimal ID3v2.3 tag with a TIT2 frame + */ +function createID3v2Tag (title) { + const titleBytes = Buffer.from(title, 'utf8') + const frameSize = 1 + titleBytes.length + + const header = new Uint8Array([ + 0x49, 0x44, 0x33, 0x03, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00 + ]) + + const frame = new Uint8Array(10 + frameSize) + frame[0] = 0x54; frame[1] = 0x49; frame[2] = 0x54; frame[3] = 0x32 + frame[4] = (frameSize >> 24) & 0xFF + frame[5] = (frameSize >> 16) & 0xFF + frame[6] = (frameSize >> 8) & 0xFF + frame[7] = frameSize & 0xFF + frame[10] = 0x00 + frame.set(titleBytes, 11) + + const tagSize = frame.length + header[6] = (tagSize >> 21) & 0x7F + header[7] = (tagSize >> 14) & 0x7F + header[8] = (tagSize >> 7) & 0x7F + header[9] = tagSize & 0x7F + + const result = new Uint8Array(header.length + frame.length) + result.set(header, 0) + result.set(frame, header.length) + return result +} + +/** + * Create a minimal AIFF file with optional ID3 chunk + */ +function createAIFF (id3Data) { + const commChunk = new Uint8Array([ + 0x43, 0x4F, 0x4D, 0x4D, + 0x00, 0x00, 0x00, 0x12, + 0x00, 0x01, + 0x00, 0x00, 0x00, 0x10, + 0x00, 0x10, + 0x40, 0x0E, 0xAC, 0x44, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 + ]) + + const ssndChunk = new Uint8Array([ + 0x53, 0x53, 0x4E, 0x44, + 0x00, 0x00, 0x00, 0x18, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 + ]) + + let totalSize = 4 + commChunk.length + ssndChunk.length + let id3Chunk = null + + if (id3Data) { + const id3Size = id3Data.length + const needsPadding = id3Size % 2 !== 0 + id3Chunk = new Uint8Array(8 + id3Size + (needsPadding ? 1 : 0)) + id3Chunk[0] = 0x49; id3Chunk[1] = 0x44; id3Chunk[2] = 0x33; id3Chunk[3] = 0x20 + id3Chunk[4] = (id3Size >> 24) & 0xFF + id3Chunk[5] = (id3Size >> 16) & 0xFF + id3Chunk[6] = (id3Size >> 8) & 0xFF + id3Chunk[7] = id3Size & 0xFF + id3Chunk.set(id3Data, 8) + totalSize += id3Chunk.length + } + + const formHeader = new Uint8Array(12) + formHeader[0] = 0x46; formHeader[1] = 0x4F; formHeader[2] = 0x52; formHeader[3] = 0x4D + formHeader[4] = (totalSize >> 24) & 0xFF + formHeader[5] = (totalSize >> 16) & 0xFF + formHeader[6] = (totalSize >> 8) & 0xFF + formHeader[7] = totalSize & 0xFF + formHeader[8] = 0x41; formHeader[9] = 0x49; formHeader[10] = 0x46; formHeader[11] = 0x46 + + const result = new Uint8Array(12 + totalSize - 4) + let offset = 0 + result.set(formHeader, offset); offset += formHeader.length + result.set(commChunk, offset); offset += commChunk.length + result.set(ssndChunk, offset); offset += ssndChunk.length + if (id3Chunk) result.set(id3Chunk, offset) + + return result +} + +/** + * Create a minimal MP4 file with ID32 box + */ +function createMP4WithID32 () { + const id3Header = [ + 0x49, 0x44, 0x33, 0x03, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x15 + ] + const tit2Frame = [ + 0x54, 0x49, 0x54, 0x32, + 0x00, 0x00, 0x00, 0x0B, + 0x00, 0x00, + 0x00, + 0x54, 0x65, 0x73, 0x74, 0x20, + 0x54, 0x69, 0x74, 0x6C, 0x65 + ] + const id3Data = [...id3Header, ...tit2Frame] + + const id32Size = 8 + 4 + 2 + id3Data.length + const id32Box = [ + (id32Size >> 24) & 0xFF, (id32Size >> 16) & 0xFF, + (id32Size >> 8) & 0xFF, id32Size & 0xFF, + 0x49, 0x44, 0x33, 0x32, + 0x00, 0x00, 0x00, 0x00, + 0x55, 0xC4, + ...id3Data + ] + + const hdlrBox = [ + 0x00, 0x00, 0x00, 0x21, + 0x68, 0x64, 0x6C, 0x72, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x6D, 0x64, 0x69, 0x72, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x00 + ] + + const metaSize = 8 + 4 + hdlrBox.length + id32Box.length + const metaBox = [ + (metaSize >> 24) & 0xFF, (metaSize >> 16) & 0xFF, + (metaSize >> 8) & 0xFF, metaSize & 0xFF, + 0x6D, 0x65, 0x74, 0x61, + 0x00, 0x00, 0x00, 0x00, + ...hdlrBox, ...id32Box + ] + + const udtaSize = 8 + metaBox.length + const udtaBox = [ + (udtaSize >> 24) & 0xFF, (udtaSize >> 16) & 0xFF, + (udtaSize >> 8) & 0xFF, udtaSize & 0xFF, + 0x75, 0x64, 0x74, 0x61, + ...metaBox + ] + + const mvhdBox = [ + 0x00, 0x00, 0x00, 0x70, + 0x6D, 0x76, 0x68, 0x64, + 0x00, 0x00, 0x00, 0x00, + ...new Array(100).fill(0) + ] + + const moovSize = 8 + mvhdBox.length + udtaBox.length + const moovBox = [ + (moovSize >> 24) & 0xFF, (moovSize >> 16) & 0xFF, + (moovSize >> 8) & 0xFF, moovSize & 0xFF, + 0x6D, 0x6F, 0x6F, 0x76, + ...mvhdBox, ...udtaBox + ] + + const ftypBox = [ + 0x00, 0x00, 0x00, 0x14, + 0x66, 0x74, 0x79, 0x70, + 0x4D, 0x34, 0x41, 0x20, + 0x00, 0x00, 0x00, 0x00, + 0x4D, 0x34, 0x41, 0x20 + ] + + const mdatBox = [ + 0x00, 0x00, 0x00, 0x6C, + 0x6D, 0x64, 0x61, 0x74, + ...new Array(100).fill(0xAA) + ] + + return new Uint8Array([...ftypBox, ...moovBox, ...mdatBox]) +} + +/** + * Create a minimal AAC (ADTS) file with ID3v2 prepended + */ +function createAACFixture (title) { + const id3Tag = createID3v2Tag(title) + + // ADTS frame header: 0xFF 0xF1 = sync(0xFFF) + MPEG-4 + layer=0 + no CRC + const adtsFrame = new Uint8Array([ + 0xFF, 0xF1, 0x50, 0x80, 0x02, 0x00, 0x1C + ]) + + const result = new Uint8Array(id3Tag.length + adtsFrame.length) + result.set(id3Tag, 0) + result.set(adtsFrame, id3Tag.length) + return result +} + +describe('readBlob', function () { + before(function () { + if (typeof Blob === 'undefined') this.skip() + }) + + describe('MP3', function () { + it('Reads ID3v2 and ID3v1 tags', async function () { + const blob = createBlob(bytes) + const tags = await MP3Tag.readBlob(blob) + + assert.ok(tags.v2) + assert.ok(tags.v1) + assert.ok(tags.v2.TIT2) + assert.strictEqual(tags.v1.title, 'TITLE') + }) + + it('Matches readBuffer output', async function () { + const blob = createBlob(bytes) + const tags = await MP3Tag.readBlob(blob) + const bufferTags = MP3Tag.readBuffer(bytes.buffer) + + assert.strictEqual(tags.title, bufferTags.title) + assert.strictEqual(tags.artist, bufferTags.artist) + assert.strictEqual(tags.album, bufferTags.album) + assert.strictEqual(tags.year, bufferTags.year) + assert.strictEqual(tags.track, bufferTags.track) + assert.strictEqual(tags.genre, bufferTags.genre) + }) + + it('Reads only ID3v1 when no ID3v2 header', async function () { + // Build a buffer: MP3 audio + ID3v1 at the end (no ID3v2 prepended) + const audio = new Uint8Array([ + 0xFF, 0xFB, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00 + ]) + const id3v1 = new Uint8Array(128) + id3v1[0] = 0x54; id3v1[1] = 0x41; id3v1[2] = 0x47 // "TAG" + id3v1[3] = 0x48; id3v1[4] = 0x65; id3v1[5] = 0x6C + id3v1[6] = 0x6C; id3v1[7] = 0x6F // "Hello" as title + + const combined = new Uint8Array(audio.length + id3v1.length) + combined.set(audio, 0) + combined.set(id3v1, audio.length) + + const blob = createBlob(combined) + const tags = await MP3Tag.readBlob(blob) + + assert.strictEqual(typeof tags.v2, 'undefined') + assert.ok(tags.v1) + assert.strictEqual(tags.v1.title, 'Hello') + assert.strictEqual(tags.title, 'Hello') + }) + }) + + describe('AIFF', function () { + it('Reads ID3 chunk from AIFF', async function () { + const id3Data = createID3v2Tag('AIFF Song') + const aiff = createAIFF(id3Data) + + const blob = createBlob(aiff) + const tags = await MP3Tag.readBlob(blob) + + assert.ok(tags.v2) + assert.strictEqual(tags.v2.TIT2, 'AIFF Song') + assert.strictEqual(tags.title, 'AIFF Song') + }) + + it('Contains AIFF-specific details', async function () { + const id3Data = createID3v2Tag('Test') + const aiff = createAIFF(id3Data) + + const blob = createBlob(aiff) + const tags = await MP3Tag.readBlob(blob) + + assert.ok(tags.v2Details) + assert.ok(tags.v2Details.aiff) + assert.strictEqual(typeof tags.v2Details.aiff.chunkOffset, 'number') + assert.strictEqual(typeof tags.v2Details.aiff.chunkSize, 'number') + }) + + it('Returns empty tags for AIFF without ID3 chunk', async function () { + const aiff = createAIFF(null) + + const blob = createBlob(aiff) + const tags = await MP3Tag.readBlob(blob) + + assert.strictEqual(typeof tags.v2, 'undefined') + assert.strictEqual(tags.title, '') + }) + }) + + describe('MP4', function () { + it('Reads ID32 box from MP4', async function () { + const mp4 = createMP4WithID32() + + const blob = createBlob(mp4) + const tags = await MP3Tag.readBlob(blob) + + assert.ok(tags.v2) + assert.strictEqual(tags.v2.TIT2, 'Test Title') + assert.strictEqual(tags.title, 'Test Title') + }) + + it('Contains MP4-specific details', async function () { + const mp4 = createMP4WithID32() + + const blob = createBlob(mp4) + const tags = await MP3Tag.readBlob(blob) + + assert.ok(tags.v2Details) + assert.ok(tags.v2Details.mp4) + assert.strictEqual(tags.v2Details.mp4.language, 'und') + }) + }) + + describe('AAC', function () { + it('Reads ID3v2 from AAC and detects ADTS', async function () { + const aac = createAACFixture('AAC Song') + + const blob = createBlob(aac) + const tags = await MP3Tag.readBlob(blob) + + assert.ok(tags.v2) + assert.strictEqual(tags.v2.TIT2, 'AAC Song') + assert.strictEqual(tags.title, 'AAC Song') + assert.ok(tags.v2Details.aac) + assert.strictEqual(tags.v2Details.aac.format, 'ADTS') + }) + + it('Does not read ID3v1 for AAC', async function () { + const aac = createAACFixture('AAC Only') + + const blob = createBlob(aac) + const tags = await MP3Tag.readBlob(blob) + + assert.strictEqual(typeof tags.v1, 'undefined') + }) + }) +}) diff --git a/types/id3v2/frames.d.ts b/types/id3v2/frames.d.ts index 69c7b47..7d9d979 100644 --- a/types/id3v2/frames.d.ts +++ b/types/id3v2/frames.d.ts @@ -17,7 +17,13 @@ export interface MP3TagAPICFrame { export interface MP3TagETCOFrame { format: number; data: Array<{ - event: number; + /** + * Event type. A single byte (0x00–0xFE) for standard events, or an + * array of bytes for extended events as defined by ID3v2.4 §4.5: + * one or more leading 0xFF escape bytes followed by a single + * terminating byte (0x00–0xFE). + */ + event: number | number[]; time: number; }>; } diff --git a/types/index.d.ts b/types/index.d.ts index 5002568..8990598 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -21,6 +21,9 @@ export type MP3TagEncodings = export interface MP3TagDefaultReadOptions { id3v1: boolean; id3v2: boolean; + mp4: boolean; + aiff: boolean; + aac: boolean; unsupported: boolean; encoding: MP3TagEncodings; } @@ -41,6 +44,9 @@ export interface MP3TagDefaultWriteOptions { unsupported: boolean; encoding: MP3TagEncodings; }; + mp4: { + language: 'und' + }; } export type MP3TagReadOptions = RecursivePartial; @@ -48,7 +54,7 @@ export type MP3TagWriteOptions = RecursivePartial; export class MP3Tag { readonly name = 'MP3Tag'; - readonly version = '3.14.1'; + readonly version = '3.16.0'; verbose: boolean; buffer: MP3Buffer; @@ -56,6 +62,7 @@ export class MP3Tag { error: string; static readBuffer (buffer: MP3Buffer, options?: MP3TagReadOptions, verbose?: boolean): MP3TagTags; + static readBlob (blob: Blob, options?: MP3TagReadOptions): Promise; static writeBuffer (buffer: MP3Buffer, tags: MP3TagTags, options?: MP3TagWriteOptions, verbose?: boolean): MP3Buffer; static getAudioBuffer (buffer: MP3Buffer, emptyNone?: boolean): MP3Buffer;