ProMods 분할 파일 다운로드 큐

ProMods 분할 파일을 쿨타임에 맞춰 하나씩 예약 다운로드합니다. 사이트의 다운로드 제한을 우회하지 않습니다.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

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

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         ProMods Download Queue
// @name:ko      ProMods 분할 파일 다운로드 큐
// @namespace    https://github.com/DexfromAntarctica
// @homepageURL  https://github.com/DexfromAntarctica/promods-download-queue
// @supportURL   https://github.com/DexfromAntarctica/promods-download-queue/issues
// @version      0.3.1
// @description  Queues multipart ProMods downloads one at a time with a visible cooldown timer. Does not bypass the site's download limits.
// @description:ko ProMods 분할 파일을 쿨타임에 맞춰 하나씩 예약 다운로드합니다. 사이트의 다운로드 제한을 우회하지 않습니다.
// @license      MIT
// @match        https://promods.net/*
// @match        https://www.promods.net/*
// @compatible   chrome Tested with Tampermonkey
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM_openInTab
// @noframes
// ==/UserScript==

(() => {
    'use strict';

    const JOB_KEY = 'promodsDownloadQueueJobV2';
    const LEGACY_JOB_KEY = 'promodsEuropeDownloadQueueJob';
    const COOLDOWN_KEY = 'promodsDownloadCooldownUntil';
    const LANGUAGE_KEY = 'promodsDownloadQueueLanguage';
    const INTERVAL_MS = 5 * 60 * 1000 + 20 * 1000;
    const DOWNLOAD_TAB_LIFETIME_MS = 30 * 1000;
    const OWNER_STALE_MS = 15 * 1000;
    // 페이지 인스턴스마다 새 ID를 써서 새 탭이 sessionStorage를 복제해도
    // 두 탭이 같은 큐를 동시에 실행하지 않게 합니다.
    const INSTANCE_ID = crypto.randomUUID?.() || `${Date.now()}-${Math.random()}`;

    const PACKAGES = [
        { id: 'tgs', label: 'The Great Steppe', pattern: /promods-tgs-v/i },
        { id: 'maghreb', label: 'Maghreb', pattern: /promods-ma-v/i },
        { id: 'europe', label: 'Europe', pattern: /promods-(?:eu-)?v/i }
    ];

    const TEXT = {
        en: {
            genericTitle: 'ProMods Download Queue',
            packageTitle: '{package} Download Queue',
            close: 'Close',
            language: 'Language',
            fromPart: 'From part',
            toPart: 'To part',
            cancel: 'Cancel Queue',
            start: 'Start',
            launcher: 'Download Queue',
            counterRemaining: 'Time until the next download',
            counterReady: 'Download available',
            setupRequired: 'Open a multipart download list page to configure a queue.',
            singleFile: 'Only one file was detected; no download queue is needed.',
            detected: '{package} · {count} files detected · Ready',
            allSent: 'All selected download requests were sent.',
            waiting: '{package} · {part} waiting · {time}',
            preparing: '{package} · Preparing {part}…',
            rangeComplete: '{package} {start}–{end} requests complete',
            failed: '{part} failed: {error}',
            multipartRequired: 'Run this on a download list containing at least two multipart files.',
            invalidRange: 'Enter a valid start and end number.',
            missingLinks: 'Missing links: {parts}',
            canceled: 'Download queue canceled.'
        },
        ko: {
            genericTitle: 'ProMods 자동 다운로드',
            packageTitle: '{package} 자동 다운로드',
            close: '닫기',
            language: '언어',
            fromPart: '몇 번부터',
            toPart: '몇 번까지',
            cancel: '예약 취소',
            start: '실행',
            launcher: '다운로드 예약',
            counterRemaining: '다음 다운로드까지 남은 시간',
            counterReady: '다운로드 가능',
            setupRequired: '분할 파일 다운로드 목록 페이지에서 설정할 수 있습니다.',
            singleFile: '파일이 하나뿐이라 다운로드 예약이 필요하지 않습니다.',
            detected: '{package} · {count}개 감지됨 · 실행 대기 중',
            allSent: '선택한 파일의 다운로드 요청을 모두 보냈습니다.',
            waiting: '{package} · {part} 대기 · {time}',
            preparing: '{package} · {part} 다운로드 준비 중…',
            rangeComplete: '{package} {start}–{end} 요청 완료',
            failed: '{part} 실행 실패: {error}',
            multipartRequired: '분할 파일이 두 개 이상인 다운로드 목록에서 실행하세요.',
            invalidRange: '시작·끝 번호를 올바르게 입력하세요.',
            missingLinks: '링크 없음: {parts}',
            canceled: '다운로드 예약을 취소했습니다.'
        }
    };

    let timerId = null;
    let uiIntervalId = null;
    let downloadInProgress = false;

    function getLanguage() {
        return GM_getValue(LANGUAGE_KEY, 'en') === 'ko' ? 'ko' : 'en';
    }

    function setLanguage(language) {
        GM_setValue(LANGUAGE_KEY, language === 'ko' ? 'ko' : 'en');
    }

    function t(key, replacements = {}) {
        let value = TEXT[getLanguage()][key] || TEXT.en[key] || key;
        for (const [name, replacement] of Object.entries(replacements)) {
            value = value.replaceAll(`{${name}}`, String(replacement));
        }
        return value;
    }

    function readJob() {
        return GM_getValue(JOB_KEY, null);
    }

    function writeJob(job) {
        GM_setValue(JOB_KEY, job);
    }

    function clearJob() {
        GM_deleteValue(JOB_KEY);
    }

    function readCooldownUntil() {
        return Number(GM_getValue(COOLDOWN_KEY, 0)) || 0;
    }

    function startCooldown(startedAt = Date.now()) {
        const cooldownUntil = startedAt + INTERVAL_MS;
        GM_setValue(COOLDOWN_KEY, cooldownUntil);
        renderDockCounter();
        return cooldownUntil;
    }

    function parsePartNumber(text) {
        const match = String(text || '').match(/\.7z\.(\d{3})(?:\b|$)/i);
        return match ? Number(match[1]) : null;
    }

    function findPackageDefinition(text) {
        return PACKAGES.find(item => item.pattern.test(String(text || ''))) || null;
    }

    function findFileRow(link) {
        let element = link.parentElement;
        while (element && element !== document.body) {
            const filenames = element.querySelectorAll('.pm-file-name');
            if (filenames.length === 1) return element;
            if (filenames.length > 1) break;
            element = element.parentElement;
        }
        return null;
    }

    function detectCurrentPackage() {
        const files = new Map();
        const links = document.querySelectorAll('a.pm-dl-btn.free[href*="getdl.php"]');
        let packageDefinition = null;

        for (const link of links) {
            const href = link.href;
            const row = findFileRow(link);
            const displayedFilename = row?.querySelector('.pm-file-name')?.textContent?.trim() || '';
            const rowText = row?.textContent || '';
            let urlFilename = '';

            try {
                urlFilename = new URL(href, location.href).searchParams.get('dlfile') || '';
            } catch (_) {
                // URL이 예상과 달라도 화면에 표시된 파일명으로 인식합니다.
            }

            packageDefinition ||= findPackageDefinition(displayedFilename || urlFilename || rowText);
            const part = parsePartNumber(displayedFilename)
                ?? parsePartNumber(urlFilename)
                ?? parsePartNumber(rowText);

            if (part !== null) {
                files.set(part, {
                    part,
                    href,
                    filename: displayedFilename || urlFilename || `part-${part}`
                });
            }
        }

        if (!files.size) return null;

        const sortedFiles = new Map([...files.entries()].sort((a, b) => a[0] - b[0]));
        return {
            id: packageDefinition?.id || 'unknown',
            label: packageDefinition?.label || 'ProMods Package',
            files: sortedFiles
        };
    }

    function formatPart(part) {
        return String(part).padStart(3, '0');
    }

    function formatRemaining(milliseconds) {
        const seconds = Math.max(0, Math.ceil(milliseconds / 1000));
        const minutesPart = Math.floor(seconds / 60);
        const secondsPart = seconds % 60;
        return `${String(minutesPart).padStart(2, '0')}:${String(secondsPart).padStart(2, '0')}`;
    }

    function renderDockCounter() {
        const counter = document.querySelector('#pmq-counter');
        if (!counter) return;

        const remaining = readCooldownUntil() - Date.now();
        counter.textContent = remaining > 0 ? formatRemaining(remaining) : '00:00';
        counter.dataset.active = remaining > 0 ? 'true' : 'false';
        counter.title = remaining > 0 ? t('counterRemaining') : t('counterReady');
    }

    function openDownload(file) {
        const tab = GM_openInTab(file.href, {
            active: false,
            insert: true,
            setParent: true
        });

        window.setTimeout(() => {
            try {
                tab?.close();
            } catch (_) {
                // 다운로드 응답 때문에 탭이 이미 닫혔다면 무시합니다.
            }
        }, DOWNLOAD_TAB_LIFETIME_MS);
    }

    function updateFormForCurrentPage() {
        const detected = detectCurrentPackage();
        const startInput = document.querySelector('#pmq-from');
        const endInput = document.querySelector('#pmq-to');
        const job = readJob();

        updateDialogTitle(detected, job);
        if (job?.running) {
            startInput.value = job.start;
            endInput.value = job.end;
            return;
        }

        if (!detected) return;

        const parts = [...detected.files.keys()];
        const minimum = parts[0];
        const maximum = parts.at(-1);
        startInput.min = minimum;
        startInput.max = maximum;
        startInput.value = minimum;
        endInput.min = minimum;
        endInput.max = maximum;
        endInput.value = maximum;
    }

    function updateDialogTitle(detected = detectCurrentPackage(), job = readJob()) {
        const title = document.querySelector('#pmq-title');
        if (!title) return;

        const packageLabel = job?.running ? job.packageLabel : detected?.label;
        title.textContent = packageLabel
            ? t('packageTitle', { package: packageLabel })
            : t('genericTitle');
    }

    function applyLanguageToUI() {
        const language = getLanguage();
        const dialog = document.querySelector('#pmq-dialog');
        const languageSelect = document.querySelector('#pmq-language');
        if (!dialog || !languageSelect) return;

        dialog.lang = language;
        languageSelect.value = language;
        document.querySelector('#pmq-close').ariaLabel = t('close');
        document.querySelector('#pmq-language-label').textContent = t('language');
        document.querySelector('#pmq-from-label').textContent = t('fromPart');
        document.querySelector('#pmq-to-label').textContent = t('toPart');
        document.querySelector('#pmq-cancel').textContent = t('cancel');
        document.querySelector('#pmq-start').textContent = t('start');
        document.querySelector('#pmq-launcher').textContent = t('launcher');

        updateDialogTitle();
        renderStatus();
        renderDockCounter();
    }

    function renderStatus() {
        const status = document.querySelector('#pmq-status');
        const cancelButton = document.querySelector('#pmq-cancel');
        const startButton = document.querySelector('#pmq-start');
        if (!status || !cancelButton || !startButton) return;

        const job = readJob();
        if (!job?.running) {
            const detected = detectCurrentPackage();
            cancelButton.hidden = true;
            startButton.disabled = !detected || detected.files.size < 2;

            if (!detected) {
                status.textContent = t('setupRequired');
            } else if (detected.files.size < 2) {
                status.textContent = t('singleFile');
            } else {
                status.textContent = t('detected', {
                    package: detected.label,
                    count: detected.files.size
                });
            }
            status.dataset.state = 'idle';
            return;
        }

        cancelButton.hidden = false;
        startButton.disabled = true;

        const nextFile = job.files?.[job.index];
        if (!nextFile) {
            status.textContent = t('allSent');
            status.dataset.state = 'done';
            return;
        }

        const remaining = Math.max(job.nextAt || 0, readCooldownUntil()) - Date.now();
        if (remaining > 0) {
            status.textContent = t('waiting', {
                package: job.packageLabel,
                part: formatPart(nextFile.part),
                time: formatRemaining(remaining)
            });
            status.dataset.state = 'waiting';
        } else {
            status.textContent = t('preparing', {
                package: job.packageLabel,
                part: formatPart(nextFile.part)
            });
            status.dataset.state = 'running';
        }
    }

    function finishJob(message) {
        clearJob();
        downloadInProgress = false;
        renderStatus();

        const status = document.querySelector('#pmq-status');
        if (status) {
            status.textContent = message;
            status.dataset.state = 'done';
        }
    }

    function scheduleTick(delay = 0) {
        if (timerId !== null) window.clearTimeout(timerId);
        timerId = window.setTimeout(tick, Math.max(0, delay));
    }

    function claimOrRefreshOwnership(job) {
        const now = Date.now();
        if (job.ownerId === INSTANCE_ID) {
            if (now - (job.heartbeatAt || 0) >= 5000) {
                job = { ...job, heartbeatAt: now };
                writeJob(job);
            }
            return job;
        }

        const ownerIsStale = now - (job.heartbeatAt || 0) > OWNER_STALE_MS;
        if (ownerIsStale && !document.hidden) {
            job = { ...job, ownerId: INSTANCE_ID, heartbeatAt: now };
            writeJob(job);
            return job;
        }

        return null;
    }

    function tick() {
        timerId = null;
        renderStatus();
        renderDockCounter();

        let job = readJob();
        if (!job?.running || downloadInProgress) return;

        job = claimOrRefreshOwnership(job);
        if (!job) {
            scheduleTick(1000);
            return;
        }

        const file = job.files?.[job.index];
        if (!file) {
            finishJob(t('rangeComplete', {
                package: job.packageLabel,
                start: formatPart(job.start),
                end: formatPart(job.end)
            }));
            return;
        }

        const nextAllowedAt = Math.max(job.nextAt || 0, readCooldownUntil());
        const remaining = nextAllowedAt - Date.now();
        if (remaining > 0) {
            scheduleTick(Math.min(remaining, 1000));
            return;
        }

        downloadInProgress = true;
        try {
            openDownload(file);
            const cooldownUntil = startCooldown();
            const nextJob = {
                ...job,
                index: job.index + 1,
                nextAt: cooldownUntil,
                lastRequested: file.part,
                heartbeatAt: Date.now()
            };
            writeJob(nextJob);
            downloadInProgress = false;

            if (nextJob.index >= nextJob.files.length) {
                finishJob(t('rangeComplete', {
                    package: nextJob.packageLabel,
                    start: formatPart(nextJob.start),
                    end: formatPart(nextJob.end)
                }));
            } else {
                renderStatus();
                scheduleTick(1000);
            }
        } catch (error) {
            downloadInProgress = false;
            finishJob(t('failed', {
                part: formatPart(file.part),
                error: error?.message || error
            }));
        }
    }

    function startQueue() {
        const detected = detectCurrentPackage();
        const startInput = document.querySelector('#pmq-from');
        const endInput = document.querySelector('#pmq-to');
        const status = document.querySelector('#pmq-status');
        const start = Number(startInput?.value);
        const end = Number(endInput?.value);

        if (!detected || detected.files.size < 2) {
            status.textContent = t('multipartRequired');
            status.dataset.state = 'error';
            return;
        }

        if (!Number.isInteger(start) || !Number.isInteger(end) || start > end) {
            status.textContent = t('invalidRange');
            status.dataset.state = 'error';
            return;
        }

        const selectedFiles = [];
        const missing = [];
        for (let part = start; part <= end; part += 1) {
            const file = detected.files.get(part);
            if (file) selectedFiles.push(file);
            else missing.push(formatPart(part));
        }

        if (missing.length) {
            status.textContent = t('missingLinks', { parts: missing.join(', ') });
            status.dataset.state = 'error';
            return;
        }

        writeJob({
            running: true,
            packageId: detected.id,
            packageLabel: detected.label,
            start,
            end,
            files: selectedFiles,
            index: 0,
            nextAt: Math.max(Date.now(), readCooldownUntil()),
            createdAt: Date.now(),
            ownerId: INSTANCE_ID,
            heartbeatAt: Date.now()
        });

        renderStatus();
        scheduleTick(0);
    }

    function cancelQueue() {
        clearJob();
        downloadInProgress = false;
        if (timerId !== null) window.clearTimeout(timerId);
        timerId = null;
        updateFormForCurrentPage();
        renderStatus();

        const status = document.querySelector('#pmq-status');
        if (status) {
            status.textContent = t('canceled');
            status.dataset.state = 'idle';
        }
    }

    function injectStyles() {
        const style = document.createElement('style');
        style.textContent = `
            #pmq-overlay {
                position: fixed;
                inset: 0;
                z-index: 2147483646;
                display: grid;
                place-items: center;
                background: rgba(0, 0, 0, .66);
                backdrop-filter: blur(4px);
                font-family: Inter, Pretendard, "Noto Sans KR", system-ui, sans-serif;
            }
            #pmq-overlay[hidden] { display: none; }
            #pmq-dialog {
                width: min(430px, calc(100vw - 32px));
                color: #f5f5f5;
                background: #101010;
                border: 1px solid #383838;
                border-radius: 16px;
                box-shadow: 0 24px 80px rgba(0, 0, 0, .65);
                overflow: hidden;
            }
            #pmq-head {
                display: flex;
                align-items: center;
                justify-content: space-between;
                padding: 18px 20px;
                border-bottom: 1px solid #2b2b2b;
            }
            #pmq-title { margin: 0; font-size: 18px; font-weight: 750; }
            #pmq-close {
                width: 32px;
                height: 32px;
                color: #aaa;
                background: transparent;
                border: 0;
                border-radius: 8px;
                font-size: 24px;
                line-height: 1;
                cursor: pointer;
            }
            #pmq-close:hover { color: white; background: #252525; }
            #pmq-body { padding: 20px; }
            .pmq-label { display: block; margin: 0 0 8px; color: #bbb; font-size: 13px; }
            #pmq-language-row {
                display: flex;
                align-items: center;
                justify-content: space-between;
                gap: 16px;
                margin-bottom: 18px;
            }
            #pmq-language-label { margin: 0; }
            .pmq-number,
            #pmq-language {
                box-sizing: border-box;
                height: 44px;
                color: #fff;
                background: #191919;
                border: 1px solid #3a3a3a;
                border-radius: 10px;
                padding: 0 12px;
                outline: none;
            }
            .pmq-number { width: 100%; }
            #pmq-language { width: 150px; height: 38px; }
            .pmq-number:focus,
            #pmq-language:focus { border-color: #ff5a00; box-shadow: 0 0 0 3px rgba(255, 90, 0, .15); }
            #pmq-range { display: grid; grid-template-columns: 1fr 28px 1fr; align-items: end; gap: 8px; }
            #pmq-range-separator { height: 44px; display: grid; place-items: center; color: #777; }
            #pmq-status {
                min-height: 20px;
                margin: 16px 0 0;
                padding: 12px;
                color: #bbb;
                background: #181818;
                border-radius: 10px;
                font-size: 13px;
                line-height: 1.5;
            }
            #pmq-status[data-state="waiting"] { color: #ffb27e; }
            #pmq-status[data-state="running"] { color: #ff6a16; }
            #pmq-status[data-state="done"] { color: #72d995; }
            #pmq-status[data-state="error"] { color: #ff7373; }
            #pmq-actions { display: flex; gap: 10px; margin-top: 18px; }
            .pmq-button {
                flex: 1;
                height: 44px;
                border: 0;
                border-radius: 10px;
                font-weight: 750;
                cursor: pointer;
            }
            #pmq-start { color: white; background: #f05400; }
            #pmq-start:hover { background: #ff6410; }
            #pmq-start:disabled { cursor: not-allowed; opacity: .45; }
            #pmq-cancel { color: #ddd; background: #292929; }
            #pmq-cancel:hover { background: #363636; }
            #pmq-dock {
                position: fixed;
                right: 22px;
                bottom: 22px;
                z-index: 2147483645;
                display: flex;
                align-items: center;
                gap: 7px;
                font-family: Inter, Pretendard, "Noto Sans KR", system-ui, sans-serif;
            }
            #pmq-counter {
                min-width: 34px;
                color: #777;
                font: 650 10px/1 ui-monospace, SFMono-Regular, Consolas, monospace;
                letter-spacing: -.3px;
                text-align: right;
                opacity: .82;
                user-select: none;
            }
            #pmq-counter[data-active="true"] { color: #ff9a5f; }
            #pmq-launcher {
                height: 44px;
                padding: 0 16px;
                color: #fff;
                background: #f05400;
                border: 0;
                border-radius: 22px;
                box-shadow: 0 8px 30px rgba(0, 0, 0, .4);
                font-weight: 750;
                cursor: pointer;
            }
        `;
        document.head.appendChild(style);
    }

    function injectUI() {
        const overlay = document.createElement('div');
        overlay.id = 'pmq-overlay';
        overlay.hidden = true;
        overlay.innerHTML = `
            <section id="pmq-dialog" role="dialog" aria-modal="true" aria-labelledby="pmq-title">
                <header id="pmq-head">
                    <h2 id="pmq-title">ProMods Download Queue</h2>
                    <button id="pmq-close" type="button" aria-label="Close">×</button>
                </header>
                <div id="pmq-body">
                    <div id="pmq-language-row">
                        <label id="pmq-language-label" class="pmq-label" for="pmq-language">Language</label>
                        <select id="pmq-language">
                            <option value="en">English</option>
                            <option value="ko">한국어</option>
                        </select>
                    </div>

                    <div id="pmq-range">
                        <div>
                            <label id="pmq-from-label" class="pmq-label" for="pmq-from">From part</label>
                            <input id="pmq-from" class="pmq-number" type="number" min="1" value="1" inputmode="numeric">
                        </div>
                        <div id="pmq-range-separator">→</div>
                        <div>
                            <label id="pmq-to-label" class="pmq-label" for="pmq-to">To part</label>
                            <input id="pmq-to" class="pmq-number" type="number" min="1" value="1" inputmode="numeric">
                        </div>
                    </div>

                    <p id="pmq-status" data-state="idle"></p>

                    <div id="pmq-actions">
                        <button id="pmq-cancel" class="pmq-button" type="button" hidden>Cancel Queue</button>
                        <button id="pmq-start" class="pmq-button" type="button">Start</button>
                    </div>
                </div>
            </section>
        `;

        const dock = document.createElement('div');
        dock.id = 'pmq-dock';
        dock.innerHTML = `
            <span id="pmq-counter" data-active="false" title="Download available">00:00</span>
            <button id="pmq-launcher" type="button">Download Queue</button>
        `;

        document.body.append(overlay, dock);

        document.querySelector('#pmq-close').addEventListener('click', () => {
            overlay.hidden = true;
        });
        document.querySelector('#pmq-launcher').addEventListener('click', () => {
            updateFormForCurrentPage();
            renderStatus();
            overlay.hidden = false;
            document.querySelector('#pmq-from')?.focus();
        });
        document.querySelector('#pmq-start').addEventListener('click', startQueue);
        document.querySelector('#pmq-cancel').addEventListener('click', cancelQueue);
        document.querySelector('#pmq-language').addEventListener('change', event => {
            setLanguage(event.target.value);
            applyLanguageToUI();
        });
        document.querySelector('#pmq-dialog').addEventListener('keydown', event => {
            if (event.key === 'Enter' && !readJob()?.running) {
                event.preventDefault();
                startQueue();
            }
            if (event.key === 'Escape') overlay.hidden = true;
        });

        updateFormForCurrentPage();
        applyLanguageToUI();
    }

    function watchManualDownloads() {
        document.addEventListener('click', event => {
            const link = event.target.closest?.('a.pm-dl-btn.free[href*="getdl.php"]');
            if (link) startCooldown();
        }, true);
    }

    function init() {
        if (GM_getValue(LEGACY_JOB_KEY, null)) GM_deleteValue(LEGACY_JOB_KEY);

        injectStyles();
        injectUI();
        watchManualDownloads();

        if (readJob()?.running) scheduleTick(0);
        uiIntervalId = window.setInterval(() => {
            renderDockCounter();
            const overlay = document.querySelector('#pmq-overlay');
            if (overlay && !overlay.hidden) renderStatus();
        }, 1000);

        document.addEventListener('visibilitychange', () => {
            if (!document.hidden && readJob()?.running) scheduleTick(0);
        });

        window.addEventListener('beforeunload', () => {
            if (timerId !== null) window.clearTimeout(timerId);
            if (uiIntervalId !== null) window.clearInterval(uiIntervalId);
        }, { once: true });
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init, { once: true });
    } else {
        init();
    }
})();