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