Passed
Pull Request — master (#127)
by
unknown
01:29
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
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 {
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
        }
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
        return;
0 ignored issues
show
Comprehensibility Best Practice introduced by
Are you sure this return statement is not missing an argument? If this is intended, consider adding an explicit undefined like return undefined;.
Loading history...
102
    }
103
    return Buffer.concat(frames)
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 {
120
            acc[val] = tags[val]
121
        }
122
        return acc
123
    }, {})
124
125
    Object.keys(rawObject).forEach((specName) => {
126
        let frame
127
        // Check if invalid specName
128
        if(specName.length !== 4) {
129
            return
130
        }
131
        if(ID3Frames[specName] !== undefined) {
132
            frame = ID3Frames[specName].create(rawObject[specName], 3, this)
133
        } else if(specName.startsWith('T')) {
134
            frame = ID3Frames.GENERIC_TEXT.create(specName, rawObject[specName], 3)
135
        } else if(specName.startsWith('W')) {
136
            if(ID3Util.getSpecOptions(specName, 3).multiple && rawObject[specName] instanceof Array && rawObject[specName].length > 0) {
137
                frame = Buffer.alloc(0)
138
                // deduplicate array
139
                for(let url of [...new Set(rawObject[specName])]) {
140
                    frame = Buffer.concat([frame, ID3Frames.GENERIC_URL.create(specName, url, 3)])
141
                }
142
            } else {
143
                frame = ID3Frames.GENERIC_URL.create(specName, rawObject[specName], 3)
144
            }
145
        }
146
147
        if (frame && frame instanceof Buffer) {
148
            frames.push(frame)
149
        }
150
    })
151
152
    return frames
153
}
154
155
/**
156
 * Read ID3-Tags from passed buffer/filepath
157
 * @param filebuffer - Can contain a filepath string or buffer
158
 * @param options - (optional) Object containing options
159
 * @param fn - (optional) Function for async version
160
 * @returns {boolean}
161
 */
162
module.exports.read = function(filebuffer, options, fn) {
163
    if(!options || typeof options === 'function') {
164
        fn = fn || options
165
        options = {}
166
    }
167
    if(!fn || typeof fn !== 'function') {
168
        if(typeof filebuffer === "string" || filebuffer instanceof String) {
169
            filebuffer = fs.readFileSync(filebuffer)
170
        }
171
        return this.getTagsFromBuffer(filebuffer, options)
172
    } else {
173
        if(typeof filebuffer === "string" || filebuffer instanceof String) {
174
            fs.readFile(filebuffer, function(err, data) {
175
                if(err) {
176
                    fn(err, null)
177
                } else {
178
                    fn(null, this.getTagsFromBuffer(data, options))
179
                }
180
            }.bind(this))
181
        } else {
182
            fn(null, this.getTagsFromBuffer(filebuffer, options))
183
        }
184
    }
185
}
186
187
/**
188
 * Update ID3-Tags from passed buffer/filepath
189
 * @param tags - Object containing tags to be written
190
 * @param filebuffer - Can contain a filepath string or buffer
191
 * @param options - (optional) Object containing options
192
 * @param fn - (optional) Function for async version
193
 * @returns {boolean|Buffer|Error}
194
 */
