diff --git a/package.json b/package.json index 625b631..f02f8a3 100644 --- a/package.json +++ b/package.json @@ -47,8 +47,7 @@ }, "license": "MIT", "dependencies": { - "iconv-lite": "0.6.2", - "tmp": "^0.2.1" + "iconv-lite": "0.6.2" }, "devDependencies": { "@istanbuljs/nyc-config-typescript": "^1.0.2", @@ -58,7 +57,6 @@ "@types/mocha": "^10.0.1", "@types/node": "^18.11.10", "@types/source-map-support": "^0.5.6", - "@types/tmp": "^0.2.3", "@typescript-eslint/eslint-plugin": "^5.45.0", "@typescript-eslint/parser": "^5.45.0", "chai": "^4.3.7", diff --git a/src/api/create.ts b/src/api/create.ts index a44e6e2..80359da 100644 --- a/src/api/create.ts +++ b/src/api/create.ts @@ -1,33 +1,11 @@ import { WriteTags } from "../types/Tags" -import { isFunction } from "../util" import { createId3Tag } from "../id3-tag" -/** - * Callback used to return a buffer with the created ID Tag. - * - * @public - */ -export type CreateCallback = - (data: Buffer) => void - /** * Creates a buffer containing an ID3 Tag and returns it. * * @public */ -export function create(tags: WriteTags): Buffer - -/** - * Creates a buffer containing an ID3 Tag and returns it via the callback. - * - * @public - */ -export function create(tags: WriteTags, callback: CreateCallback): void - -export function create(tags: WriteTags, callback?: CreateCallback) { - const id3Data = createId3Tag(tags) - if (isFunction(callback)) { - return callback(id3Data) - } - return id3Data +export function create(tags: WriteTags): Buffer { + return createId3Tag(tags) } diff --git a/src/api/promises.ts b/src/api/promises.ts index 7d93f92..182b7d3 100644 --- a/src/api/promises.ts +++ b/src/api/promises.ts @@ -1,10 +1,10 @@ import { Tags, TagIdentifiers, WriteTags } from '../types/Tags' import { Options } from '../types/Options' -import { create } from "./create" -import { read, ReadCallback } from "./read" +import { ReadCallback } from "../types/read" +import { read, } from "./read" import { removeTags } from "./remove" import { update } from "./update" -import { write, WriteCallback, WriteFileCallback } from "./write" +import { write } from "./write" type Settle = { (error: NodeJS.ErrnoException | Error, result: null): void @@ -14,7 +14,7 @@ type Settle = { function makePromise(callback: (settle: Settle) => void) { return new Promise((resolve, reject) => { callback((error, result) => { - if(error) { + if (error) { reject(error) } else { // result can't be null here according the Settle callable @@ -33,36 +33,29 @@ function makePromise(callback: (settle: Settle) => void) { * @public */ export const Promises = { - create: (tags: WriteTags) => - makePromise((settle: Settle) => - create(tags, result => settle(null, result)), + write: (tags: WriteTags, filepath: string) => + makePromise(settle => write( + tags, + filepath, + (error) => error ? settle(error, null) : settle(null) + )), + update: (tags: WriteTags, filepath: string, options?: Options) => + makePromise((settle) => update( + tags, + filepath, + options ?? {}, + (error) => error ? settle(error, null) : settle(null) + ) ), - write: (tags: WriteTags, filebuffer: string | Buffer) => - makePromise((callback: WriteCallback | WriteFileCallback) => - write(tags, filebuffer, callback) - ), - update: (tags: WriteTags, filebuffer: string | Buffer, options?: Options) => - makePromise((callback: WriteCallback | WriteFileCallback) => - update(tags, filebuffer, options ?? {}, callback) - ), - read: (file: string | Buffer, options?: Options) => + read: (filepath: string, options?: Options) => makePromise((callback: ReadCallback) => - read(file, options ?? {}, callback) + read(filepath, options ?? {}, callback) ), removeTags: (filepath: string) => - makePromise((settle: Settle) => + makePromise((settle) => removeTags( filepath, (error) => error ? settle(error, null) : settle(null) ) ) } as const - -/** - * Asynchronous API for files and buffers operations using promises. - * - * @public - * @deprecated Consider using `Promises` instead as `Promise` creates conflict - * with the Javascript native promise. - */ -export { Promises as Promise } diff --git a/src/api/read.ts b/src/api/read.ts index 1c7f541..65c7e95 100644 --- a/src/api/read.ts +++ b/src/api/read.ts @@ -1,65 +1,49 @@ -import * as fs from 'fs' import { getTagsFromId3Tag } from '../id3-tag' import { isFunction, isString } from '../util' import { Tags, TagIdentifiers } from '../types/Tags' import { Options } from '../types/Options' -import { getId3TagDataFromFileAsync, getId3TagDataFromFileSync } from '../file-read' +import { + getId3TagDataFromFileAsync, + getId3TagDataFromFileSync +} from '../file-read' +import { FileReadOptions, ReadCallback } from '../types/read' /** - * Callback signature for successful asynchronous read operation. - * - * @param tags - `TagsIdentifiers` if the `rawOnly` option was true otherwise - * `Tags` - * @public - */ -export type ReadSuccessCallback = - (error: null, tags: Tags | TagIdentifiers) => void - -/** - * Callback signatures for failing asynchronous read operation. - * + * Reads ID3-Tags from a buffer. + * @deprecated Use `readFromBuffer` instead. * @public */ -export type ReadErrorCallback = - (error: NodeJS.ErrnoException | Error, tags: null) => void +export function read(buffer: Buffer, options?: Options): Tags /** - * Callback signatures for asynchronous read operation. - * - * @public - */ -export type ReadCallback = - ReadSuccessCallback & ReadErrorCallback - -/** - * Reads ID3-Tags synchronously from passed buffer/filepath. - * + * Reads ID3-Tags synchronously from a file. + * @deprecated Use `readFromFileSync` instead. * @public */ -export function read(filebuffer: string | Buffer, options?: Options): Tags +export function read(filepath: string, options?: FileReadOptions): Tags /** - * Reads ID3-Tags asynchronously from passed buffer/filepath. - * + * Reads ID3-Tags asynchronously from passed filepath. + * @deprecated Use `readFromFile` instead. * @public */ -export function read(filebuffer: string | Buffer, callback: ReadCallback): void +export function read(filebuffer: string, callback: ReadCallback): void /** - * Reads ID3-Tags asynchronously from passed buffer/filepath. - * + * Reads ID3-Tags asynchronously from passed filepath. + * @deprecated Use `readFromFile` instead. * @public */ export function read( - filebuffer: string | Buffer, options: Options, callback: ReadCallback + filebuffer: string, options: FileReadOptions, callback: ReadCallback ): void export function read( filebuffer: string | Buffer, - optionsOrCallback?: Options | ReadCallback, + optionsOrCallback?: FileReadOptions | ReadCallback, callback?: ReadCallback ): Tags | TagIdentifiers | void { - const options: Options = + const options: FileReadOptions = (isFunction(optionsOrCallback) ? {} : optionsOrCallback) ?? {} callback = isFunction(optionsOrCallback) ? optionsOrCallback : callback @@ -70,27 +54,98 @@ export function read( return readSync(filebuffer, options) } -function readSync(filebuffer: string | Buffer, options: Options) { +function readSync(filebuffer: string | Buffer, options: FileReadOptions) { if (isString(filebuffer)) { - filebuffer = getId3TagDataFromFileSync(filebuffer) ?? Buffer.alloc(0) + filebuffer = getId3TagDataFromFileSync(filebuffer, options)[0] ?? Buffer.alloc(0) } return getTagsFromId3Tag(filebuffer, options) } function readAsync( filebuffer: string | Buffer, - options: Options, + options: FileReadOptions, callback: ReadCallback ) { if (isString(filebuffer)) { - getId3TagDataFromFileAsync(filebuffer, (error, data) => { - if(error) { - callback(error, null) - } else { - callback(null, getTagsFromId3Tag(data ?? Buffer.alloc(0), options)) - } - }) + getId3TagDataFromFileAsync(filebuffer, options).then( + (buffers) => callback( + null, decodeTagBuffers(buffers, options) + ), + (error) => callback(error, null) + ) } else { callback(null, getTagsFromId3Tag(filebuffer, options)) } } + +// New API + +/** + * Reads ID3-Tags from a buffer. + * + * @public + */ +export function readFromBuffer( + buffer: Buffer, + options: Options = {} +): Tags | TagIdentifiers { + return getTagsFromId3Tag(buffer, options) +} + +/** + * Reads ID3-Tags synchronously from a file. + * + * @public + */ +export function readFromFileSync( + filepath: string, + options: FileReadOptions = {} +): Tags | TagIdentifiers { + const buffers = getId3TagDataFromFileSync(filepath, options) + return decodeTagBuffers(buffers, options) +} + +/** + * Reads ID3-Tags asynchronously from a file. + * + * @public + */ +export function readFromFile( + filepath: string, + callback: ReadCallback +): void + +/** + * Reads ID3-Tags asynchronously from a file. + * + * @public + */ +export function readFromFile( + filepath: string, + options: FileReadOptions, + callback: ReadCallback +): void + +export function readFromFile( + filepath: string, + optionsOrCallback?: FileReadOptions | ReadCallback, + maybeCallback: ReadCallback = () => { /* */ } +): void { + const options: FileReadOptions = + (isFunction(optionsOrCallback) ? {} : optionsOrCallback) ?? {} + const callback = + isFunction(optionsOrCallback) ? optionsOrCallback : maybeCallback + + getId3TagDataFromFileAsync(filepath, options).then( + (buffers) => callback( + null, decodeTagBuffers(buffers, options) + ), + (error) => callback(error, null) + ) +} + +// For now, just take the first one +function decodeTagBuffers(buffers: Buffer[], options: Options) { + const firstBuffer = buffers[0] ?? Buffer.alloc(0) + return getTagsFromId3Tag(firstBuffer, options) +} diff --git a/src/api/update.ts b/src/api/update.ts index 0c67a77..2360a64 100644 --- a/src/api/update.ts +++ b/src/api/update.ts @@ -1,16 +1,18 @@ import { WriteTags } from "../types/Tags" import { Options } from "../types/Options" -import { isFunction, isString } from "../util" +import { isBuffer, isFunction, isString, validateString } from "../util" import { read } from "./read" import { updateTags } from '../updateTags' -import { write, WriteCallback } from "./write" +import { write } from "./write" +import { WriteCallback } from "../types/write" /** * Updates ID3-Tags from the given buffer. + * Throws in case of error. * * @public */ - export function update( +export function update( tags: WriteTags, buffer: Buffer, options?: Options @@ -18,34 +20,37 @@ import { write, WriteCallback } from "./write" /** * Updates ID3-Tags synchronously in the specified file. + * Throws in case of error. * * @public */ - export function update( +export function update( tags: WriteTags, filepath: string, options?: Options -): true | Error +): void /** * Updates ID3-Tags asynchronously in the specified file. + * throws in case of error. * * @public */ - export function update( +export function update( tags: WriteTags, - filebuffer: string | Buffer, + filepath: string, callback: WriteCallback ): void /** * Updates ID3-Tags asynchronously from the given buffer or specified file. + * throws in case of error. * * @public */ - export function update( +export function update( tags: WriteTags, - filebuffer: string | Buffer, + filepath: string, options: Options, callback: WriteCallback ): void @@ -55,16 +60,17 @@ export function update( filebuffer: string | Buffer, optionsOrCallback?: Options | WriteCallback, callback?: WriteCallback -): Buffer | true | Error | void { +): Buffer | void { const options: Options = (isFunction(optionsOrCallback) ? {} : optionsOrCallback) ?? {} callback = isFunction(optionsOrCallback) ? optionsOrCallback : callback - const currentTags = read(filebuffer, options) + const currentTags = isBuffer(filebuffer) ? + read(filebuffer, options) : read(filebuffer, options) const updatedTags = updateTags(tags, currentTags) if (isFunction(callback)) { - return write(updatedTags, filebuffer, callback) + return write(updatedTags, validateString(filebuffer), callback) } if (isString(filebuffer)) { return write(updatedTags, filebuffer) diff --git a/src/api/write.ts b/src/api/write.ts index 6fdc9cd..6c674ac 100644 --- a/src/api/write.ts +++ b/src/api/write.ts @@ -1,117 +1,134 @@ -import * as fs from "fs" import { WriteTags } from "../types/Tags" +import { WriteCallback, WriteOptions } from "../types/write" import { create } from "./create" import { removeTagsFromBuffer } from "./remove" -import { isFunction, isString } from "../util" +import { isFunction, isString, validateString } from "../util" import { writeId3TagToFileAsync, writeId3TagToFileSync } from "../file-write" /** - * Callback signature for successful asynchronous update and write operations. - * + * Replaces any existing tags with the given tags in the given buffer. + * Throws in case of error. + * @deprecated Use `writeInBuffer` instead. * @public */ -export type WriteSuccessCallback = - (error: null, data: Buffer) => void +export function write(tags: WriteTags, buffer: Buffer): Buffer /** - * Callback signature for failing asynchronous update and write operations. - * + * Replaces synchronously any existing tags with the given tags in the + * specified file. + * Throws in case of error. + * @deprecated Use `writeInFileSync` instead. * @public */ -export type WriteErrorCallback = - (error: NodeJS.ErrnoException | Error, data: null) => void +export function write( + tags: WriteTags, + filepath: string, + options?: WriteOptions +): void /** - * Callback signatures for asynchronous update and write operations. - * + * Replaces asynchronously any existing tags with the given tags in the + * specified file. + * @deprecated Use `writeInFile` instead. * @public */ -export type WriteCallback = - WriteSuccessCallback & WriteErrorCallback +export function write( + tags: WriteTags, + filepath: string, + callback: WriteCallback +): void -export type WriteFileCallback = - (error: NodeJS.ErrnoException | Error | null) => void +export function write( + tags: WriteTags, + filebuffer: string | Buffer, + optionsOrCallback?: WriteOptions | WriteCallback, + maybeCallback?: WriteCallback +): Buffer | void { + const options = + (isFunction(optionsOrCallback) ? {} : optionsOrCallback) ?? {} + const callback = + isFunction(optionsOrCallback) ? optionsOrCallback : maybeCallback + + if (isFunction(callback)) { + writeToFile(tags, validateString(filebuffer), options, callback) + return + } + if (isString(filebuffer)) { + return writeToFileSync(tags, filebuffer, options) + } + return writeInBuffer(tags, filebuffer) +} + +// New API /** * Replaces any existing tags with the given tags in the given buffer. - * + * Throws in case of error. * @public */ -export function write(tags: WriteTags, buffer: Buffer): Buffer +export function writeInBuffer(tags: WriteTags, buffer: Buffer): Buffer { + const id3Tag = create(tags) + const bufferWithoutId3Tag = removeTagsFromBuffer(buffer) || buffer + return Buffer.concat([id3Tag, bufferWithoutId3Tag]) +} /** * Replaces synchronously any existing tags with the given tags in the * specified file. - * + * Throws in case of error. * @public */ -export function write(tags: WriteTags, filepath: string): true | Error +export function writeToFileSync( + tags: WriteTags, + filepath: string, + options: WriteOptions = {} +): void { + const id3Tag = create(tags) + writeId3TagToFileSync(filepath, id3Tag, options) +} /** * Replaces asynchronously any existing tags with the given tags in the - * given buffer. - * + * specified file. * @public */ -export function write( - tags: WriteTags, filebuffer: Buffer, callback: WriteCallback +export function writeToFile( + tags: WriteTags, + filepath: string, + callback: WriteCallback ): void /** * Replaces asynchronously any existing tags with the given tags in the - * given file. - * + * specified file. * @public */ -export function write( - tags: WriteTags, filebuffer: string, callback: WriteFileCallback +export function writeToFile( + tags: WriteTags, + filepath: string, + options: WriteOptions, + callback: WriteCallback ): void /** * Replaces asynchronously any existing tags with the given tags in the - * given buffer or specified file. - * + * specified file. * @public */ -export function write( - tags: WriteTags, filebuffer: string | Buffer, callback: WriteFileCallback | WriteCallback -): void - -export function write( +export function writeToFile( tags: WriteTags, - filebuffer: string | Buffer, - callback?: WriteCallback | WriteFileCallback -): Buffer | true | Error | void { - const tagsBuffer = create(tags) - - if (isFunction(callback)) { - if (isString(filebuffer)) { - return writeAsync(tagsBuffer, filebuffer, callback as WriteFileCallback) - } - return callback(null, writeInBuffer(tagsBuffer, filebuffer)) - } - if (isString(filebuffer)) { - return writeSync(tagsBuffer, filebuffer) - } - return writeInBuffer(tagsBuffer, filebuffer) -} - -function writeInBuffer(tags: Buffer, buffer: Buffer) { - buffer = removeTagsFromBuffer(buffer) || buffer - return Buffer.concat([tags, buffer]) -} + filepath: string, + optionsOrCallback: WriteOptions | WriteCallback, + maybeCallback: WriteCallback = () => { /* */ } +): void { + const options = + (isFunction(optionsOrCallback) ? {} : optionsOrCallback) ?? {} + const callback = + isFunction(optionsOrCallback) ? optionsOrCallback : maybeCallback -function writeAsync(tags: Buffer, filepath: string, callback: WriteFileCallback) { - writeId3TagToFileAsync(filepath, tags, (err) => { - callback(err) - }) -} - -function writeSync(tags: Buffer, filepath: string) { - try { - writeId3TagToFileSync(filepath, tags) - return true - } catch(error) { - return error as Error - } + const id3Tag = create(tags) + writeId3TagToFileAsync(filepath, id3Tag, options).then( + () => callback(null), + (error) => callback(error) + ) } diff --git a/src/file-read.ts b/src/file-read.ts index a41f5de..9f5cae4 100644 --- a/src/file-read.ts +++ b/src/file-read.ts @@ -1,91 +1,36 @@ import * as fs from 'fs' -import { findId3TagPosition, getId3TagSize, Header } from './id3-tag' -import { fsReadPromise, getNextBufferSubarrayAsync, getNextBufferSubarraySync, processFile, processFileAsync } from './util-file' - -const FileBufferSize = 20 * 1024 * 1024 - -type SuccessCallback = (err: null, buffer: Buffer|null) => void -type ErrorCallback = (err: Error, buffer: null) => void -type Callback = SuccessCallback & ErrorCallback - -export function getId3TagDataFromFileSync(filepath: string): Buffer|null { - return processFile(filepath, 'r', (fileDescriptor) => { - const partialId3TagData = findPartialId3TagSync(fileDescriptor) - return partialId3TagData ? completePartialId3TagData( - fileDescriptor, - partialId3TagData - ) : null +import { Id3TagStreamProcessor } from './file-stream-processor' +import { processFileSync, processFileAsync, fsReadAsync } from './util-file' +import { FileOptions } from './types/FileOptions' + +export function getId3TagDataFromFileSync( + filepath: string, + options: FileOptions +) { + const reader = new Id3TagStreamProcessor(options.fileBufferSize) + processFileSync(filepath, 'r', (fileDescriptor) => { + do { + const readBuffer = reader.getReadBuffer() + const sizeRead = fs.readSync(fileDescriptor, readBuffer) + const missingData = reader.processReadBuffer(sizeRead) + fs.readSync(fileDescriptor, missingData) + } while(reader.continue) }) + return reader.getTags() } -export function getId3TagDataFromFileAsync(filepath: string, callback: Callback) { - processFileAsync(filepath, 'r', async (fileDescriptor) => { - const partialId3TagData = await findPartialId3TagAsync(fileDescriptor) - return partialId3TagData ? completePartialId3TagDataAsync( - fileDescriptor, - partialId3TagData - ) : null - }).then((data) => { - callback(null, data) - }).catch((error) => { - callback(error, null) +export async function getId3TagDataFromFileAsync( + filepath: string, + options: FileOptions +) { + const reader = new Id3TagStreamProcessor(options.fileBufferSize) + await processFileAsync(filepath, 'r', async (fileDescriptor) => { + do { + const readBuffer = reader.getReadBuffer() + const sizeRead = await fsReadAsync(fileDescriptor, readBuffer) + const missingData = reader.processReadBuffer(sizeRead) + await fsReadAsync(fileDescriptor, missingData) + } while(reader.continue) }) + return reader.getTags() } - -function findPartialId3TagSync(fileDescriptor: number): Buffer|null { - const buffer = Buffer.alloc(FileBufferSize) - let data - while((data = getNextBufferSubarraySync(fileDescriptor, buffer, Header.size)).length > Header.size) { - const id3TagPosition = findId3TagPosition(data) - if(id3TagPosition !== -1) { - return data.subarray(id3TagPosition) - } - buffer.copyWithin(0, buffer.length - Header.size) - } - return null -} - -async function findPartialId3TagAsync(fileDescriptor: number): Promise { - const buffer = Buffer.alloc(FileBufferSize) - let data - while((data = await getNextBufferSubarrayAsync(fileDescriptor, buffer, Header.size)).length > Header.size) { - const id3TagPosition = findId3TagPosition(data) - if(id3TagPosition !== -1) { - return data.subarray(id3TagPosition) - } - buffer.copyWithin(0, buffer.length - Header.size) - } - return null -} - -function calculateMissingBytes(id3TagSize: number, id3TagBuffer: Buffer): number { - return Math.max(0, id3TagSize - id3TagBuffer.length) -} - -function completePartialId3TagData(fileDescriptor: number, partialId3TagData: Buffer): Buffer { - const id3TagSize = getId3TagSize(partialId3TagData) - const missingBytesCount = calculateMissingBytes(id3TagSize, partialId3TagData) - if(missingBytesCount) { - const id3TagRemainingBuffer = Buffer.alloc(missingBytesCount, 0x00) - fs.readSync(fileDescriptor, id3TagRemainingBuffer) - return Buffer.concat([ - partialId3TagData, - id3TagRemainingBuffer - ]) - } - return partialId3TagData.subarray(0, id3TagSize) -} - -async function completePartialId3TagDataAsync(fileDescriptor: number, partialId3TagData: Buffer): Promise { - const id3TagSize = getId3TagSize(partialId3TagData) - const missingBytesCount = calculateMissingBytes(id3TagSize, partialId3TagData) - if(missingBytesCount) { - const id3TagRemainingBuffer = Buffer.alloc(missingBytesCount, 0x00) - await fsReadPromise(fileDescriptor, {buffer: id3TagRemainingBuffer}) - return Buffer.concat([ - partialId3TagData, - id3TagRemainingBuffer - ]) - } - return partialId3TagData.subarray(0, id3TagSize) -} \ No newline at end of file diff --git a/src/file-stream-processor.ts b/src/file-stream-processor.ts new file mode 100644 index 0000000..8e7fdba --- /dev/null +++ b/src/file-stream-processor.ts @@ -0,0 +1,111 @@ +import { getId3Tag, Header } from './id3-tag' + +// const FileBufferSize = 20 * 1024 * 1024 + +// Must be at least Header.size which is the min size to detect an ID3 header. +// Naming it help identifying the code handling it. +const RolloverBufferSize = Header.size + +const MinBufferSize = RolloverBufferSize + 1 +const DefaultFileBufferSize = RolloverBufferSize + 20 * 1024 * 1024 + +export class Id3TagStreamProcessor { + private buffer: Buffer + private tags: Buffer[] = [] + private rolloverSize = 0 + continue = false + + constructor(bufferSize?: number) { + this.buffer = Buffer.alloc(getBufferSize(bufferSize)) + } + + getReadBuffer() { + return this.buffer.subarray(this.rolloverSize) + } + + processReadBuffer(readSize: number) { + let data = this.buffer.subarray(0, this.rolloverSize + readSize) + + // TODO extract that to id3-tag + // Remove tags from `data` + let missingData = Buffer.alloc(0) + let tag + while((tag = getId3Tag(data))) { + const tagBuffer = Buffer.alloc(tag.size) + tag.data.copy(tagBuffer) + data = tag.after + missingData = tagBuffer.subarray(tag.data.length) + } + + // Exclude rollover window on the last part + this.rolloverSize = Math.min(RolloverBufferSize, data.length, readSize) + const rolloverStart = data.length - this.rolloverSize + const rolloverData = Buffer.from(data.subarray(rolloverStart)) + + // Update rollover window + rolloverData.copy(this.buffer) + + this.continue = this.rolloverSize !==0 || missingData.length !== 0 + + return missingData + } + + getTags() { + return this.tags + } +} + +function getBufferSize(bufferSize?: number) { + return Math.max( + bufferSize ?? DefaultFileBufferSize, + MinBufferSize + ) +} + +export class Id3TagRemover { + private buffer: Buffer + private rolloverSize = 0 + continue = false + + constructor(bufferSize?: number) { + this.buffer = Buffer.alloc(getBufferSize(bufferSize)) + } + + getReadBuffer() { + return this.buffer.subarray(this.rolloverSize) + } + + processReadBuffer(readSize: number) { + let data = this.buffer.subarray(0, this.rolloverSize + readSize) + + // TODO extract that to id3-tag + // Remove tags from `data` + let missingBytes = 0 + const parts = Array.from((function*() { + let tag + while((tag = getId3Tag(data))) { + yield tag.before + data = tag.after + missingBytes = tag.missingBytes + } + })()) + + // Exclude rollover window on the last part + this.rolloverSize = Math.min(RolloverBufferSize, data.length, readSize) + const rolloverStart = data.length - this.rolloverSize + const rolloverData = Buffer.from(data.subarray(rolloverStart)) + parts.push(data.subarray(0, rolloverStart)) + + const writeBuffer = Buffer.concat(parts) + + // Update rollover window + rolloverData.copy(this.buffer) + + this.continue = this.rolloverSize !==0 || missingBytes !== 0 + + return { + skipBuffer: Buffer.alloc(missingBytes), + writeBuffer + } + } +} diff --git a/src/file-write.ts b/src/file-write.ts index ddba36f..cc1b492 100644 --- a/src/file-write.ts +++ b/src/file-write.ts @@ -1,135 +1,104 @@ import { - fsReadPromise, - fsRenamePromise, - fsUnlinkPromise, fsWritePromise, - getNextBufferSubarrayAsync, - getNextBufferSubarraySync, - processFile, - processFileAsync + processFileSync, + processFileAsync, + fsExistsPromise, + fsWriteFilePromise, + fsRenamePromise, + unlinkIfExistSync, + unlinkIfExist, + fsReadAsync, + makeTempFilepath } from "./util-file" -import * as tmp from 'tmp' -import * as path from 'path' import * as fs from 'fs' -import { findId3TagPosition, getId3TagSize } from "./id3-tag" +import { Id3TagRemover } from "./file-stream-processor" +import { WriteOptions } from "./types/write" -const FileBufferSize = 20 * 1024 * 1024 - -export function writeId3TagToFileSync(filepath: string, id3Tag: Buffer) { - const tmpFile = getTmpFilePathSync(filepath) - processFile(filepath, 'r', (readFileDescriptor) => { - processFile(tmpFile, 'w', (writeFileDescriptor) => { - fs.writeSync(writeFileDescriptor, id3Tag) - streamOriginalIntoNewFileSync(readFileDescriptor, writeFileDescriptor) - }) - }) - fs.unlinkSync(filepath) - fs.renameSync(tmpFile, filepath) -} - -export function writeId3TagToFileAsync(filepath: string, id3Tag: Buffer, callback: (err: Error|null) => void) { - getTmpFileAsync(filepath, (err, tmpFile) => { - if(err || !tmpFile) { - return callback(err) - } - - processFileAsync(filepath, 'r', async (readFileDescriptor) => { - return processFileAsync(tmpFile, 'w', async (writeFileDescriptor) => { - await fsWritePromise(writeFileDescriptor, id3Tag) - await streamOriginalIntoNewFileAsync(readFileDescriptor, writeFileDescriptor) +export function writeId3TagToFileSync( + filepath: string, + id3Tag: Buffer, + options: WriteOptions +): void { + if (!fs.existsSync(filepath)) { + fs.writeFileSync(filepath, id3Tag) + return + } + const tempFilepath = makeTempFilepath(filepath) + processFileSync(filepath, 'r', (readFileDescriptor) => { + try { + processFileSync(tempFilepath, 'w', (writeFileDescriptor) => { + fs.writeSync(writeFileDescriptor, id3Tag) + copyFileWithoutId3TagSync( + readFileDescriptor, + writeFileDescriptor, + options.fileBufferSize + ) }) - }).then(async () => { - await fsUnlinkPromise(filepath) - await fsRenamePromise(tmpFile, filepath) - callback(null) - }).catch((error) => { - callback(error) - }) + } catch(error) { + unlinkIfExistSync(tempFilepath) + throw error + } }) + fs.renameSync(tempFilepath, filepath) } -function getTmpFilePathSync(filepath: string): string { - const parsedPath = path.parse(filepath) - return tmp.tmpNameSync({ - tmpdir: parsedPath.dir, - template: `${parsedPath.base}.tmp-XXXXXX`, - }) -} +export async function writeId3TagToFileAsync( + filepath: string, + id3Tag: Buffer, + options: WriteOptions +): Promise { + if (!await fsExistsPromise(filepath)) { + await fsWriteFilePromise(filepath, id3Tag) + return + } + const tempFilepath = makeTempFilepath(filepath) + await processFileAsync(filepath, 'r', async (readFileDescriptor) => { + try { + await processFileAsync(tempFilepath, 'w', + async (writeFileDescriptor) => { + await fsWritePromise(writeFileDescriptor, id3Tag) + await copyFileWithoutId3TagAsync( + readFileDescriptor, + writeFileDescriptor, + options.fileBufferSize + ) + } + ) + } catch(error) { + await unlinkIfExist(tempFilepath) + throw error + } -function getTmpFileAsync(filepath: string, callback: tmp.TmpNameCallback) { - const parsedPath = path.parse(filepath) - tmp.tmpName({ - tmpdir: parsedPath.dir, - template: `${parsedPath.base}.tmp-XXXXXX`, - }, (err, filename) => { - callback(err, filename) }) + await fsRenamePromise(tempFilepath, filepath) } -function streamOriginalIntoNewFileSync(readFileDescriptor: number, writeFileDescriptor: number) { - const buffer = Buffer.alloc(FileBufferSize) - let data - while((data = getNextBufferSubarraySync(readFileDescriptor, buffer)).length) { - const id3TagPosition = findId3TagPosition(data) - if(id3TagPosition !== -1) { - data = getBufferWithoutId3TagAndSkipSync(readFileDescriptor, data, id3TagPosition) - } - fs.writeSync(writeFileDescriptor, data, 0, data.length, null) - } -} - -async function streamOriginalIntoNewFileAsync(readFileDescriptor: number, writeFileDescriptor: number) { - const buffer = Buffer.alloc(FileBufferSize) - let data - while((data = await getNextBufferSubarrayAsync(readFileDescriptor, buffer)).length) { - const id3TagPosition = findId3TagPosition(data) - if(id3TagPosition !== -1) { - data = await getBufferWithoutId3TagAndSkipAsync(readFileDescriptor, data, id3TagPosition) - } - await fsWritePromise(writeFileDescriptor, data, 0, data.length, null) - } +function copyFileWithoutId3TagSync( + readFileDescriptor: number, + writeFileDescriptor: number, + fileBufferSize?: number +) { + const remover = new Id3TagRemover(fileBufferSize) + do { + const readBuffer = remover.getReadBuffer() + const sizeRead = fs.readSync(readFileDescriptor, readBuffer) + const { skipBuffer, writeBuffer } = remover.processReadBuffer(sizeRead) + fs.readSync(readFileDescriptor, skipBuffer) + fs.writeSync(writeFileDescriptor, writeBuffer) + } while(remover.continue) } -function getBufferWithoutId3TagAndSkipSync(fileDescriptor: number, data: Buffer, id3TagPosition: number): Buffer { - const dataFromId3Start = data.subarray(id3TagPosition) - const id3TagSize = getId3TagSize(dataFromId3Start) - if(id3TagSize > dataFromId3Start.length) { - const missingBytesCount = id3TagSize - dataFromId3Start.length - fs.readSync( - fileDescriptor, - Buffer.alloc(missingBytesCount), - 0, - missingBytesCount, - null - ) - return data.subarray(0, id3TagPosition) - } - - const id3TagEndPosition = id3TagPosition + id3TagSize - return Buffer.concat([ - data.subarray(0, id3TagPosition), - data.subarray(id3TagEndPosition) - ]) +async function copyFileWithoutId3TagAsync( + readFileDescriptor: number, + writeFileDescriptor: number, + fileBufferSize?: number +) { + const remover = new Id3TagRemover(fileBufferSize) + do { + const readBuffer = remover.getReadBuffer() + const sizeRead = await fsReadAsync(readFileDescriptor, readBuffer) + const { skipBuffer, writeBuffer } = remover.processReadBuffer(sizeRead) + await fsReadAsync(readFileDescriptor, skipBuffer) + await fsWriteFilePromise(writeFileDescriptor, writeBuffer) + } while(remover.continue) } - -async function getBufferWithoutId3TagAndSkipAsync(fileDescriptor: number, data: Buffer, id3TagPosition: number): Promise { - const dataFromId3Start = data.subarray(id3TagPosition) - const id3TagSize = getId3TagSize(dataFromId3Start) - if(id3TagSize > dataFromId3Start.length) { - const missingBytesCount = id3TagSize - dataFromId3Start.length - await fsReadPromise( - fileDescriptor, - Buffer.alloc(missingBytesCount), - 0, - missingBytesCount, - null - ) - return data.subarray(0, id3TagPosition) - } - - const id3TagEndPosition = id3TagPosition + id3TagSize - return Buffer.concat([ - data.subarray(0, id3TagPosition), - data.subarray(id3TagEndPosition) - ]) -} \ No newline at end of file diff --git a/src/id3-tag.ts b/src/id3-tag.ts index 2a69869..2a84304 100644 --- a/src/id3-tag.ts +++ b/src/id3-tag.ts @@ -85,26 +85,10 @@ export function embedFramesInId3Tag(frames: Buffer) { /** * Remove already written ID3-Frames from a buffer */ -export function removeId3Tag(data: Buffer) { - const tagPosition = findId3TagPosition(data) - if (tagPosition === -1) { - return data - } - const encodedSize = subarray(data, tagPosition + Header.offset.size, 4) - - if (!isValidEncodedSize(encodedSize)) { - return false - } - - if (data.length >= tagPosition + Header.size) { - const size = decodeSize(encodedSize) - return Buffer.concat([ - data.subarray(0, tagPosition), - data.subarray(tagPosition + size + Header.size) - ]) - } - - return data +export function removeId3Tag(data: Buffer): Buffer { + // TODO support multiple-frames, improve tests + const tag = getId3Tag(data) + return tag ? Buffer.concat([tag.before, tag.after]) : data } export function getTagsFromId3Tag(buffer: Buffer, options: Options) { @@ -130,8 +114,7 @@ function getId3TagBody(buffer: Buffer) { // Copy for now, it might not be necessary, but we are not really sure for // now, will be re-assessed if we can avoid the copy. - const body = Buffer.alloc(bodySize) - tagBuffer.copy(body, 0, totalHeaderSize) + const body = Buffer.from(subarray(tagBuffer, totalHeaderSize, bodySize)) return { version: tagHeader.version.major, @@ -204,6 +187,23 @@ function parseTagHeaderFlags(header: Buffer): TagHeaderFlags { } } +export function getId3Tag(data: Buffer) { + const position = findId3TagPosition(data) + if (position === -1) { + return null + } + const from = data.subarray(position) + const size = getId3TagSize(from) + return { + size, + from, + before: data.subarray(0, position), + data: from.subarray(0, size), + after: from.subarray(size), + missingBytes: Math.max(0, size - from.length) + } +} + /** * Returns the position of the first valid tag found or -1 if no tag was found. */ diff --git a/src/types/FileOptions.ts b/src/types/FileOptions.ts new file mode 100644 index 0000000..58a8218 --- /dev/null +++ b/src/types/FileOptions.ts @@ -0,0 +1,11 @@ +/** + * Options for file operations. + * + * @public + */ +export type FileOptions = { + /** + * File buffer size in bytes. + */ + fileBufferSize?: number +} diff --git a/src/types/read.ts b/src/types/read.ts new file mode 100644 index 0000000..f6aac34 --- /dev/null +++ b/src/types/read.ts @@ -0,0 +1,31 @@ +import { FileOptions } from "./FileOptions" +import { Options } from "./Options" +import { TagIdentifiers, Tags } from "./Tags" + +/** + * Callback signature for successful asynchronous read operation. + * + * @param tags - `TagsIdentifiers` if the `rawOnly` option was true otherwise + * `Tags` + * @public + */ +export type ReadSuccessCallback = + (error: null, tags: Tags | TagIdentifiers) => void + +/** + * Callback signatures for failing asynchronous read operation. + * + * @public + */ +export type ReadErrorCallback = + (error: NodeJS.ErrnoException | Error, tags: null) => void + +/** + * Callback signatures for asynchronous read operation. + * + * @public + */ +export type ReadCallback = + ReadSuccessCallback & ReadErrorCallback + +export type FileReadOptions = FileOptions & Options \ No newline at end of file diff --git a/src/types/write.ts b/src/types/write.ts new file mode 100644 index 0000000..c106c06 --- /dev/null +++ b/src/types/write.ts @@ -0,0 +1,14 @@ +import { FileOptions } from "./FileOptions" + +/** + * Callback signatures for asynchronous update and write operations. + * `null` indicated success. + * + * @public + */ +export type WriteCallback = + (error: NodeJS.ErrnoException | Error | null) => void + +export type WriteOptions = FileOptions & { + // TODO: add padding options +} diff --git a/src/util-file.ts b/src/util-file.ts index fbfbe23..f061b6d 100644 --- a/src/util-file.ts +++ b/src/util-file.ts @@ -1,5 +1,6 @@ import * as fs from 'fs' import { promisify } from 'util' +import { hrtime } from "process" export const fsOpenPromise = promisify(fs.open) export const fsReadPromise = promisify(fs.read) @@ -7,8 +8,46 @@ export const fsClosePromise = promisify(fs.close) export const fsWritePromise = promisify(fs.write) export const fsUnlinkPromise = promisify(fs.unlink) export const fsRenamePromise = promisify(fs.rename) +export const fsExistsPromise = promisify(fs.exists) +export const fsWriteFilePromise = promisify(fs.writeFile) -export function processFile( +export async function fsReadAsync( + fileDescriptor: number, + buffer: Buffer, + offset = 0 +): Promise { + return (await fsReadPromise( + fileDescriptor, + buffer, + offset, + buffer.length, + null + )).bytesRead +} + +/** + * @returns true if the file existed + */ +export function unlinkIfExistSync(filepath: string) { + const exist = fs.existsSync(filepath) + if (exist) { + fs.unlinkSync(filepath) + } + return exist +} + +/** + * @returns true if the file existed + */ +export async function unlinkIfExist(filepath: string) { + const exist = await fsExistsPromise(filepath) + if (exist) { + await fsUnlinkPromise(filepath) + } + return exist +} + +export function processFileSync( filepath: string, flags: string, process: (fileDescriptor: number) => T @@ -36,24 +75,9 @@ export async function processFileAsync( } } -export function getNextBufferSubarraySync(fileDescriptor: number, buffer: Buffer, offset = 0): Buffer { - const bytesRead = fs.readSync( - fileDescriptor, - buffer, - offset, - buffer.length - offset, - null - ) - return buffer.subarray(0, bytesRead + offset) +export function makeTempFilepath(filepath: string) { + // A high-resolution time is required to avoid potential conflicts + // when running multiple tests in parallel for example. + // Date.now() resolution is too low. + return `${filepath}.tmp-${hrtime.bigint()}` } - -export async function getNextBufferSubarrayAsync(fileDescriptor: number, buffer: Buffer, offset = 0): Promise { - const bytesRead = (await fsReadPromise( - fileDescriptor, - buffer, - offset, - buffer.length - offset, - null - )).bytesRead - return buffer.subarray(0, bytesRead + offset) -} \ No newline at end of file diff --git a/src/util.ts b/src/util.ts index d1ea567..dcf0101 100644 --- a/src/util.ts +++ b/src/util.ts @@ -9,6 +9,13 @@ export const isString = (value: unknown): value is string => export const isBuffer = (value: unknown): value is Buffer => value instanceof Buffer +export const validateString = (value: unknown): string => { + if (isString(value)) { + return value + } + throw new TypeError("A string is expected") +} + export const deduplicate = (values: T[]) => [ ...new Set(values)] /** diff --git a/test/api/create.ts b/test/api/create.ts index ea6ee56..b14bf75 100644 --- a/test/api/create.ts +++ b/test/api/create.ts @@ -3,147 +3,145 @@ import assert = require('assert') import iconv = require('iconv-lite') import { encodeSize } from '../../src/util-size' -describe('NodeID3 API', function () { - describe('#create()', function () { - it('empty tags', function () { - assert.strictEqual( - NodeID3.create({}).compare( - Buffer.from([0x49, 0x44, 0x33, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]) - ), - 0 - ) - }) - it('text frames', function () { - const tags = { - ...{ - TIT2: "abcdeÜ看板かんばん", - album: "nasÖÄkdnasd", - year: "1990" - } satisfies NodeID3.WriteTags, - notfound: "notfound" - } - const buffer = NodeID3.create(tags) - const titleSize = 10 + 1 + iconv.encode(tags.TIT2, 'utf16').length - const albumSize = 10 + 1 + iconv.encode(tags.album, 'utf16').length - const yearSize = 10 + 1 + iconv.encode(tags.year, 'utf16').length - assert.strictEqual(buffer.length, - 10 + // ID3 frame header - titleSize + // TIT2 header + encoding byte + utf16 bytes + utf16 string - albumSize +// same as above for album, - yearSize - ) - // Check ID3 header - assert.ok(buffer.includes( - Buffer.concat([ - Buffer.from([0x49, 0x44, 0x33, 0x03, 0x00, 0x00]), - Buffer.from(encodeSize(titleSize + albumSize + yearSize)) - ]) - )) +describe('NodeID3.create()', function () { + it('empty tags', function () { + assert.strictEqual( + NodeID3.create({}).compare( + Buffer.from([0x49, 0x44, 0x33, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]) + ), + 0 + ) + }) + it('text frames', function () { + const tags = { + ...{ + TIT2: "abcdeÜ看板かんばん", + album: "nasÖÄkdnasd", + year: "1990" + } satisfies NodeID3.WriteTags, + notfound: "notfound" + } + const buffer = NodeID3.create(tags) + const titleSize = 10 + 1 + iconv.encode(tags.TIT2, 'utf16').length + const albumSize = 10 + 1 + iconv.encode(tags.album, 'utf16').length + const yearSize = 10 + 1 + iconv.encode(tags.year, 'utf16').length + assert.strictEqual(buffer.length, + 10 + // ID3 frame header + titleSize + // TIT2 header + encoding byte + utf16 bytes + utf16 string + albumSize +// same as above for album, + yearSize + ) + // Check ID3 header + assert.ok(buffer.includes( + Buffer.concat([ + Buffer.from([0x49, 0x44, 0x33, 0x03, 0x00, 0x00]), + Buffer.from(encodeSize(titleSize + albumSize + yearSize)) + ]) + )) - // Check TIT2 frame - assert.ok(buffer.includes( - Buffer.concat([ - Buffer.from([0x54, 0x49, 0x54, 0x32]), - sizeToBuffer(titleSize - 10), - Buffer.from([0x00, 0x00]), - Buffer.from([0x01]), - iconv.encode(tags.TIT2, 'utf16') - ]) - )) - // Check album frame - assert.ok(buffer.includes( - Buffer.concat([ - Buffer.from([0x54, 0x41, 0x4C, 0x42]), - sizeToBuffer(albumSize - 10), - Buffer.from([0x00, 0x00]), - Buffer.from([0x01]), - iconv.encode(tags.album, 'utf16') - ]) - )) - assert.ok(buffer.includes( - Buffer.concat([ - Buffer.from([0x54, 0x59, 0x45, 0x52]), - sizeToBuffer(yearSize - 10), - Buffer.from([0x00, 0x00]), - Buffer.from([0x01]), - iconv.encode(tags.year, 'utf16') - ]) - )) - }) + // Check TIT2 frame + assert.ok(buffer.includes( + Buffer.concat([ + Buffer.from([0x54, 0x49, 0x54, 0x32]), + sizeToBuffer(titleSize - 10), + Buffer.from([0x00, 0x00]), + Buffer.from([0x01]), + iconv.encode(tags.TIT2, 'utf16') + ]) + )) + // Check album frame + assert.ok(buffer.includes( + Buffer.concat([ + Buffer.from([0x54, 0x41, 0x4C, 0x42]), + sizeToBuffer(albumSize - 10), + Buffer.from([0x00, 0x00]), + Buffer.from([0x01]), + iconv.encode(tags.album, 'utf16') + ]) + )) + assert.ok(buffer.includes( + Buffer.concat([ + Buffer.from([0x54, 0x59, 0x45, 0x52]), + sizeToBuffer(yearSize - 10), + Buffer.from([0x00, 0x00]), + Buffer.from([0x01]), + iconv.encode(tags.year, 'utf16') + ]) + )) + }) - it('user defined text frame single value', function() { - const tags = { - userDefinedText: { - description: "abc", - value: "defg" - } - } satisfies NodeID3.WriteTags - const buffer = NodeID3.create(tags).subarray(10) - const descEncoded = iconv.encode(tags.userDefinedText.description + "\0", "UTF-16") - const valueEncoded = iconv.encode(tags.userDefinedText.value, "UTF-16") + it('user defined text frame single value', function() { + const tags = { + userDefinedText: { + description: "abc", + value: "defg" + } + } satisfies NodeID3.WriteTags + const buffer = NodeID3.create(tags).subarray(10) + const descEncoded = iconv.encode(tags.userDefinedText.description + "\0", "UTF-16") + const valueEncoded = iconv.encode(tags.userDefinedText.value, "UTF-16") - assert.strictEqual(Buffer.compare( - buffer, - Buffer.concat([ - Buffer.from([0x54, 0x58, 0x58, 0x58]), - sizeToBuffer(1 + descEncoded.length + valueEncoded.length), - Buffer.from([0x00, 0x00]), - Buffer.from([0x01]), - descEncoded, - valueEncoded - ]) - ), 0) - }) + assert.strictEqual(Buffer.compare( + buffer, + Buffer.concat([ + Buffer.from([0x54, 0x58, 0x58, 0x58]), + sizeToBuffer(1 + descEncoded.length + valueEncoded.length), + Buffer.from([0x00, 0x00]), + Buffer.from([0x01]), + descEncoded, + valueEncoded + ]) + ), 0) + }) - it('user defined text frame array', function() { - const tags = { - userDefinedText: [{ - description: "abc", - value: "defg" - }, { - description: "hij", - value: "klmn" - }] - } satisfies NodeID3.WriteTags - const buffer = NodeID3.create(tags).subarray(10) - const desc1Encoded = iconv.encode(tags.userDefinedText[0].description + "\0", "UTF-16") - const value1Encoded = iconv.encode(tags.userDefinedText[0].value, "UTF-16") - const desc2Encoded = iconv.encode(tags.userDefinedText[1].description + "\0", "UTF-16") - const value2Encoded = iconv.encode(tags.userDefinedText[1].value, "UTF-16") + it('user defined text frame array', function() { + const tags = { + userDefinedText: [{ + description: "abc", + value: "defg" + }, { + description: "hij", + value: "klmn" + }] + } satisfies NodeID3.WriteTags + const buffer = NodeID3.create(tags).subarray(10) + const desc1Encoded = iconv.encode(tags.userDefinedText[0].description + "\0", "UTF-16") + const value1Encoded = iconv.encode(tags.userDefinedText[0].value, "UTF-16") + const desc2Encoded = iconv.encode(tags.userDefinedText[1].description + "\0", "UTF-16") + const value2Encoded = iconv.encode(tags.userDefinedText[1].value, "UTF-16") - assert.strictEqual(Buffer.compare( - buffer, - Buffer.concat([ - Buffer.from([0x54, 0x58, 0x58, 0x58]), - sizeToBuffer(1 + desc1Encoded.length + value1Encoded.length), - Buffer.from([0x00, 0x00]), - Buffer.from([0x01]), - desc1Encoded, - value1Encoded, - Buffer.from([0x54, 0x58, 0x58, 0x58]), - sizeToBuffer(1 + desc2Encoded.length + value2Encoded.length), - Buffer.from([0x00, 0x00]), - Buffer.from([0x01]), - desc2Encoded, - value2Encoded - ]) - ), 0) - }) + assert.strictEqual(Buffer.compare( + buffer, + Buffer.concat([ + Buffer.from([0x54, 0x58, 0x58, 0x58]), + sizeToBuffer(1 + desc1Encoded.length + value1Encoded.length), + Buffer.from([0x00, 0x00]), + Buffer.from([0x01]), + desc1Encoded, + value1Encoded, + Buffer.from([0x54, 0x58, 0x58, 0x58]), + sizeToBuffer(1 + desc2Encoded.length + value2Encoded.length), + Buffer.from([0x00, 0x00]), + Buffer.from([0x01]), + desc2Encoded, + value2Encoded + ]) + ), 0) + }) - it('create mixed v3/v4 tag', function() { - const frameBuf = Buffer.from('4944330300000000003d5449543200000009000001fffe61006c006c00545945520000000b000001fffe3200300032003200544452430000000b000001fffe3200300032003300', 'hex') + it('create mixed v3/v4 tag', function() { + const frameBuf = Buffer.from('4944330300000000003d5449543200000009000001fffe61006c006c00545945520000000b000001fffe3200300032003200544452430000000b000001fffe3200300032003300', 'hex') - const tags = { - title: "all", - year: "2022", - recordingTime: "2023" - } satisfies NodeID3.WriteTags + const tags = { + title: "all", + year: "2022", + recordingTime: "2023" + } satisfies NodeID3.WriteTags - assert.deepStrictEqual( - NodeID3.create(tags), - frameBuf - ) - }) + assert.deepStrictEqual( + NodeID3.create(tags), + frameBuf + ) }) }) diff --git a/test/api/promises.ts b/test/api/promises.ts index 00606ea..e66c948 100644 --- a/test/api/promises.ts +++ b/test/api/promises.ts @@ -1,104 +1,61 @@ import fs = require('fs') -import * as NodeID3 from '../../index' +import { Promises } from '../../index' import chai = require('chai') import chaiAsPromised = require('chai-as-promised') +import { unlinkIfExistSync } from '../../src/util-file' const { expect } = chai chai.use(chaiAsPromised) -describe('NodeID3 API Promises', function () { - const invalidFilepath = 'should-hopefully-not-be-a-valid-file.mp3' - const testFilepath = 'write-promise-test-file.mp3' - describe('#create()', function () { - it('resolve', function () { - return expect( - NodeID3.Promises.create({}) - ).to.eventually.be.instanceOf(Buffer) - }) +describe('NodeID3.Promises', function () { + const nonExistingFile = { + path: "hopefully-non-existing-file.mp3", + name: "path on non-existing file" + } + const existingFile = { + path: "promise-test-file.mp3", + name: "path on existing file" + } + beforeEach(function() { + unlinkIfExistSync(nonExistingFile.path) + fs.writeFileSync(existingFile.path, Buffer.alloc(0)) }) - const writeTestCases = [ - ['#write()', NodeID3.Promises.write], - ['#update()', NodeID3.Promises.update] - ] as const - writeTestCases.forEach(([name, fn]) => { - describe(name, function () { - describe('with buffer', function() { - it('resolve', function () { - return expect( - fn({}, Buffer.alloc(0)) - ).to.eventually.be.instanceOf(Buffer) - }) - }) - describe('with invalid file path', function() { - it('reject', function () { - return expect( - fn({}, invalidFilepath) - ).to.eventually.be.rejectedWith(Error) - }) - }) - describe('with valid file path', function() { - before(function() { - fs.writeFileSync(testFilepath, Buffer.alloc(0)) - }) - it('resolve', function () { - return expect( - fn({}, testFilepath) - ).to.eventually.be.fulfilled - }) - after(function() { - fs.unlinkSync(testFilepath) - }) - }) - }) + afterEach(function() { + unlinkIfExistSync(nonExistingFile.path) + unlinkIfExistSync(existingFile.path) }) - describe('#read()', function () { - describe('with buffer', function() { - it('resolve', function () { + type TestCase = [ + string, + {path: string, name: string}, + (path: string) => Promise + ] + const successfulTestCases: TestCase[] = [ + ["read()", existingFile, path => Promises.read(path)], + ["write()", existingFile, path => Promises.write({}, path)], + ["write()", nonExistingFile, path => Promises.write({}, path)], + ["update()", existingFile, path => Promises.update({}, path)], + ["removeTags()", existingFile, path => Promises.removeTags(path)] + ] + successfulTestCases.forEach(([funcName, fileCase, operation]) => { + describe(`${funcName} with ${fileCase.name}`, function() { + it('should resolve', function() { return expect( - NodeID3.Promises.read(Buffer.alloc(0)) - ).to.eventually.to.have.key('raw') - }) - }) - describe('with invalid file path', function() { - it('reject', function () { - return expect( - NodeID3.Promises.read(invalidFilepath) - ).to.eventually.be.rejectedWith(Error) - }) - }) - describe('with valid file path', function() { - before(function() { - fs.writeFileSync(testFilepath, Buffer.alloc(0)) - }) - it('resolve', function () { - return expect( - NodeID3.Promises.read(testFilepath) - ).to.eventually.to.have.key('raw') - }) - after(function() { - fs.unlinkSync(testFilepath) + operation(fileCase.path) + ).to.eventually.be.fulfilled }) }) }) - describe('#removeTags()', function () { - describe('with invalid file path', function() { - it('reject', function () { + const failingTestCases: TestCase[] = [ + ["read()", nonExistingFile, path => Promises.read(path)], + ["update()", nonExistingFile, path => Promises.update({}, path)], + ["removeTags()", nonExistingFile, path => Promises.removeTags(path)] + ] + failingTestCases.forEach(([funcName, fileCase, operation]) => { + describe(`${funcName} with ${fileCase.name}`, function() { + it('should reject', function() { return expect( - NodeID3.Promises.removeTags(invalidFilepath) + operation(fileCase.path) ).to.eventually.be.rejectedWith(Error) }) }) - describe('with valid file path', function() { - before(function() { - fs.writeFileSync(testFilepath, Buffer.alloc(0)) - }) - it('resolve', function () { - return expect( - NodeID3.Promises.removeTags(testFilepath) - ).to.eventually.be.fulfilled - }) - after(function() { - fs.unlinkSync(testFilepath) - }) - }) }) }) diff --git a/test/api/read.ts b/test/api/read.ts index 03c73be..5275891 100644 --- a/test/api/read.ts +++ b/test/api/read.ts @@ -1,183 +1,181 @@ import * as NodeID3 from '../../index' import assert = require('assert') -describe('NodeID3 API', function () { - describe('#read()', function() { - it('read empty id3 tag', function() { - const frame = NodeID3.create({}) - assert.deepStrictEqual( - NodeID3.read(frame), - {raw: {}} - ) - }) - - it('read text frames id3 tag', function() { - const frame = NodeID3.create({ title: "asdfghjÄÖP", album: "naBGZwssg" }) - assert.deepStrictEqual( - NodeID3.read(frame), - { title: "asdfghjÄÖP", album: "naBGZwssg", raw: { TIT2: "asdfghjÄÖP", TALB: "naBGZwssg" }} - ) - }) - - it('read tag with broken frame', function() { - const frame = NodeID3.create({ title: "asdfghjÄÖP", album: "naBGZwssg" }) - frame[10] = 0x99 - assert.deepStrictEqual( - NodeID3.read(frame), - { album: "naBGZwssg", raw: { TALB: "naBGZwssg" }} - ) - }) - - it('read tag with bigger size', function() { - const frame = NodeID3.create({ title: "asdfghjÄÖP", album: "naBGZwssg" }) - const newFrameSize = 127 - frame[9] = 127 - assert.ok(frame.length < newFrameSize + 10) - assert.deepStrictEqual( - NodeID3.read(frame), - { title: "asdfghjÄÖP", album: "naBGZwssg", raw: { TIT2: "asdfghjÄÖP", TALB: "naBGZwssg" }} - ) - }) - - it('read tag with smaller size', function() { - const frame = NodeID3.create({ title: "asdfghjÄÖP", album: "naBGZwssg" }) - frame[9] -= 25 - assert.deepStrictEqual( - NodeID3.read(frame), - { title: "asdfghjÄÖP", raw: { TIT2: "asdfghjÄÖP" }} - ) - }) - - it('read tag with invalid size', function() { - const frame = NodeID3.create({ title: 'a' }) - frame[9] = 128 - assert.deepStrictEqual( - NodeID3.read(frame).raw, - {} - ) - }) - - it('read TXXX frame', function() { - const tags = { userDefinedText: {description: "abc", value: "deg"} } - const frame = NodeID3.create(tags) - assert.deepStrictEqual( - NodeID3.read(frame), - { - userDefinedText: [tags.userDefinedText], - raw: { - TXXX: [tags.userDefinedText] - } +describe('NodeID3.read()', function () { + it('read empty id3 tag', function() { + const frame = NodeID3.create({}) + assert.deepStrictEqual( + NodeID3.read(frame), + {raw: {}} + ) + }) + + it('read text frames id3 tag', function() { + const frame = NodeID3.create({ title: "asdfghjÄÖP", album: "naBGZwssg" }) + assert.deepStrictEqual( + NodeID3.read(frame), + { title: "asdfghjÄÖP", album: "naBGZwssg", raw: { TIT2: "asdfghjÄÖP", TALB: "naBGZwssg" }} + ) + }) + + it('read tag with broken frame', function() { + const frame = NodeID3.create({ title: "asdfghjÄÖP", album: "naBGZwssg" }) + frame[10] = 0x99 + assert.deepStrictEqual( + NodeID3.read(frame), + { album: "naBGZwssg", raw: { TALB: "naBGZwssg" }} + ) + }) + + it('read tag with bigger size', function() { + const frame = NodeID3.create({ title: "asdfghjÄÖP", album: "naBGZwssg" }) + const newFrameSize = 127 + frame[9] = 127 + assert.ok(frame.length < newFrameSize + 10) + assert.deepStrictEqual( + NodeID3.read(frame), + { title: "asdfghjÄÖP", album: "naBGZwssg", raw: { TIT2: "asdfghjÄÖP", TALB: "naBGZwssg" }} + ) + }) + + it('read tag with smaller size', function() { + const frame = NodeID3.create({ title: "asdfghjÄÖP", album: "naBGZwssg" }) + frame[9] -= 25 + assert.deepStrictEqual( + NodeID3.read(frame), + { title: "asdfghjÄÖP", raw: { TIT2: "asdfghjÄÖP" }} + ) + }) + + it('read tag with invalid size', function() { + const frame = NodeID3.create({ title: 'a' }) + frame[9] = 128 + assert.deepStrictEqual( + NodeID3.read(frame).raw, + {} + ) + }) + + it('read TXXX frame', function() { + const tags = { userDefinedText: {description: "abc", value: "deg"} } + const frame = NodeID3.create(tags) + assert.deepStrictEqual( + NodeID3.read(frame), + { + userDefinedText: [tags.userDefinedText], + raw: { + TXXX: [tags.userDefinedText] } - ) - }) - - it('read TXXX array frame', function() { - const tags = { userDefinedText: [{description: "abc", value: "deg"}, {description: "abcd", value: "efgh"}] } - const frame = NodeID3.create(tags) - assert.deepStrictEqual( - NodeID3.read(frame), - { - userDefinedText: tags.userDefinedText, - raw: { - TXXX: tags.userDefinedText - } + } + ) + }) + + it('read TXXX array frame', function() { + const tags = { userDefinedText: [{description: "abc", value: "deg"}, {description: "abcd", value: "efgh"}] } + const frame = NodeID3.create(tags) + assert.deepStrictEqual( + NodeID3.read(frame), + { + userDefinedText: tags.userDefinedText, + raw: { + TXXX: tags.userDefinedText } - ) - }) + } + ) + }) - it('create mixed v3/v4 tag', function() { - const frameBuf = Buffer.from('494433030000000000315449543200000009000001fffe61006c006c005459455200000005000001fffe33005444524300000005000001fffe3400', 'hex') + it('create mixed v3/v4 tag', function() { + const frameBuf = Buffer.from('494433030000000000315449543200000009000001fffe61006c006c005459455200000005000001fffe33005444524300000005000001fffe3400', 'hex') - const tags = { - title: "all", - year: "3", - recordingTime: "4" - } + const tags = { + title: "all", + year: "3", + recordingTime: "4" + } + + assert.deepStrictEqual( + NodeID3.read(frameBuf, { noRaw: true }), + tags + ) + }) + + it('read exclude', function() { + const tagsWithoutTitle = { + album: "nasÖÄkdnasd", + year: "1990" + } satisfies NodeID3.WriteTags + const tags = { + TIT2: "abcdeÜ看板かんばん", + ...tagsWithoutTitle + } satisfies NodeID3.WriteTags + + const buffer = NodeID3.create(tags) + const read = NodeID3.read(buffer, { exclude: ['TIT2'], noRaw: true }) + assert.deepStrictEqual( + read, + tagsWithoutTitle + ) + }) + + it('read include', function() { + const tagsWithoutYear = { + title: "abcdeÜ看板かんばん", + album: "nasÖÄkdnasd" + } satisfies NodeID3.WriteTags + const tags = { + ...tagsWithoutYear, + year: "1990" + } satisfies NodeID3.WriteTags + + const buffer = NodeID3.create(tags) + const read = NodeID3.read(buffer, { include: ['TALB', 'TIT2'], noRaw: true }) + assert.deepStrictEqual( + read, + tagsWithoutYear + ) + }) + + it('onlyRaw', function() { + const tags = { + TIT2: "abcdeÜ看板かんばん", + TALB: "nasÖÄkdnasd" + } satisfies NodeID3.WriteTags + + const buffer = NodeID3.create(tags) + const read = NodeID3.read(buffer, { onlyRaw: true }) + assert.deepStrictEqual( + read, + tags + ) + }) + + it('noRaw', function() { + const tags = { + title: "abcdeÜ看板かんばん", + album: "nasÖÄkdnasd" + } satisfies NodeID3.WriteTags + + const buffer = NodeID3.create(tags) + const read = NodeID3.read(buffer, { noRaw: true }) + assert.deepStrictEqual( + read, + tags + ) + }) + + it('compressed frame', function() { + const frameBufV3 = Buffer.from('4944330300000000001c5449543200000011008000000005789c6328492d2e0100045e01c1', 'hex') + const frameBufV4 = Buffer.from('4944330400000000001c5449543200000011000900000005789c6328492d2e0100045e01c1', 'hex') + const tags = { TIT2: 'test' } + + assert.deepStrictEqual( + NodeID3.read(frameBufV3).raw, + tags + ) - assert.deepStrictEqual( - NodeID3.read(frameBuf, { noRaw: true }), - tags - ) - }) - - it('read exclude', function() { - const tagsWithoutTitle = { - album: "nasÖÄkdnasd", - year: "1990" - } satisfies NodeID3.WriteTags - const tags = { - TIT2: "abcdeÜ看板かんばん", - ...tagsWithoutTitle - } satisfies NodeID3.WriteTags - - const buffer = NodeID3.create(tags) - const read = NodeID3.read(buffer, { exclude: ['TIT2'], noRaw: true }) - assert.deepStrictEqual( - read, - tagsWithoutTitle - ) - }) - - it('read include', function() { - const tagsWithoutYear = { - title: "abcdeÜ看板かんばん", - album: "nasÖÄkdnasd" - } satisfies NodeID3.WriteTags - const tags = { - ...tagsWithoutYear, - year: "1990" - } satisfies NodeID3.WriteTags - - const buffer = NodeID3.create(tags) - const read = NodeID3.read(buffer, { include: ['TALB', 'TIT2'], noRaw: true }) - assert.deepStrictEqual( - read, - tagsWithoutYear - ) - }) - - it('onlyRaw', function() { - const tags = { - TIT2: "abcdeÜ看板かんばん", - TALB: "nasÖÄkdnasd" - } satisfies NodeID3.WriteTags - - const buffer = NodeID3.create(tags) - const read = NodeID3.read(buffer, { onlyRaw: true }) - assert.deepStrictEqual( - read, - tags - ) - }) - - it('noRaw', function() { - const tags = { - title: "abcdeÜ看板かんばん", - album: "nasÖÄkdnasd" - } satisfies NodeID3.WriteTags - - const buffer = NodeID3.create(tags) - const read = NodeID3.read(buffer, { noRaw: true }) - assert.deepStrictEqual( - read, - tags - ) - }) - - it('compressed frame', function() { - const frameBufV3 = Buffer.from('4944330300000000001c5449543200000011008000000005789c6328492d2e0100045e01c1', 'hex') - const frameBufV4 = Buffer.from('4944330400000000001c5449543200000011000900000005789c6328492d2e0100045e01c1', 'hex') - const tags = { TIT2: 'test' } - - assert.deepStrictEqual( - NodeID3.read(frameBufV3).raw, - tags - ) - - assert.deepStrictEqual( - NodeID3.read(frameBufV4).raw, - tags - ) - }) + assert.deepStrictEqual( + NodeID3.read(frameBufV4).raw, + tags + ) }) }) diff --git a/test/api/write.ts b/test/api/write.ts index 23d64e8..49d66c7 100644 --- a/test/api/write.ts +++ b/test/api/write.ts @@ -1,91 +1,137 @@ import * as NodeID3 from '../../index' -import assert = require('assert') -import chai = require('chai') +import { expect } from 'chai' import * as fs from 'fs' +import { unlinkIfExistSync } from '../../src/util-file' +import { createTestBuffer } from '../util' -describe('NodeID3 API', function () { - describe('#write()', function() { - const nonExistingFilepath = './hopefully-does-not-exist.mp3' - it('sync not existing filepath', function() { - chai.assert.isFalse(fs.existsSync(nonExistingFilepath)) - chai.assert.instanceOf( - NodeID3.write({}, nonExistingFilepath), Error - ) - }) - it('async not existing filepath', function() { - chai.assert.isFalse(fs.existsSync(nonExistingFilepath)) - NodeID3.write({}, nonExistingFilepath, function(err) { - if(!(err instanceof Error)) { - assert.fail("No error thrown on non-existing filepath") - } - }) - }) - - const buffer = Buffer.from([0x02, 0x06, 0x12, 0x22]) - const titleTag = { - title: "abc" - } satisfies NodeID3.WriteTags - const filepath = './testfile.mp3' - - it('sync write file without id3 tag', function() { - fs.writeFileSync(filepath, buffer, 'binary') - NodeID3.write(titleTag, filepath) - const newFileBuffer = fs.readFileSync(filepath) - fs.unlinkSync(filepath) - assert.strictEqual(Buffer.compare( - newFileBuffer, - Buffer.concat([NodeID3.create(titleTag), buffer]) - ), 0) - }) - - it('async write file without id3 tag', function(done) { - fs.writeFileSync(filepath, buffer, 'binary') - NodeID3.write(titleTag, filepath, function() { - const newFileBuffer = fs.readFileSync(filepath) - fs.unlinkSync(filepath) - if(Buffer.compare( - newFileBuffer, - Buffer.concat([NodeID3.create(titleTag), buffer]) - ) === 0) { - done() - } else { - done(new Error("buffer not the same")) - } - }) +describe('NodeID3.write()', function () { + const nonExistingFilepath = './hopefully-does-not-exist.mp3' + const testFilepath = './write-test-file.mp3' + beforeEach(function () { + unlinkIfExistSync(nonExistingFilepath) + unlinkIfExistSync(testFilepath) + }) + afterEach(function() { + unlinkIfExistSync(nonExistingFilepath) + unlinkIfExistSync(testFilepath) + }) + it('sync creates a file when non-existing', function() { + NodeID3.write({}, nonExistingFilepath) + expect(fs.existsSync(nonExistingFilepath)).to.be.true + }) + it('async creates a file when non-existing', function(done) { + NodeID3.write({}, nonExistingFilepath, function(error) { + if (error) { + done(new Error("Unexpected error")) + } else { + expect(fs.existsSync(nonExistingFilepath)).to.be.true + done() + } }) + }) - { + const data = createTestBuffer(256) + const tag = NodeID3.create({ album: "album"}) + const newFrame = { title: "title "} satisfies NodeID3.WriteTags + const newTag = NodeID3.create(newFrame) - const bufferWithTag = Buffer.concat([NodeID3.create(titleTag), buffer]) - const albumTag = { - album: "ix123" - } satisfies NodeID3.WriteTags + type TestCase = [ + // Name + string, + // Input data + readonly Buffer[] | null, + // Expected data + readonly Buffer[], + // File buffer size + number | null + ] + const testCases: TestCase[] = [ + [ + "without file", + null, [newTag], null + ], + [ + "without tag (buffer.length > data.length)", + [data], [newTag, data], data.length + 1 + ], + [ + "without tag (buffer.length === data.length)", + [data], [newTag, data], data.length / 2 + ], + [ + "without tag (buffer.length < data.length)", + [data], [newTag, data], data.length - 1 + ], + [ + "with same tag", + [newTag, data], [newTag, data], newTag.length - 1 + ], + [ + "with tag at the start", + [tag, data], [newTag, data], tag.length - 1 + ], + [ + "with tag in the middle (ID3 identifier crossover)", + [data, tag, data], [newTag, data, data], data.length + 1 + ], + [ + "with tag in the middle (at ID3 identifier position)", + [data, tag, data], [newTag, data, data], data.length + ], + [ + "with tag in the middle (before ID3 identifier position)", + [data, tag, data], [newTag, data, data], data.length - 1 + ], + [ + "with multiple tags", + [data, tag, data, tag, data], [newTag, data, data, data], data.length - 1 + ], + [ + "with multiple tags (buffer smaller than tag)", + [data, tag, data, tag, data], [newTag, data, data, data], tag.length - 1 + ], + [ + "with multiple tags (2nd tag across reads)", + [data, tag, tag, data], [newTag, data, data], data.length + ], + [ + "w/ multiple tags (2nd tag across reads, buffer smalller than tag)", + [data, tag, tag, data], [newTag, data, data], tag.length - 1 + ], + [ + "with tag at the end", + [data, tag], [newTag, data], data.length - 1 + ], + ] - it('sync write file with id3 tag', function() { - fs.writeFileSync(filepath, bufferWithTag, 'binary') - NodeID3.write(albumTag, filepath) - const newFileBuffer = fs.readFileSync(filepath) - fs.unlinkSync(filepath) - assert.strictEqual(Buffer.compare( - newFileBuffer, - Buffer.concat([NodeID3.create(albumTag), buffer]) - ), 0) + testCases.forEach( + ([caseName, inputBuffers, expectedBuffers, fileBufferSize]) => { + const options = fileBufferSize ? { fileBufferSize } : {} + const inputBuffer = inputBuffers ? Buffer.concat(inputBuffers) : null + const expectedBuffer = Buffer.concat(expectedBuffers) + it(`sync write ${caseName}`, function() { + if (inputBuffer) { + fs.writeFileSync(testFilepath, inputBuffer, 'binary') + } + NodeID3.writeToFileSync(newFrame, testFilepath, options) + const newFileBuffer = fs.readFileSync(testFilepath) + expect(newFileBuffer).to.deep.equal(expectedBuffer) }) - it('async write file with id3 tag', function(done) { - fs.writeFileSync(filepath, bufferWithTag, 'binary') - NodeID3.write(albumTag, filepath, function() { - const newFileBuffer = fs.readFileSync(filepath) - fs.unlinkSync(filepath) - if(Buffer.compare( - newFileBuffer, - Buffer.concat([NodeID3.create(albumTag), buffer]) - ) === 0) { + it(`async write ${caseName}`, function(done) { + if (inputBuffer) { + fs.writeFileSync(testFilepath, inputBuffer, 'binary') + } + NodeID3.writeToFile( + newFrame, testFilepath, options, (error) => { + if (error) { + done(error) + return + } + const newFileBuffer = fs.readFileSync(testFilepath) + expect(newFileBuffer).to.deep.equal(expectedBuffer) done() - } else { - done(new Error("file written incorrectly")) } - }) + ) }) - } }) }) diff --git a/test/util.ts b/test/util.ts new file mode 100644 index 0000000..beb7a04 --- /dev/null +++ b/test/util.ts @@ -0,0 +1,4 @@ + +export function createTestBuffer(length: number): Buffer { + return Buffer.from(Array.from({ length }, (_, index) => index % 256)) +}