diff --git a/README.md b/README.md index f25a430..df03821 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,33 @@ NodeID3.update(tags, filepath, options, function(err, buffer) { }) NodeID3.update(tags, filebuffer, options, function(err, buffer) { }) ``` +### ID3v1 tags + +ID3v1 operations are separate from the existing ID3v2 `write()` and `update()` functions. Applications that maintain +both formats must map compatible fields and call the ID3v1 API explicitly. + +ID3v1 supports title, artist, and album fields up to 30 bytes, a four-byte year, and a 28-byte ID3v1.1 comment. It +also supports numeric track numbers and genre IDs from 0 through 255. Values must already be expressed in their ID3v1 +representation; genre names and ID3v2 track forms such as `3/12` are not converted automatically. + +Text is encoded as Latin-1. By default, unsupported characters become `?`, embedded nulls are removed, and fields are +truncated to their encoded byte limits. Use `id3v1Truncation: 'error'` to reject lossy values before changing a file. + +The dedicated APIs read, replace, or remove only the final 128-byte ID3v1 tag. `writeId3v1()` always writes ID3v1.1; +the `version` property returned by `readId3v1()` reports the layout that was read. + +```javascript +NodeID3.write(tags, filepath) // Optional, separate ID3v2 operation +NodeID3.writeId3v1({ title: 'Tomorrow', trackNumber: 3 }, filepath) + +const id3v1 = NodeID3.readId3v1(filepath) // ID3v1 object or null +const removed = NodeID3.removeId3v1(filebuffer) // Buffer without the trailing ID3v1 tag + +NodeID3.readId3v1(filepath, function(err, tag) { }) +NodeID3.writeId3v1({ title: 'Tomorrow' }, filebuffer, function(err, buffer) { }) +NodeID3.removeId3v1(filepath, function(err) { }) +``` + ### 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. @@ -133,11 +160,14 @@ let bufferWithoutID3Frame = NodeID3.removeTagsFromBuffer(filebuffer) // Return ```javascript const NodeID3Promise = require('node-id3').Promise -NodeID3.write(tags, fileOrBuffer) -NodeID3.update(tags, fileOrBuffer) -NodeID3.create(tags) -NodeID3.read(filepath) -NodeID3.removeTags(filepath) +NodeID3Promise.write(tags, fileOrBuffer) +NodeID3Promise.update(tags, fileOrBuffer) +NodeID3Promise.create(tags) +NodeID3Promise.read(filepath) +NodeID3Promise.readId3v1(filepath) +NodeID3Promise.writeId3v1({ title: 'Tomorrow' }, fileOrBuffer) +NodeID3Promise.removeId3v1(fileOrBuffer) +NodeID3Promise.removeTags(filepath) ``` ## Supported aliases/fields diff --git a/index.d.ts b/index.d.ts index 34f129e..d1a74e7 100644 --- a/index.d.ts +++ b/index.d.ts @@ -699,34 +699,96 @@ declare module "node-id3" { } } } + + /** + * ID3v1 tag management + */ + export type Id3v1Truncation = "truncate" | "error" + export interface Id3v1WriteOptions { + id3v1Truncation?: Id3v1Truncation + } + export interface Id3v1Tag { + title?: string, + artist?: string, + album?: string, + year?: string, + comment?: string, + trackNumber?: number, + genreId?: number, + version: "1.0" | "1.1" + } + export interface Id3v1WriteTag { + title?: string, + artist?: string, + album?: string, + year?: string, + comment?: string, + trackNumber?: number, + genreId?: number, + version?: "1.0" | "1.1" + } + export function write(tags: Tags, filebuffer: Buffer): Buffer export function write(tags: Tags, filebuffer: Buffer, fun: (err: null, buffer: Buffer) => void): void export function write(tags: Tags, filepath: string): true | Error export function write(tags: Tags, filepath: string, fn: (err: NodeJS.ErrnoException | Error | null) => void): void + export function create(tags: Tags): Buffer export function create(tags: Tags, fn: (buffer: Buffer) => void): void + export function read(filebuffer: string | Buffer): Tags 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, 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, 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 removeTags(filepath: string): true | Error export function removeTags(filepath: string, fn: (err: NodeJS.ErrnoException | Error | null) => void): void + + export function readId3v1(filebuffer: string | Buffer): Id3v1Tag | null + export function readId3v1(filebuffer: string | Buffer, fn: (err: NodeJS.ErrnoException | Error | null, tag: Id3v1Tag | null) => void): void + + export function writeId3v1(tag: Id3v1WriteTag, filebuffer: Buffer, options?: Id3v1WriteOptions): Buffer + export function writeId3v1(tag: Id3v1WriteTag, filepath: string, options?: Id3v1WriteOptions): true | Error + export function writeId3v1(tag: Id3v1WriteTag, filebuffer: Buffer, fn: (err: NodeJS.ErrnoException | Error | null, buffer?: Buffer) => void): void + export function writeId3v1(tag: Id3v1WriteTag, filepath: string, fn: (err: NodeJS.ErrnoException | Error | null) => void): void + export function writeId3v1(tag: Id3v1WriteTag, filebuffer: Buffer, options: Id3v1WriteOptions, fn: (err: NodeJS.ErrnoException | Error | null, buffer?: Buffer) => void): void + export function writeId3v1(tag: Id3v1WriteTag, filepath: string, options: Id3v1WriteOptions, fn: (err: NodeJS.ErrnoException | Error | null) => void): void + + export function removeId3v1(filebuffer: Buffer): Buffer + export function removeId3v1(filepath: string): true | Error + export function removeId3v1(filebuffer: Buffer, fn: (err: NodeJS.ErrnoException | Error | null, buffer?: Buffer) => void): void + export function removeId3v1(filepath: string, fn: (err: NodeJS.ErrnoException | Error | null) => void): void + export const Promise: { write(tags: Tags, filebuffer: Buffer) : Promise, write(tags: Tags, filepath: string) : Promise, + 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?: Object) : Promise, + update(tags: Tags, filepath: string, options?: Object) : Promise, + + + removeId3v1(filebuffer: Buffer) : Promise, + removeId3v1(filepath: string) : Promise, + removeTags(filepath: string) : Promise, - removeTags(filebuffer: Buffer) : Promise + removeTags(filebuffer: Buffer) : Promise, + + readId3v1(filebuffer: string | Buffer) : Promise, + + writeId3v1(tag: Id3v1WriteTag, filebuffer: Buffer, options?: Id3v1WriteOptions) : Promise, + writeId3v1(tag: Id3v1WriteTag, filepath: string, options?: Id3v1WriteOptions) : Promise, } } export = NodeID3 diff --git a/index.js b/index.js index bd1da0f..09cfc8e 100644 --- a/index.js +++ b/index.js @@ -1,8 +1,12 @@ const fs = require('fs') const ID3Definitions = require("./src/ID3Definitions") -const ID3Util = require('./src/ID3Util') -const ID3Helpers = require('./src/ID3Helpers') -const { isFunction, isString } = require('./src/util') +const ID3Util = require('./src/ID3Util') +const ID3Helpers = require('./src/ID3Helpers') +const { isFunction, isString } = require('./src/util') +const ID3v1 = require('./src/ID3v1') +const readId3v1 = ID3v1.readId3v1 +const writeId3v1 = ID3v1.writeId3v1 +const removeId3v1 = ID3v1.removeId3v1 /* ** Used specification: http://id3.org/id3v2.3.0 @@ -316,16 +320,22 @@ const PromiseExport = { write: (tags, file) => makePromise(write.bind(null, tags, file)), update: (tags, file, options) => makePromise(update.bind(null, tags, file, options)), read: (file, options) => makePromise(read.bind(null, file, options)), - removeTags: (filepath) => makePromise(removeTags.bind(null, filepath)) + removeTags: (filepath) => makePromise(removeTags.bind(null, filepath)), + readId3v1: (file) => makePromise(readId3v1.bind(null, file)), + writeId3v1: (tag, file, options) => makePromise(writeId3v1.bind(null, tag, file, options)), + removeId3v1: (file) => makePromise(removeId3v1.bind(null, file)) } module.exports = { TagConstants: ID3Definitions.TagConstants, create, write, - update, - read, - removeTags, + update, + read, + readId3v1, + writeId3v1, + removeId3v1, + removeTags, removeTagsFromBuffer, Promise: PromiseExport } diff --git a/package.json b/package.json index ce4be24..653117a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "node-id3", "version": "0.2.9", - "description": "Pure JavaScript ID3v2 Tag writer and reader", + "description": "Pure JavaScript ID3 tag writer and reader", "author": "Jan Metzger ", "main": "index.js", "repository": { @@ -13,6 +13,7 @@ }, "keywords": [ "ID3", + "ID3v1", "ID3v2", "metadata", "tags", diff --git a/src/ID3v1.js b/src/ID3v1.js new file mode 100644 index 0000000..cc9757e --- /dev/null +++ b/src/ID3v1.js @@ -0,0 +1,315 @@ +const fs = require('fs') +const { isFunction, isString } = require('./util') + +const TAG_SIZE = 128 +const TAG_IDENTIFIER = Buffer.from('TAG', 'ascii') + +/** + * Encodes an ID3v1.1 tag as exactly 128 bytes. + * All text is written as Latin-1 and the ID3v1.1 comment separator and track + * byte are always emitted. A supplied version property does not change layout. + * + * @param {Object} tag ID3v1 values to encode. + * @param {Object} [options] ID3v1 write options. + * @returns {Buffer} Encoded 128-byte ID3v1.1 tag. + */ +function createTag(tag, options) { + if(!tag || typeof tag !== 'object' || Array.isArray(tag)) { + throw new TypeError('tag must be an object') + } + const truncation = getTruncation(options) + const trackNumber = validateByte(tag.trackNumber, 'trackNumber') + const genreId = tag.genreId === undefined || tag.genreId === null + ? 255 + : validateByte(tag.genreId, 'genreId') + const result = Buffer.alloc(TAG_SIZE) + TAG_IDENTIFIER.copy(result) + encodeText(tag.title, 30, truncation, 'title').copy(result, 3) + encodeText(tag.artist, 30, truncation, 'artist').copy(result, 33) + encodeText(tag.album, 30, truncation, 'album').copy(result, 63) + encodeText(tag.year, 4, truncation, 'year').copy(result, 93) + encodeText(tag.comment, 28, truncation, 'comment').copy(result, 97) + result[125] = 0 + result[126] = trackNumber + result[127] = genreId + return result +} + +/** + * Reads a trailing ID3v1.0 or ID3v1.1 tag. + * A zero separator at byte 125 identifies ID3v1.1, including when the track + * byte is zero. Missing fields are omitted from the returned object. + * + * @param {Buffer} buffer Complete audio file data. + * @returns {Object|null} Decoded ID3v1 tag, or null when none exists. + */ +function readTag(buffer) { + const position = findTag(buffer) + if(position === -1) return null + + const tag = buffer.slice(position) + const isVersion11 = tag[125] === 0 + const result = { + version: isVersion11 ? '1.1' : '1.0' + } + const fields = [ + ['title', 3, 30], + ['artist', 33, 30], + ['album', 63, 30], + ['year', 93, 4], + ['comment', 97, isVersion11 ? 28 : 30] + ] + fields.forEach(([name, start, length]) => { + const value = readText(tag, start, length) + if(value) result[name] = value + }) + if(isVersion11 && tag[126] !== 0) result.trackNumber = tag[126] + if(tag[127] !== 255) result.genreId = tag[127] + return result +} + +/** + * Finds a valid ID3v1 tag at the only location where it may occur: the final + * 128 bytes of a buffer. Internal `TAG` byte sequences are intentionally ignored. + * + * @param {Buffer} buffer Complete audio file data. + * @returns {number} Start offset of the tag, or -1 when no trailing tag exists. + */ +function findTag(buffer) { + if(!Buffer.isBuffer(buffer)) { + throw new TypeError('fileOrBuffer must be a filepath or Buffer') + } + if(buffer.length < TAG_SIZE) return -1 + const position = buffer.length - TAG_SIZE + return buffer.slice(position, position + 3).equals(TAG_IDENTIFIER) ? position : -1 +} + +/** + * Separates a trailing ID3v1 tag from the preceding ID3v2/audio data. + * The exact tag bytes are retained so write and update operations can preserve + * them without decoding and re-encoding the tag. + * + * @param {Buffer} buffer Complete audio file data. + * @returns {{buffer: Buffer, tag: Buffer|null}} Data without ID3v1 and the detached tag. + */ +function detachTag(buffer) { + const position = findTag(buffer) + if(position === -1) return { buffer, tag: null } + return { + buffer: buffer.slice(0, position), + tag: buffer.slice(position) + } +} + +/** + * Removes only a valid trailing ID3v1 tag. + * + * @param {Buffer} buffer Complete audio file data. + * @returns {Buffer} Data without the trailing ID3v1 tag. + */ +function removeTag(buffer) { + return detachTag(buffer).buffer +} + +function readText(buffer, start, length) { + return buffer.slice(start, start + length).toString('latin1').replace(/[\0 ]+$/, '') +} + + +/** + * Validates ID3v1 text conversion options and returns the effective policy. + * + * @param {Object} [options] ID3v1 write options. + * @returns {'truncate'|'error'} Effective truncation policy. + */ +function getTruncation(options) { + if(options === undefined) return 'truncate' + if(!options || typeof options !== 'object' || Array.isArray(options)) { + throw new TypeError('options must be an object') + } + const truncation = options.id3v1Truncation === undefined + ? (options.truncation === undefined ? 'truncate' : options.truncation) + : options.id3v1Truncation + if(truncation !== 'truncate' && truncation !== 'error') { + throw new TypeError('id3v1Truncation must be "truncate" or "error"') + } + return truncation +} + +function encodeText(value, width, truncation, name) { + if(value === undefined || value === null) return Buffer.alloc(width) + if(typeof value !== 'string' && !(value instanceof String)) { + throw new TypeError(`${name} must be a string`) + } + + let encodedValue = '' + for(const character of String(value)) { + const codePoint = character.codePointAt(0) + if(codePoint === 0) { + if(truncation === 'error') throw new TypeError(`${name} must not contain null characters`) + continue + } + if(codePoint > 255) { + if(truncation === 'error') throw new TypeError(`${name} contains characters outside Latin-1`) + encodedValue += '?' + } else { + encodedValue += character + } + } + + let encoded = Buffer.from(encodedValue, 'latin1') + if(encoded.length > width) { + if(truncation === 'error') throw new RangeError(`${name} exceeds ${width} bytes`) + encoded = encoded.slice(0, width) + } + const result = Buffer.alloc(width) + encoded.copy(result) + return result +} + +function validateByte(value, name) { + if(value === undefined || value === null) return 0 + if(typeof value !== 'number' || !Number.isInteger(value)) { + throw new TypeError(`${name} must be an integer`) + } + if(value < 0 || value > 255) throw new RangeError(`${name} must be between 0 and 255`) + return value +} + + +/** + * Reads an ID3v1 tag from a filepath or Buffer. + * + * @param {string|Buffer} filebuffer Filepath or complete audio file data. + * @param {Function} [fn] Optional error-first `(error, tag)` callback. + * @returns {Object|null|undefined} Tag for synchronous calls, otherwise undefined. + */ +function readId3v1(filebuffer, fn) { + if(isFunction(fn)) { + if(isString(filebuffer)) { + fs.readFile(filebuffer, (error, data) => { + if(error) return fn(error, null) + try { + fn(null, readTag(data)) + } catch(error) { + fn(error, null) + } + }) + } else { + try { + fn(null, readTag(filebuffer)) + } catch(error) { + fn(error, null) + } + } + return undefined + } + if(isString(filebuffer)) filebuffer = fs.readFileSync(filebuffer) + return readTag(filebuffer) +} + +function writeSync(tag, filebuffer, options) { + if(isString(filebuffer)) { + try { + const data = fs.readFileSync(filebuffer) + const newData = Buffer.concat([removeTag(data), createTag(tag, options)]) + fs.writeFileSync(filebuffer, newData, 'binary') + return true + } catch(error) { + return error + } + } + return Buffer.concat([removeTag(filebuffer), createTag(tag, options)]) +} + +/** + * Creates or replaces a trailing ID3v1.1 tag on a filepath or Buffer. + * File data is validated and encoded before a filepath is overwritten. + * + * @param {Object} tag ID3v1 values to encode. + * @param {string|Buffer} filebuffer Filepath or complete audio file data. + * @param {Object|Function} [options] ID3v1 write options or callback. + * @param {Function} [fn] Optional error-first callback. + * @returns {true|Buffer|Error|undefined} Result matching the selected API form. + */ +function writeId3v1(tag, filebuffer, options, fn) { + if(isFunction(options)) { + fn = options + options = {} + } + if(isFunction(fn)) { + if(isString(filebuffer)) { + fs.readFile(filebuffer, (error, data) => { + if(error) return fn(error) + let newData + try { + newData = Buffer.concat([removeTag(data), createTag(tag, options)]) + } catch(error) { + return fn(error) + } + fs.writeFile(filebuffer, newData, 'binary', fn) + }) + } else { + try { + fn(null, Buffer.concat([removeTag(filebuffer), createTag(tag, options)])) + } catch(error) { + fn(error) + } + } + return undefined + } + return writeSync(tag, filebuffer, options) +} + +function removeSync(filebuffer) { + if(isString(filebuffer)) { + try { + const data = fs.readFileSync(filebuffer) + const newData = removeTag(data) + fs.writeFileSync(filebuffer, newData, 'binary') + return true + } catch(error) { + return error + } + } + return removeTag(filebuffer) +} + +/** + * Removes only a trailing ID3v1 tag from a filepath or Buffer. + * An absent tag is treated as a successful no-op. + * + * @param {string|Buffer} filebuffer Filepath or complete audio file data. + * @param {Function} [fn] Optional error-first callback. + * @returns {true|Buffer|Error|undefined} Result matching the selected API form. + */ +function removeId3v1(filebuffer, fn) { + if(isFunction(fn)) { + if(isString(filebuffer)) { + fs.readFile(filebuffer, (error, data) => { + if(error) return fn(error) + let newData + try { + newData = removeTag(data) + } catch(error) { + return fn(error) + } + fs.writeFile(filebuffer, newData, 'binary', fn) + }) + } else { + try { + fn(null, removeTag(filebuffer)) + } catch(error) { + fn(error) + } + } + return undefined + } + return removeSync(filebuffer) +} + +module.exports = { + readId3v1, + removeId3v1, + writeId3v1 +} diff --git a/test/id3v1.js b/test/id3v1.js new file mode 100644 index 0000000..62e1163 --- /dev/null +++ b/test/id3v1.js @@ -0,0 +1,195 @@ +const assert = require('assert') +const fs = require('fs') +const NodeID3 = require('../index') + +function createVersion10Tag() { + const tag = Buffer.alloc(128) + tag.write('TAG', 0, 'ascii') + tag.write(' Title ', 3, 'latin1') + tag.write('Artist', 33, 'latin1') + tag.write('Album', 63, 'latin1') + tag.write('1999', 93, 'latin1') + tag.write('A thirty byte comment value!!', 97, 'latin1') + tag[127] = 17 + return tag +} + +describe('ID3v1', function() { + const audio = Buffer.from([0xFF, 0xFB, 0x90, 0x64, 0x54, 0x41, 0x47, 0x01]) + const filepath = './id3v1-test-file.mp3' + + afterEach(function() { + if(fs.existsSync(filepath)) fs.unlinkSync(filepath) + }) + + describe('#readId3v1()', function() { + it('reads ID3v1.0 without trimming leading spaces', function() { + assert.deepStrictEqual(NodeID3.readId3v1(Buffer.concat([audio, createVersion10Tag()])), { + version: '1.0', + title: ' Title', + artist: 'Artist', + album: 'Album', + year: '1999', + comment: 'A thirty byte comment value!!', + genreId: 17 + }) + }) + + it('reads ID3v1.1 with a zero track byte as version 1.1', function() { + const buffer = NodeID3.writeId3v1({ title: 'Title', trackNumber: 0 }, audio) + assert.deepStrictEqual(NodeID3.readId3v1(buffer), { + version: '1.1', + title: 'Title' + }) + }) + + it('returns null for short buffers and internal TAG bytes', function() { + assert.strictEqual(NodeID3.readId3v1(Buffer.from('TAG')), null) + assert.strictEqual(NodeID3.readId3v1(audio), null) + }) + }) + + describe('#writeId3v1()', function() { + it('writes maximum-width fields and exact layout', function() { + const tag = { + title: 't'.repeat(30), + artist: 'a'.repeat(30), + album: 'b'.repeat(30), + year: '2026', + comment: 'c'.repeat(28), + trackNumber: 255, + genreId: 147, + version: '1.0' + } + const output = NodeID3.writeId3v1(tag, audio) + const suffix = output.slice(-128) + assert.strictEqual(output.length, audio.length + 128) + assert.strictEqual(suffix.slice(0, 3).toString('ascii'), 'TAG') + assert.strictEqual(suffix.slice(3, 33).toString('latin1'), tag.title) + assert.strictEqual(suffix.slice(33, 63).toString('latin1'), tag.artist) + assert.strictEqual(suffix.slice(63, 93).toString('latin1'), tag.album) + assert.strictEqual(suffix.slice(93, 97).toString('latin1'), tag.year) + assert.strictEqual(suffix.slice(97, 125).toString('latin1'), tag.comment) + assert.deepStrictEqual([...suffix.slice(125)], [0, 255, 147]) + }) + + it('null-pads short values and replaces/truncates lossy text', function() { + const output = NodeID3.writeId3v1({ + title: `caf\u00e9\u{1F3B5}${'x'.repeat(30)}`, + comment: 'a\0b' + }, audio) + const suffix = output.slice(-128) + assert.strictEqual(suffix.slice(3, 33).toString('latin1'), `caf\u00e9?${'x'.repeat(25)}`) + assert.strictEqual(suffix.slice(97, 100).toString('latin1'), 'ab\0') + assert.ok(suffix.slice(100, 125).every((value) => value === 0)) + }) + + it('rejects lossy and overlong fields in error mode', function() { + assert.throws(() => NodeID3.writeId3v1({ title: 'music \u{1F3B5}' }, audio, { + id3v1Truncation: 'error' + }), TypeError) + assert.throws(() => NodeID3.writeId3v1({ title: 'x'.repeat(31) }, audio, { + id3v1Truncation: 'error' + }), RangeError) + assert.throws(() => NodeID3.writeId3v1({ comment: 'a\0b' }, audio, { + id3v1Truncation: 'error' + }), TypeError) + const widths = { title: 30, artist: 30, album: 30, year: 4, comment: 28 } + Object.keys(widths).forEach((field) => { + const tag = {} + tag[field] = 'x'.repeat(widths[field] + 1) + assert.throws(() => NodeID3.writeId3v1(tag, audio, { + id3v1Truncation: 'error' + }), RangeError) + }) + }) + + it('strictly validates direct track, genre, and option values', function() { + assert.throws(() => NodeID3.writeId3v1({ trackNumber: '3' }, audio), TypeError) + assert.throws(() => NodeID3.writeId3v1({ trackNumber: 256 }, audio), RangeError) + assert.throws(() => NodeID3.writeId3v1({ genreId: -1 }, audio), RangeError) + assert.throws(() => NodeID3.writeId3v1({}, audio, { id3v1Truncation: 'clip' }), TypeError) + assert.strictEqual(NodeID3.readId3v1(NodeID3.writeId3v1({ trackNumber: 1 }, audio)).trackNumber, 1) + }) + + it('replaces exactly one trailing tag', function() { + const first = NodeID3.writeId3v1({ title: 'First' }, audio) + const second = NodeID3.writeId3v1({ title: 'Second' }, first) + assert.strictEqual(second.length, first.length) + assert.strictEqual(NodeID3.readId3v1(second).title, 'Second') + assert.deepStrictEqual(second.slice(0, audio.length), audio) + }) + }) + + describe('#removeId3v1()', function() { + it('is byte-identical when no trailing tag exists', function() { + assert.deepStrictEqual(NodeID3.removeId3v1(audio), audio) + }) + + it('removes the trailing tag without removing ID3v2', function() { + const id3v2 = NodeID3.write({ title: 'Title' }, audio) + const source = NodeID3.writeId3v1({ title: 'Title' }, id3v2) + const result = NodeID3.removeId3v1(source) + assert.strictEqual(NodeID3.read(result).title, 'Title') + assert.deepStrictEqual(NodeID3.removeTagsFromBuffer(result), audio) + }) + }) + + describe('filepath, callback, and Promise APIs', function() { + it('supports synchronous filepath write, update, read, and removal', function() { + fs.writeFileSync(filepath, audio) + assert.strictEqual(NodeID3.write({ title: 'Initial', artist: 'Artist' }, filepath), true) + assert.strictEqual(NodeID3.update({ title: 'Updated' }, filepath), true) + assert.strictEqual(NodeID3.writeId3v1({ title: 'Updated', artist: 'Artist' }, filepath), true) + assert.deepStrictEqual(NodeID3.readId3v1(filepath), { + version: '1.1', title: 'Updated', artist: 'Artist' + }) + assert.strictEqual(NodeID3.removeId3v1(filepath), true) + assert.strictEqual(NodeID3.readId3v1(filepath), null) + assert.strictEqual(NodeID3.read(filepath).title, 'Updated') + }) + + it('does not change a filepath when error-mode validation fails', function() { + fs.writeFileSync(filepath, audio) + const result = NodeID3.writeId3v1({ title: '\u{1F3B5}' }, filepath, { + id3v1Truncation: 'error' + }) + assert.ok(result instanceof TypeError) + assert.deepStrictEqual(fs.readFileSync(filepath), audio) + }) + + it('supports options and results in callbacks', function(done) { + NodeID3.write({ title: 'Title' }, audio, (error, buffer) => { + assert.ifError(error) + NodeID3.writeId3v1({ title: 'Title' }, buffer, (error, buffer) => { + assert.ifError(error) + NodeID3.readId3v1(buffer, (error, tag) => { + assert.ifError(error) + assert.strictEqual(tag.title, 'Title') + NodeID3.removeId3v1(buffer, (error, result) => { + assert.ifError(error) + assert.strictEqual(NodeID3.readId3v1(result), null) + done() + }) + }) + }) + }) + }) + + it('supports dedicated Promise APIs and write options', function() { + return NodeID3.Promise.writeId3v1({ title: 'Direct' }, audio) + .then((written) => NodeID3.Promise.readId3v1(written).then((tag) => { + assert.strictEqual(tag.title, 'Direct') + return NodeID3.Promise.removeId3v1(written) + })) + .then((removed) => { + assert.deepStrictEqual(removed, audio) + return NodeID3.Promise.write({ title: 'ID3v2' }, audio) + }) + .then((id3v2) => NodeID3.Promise.writeId3v1({ title: 'Separate' }, id3v2)) + .then((result) => { + assert.strictEqual(NodeID3.readId3v1(result).title, 'Separate') + }) + }) + }) +})