ProMods 분할 파일을 쿨타임에 맞춰 하나씩 예약 다운로드합니다. 사이트의 다운로드 제한을 우회하지 않습니다.
// ==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();
}
})();