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