Greasy Fork

Nexus Download Collection

Download every mods of a collection in a single click

当前为 2024-04-21 提交的版本,查看 最新版本

// ==UserScript==
// @name         Nexus Download Collection
// @namespace    NDC
// @version      0.7.2
// @description  Download every mods of a collection in a single click
// @author       Drigtime
// @match        https://next.nexusmods.com/*/collections*
// @icon         data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==
// @grant        GM.xmlHttpRequest
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM.setValue
// @grant        GM.getValue
// @connect      nexusmods.com
// ==/UserScript==

// MDI : https://pictogrammers.com/library/mdi/
// MDI : https://github.com/MathewSachin/Captura/blob/master/src/Captura.Core/MaterialDesignIcons.cs

/** CORSViaGM BEGINING */

const CORSViaGM = document.body.appendChild(Object.assign(document.createElement('div'), { id: 'CORSViaGM' }))

addEventListener('fetchViaGM', e => GM_fetch(e.detail.forwardingFetch))

CORSViaGM.init = function (window) {
    if (!window) throw 'The `window` parameter must be passed in!'
    window.fetchViaGM = fetchViaGM.bind(window)

    // Support for service worker
    window.forwardingFetch = new BroadcastChannel('forwardingFetch')
    window.forwardingFetch.onmessage = async e => {
        const req = e.data
        const { url } = req
        const res = await fetchViaGM(url, req)
        const response = await res.blob()
        window.forwardingFetch.postMessage({ type: 'fetchResponse', url, response })
    }

    window._CORSViaGM && window._CORSViaGM.inited.done()

    const info = '🙉 CORS-via-GM initiated!'
    console.info(info)
    return info
}

function GM_fetch(p) {
    GM_xmlhttpRequest({
        ...p.init,
        url: p.url, method: p.init.method || 'GET',
        onload: responseDetails => p.res(new Response(responseDetails.response, responseDetails))
    })
}

function fetchViaGM(url, init) {
    let _r
    const p = new Promise(r => _r = r)
    p.res = _r
    p.url = url
    p.init = init || {}
    dispatchEvent(new CustomEvent('fetchViaGM', { detail: { forwardingFetch: p } }))
    return p
}

CORSViaGM.init(window);

/** CORSViaGM END */

class NDC {
    pauseBetweenDownload = 5;
    thisforceStop = false;
    mods = {
        all: [],
        mandatory: [],
        optional: []
    };

    constructor(gameId, collectionId) {
        this.element = document.createElement('div');

        this.gameId = gameId;
        this.collectionId = collectionId;

        this.downloadButton = new NDCDownloadButton(this);
        this.progressBar = new NDCProgressBar(this);
        this.console = new NDCLogConsole(this);
    }

    async init() {
        this.pauseBetweenDownload = await GM.getValue('pauseBetweenDownload', 5);

        this.element.innerHTML = `
        <button class="w-full font-montserrat font-semibold text-sm leading-none tracking-wider uppercase flex gap-x-2 justify-center items-center transition-colors relative min-h-9 focus:outline focus:outline-2 focus:outline-accent focus:outline-offset-2 px-2 py-1 cursor-pointer bg-primary-moderate fill-font-primary text-font-primary border-transparent focus:bg-primary-strong hover:bg-primary-subdued rounded">
            Fetching mods list...
        </button>`;

        const response = await this.fetchMods();

        if (!response) {
            this.element.innerHTML = '<div class="w-full font-montserrat font-semibold text-sm leading-none tracking-wider uppercase flex gap-x-2 justify-center items-center transition-colors relative min-h-9 focus:outline focus:outline-2 focus:outline-accent focus:outline-offset-2 px-2 py-1 cursor-pointer bg-primary-moderate fill-font-primary text-font-primary border-transparent focus:bg-primary-strong hover:bg-primary-subdued rounded">Failed to fetch mods list</div>';
            return;
        }

        const mods = response.modFiles.sort((a, b) => a.file.mod.name.localeCompare(b.file.mod.name));
        this.mods = {
            all: mods,
            mandatory: mods.filter(mod => !mod.optional),
            optional: mods.filter(mod => mod.optional)
        }

        this.downloadButton.render();
        this.downloadButton.allBtn.addEventListener('click', () => this.downloadMods(this.mods.all, "all"));
        this.downloadButton.mandatoryBtn.addEventListener('click', () => this.downloadMods(this.mods.mandatory, "mandatory"));
        this.downloadButton.optionalBtn.addEventListener('click', () => this.downloadMods(this.mods.optional, "optional"));

        this.element.innerHTML = '';
        this.element.appendChild(this.downloadButton.element);
        this.element.appendChild(this.progressBar.element);
        this.element.appendChild(this.console.element);
    }

