UNPKG

node-id3

Version:

Pure JavaScript ID3v2 Tag writer and reader

271 lines (240 loc) 10.5 kB
const zlib = require('zlib') const ID3Definitions = require("./ID3Definitions") const ID3Frames = require('./ID3Frames') const ID3Util = require('./ID3Util') /** * Returns array of buffers created by tags specified in the tags argument * @param tags - Object containing tags to be written * @returns {Array} */ function createBuffersFromTags(tags) { const frames = [] if(!tags) { return frames } const rawObject = Object.keys(tags).reduce((acc, val) => { if(ID3Definitions.FRAME_IDENTIFIERS.v3[val] !== undefined) { acc[ID3Definitions.FRAME_IDENTIFIERS.v3[val]] = tags[val] } else if(ID3Definitions.FRAME_IDENTIFIERS.v4[val] !== undefined) { /** * Currently, node-id3 always writes ID3 version 3. * However, version 3 and 4 are very similar, and node-id3 can also read version 4 frames. * Until version 4 is fully supported, as a workaround, allow writing version 4 frames into a version 3 tag. * If a reader does not support a v4 frame, it's (per spec) supposed to skip it, so it should not be a problem. */ acc[ID3Definitions.FRAME_IDENTIFIERS.v4[val]] = tags[val] } else { acc[val] = tags[val] } return acc }, {}) Object.keys(rawObject).forEach((frameIdentifier) => { let frame // Check if invalid frameIdentifier if(frameIdentifier.length !== 4) { return } if(ID3Frames[frameIdentifier] !== undefined) { frame = ID3Frames[frameIdentifier].create(rawObject[frameIdentifier], 3) } else if(frameIdentifier.startsWith('T')) { frame = ID3Frames.GENERIC_TEXT.create(frameIdentifier, rawObject[frameIdentifier], 3) } else if(frameIdentifier.startsWith('W')) { if(ID3Util.getSpecOptions(frameIdentifier, 3).multiple && rawObject[frameIdentifier] instanceof Array && rawObject[frameIdentifier].length > 0) { frame = Buffer.alloc(0) // deduplicate array for(const url of [...new Set(rawObject[frameIdentifier])]) { frame = Buffer.concat([frame, ID3Frames.GENERIC_URL.create(frameIdentifier, url, 3)]) } } else { frame = ID3Frames.GENERIC_URL.create(frameIdentifier, rawObject[frameIdentifier], 3) } } if (frame && frame instanceof Buffer) { frames.push(frame) } }) return frames } /** * Return a buffer with the frames for the specified tags * @param tags - Object containing tags to be written * @returns {Buffer} */ module.exports.createBufferFromTags = function(tags) { return Buffer.concat(createBuffersFromTags(tags)) } module.exports.getTagsFromBuffer = function(filebuffer, options) { const framePosition = ID3Util.getFramePosition(filebuffer) if(framePosition === -1) { return getTagsFromFrames([], 3, options) } const frameSize = ID3Util.decodeSize(filebuffer.slice(framePosition + 6, framePosition + 10)) + 10 const ID3Frame = Buffer.alloc(frameSize + 1) filebuffer.copy(ID3Frame, 0, framePosition) //ID3 version e.g. 3 if ID3v2.3.0 const ID3Version = ID3Frame[3] const tagFlags = ID3Util.parseTagHeaderFlags(ID3Frame) let extendedHeaderOffset = 0 if(tagFlags.extendedHeader) { if(ID3Version === 3) { extendedHeaderOffset = 4 + filebuffer.readUInt32BE(10) } else if(ID3Version === 4) { extendedHeaderOffset = ID3Util.decodeSize(filebuffer.slice(10, 14)) } } const ID3FrameBody = Buffer.alloc(frameSize - 10 - extendedHeaderOffset) filebuffer.copy(ID3FrameBody, 0, framePosition + 10 + extendedHeaderOffset) const frames = getFramesFromID3Body(ID3FrameBody, ID3Version, options) return getTagsFromFrames(frames, ID3Version, options) } function isFrameDiscardedByOptions(frameIdentifier, options) { if(options.exclude instanceof Array && options.exclude.includes(frameIdentifier)) { return true } return options.include instanceof Array && !options.include.includes(frameIdentifier) } function getFramesFromID3Body(ID3TagBody, ID3Version, options = {}) { let currentPosition = 0 const frames = [] if(!ID3TagBody || !(ID3TagBody instanceof Buffer)) { return frames } const frameIdentifierSize = (ID3Version === 2) ? 3 : 4 const frameHeaderSize = (ID3Version === 2) ? 6 : 10 while(currentPosition < ID3TagBody.length && ID3TagBody[currentPosition] !== 0x00) { const frameHeader = ID3TagBody.subarray(currentPosition, currentPosition + frameHeaderSize) const frameIdentifier = frameHeader.toString('utf8', 0, frameIdentifierSize) const decodeSize = ID3Version === 4 const frameBodySize = ID3Util.getFrameSize(frameHeader, decodeSize, ID3Version) // It's possible to discard frames via options.exclude/options.include // If that is the case, skip this frame and continue with the next if(isFrameDiscardedByOptions(frameIdentifier, options)) { currentPosition += frameBodySize + frameHeaderSize continue } // Prevent errors when the current frame's size exceeds the remaining tags size (e.g. due to broken size bytes). if(frameBodySize + frameHeaderSize > (ID3TagBody.length - currentPosition)) { break } const frameHeaderFlags = ID3Util.parseFrameHeaderFlags(frameHeader, ID3Version) // Frames may have a 32-bit data length indicator appended after their header, // if that is the case, the real body starts after those 4 bytes. const frameBodyOffset = frameHeaderFlags.dataLengthIndicator ? 4 : 0 const frameBodyStart = currentPosition + frameHeaderSize + frameBodyOffset const frameBody = ID3TagBody.subarray(frameBodyStart, frameBodyStart + frameBodySize - frameBodyOffset) const frame = { name: frameIdentifier, flags: frameHeaderFlags, body: frameHeaderFlags.unsynchronisation ? ID3Util.processUnsynchronisedBuffer(frameBody) : frameBody } if(frameHeaderFlags.dataLengthIndicator) { frame.dataLengthIndicator = ID3TagBody.readInt32BE(currentPosition + frameHeaderSize) } frames.push(frame) // Size of frame body + its header currentPosition += frameBodySize + frameHeaderSize } return frames } function decompressFrame(frame) { if(frame.body.length < 5 || frame.dataLengthIndicator === undefined) { return null } /* * ID3 spec defines that compression is stored in ZLIB format, but doesn't specify if header is present or not. * ZLIB has a 2-byte header. * 1. try if header + body decompression * 2. else try if header is not stored (assume that all content is deflated "body") * 3. else try if inflation works if the header is omitted (implementation dependent) * */ let decompressedBody try { decompressedBody = zlib.inflateSync(frame.body) } catch (e) { try { decompressedBody = zlib.inflateRawSync(frame.body) } catch (e) { try { decompressedBody = zlib.inflateRawSync(frame.body.slice(2)) } catch (e) { return null } } } if(decompressedBody.length !== frame.dataLengthIndicator) { return null } return decompressedBody } function getTagsFromFrames(frames, ID3Version, options = {}) { const tags = { } const raw = { } frames.forEach((frame) => { let frameIdentifier let identifier if(ID3Version === 2) { frameIdentifier = ID3Definitions.FRAME_IDENTIFIERS.v3[ID3Definitions.FRAME_INTERNAL_IDENTIFIERS.v2[frame.name]] identifier = ID3Definitions.FRAME_INTERNAL_IDENTIFIERS.v2[frame.name] } else if(ID3Version === 3 || ID3Version === 4) { /** * Due to their similarity, it's possible to mix v3 and v4 frames even if they don't exist in their corrosponding spec. * Programs like Mp3tag allow you to do so, so we should allow reading e.g. v4 frames from a v3 ID3 Tag */ frameIdentifier = frame.name identifier = ID3Definitions.FRAME_INTERNAL_IDENTIFIERS.v3[frame.name] || ID3Definitions.FRAME_INTERNAL_IDENTIFIERS.v4[frame.name] } if(!frameIdentifier || !identifier || frame.flags.encryption) { return } if(frame.flags.compression) { const decompressedBody = decompressFrame(frame) if(!decompressedBody) { return } frame.body = decompressedBody } let decoded if(ID3Frames[frameIdentifier]) { decoded = ID3Frames[frameIdentifier].read(frame.body, ID3Version) } else if(frameIdentifier.startsWith('T')) { decoded = ID3Frames.GENERIC_TEXT.read(frame.body, ID3Version) } else if(frameIdentifier.startsWith('W')) { decoded = ID3Frames.GENERIC_URL.read(frame.body, ID3Version) } if(!decoded) { return } if(ID3Util.getSpecOptions(frameIdentifier, ID3Version).multiple) { if(!options.onlyRaw) { if(!tags[identifier]) { tags[identifier] = [] } tags[identifier].push(decoded) } if(!options.noRaw) { if(!raw[frameIdentifier]) { raw[frameIdentifier] = [] } raw[frameIdentifier].push(decoded) } } else { if(!options.onlyRaw) { tags[identifier] = decoded } if(!options.noRaw) { raw[frameIdentifier] = decoded } } }) if(options.onlyRaw) { return raw } if(options.noRaw) { return tags } tags.raw = raw return tags } module.exports.getTagsFromID3Body = function(body) { return getTagsFromFrames(getFramesFromID3Body(body, 3), 3) }