JS FLACMetadataEditor

Allows you to edit metadata of FLAC files. CO

This script should not be not be installed directly. It is a library for other scripts to include with the meta directive // @require https://greasyfork.org/scripts/40545-js-flacmetadataeditor/code/JS%20FLACMetadataEditor.js?version=646530

/* jslint esversion: 6, bitwise: true */

// ==UserScript==
// @name        JS FLACMetadataEditor
// @description Allows you to edit metadata of FLAC files. CO
// @namespace   universe.earth.www.ahohnmyc
// @version     0.0.2.1
// @license     GPL-3.0-or-later
// @grant       none
// ==/UserScript==

const FLACMetadataEditor = (()=>{
    'use strict';

    const _version = '0.0.2.1';

    class VorbisComment extends Array {}

    class VorbisCommentPacket {
        /* Need to easy initialization */
        _addComment(field) {
            const value = field.split('=')[1];
            field = field.split('=')[0].toUpperCase();
            if (!this.hasOwnProperty(field))
                this[field] = new VorbisComment();
            if (!this[field].some(storedValue=> storedValue===value))
                this[field].push(value.toString());
            return this;
        }
        toStringArray() {
            const array = [];
            Object.keys(this).sort().forEach(key=> {
                this[key].forEach(value=> {
                    array.push(key+'='+value);
                });
            });
            return array;
        }
    }

    class FLACMetadataBlockData {}

    class FLACMetadataBlock {
        constructor() {
            this.blockType = '';
            this.blockTypeNubmer = 0;
            this.blockSize = 0;
            this.data = new FLACMetadataBlockData();
            this.offset = 0;
        }
        get serializedSize() {
            switch (this.blockType) {
                case 'STREAMINFO': return 34;
                case 'PADDING': return this.blockSize;
                case 'APPLICATION': return 4+this.data.applicationData.length;
                case 'SEEKTABLE': return this.data.points.length*18;
                case 'VORBIS_COMMENT':
                    const totl = this.data.comments.toStringArray().reduce((sum, str)=>sum+4+str.toUTF8().length, 0);
                    return 4+this.data.vendorString.length+4+ totl;
                case 'CUESHEET': return 0;
                case 'PICTURE': return 4+4+this.data.MIMEType.toUTF8().length+4+this.data.description.toUTF8().length+4+4+4+4+4+this.data.data.length;
            }
        }
    }

    class FLACMetadataBlocks extends Array {}

    class FLACMetadata {
        constructor() {
            this.blocks = new FLACMetadataBlocks();
            this.framesOffset = 0;
            this.signature = '';
        }
    }

    class _FLACMetadataEditor {
        get scriptVersion() {return _version;}

        constructor(buffer) {
            if (!buffer || typeof buffer !== 'object' || !('byteLength' in buffer)) {
                throw new Error('First argument should be an instance of ArrayBuffer or Buffer');
            }

            this.arrayBuffer = buffer;
            this.metadata = new FLACMetadata();

            String.prototype.toUTF8 = function(str = null) {
                return new TextEncoder().encode(str ? str : this);
            };

            this._parseMetadata();

            return this;
        }

        /* unpack */
        _getBytesAsNumber            (array, start=0, end=array.length-start) {return Array.from(array.subarray(start, start+end)).reduce     ((result, b)=>result=256*result+b, 0);}
        _getBytesAsNumberLittleEndian(array, start=0, end=array.length-start) {return Array.from(array.subarray(start, start+end)).reduceRight((result, b)=>result=256*result+b, 0);}
        _getBytesAsHexString (array, start=0, end=array.length-start) {return Array.from(array.subarray(start, start+end)).map(n=>(n>>4).toString(16)+(n&0xF).toString(16)).join('');}
        _getBytesAsUTF8String(array, start=0, end=array.length-start) {return new TextDecoder().decode(array.subarray(start, start+end));}
        _getBlockType(number){
            switch (number) {
                case 0: return 'STREAMINFO';
                case 1: return 'PADDING';
                case 2: return 'APPLICATION';
                case 3: return 'SEEKTABLE';
                case 4: return 'VORBIS_COMMENT';
                case 5: return 'CUESHEET';
                case 6: return 'PICTURE';
                case 127: return 'invalid, to avoid confusion with a frame sync code';
                default: return 'reserved';
            }
        }
        /* pack */
        _uint32ToUint8Array(uint32) {
            const eightBitMask = 0xff;
            return [
                (uint32 >>> 24) & eightBitMask,
                (uint32 >>> 16) & eightBitMask,
                (uint32 >>> 8) & eightBitMask,
                uint32 & eightBitMask,
            ];
        }
        _uint24ToUint8Array(uint32) {
            const eightBitMask = 0xff;
            return [
                (uint32 >>> 16) & eightBitMask,
                (uint32 >>> 8) & eightBitMask,
                uint32 & eightBitMask,
            ];
        }
        _uint16ToUint8Array(uint32) {
            const eightBitMask = 0xff;
            return [
                (uint32 >>> 8) & eightBitMask,
                uint32 & eightBitMask,
            ];
        }
        _hexStringToUint8Array(str) {
            return str.replace(/(\w\w)/g,'$1,').slice(0,-1).split(',').map(s=> (parseInt(s[0],16)<<4) + parseInt(s[1],16));
        }

        get _vorbisComment() {
            const block = this.metadata.blocks.find(block=>block.blockType==='VORBIS_COMMENT');
            if (block)
                return block.data;
        }

        addComment(field, value = null) {
            if (field) {
                if (!value) {
                    const splitted = field.split('=');
                    if (!splitted[1]) return this;
                    value = splitted[1];
                    field = splitted[0];
                }
                field = field.toUpperCase();
                if (!this._vorbisComment.comments.hasOwnProperty(field))
                    this._vorbisComment.comments[field] = new VorbisComment();
                if (!this._vorbisComment.comments[field].find(storedValue=> storedValue===value))
                    this._vorbisComment.comments[field].push(value.toString());
            }
            return this;
        }
        removeComment(field = null, value = null) {
            if (!field) {
                Object.keys(this._vorbisComment.comments).forEach(key=> delete this._vorbisComment.comments[key]);
            } else {
                field = field.toUpperCase();
                if (!value) {
                    delete this._vorbisComment.comments[field];
                } else {
                    value = value.toString();
                    if (this.hasOwnProperty(field))
                        this._vorbisComment.comments[field] = this._vorbisComment.comments[field].filter(storedValue=> storedValue!==value);
                }
            }
            return this;
        }
        getComment(field) {
            return this._vorbisComment.comments[field.toUpperCase()];
        }

        addPicture(dataInput) {
            if (!dataInput.data || !dataInput.data || typeof dataInput.data !== 'object' || !('byteLength' in dataInput.data)) {
                throw new Error('Field "data" should be an instance of ArrayBuffer or Buffer');
            }
            dataInput.data = new Uint8Array(dataInput.data);

            const dataDefault = {
                APICtype: 3,
                MIMEType: 'image/jpeg',
                colorDepth: 0,
                colorNumber: 0,
                data: new Uint8Array([]),
                description: '',
                width: 0,
                height: 0,
            };

            const block = new FLACMetadataBlock();
            block.blockTypeNubmer = 6;
            block.blockType = 'PICTURE';
            for (let property in dataDefault) {
                if (dataInput[property]) {
                    block.data[property] = dataInput[property];
                } else {
                    block.data[property] = dataDefault[property];
                }
            }

            const bl = this.metadata.blocks;
            let index = bl.length;
            if (bl[bl.length-1].blockType === 'PADDING') index--;

            bl.splice(index, 0, block);
            this.metadata.blocks = bl;

            return this;
        }



        _serializeMetadataBlock(block) {
            const bytes = new Uint8Array(block.serializedSize);
            const data = block.data;
            let offset = 0;

            switch (block.blockType) {
                case 'STREAMINFO':
                    bytes.set(this._uint16ToUint8Array(data.minBlockSize));
                    offset += 2;
                    bytes.set(this._uint16ToUint8Array(data.maxBlockSize), offset);
                    offset += 2;
                    bytes.set(this._uint24ToUint8Array(data.minFrameSize), offset);
                    offset += 3;
                    bytes.set(this._uint24ToUint8Array(data.maxFrameSize), offset);
                    offset += 3;
                    bytes.set(this._uint24ToUint8Array((data.sampleRate<<4) + (data.numberOfChannels-1<<1) + (data.bitsPerSample-1>>4)), offset);
                    offset += 3;
                    bytes[offset] = ((data.bitsPerSample-1&0xF)<<4) + (Math.trunc(data.totalSamples/Math.pow(2,32))&0xF);
                    offset += 1;
                    bytes.set(this._uint32ToUint8Array(data.totalSamples), offset);
                    offset += 4;
                    bytes.set(this._hexStringToUint8Array(data.rawMD5), offset);
                    break;
                case 'PADDING':
                    break;
                case 'APPLICATION':
                    bytes.set(data.applicationID.toUTF8());
                    offset += 4;
                    bytes.set(data.applicationData, offset);
                    break;
                case 'SEEKTABLE':
                    data.points.forEach(point=> {
                        bytes.set(this._hexStringToUint8Array(point.sampleNumber), offset);
                        bytes.set(this._hexStringToUint8Array(point.offset), offset+8);
                        bytes.set(this._hexStringToUint8Array(point.numberOfSamples), offset+16);
                        offset += 18;
                    });
                    break;
                case 'VORBIS_COMMENT':
                    bytes.set(this._uint32ToUint8Array(data.vendorString.toUTF8().length).reverse(), offset);
                    offset += 4;
                    bytes.set(data.vendorString.toUTF8(), offset);
                    offset += data.vendorString.toUTF8().length;

                    const comments = data.comments.toStringArray();
                    bytes.set(this._uint32ToUint8Array(comments.length).reverse(), offset);
                    offset += 4;
                    comments.forEach(comment=> {
                        bytes.set(this._uint32ToUint8Array(comment.toUTF8().length).reverse(), offset);
                        offset += 4;
                        bytes.set(comment.toUTF8(), offset);
                        offset += comment.toUTF8().length;
                    });
                    break;
                case 'CUESHEET':
                    break;
                case 'PICTURE':
                    bytes.set(this._uint32ToUint8Array(data.APICtype));
                    offset += 4;
                    bytes.set(this._uint32ToUint8Array(data.MIMEType.toUTF8().length), offset);
                    offset += 4;
                    bytes.set(data.MIMEType.toUTF8(), offset);
                    offset += data.MIMEType.toUTF8().length;

                    bytes.set(this._uint32ToUint8Array(data.description.toUTF8().length), offset);
                    offset += 4;
                    bytes.set(data.description.toUTF8(), offset);
                    offset += data.description.toUTF8().length;

                    bytes.set(this._uint32ToUint8Array(data.width), offset);
                    offset += 4;
                    bytes.set(this._uint32ToUint8Array(data.height), offset);
                    offset += 4;
                    bytes.set(this._uint32ToUint8Array(data.colorDepth), offset);
                    offset += 4;
                    bytes.set(this._uint32ToUint8Array(data.colorNumber), offset);
                    offset += 4;
                    bytes.set(this._uint32ToUint8Array(data.data.length), offset);
                    offset += 4;
                    bytes.set(data.data, offset);
                    break;
            }
            return bytes;
        }

        serializeMetadata() {
            const newMetadataLengthFull = 4+this.metadata.blocks.reduce((sum, block)=>sum+4+block.serializedSize, 0);
            const newSize = newMetadataLengthFull + (this.arrayBuffer.byteLength>this.metadata.framesOffset ? this.arrayBuffer.byteLength-this.metadata.framesOffset : 0);

            const bytes = new Uint8Array(newSize);
            bytes.set(this.metadata.signature.toUTF8());

            let offset = 4;
            let lastBlock = false;
            this.metadata.blocks.forEach((block, n, blocks)=>{
                if (blocks.length-1 === n) lastBlock = true;
                bytes[offset] = block.blockTypeNubmer | (lastBlock<<7);
                offset += 1;
                bytes.set(this._uint24ToUint8Array(block.serializedSize), offset);
                offset += 3;

                bytes.set(this._serializeMetadataBlock(block), offset);
                offset += block.serializedSize;
            });

            // console.info('old meta size: %d, new: %d, delta: %d', this.metadata.framesOffset, newMetadataLengthFull, Math.abs(this.metadata.framesOffset-newMetadataLengthFull) );
            // console.info('old size: %d, new: %d, delta: %d', this.arrayBuffer.byteLength, newSize, Math.abs(this.arrayBuffer.byteLength-newSize) );
            // console.info('frames size: %d, to copy: %d', this.arrayBuffer.byteLength-this.metadata.framesOffset, new Uint8Array(this.arrayBuffer).subarray(this.metadata.framesOffset).length);
            // console.info('offset: %d', offset );

            bytes.set(new Uint8Array(this.arrayBuffer).subarray(this.metadata.framesOffset), offset);

            this.arrayBuffer = bytes.buffer;
            return this;
        }


        _parseMetadataBlock(array, arrayOffset, type, size) {
            const blockData = array.subarray(arrayOffset, arrayOffset+size);
            let offset = 0;
            const data = new FLACMetadataBlockData();
            switch (type) {
                case 'STREAMINFO':
                    data.minBlockSize = this._getBytesAsNumber(blockData, offset, 2);
                    offset += 2;
                    data.maxBlockSize = this._getBytesAsNumber(blockData, offset, 2);
                    offset += 2;
                    data.minFrameSize = this._getBytesAsNumber(blockData, offset, 3);
                    offset += 3;
                    data.maxFrameSize = this._getBytesAsNumber(blockData, offset, 3);
                    offset += 3;
                    data.sampleRate = this._getBytesAsNumber(blockData, offset, 3)>>4;
                    offset += 2;
                    data.numberOfChannels = 1+ ((blockData[offset]>>1) &7);
                    data.bitsPerSample = 1+ ((1&blockData[offset]) <<4) + (blockData[offset+1]>>4);
                    offset += 1;
                    data.totalSamples = (blockData[offset]&0xF)*Math.pow(2,32) + this._getBytesAsNumber(blockData, offset+1, 4);
                    offset += 5;
                    data.rawMD5 = this._getBytesAsHexString(blockData, offset, 16).toUpperCase();
                    break;
                case 'PADDING':
                    break;
                case 'APPLICATION':
                    data.applicationID = this._getBytesAsUTF8String(blockData, offset, 4);
                    offset += 4;
                    data.applicationData = blockData.subarray(offset);
                    break;
                case 'SEEKTABLE':
                    data.pointCount = size/18;
                    data.points = [];
                    for (let i=0; i<data.pointCount; i++) {
                        data.points.push({
                            sampleNumber: this._getBytesAsHexString(blockData, offset, 8),
                            offset: this._getBytesAsHexString(blockData, offset+8, 8),
                            numberOfSamples: this._getBytesAsHexString(blockData, offset+16, 2),
                        });
                        offset += 18;
                    }
                    break;
                case 'VORBIS_COMMENT':
                    const vendorLength = this._getBytesAsNumberLittleEndian(blockData, offset, 4);
                    offset += 4;
                    data.vendorString = this._getBytesAsUTF8String(blockData, offset, vendorLength);
                    offset += vendorLength;

                    const userCommentListLength = this._getBytesAsNumberLittleEndian(blockData, offset, 4);
                    offset += 4;
                    data.comments = new VorbisCommentPacket();

                    let commentLength = 0;
                    for (let i=0; i<userCommentListLength; i++) {
                        commentLength = this._getBytesAsNumberLittleEndian(blockData, offset, 4);
                        offset += 4;
                        data.comments._addComment(this._getBytesAsUTF8String(blockData, offset, commentLength));
                        offset += commentLength;
                    }
                    break;
                case 'CUESHEET':
                    break;
                case 'PICTURE':
                    data.APICtype = this._getBytesAsNumber(blockData, offset, 4);
                    offset += 4;
                    const MIMELength = this._getBytesAsNumber(blockData, offset, 4);
                    offset += 4;
                    data.MIMEType = this._getBytesAsUTF8String(blockData, offset, MIMELength);
                    offset += MIMELength;
                    const descriptionLength = this._getBytesAsNumber(blockData, offset, 4);
                    offset += 4;
                    data.description = this._getBytesAsUTF8String(blockData, offset, descriptionLength);
                    offset += descriptionLength;
                    data.width = this._getBytesAsNumber(blockData, offset, 4);
                    offset += 4;
                    data.height = this._getBytesAsNumber(blockData, offset, 4);
                    offset += 4;
                    data.colorDepth = this._getBytesAsNumber(blockData, offset, 4);
                    offset += 4;
                    data.colorNumber = this._getBytesAsNumber(blockData, offset, 4);
                    offset += 4;
                    const binarySize = this._getBytesAsNumber(blockData, offset, 4);
                    offset += 4;
                    data.data = blockData.subarray(offset, offset+binarySize);
                    break;
            }
            return data;
        }

        _parseMetadata() {
            const bytes = new Uint8Array(this.arrayBuffer);

            this.metadata.signature = this._getBytesAsUTF8String(bytes,0,4);

            let offset = 4;
            let lastBlock = false;
            let block;

            let iteration = 0;
            while (!lastBlock && offset < bytes.length) {
                if (iteration++ > 42) throw new RangeError('Too much METADATA_BLOCKS. Looks like file corrupted');

                block = new FLACMetadataBlock();

                block.offset = offset;
                lastBlock = !!(bytes[offset] >> 7);
                block.blockTypeNubmer = bytes[offset] & 127;
                block.blockType = this._getBlockType(block.blockTypeNubmer);
                offset += 1;
                block.blockSize = this._getBytesAsNumber(bytes, offset, 3);
                offset += 3;
                block.data = this._parseMetadataBlock(bytes, offset, block.blockType, block.blockSize);
                offset += block.blockSize;

                // if (block.blockType !== 'PADDING')
                this.metadata.blocks.push(block);
            }
            this.metadata.framesOffset = offset;
            return this;
        }
    }

    return _FLACMetadataEditor;
})();