    async fetchMods() {
        const response = await fetch("https://next.nexusmods.com/api/graphql", {
            "headers": {
                "content-type": "application/json",
            },
            "referrer": `https://next.nexusmods.com/${this.gameId}/collections/${this.collectionId}?tab=mods`,
            "referrerPolicy": "strict-origin-when-cross-origin",
            "body": JSON.stringify({
                "query": "query CollectionRevisionMods ($revision: Int, $slug: String!, $viewAdultContent: Boolean) { collectionRevision (revision: $revision, slug: $slug, viewAdultContent: $viewAdultContent) { externalResources { id, name, resourceType, resourceUrl }, modFiles { fileId, optional, file { fileId, name, scanned, size, sizeInBytes, version, mod { adult, author, category, modId, name, pictureUrl, summary, version, game { domainName }, uploader { avatar, memberId, name } } } } } }",
                "variables": { "slug": this.collectionId, "viewAdultContent": true },
                "operationName": "CollectionRevisionMods"
            }),
            "method": "POST",
            "mode": "cors",
            "credentials": "include"
        });

        if (!response.ok) {
            return;
        }

        const json = await response.json();

        if (!json.data.collectionRevision) {
            return;
        }

        json.data.collectionRevision.modFiles = json.data.collectionRevision.modFiles.map(modFile => {
            modFile.file.url = `https://www.nexusmods.com/${this.gameId}/mods/${modFile.file.mod.modId}?tab=files&file_id=${modFile.file.fileId}`;
            return modFile;
        });

        return json.data.collectionRevision;
    }

    async fetchSlowDownloadLink(mod) {
        const url = `${mod.file.url}&nmm=1`;

        const response = await fetchViaGM(url, {
            headers: {
                accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
                "cache-control": "max-age=0",
            },
            referrer: url,
            referrerPolicy: "strict-origin-when-cross-origin",
            method: "GET",
            mode: "cors",
            credentials: "include"
        });

        const text = await response.text();

        if (!response.ok) return { downloadUrl: '', text };

        const downloadUrlMatch = text.match(/id="slowDownloadButton".*?data-download-url="([^"]+)"/);
        const downloadUrl = downloadUrlMatch ? downloadUrlMatch[1] : '';

