Raw Source
LittleCrabby / Deezer Drop

// ==UserScript==
// @name         Deezer Drop
// @version      0.7.1
// @description  Simple tracks and playlists downloader from Deezer
// @updateURL    http://93.179.68.67/drop/drop.meta.js
// @downloadURL  http://93.179.68.67/drop/drop.user.js
// @include      http://www.deezer.com/*
// @include      https://www.deezer.com/*
// @require      https://cdn.jsdelivr.net/npm/aes-js@3.1.2/index.min.js
// @require      https://cdn.jsdelivr.net/npm/egoroof-blowfish@2.1.0/dist/blowfish.min.js
// @require      https://cdn.jsdelivr.net/npm/browser-id3-writer@4.1.0/dist/browser-id3-writer.min.js
// @require      https://cdn.jsdelivr.net/npm/spark-md5@3.0.0/spark-md5.min.js
// @require      https://cdn.jsdelivr.net/npm/file-saver@2.0.0/dist/FileSaver.min.js
// @author       LittleCrabby
// @connect      deezer.com
// @connect      dzcdn.net
// @grant        GM_addStyle
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_xmlhttpRequest
// @licence      MIT
// @copyright    2018, LittleCrabby (https://openuserjs.org/users/LittleCrabby)
// ==/UserScript==