195
module.exports.update = function(tags, filebuffer, options, fn) {
196
    if(!options || typeof options === 'function') {
197
        fn = fn || options
198
        options = {}
199
    }
200
201
    const rawTags = Object.keys(tags).reduce((acc, val) => {
202
        if(ID3Definitions.FRAME_IDENTIFIERS.v3[val] !== undefined) {
203
            acc[ID3Definitions.FRAME_IDENTIFIERS.v3[val]] = tags[val]
204
        } else {
205
            acc[val] = tags[val]
206
        }
207
        return acc
208
    }, {})
209
210
    const updateFn = (currentTags) => {
211
        currentTags = currentTags.raw || {}
212
        Object.keys(rawTags).map((specName) => {
213
            const options = ID3Util.getSpecOptions(specName, 3)
214
            const cCompare = {}
215
            if(options.multiple && currentTags[specName] && rawTags[specName]) {
216
                if(options.updateCompareKey) {
217
                    currentTags[specName].forEach((cTag, index) => {
218
                        cCompare[cTag[options.updateCompareKey]] = index
219
                    })
220
221
                }
222
                if (!(rawTags[specName] instanceof Array)) {
223
                    rawTags[specName] = [rawTags[specName]]
224
                }
225
                rawTags[specName].forEach((rTag) => {
226
                    const comparison = cCompare[rTag[options.updateCompareKey]]
227
                    if (comparison !== undefined) {
228
                        currentTags[specName][comparison] = rTag
229
                    } else {
230
                        currentTags[specName].push(rTag)
231
                    }
232
                })
233
            } else {
234
                currentTags[specName] = rawTags[specName]
235
            }
236
        })
237
238
        return currentTags
239
    }
240
241
    if(!fn || typeof fn !== 'function') {
242
        return this.write(updateFn(this.read(filebuffer, options)), filebuffer)
243
    }
244
245
    this.write(updateFn(this.read(filebuffer, options)), filebuffer, fn)
246
}
247
248
module.exports.getTagsFromBuffer = function(filebuffer, options) {
249
    let framePosition = ID3Util.getFramePosition(filebuffer)
250
    if(framePosition === -1) {
251
        return this.getTagsFromFrames([], 3, options)
252
    }
253
    const frameSize = ID3Util.decodeSize(filebuffer.slice(framePosition + 6, framePosition + 10)) + 10
254
    let ID3Frame = Buffer.alloc(frameSize + 1)
255
    filebuffer.copy(ID3Frame, 0, framePosition)
256
    //ID3 version e.g. 3 if ID3v2.3.0
257
    let ID3Version = ID3Frame[3]
258
    const tagFlags = ID3Util.parseTagHeaderFlags(ID3Frame)
259
    let extendedHeaderOffset = 0
260
    if(tagFlags.extendedHeader) {
261
        if(ID3Version === 3) {
262
            extendedHeaderOffset = 4 + filebuffer.readUInt32BE(10)
263
        } else if(ID3Version === 4) {
264
            extendedHeaderOffset = ID3Util.decodeSize(filebuffer.slice(10, 14))
265
        }
266
    }
267
    let ID3FrameBody = Buffer.alloc(frameSize - 10 - extendedHeaderOffset)
268
    filebuffer.copy(ID3FrameBody, 0, framePosition + 10 + extendedHeaderOffset)
269
270
    let frames = this.getFramesFromID3Body(ID3FrameBody, ID3Version, options)
271
272
    return this.getTagsFromFrames(frames, ID3Version, options)
273
}
274
275
module.exports.getFramesFromID3Body = function(ID3FrameBody, ID3Version, options = {}) {
276
    let currentPosition = 0
277
    let frames = []
278
    if(!ID3FrameBody || !(ID3FrameBody instanceof Buffer)) {
279
        return frames
280
    }
281
282
    let identifierSize = 4
283
    let textframeHeaderSize = 10
284
    if(ID3Version === 2) {
285
        identifierSize = 3
286
        textframeHeaderSize = 6
287
    }
288
289
    while(currentPosition < ID3FrameBody.length && ID3FrameBody[currentPosition] !== 0x00) {
290
        let bodyFrameHeader = Buffer.alloc(textframeHeaderSize)
291
        ID3FrameBody.copy(bodyFrameHeader, 0, currentPosition)
292
293
        let decodeSize = false
294
        if(ID3Version === 4) {
295
            decodeSize = true
296
        }
297
        let bodyFrameSize = ID3Util.getFrameSize(bodyFrameHeader, decodeSize, ID3Version)
298
        if(bodyFrameSize + 10 > (ID3FrameBody.length - currentPosition)) {
299
            break
300
        }
301
        const specName = bodyFrameHeader.toString('utf8', 0, identifierSize)
302
        if(options.exclude instanceof Array && options.exclude.includes(specName) || options.include instanceof Array && !options.include.includes(specName)) {
303
            currentPosition += bodyFrameSize + textframeHeaderSize
304
            continue
305
        }
306
        const frameHeaderFlags = ID3Util.parseFrameHeaderFlags(bodyFrameHeader, ID3Version)
307
        let bodyFrameBuffer = Buffer.alloc(bodyFrameSize)
308
        ID3FrameBody.copy(bodyFrameBuffer, 0, currentPosition + textframeHeaderSize + (frameHeaderFlags.dataLengthIndicator ? 4 : 0))
309
        //  Size of sub frame + its header
310
        currentPosition += bodyFrameSize + textframeHeaderSize
311
        frames.push({
312
            name: specName,
313
            flags: frameHeaderFlags,
314
            body: frameHeaderFlags.unsynchronisation ? ID3Util.processUnsynchronisedBuffer(bodyFrameBuffer) : bodyFrameBuffer
315
        })
316
    }
317
318
    return frames
319
}
320
321
module.exports.getTagsFromFrames = function(frames, ID3Version, options = {}) {
322
    let tags = { }
323
    let raw = { }
324
325
    frames.forEach((frame) => {
326
        const specName = ID3Version === 2 ? ID3Definitions.FRAME_IDENTIFIERS.v3[ID3Definitions.FRAME_INTERNAL_IDENTIFIERS.v2[frame.name]] : frame.name
327
        const identifier = ID3Version === 2 ? ID3Definitions.FRAME_INTERNAL_IDENTIFIERS.v2[frame.name] : ID3Definitions.FRAME_INTERNAL_IDENTIFIERS.v3[frame.name]
328
329
        if(!specName || !identifier || frame.flags.encryption) {
330
            return
331
        }
332
333
        if(frame.flags.compression) {
334
            if(frame.body.length < 5) {
335
                return
336
            }
337
            const inflatedSize = frame.body.readInt32BE()
338
            /*
339
            * ID3 spec defines that compression is stored in ZLIB format, but doesn't specify if header is present or not.
340
            * ZLIB has a 2-byte header.
341
            * 1. try if header + body decompression
342
            * 2. else try if header is not stored (assume that all content is deflated "body")
343
            * 3. else try if inflation works if the header is omitted (implementation dependent)
344
            * */
345
            try {
346
                frame.body = zlib.inflateSync(frame.body.slice(4))
347
            } catch (e) {
348
                try {
349
                    frame.body = zlib.inflateRawSync(frame.body.slice(4))
350
                } catch (e) {
351
                    try {
352
                        frame.body = zlib.inflateRawSync(frame.body.slice(6))
353
                    } catch (e) {
354
                        return
355
                    }
356
                }
357
            }
358
            if(frame.body.length !== inflatedSize) {
359
                return
360
            }
361
        }
362
363
        let decoded
364
        if(ID3Frames[specName]) {
365
            decoded = ID3Frames[specName].read(frame.body, ID3Version, this)
366
        } else if(specName.startsWith('T')) {
367
            decoded = ID3Frames.GENERIC_TEXT.read(frame.body, ID3Version)
368
        } else if(specName.startsWith('W')) {
369
            decoded = ID3Frames.GENERIC_URL.read(frame.body, ID3Version)
370
        }
371
372
        if(decoded) {
373
            if(ID3Util.getSpecOptions(specName, ID3Version).multiple) {
374
                if(!options.onlyRaw) {
375
                    if(!tags[identifier]) {
376
                        tags[identifier] = []
377
                    }
378
                    tags[identifier].push(decoded)
379
                }
380
                if(!options.noRaw) {
381
                    if(!raw[specName]) {
382
                        raw[specName] = []
383
                    }
384
                    raw[specName].push(decoded)
385
                }
386
            } else {
387
                if(!options.onlyRaw) {
388
                    tags[identifier] = decoded
389
                }
390
                if(!options.noRaw) {
391
                    raw[specName] = decoded
392
                }
393
            }
394
        }
395
    })
396
397
    if(options.onlyRaw) {
398
        return raw
399
    }
400
    if(options.noRaw) {
401
        return tags
402
    }
403
404
    tags.raw = raw
405
    return tags
406
}
407
408
/**
409
 * Checks and removes already written ID3-Frames from a buffer
410
 * @param data - Buffer
411
 * @returns {boolean|Buffer}
412
 */