        return { downloadUrl, text };
    }

    async addModToVortex(mod) {
        // const {downloadUrl, text} = await new Promise(resolve => setTimeout(() => resolve({downloadUrl: 'debug', text: 'debug'}), 1000));
        // const {downloadUrl, text} = await new Promise(resolve => setTimeout(() => resolve({downloadUrl: '', text: 'debug'}), 1000));
        const { downloadUrl, text } = await this.fetchSlowDownloadLink(mod);

        if (downloadUrl === '') {
            // make link to copy in the clipboard the response

            const logRow = this.console.log(`Failed to get download link for
            <a href="${mod.file.url}" target="_blank" class="text-primary-moderate">${mod.file.name}</a>
            <button class="text-primary-moderate" title="Copy response to clipboard">
                <svg class="w-4 h-4 fill-current" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" role="presentation" style="width: 1rem; height: 1rem;">
                    <path d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z" style="fill: currentcolor;"></path>
                </svg>
            </button>`, NDCLogConsole.TYPE_ERROR);
            logRow.querySelector('button').addEventListener('click', () => {
                navigator.clipboard.writeText(text);
                alert('Response copied to clipboard');
            });

            // check if find .replaced-login-link in the html it is because the user is not connect on nexusmods
            if (text.match(/class="replaced-login-link"/)) {
                this.console.log('You are not connected on NexusMods. <a href="https://users.nexusmods.com/auth/continue?client_id=nexus&redirect_uri=https://www.nexusmods.com/oauth/callback&response_type=code&referrer=https%3A%2F%2Fwww.nexusmods.com%2F" target="_blank" class="text-primary-moderate">Login</a> and try again.', NDCLogConsole.TYPE_ERROR);
                this.forceStop = true;
            }

            if (text.match(/Just a moment.../)) {
                this.console.log(`You are rate limited by Cloudflare. Click on the link to solve the captcha and try again. <a href="${mod.file.url}" target="_blank" class="text-primary-moderate">Solve captcha</a>`, NDCLogConsole.TYPE_ERROR);
                this.forceStop = true;
            }

            return false;
        }

        document.location.href = downloadUrl;
        return true;
    }

    // function to avoid repeating the same code this.downloadButtonContainer.hide(); this.progressBarContainer.setModsCount(this.downloadButtonContainer.modsCount); this.progressBarContainer.show();

    async downloadMods(mods, type = "all") {
        const history = await GM.getValue('history', {}); // {"gameId": {"collectionId": {"type": []}}}

        this.startDownload(mods.length);

        // get history for this collection (index is the collectionId)
        let historyForThisCollection = history[this.gameId]?.[this.collectionId]?.[type] || [];
        history[this.gameId] ??= {};
        history[this.gameId][this.collectionId] ??= {};
        history[this.gameId][this.collectionId][type] ??= [];

        if (historyForThisCollection?.length) {
            const confirm = await new Promise(resolve => {
                resolve(window.confirm(`You already downloaded ${historyForThisCollection.length} out of ${mods.length} mods from this collection.\nDo you want to resume the download?\nCancel will clear the history and download all mods again.`));
            });

            if (!confirm) {
                historyForThisCollection = [];
                history[this.gameId][this.collectionId][type] = [];
                await GM.setValue('history', history);
            }
        }

        const failedDownload = [];

        for (const [index, mod] of mods.entries()) {
            if (historyForThisCollection.includes(mod.fileId)) {
                this.console.log(`[${index + 1}/${mods.length}] Already downloaded <a href="${mod.file.url}" target="_blank" class="text-primary-moderate">${mod.file.name}</a>`);
                this.progressBar.incrementProgress();
                continue;
            }

            if (this.progressBar.skipToIndex) {
                if ((this.progressBar.skipIndex - 1) > index) {
                    this.console.log(`[${index + 1}/${mods.length}] Skipping <a href="${mod.file.url}" target="_blank" class="text-primary-moderate">${mod.file.name}</a>`);
                    this.progressBar.incrementProgress();
                    if ((this.progressBar.skipIndex - 1) === (index + 1)) { // if skip to index is the next index
                        this.progressBar.skipToIndex = false;
                    }
                    continue;
                } else {
                    this.progressBar.skipToIndex = false;
                }
            }

            const status = await this.addModToVortex(mod);
            this.progressBar.incrementProgress();

            if (this.forceStop) {
                this.console.log('Download stopped.');
                break;
            }

            if (!status) {
                failedDownload.push(mod);
                await this.waitingVortexDownload(mod, mods.length, null, false);

                if (this.progressBar.status === NDCProgressBar.STATUS_STOPPED) {
                    this.console.log('Download stopped.');
                    break;
                }

                continue;
            }

            history[this.gameId][this.collectionId][type] = [...new Set([...history[this.gameId][this.collectionId][type], mod.fileId])]; // remove duplicate and update history
            await GM.setValue('history', history);

            await this.waitingVortexDownload(mod, mods.length, index);

            if (this.progressBar.status === NDCProgressBar.STATUS_STOPPED) {
                this.console.log('Download stopped.');
                break;
            }

            // if all mods are downloaded clear the history
            if (this.progressBar.progress === this.progressBar.modsCount) {
                history[this.gameId][this.collectionId][type] = [];
                await GM.setValue('history', history);
            }
        }

        if (failedDownload.length) {
            this.console.log(`Failed to download ${failedDownload.length} mods:`, NDCLogConsole.TYPE_ERROR);
            for (const mod of failedDownload) {
                this.console.log(`<a href="${mod.file.url}" target="_blank" class="text-primary-moderate">${mod.file.name}</a>`);
            }
        }

        this.endDownload();
    }

    async waitingVortexDownload(mod, modsCount, index = null, success = true) {
        return new Promise(async resolve => {
            let downloadTime = null;
            let downloadEstimatifTimeLog = null;
            let downloadProgressLog = null;
            let downloadProgressLogCreatedAt = null;

            if (success) {
                this.console.log(`[${index + 1}/${modsCount}] Sending download link to Vortex <a href="${mod.file.url}" target="_blank" class="text-primary-moderate">${mod.file.mod.name}</a>`);
                // based on download 1mb/s wait until the download is supposed to be finished + this.pauseBetweenDownload seconds for the download to start on vortex
                downloadTime = this.pauseBetweenDownload == 0 ? 0 : Math.round(mod.file.size / 1024 / 1) + this.pauseBetweenDownload;
                downloadEstimatifTimeLog = this.console.log(`Waiting approximately ${downloadTime} seconds for the download to finish on Vortex before starting the next one.`);
                downloadProgressLog = this.console.log(`Downloading... ${downloadTime} seconds left (~0%)`);
                downloadProgressLogCreatedAt = Date.now();
            }

            await new Promise(resolve => {
                const downloadProgressLogInterval = setInterval(async () => {
                    // if STATUS_PAUSED loop until STATUS_DOWNLOADING or STATUS_STOPPED or skip
                    if (this.progressBar.status === NDCProgressBar.STATUS_PAUSED) {
                        while (this.progressBar.status === NDCProgressBar.STATUS_PAUSED) {
                            // if STATUS_STOPPED clear the interval and resolve the promise
                            if (this.progressBar.status === NDCProgressBar.STATUS_STOPPED) {
                                clearInterval(downloadProgressLogInterval);
                                return resolve();
                            }

                            // if skip to index
                            if (this.progressBar.skipPause) {
                                this.console.log('Skip to next mod.');
                                this.progressBar.skipPause = false;
                                clearInterval(downloadProgressLogInterval);
                                return resolve();
                            }

                            // if skipToIndex
                            if (this.progressBar.skipToIndex) {
                                this.console.log(`Skip to index ${this.progressBar.skipIndex}.`);
                                clearInterval(downloadProgressLogInterval);
                                return resolve();
                            }

                            await new Promise(resolve => setTimeout(resolve, 100));
                        }
                    }

                    // if STATUS_STOPPED clear the interval and resolve the promise
                    if (this.progressBar.status === NDCProgressBar.STATUS_STOPPED) {
                        clearInterval(downloadProgressLogInterval);
                        return resolve();
                    }

                    // if skip to index
                    if (this.progressBar.skipPause) {
                        this.console.log('Skip to next mod.');
                        this.progressBar.skipPause = false;
                        clearInterval(downloadProgressLogInterval);
                        return resolve();
                    }

                    // if skipToIndex
                    if (this.progressBar.skipToIndex) {
                        this.console.log(`Skip to index ${this.progressBar.skipIndex}.`);
                        clearInterval(downloadProgressLogInterval);
                        return resolve();
                    }

                    if (success) {
                        const elapsedTime = Math.round((Date.now() - downloadProgressLogCreatedAt) / 1000);
                        const remainingTime = downloadTime - elapsedTime;
                        downloadProgressLog.innerHTML = `Downloading... ${remainingTime} seconds left (~${Math.round((elapsedTime / downloadTime) * 100)}%)`;

                        if (remainingTime <= 0) {
                            clearInterval(downloadProgressLogInterval);
                            return resolve();
                        }
                    } else {
                        clearInterval(downloadProgressLogInterval);
                        return resolve();
                    }
                }, 100);
            });

            if (success) {
                downloadEstimatifTimeLog.remove();
                downloadProgressLog.remove();
            }

            resolve();
        });
    }

    startDownload(modsCount) {
        this.forceStop = false;
        this.progressBar.setModsCount(modsCount);
        this.progressBar.setProgress(0);
        this.progressBar.setStatus(NDCProgressBar.STATUS_DOWNLOADING);
        this.downloadButton.element.style.display = 'none';
        this.progressBar.element.style.display = '';
        this.console.log('Download started.');
    }

    endDownload() {
        this.forceStop = false;
        this.progressBar.setStatus(NDCProgressBar.STATUS_FINISHED);
        this.progressBar.element.style.display = 'none';
        this.downloadButton.element.style.display = '';
        this.console.log('Download finished.');
    }
}

