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