Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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.
Expand Down
21 changes: 15 additions & 6 deletions index.d.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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: {
Expand All @@ -723,8 +732,8 @@ declare module "node-id3" {
create(tags: Tags) : Promise<Buffer>,
read(filebuffer: Buffer, options?: Object) : Promise<Tags>,
read(filepath: string, options?: Object) : Promise<Tags>,
update(tags: Tags, filebuffer: Buffer) : Promise<Buffer>,
update(tags: Tags, filepath: string) : Promise<boolean>,
update(tags: Tags, filebuffer: Buffer, options?: UpdateOptions) : Promise<Buffer>,
update(tags: Tags, filepath: string, options?: UpdateOptions) : Promise<boolean>,
removeTags(filepath: string) : Promise<Buffer>,
removeTags(filebuffer: Buffer) : Promise<Buffer>
}
Expand Down
61 changes: 50 additions & 11 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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) {
Expand Down
168 changes: 168 additions & 0 deletions test/update.js
Original file line number Diff line number Diff line change
@@ -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
)
})
})
})