class NDCDownloadButton {
    constructor(ndc) {
        this.element = document.createElement('div');
        this.element.classList.add('flex', 'w-100');

        this.ndc = ndc;

        this.html = `
            <button class="w-full font-montserrat font-semibold text-sm leading-none tracking-wider uppercase flex gap-x-2 justify-center items-center transition-colors relative min-h-9 focus:outline focus:outline-2 focus:outline-accent focus:outline-offset-2 px-2 py-1 cursor-pointer bg-primary-moderate fill-font-primary text-font-primary border-transparent focus:bg-primary-strong hover:bg-primary-subdued justify-between rounded-l" id="mainBtn">
                Add all mods to vortex
                <span class="p-2 bg-surface-low rounded-full text-xs text-white whitespace-nowrap" id="mainModsCount"></span>
            </button>
            <button class="font-montserrat font-semibold text-sm leading-none tracking-wider uppercase flex gap-x-2 justify-center items-center transition-colors relative min-h-9 focus:outline focus:outline-2 focus:outline-accent focus:outline-offset-2 px-2 py-1 cursor-pointer bg-primary-moderate fill-font-primary text-font-primary border-transparent focus:bg-primary-strong hover:bg-primary-subdued justify-between rounded-r" id="menuBtn">
                <svg class="w-4 h-4 fill-current" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" role="presentation" style="width: 1.5rem; height: 1.5rem;"><path d="M7.41,8.58L12,13.17L16.59,8.58L18,10L12,16L6,10L7.41,8.58Z" style="fill: currentcolor;"></path></svg>
            </button>
            <div class="absolute z-10 min-w-48 py-1 px-0 mt-1 text-base text-gray-600 border-stroke-subdued bg-surface-low border border-gray-200 rounded-md shadow-lg outline-none hidden" id="menu" style="transform: translate(488.453px, 36px);">
                <button class="font-montserrat text-sm font-semibold uppercase leading-none tracking-wider first:rounded-t last:rounded-b relative flex w-full items-center gap-x-2 p-2 text-left font-normal hover:bg-surface-mid hover:text-primary-moderate focus:shadow-accent focus:z-10 focus:outline-none text-start justify-between" id="menuBtnMandatory">
                    Add all mandatory mods
                    <span class="p-2 bg-primary-moderate rounded-full text-xs text-white whitespace-nowrap" id="menuBtnMandatoryModsCount"></span>
                </button>
                <button class="font-montserrat text-sm font-semibold uppercase leading-none tracking-wider first:rounded-t last:rounded-b relative flex w-full items-center gap-x-2 p-2 text-left font-normal hover:bg-surface-mid hover:text-primary-moderate focus:shadow-accent focus:z-10 focus:outline-none text-start justify-between" id="menuBtnOptional">
                    Add all optional mods
                    <span class="p-2 bg-primary-moderate rounded-full text-xs text-white whitespace-nowrap" id="menuBtnOptionalModsCount"></span>
                </button>
            </div>
        `;

        this.element.innerHTML = this.html;

        this.allBtn = this.element.querySelector('#mainBtn');
        this.modsCount = this.element.querySelector('#mainModsCount');
        this.mandatoryBtn = this.element.querySelector('#menuBtnMandatory');
        this.mandatoryModsCount = this.element.querySelector('#menuBtnMandatoryModsCount');
        this.optionalBtn = this.element.querySelector('#menuBtnOptional');
        this.optionalModsCount = this.element.querySelector('#menuBtnOptionalModsCount');

        const menuBtn = this.element.querySelector('#menuBtn');
        const menu = this.element.querySelector('#menu');

        menuBtn.addEventListener('click', () => {
            const btnGroupOffset = this.element.getBoundingClientRect();
            menu.classList.toggle('hidden');
            const dropdownMenuOffset = menu.getBoundingClientRect();
            menu.style.transform = `translate(${btnGroupOffset.width - dropdownMenuOffset.width}px, ${btnGroupOffset.height}px)`;
        });

        document.addEventListener('click', (event) => {
            const isClickInside = menu.contains(event.target) || menuBtn.contains(event.target);
            if (!isClickInside) {
                menu.classList.add('hidden');
            }
        });
    }

