From e73c2145e704084b860b6248cb98834c5a3702fe Mon Sep 17 00:00:00 2001 From: 26fe <525453+26fe@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:53:06 +0200 Subject: [PATCH] Add frame replacement and removal options --- README.md | 24 ++++++- index.d.ts | 21 +++++-- index.js | 61 ++++++++++++++---- test/update.js | 168 +++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 256 insertions(+), 18 deletions(-) create mode 100644 test/update.js diff --git a/README.md b/README.md index f25a430..1a8916e 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,9 @@ NodeID3.update(tags, filebuffer, function(err, buffer) { }) // Possible options const options = { include: ['TALB', 'TIT2'], // only read the specified tags (default: all) - exclude: ['APIC'] // don't read the specified tags (default: []) + exclude: ['APIC'], // don't read the specified tags (default: []) + replaceFrames: ['SYLT'], // completely replace these frames with values from tags + removeFrames: ['PRIV'] // remove these frames, even when absent from tags } NodeID3.update(tags, filepath, options) @@ -73,6 +75,26 @@ NodeID3.update(tags, filepath, options, function(err, buffer) { }) NodeID3.update(tags, filebuffer, options, function(err, buffer) { }) ``` +Updates merge with existing frames by default. Multiple-value frames can instead be replaced completely: + +```javascript +NodeID3.update({ + synchronisedLyrics: [{ + language: 'eng', + timeStampFormat: NodeID3.TagConstants.TimeStampFormat.MILLISECONDS, + contentType: NodeID3.TagConstants.SynchronisedLyrics.ContentType.LYRICS, + shortText: 'Synchronized lyrics', + synchronisedText: [{ text: 'First line', timeStamp: 1234 }] + }] +}, fileOrBuffer, { replaceFrames: ['SYLT'] }) +``` + +Frames can also be removed without supplying a replacement value: + +```javascript +NodeID3.update({}, fileOrBuffer, { removeFrames: ['SYLT'] }) +``` + ### Create tags as buffer The create method will return a buffer of your ID3-Tag. You can use it to e.g. write it into a file yourself instead of using the write method. diff --git a/index.d.ts b/index.d.ts index 34f129e..5427320 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,5 +1,14 @@ declare module "node-id3" { namespace NodeID3 { + export interface UpdateOptions { + include?: string[], + exclude?: string[], + onlyRaw?: boolean, + noRaw?: boolean, + replaceFrames?: string[], + removeFrames?: string[] + } + export interface Tags { /** * The 'Album/Movie/Show title' frame is intended for the title of the recording(/source of sound) which the audio in the file is taken from. @@ -709,12 +718,12 @@ declare module "node-id3" { export function read(filebuffer: string | Buffer, options: Object): Tags export function read(filebuffer: string | Buffer, fn: (err: NodeJS.ErrnoException | null, tags: Tags | null) => void): void export function read(filebuffer: string | Buffer, options: Object, fn: (err: NodeJS.ErrnoException | null, tags: Tags | null) => void): void - export function update(tags: Tags, filebuffer: Buffer, options?: Object): Buffer - export function update(tags: Tags, filepath: string, options?: Object): true | Error + export function update(tags: Tags, filebuffer: Buffer, options?: UpdateOptions): Buffer + export function update(tags: Tags, filepath: string, options?: UpdateOptions): true | Error export function update(tags: Tags, filepath: string, fn: (err: NodeJS.ErrnoException | Error | null) => void): void - export function update(tags: Tags, filepath: string, options: Object, fn: (err: NodeJS.ErrnoException | Error | null) => void): void + export function update(tags: Tags, filepath: string, options: UpdateOptions, fn: (err: NodeJS.ErrnoException | Error | null) => void): void export function update(tags: Tags, filebuffer: Buffer, fn: (err: NodeJS.ErrnoException | null, buffer?: Buffer) => void): void - export function update(tags: Tags, filebuffer: Buffer, options: Object, fn: (err: NodeJS.ErrnoException | null, buffer?: Buffer) => void): void + export function update(tags: Tags, filebuffer: Buffer, options: UpdateOptions, fn: (err: NodeJS.ErrnoException | Error | null, buffer?: Buffer) => void): void export function removeTags(filepath: string): true | Error export function removeTags(filepath: string, fn: (err: NodeJS.ErrnoException | Error | null) => void): void export const Promise: { @@ -723,8 +732,8 @@ declare module "node-id3" { create(tags: Tags) : Promise, read(filebuffer: Buffer, options?: Object) : Promise, read(filepath: string, options?: Object) : Promise, - update(tags: Tags, filebuffer: Buffer) : Promise, - update(tags: Tags, filepath: string) : Promise, + update(tags: Tags, filebuffer: Buffer, options?: UpdateOptions) : Promise, + update(tags: Tags, filepath: string, options?: UpdateOptions) : Promise, removeTags(filepath: string) : Promise, removeTags(filebuffer: Buffer) : Promise } diff --git a/index.js b/index.js index bd1da0f..cad924a 100644 --- a/index.js +++ b/index.js @@ -166,13 +166,41 @@ function read(filebuffer, options, fn) { * @param fn - (optional) Function for async version * @returns {boolean|Buffer|Error} */ -function update(tags, filebuffer, options, fn) { - if(!options || typeof options === 'function') { - fn = fn || options - options = {} - } - - const rawTags = Object.keys(tags).reduce((acc, val) => { +function update(tags, filebuffer, options, fn) { + if(!options || typeof options === 'function') { + fn = fn || options + options = {} + } + + const frameOptions = ['replaceFrames', 'removeFrames'] + let optionError + for(const optionName of frameOptions) { + if(options[optionName] !== undefined && !Array.isArray(options[optionName])) { + optionError = new TypeError(`${optionName} must be an array of frame identifiers`) + break + } + if(options[optionName] && options[optionName].some((frameIdentifier) => + typeof frameIdentifier !== 'string' || !/^[A-Z0-9]{4}$/.test(frameIdentifier) + )) { + optionError = new TypeError(`${optionName} must contain valid four-character frame identifiers`) + break + } + } + + const replaceFrames = new Set(options.replaceFrames || []) + const removeFrames = new Set(options.removeFrames || []) + if(!optionError && [...replaceFrames].some((frameIdentifier) => removeFrames.has(frameIdentifier))) { + optionError = new TypeError('A frame identifier cannot be listed in both replaceFrames and removeFrames') + } + if(optionError) { + if(isFunction(fn)) { + fn(optionError) + return undefined + } + throw optionError + } + + const rawTags = Object.keys(tags).reduce((acc, val) => { if(ID3Definitions.FRAME_IDENTIFIERS.v3[val] !== undefined) { acc[ID3Definitions.FRAME_IDENTIFIERS.v3[val]] = tags[val] } else { @@ -181,10 +209,21 @@ function update(tags, filebuffer, options, fn) { return acc }, {}) - const updateFn = (currentTags) => { - currentTags = currentTags.raw || {} - Object.keys(rawTags).map((frameIdentifier) => { - const options = ID3Util.getSpecOptions(frameIdentifier, 3) + const updateFn = (currentTags) => { + currentTags = currentTags.raw || {} + removeFrames.forEach((frameIdentifier) => { + delete currentTags[frameIdentifier] + }) + replaceFrames.forEach((frameIdentifier) => { + if(Object.prototype.hasOwnProperty.call(rawTags, frameIdentifier)) { + delete currentTags[frameIdentifier] + } + }) + Object.keys(rawTags).map((frameIdentifier) => { + if(removeFrames.has(frameIdentifier)) { + return + } + const options = ID3Util.getSpecOptions(frameIdentifier, 3) const cCompare = {} if(options.multiple && currentTags[frameIdentifier] && rawTags[frameIdentifier]) { if(options.updateCompareKey) { diff --git a/test/update.js b/test/update.js new file mode 100644 index 0000000..591b7e9 --- /dev/null +++ b/test/update.js @@ -0,0 +1,168 @@ +const assert = require('assert') +const fs = require('fs') +const NodeID3 = require('../index.js') + +function synchronisedLyrics(shortText, timeStamp) { + return { + language: 'eng', + timeStampFormat: NodeID3.TagConstants.TimeStampFormat.MILLISECONDS, + contentType: NodeID3.TagConstants.SynchronisedLyrics.ContentType.LYRICS, + shortText, + synchronisedText: [{ text: shortText, timeStamp }] + } +} + +describe('NodeID3 frame update options', function() { + const oldLyrics = synchronisedLyrics('Old lyrics', 1000) + const newLyrics = synchronisedLyrics('New lyrics', 2000) + const audio = Buffer.from([0xFF, 0xFB, 0x90, 0x64, 0x01, 0x02, 0x03]) + const filepath = './frame-update-test.mp3' + + afterEach(function() { + if(fs.existsSync(filepath)) { + fs.unlinkSync(filepath) + } + }) + + describe('frame behavior', function() { + it('retains the existing merge behavior by default', function() { + const original = NodeID3.create({ synchronisedLyrics: [oldLyrics] }) + const updated = NodeID3.update({ synchronisedLyrics: [newLyrics] }, original) + + assert.deepStrictEqual(NodeID3.read(updated).synchronisedLyrics, [oldLyrics, newLyrics]) + }) + + it('replaces all instances while preserving unrelated frames and audio', function() { + const image = { + mime: 'image/png', + type: { id: NodeID3.TagConstants.AttachedPicture.FRONT_COVER }, + description: 'cover', + imageBuffer: Buffer.from([0x89, 0x50, 0x4E, 0x47]) + } + const privateFrame = { ownerIdentifier: 'owner', data: Buffer.from([1, 2, 3]) } + const original = Buffer.concat([NodeID3.create({ + title: 'Title', + image, + private: [privateFrame], + synchronisedLyrics: [oldLyrics, synchronisedLyrics('Older lyrics', 500)] + }), audio]) + + const updated = NodeID3.update( + { synchronisedLyrics: [newLyrics] }, + original, + { replaceFrames: ['SYLT', 'SYLT'] } + ) + const read = NodeID3.read(updated) + + assert.deepStrictEqual(read.synchronisedLyrics, [newLyrics]) + assert.strictEqual(read.title, 'Title') + assert.deepStrictEqual(read.image.imageBuffer, image.imageBuffer) + assert.deepStrictEqual(read.private, [privateFrame]) + assert.deepStrictEqual(updated.subarray(updated.length - audio.length), audio) + }) + + it('removes frames and gives removal precedence over supplied tags', function() { + const original = NodeID3.create({ + title: 'Title', + synchronisedLyrics: [oldLyrics] + }) + const updated = NodeID3.update( + { synchronisedLyrics: [newLyrics] }, + original, + { removeFrames: ['SYLT'] } + ) + const read = NodeID3.read(updated) + + assert.strictEqual(read.synchronisedLyrics, undefined) + assert.strictEqual(read.title, 'Title') + }) + + it('leaves a replacement frame unchanged when no value is supplied', function() { + const original = NodeID3.create({ synchronisedLyrics: [oldLyrics] }) + const updated = NodeID3.update({}, original, { replaceFrames: ['SYLT'] }) + + assert.deepStrictEqual(NodeID3.read(updated).synchronisedLyrics, [oldLyrics]) + }) + }) + + describe('API forms', function() { + it('supports callback updates with buffers', function(done) { + const original = NodeID3.create({ synchronisedLyrics: [oldLyrics] }) + + NodeID3.update( + { synchronisedLyrics: [newLyrics] }, + original, + { replaceFrames: ['SYLT'] }, + function(error, updated) { + if(error) { + done(error) + return + } + assert.deepStrictEqual(NodeID3.read(updated).synchronisedLyrics, [newLyrics]) + done() + } + ) + }) + + it('supports synchronous and callback updates with filepaths', function(done) { + fs.writeFileSync(filepath, NodeID3.create({ synchronisedLyrics: [oldLyrics] })) + assert.strictEqual(NodeID3.update({}, filepath, { removeFrames: ['SYLT'] }), true) + assert.strictEqual(NodeID3.read(filepath).synchronisedLyrics, undefined) + + NodeID3.update( + { synchronisedLyrics: [newLyrics] }, + filepath, + { replaceFrames: ['SYLT'] }, + function(error) { + if(error) { + done(error) + return + } + assert.deepStrictEqual(NodeID3.read(filepath).synchronisedLyrics, [newLyrics]) + done() + } + ) + }) + + it('supports promise updates', function() { + const original = NodeID3.create({ synchronisedLyrics: [oldLyrics] }) + return NodeID3.Promise.update( + { synchronisedLyrics: [newLyrics] }, + original, + { replaceFrames: ['SYLT'] } + ).then((updated) => { + assert.deepStrictEqual(NodeID3.read(updated).synchronisedLyrics, [newLyrics]) + }) + }) + }) + + describe('option validation', function() { + it('rejects invalid option values synchronously', function() { + const invalidOptions = [ + { replaceFrames: 'SYLT' }, + { removeFrames: [1234] }, + { replaceFrames: ['sylt'] }, + { removeFrames: ['SYL'] }, + { replaceFrames: ['SYLT'], removeFrames: ['SYLT'] } + ] + + invalidOptions.forEach((options) => { + assert.throws(() => NodeID3.update({}, Buffer.alloc(0), options), TypeError) + }) + }) + + it('reports invalid options to callbacks', function(done) { + NodeID3.update({}, Buffer.alloc(0), { removeFrames: ['bad'] }, function(error) { + assert(error instanceof TypeError) + done() + }) + }) + + it('rejects promises for invalid options', function() { + return assert.rejects( + NodeID3.Promise.update({}, Buffer.alloc(0), { replaceFrames: ['SYLT'], removeFrames: ['SYLT'] }), + TypeError + ) + }) + }) +})