Nexus No Wait ++

Skip Countdown, Auto Download, and More for Nexus Mods. Supports (Manual/Vortex/MO2/NMM)

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

Advertisement:

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

Advertisement:

// ==UserScript==
// @name        Nexus No Wait ++
// @description Skip Countdown, Auto Download, and More for Nexus Mods. Supports (Manual/Vortex/MO2/NMM)
// @version     2.2.1
// @namespace   NexusNoWaitPlusPlus
// @author      Torkelicious
// @iconURL     https://raw.githubusercontent.com/torkelicious/nexus-no-wait-pp/refs/heads/main/icon.png
// @icon        https://raw.githubusercontent.com/torkelicious/nexus-no-wait-pp/refs/heads/main/icon.png
// @license     GPL-3.0-or-later
// @match       https://*.nexusmods.com/*
// @run-at      document-idle
// @grant       GM_getValue
// @grant       GM_setValue
// @grant       GM.xmlHttpRequest
// @grant       GM_xmlhttpRequest
// @grant       GM_info
// @grant       GM_addStyle
// @grant       GM_registerMenuCommand
// @grant       GM_download
// @connect     *.nexusmods.com
// @connect     files.nexus-cdn.com
// @connect     *.nexus-cdn.com
// @connect     raw.githubusercontent.com
// ==/UserScript==