    updateModsCount() {
        this.modsCount.innerHTML = `${this.ndc.mods.mandatory.length + this.ndc.mods.optional.length} mods`;
    }

    updateMandatoryModsCount() {
        this.mandatoryModsCount.innerHTML = `${this.ndc.mods.mandatory.length} mods`;
    }

    updateOptionalModsCount() {
        this.optionalModsCount.innerHTML = `${this.ndc.mods.optional.length} mods`;
    }

    render() {
        this.updateModsCount();
        this.updateMandatoryModsCount();
        this.updateOptionalModsCount();
    }
}

class NDCProgressBar {
    static STATUS_DOWNLOADING = 0;
    static STATUS_PAUSED = 1;
    static STATUS_FINISHED = 2;
    static STATUS_STOPPED = 3;

    static STATUS_TEXT = {
        [NDCProgressBar.STATUS_DOWNLOADING]: 'Downloading...',
        [NDCProgressBar.STATUS_PAUSED]: 'Paused',
        [NDCProgressBar.STATUS_FINISHED]: 'Finished',
        [NDCProgressBar.STATUS_STOPPED]: 'Stopped'
    }

    constructor(ndc, options = {}) {
        this.element = document.createElement('div');
        this.element.classList.add('flex', 'flex-wrap', 'w-100');
        this.element.style.display = 'none';

        this.ndc = ndc;

        this.modsCount = 0;
        this.progress = 0;

        this.skipPause = false;

        this.skipToIndex = false;
        this.skipIndex = 0;

        this.status = NDCProgressBar.STATUS_DOWNLOADING;

        this.html = `
            <div class="flex-1 relative w-100 min-h-9 bg-surface-low rounded-l overflow-hidden" id="progressBar">
                <div class="absolute top-0 left-0 w-0 h-full bg-primary-moderate" style="transition: width 0.3s ease 0s; width: 0%;" id="progressBarFill"></div>
                <div class="absolute top-0 left-0 w-full h-full cursor-pointer grid grid-cols-3 items-center text-white font-montserrat font-semibold text-sm leading-none tracking-wider uppercase" id="progressBarText">
                    <div class="ml-2" id="progressBarProgress">${this.progress}%</div>
                    <div class="text-center" id="progressBarTextCenter">Downloading...</div>
                    <div class="text-right mr-2" id="progressBarTextRight">${this.progress}/${this.modsCount}</div>
                </div>
            </div>
            <div class="flex" id="actionBtnGroup">
                <button class="font-montserrat font-semibold text-sm leading-none tracking-wider uppercase flex gap-x-2 justify-center items-center transition-colors relative min-h-9 focus:outline focus:outline-2 focus:outline-accent focus:outline-offset-2 px-2 py-1 cursor-pointer bg-primary-moderate fill-font-primary text-font-primary border-transparent focus:bg-primary-strong hover:bg-primary-subdued justify-between" id="playPauseBtn">
                    <svg class="w-4 h-4 fill-current" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" role="presentation" style="width: 1.5rem; height: 1.5rem;"><path d="M14,19H18V5H14M6,19H10V5H6V19Z" style="fill: currentcolor;"></path></svg>
                </button>
                <button class="font-montserrat font-semibold text-sm leading-none tracking-wider uppercase flex gap-x-2 justify-center items-center transition-colors relative min-h-9 focus:outline focus:outline-2 focus:outline-accent focus:outline-offset-2 px-2 py-1 cursor-pointer bg-primary-moderate fill-font-primary text-font-primary border-transparent focus:bg-primary-strong hover:bg-primary-subdued justify-between rounded-r" id="stopBtn">
                    <svg class="w-4 h-4 fill-current" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" role="presentation" style="width: 1.5rem; height: 1.5rem;"><path d="M18,18H6V6H18V18Z" style="fill: currentcolor;"></path></svg>
                </button>
            </div>
            <div class="flex my-2 justify-between" style="flex-basis: 100%;" id="toolbarContainer">
                <div class="flex gap-2 items-center" id="pauseBetweenDownloadInputContainer">
                    <input class="text-md text-neutral-subdued border-neutral-subdued bg-surface-mid rounded border indent-2 outline-none hover:border-white focus:border-white focus:text-white p-1 w-14" type="number" min="0" placeholder="5" value="${this.ndc.pauseBetweenDownload}" id="pauseBetweenDownloadInput">
                    <label class="text-white font-montserrat font-semibold text-sm leading-none tracking-wider uppercase" for="pauseBetweenDownloadInput">Extra pause</label>
                    <svg id="extraPauseInfo" class="w-4 h-4 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="presentation" style="width: 1.5rem; height: 1.5rem; cursor: pointer;"><title>information</title><path d="M13,9H11V7H13M13,17H11V11H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" style="fill: currentcolor;"/></svg>
                </div>
                <div class="flex gap-2 items-center" id="skipContainer">
                    <button class="rounded font-montserrat font-semibold text-sm leading-none tracking-wider uppercase flex gap-x-2 justify-center items-center transition-colors relative min-h-9 focus:outline focus:outline-2 focus:outline-accent focus:outline-offset-2 px-2 py-1 cursor-pointer bg-primary-moderate fill-font-primary text-font-primary border-transparent focus:bg-primary-strong hover:bg-primary-subdued justify-between" id="skipNextBtn">
                        Skip pause
                    </button>
                    <button class="rounded font-montserrat font-semibold text-sm leading-none tracking-wider uppercase flex gap-x-2 justify-center items-center transition-colors relative min-h-9 focus:outline focus:outline-2 focus:outline-accent focus:outline-offset-2 px-2 py-1 cursor-pointer bg-primary-moderate fill-font-primary text-font-primary border-transparent focus:bg-primary-strong hover:bg-primary-subdued justify-between" id="skipToIndexBtn">
                        Skip to index
                    </button>
                    <input class="text-md text-neutral-subdued border-neutral-subdued bg-surface-mid rounded border indent-2 outline-none hover:border-white focus:border-white focus:text-white p-1 w-20" type="number" min="0" placeholder="Index" id="skipToIndexInput">
                </div>
            </div>
        `;

        this.element.innerHTML = this.html;

        const extraPauseInfo = this.element.querySelector('#extraPauseInfo');

        this.progressBarFill = this.element.querySelector('#progressBarFill');
        this.progressBarProgress = this.element.querySelector('#progressBarProgress');
        this.progressBarTextCenter = this.element.querySelector('#progressBarTextCenter');
        this.progressBarTextRight = this.element.querySelector('#progressBarTextRight');
        this.playPauseBtn = this.element.querySelector('#playPauseBtn');
        this.stopBtn = this.element.querySelector('#stopBtn');
        this.pauseBetweenDownloadInput = this.element.querySelector('#pauseBetweenDownloadInput');
        this.skipNextBtn = this.element.querySelector('#skipNextBtn');
        this.skipToIndexBtn = this.element.querySelector('#skipToIndexBtn');
        this.skipToIndexInput = this.element.querySelector('#skipToIndexInput');

        extraPauseInfo.addEventListener('click', () => {
            alert(`"Extra pause" is the time in seconds the script waits before starting the next download. Without it, downloads begin immediately but Vortex may become unresponsive with large collections.\n\nA supplementary pause is calculated based on the mod file size and download speed (1.5mb/s), noticeable only with large mods.\n\nIf "extra pause" is set to 0, the calculated pause is ignored.`);
        });

        this.playPauseBtn.addEventListener('click', () => {
            const status = this.status == NDCProgressBar.STATUS_DOWNLOADING ? NDCProgressBar.STATUS_PAUSED : NDCProgressBar.STATUS_DOWNLOADING;
            this.setStatus(status);
        });

        this.stopBtn.addEventListener('click', () => {
            this.setStatus(NDCProgressBar.STATUS_STOPPED);
        });

        this.pauseBetweenDownloadInput.addEventListener('change', async (event) => {
            this.ndc.pauseBetweenDownload = parseInt(event.target.value);
            await GM.setValue('pauseBetweenDownload', this.ndc.pauseBetweenDownload);
        });

        this.skipNextBtn.addEventListener('click', () => {
            this.skipPause = true;
        });

        this.skipToIndexBtn.addEventListener('click', () => {
            const index = parseInt(this.skipToIndexInput.value);
            if (index > this.progress && index <= this.modsCount) {
                this.skipToIndex = true;
                this.skipIndex = index;
            }
        });
    }

