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