Passed
Pull Request — master (#127)
by
unknown
01:38
created

index.js (1 issue)

1
const fs = require('fs')
2
const ID3Definitions = require("./src/ID3Definitions")
3
const ID3Frames = require('./src/ID3Frames')
4
const ID3Util = require('./src/ID3Util')
5
const zlib = require('zlib')
6
const { isFunction, isString } = require('./src/util')
7
8
/*
9
**  Used specification: http://id3.org/id3v2.3.0
10
*/
11
12
function writeInBuffer(tags, buffer, fn) {
13
    buffer = removeTagsFromBuffer(buffer) || buffer
14
    const completeBuffer = Buffer.concat([tags, buffer])
15
    if(isFunction(fn)) {
16
        fn(null, completeBuffer)
17
        return undefined
18
    }
19
    return completeBuffer
20
}
21
22
function writeAsync(tags, filename, fn) {
23
    try {
24
        fs.readFile(filename, function(error, data) {
25
            if(error) {
26
                fn(error)
27
                return
28
            }
29
            data = removeTagsFromBuffer(data) || data
30
            const newData = Buffer.concat([tags, data])
31
            fs.writeFile(filename, newData, 'binary', (err) => {
32
                fn(err)
33
            })
34
        }.bind(this))
0 ignored issues
show
The call to bind does not seem necessary since the function does not use this. Consider calling it directly.
Loading history...
35
    } catch(err) {
36
        fn(err)
37
    }
38
}
39
40
function writeSync(tags, filename) {
41
    try {
42
        let data = fs.readFileSync(filename)
43
        data = removeTagsFromBuffer(data) || data
44
        const newData = Buffer.concat([tags, data])
45
        fs.writeFileSync(filename, newData, 'binary')
46
    } catch(error) {
47
        return error
48
    }
49
    return true
50
}
51
52
/**
53
 * Write passed tags to a file/buffer
54
 * @param tags - Object containing tags to be written
55
 * @param filebuffer - Can contain a filepath string or buffer
56
 * @param fn - (optional) Function for async version
57
 * @returns {boolean|Buffer|Error}
58
 */
59
module.exports.write = function(tags, filebuffer, fn) {
60
    const completeTags = this.create(tags)
61
62
    if(filebuffer instanceof Buffer) {
63
        return writeInBuffer(completeTags, filebuffer, fn)
64
    }
65
    if(isFunction(fn)) {
66
        return writeAsync(completeTags, filebuffer, fn)
67
    }
68
    return writeSync(completeTags, filebuffer)
69
}
70
71
/**
72
 * Creates a buffer containing the ID3 Tag
73
 * @param tags - Object containing tags to be written
74
 * @param fn fn - (optional) Function for async version
75
 * @returns {Buffer}
76
 */
77
module.exports.create = function(tags, fn) {
78
    const frames = this.createBuffersFromTags(tags)
79
80
    //  Calculate ID3 body frames size for the header
81
    const framesSize = frames.reduce((size, frame) => size + frame.length, 0)
82
83
    //  Create ID3 header
84
    const header = Buffer.alloc(10)
85
    header.fill(0)
86
    header.write("ID3", 0)              //File identifier
87
    header.writeUInt16BE(0x0300, 3)     //Version 2.3.0  --  03 00
88
    header.writeUInt16BE(0x0000, 5)     //Flags 00
89
    ID3Util.encodeSize(framesSize).copy(header, 6)
90
91
    const id3Data = [header].concat(frames)
92
93
    if(isFunction(fn)) {
94
        fn(Buffer.concat(id3Data))
95
        return undefined
96
    }
97
    return Buffer.concat(id3Data)
98
}
99
100
/**
101
 * Returns array of buffers created by tags specified in the tags argument
102
 * @param tags - Object containing tags to be written
103
 * @returns {Array}
104
 */
105
module.exports.createBuffersFromTags = function(tags) {
106
    let frames = []
107
    if(!tags) {
108
        return frames
109
    }
110
    const rawObject = Object.keys(tags).reduce((acc, val) => {
111
        if(ID3Definitions.FRAME_IDENTIFIERS.v3[val] !== undefined) {
112
            acc[ID3Definitions.FRAME_IDENTIFIERS.v3[val]] = tags[val]
113
        } else if(ID3Definitions.FRAME_IDENTIFIERS.v4[val] !== undefined) {
114
            /**
115
             * Currently, node-id3 always writes ID3 version 3.
116
             * However, version 3 and 4 are very similar, and node-id3 can also read version 4 frames.
117
             * Until version 4 is fully supported, as a workaround, allow writing version 4 frames into a version 3 tag.
118
             * If a reader does not support a v4 frame, it's (per spec) supposed to skip it, so it should not be a problem.
119
             */
120
            acc[ID3Definitions.FRAME_IDENTIFIERS.v4[val]] = tags[val]
121
        } else {
122
            acc[val] = tags[val]
123
        }
124
        return acc
125
    }, {})
126
127
    Object.keys(rawObject).forEach((specName) => {
128
        let frame
129
        // Check if invalid specName
130
        if(specName.length !== 4) {
131
            return
132
        }
133
        if(ID3Frames[specName] !== undefined) {
134
            frame = ID3Frames[specName].create(rawObject[specName], 3, this)
135
        } else if(specName.startsWith('T')) {
136
            frame = ID3Frames.GENERIC_TEXT.create(specName, rawObject[specName], 3)
137
        } else if(specName.startsWith('W')) {
138
            if(ID3Util.getSpecOptions(specName, 3).multiple && rawObject[specName] instanceof Array && rawObject[specName].length > 0) {
139
                frame = Buffer.alloc(0)
140
                // deduplicate array
141
                for(let url of [...new Set(rawObject[specName])]) {
142
                    frame = Buffer.concat([frame, ID3Frames.GENERIC_URL.create(specName, url, 3)])
143
                }
144
            } else {
145
                frame = ID3Frames.GENERIC_URL.create(specName, rawObject[specName], 3)
146
            }
147
        }
148
149
        if (frame && frame instanceof Buffer) {
150
            frames.push(frame)
151
        }
152
    })
153
154
    return frames
155
}
156
157
function readSync(filebuffer, options) {
158
    if(isString(filebuffer)) {
159
        filebuffer = fs.readFileSync(filebuffer)
160
    }
161
    return this.getTagsFromBuffer(filebuffer, options)
162
}
163
164
function readAsync(filebuffer, options, fn) {
165
    if(isString(filebuffer)) {
166
        fs.readFile(filebuffer, (error, data) => {
167
            if(error) {
168
                fn(error, null)
169
            } else {
170
                fn(null, this.getTagsFromBuffer(data, options))
171
            }
172
        })
173
    } else {
174
        fn(null, this.getTagsFromBuffer(filebuffer, options))
175
    }
176
}
177
178
/**
179
 * Read ID3-Tags from passed buffer/filepath
180
 * @param filebuffer - Can contain a filepath string or buffer
181
 * @param options - (optional) Object containing options
182
 * @param fn - (optional) Function for async version
183
 * @returns {boolean}
184
 */
185
module.exports.read = function(filebuffer, options, fn) {
186
    if(!options || typeof options === 'function') {
187
        fn = fn || options
188
        options = {}
189
    }
190
    if(isFunction(fn)) {
191
        return readAsync.bind(this)(filebuffer, options, fn)
192
    }
193
    return readSync.bind(this)(filebuffer, options)
194
}
195
196
/**
197
 * Update ID3-Tags from passed buffer/filepath
198
 * @param tags - Object containing tags to be written
199
 * @param filebuffer - Can contain a filepath string or buffer
200
 * @param options - (optional) Object containing options
201
 * @param fn - (optional) Function for async version
202
 * @returns {boolean|Buffer|Error}
203
 */
204
module.exports.update = function(tags, filebuffer, options, fn) {
205
    if(!options || typeof options === 'function') {
206
        fn = fn || options
207
        options = {}
208
    }
209
210
    const rawTags = Object.keys(tags).reduce((acc, val) => {
211
        if(ID3Definitions.FRAME_IDENTIFIERS.v3[val] !== undefined) {
212
            acc[ID3Definitions.FRAME_IDENTIFIERS.v3[val]] = tags[val]
213
        } else {
214
            acc[val] = tags[val]
215
        }
216
        return acc
217
    }, {})
218
219
    const updateFn = (currentTags) => {
220
        currentTags = currentTags.raw || {}
221
        Object.keys(rawTags).map((specName) => {
222
            const options = ID3Util.getSpecOptions(specName, 3)
223
            const cCompare = {}
224
            if(options.multiple && currentTags[specName] && rawTags[specName]) {
225
                if(options.updateCompareKey) {
226
                    currentTags[specName].forEach((cTag, index) => {
227
                        cCompare[cTag[options.updateCompareKey]] = index
228
                    })
229
230
                }
231
                if (!(rawTags[specName] instanceof Array)) {
232
                    rawTags[specName] = [rawTags[specName]]
233
                }
234
                rawTags[specName].forEach((rTag) => {
235
                    const comparison = cCompare[rTag[options.updateCompareKey]]
236
                    if (comparison !== undefined) {
237
                        currentTags[specName][comparison] = rTag
238
                    } else {
239
                        currentTags[specName].push(rTag)
240
                    }
241
                })
242
            } else {
243
                currentTags[specName] = rawTags[specName]
244
            }
245
        })
246
247
        return currentTags
248
    }
249
250
    if(!isFunction(fn)) {
251
        return this.write(updateFn(this.read(filebuffer, options)), filebuffer)
252
    }
253
254
    this.write(updateFn(this.read(filebuffer, options)), filebuffer, fn)
255
}
256
257
module.exports.getTagsFromBuffer = function(filebuffer, options) {
258
    let framePosition = ID3Util.getFramePosition(filebuffer)
259
    if(framePosition === -1) {
260
        return this.getTagsFromFrames([], 3, options)
261
    }
262
    const frameSize = ID3Util.decodeSize(filebuffer.slice(framePosition + 6, framePosition + 10)) + 10
263
    let ID3Frame = Buffer.alloc(frameSize + 1)
264
    filebuffer.copy(ID3Frame, 0, framePosition)
265
    //ID3 version e.g. 3 if ID3v2.3.0
266
    let ID3Version = ID3Frame[3]
267
    const tagFlags = ID3Util.parseTagHeaderFlags(ID3Frame)
268
    let extendedHeaderOffset = 0
269
    if(tagFlags.extendedHeader) {
270
        if(ID3Version === 3) {
271
            extendedHeaderOffset = 4 + filebuffer.readUInt32BE(10)
272
        } else if(ID3Version === 4) {
273
            extendedHeaderOffset = ID3Util.decodeSize(filebuffer.slice(10, 14))
274
        }
275
    }
276
    let ID3FrameBody = Buffer.alloc(frameSize - 10 - extendedHeaderOffset)
277
    filebuffer.copy(ID3FrameBody, 0, framePosition + 10 + extendedHeaderOffset)
278
279
    let frames = this.getFramesFromID3Body(ID3FrameBody, ID3Version, options)
280
281
    return this.getTagsFromFrames(frames, ID3Version, options)
282
}
283
284
module.exports.getFramesFromID3Body = function(ID3FrameBody, ID3Version, options = {}) {
285
    let currentPosition = 0
286
    let frames = []
287
    if(!ID3FrameBody || !(ID3FrameBody instanceof Buffer)) {
288
        return frames
289
    }
290
291
    let identifierSize = 4
292
    let textframeHeaderSize = 10
293
    if(ID3Version === 2) {
294
        identifierSize = 3
295
        textframeHeaderSize = 6
296
    }
297
298
    while(currentPosition < ID3FrameBody.length && ID3FrameBody[currentPosition] !== 0x00) {
299
        let bodyFrameHeader = Buffer.alloc(textframeHeaderSize)
300
        ID3FrameBody.copy(bodyFrameHeader, 0, currentPosition)
301
302
        let decodeSize = false
303
        if(ID3Version === 4) {
304
            decodeSize = true
305
        }
306
        let bodyFrameSize = ID3Util.getFrameSize(bodyFrameHeader, decodeSize, ID3Version)
307
        if(bodyFrameSize + 10 > (ID3FrameBody.length - currentPosition)) {
308
            break
309
        }
310
        const specName = bodyFrameHeader.toString('utf8', 0, identifierSize)
311
        if(options.exclude instanceof Array && options.exclude.includes(specName) || options.include instanceof Array && !options.include.includes(specName)) {
312
            currentPosition += bodyFrameSize + textframeHeaderSize
313
            continue
314
        }
315
        const frameHeaderFlags = ID3Util.parseFrameHeaderFlags(bodyFrameHeader, ID3Version)
316
        let bodyFrameBuffer = Buffer.alloc(bodyFrameSize)
317
        ID3FrameBody.copy(bodyFrameBuffer, 0, currentPosition + textframeHeaderSize + (frameHeaderFlags.dataLengthIndicator ? 4 : 0))
318
        //  Size of sub frame + its header
319
        currentPosition += bodyFrameSize + textframeHeaderSize
320
        frames.push({
321
            name: specName,
322
            flags: frameHeaderFlags,
323
            body: frameHeaderFlags.unsynchronisation ? ID3Util.processUnsynchronisedBuffer(bodyFrameBuffer) : bodyFrameBuffer
324
        })
325
    }
326
327
    return frames
328
}
329
330
module.exports.getTagsFromFrames = function(frames, ID3Version, options = {}) {
331
    let tags = { }
332
    let raw = { }
333
334
    frames.forEach((frame) => {
335
        let specName
336
        let identifier
337
        if(ID3Version === 2) {
338
            specName = ID3Definitions.FRAME_IDENTIFIERS.v3[ID3Definitions.FRAME_INTERNAL_IDENTIFIERS.v2[frame.name]]
339
            identifier = ID3Definitions.FRAME_INTERNAL_IDENTIFIERS.v2[frame.name]
340
        } else if(ID3Version === 3 || ID3Version === 4) {
341
            /**
342
             * Due to their similarity, it's possible to mix v3 and v4 frames even if they don't exist in their corrosponding spec.
343
             * Programs like Mp3tag allow you to do so, so we should allow reading e.g. v4 frames from a v3 ID3 Tag
344
             */
345
            specName = frame.name
346
            identifier = ID3Definitions.FRAME_INTERNAL_IDENTIFIERS.v3[frame.name] || ID3Definitions.FRAME_INTERNAL_IDENTIFIERS.v4[frame.name]
347
        }
348
349
        if(!specName || !identifier || frame.flags.encryption) {
350
            return
351
        }
352
353
        if(frame.flags.compression) {
354
            if(frame.body.length < 5) {
355
                return
356
            }
357
            const inflatedSize = frame.body.readInt32BE()
358
            /*
359
            * ID3 spec defines that compression is stored in ZLIB format, but doesn't specify if header is present or not.
360
            * ZLIB has a 2-byte header.
361
            * 1. try if header + body decompression
362
            * 2. else try if header is not stored (assume that all content is deflated "body")
363
            * 3. else try if inflation works if the header is omitted (implementation dependent)
364
            * */
365
            try {
366
                frame.body = zlib.inflateSync(frame.body.slice(4))
367
            } catch (e) {
368
                try {
369
                    frame.body = zlib.inflateRawSync(frame.body.slice(4))
370
                } catch (e) {
371
                    try {
372
                        frame.body = zlib.inflateRawSync(frame.body.slice(6))
373
                    } catch (e) {
374
                        return
375
                    }
376
                }
377
            }
378
            if(frame.body.length !== inflatedSize) {
379
                return
380
            }
381
        }
382
383
        let decoded
384
        if(ID3Frames[specName]) {
385
            decoded = ID3Frames[specName].read(frame.body, ID3Version, this)
386
        } else if(specName.startsWith('T')) {
387
            decoded = ID3Frames.GENERIC_TEXT.read(frame.body, ID3Version)
388
        } else if(specName.startsWith('W')) {
389
            decoded = ID3Frames.GENERIC_URL.read(frame.body, ID3Version)
390
        }
391
392
        if(decoded) {
393
            if(ID3Util.getSpecOptions(specName, ID3Version).multiple) {
394
                if(!options.onlyRaw) {
395
                    if(!tags[identifier]) {
396
                        tags[identifier] = []
397
                    }
398
                    tags[identifier].push(decoded)
399
                }
400
                if(!options.noRaw) {
401
                    if(!raw[specName]) {
402
                        raw[specName] = []
403
                    }
404
                    raw[specName].push(decoded)
405
                }
406
            } else {
407
                if(!options.onlyRaw) {
408
                    tags[identifier] = decoded
409
                }
410
                if(!options.noRaw) {
411
                    raw[specName] = decoded
412
                }
413
            }
414
        }
415
    })
416
417
    if(options.onlyRaw) {
418
        return raw
419
    }
420
    if(options.noRaw) {
421
        return tags
422
    }
423
424
    tags.raw = raw
425
    return tags
426
}
427
428
/**
429
 * Checks and removes already written ID3-Frames from a buffer
430
 * @param data - Buffer
431
 * @returns {boolean|Buffer}
432
 */
433
module.exports.removeTagsFromBuffer = removeTagsFromBuffer
434
function removeTagsFromBuffer(data) {
435
    let framePosition = ID3Util.getFramePosition(data)
436
437
    if (framePosition === -1) {
438
        return data
439
    }
440
441
    let hSize = Buffer.from([data[framePosition + 6], data[framePosition + 7], data[framePosition + 8], data[framePosition + 9]])
442
443
    const isMsbSet = !!((hSize[0] | hSize[1] | hSize[2] | hSize[3]) & 0x80)
444
    if (isMsbSet) {
445
        //  Invalid tag size (msb not 0)
446
        return false
447
    }
448
449
    if (data.length >= framePosition + 10) {
450
        const size = ID3Util.decodeSize(data.slice(framePosition + 6, framePosition + 10))
451
        return Buffer.concat([data.slice(0, framePosition), data.slice(framePosition + size + 10)])
452
    }
453
454
    return data
455
}
456
457
/**
458
 * @param {string} filepath - Filepath to file
459
 * @returns {boolean|Error}
460
 */
461
function removeTagsSync(filepath) {
462
    let data
463
    try {
464
        data = fs.readFileSync(filepath)
465
    } catch(error) {
466
        return error
467
    }
468
469
    const newData = removeTagsFromBuffer(data)
470
    if(!newData) {
471
        return false
472
    }
473
474
    try {
475
        fs.writeFileSync(filepath, newData, 'binary')
476
    } catch(error) {
477
        return error
478
    }
479
480
    return true
481
}
482
483
/**
484
 * @param {string} filepath - Filepath to file
485
 * @param {(error: Error) => void} fn - Function for async usage
486
 * @returns {void}
487
 */
488
function removeTagsAsync(filepath, fn) {
489
    fs.readFile(filepath, (error, data) => {
490
        if(error) {
491
            fn(error)
492
        }
493
494
        const newData = removeTagsFromBuffer(data)
495
        if(!newData) {
496
            fn(error)
497
            return
498
        }
499
500
        fs.writeFile(filepath, newData, 'binary', (error) => {
501
            if(error) {
502
                fn(error)
503
            } else {
504
                fn(false)
505
            }
506
        })
507
    })
508
}
509
510
/**
511
 * Checks and removes already written ID3-Frames from a file
512
 * @param {string} filepath - Filepath to file
513
 * @param fn - (optional) Function for async usage
514
 * @returns {boolean|Error}
515
 */
516
module.exports.removeTags = function(filepath, fn) {
517
    if(isFunction(fn)) {
518
        return removeTagsAsync(filepath, fn)
519
    }
520
    return removeTagsSync(filepath)
521
}
522
523
function makePromise(fn) {
524
    return new Promise((resolve, reject) => {
525
        fn((error, result) => {
526
            if(error) {
527
                reject(error)
528
            } else {
529
                resolve(result)
530
            }
531
        })
532
    })
533
}
534
535
module.exports.Promise = {
536
    write: (tags, file) => makePromise(this.write.bind(this, tags, file)),
537
    update: (tags, file) => makePromise(this.update.bind(this, tags, file)),
538
    create: (tags) => {
539
        return new Promise((resolve) => {
540
            this.create(tags, (buffer) => {
541
                resolve(buffer)
542
            })
543
        })
544
    },
545
    read: (file, options) => makePromise(this.read.bind(this, file, options)),
546
    removeTags: (filepath) => makePromise(this.removeTags.bind(this, filepath))
547
}
548
549
module.exports.Constants = ID3Definitions.Constants
550