    setState(newState) {
        Object.assign(this, newState);
        this.render();
    }

    setModsCount(modsCount) {
        this.setState({ modsCount });
    }

    setProgress(progress) {
        this.setState({ progress });
    }

    incrementProgress() {
        this.setState({ progress: this.progress + 1 });
    }

    setStatus(status) {
        this.setState({ status });
        this.progressBarTextCenter.innerHTML = NDCProgressBar.STATUS_TEXT[status];
    }

    getProgressPercent() {
        return (this.progress / this.modsCount * 100).toFixed(2);
    }

    updateProgressBarFillWidth() {
        this.progressBarFill.style.width = `${this.getProgressPercent()}%`;
    }

    updateProgressBarTextProgress() {
        this.progressBarProgress.innerHTML = `${this.getProgressPercent()}%`;
    }

    updateProgressBarTextRight() {
        this.progressBarTextRight.innerHTML = `${this.progress}/${this.modsCount}`;
    }

    updatePlayPauseBtn() {
        this.playPauseBtn.innerHTML = this.status == NDCProgressBar.STATUS_PAUSED ? '<svg class="w-4 h-4 fill-current" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" role="presentation" style="width: 1.5rem; height: 1.5rem;"><path d="M8,5.14V19.14L19,12.14L8,5.14Z" style="fill: currentcolor;"></path></svg>' : '<svg class="w-4 h-4 fill-current" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" role="presentation" style="width: 1.5rem; height: 1.5rem;"><path d="M14,19H18V5H14M6,19H10V5H6V19Z" style="fill: currentcolor;"></path></svg>';
    }

