Nexus Download Collection

Download every mods of a collection in a single click

Ajankohdalta 7.4.2024. Katso uusin versio.

// ==UserScript==
// @name         Nexus Download Collection
// @namespace    NDC
// @version      0.6.7
// @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
// @connect      nexusmods.com
// ==/UserScript==

(async function () {
    'use strict';
    /** CORSViaGM BEGINING */

    let forceStop = false;

    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 */

    function createElement(elementName, options) {
        var element = document.createElement(elementName);
        if (options.html) {
            element.innerHTML = options.html;
        }
        if (options.elements) {
            for (var i = 0; i < options.elements.length; i++) {
                element.appendChild(options.elements[i]);
            }
        }
        if (options.classes) {
            element.className = options.classes;
        }
        if (options.attributes) {
            for (var key in options.attributes) {
                element.setAttribute(key, options.attributes[key]);
            }
        }
        if (options.events) {
            for (var key in options.events) {
                element.addEventListener(key, options.events[key]);
            }
        }
        return element;
    }

    class LogRow {
        constructor(message, type) {
            this.message = message;
            this.type = type;
            this.createdAt = new Date();
            this.row = createElement('div', {
                classes: 'gap-x-2 px-2 py-1',
                html: `[${this.createdAt.toLocaleTimeString()}][${this.type}] ${this.message}`
            });
        }

        updateMessage(message) {
            this.message = message;
            this.row.innerHTML = `[${this.createdAt.toLocaleTimeString()}][${this.type}] ${this.message}`;
        }

        destroy() {
            this.row.remove();
        }
    }

    function log(message, type) {
        const logRow = new LogRow(message, type);
        logArea.appendChild(logRow.row);
        logArea.scrollTop = logArea.scrollHeight;

        return logRow;
    }

    function refreshProgressBar(percent, currentMod, totalMods) {
        progressBar.style.width = `${percent}%`;
        progressBarButtonProgress.innerText = `${Math.round(percent)}%`;
        progressBarButtonDownloaded.innerText = `${currentMod}/${totalMods}`;
    }

    async function getModCollection(gameId, collectionId) {
        const response = await fetch("https://next.nexusmods.com/api/graphql", {
            "headers": {
                "accept": "*/*",
                "accept-language": "fr;q=0.5",
                "api-version": "2023-09-05",
                "content-type": "application/json",
                "sec-ch-ua": "\"Not_A Brand\";v=\"8\", \"Chromium\";v=\"120\", \"Brave\";v=\"120\"",
                "sec-ch-ua-mobile": "?0",
                "sec-ch-ua-platform": "\"Windows\"",
                "sec-fetch-dest": "empty",
                "sec-fetch-mode": "cors",
                "sec-fetch-site": "same-origin",
                "sec-gpc": "1"
            },
            "referrer": `https://next.nexusmods.com/${gameId}/collections/${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": collectionId, "viewAdultContent": true },
                "operationName": "CollectionRevisionMods"
            }),
            "method": "POST",
            "mode": "cors",
            "credentials": "include"
        });

        const data = await response.json();

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

        return data.data.collectionRevision;
    }

    async function getSlowDownloadModLink(mod) {
        let downloadUrl = '';
        const url = mod.file.url + '&nmm=1';

        const response = await fetchViaGM(url, {
            "headers": {
                "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
                "accept-language": "fr;q=0.6",
                "cache-control": "max-age=0",
                "sec-ch-ua": "\"Not_A Brand\";v=\"8\", \"Chromium\";v=\"120\", \"Brave\";v=\"120\"",
                "sec-ch-ua-mobile": "?0",
                "sec-ch-ua-platform": "\"Windows\"",
                "sec-fetch-dest": "document",
                "sec-fetch-mode": "navigate",
                "sec-fetch-site": "same-origin",
                "sec-fetch-user": "?1",
                "sec-gpc": "1",
                "upgrade-insecure-requests": "1"
            },
            "referrer": url,
            "referrerPolicy": "strict-origin-when-cross-origin",
            "body": null,
            "method": "GET",
            "mode": "cors",
            "credentials": "include"
        });

        const text = await response.text();
        const html = new DOMParser().parseFromString(text, "text/html");

        const slow = html.getElementById("slowDownloadButton");
        if (slow) {
            downloadUrl = slow.getAttribute("data-download-url");
        }

        return { downloadUrl, text, html }
    };

    async function 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, html } = await getSlowDownloadModLink(mod, true);
        if (downloadUrl === '') {
            // make link to copy in the clipboard the response

            const logRow = 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"></button>`, 'ERROR');
            const svg = createElement('svg', {
                classes: 'w-4 h-4 fill-current',
                attributes: {
                    viewBox: '0 0 24 24',
                    xmlns: 'http://www.w3.org/2000/svg',
                    role: 'presentation',
                    style: 'width: 1rem; height: 1rem;'
                },
                html: '<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>'
            });
            // add svg to the button
            const copyButton = logRow.row.querySelector('button');
            copyButton.innerHTML = svg.outerHTML;
            copyButton.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 (html.querySelector('.replaced-login-link')) {
                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.', 'ERROR');
                forceStop = true;
            }

            return false;
        }

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

    async function downloadMods(mods) {
        let downloadProgress = 0;
        let downloadProgressPercent = 0;

        refreshProgressBar(0, 0, mods.length);

        btnGroup.classList.add('hidden');
        progressBarContainer.classList.remove('hidden');
        logAreaContainer.classList.remove('hidden');

        for (const [index, mod] of mods.entries()) {
            if (downloadPaused) {
                log(`Download paused.`, 'INFO');
                while (downloadPaused) {
                    await new Promise(resolve => setTimeout(resolve, 100));
                }
                log(`Download resumed.`, 'INFO');
            }

            const status = await addModToVortex(mod);

            if (forceStop) {
                log(`Force stop.`, 'INFO');
                break;
            }

            if (!status) {
                continue;
            }

            log(`Sending download link to Vortex <a href="${mod.file.url}" target="_blank" class="text-primary-moderate">${mod.file.name}</a>`, 'INFO');

            downloadProgress += 1;
            downloadProgressPercent = downloadProgress / mods.length * 100;

            refreshProgressBar(downloadProgressPercent, index + 1, mods.length);

            // based on download 1.5mb/s wait until the download is supposed to be finished + 5 seconds for the download to start on vortex
            const downloadTime = Math.round(mod.file.sizeInBytes / 1500000) + 5;
            const downloadEstimatifTimeLog = log(`Waiting approximately ${downloadTime} seconds for the download to finish on Vortex before starting the next one.`, 'INFO');
            const downloadProgressLog = log(`Downloading... ${downloadTime} seconds left (~0%)`, 'INFO');
            const downloadProgressLogInterval = setInterval(() => {
                const timeLeft = downloadTime - Math.round((Date.now() - downloadProgressLog.createdAt) / 1000);
                // 0 to 100% based on the time left
                const approximativePercent = Math.round((downloadTime - timeLeft) / downloadTime * 100);
                downloadProgressLog.updateMessage(`Downloading... ${timeLeft} seconds left (~${approximativePercent}%)`);
            }, 1000);

            await new Promise(resolve => {
                setTimeout(() => {
                    clearInterval(downloadProgressLogInterval);
                    downloadEstimatifTimeLog.destroy();
                    downloadProgressLog.destroy();
                    resolve();
                }, downloadTime * 1000);
            });
        }

        progressBar.style.width = "0%";
        progressBarContainer.classList.add('hidden');

        btnGroup.classList.remove('hidden');

        if (forceStop) {
            forceStop = false;
            return;
        }

        logAreaContainer.classList.add('hidden');
        logArea.innerHTML = "";
    };

    const loadingContainer = createElement('div', {
        html: 'Loading...',
        classes: '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',
    });

    const modsCountSpan = createElement('span', {
        classes: 'p-2 bg-surface-low rounded-full text-xs text-white whitespace-nowrap',
    });
    const downloadAllButton = createElement('button', {
        html: 'Add all mods to vortex',
        elements: [
            modsCountSpan
        ],
        classes: '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',
        events: {
            click: () => {
                downloadMods(mods.modFiles);
            }
        }
    });

    const dropdownCarret = createElement('svg', {
        classes: 'w-4 h-4 fill-current',
        attributes: {
            viewBox: '0 0 24 24',
            xmlns: 'http://www.w3.org/2000/svg',
            role: 'presentation',
            style: 'width: 1.5rem; height: 1.5rem;'
        },
        html: '<path d="M7.41,8.58L12,13.17L16.59,8.58L18,10L12,16L6,10L7.41,8.58Z" style="fill: currentcolor;"></path>'
    });
    const dropdownItemMandatoryModsCount = createElement('span', {
        classes: 'p-2 bg-primary-moderate rounded-full text-xs text-white whitespace-nowrap',
    });
    const dropdownItemMandatory = createElement('button', {
        html: 'Add all mandatory mods',
        elements: [
            dropdownItemMandatoryModsCount
        ],
        classes: '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',
        events: {
            click: () => {
                downloadMods(mandatoryMods)
            }
        }
    });
    const dropdownItemOptionalModsCount = createElement('span', {
        classes: 'p-2 bg-primary-moderate rounded-full text-xs text-white whitespace-nowrap',
    });
    const dropdownItemOptional = createElement('button', {
        html: 'Add all optional mods',
        elements: [
            dropdownItemOptionalModsCount
        ],
        classes: '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',
        events: {
            click: () => {
                downloadMods(optionalMods)
            }
        }
    });
    const dropdownMenu = createElement('div', {
        classes: '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',
        elements: [
            dropdownItemMandatory,
            dropdownItemOptional
        ]
    });
    const dropdownButton = createElement('button', {
        html: dropdownCarret.outerHTML,
        classes: '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',
        events: {
            click: function () {
                const btnGroupOffset = btnGroup.getBoundingClientRect();
                dropdownMenu.classList.toggle('hidden');
                const dropdownMenuOffset = dropdownMenu.getBoundingClientRect();
                dropdownMenu.style.transform = `translate(${btnGroupOffset.width - dropdownMenuOffset.width}px, ${btnGroupOffset.height}px)`;
            }
        }
    });
    const btnGroup = createElement('div', {
        classes: 'flex w-100',
        elements: [
            downloadAllButton,
            dropdownButton,
            dropdownMenu
        ]
    });

    document.addEventListener('click', function (event) {
        const isClickInside = dropdownButton.contains(event.target);
        if (!isClickInside) {
            dropdownMenu.classList.add('hidden');
        }
    });

    const progressBar = createElement('div', {
        classes: 'absolute top-0 left-0 w-0 h-full bg-primary-moderate',
        attributes: {
            style: 'transition: width 0.3s ease;'
        }
    });
    const progressBarButtonProgress = createElement('div', {
        classes: 'ml-3',
        html: '0%',
    });
    const progressBarButtonText = createElement('div', {
        classes: 'text-center',
        html: 'Downloading...',
    });
    const progressBarButtonDownloaded = createElement('div', {
        classes: 'text-right',
        attributes: {
            style: 'margin-right: .75rem;'
        },
    });
    const progressBarButton = createElement('div', {
        elements: [
            progressBarButtonProgress,
            progressBarButtonText,
            progressBarButtonDownloaded
        ],
        classes: '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',
        events: {
            click: function () {
                downloadPaused = !downloadPaused;
                progressBarButtonText.innerHTML = downloadPaused ? 'Resume' : 'Pause';
            },
            mouseenter: function () {
                progressBarButtonText.innerHTML = downloadPaused ? 'Resume' : 'Pause';
            },
            mouseleave: function () {
                progressBarButtonText.innerHTML = downloadPaused ? 'Resume' : 'Downloading...';
            },
        }
    });
    const progressBarContainer = createElement('div', {
        classes: 'relative w-100 min-h-9 bg-surface-low rounded overflow-hidden hidden',
        elements: [
            progressBar,
            progressBarButton
        ]
    });

    const logArea = createElement('div', {
        classes: 'w-full bg-surface-low rounded overflow-y-auto text-white font-montserrat font-semibold text-sm border border-primary',
        attributes: {
            style: 'height: 10rem; resize: vertical;'
        }
    });
    const logAreaToggleButton = createElement('button', {
        html: 'Hide logs',
        classes: 'w-full font-montserrat font-semibold text-sm leading-none tracking-wider uppercase',
        events: {
            click: function () {
                logArea.classList.toggle('hidden');
                logAreaToggleButton.innerText = logArea.classList.contains('hidden') ? 'Show logs' : 'Hide logs';
            }
        }
    });
    const logAreaContainer = createElement('div', {
        classes: 'flex flex-col w-100 gap-3 hidden',
        elements: [
            logAreaToggleButton,
            logArea
        ]
    });

    const NDCContainer = createElement('div', {
        classes: 'flex flex-col w-100 gap-3 mb-3',
        elements: [
            btnGroup,
            progressBarContainer,
            logAreaContainer
        ]
    });

    let previousRoute = null;

    let mods = null;
    let mandatoryMods = [];
    let optionalMods = [];

    let downloadPaused = false; // used for pause button

    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}`;

                if (tab === "mods") {
                    const tabcontentMods = document.querySelector("#tabcontent-mods > div > div > div");
                    tabcontentMods.prepend(loadingContainer);
                }

                mods = await getModCollection(gameDomain, collectionSlug);
                const modFiles = mods.modFiles.sort((a, b) => a.file.name.localeCompare(b.file.name));
                mandatoryMods = modFiles.filter(mod => !mod.optional);
                optionalMods = modFiles.filter(mod => mod.optional);

                if (tab === "mods") {
                    loadingContainer.remove();
                }
            }

            while (mods === null) {
                await new Promise(resolve => setTimeout(resolve, 100));
            }

            if (tab === "mods") {
                const tabcontentMods = document.querySelector("#tabcontent-mods > div > div > div");

                const modsCount = mods.modFiles.length;
                modsCountSpan.innerText = `${modsCount} mods`;
                dropdownItemMandatoryModsCount.innerText = `${mandatoryMods.length} mods`;
                dropdownItemOptionalModsCount.innerText = `${optionalMods.length} mods`;

                tabcontentMods.prepend(NDCContainer);
            }
        }
    }

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

    handleNextRouterChange();
})();