413
module.exports.removeTagsFromBuffer = function(data) {
414
    let framePosition = ID3Util.getFramePosition(data)
415
416
    if (framePosition === -1) {
417
        return data
418
    }
419
420
    let hSize = Buffer.from([data[framePosition + 6], data[framePosition + 7], data[framePosition + 8], data[framePosition + 9]])
421
422
    const isMsbSet = !!((hSize[0] | hSize[1] | hSize[2] | hSize[3]) & 0x80)
423
    if (isMsbSet) {
424
        //  Invalid tag size (msb not 0)
425
        return false
426
    }
427
428
    if (data.length >= framePosition + 10) {
429
        const size = ID3Util.decodeSize(data.slice(framePosition + 6, framePosition + 10))
430
        return Buffer.concat([data.slice(0, framePosition), data.slice(framePosition + size + 10)])
431
    }
432
433
    return data
434
}
435
436
/**
437
 * Checks and removes already written ID3-Frames from a file
438
 * @param filepath - Filepath to file
439
 * @param fn - (optional) Function for async usage
440
 * @returns {boolean|Error}
441
 */
442
module.exports.removeTags = function(filepath, fn) {
443
    if(!fn || typeof fn !== 'function') {
444
        let data
445
        try {
446
            data = fs.readFileSync(filepath)
447
        } catch(e) {
448
            return e
449
        }
450
451
        let newData = this.removeTagsFromBuffer(data)
452
        if(!newData) {
453
            return false
454
        }
455
456
        try {
457
            fs.writeFileSync(filepath, newData, 'binary')
458
        } catch(e) {
459
            return e
460
        }
461
462
        return true
463
    }
464
465
    fs.readFile(filepath, function(err, data) {
466
        if(err) {
467
            fn(err)
468
        }
469
470
        let newData = this.removeTagsFromBuffer(data)
471
        if(!newData) {
472
            fn(err)
473
            return
474
        }
475
476
        fs.writeFile(filepath, newData, 'binary', function(err) {
477
            if(err) {
478
                fn(err)
479
            } else {
480
                fn(false)
481
            }
482
        })
483
    }.bind(this))
484
}
485
486
module.exports.Promise = {
487
    write: (tags, file) => {
488
        return new Promise((resolve, reject) => {
489
            this.write(tags, file, (err, ret) => {
490
                if(err) {
491
                    reject(err)
492
                } else {
493
                    resolve(ret)
494
                }
495
            })
496
        })
497
    },
498
    update: (tags, file) => {
499
        return new Promise((resolve, reject) => {
500
            this.update(tags, file, (err, ret) => {
501
                if(err) {
502
                    reject(err)
503
                } else {
504
                    resolve(ret)
505
                }
506
            })
507
        })
508
    },
509
    create: (tags) => {
510
        return new Promise((resolve) => {
511
            this.create(tags, (buffer) => {
512
                resolve(buffer)
513
            })
514
        })
515
    },
516
    read: (file, options) => {
517
        return new Promise((resolve, reject) => {
518
            this.read(file, options, (err, ret) => {
519
                if(err) {
520
                    reject(err)
521
                } else {
522
                    resolve(ret)
523
                }
524
            })
525
        })
526
    },
527
    removeTags: (filepath) => {
528
        return new Promise((resolve, reject) => {
529
            this.removeTags(filepath, (err) => {
530
                if(err) {
531
                    reject(err)
532
                } else {
533
                    resolve()
534
                }
535
            })
536
        })
537
    }
538
}
539