    updatePauseBetweenDownloadInput() {
        this.pauseBetweenDownloadInput.value = this.ndc.pauseBetweenDownload;
    }

    render() {
        this.updateProgressBarFillWidth()
        this.updateProgressBarTextProgress()
        this.updateProgressBarTextRight()
        this.updatePlayPauseBtn()
        this.updatePauseBetweenDownloadInput()
    }
}

class NDCLogConsole {
    static TYPE_INFO = 'INFO';
    static TYPE_ERROR = 'ERROR';

    constructor(ndc, options = {}) {
        this.element = document.createElement('div');
        this.element.classList.add('flex', 'flex-col', 'w-100', 'gap-3', 'mt-3');

        this.ndc = ndc;
        this.hidden = false;

        this.html = `
            <div class="flex flex-col w-100 gap-3 mt-3">
                <button class="w-full font-montserrat font-semibold text-sm leading-none tracking-wider uppercase" id="toggleLogsButton">
                    Hide logs
                </button>
                <div class="w-full bg-surface-low rounded overflow-y-auto text-white font-montserrat font-semibold text-sm border border-primary" style="height: 10rem; resize: vertical;" id="logContainer">
                </div>
            </div>
        `;

        this.element.innerHTML = this.html;

        this.toggle = this.element.querySelector('#toggleLogsButton');
        this.logContainer = this.element.querySelector('#logContainer');

        this.toggle.addEventListener('click', () => {
            this.hidden = !this.hidden;
            logContainer.style.display = this.hidden ? 'none' : '';
            this.toggle.innerHTML = this.hidden ? 'Show logs' : 'Hide logs';
        });
    }