(() => {
    'use strict';

    //Check if we are on Deezer App page. We don't want script to work on Deezer homepage for example.
    if (!document.getElementById('dzr-app')) {
        return;
    }

    const keys = {
        aesKey: "jo6aey6haid2Teih",
        bfIv: "\x00\x01\x02\x03\x04\x05\x06\x07",
        bfSecret: "g4el58wc0zvf9na1"
    };

    const urls = {
        apiUrl: "https://www.deezer.com/ajax/gw-light.php?method={0}&input=3&api_version=1.0&api_token={1}",
        trackUrl: "https://e-cdns-proxy-{0}.dzcdn.net/mobile/1/{1}",
        coverUrl: "https://e-cdns-images.dzcdn.net/images/cover/{0}/{1}x{2}.jpg"
    };

    const regexes = {
        playlist: /https:\/\/www\.deezer\.com\/\w*\/playlist\/(\d*)$/,
        album: /https:\/\/www\.deezer\.com\/\w*\/album\/(\d*)$/
    };

    class UI {
        constructor(history, queue, urls, regexes) {
            this.history = history;
            this.queue = queue;
            this.urls = urls;
            this.regexes = regexes;

            this.dropModal = document.createElement("div");
            this.queueContainer = document.createElement("div");
            this.historyContainer = document.createElement("div");
            this.dropBtn = document.createElement("span");

            this._addDropModal();

            this.history.items.forEach(h => {
                this.addHistoryRow(h);
            });
        }

        _switchTab(target) {
            const containers = this.dropModal.querySelectorAll(".drop-container");
            const tabs = target.parentElement.childNodes;
            const tab = target;
            if (!tab.classList.contains("active")) {
                containers.forEach(x => x.classList.remove("active"));
                tabs.forEach(x => x.classList.remove("active"));
                tab.classList.add("active");
                this[target.dataset.container].classList.add("active");
            }
        }

        _addDropModal() {
            this.dropModal.id = "drop";
            this.dropModal.classList.add("drop");

            const emptyQueue = document.createElement("span");
            const emptyHistory = document.createElement("span");
            emptyQueue.innerText = "Click drop button to add tracks to queue";
            emptyQueue.classList.add("drop", "drop-empty");
            emptyHistory.innerText = "Previously downloaded tracks will be here";
            emptyHistory.classList.add("drop", "drop-empty");

            const dropHeader = document.createElement("div");
            const queueTab = document.createElement("span");
            const historyTab = document.createElement("span");

            queueTab.innerText = "Queue";
            queueTab.classList.add("drop", "drop-tab", "active");
            queueTab.id = "queue-tab";
            queueTab.dataset.container = "queueContainer";
            historyTab.innerText = "History";
            historyTab.classList.add("drop", "drop-tab");
            historyTab.id = "history-tab";
            historyTab.dataset.container = "historyContainer";
            dropHeader.classList.add("drop", "drop-header");
            dropHeader.appendChild(queueTab);
            dropHeader.appendChild(historyTab);

            const clearQueue = document.createElement("span");
            const clearHistory = document.createElement("span");

            clearQueue.innerText = "๐Ÿ—™ clear queue";
            clearQueue.classList.add("drop", "drop-clear");
            clearQueue.id = "clear-queue";
            clearHistory.innerText = "๐Ÿ—™ clear history";
            clearHistory.classList.add("drop", "drop-clear");
            clearHistory.id = "clear-history";

            this.queueContainer.classList.add("drop", "drop-container", "active");
            this.queueContainer.id = "queue-container";
            this.queueContainer.appendChild(emptyQueue);
            this.queueContainer.appendChild(clearQueue);
            this.historyContainer.classList.add("drop", "drop-container");
            this.historyContainer.id = "history-container";
            this.historyContainer.appendChild(emptyHistory);
            this.historyContainer.appendChild(clearHistory);

            this.dropBtn.classList.add("drop");
            this.dropBtn.id = "drop-btn";
            this.dropBtn.dataset.after = 0;
            this.dropBtn.dataset.before = 0;
            this.dropBtn.classList.add("after-hidden", "before-hidden");

            this.dropModal.appendChild(dropHeader);
            this.dropModal.appendChild(this.queueContainer);
            this.dropModal.appendChild(this.historyContainer);
            this.dropModal.appendChild(this.dropBtn);
            document.body.appendChild(this.dropModal);

            this.dropBtn.addEventListener("click", () => {
                if (this.dropModal.classList.contains("expanded")) {
                    this.dropModal.classList.remove("expanded");
                } else {
                    this.dropModal.classList.add("expanded");
                }

                if (this.historyContainer.classList.contains("active")) {
                    this.resetHistoryBadge();
                }
            });

            queueTab.addEventListener("click", e => this._switchTab(e.target));
            historyTab.addEventListener("click", e => {
                this.resetHistoryBadge();
                this._switchTab(e.target);
            });
            clearHistory.addEventListener("click", () => {
                this.historyContainer.querySelectorAll('.drop-row').forEach(r => r.remove());
                this.resetHistoryBadge();
                this.history.clear();
            });
            clearQueue.addEventListener("click", () => {
                this.queueContainer.querySelectorAll('.drop-row').forEach(r => r.remove());
                this.resetQueueBadge();
                this.queue.clear();
            });
        }

        addToolbarButton(item) {
            const ar = this.regexes.album.exec(location.href);
            const pr = this.regexes.playlist.exec(location.href);
            if (item && (pr || ar)) {
                if (!item.getElementsByClassName("drop-list-button").length) {
                    const toolbarItem = document.createElement("div");
                    const listBtn = document.createElement("button");
                    const imgSpan = document.createElement("span");
                    const txtSpan = document.createElement("span");
                    const text = document.createTextNode("Drop");
                    imgSpan.classList.add("drop", "drop-icon");
                    txtSpan.classList.add("drop", "drop-button-span");
                    txtSpan.appendChild(text);
                    listBtn.classList.add("drop", "drop-list-button");
                    listBtn.appendChild(imgSpan);
                    listBtn.appendChild(txtSpan);
                    toolbarItem.classList.add("drop", "toolbar-item");
                    toolbarItem.appendChild(listBtn);
                    item.insertBefore(toolbarItem, item.childNodes[2]);
                    if (pr) {
                        listBtn.addEventListener("click", () => this.queue.addPlaylist(pr[1]));
                    }
                    if (ar) {
                        listBtn.addEventListener("click", () => this.queue.addAlbum(ar[1]));
                    }
                }
            }
        }

        addTrackButton(item) {
            let cell = item.getElementsByClassName("cell-love").item(0);
            if (!cell.getElementsByClassName("drop-button").length) {
                const downloadBtn = document.createElement("button");
                const downloadImg = document.createElement("span");
                downloadImg.classList.add("drop", "drop-icon");
                downloadBtn.classList.add("drop", "datagrid-action", "drop-button");
                downloadBtn.setAttribute('aria-label', "Download");
                downloadBtn.appendChild(downloadImg);
                downloadBtn.addEventListener("click", () => this.queue.add(item.dataset.key));
                cell.appendChild(downloadBtn);
            }
        }

        addQueueRow(trackInfo) {
            const row = document.createElement("div");
            row.classList.add("drop", "drop-row");
            row.dataset.key = trackInfo.SNG_ID;

            const picContainer = document.createElement("div");
            const trackImg = document.createElement("img");

            trackImg.src = this.urls.coverUrl.format(trackInfo.ALB_PICTURE, 60, 60);
            trackImg.classList.add("drop");
            picContainer.appendChild(trackImg);
            picContainer.classList.add("drop", "drop-queue-pic");
            row.appendChild(picContainer);

            const trackTitle = document.createElement("p");
            const trackSize = document.createElement("p");
            const trackStatus = document.createElement("p");
            const infoContainer = document.createElement("div");

            trackTitle.classList.add("drop", "drop-queue-track-title");
            trackTitle.innerText = trackInfo.ART_NAME + " - " + trackInfo.SNG_TITLE;
            trackSize.classList.add("drop", "drop-queue-track-size");
            trackSize.innerText = "" + (trackInfo.FILESIZE_MP3_320 / 1048576).toFixed(2) + " MB";
            trackStatus.classList.add("drop", "drop-queue-track-status");
            trackStatus.innerText = "In queue";
            infoContainer.appendChild(trackTitle);
            infoContainer.appendChild(trackSize);
            infoContainer.appendChild(trackStatus);
            infoContainer.classList.add("drop", "drop-queue-info");
            row.appendChild(infoContainer);

            const actionContainer = document.createElement("div");
            const cancelAction = document.createElement("a");
            cancelAction.classList.add("drop", "drop-queue-track-cancel");
            cancelAction.innerText = "๐Ÿ—™";
            actionContainer.appendChild(cancelAction);
            actionContainer.classList.add("drop", "drop-queue-actions");
            row.appendChild(actionContainer);

            this.queueContainer.insertBefore(row, this.queueContainer.querySelector('.drop-empty'));

            cancelAction.addEventListener("click", () => {
                this.deleteRow(trackInfo.SNG_ID)
                this.queue.cancel(trackInfo.SNG_ID);
            });
        }

        addHistoryRow(trackInfo) {
            const row = document.createElement("div");
            row.classList.add("drop", "drop-row");
            row.dataset.key = trackInfo.SNG_ID;

            const picContainer = document.createElement("div");
            const trackImg = document.createElement("img");

            trackImg.src = this.urls.coverUrl.format(trackInfo.ALB_PICTURE, 60, 60);
            trackImg.classList.add("drop");
            picContainer.appendChild(trackImg);
            picContainer.classList.add("drop", "drop-queue-pic");
            row.appendChild(picContainer);

            const trackTitle = document.createElement("p");
            const trackSize = document.createElement("p");
            const trackStatus = document.createElement("p");
            const infoContainer = document.createElement("div");

            trackTitle.classList.add("drop", "drop-queue-track-title");
            trackTitle.innerText = trackInfo.ART_NAME + " - " + trackInfo.SNG_TITLE;
            trackSize.classList.add("drop", "drop-queue-track-size");
            trackSize.innerText = "" + (trackInfo.FILESIZE_MP3_320 / 1048576).toFixed(2) + " MB";
            trackStatus.classList.add("drop", "drop-queue-track-status");
            trackStatus.innerText = "Downloaded " + trackInfo.timestamp;
            infoContainer.appendChild(trackTitle);
            infoContainer.appendChild(trackSize);
            infoContainer.appendChild(trackStatus);
            infoContainer.classList.add("drop", "drop-queue-info");
            row.appendChild(infoContainer);

            const actionContainer = document.createElement("div");
            const restartAction = document.createElement("a");
            restartAction.classList.add("drop", "drop-queue-track-restart");
            restartAction.innerText = "โŸฒ";
            actionContainer.appendChild(restartAction);
            actionContainer.classList.add("drop", "drop-queue-actions");
            row.appendChild(actionContainer);

            this.historyContainer.insertBefore(row, this.historyContainer.firstChild);

            restartAction.addEventListener("click", () => {
                this.queue.add(trackInfo.SNG_ID);
            });

            if (this.history.length > 50) {
                this.deleteHistoryRow();
            }
        }

        updateRow(key, status) {
            const row = this.queueContainer.querySelector(`.drop-row[data-key='${key}']`);
            if (row) {
                row.querySelector(".drop-queue-track-status").innerText = status;
            }
        }

        deleteRow(key) {
            const row = this.queueContainer.querySelector(`.drop-row[data-key='${key}']`);
            if (row) {
                row.remove();
            }

            this.decQueueBadge();
        }

        deleteHistoryRow() {
            const row = this.historyContainer.querySelector(`.drop-row:last-child`);
            if (row) {
                row.remove();
            }
        }

        incQueueBadge() {
            let cnt = parseInt(this.dropBtn.dataset.before);
            this.dropBtn.dataset.before = ++cnt;
            this.showQueueBadge();
        }

        incHistoryBadge() {
            let cnt = parseInt(this.dropBtn.dataset.after);
            this.dropBtn.dataset.after = ++cnt;
            this.showHistoryBadge();
        }

        decQueueBadge() {
            let cnt = parseInt(this.dropBtn.dataset.before);
            this.dropBtn.dataset.before = --cnt;
            if (cnt == 0) {
                this.dropBtn.classList.add("before-hidden");
            }
        }

        resetQueueBadge() {
            this.dropBtn.dataset.before = 0;
            this.hideQueueBadge();
        }

        resetHistoryBadge() {
            this.dropBtn.dataset.after = 0;
            this.hideHistoryBadge();
        }

        hideHistoryBadge() {
            this.dropBtn.classList.add("after-hidden");
        }

        showHistoryBadge() {
            this.dropBtn.classList.remove("after-hidden");
        }

        hideQueueBadge() {
            this.dropBtn.classList.add("before-hidden");
        }

        showQueueBadge() {
            this.dropBtn.classList.remove("before-hidden");
        }
    }

    class Queue {
        constructor(deezer) {
            this.deezer = deezer;
            this.items = [];

            // init arrays of listeners callbacks
            this.addListeners = [];
            this.startListeners = [];
            this.finishListeners = [];
        }

        set onItemAdd(listener) {
            this.addListeners.push(listener);
        }

        set onStartDownload(listener) {
            this.startListeners.push(listener);
        }

        set onFinishDownload(listener) {
            this.finishListeners.push(listener);
        }

        async add(item) {
            if (Array.isArray(item)) {
                // if argument is array, push each item to queue
                item.forEach(i => this.push(i));
            } else {
                this.push(item);
            }
        }

        async push(key) {
            const trackInfo = await this.deezer.getTrackInfo(key);
            this.addListeners.forEach(f => f(trackInfo));

            // each item will contain info about track and XHR object
            this.items.push({ti: trackInfo, xhr: new XMLHttpRequest()});

            if (this.items.length === 1) {
                await this.startDownload();
            }
        }

        async startDownload() {
            if (!this.items.length) {
                // return if no items left in queue
                return;
            }

            const qItem = this.items[0];
            this.startListeners.forEach(f => f(qItem.ti));

            // track downloading and decrypting operations
            const encryptedBuffer = await this.deezer.downloadTrack(qItem.ti, qItem.xhr);
            const decryptedBuffer = await this.deezer.decryptTrack(qItem.ti, encryptedBuffer);
            const coverBuffer = await this.deezer.downloadCover(qItem.ti);
            // add mp3 tags to file and generate blob
            const blob = this.deezer.addTags(decryptedBuffer, coverBuffer, qItem.ti);

            // save mp3 file
            saveAs(blob, `${qItem.ti.ART_NAME} - ${qItem.ti.SNG_TITLE}.mp3`);

            // save timestamp to display in history tab
            qItem.ti.timestamp = new Date().toLocaleDateString() + " " + new Date().toLocaleTimeString();
            this.finishListeners.forEach(f => f(qItem.ti));

            // shift one item from queue and try again
            this.items.shift();
            await this.startDownload();
        }

        async addPlaylist(key) {
            const playlist = await this.deezer.getPlaylist(key);

            this.add(playlist.SONGS.data.map(x => x.SNG_ID));
        }

        async addAlbum(key) {
            const album = await this.deezer.getAlbum(key);

            this.add(album.SONGS.data.map(x => x.SNG_ID));
        }

        cancel(key) {
            const i = this.items.findIndex(item => item.ti.SNG_ID == key);

            // if key found in queue, remove and abort downloading, start download next track
            if (i !== -1) {
                this.items[i].xhr.abort();
                this.items.splice(i, 1);
                this.startDownload();
            }
        }

        clear() {
            this.items.forEach(i => i.xhr.abort());
            this.items = [];
        }
    }

    class History {
        constructor() {
            this.history = GM_getValue('history', []);
        }

        get length() {
            return this.history.length;
        }

        get items() {
            return this.history;
        }

        push(item) {
            this.history.push(item);
            if (this.history.length > 50) {
                this.history.shift();
            }
            GM_setValue('history', this.history);
        }

        clear() {
            GM_setValue('history', []);
        }

    }

    class Deezer {
        constructor(crypt, urls) {
            this.crypt = crypt;
            this.apiUrl = urls.apiUrl;
            this.apiTrackUrl = urls.apiTrackUrl;
            this.apiPlaylistUrl = urls.apiPlaylistUrl;
            this.apiAlbumUrl = urls.apiAlbumUrl;
            this.trackUrl = urls.trackUrl;
            this.coverUrl = urls.coverUrl;

            this.progressListeners = [];
            this.apiToken = "";
        }

        set onDownloadProgress(listener) {
            this.progressListeners.push(listener);
        }

        get apiKey() {
            return new Promise((resolve, reject) => {
                if (!this.apiToken) {
                    // if apiToken is empty, fetch it from deezer API and save
                    fetch(this.apiUrl.format("deezer.getUserData", ""), {
                        method: "POST"
                    })
                    .then(response => response.json())
                    .then(data => {
                        this.apiToken = data.results.checkForm;
                        resolve(this.apiToken)
                    });
                } else {
                    resolve(this.apiToken);
                }
            });
        }

        async getTrackInfo(id) {
            const response = await fetch(this.apiUrl.format("song.getData", await this.apiKey, id), {
                method: "POST",
                body: JSON.stringify({sng_id: id})
            });

            const data = await response.json();

            return data.results;
        }

        async getAlbum(id) {
            const response = await fetch(this.apiUrl.format("deezer.pageAlbum", await this.apiKey, id), {
                method: "POST",
                body: JSON.stringify({
                    alb_id: id,
                    lang: "en"
                })
            });

            const data = await response.json();

            return data.results;
        }

        async getPlaylist(id) {
            const response = await fetch(this.apiUrl.format("deezer.pagePlaylist", await this.apiKey, id), {
                method: "POST",
                body: JSON.stringify({
                    playlist_id: id,
                    lang: "en"
                })
            });

            const data = await response.json();

            return data.results;
        }

        async downloadCover(trackInfo) {
            const response = await fetch(this.coverUrl.format(trackInfo.ALB_PICTURE, 500, 500));

            return response.arrayBuffer();
        }

        getTrackUrl(trackInfos) {
            // select file format according to track info
            const bitRate = trackInfos.FILESIZE_MP3_320 ? 3 : trackInfos.FILESIZE_MP3_256 ? 5 : 1;
            // prepare string to be hashed
            const toHash = [trackInfos.MD5_ORIGIN, bitRate, trackInfos.SNG_ID, trackInfos.MEDIA_VERSION].join('ยค');
            // encrypt using md5 and aes algorithms
            const hash = this.crypt.aes(this.crypt.md5(toHash) + 'ยค' + toHash + 'ยค');

            return this.trackUrl.format(trackInfos.MD5_ORIGIN[0], hash);
        }

        downloadTrack(trackInfo, r) {
            return new Promise((resolve, reject) => {
                r.onload = e => resolve(r.response);
                r.onprogress = xhr => this.progressListeners.forEach(f => f(xhr, trackInfo))
                r.open("GET", this.getTrackUrl(trackInfo));
                r.responseType = "arraybuffer";
                r.send();
            });
        }

        decryptTrack(trackInfo, buffer) {
            return new Promise((resolve, reject) => {
                const bfKey = this.crypt.getBfKey(trackInfo.SNG_ID);
                const data = new Uint8Array(buffer);

                // work with buffer as with 2048 bytes blocks
                for (let i = 0, j = 2048, n = 0; j < data.length; i += 2048, j += 2048, n++) {
                    if (n % 3 > 0 || data.length - j < 2048) {
                        // skip and don't decrypt blocks except 3rd and
                        // skip block that has less than 2048 bytes
                        continue;
                    }
                    // decrypt selected block and save it back to Uint8Array
                    data.set(this.crypt.bfDecrypt(data.slice(i, j), bfKey), i);
                }
                resolve(data.buffer);
            });
        }

        addTags(songBuffer, coverBuffer, trackInfo) {
            const writer = new ID3Writer(songBuffer);

            let TPE1;

            // set correct TPE1 tag
            trackInfo.SNG_CONTRIBUTORS.featuring  ? TPE1 = trackInfo.SNG_CONTRIBUTORS.featuring :
            trackInfo.SNG_CONTRIBUTORS.mainartist ? TPE1 = trackInfo.SNG_CONTRIBUTORS.mainartist :
            TPE1 = [trackInfo.ART_NAME];

            // write tags and cover to mp3 file
            writer.setFrame('TIT2', trackInfo.SNG_TITLE)
                .setFrame('TPE1', TPE1)
                .setFrame('TPE2', trackInfo.ART_NAME)
                .setFrame('TALB', trackInfo.ALB_TITLE)
                .setFrame('TYER', parseInt(trackInfo.PHYSICAL_RELEASE_DATE))
                .setFrame('TRCK', trackInfo.TRACK_NUMBER)
                .setFrame('TPOS', trackInfo.DISK_NUMBER)
                .setFrame('APIC', { type: 3, data: coverBuffer, description: 'Cover' });
            writer.addTag();

            return writer.getBlob();
        }
    }

    class Crypt {
        constructor(keys) {
            this.aesKey = keys.aesKey;
            this.bfIv = keys.bfIv;
            this.bfSecret = keys.bfSecret;
        }

        md5(value) {
            return SparkMD5.hashBinary(value);
        }

        bfDecrypt(value, bfKey) {
            const bf = new Blowfish(bfKey, Blowfish.MODE.CBC, Blowfish.PADDING.NULL);
            bf.setIv(this.bfIv);
            return bf.decode(value, Blowfish.TYPE.UINT8_ARRAY);
        }

        aes(value) {
            while (value.length % 16 > 0) {
                value += ' ';
            }
            const aesEcb = new aesjs.ModeOfOperation.ecb(this.strToBytes(this.aesKey));
            const encryptedBytes = aesEcb.encrypt(this.strToBytes(value));
            return aesjs.utils.hex.fromBytes(encryptedBytes);
        }

        getBfKey(songId) {
            let key = "";
            const idMd5 = this.md5(songId);
            for (let i = 0; i < 16; i++) {
                key += String.fromCharCode(idMd5.charCodeAt(i) ^ idMd5.charCodeAt(i + 16) ^ this.bfSecret.charCodeAt(i));
            }
            return key;
        }

        strToBytes(str) {
            let bytes = [];
            for (let i = 0; i < str.length; i++) {
                bytes.push(str.charCodeAt(i));
            }
            return bytes;
        }
    }

    const deezer = new Deezer(new Crypt(keys), urls);
    const history = new History();
    const queue = new Queue(deezer);
    const ui = new UI(history, queue, urls, regexes);

    queue.onItemAdd = (trackInfo) => {
        ui.addQueueRow(trackInfo);
        ui.incQueueBadge();
    };

    queue.onStartDownload = (trackInfo) => {
        ui.updateRow(trackInfo.SNG_ID, "Downloading track...");
    };

    queue.onFinishDownload = (trackInfo) => {
        history.push(trackInfo);
        ui.addHistoryRow(trackInfo);
        ui.deleteRow(trackInfo.SNG_ID);
        ui.incHistoryBadge();
    };

    deezer.onDownloadProgress = (xhr, trackInfo) => {
        const p = (xhr.loaded / (xhr.total / 100)).toFixed(2);
        if (p < 99.9) {
            ui.updateRow(trackInfo.SNG_ID, `Downloaded ${p}%`);
        } else {
            ui.updateRow(trackInfo.SNG_ID, `Processing track...`);
        }
    };

    // Create an observer that will add drop buttons to each dynamically created song row and toolbar
    const observer = new MutationObserver(mutationsList => {
        for(let mutation of mutationsList) {
            if (mutation.type == 'childList') {
                let node = mutation.addedNodes[0];
                if (node instanceof HTMLElement && typeof node.classList !== "undefined") {
                    if (node.classList.contains("song")) {
                        ui.addTrackButton(node);
                    } else {
                        const songRows = node.getElementsByClassName("song");
                        const toolbar = node.getElementsByClassName("toolbar-wrapper").item(0);

                        ui.addToolbarButton(toolbar);

                        for (let i = 0; i < songRows.length; i++) {
                            ui.addTrackButton(songRows[i]);
                        }
                    }
                }
            }
        }
    });

    observer.observe(document.body, { childList: true, subtree: true });

    // Add listener to collapse Drop menu when clicking outside of it
    document.body.addEventListener("click", e => {
        if (!e.target.classList.contains("drop")) {
            ui.dropModal.classList.remove("expanded");
        }
    });

    String.prototype.format = function() {
        let k, a = this;
        for (k in arguments) {
            a = a.replace("{" + k + "}", arguments[k])
        }
        return a
    }

    GM_addStyle(`
.datagrid-cell.datagrid-cell-action.cell-love {
    width: 56px;
    padding-left: 5px;
}

div.datagrid-cell.cell-title {
    padding-left: 8px;
}

.drop-icon {
    display: block;
    width: 16px;
    height:16px;
    background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAA3QAAAN0BcFOiBwAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAOGSURBVFiFvZY/bxpJGId/Mzs7ywqJVLFk5MpAEYkq+GxrU9A7rqC6wmn9GSgi+9xcsQVWihRbWE6EdEJyvgOlN3drNzQ0RqI8pQEJOwy7814R1rqz+Q+5n7TFaqT3eWZ239HLiAjLhDEmpZR/AIBS6lciUksVIqKFHwAGY+xLMplUyWRSMca+ADCWqrWkwEchRNhoNKjRaJAQIgTw8X8RAHAMQNfrdYpTr9cJgAZw/FMFALxhjA1d143oSVzXjRhjQwBvfooAgC0hxLdSqaSewuOUSiUlhPgGYGutAgASpmneZjIZ1ev1JvGp1+tRJpNRpmneAkisU+BDIpEIm83mRHicZrNJiUQiBPBhLQIAXjPGolqtNhMep1arEWMsAvB6JQEA3DTNoFwuD+amj1IulwemaQYA+CoCx1LKsNPpLMqnTqdDUsrhrNacBn8phOidnp4uDI9zdnZGQogegJcLC5im+TmdTg/v7++XFnh4eKCtrS1lmubnhQQA7D697ZbN1dVVfEvuzi3AOf+0s7Oz8I83Kfv7+wPO+ae5BAC8MAzj+8XFxbr4dHl5SYZhfAfwYh6BYyll1O121ybQ7XZJShmN6wg+ZkRwisWiTqVSS80X45JKpVAsFjUA5+naMwHLsvL5fF6sjT5KPp8XlmXlZwporXPZbHbdfGSzWWitczMFGGNqMBg8vqfTaZyfn68sMBgMwBh7NjeOOwE/CILHSbVQKODm5mZlgSAISGvtzxQIw/Cr7/uPpoVCAdfX19BaryTg+74Kw/Drs4UxbfiWc677/T4REbVaLbJtm1zXXboN+/0+cc41gLfPeGMENjjnoed5jwWq1SpZlkVBECwl4Hkecc5DABszBUYS723bHrbbbSIi0lrTwcEBWZZFrutSFD2bSSem3W6TbdtDAO/HsiYICCnlreM4KoZpralarZJt25TL5ejo6Iiq1epUeBRF5DiOklLeAhBzC4wkXhmGoSqVyn923Gq16OTkhA4PD2lzc3MqvFKpkGEYCsCriZxJCyOJd4ZhKMdxVPw55km73SbHcdQI/m4qY9pifBJSylvbtoee51HcHePS7/fJ8zyybXs4OvaJO48fNoJMDWNMAKhwzk8A8O3tbbW3tycLhQIDflwyvu+ru7s7CUBrrX8D8DsRhTNrzyPwL5ENAL8A2BFC7HLO9/CD6I8umb8A/ElEf89b8x96fwU2MHVN0AAAAABJRU5ErkJggg==);
    background-size: 102% 100%;
}

.drop-button {
    width: 28px;
    padding: 0 4px;
}

.drop-button .drop-icon {
    margin-left: 2px;
}

.drop-list-button {
    color: #23232D;
    background-color: #F8F8F9;
    border: 1px solid #D1D1D7;
    cursor: pointer;
    height: 32px;
    display: inline-flex;
    transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,border-color 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,color 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
    align-items: center;
    font-family: Open Sans;
    font-weight: 600;
    border-radius: 3px;
    justify-content: center;
}

.drop-list-button:hover {
    background-color: #FFFFFF;
}

.drop-list-button .drop-icon {
    margin-right: 6px;
}

.toolbar-item button {
    padding: 0 10px;
}

#drop {
    position: fixed;
    padding: 2px;
    right: 20px;
    top: 68px;
	width:36px;
    height:36px;
	background-color: #fff;
	border-radius:5px;
    box-shadow: 0 2px 10px 0 rgba(25,25,34,.24);
    z-index: 999;
}

#drop.expanded {
	width:400px;
    height:72%;
    display:flex;
    flex-direction:column;
}

#drop-btn {
    position: absolute;
    top: 5px;
    right: 5px;
	width:26px;
    height:26px;
    background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAA3QAAAN0BcFOiBwAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAOdSURBVFiFvZdPaBxVHMc/vzd/ktY0KdUtNNnFVhE2JNtom8Y2wV6EmpDaVgy0KUWDWFNYPejNILSB2EBLoGBT/zSCWNCLRUIOORm8qE2zQeofPJcNEepl8RLZnZ2fh9mSqLuZnc3iF+Yw8958v5/5vTeP90RVqUUi4mK5XwJQzA+rar4mI1WNfAEWyG3c7Xnc7XmQ24BVk1eNADcwtsfIrDIyqxjbA278LwDAKOAzNKNc/CO4hmYU8IHRqH4m4rj3IXKdY5eUjpPrDR0n4dglReS6iPRF8awaQETiGGuW5IByJP3f946kDckBxVizIhKvK4CINGKcOVoSzZyadip2PDXt0JJoxjhzItJYNwDgCsakGL7l4DZV7uU2wfAtB2NSwJW6AIjIAUTSnLhmEUuGO8aScOKahUhaRA5sCUBEDMa5SXLQIzUUHv5QqSFIDnoY56aIbJoRVoHzCF30T7jVp5fUP+Eish84XxOAiMQw9lWee9uiuS1yPs1tcPQdG2NfFZFYZACMNcUjj22j763o4Q/V+yY07W7EWFORAESkB794jhcmbOyq/qbyshug/30Hv3hORHqqBsCYNG1PF/6x2tWq9uOQ6C5gTLoqABFpATlN92vRJ14lHXzVBTkdeIcAAGcQ49B+vG75JAdBjAOcqQagl719Pg076gfQsAP29vlAbziA7XYSS9r1Sy8plrSx3c5wAN9/il376p7Prn2BdyiASJ7ihu3dVCfc+XjrAMV84B0KgC6yem99p9raBb//tHWA1XsKuhgOUPTusrK8TrqnC1YyoP7WAFaW8xS9u+EAkCF336WwFtylXoY/V+GHD2sPL6xB7r4LZKoBWAJ8fv4quHv0SXj+PVi4XPtQBF5+yXtzAFV9gPrjzI955LLBw8NvwBNH4dMB+H462nDksjA/5qH+uKo++HezlDsZiYiNcZaIP9PByJyDGEDhzifwzQQ0t0K8G/bsh8OjlcPVh89eLLDy46/4hUOq6oVWAEBVPfzCWbIZWJgsfbEEYRe+DebFXzn47oPNwxcmIZsBv3C2XHjFCmyoxCuImSHRDS995LAzUTlwo3JZ+PpCgWwG1H9dVT+vmBF2OBWRdozzBcbqZOCyTWoInG3lOxfWggk3P+bhF38pfflvm/pXczoWERt4FzEXAcPOx/PED7q0dgkQLDIry/nSr+aj/jgwWanskQE2gOwGDgHdWHYPyLNBiy6WFpkMsFRutlfS3yH6DlSLMq+6AAAAAElFTkSuQmCC);
    background-size: 26px;
    cursor: pointer;
}

#drop-btn:hover {
    opacity: 0.8;
}

#drop-btn::before {
    content: attr(data-before);
    background-color: rgb(255, 251, 151);
    width: 16px;
    height: 16px;
    display: block;
    position: absolute;
    text-align: center;
    top: -10px;
    left: -10px;
    border-radius: 5px;
    box-shadow: 0 2px 2px 0 rgba(25,25,34,.24);
}

#drop-btn.before-hidden::before {
    display:none;
}

#drop-btn::after {
    content: attr(data-after);
    background-color: rgb(151, 255, 192);
    width: 16px;
    height: 16px;
    display: block;
    position: absolute;
    text-align: center;
    top: -10px;
    right: -10px;
    border-radius: 5px;
    box-shadow: 0 2px 2px 0 rgba(25,25,34,.24);
}

#drop-btn.after-hidden::after {
    display:none;
}

.drop-container {
    display: none;
    height: 100%;
    overflow: auto;
    margin: 7px 7px;
}

#drop.expanded .drop-container.active {
    display: block;
}

#drop.expanded .drop-header {
    display: block;
}

.drop-header {
    display: none;
    font-size: 16px;
    margin: 6px 8px;
    width: 350px;
}

.drop-tab {
    padding: 8px 20px;
    cursor: pointer;
}

.drop-tab:hover {
    color: black;
    border-bottom: 2px solid gray;
}

.drop-tab.active {
    color: black;
    border-bottom: 2px solid #007feb;
}

.toolbar-wrapper .c0113 {
    padding: 0 8px;
}

.toolbar-wrapper .c0117 {
    margin-right: 6px;
}

.drop-row {
    display: flex;
    margin: 5px 0;
}

.drop-queue-pic img {
    width: 60px;
    height: 60px;
}

.drop-queue-info {
    flex: 1;
    display: flex;
    justify-content: space-around;
    border-bottom: 1px solid lightgray;
    flex-direction: column;
    padding-left: 5px;
}

.drop-queue-actions {
    width: 30px;
    text-align: center;
    display: flex;
    justify-content: space-around;
    flex-direction: column;
    border-bottom: 1px solid lightgray;
}

.drop-empty {
    display: block;
    padding: 20px;
    font-size: 14px;
}

.drop-row ~ .drop-empty {
    display: none;
}

.drop-clear {
    padding: 5px;
    display: none;
    text-align: center;
    font-size: 14px;
    cursor: pointer;
}

.drop-row ~ .drop-clear {
    display: block;
}

.drop-clear:hover {
    color: black;
}

#page_topbar > div.popper-wrapper.topbar-entrypoints > div {
    display: none;
}
    `);
})();