;(function () {
    'use strict'

    // config / state
    const CONFIG_KEY = 'NexusNoWaitPP'
    const AUDIO_CACHE_KEY = 'NexusNoWaitPP_ErrorSoundCache'
    const DEFAULTS = { AutoStartDownload: true, AutoCloseTab: true, SkipRequirements: true, ShowAlertsOnError: true, PlayErrorSound: true, ErrorSoundUrl: 'https://github.com/torkelicious/nexus-no-wait-pp/raw/cf4fdca1cde74a173ac115e95eb1c8ffeb19a4ae/errorsound.mp3', HandleArchivedFiles: true, HidePremiumUpsells: false, OverrideFileNames: false, ForceModManagerDownload: false, CloseTabDelay: 2000, RequestTimeout: 30000 }

    function loadConfig() {
        try {
            const raw = typeof GM_getValue === 'function' ? GM_getValue(CONFIG_KEY, null) : null
            const parsed = raw ? { ...DEFAULTS, ...(typeof raw === 'string' ? JSON.parse(raw) : raw) } : { ...DEFAULTS }
            logEvent('debug', 'config:load', { activeConfig: parsed })
            return parsed
        } catch (e) {
            Logger.warn('Failed to load config, using defaults', e)
            return { ...DEFAULTS }
        }
    }

    function cleanResetConfig() {
        logEvent('info', 'config:reset-requested')
        Object.assign(cfg, DEFAULTS)
        if (typeof GM_setValue === 'function') GM_setValue(CONFIG_KEY, JSON.stringify(cfg))
        location.reload()
    }

    // logging
    const Logger = ['debug', 'info', 'warn', 'error'].reduce((o, lvl) => {
        o[lvl] = (...a) => console[lvl](`[NexusNoWait++ v${GM_info.script.version}]`, ...a)
        return o
    }, {})
    const logEvent = (level, event, data = {}) => Logger[level](event, data)

    let cfg = loadConfig()

    // prevent duplicate actions & state tracking
    const processing = new WeakSet(),
        handledArchive = new WeakSet(),
        handledForceNmm = new WeakSet(),
        attachedSlowDl = new WeakSet()
    const autoFiredIds = new Set()
    let listenersAttached = false,
        errorAudioPlayer = null,
        stylesInjected = false,
        domObserver = null,
        domUpdateTimeout = null

    // network setup
    const gmXmlHttpRequest = typeof GM !== 'undefined' && typeof GM.xmlHttpRequest === 'function' ? GM.xmlHttpRequest.bind(GM) : typeof GM_xmlhttpRequest === 'function' ? GM_xmlhttpRequest : null
    if (!gmXmlHttpRequest) Logger.error('No GM XHR API available. Script may not function correctly.')

    // network utils
    function gmRequest(url, opts = {}) {
        return new Promise(resolve => {
            if (!gmXmlHttpRequest) return resolve({ text: '', finalUrl: '', headers: '' })
            gmXmlHttpRequest({
                method: opts.method || 'GET',
                ...opts,
                url,
                timeout: opts.timeout ?? cfg.RequestTimeout,
                headers: opts.headers || {},
                data: opts.data || null,
                onload: r => resolve({ text: r.responseText || '', finalUrl: r.finalUrl || '', headers: r.responseHeaders || '' }),
                onerror: e => {
                    logEvent('error', 'network:request-failed', { url, error: e })
                    resolve({ text: '', finalUrl: '', headers: '' })
                },
                ontimeout: () => {
                    logEvent('warn', 'network:request-timeout', { url })
                    resolve({ text: '', finalUrl: '', headers: '' })
                }
            })
        })
    }

    // audio setup
    function initAudioPlayer(src) {
        errorAudioPlayer = new Audio(src)
        errorAudioPlayer.preload = 'auto'
        errorAudioPlayer.load()
    }

    async function setupAudio() {
        if (!cfg.PlayErrorSound || !cfg.ErrorSoundUrl || errorAudioPlayer) return
        const cachedSound = typeof GM_getValue === 'function' ? GM_getValue(AUDIO_CACHE_KEY, null) : null
        if (cachedSound) {
            logEvent('debug', 'audio:loaded-from-cache')
            initAudioPlayer(cachedSound)
        } else {
            logEvent('debug', 'audio:fetching-from-network')
            const b64DataURI = await new Promise(resolve => {
                if (!gmXmlHttpRequest) return resolve(null)
                gmXmlHttpRequest({
                    method: 'GET',
                    url: cfg.ErrorSoundUrl,
                    responseType: 'blob',
                    onload: res => {
                        if (res.status >= 200 && res.status < 300 && res.response) {
                            const reader = new FileReader()
                            reader.onloadend = () => resolve(reader.result)
                            reader.onerror = () => resolve(null)
                            reader.readAsDataURL(res.response)
                        } else resolve(null)
                    },
                    onerror: () => resolve(null),
                    ontimeout: () => resolve(null)
                })
            })
            if (b64DataURI) {
                if (typeof GM_setValue === 'function') {
                    GM_setValue(AUDIO_CACHE_KEY, b64DataURI)
                    logEvent('debug', 'audio:saved-to-cache')
                }
                initAudioPlayer(b64DataURI)
            } else {
                logEvent('warn', 'audio:fetch-failed-using-fallback')
                initAudioPlayer(cfg.ErrorSoundUrl)
            }
        }
    }

    function playErrorSound() {
        if (errorAudioPlayer) {
            errorAudioPlayer.currentTime = 0
            errorAudioPlayer.play().catch(e => Logger.warn('Error playing sound:', e))
        }
    }

    // DOM & parsing utils
    function escapeAttr(str) {
        return String(str).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#39;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
    }

    function sanitizeFilename(name) {
        if (!name) return name
        return String(name).replace(/\\/g, '/').split('/').pop().replace(/^\.+/, '').trim() || 'nexus_download'
    }

    const appendNmmParam = href => (!href || href.includes('nmm=1') ? href : `${href}${href.includes('?') ? '&' : '?'}nmm=1`)
    const isModPage = () => /\/mods\/\d+/.test(location.pathname)

    function isNMMDownload(element, href = '') {
        if (href && (href.startsWith('nxm://') || href.includes('nmm=1') || href.includes('&nmm=1'))) return true
        if (!element) return false
        if (element.id === 'action-vortex' || element.id === 'action-nmm') return true
        const text = (element.textContent || (typeof element.getAttribute === 'function' && element.getAttribute('aria-label')) || '').toLowerCase()
        return /(vortex|mod manager|manager download)/i.test(text)
    }

    const getGameId = (clickedElement = null) => {
        if (clickedElement) {
            let current = clickedElement
            while (current) {
                if (['MOD-DOWNLOAD-BUTTONS', 'MOD-FILE-DOWNLOAD'].includes(current.tagName) && current.getAttribute('game-id')) {
                    return current.getAttribute('game-id')
                }
                current = current.parentNode || (current instanceof ShadowRoot ? current.host : null)
            }
        }
        const el = document.querySelector('[data-game-id], [game-id]')
        if (el) return el.dataset.gameId || el.getAttribute('game-id')
        for (const script of document.querySelectorAll('script')) {
            const m = script.textContent.match(/game_id\s*:\s*(\d+)/) || script.textContent.match(/gameId\s*:\s*(\d+)/)
            if (m) return m[1]
        }
        return document.getElementById('section')?.dataset?.gameId || location.pathname.split('/')[1] || ''
    }

    function parseDownloadURLFromResponse(text) {
        if (!text) return null
        try {
            const json = JSON.parse(String(text))
            if (json?.url) return { url: json.url.replace(/&amp;/g, '&') }
        } catch (e) {}
        const match = String(text).match(/id=["']dl_link["'][^>]*value=["']([^"']+)["']/i)
        return match ? { url: match[1].replace(/&amp;/g, '&') } : null
    }

    function parseDownloadLink(text) {
        if (!text) return null
        text = String(text).replace(/&amp;/g, '&').replace(/\\\//g, '/')
        const match = text.match(/nxm:\/\/[^\s"'<>]+/i)
        if (!match) return null
        const url = match[0]
        const queryIndex = url.indexOf('?')
        if (queryIndex === -1) return null
        const params = new URLSearchParams(url.slice(queryIndex + 1))
        if (!params.has('key') || !params.has('expires') || !params.has('user_id')) return null
        return url
    }

    function getFilenameFromHead(headerStr) {
        if (!headerStr) return null
        if (typeof headerStr === 'object') headerStr = headerStr['content-disposition'] || headerStr['Content-Disposition'] || ''
        const match = String(headerStr).match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/i)
        return match && match[1] ? match[1].replace(/['"]/g, '').trim() : null
    }

    // requirements detection
    function readRequirementsFlag(el) {
        if (!el || typeof el.getAttribute !== 'function') return null
        let sawAnyAttribute = false,
            hasEntries = false,
            hadParseError = false
        const reqRaw = el.getAttribute('requirements')
        if (reqRaw !== null && reqRaw !== undefined) {
            sawAnyAttribute = true
            try {
                const parsed = JSON.parse(reqRaw)
                if (Array.isArray(parsed) && parsed.length > 0) hasEntries = true
            } catch (e) {
                hadParseError = true
            }
        }
        const depRaw = el.getAttribute('dependencies')
        if (depRaw !== null && depRaw !== undefined) {
            sawAnyAttribute = true
            try {
                const parsed = JSON.parse(depRaw)
                if (Array.isArray(parsed) && parsed.some(group => Array.isArray(group?.files) && group.files.length > 0)) {
                    hasEntries = true
                }
            } catch (e) {
                hadParseError = true
            }
        }
        if (!sawAnyAttribute) return false
        if (hasEntries) return true
        if (hadParseError) return null
        return false
    }

    function detectRequirements(elements) {
        let sawDefinitiveFalse = false,
            sawAnyCandidate = false
        for (const el of elements) {
            if (!el) continue
            sawAnyCandidate = true
            const flag = readRequirementsFlag(el)
            if (flag === true) return { hasRequirements: true, unknown: false }
            if (flag === false) sawDefinitiveFalse = true
        }
        if (sawDefinitiveFalse) return { hasRequirements: false, unknown: false }
        return { hasRequirements: false, unknown: sawAnyCandidate }
    }

    // download resolution
    async function getDownloadUrl({ fileId, gameId, isNMM, href }) {
        if (!fileId && !href) return { url: null, error: 'Missing fileId' }
        if (href && href.startsWith('nxm://')) return { url: href }
        let link = null
        if (href && href.includes('/api/files/')) {
            logEvent('info', 'download:resolve-api', { href })
            let targetApiUrl = isNMM ? appendNmmParam(href) : href
            try {
                const res = await gmRequest(targetApiUrl, { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
                link = res.finalUrl && res.finalUrl !== targetApiUrl ? res.finalUrl : null
                const locationMatch = res.headers.match(/Location:\s*(nxm:\/\/[^\s]+)/i)
                if (locationMatch) link = locationMatch[1]
                const extractedJson = parseDownloadURLFromResponse(res.text)
                if (extractedJson) link = extractedJson.url
                const extractedNxm = parseDownloadLink(res.text) || parseDownloadLink(res.finalUrl)
                if (extractedNxm) link = extractedNxm
                if (link) return { url: link }
            } catch (err) {
                Logger.warn('API fetch failed:', err)
            }
        }
        if (isNMM && href) {
            let nmmHref = appendNmmParam(href)
            const res = await gmRequest(nmmHref, { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
            link = parseDownloadLink(res.finalUrl) || (res.headers.match(/Location:\s*(nxm:\/\/[^\s]+)/i) || [])[1] || parseDownloadLink(res.text)
            if (!link && /ModRequirementsPopUp|tab=requirements/i.test(nmmHref)) {
                const reqMatch = res.text.match(/href=["']([^"']*?file_id[^"']*?)["']/i)
                if (reqMatch) {
                    const res2 = await gmRequest(reqMatch[1])
                    link = parseDownloadLink(res2.finalUrl) || (res2.headers.match(/Location:\s*(nxm:\/\/[^\s]+)/i) || [])[1] || parseDownloadLink(res2.text)
                }
            }
            if (link) return { url: link }
        }
        if (fileId) {
            const endpoint = '/Core/Libs/Common/Managers/Downloads?GenerateDownloadUrl'
            const spoofedReferer = `https://www.nexusmods.com${location.pathname}?tab=files&file_id=${fileId}`
            let postData = `fid=${encodeURIComponent(fileId)}&game_id=${encodeURIComponent(gameId || '')}`
            if (isNMM) postData += '&nmm=1'

            const res = await gmRequest(endpoint, { method: 'POST', data: postData, headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'X-Requested-With': 'XMLHttpRequest', Origin: 'https://www.nexusmods.com', Referer: href || spoofedReferer } })
            const extracted = parseDownloadURLFromResponse(res.text)
            if (extracted) link = extracted.url
        }
        if (link) return { url: link }
        return { url: null, error: 'Could not resolve file link (are you logged in?)' }
    }

    async function normalizeDownloadUrl(url, isNMM) {
        if (!url || url.startsWith('nxm://')) return url
        if (url.includes('nexusmods.com') && url.includes('file_id=')) {
            try {
                const parsed = new URL(url, location.href)
                const fileId = parsed.searchParams.get('file_id')
                if (fileId) {
                    const res = await getDownloadUrl({ fileId, gameId: getGameId(), isNMM, href: url })
                    if (res?.url) return res.url
                }
            } catch (e) {}
        }
        return url
    }

    async function scrapeDeepDownloadLink(pageUrl, isNMM) {
        try {
            const res = await gmRequest(pageUrl)
            const html = res.text
            const fileDataRegex = /(?:main-file|file)=(["'])(.*?)\1/gi
            let match
            while ((match = fileDataRegex.exec(html)) !== null) {
                try {
                    const unescaped = match[2]
                        .replace(/&quot;/g, '"')
                        .replace(/&amp;/g, '&')
                        .replace(/&#34;/g, '"')
                    if (!unescaped.includes('downloadUrl')) continue
                    const fileData = JSON.parse(unescaped)
                    const secureApiUrl = isNMM ? fileData.vortexDownloadUrl || fileData.downloadUrl : fileData.downloadUrl
                    if (secureApiUrl) return secureApiUrl
                } catch (e) {}
            }
            const cdnMatch = html.match(/https?:\/\/[a-zA-Z0-9-]+\.nexus-cdn\.com[^"']+/i)
            if (cdnMatch) return cdnMatch[0].replace(/&amp;/g, '&')
            return null
        } catch (e) {
            return null
        }
    }

    // button state & execution
    function setButtonState(button, state, message) {
        if (!button) return
        const textElement = button.querySelector('span.flex-label, span') || button
        const stateConfig = { waiting: { text: 'Please Wait...', color: 'orange' }, downloading: { text: 'Downloading!', color: 'green' }, error: { text: message || 'Error', color: 'red' } }
        const config = stateConfig[state] || stateConfig.error
        if (textElement) textElement.innerText = config.text
        button.style.color = config.color
    }

    function handleError(btn, error) {
        if (btn) setButtonState(btn, 'error', error)
        Logger.error('Download Error:', error)
        if (cfg.PlayErrorSound) playErrorSound()
        if (cfg.ShowAlertsOnError) alert(`Download error: ${error}`)
    }

    async function startDownloadFlow({ btn, fileId, isNMM, href, isAutoStart = false }) {
        if (btn) setButtonState(btn, 'waiting')
        logEvent('debug', isAutoStart ? 'download:auto-start' : 'download:start', { fileId, isNMM, href })
        const gameId = getGameId(btn)
        const { url, error } = await getDownloadUrl({ fileId, gameId, isNMM, href })
        if (error) return handleError(btn || null, error)
        let finalUrl = url
        if (btn) setButtonState(btn, 'downloading')
        finalUrl = await normalizeDownloadUrl(finalUrl, isNMM)
        if (!finalUrl) return handleError(btn || null, 'Failed to resolve download URL')
        logEvent('info', 'download:resolved', { url: finalUrl })

        // safety: if API returns a Requirements URL and user wants to see requirements then stop
        if (/ModRequirementsPopUp|tab=requirements/i.test(finalUrl) && !cfg.SkipRequirements) {
            logEvent('info', 'Halting flow: File has requirements & SkipRequirements is false', { finalUrl })
            location.assign(finalUrl)
            return
        }

        // prevent redirects
        if (cfg.SkipRequirements && !finalUrl.startsWith('nxm://') && finalUrl.includes('nexusmods.com') && (finalUrl.includes('file_id=') || /\/files\/\d+/i.test(finalUrl)) && !finalUrl.includes('GenerateDownloadUrl') && !finalUrl.includes('nexus-cdn.com')) {
            const deepLink = await scrapeDeepDownloadLink(finalUrl, isNMM)
            if (deepLink) {
                if (deepLink.includes('/api/files/') || deepLink.includes('GenerateDownloadUrl')) {
                    const resolved = await getDownloadUrl({ href: deepLink, gameId, isNMM })
                    if (resolved?.url) finalUrl = resolved.url
                } else {
                    finalUrl = deepLink
                }
            }
        }
        if (cfg.OverrideFileNames && typeof GM_download === 'function' && !isNMM && !finalUrl.startsWith('nxm://')) {
            const modIdMatch = location.pathname.match(/\/mods\/(\d+)/)
            const modId = modIdMatch ? modIdMatch[1] : null
            if (modId) {
                let originalName = sanitizeFilename(decodeURIComponent(finalUrl.split('/').pop().split('?')[0])) || 'nexus_download'
                let extIndex = originalName.lastIndexOf('.')
                const looksLikeUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(originalName)
                if (looksLikeUUID || extIndex === -1) {
                    logEvent('info', 'download: URL hides filename, fetching headers')
                    try {
                        const headRes = await gmRequest(finalUrl, { method: 'HEAD' })
                        const realName = sanitizeFilename(getFilenameFromHead(headRes.headers))
                        if (realName) {
                            originalName = realName
                            extIndex = originalName.lastIndexOf('.')
                        } else {
                            logEvent('warn', 'download: Could not get real filename from server, fallback', { url: finalUrl })
                            location.assign(finalUrl)
                            return
                        }
                    } catch (err) {
                        logEvent('error', 'download: Header fetch failed, fallback', err)
                        location.assign(finalUrl)
                        return
                    }
                }
                let newName = extIndex !== -1 ? `${originalName.substring(0, extIndex)}-${modId}${originalName.substring(extIndex)}` : `${originalName}-${modId}`
                const idRegex = new RegExp(`(^|[^0-9])${modId}([^0-9]|$)`)
                if (extIndex === -1) logEvent('info', 'download: filename has no extension, appending ID to end')

                if (idRegex.test(originalName)) {
                    newName = originalName
                } else {
                    logEvent('info', 'download: overriding file-name', { originalName, newName })
                }
                GM_download({
                    url: finalUrl,
                    name: newName,
                    saveAs: false,
                    onload: () => logEvent('debug', 'download: override name success'),
                    onerror: err => {
                        logEvent('warn', 'download: override name failed, fallback', err)
                        location.assign(finalUrl)
                    }
                })
                return
            }
        }
        location.assign(finalUrl)
    }

    // click interception
    const IGNORE_ANCESTORS = '.pagination, .comment-container, .comment-content, .forum-post, .search-results, #nnwpp-btn'
    const ALLOWED_AJAX_POPUP_HREF_PATTERNS = ['Widgets/ModRequirementsPopUp']
    const DOWNLOAD_HREF_PATTERNS = ['tab=files&file_id=', 'file_id=', 'Widgets/ModRequirementsPopUp', '/api/files/', 'nxm://']
    const isDownloadHref = href => DOWNLOAD_HREF_PATTERNS.some(p => href.toLowerCase().includes(p.toLowerCase()))

    function extractFileId(href) {
        try {
            if (href.startsWith('nxm://')) {
                const urlParams = new URLSearchParams(href.substring(href.indexOf('?')))
                return urlParams.get('id') || urlParams.get('file_id')
            }
            const url = new URL(href, location.href)
            const apiMatch = url.pathname.match(/\/api\/files\/(\d+)/)
            if (apiMatch) return apiMatch[1]
            return url.searchParams.get('file_id') || url.searchParams.get('id')
        } catch (e) {
            return null
        }
    }

    function findDownloadModalInPath(path) {
        let modal = path.find(n => n && n.tagName === 'MOD-DOWNLOAD-MODAL')
        if (!modal) {
            for (const node of path) {
                if (node && node.shadowRoot) {
                    modal = node.shadowRoot.querySelector('mod-download-modal')
                    if (modal) break
                }
            }
        }
        return modal || path.find(n => n && ['MOD-DOWNLOAD-BUTTONS', 'MOD-FILE-DOWNLOAD'].includes(n.tagName))?.querySelector('mod-download-modal')
    }

    function attachClickInterceptor() {
        document.body.addEventListener(
            'click',
            async function (event) {
                if (!isModPage() || !event.isTrusted || event.defaultPrevented) return

                const path = event.composedPath ? event.composedPath() : [event.target]
                const element = path.find(n => n && (n.tagName === 'A' || n.tagName === 'BUTTON')) || event.target.closest('a,button')
                if (!element || element.closest(IGNORE_ANCESTORS)) return
                let href = element.getAttribute('href') || element.href || ''
                // Gate unless explicitly in allowlist
                if (element.classList.contains('popup-btn-ajax') && !ALLOWED_AJAX_POPUP_HREF_PATTERNS.some(p => href.includes(p))) return
                if (href.includes('tab=files') && !href.includes('file_id=')) return
                const isNMM = isNMMDownload(element, href)
                let fileId = extractFileId(href)
                let secureApiUrl = null

                const modal = findDownloadModalInPath(path)
                if (modal) {
                    try {
                        const fileData = JSON.parse(modal.getAttribute('file') || '{}')
                        if (fileData.downloadUrl) secureApiUrl = isNMM ? fileData.vortexDownloadUrl || fileData.downloadUrl : fileData.downloadUrl
                    } catch (e) {}
                }

                const hostContainer = path.find(n => n && ['MOD-DOWNLOAD-BUTTONS', 'MOD-FILE-DOWNLOAD'].includes(n.tagName))
                if (!secureApiUrl && hostContainer) {
                    try {
                        const attrName = hostContainer.tagName === 'MOD-DOWNLOAD-BUTTONS' ? 'main-file' : 'file'
                        const fileData = JSON.parse(hostContainer.getAttribute(attrName) || '{}')
                        if (fileData.id && !fileId) fileId = fileData.id.toString()
                    } catch (e) {}
                }

                const { hasRequirements: hasRequirementsAlert, unknown: requirementsUnknown } = detectRequirements([modal, hostContainer])
                const hasRequirementsHref = href.includes('ModRequirementsPopUp') || href.includes('tab=requirements')
                const isDownloadLink = fileId || secureApiUrl || isDownloadHref(href) || (modal && (isNMM || (element.textContent || '').toLowerCase().includes('manual')))
                if (!isDownloadLink) return
                if ((hasRequirementsAlert || hasRequirementsHref) && !cfg.SkipRequirements) {
                    secureApiUrl = null
                    logEvent('info', 'Yielding to native UI: File has requirements & SkipRequirements is false')
                    return
                }
                if (requirementsUnknown && !cfg.SkipRequirements) {
                    logEvent('warn', 'Yielding to native UI: could not confirm requirements state & SkipRequirements is false')
                    return
                }
                if (processing.has(element)) return
                if ((element.textContent || '').toLowerCase().includes('slow download')) return
                processing.add(element)
                event.preventDefault()
                event.stopImmediatePropagation()
                try {
                    const finalHref = secureApiUrl || href || `https://www.nexusmods.com${location.pathname}?tab=files&file_id=${fileId}`
                    if ((hasRequirementsAlert || hasRequirementsHref) && cfg.SkipRequirements) {
                        logEvent('info', 'requirements:skipped', { isNMM, url: finalHref })
                    }
                    await startDownloadFlow({ btn: element, fileId: secureApiUrl ? null : fileId, isNMM, href: finalHref })
                } catch (e) {
                    handleError(element, String(e))
                } finally {
                    processing.delete(element)
                }
            },
            true
        )
    }

    function interceptRequirementsTab() {
        document.body.addEventListener(
            'click',
            function (event) {
                if (!event.isTrusted || event.defaultPrevented || !cfg.SkipRequirements) return
                const linkElement = event.composedPath ? event.composedPath().find(n => n && n.tagName === 'A') : event.target.closest('a')
                if (!linkElement) return
                const href = linkElement.getAttribute('href') || linkElement.href || ''
                if (!href.includes('tab=requirements')) return
                event.preventDefault()
                event.stopImmediatePropagation()
                logEvent('debug', 'navigation:requirements-tab-intercepted')
                location.replace(href.replace('tab=requirements', 'tab=files'))
            },
            true
        )
    }

    // features
    async function autoStartDownload() {
        if (!cfg.AutoStartDownload || !isModPage()) return
        if (location.search.includes('tab=files') && !location.pathname.includes('/files/')) return
        if (document.querySelector('mod-file-download')) return
        const fileId = new URLSearchParams(location.search).get('file_id')
        if (!fileId || autoFiredIds.has(fileId)) return
        autoFiredIds.add(fileId)
        const isNMM = isNMMDownload(null, location.search)
        await new Promise(r => setTimeout(r, 200))
        await startDownloadFlow({ fileId, isNMM, href: location.href, isAutoStart: true })

        if (cfg.AutoCloseTab) {
            logEvent('debug', 'tab:closing', { delay: cfg.CloseTabDelay })
            setTimeout(() => window.close(), cfg.CloseTabDelay)
        }
    }

    function setupSlowDownloadIntercept() {
        if (!location.search.includes('file_id')) return
        const slowBtn = document.querySelector('mod-file-download')?.shadowRoot?.querySelector('button')
        if (!slowBtn || !(slowBtn.textContent || '').toLowerCase().includes('slow download') || attachedSlowDl.has(slowBtn)) return
        attachedSlowDl.add(slowBtn)
        slowBtn.addEventListener('click', async event => {
            event.preventDefault()
            event.stopImmediatePropagation()
            const fid = new URLSearchParams(location.search).get('file_id')
            if (fid) {
                logEvent('debug', 'download:slow-intercept', { fileId: fid, isNMM: isNMMDownload(slowBtn, location.search) })
                await startDownloadFlow({ btn: slowBtn, fileId: fid, isNMM: isNMMDownload(slowBtn, location.search), href: location.href })
            }
        })

        if (cfg.AutoStartDownload) {
            const fid = new URLSearchParams(location.search).get('file_id')
            if (fid && !autoFiredIds.has(fid)) {
                autoFiredIds.add(fid)
                startDownloadFlow({ btn: slowBtn, fileId: fid, isNMM: isNMMDownload(slowBtn, location.search), href: location.href, isAutoStart: true }).then(() => {
                    if (cfg.AutoCloseTab) {
                        logEvent('debug', 'tab:closing', { delay: cfg.CloseTabDelay })
                        setTimeout(() => window.close(), cfg.CloseTabDelay)
                    }
                })
            }
        }
    }

    function upsellBlocker() {
        if (!cfg.HidePremiumUpsells) return
        if (!stylesInjected) {
            logEvent('debug', 'ui:upsell-blocker-active')
            const selectors = ['#nonPremiumBanner', '#freeTrialBanner', '#ig-banner-container', '#rj-vortex', '[class*="ads-bottom"]', '[class*="ads-top"]', '[class*="to-premium"]', '[class*="from-premium"]', '[class*="premium"]', '#mainContent > div.ads-holder', '#head > div.rj-right-tray.rj-profile-tray.rj-open > ul > li.user-profile-menu-section-top > a']
            GM_addStyle(selectors.map(s => `${s}{display:none!important}`).join('\n'))
            stylesInjected = true
        }
        document.querySelector('.bg-nexus-premium-gradient')?.remove()
    }

    function archivedFileHandler() {
        if (!cfg.HandleArchivedFiles || !isModPage()) return
        const url = location.href
        if (url.includes('tab=files') && !url.includes('category=archived')) {
            const footer = document.querySelector('#files-tab-footer')
            if (footer && !handledArchive.has(footer)) {
                handledArchive.add(footer)
                footer.querySelector('p')?.style.setProperty('display', 'none')
                const hasArchiveBtn = Array.from(footer.querySelectorAll('a.btn.inline-flex .flex-label')).some(el => el.textContent.trim() === 'File archive')
                if (!hasArchiveBtn) {
                    logEvent('debug', 'ui:archive-button-restored')
                    footer.insertAdjacentHTML('beforeend', `<a class="btn inline-flex" data-archived-btn="true" href="${escapeAttr(url)}&category=archived" style="background:#da8e35;color:#fff;margin-left:8px;"><span class="flex-label">File archive</span></a>`)
                }
            }
        }
        if (!url.includes('category=archived')) return
        const headers = document.getElementsByClassName('file-expander-header'),
            downloads = document.getElementsByClassName('accordion-downloads')
        for (let i = 0; i < headers.length; i++) {
            const fileId = headers[i]?.dataset?.id,
                box = downloads[i]
            if (!fileId || !box || handledArchive.has(box)) continue
            handledArchive.add(box)
            const safeId = encodeURIComponent(fileId),
                safeBase = escapeAttr(`${location.origin}${location.pathname}`)
            box.innerHTML = `<a class="btn inline-flex" href="${safeBase}?tab=files&file_id=${safeId}&nmm=1"><span class="flex-label">Mod manager download</span></a> <a class="btn inline-flex" href="${safeBase}?tab=files&file_id=${safeId}"><span class="flex-label">Manual download</span></a>`
        }
    }

    function forceModManagerHandler() {
        if (!cfg.ForceModManagerDownload || !isModPage()) return
        const manualLinks = document.querySelectorAll('a[href*="file_id="]:not([href*="nmm=1"]), a.btn[href*="tab=files"]:not([href*="nmm=1"])')
        for (const link of manualLinks) {
            if (handledForceNmm.has(link)) continue
            const text = (link.textContent || link.getAttribute('aria-label') || '').toLowerCase()
            if (!text.includes('manual')) continue
            let hasManagerLink = false,
                searchArea = link.parentElement
            for (let i = 0; i < 3; i++) {
                if (!searchArea) break
                for (const el of searchArea.querySelectorAll('a, button')) {
                    if (el === link || handledForceNmm.has(el)) continue
                    const t = (el.textContent || el.getAttribute('aria-label') || '').toLowerCase()
                    if ((el.href && el.href.includes('nmm=1')) || t.includes('manager') || t.includes('vortex')) {
                        hasManagerLink = true
                        break
                    }
                }
                if (hasManagerLink) break
                searchArea = searchArea.parentElement
            }
            handledForceNmm.add(link)
            if (hasManagerLink) continue
            const isLi = link.parentElement && link.parentElement.tagName === 'LI'
            const nodeToClone = isLi ? link.parentElement : link
            const clone = nodeToClone.cloneNode(true)
            const managerLink = isLi ? clone.querySelector('a') : clone
            if (managerLink.href && managerLink.href.includes('file_id=')) {
                managerLink.href = appendNmmParam(managerLink.href)
            }
            const label = managerLink.querySelector('.flex-label') || managerLink
            label.textContent = text.includes('download') ? '(NNW++) Mod manager download' : '(NNW++) Mod manager'
            nodeToClone.parentNode.insertBefore(clone, nodeToClone)
        }
    }

    // Settings UI
    function SettingsUI() {
        const SETTING_UI = [
            { key: 'AutoStartDownload', label: 'Auto Start Download on file_id= URLs', type: 'bool', description: 'Automatically start downloads when visiting file download pages (URLs containing file_id=)' },
            { key: 'AutoCloseTab', label: 'Auto-Close Tab After AutoStartDownload', type: 'bool', description: 'Auto-close may be unreliable due to browser permissions.', showIf: () => cfg.AutoStartDownload },
            { key: 'SkipRequirements', label: 'Skip Requirements PopUp/Tab', type: 'bool', description: 'Skip the requirements popup/page and proceed directly to download' },
            { key: 'ShowAlertsOnError', label: 'Show Alert Messages on Errors', type: 'bool', description: 'Display error messages as browser popup alerts' },
            { key: 'PlayErrorSound', label: 'Play Error Sound', type: 'bool', description: 'Play an error sound when download errors occur' },
            { key: 'HidePremiumUpsells', label: 'Hide Premium Upsells & misc Annoyances (experimental)', type: 'bool', description: 'Hide premium upgrade banners, trial offers, and other annoyances on the site (experimental). You are probably better off using an adblocker.' },
            { key: 'OverrideFileNames', label: 'Append Mod ID to Filenames (Manual Downloads)', type: 'bool', description: 'Restores the Mod ID to downloaded files. Note: Your browser may prompt you for download permissions the first time, and it may take longer to initate downloads.' },
            { key: 'ForceModManagerDownload', label: 'Generate mod manager download buttons for manual-only downloads', type: 'bool', description: "Inject mod-manager download buttons on files that don't have any." },
            { key: 'HandleArchivedFiles', label: 'Generate download buttons for Archived Files', type: 'bool', description: 'Enable handling of archived files.' },
            { key: 'RequestTimeout', label: 'Request Timeout', type: 'number', description: 'Maximum time to wait for server responses before timing out (in milliseconds)' },
            { key: 'CloseTabDelay', label: 'Auto-Close Tab Delay', type: 'number', description: 'Delay before automatically closing the tab after download starts (in milliseconds)', showIf: () => cfg.AutoStartDownload && cfg.AutoCloseTab },
            { key: 'ErrorSoundUrl', label: 'Error Sound URL', type: 'text', description: 'URL of the custom sound file to play for error alerts', showIf: () => cfg.PlayErrorSound }
        ]

        const STYLES = {
            modal: "position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:#2f2f2f;color:#dadada;padding:25px;border-radius:4px;z-index:2147483647;min-width:300px;max-width:90%;max-height:90vh;overflow-y:auto;font-family:'Inter','Helvetica Neue', Helvetica, Arial, sans-serif;",
            backdrop: 'position:fixed;top:0;left:0;width:100vw;height:100vh;background:rgba(0,0,0,0.5);z-index:2147483646;',
            section: 'background:#363636;padding:15px;border-radius:4px;margin-bottom:15px;',
            sectionHeader: 'color:#da8e35;margin:0 0 10px 0;font-size:16px;font-weight:500;',
            input: 'background:#2f2f2f;border:1px solid #444;color:#dadada;border-radius:3px;padding:5px;',
            row: 'margin-bottom:10px;',
            label: 'display:flex;align-items:center;gap:8px;',
            btnObj: { primary: 'padding:8px 15px;border:none;background:#da8e35;color:white;border-radius:3px;cursor:pointer;', secondary: 'padding:8px 15px;border:1px solid #da8e35;background:transparent;color:#da8e35;border-radius:3px;cursor:pointer;', closeX: 'position:absolute;top:10px;right:10px;background:transparent;border:none;color:#fff;font-size:18px;cursor:pointer;line-height:1;padding:5px;' }
        }

        function save() {
            try {
                GM_setValue(CONFIG_KEY, JSON.stringify(cfg))
                logEvent('debug', 'config:saved')
            } catch (e) {
                Logger.error('Failed to save config:', e)
            }
        }

        let activeModal = null,
            activeBackdrop = null
        const closeModal = () => {
            activeModal?.remove()
            activeModal = null
            activeBackdrop?.remove()
            activeBackdrop = null
            document.removeEventListener('keydown', onSettingsKeyDown)
        }
        const onSettingsKeyDown = event => {
            if (event.key === 'Escape') closeModal()
        }

        function showSettingsModal() {
            cfg = loadConfig()
            closeModal()
            const backdrop = document.createElement('div')
            backdrop.style.cssText = STYLES.backdrop
            backdrop.onclick = closeModal
            document.body.appendChild(backdrop)
            activeBackdrop = backdrop
            const modal = document.createElement('div')
            modal.style.cssText = STYLES.modal
            const build = setting => {
                const display = !setting.showIf || setting.showIf() ? 'block' : 'none'
                const desc = escapeAttr(setting.description || '')
                if (setting.type === 'bool') {
                    return `<div style="${STYLES.row}display:${display}"><label title="${desc}" style="${STYLES.label}cursor:pointer;"><input type="checkbox" data-setting="${setting.key}" ${cfg[setting.key] ? 'checked' : ''}><span>${setting.label}</span></label></div>`
                } else if (setting.type === 'number') {
                    const step = setting.key === 'CloseTabDelay' ? 100 : 1
                    return `<div style="${STYLES.row}display:${display}"><label title="${desc}" style="${STYLES.label}"><span>${setting.label}:</span><input type="number" value="${escapeAttr(cfg[setting.key])}" min="0" step="${step}" data-setting="${setting.key}" style="${STYLES.input}width:120px;"></label></div>`
                } else if (setting.type === 'text') {
                    return `<div style="${STYLES.row}display:${display}"><label title="${desc}" style="${STYLES.label}flex-direction:column;align-items:stretch;gap:4px;"><span style="font-size:0.9em;color:#aaa;">${setting.label}:</span><input type="text" value="${escapeAttr(cfg[setting.key])}" data-setting="${setting.key}" style="${STYLES.input}width:95%;"></label></div>`
                }
                return ''
            }

            modal.innerHTML = `<style>a:hover { text-decoration: underline !important; }</style>
        <button id="closeSettingsX" style="${STYLES.btnObj.closeX}">×</button>
        <h3 style="${STYLES.sectionHeader}margin-top:0;">NexusNoWait++ Settings</h3>
        <div style="${STYLES.section}"><h4 style="${STYLES.sectionHeader}">Features</h4>${SETTING_UI.filter(u => u.type !== 'number')
            .map(build)
            .join('')}</div>
        <div style="${STYLES.section}"><h4 style="${STYLES.sectionHeader}">Timing</h4>${SETTING_UI.filter(u => u.type === 'number')
            .map(build)
            .join('')}</div>
        <div style="display:flex;justify-content:center;gap:10px;margin-top:20px;"><button id="resetSettings" style="${STYLES.btnObj.secondary}">Reset Settings</button><button id="closeSettings" style="${STYLES.btnObj.primary}">Save & Close</button></div>
        <div style="text-align:center;margin-top:10px;color:#888;font-size:11px;">Some changed settings may require a page reload to take effect.</div>
        <div style="text-align:center;margin-top:12px;color:#666;font-size:12px;">v${GM_info.script.version} by Torkelicious</div>
        <div style="text-align:center;margin-top:6px;color:#666;font-size:10px;"><a href="https://github.com/torkelicious/nexus-no-wait-pp/" target="_blank" style="color:#666;">This software is open-source and licensed under the GPLv3</a></div>`

            const updateVisibility = () => {
                for (const setting of SETTING_UI) {
                    if (!setting.showIf) continue
                    const row = modal.querySelector(`[data-setting="${setting.key}"]`)?.closest('div')
                    if (row) row.style.display = setting.showIf() ? 'block' : 'none'
                }
            }

            const update = element => {
                const key = element.getAttribute('data-setting')
                if (!key) return
                let value = element.type === 'checkbox' ? element.checked : element.type === 'number' ? parseInt(element.value, 10) : element.value
                if (typeof value === 'number' && isNaN(value)) return (element.value = cfg[key])
                if (cfg[key] !== value) {
                    cfg[key] = value
                    save()
                }
                updateVisibility()
            }

            modal.addEventListener('change', e => {
                if (e.target?.hasAttribute('data-setting')) update(e.target)
            })
            modal.addEventListener('input', e => {
                if (['number', 'text'].includes(e.target.type) && e.target?.hasAttribute('data-setting')) update(e.target)
            })
            modal.querySelector('#closeSettingsX').addEventListener('click', closeModal)
            modal.querySelector('#closeSettings').addEventListener('click', closeModal)
            modal.querySelector('#resetSettings').addEventListener('click', () => {
                cleanResetConfig()
                closeModal()
            })
            document.body.appendChild(modal)
            activeModal = modal
            updateVisibility()
            document.addEventListener('keydown', onSettingsKeyDown)
        }
        if (typeof GM_registerMenuCommand === 'function') GM_registerMenuCommand('Settings', showSettingsModal)
    }

    // initialization
    function handleDomUpdates() {
        upsellBlocker()
        archivedFileHandler()
        forceModManagerHandler()
        setupSlowDownloadIntercept()
    }

    function main() {
        if (!listenersAttached) {
            setupAudio()
            SettingsUI()
            attachClickInterceptor()
            interceptRequirementsTab()
            // persistent MutationObserver for SPA DOM injections
            domObserver = new MutationObserver(() => {
                if (domUpdateTimeout) clearTimeout(domUpdateTimeout)
                domUpdateTimeout = setTimeout(handleDomUpdates, 150)
            })
            domObserver.observe(document.body, { childList: true, subtree: true })
            listenersAttached = true
        }
        autoStartDownload()
        handleDomUpdates()
        logEvent('info', 'init', { url: location.href })
    }

    let lastUrl = location.href
    const originalPushState = history.pushState,
        originalReplaceState = history.replaceState
    function onNavigate() {
        if (location.href === lastUrl) return
        lastUrl = location.href
        main()
    }
    history.pushState = function (...args) {
        originalPushState.apply(this, args)
        onNavigate()
    }
    history.replaceState = function (...args) {
        originalReplaceState.apply(this, args)
        onNavigate()
    }
    window.addEventListener('popstate', onNavigate)
    main()
})()