    log(message, type = NDCLogConsole.TYPE_INFO) {
        const rowElement = document.createElement('div');

        rowElement.classList.add('gap-x-2', 'px-2', 'py-1');
        if (type === NDCLogConsole.TYPE_ERROR) {
            rowElement.classList.add('text-danger-moderate');
        }

        rowElement.innerHTML = `[${new Date().toLocaleTimeString()}][${type}] ${message}`;

        this.logContainer.appendChild(rowElement);
        this.logContainer.scrollTop = this.logContainer.scrollHeight;

        console.log(`[${type}] ${message}`);

        return rowElement;
    }

    clear() {
        this.logContainer.innerHTML = '';
    }
}


let previousRoute = null;
let ndc = null;

async function handleNextRouterChange() {
    if (next.router.state.route === "/[gameDomain]/collections/[collectionSlug]") {
        const { gameDomain, collectionSlug, tab } = next.router.query;

        if (previousRoute !== `${gameDomain}/${collectionSlug}`) {
            previousRoute = `${gameDomain}/${collectionSlug}`;

            ndc = new NDC(gameDomain, collectionSlug);
            ndc.init();
        }

        if (tab === "mods") {
            document.querySelector("#tabcontent-mods > div > div > div").prepend(ndc.element);
        } else {
            ndc.element.remove();
        }
    }
}

// Add an event listener for the hashchange event
next.router.events.on('routeChangeComplete', handleNextRouterChange);

handleNextRouterChange();