Automatically configures minimum-cost magic and optionally applies it before downloading torrents.
// ==UserScript==
// @name U2 Magic Form
// @namespace https://u2.dmhy.org/
// @version 2.0
// @description Automatically configures minimum-cost magic and optionally applies it before downloading torrents.
// @author LightArrowsEXE
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_listValues
// @grant GM_registerMenuCommand
// @match *://u2.dmhy.org/userdetails.php*
// @match *://u2.dmhy.org/torrents.php*
// @match *://u2.dmhy.org/details.php*
// @match *://u2.dmhy.org/promotion.php*
// @icon https://u2.dmhy.org/favicon.ico
// @license MIT
// @run-at document-end
// @noframes
// ==/UserScript==
(function () {
'use strict';
const SCRIPT_NAME = 'U2 Magic Form';
const STORAGE_PREFIX = 'u2-magic-form:';
const TORRENT_CACHE_PREFIX = `${STORAGE_PREFIX}torrent:`;
const SETTINGS_KEY = `${STORAGE_PREFIX}settings`;
const MAGIC_CACHE_MS = hours(23);
const MAGIC_HOURS = '24';
const DEFAULT_COST_LIMIT = 250_000;
const REQUEST_TIMEOUT_MS = 15_000;
const COST_WAIT_MS = 5_000;
const DEFAULT_SETTINGS = Object.freeze({
autoApplyOnDownload: true,
costLimit: DEFAULT_COST_LIMIT,
overLimitAction: 'warn', // skip | warn | apply
});
const MAGIC_FORM_SELECTORS = Object.freeze({
hours: 'input[name="hours"]',
selfRadio: '#s_user_self, input[name="user"][value="SELF"], input[name="user"][value="self"]',
promotion: 'select[name="promotion"]',
queryButton: '#btn_query',
submitButton: '#btn_submit',
price: '#label_price .ucoin-notation[title], #label_price [title], .ucoin-notation[title]',
priceLabel: '#label_price',
});
const OVER_LIMIT_ACTIONS = new Set(['skip', 'warn', 'apply']);
const ALLOWED_PATHS = new Set(['/userdetails.php', '/torrents.php', '/details.php', '/promotion.php']);
const BUTTON_SETS = Object.freeze({
ok: [
{ value: 'ok', label: 'OK', primary: true },
],
overLimit: [
{ value: 'apply', label: 'Apply magic and download', primary: true },
{ value: 'skip', label: 'Download without magic' },
{ value: 'cancel', label: 'Cancel download' },
],
applyFailed: [
{ value: 'retry', label: 'Retry', primary: true },
{ value: 'download', label: 'Download without magic' },
{ value: 'cancel', label: 'Cancel download' },
],
overLimitSetting: [
{ value: 'warn', label: 'Ask every time' },
{ value: 'skip', label: 'Download without magic' },
{ value: 'apply', label: 'Apply magic anyway' },
{ value: 'cancel', label: 'Cancel' },
],
});
let settingsCache = null;
init();
function init() {
if (!isAllowedPage()) return;
injectStyles();
registerMenuCommands();
pruneOldCaches();
if (isMagicPage()) configureVisibleMagicForm();
document.addEventListener('click', onDownloadClick, true);
}
async function onDownloadClick(event) {
const settings = readSettings();
if (!settings.autoApplyOnDownload) return;
const link = event.target?.closest?.('a[href*="download.php?id="]');
if (!link) return;
const torrentId = getTorrentIdFromUrl(link.href);
if (!torrentId || isMagicCached(torrentId)) return;
const promotionContext = findPromotionContext(link);
if (promotionContext && hasFreeOrEquivalentPromotion(promotionContext)) {
writeTorrentCache(torrentId, 'already-free');
return;
}
event.preventDefault();
event.stopPropagation();
await applyMagicThenDownload({ torrentId, downloadUrl: link.href, settings });
}
async function applyMagicThenDownload({ torrentId, downloadUrl, settings }) {
while (true) {
try {
const result = await applyMagicHidden(torrentId, settings);
if (result.cacheReason) writeTorrentCache(torrentId, result.cacheReason);
if (result.continueDownload) window.location.href = downloadUrl;
return;
} catch (error) {
console.error(`[${SCRIPT_NAME}] Failed to apply magic:`, error);
const choice = await showApplyFailedDialog(error);
if (choice === 'retry') continue;
if (choice === 'download') window.location.href = downloadUrl;
return;
}
}
}
async function applyMagicHidden(torrentId, settings) {
const iframe = await loadHiddenMagicFrame(torrentId);
try {
const doc = iframe.contentDocument;
if (!doc?.querySelector('form')) throw new Error('magic form not found');
configureMagicForm(doc);
const cost = await calculateMagicCost(doc);
if (!Number.isFinite(cost)) throw new Error('could not read UCoin cost');
const decision = await resolveOverLimitDecision(cost, settings);
if (decision === 'cancel') return { continueDownload: false, cacheReason: null };
if (decision === 'skip') return { continueDownload: true, cacheReason: 'over-limit-skipped' };
await submitMagicInFrame(doc);
return { continueDownload: true, cacheReason: 'magic-applied' };
} finally {
iframe.remove();
}
}
async function resolveOverLimitDecision(cost, settings) {
const limit = Number(settings.costLimit);
if (!Number.isFinite(limit) || limit <= 0 || cost <= limit) return 'apply';
if (settings.overLimitAction !== 'warn') return settings.overLimitAction;
return showChoiceDialog({
title: 'Magic cost above limit',
message: [
`Applying magic would cost ${formatUCoinsWithUnit(cost)}.`,
`Your configured limit is ${formatUCoinsWithUnit(limit)}.`,
'',
'Choose what to do with this download.',
],
buttons: BUTTON_SETS.overLimit,
});
}
function showApplyFailedDialog(error) {
return showChoiceDialog({
title: 'Could not apply magic',
message: [
'U2 Magic Form could not apply magic automatically.',
'',
`Reason: ${errorMessage(error)}`,
'',
'Choose what to do with this download.',
],
buttons: BUTTON_SETS.applyFailed,
});
}
function loadHiddenMagicFrame(torrentId) {
return new Promise((resolve, reject) => {
const iframe = createNode('iframe', {
className: 'u2-magic-hidden-frame',
src: buildMagicUrl(torrentId),
});
const fail = error => {
window.clearTimeout(timeout);
iframe.remove();
reject(error);
};
const timeout = window.setTimeout(() => {
fail(new Error('magic page timed out'));
}, REQUEST_TIMEOUT_MS);
iframe.addEventListener('load', () => {
window.clearTimeout(timeout);
const doc = iframe.contentDocument;
const text = normaliseText(doc?.body?.textContent || '').toLowerCase();
if (text.includes('invalid id')) {
fail(new Error('U2 returned invalid ID'));
return;
}
if (!doc?.querySelector('form')) {
fail(new Error('magic form not found'));
return;
}
resolve(iframe);
}, { once: true });
document.body.append(iframe);
});
}
function configureVisibleMagicForm() {
const form = document.querySelector('form');
if (!form || form.dataset.u2MagicConfigured === '1') return;
form.dataset.u2MagicConfigured = '1';
configureMagicForm(document);
window.setTimeout(() => getMagicElement(document, 'queryButton')?.click(), 100);
}
function configureMagicForm(doc) {
const hoursInput = getMagicElement(doc, 'hours');
const selfRadio = getMagicElement(doc, 'selfRadio');
const promotionSelect = getMagicElement(doc, 'promotion');
if (hoursInput) setFieldValue(hoursInput, MAGIC_HOURS);
if (selfRadio && !selfRadio.checked) {
selfRadio.checked = true;
selfRadio.dispatchEvent(new Event('change', { bubbles: true }));
}
if (promotionSelect && promotionSelect.value !== '2') {
setFieldValue(promotionSelect, '2'); // Free
}
}
async function calculateMagicCost(doc) {
const before = readMagicCost(doc);
const queryButton = getMagicElement(doc, 'queryButton');
if (!queryButton) return before;
queryButton.click();
const changed = await waitFor(() => {
const after = readMagicCost(doc);
return Number.isFinite(after) && after !== before ? after : null;
}, COST_WAIT_MS);
return changed ?? readMagicCost(doc);
}
function submitMagicInFrame(doc) {
return new Promise((resolve, reject) => {
const submitButton = getMagicElement(doc, 'submitButton');
const form = submitButton?.closest('form') || doc.querySelector('form');
const iframe = doc.defaultView?.frameElement;
if (!submitButton || !form || !iframe) {
reject(new Error('magic submit button not found'));
return;
}
const timeout = window.setTimeout(() => {
reject(new Error('magic submit timed out'));
}, REQUEST_TIMEOUT_MS);
iframe.addEventListener('load', () => {
window.clearTimeout(timeout);
const text = normaliseText(iframe.contentDocument?.body?.textContent || '').toLowerCase();
if (text.includes('invalid id')) {
reject(new Error('U2 returned invalid ID'));
return;
}
if (looksLikeErrorPage(text)) {
reject(new Error('U2 returned an error after submitting magic'));
return;
}
resolve();
}, { once: true });
submitButton.click();
});
}
function looksLikeErrorPage(text) {
if (!text) return false;
return (
text.includes('invalid id') ||
text.includes('access denied') ||
text.includes('permission denied') ||
(
text.includes('error') &&
!text.includes('success') &&
!text.includes('succeed')
)
);
}
function readMagicCost(doc) {
const price = getMagicElement(doc, 'price');
if (price?.getAttribute('title')) {
return parseUCoins(price.getAttribute('title'));
}
return parseUCoins(getMagicElement(doc, 'priceLabel')?.textContent || '');
}
function getMagicElement(doc, key) {
return doc.querySelector(MAGIC_FORM_SELECTORS[key]);
}
function parseUCoins(value) {
const parsed = Number.parseFloat(
String(value || '')
.replace(/[^\d.,-]/g, '')
.replace(/,/g, '')
);
return Number.isFinite(parsed) ? parsed : NaN;
}
function findPromotionContext(downloadLink) {
const torrentName = downloadLink.closest('table.torrentname');
if (torrentName) {
const row = torrentName.closest('td.rowfollow, td.embedded, td')?.closest('tr');
if (row) return row;
}
return downloadLink.closest('tr') || (location.pathname === '/details.php' ? document.body : null);
}
function hasFreeOrEquivalentPromotion(context) {
const promotionText = normaliseText(getPromotionText(context)).toLowerCase();
const contextText = normaliseText(context.textContent).toLowerCase();
if (!promotionText && !contextText) return false;
if (promotionText.includes('free') || contextText.includes('free')) return true;
const hasOtherPromotion =
promotionText.includes('other') ||
contextText.includes('other') ||
Boolean(context.querySelector?.('.custompromotion'));
if (!hasOtherPromotion) return false;
const hasUploadBonus =
promotionText.includes('ul ratio') ||
contextText.includes('ul ratio') ||
promotionText.includes('upload') ||
contextText.includes('upload') ||
Boolean(context.querySelector?.('img.arrowup')) ||
/\b\d+(?:\.\d+)?x\b/i.test(promotionText);
return hasUploadBonus && hasNoDownloadRatioCost(context);
}
function hasNoDownloadRatioCost(context) {
const downloadColumnText = getNamedColumnText(context, ['D.L.', 'DL', 'Download', 'Downloaded']);
if (downloadColumnText !== null) {
return normaliseText(downloadColumnText).toLowerCase() === '0 b';
}
const promotionText = normaliseText(getPromotionText(context)).toLowerCase();
if (!promotionText) return true;
if (/\b(?:download|dl)\s*ratio\b/.test(promotionText)) {
return /(?:download|dl)\s*ratio[^0-9]*0(?:\.0+)?x?/.test(promotionText);
}
if (/\[(?:\d+)%\]/.test(promotionText)) {
return /\[0%\]/.test(promotionText);
}
return true;
}
function getPromotionText(context) {
const torrentName = context.querySelector?.('table.torrentname');
return torrentName?.querySelector('tr:nth-child(2)')?.textContent || context.textContent || '';
}
function getNamedColumnText(context, names) {
const row = context.matches?.('tr') ? context : context.closest?.('tr');
if (!row) return null;
const table = row.closest('table');
const headerRow = table?.querySelector('tr');
if (!headerRow) return null;
const normalisedNames = names.map(normaliseColumnName);
const columnIndex = [...headerRow.children].findIndex(header => {
const text = header.textContent || header.querySelector('img')?.title || '';
return normalisedNames.includes(normaliseColumnName(text));
});
return columnIndex < 0 ? null : row.children[columnIndex]?.textContent ?? null;
}
function registerMenuCommands() {
if (typeof GM_registerMenuCommand !== 'function') return;
const settings = readSettings();
GM_registerMenuCommand(
`Auto-apply at download: ${settings.autoApplyOnDownload ? 'enabled' : 'disabled'}`,
toggleAutoApply
);
GM_registerMenuCommand(
`Set ucoin limit: ${settings.costLimit > 0 ? formatUCoins(settings.costLimit) : 'no limit'}`,
configureCostLimit
);
GM_registerMenuCommand(
`Over-limit behaviour: ${settings.overLimitAction}`,
configureOverLimitAction
);
}
async function toggleAutoApply() {
const settings = readSettings();
const autoApplyOnDownload = !settings.autoApplyOnDownload;
writeSettings({ ...settings, autoApplyOnDownload });
await showInfoDialog(
'Setting updated',
`Auto-apply at download is now ${autoApplyOnDownload ? 'enabled' : 'disabled'}.`
);
}
function configureCostLimit() {
const settings = readSettings();
const value = window.prompt(
[
'Enter the maximum UCoin cost for auto-applied magic.',
'',
'Use the full UCoin number shown in the tooltip, e.g. 250000 or 250,000.',
'Enter 0 to disable the limit.',
].join('\n'),
String(settings.costLimit)
);
if (value === null) return;
const costLimit = parseUCoins(value);
if (!Number.isFinite(costLimit) || costLimit < 0) {
window.alert('Invalid UCoin limit.');
return;
}
writeSettings({ ...settings, costLimit });
}
async function configureOverLimitAction() {
const settings = readSettings();
const value = await showChoiceDialog({
title: 'Set over-limit behaviour',
message: 'Choose what to do when magic would cost more than your configured limit.',
buttons: withPrimary(BUTTON_SETS.overLimitSetting, settings.overLimitAction),
});
if (!value || value === 'cancel' || !OVER_LIMIT_ACTIONS.has(value)) return;
writeSettings({ ...settings, overLimitAction: value });
}
function readSettings() {
if (settingsCache) return settingsCache;
const stable = GM_getValue(SETTINGS_KEY, null);
const migrated = stable || readLegacySettings();
settingsCache = normaliseSettings(migrated);
GM_setValue(SETTINGS_KEY, settingsCache);
return settingsCache;
}
function readLegacySettings() {
const legacyKeys = [
'u2-magic-form:v8:settings',
'u2-magic-form:v7:settings',
'u2-magic-form:v6:settings',
'u2-magic-form:v5:settings',
];
for (const key of legacyKeys) {
const value = GM_getValue(key, null);
if (value && typeof value === 'object') return value;
}
return null;
}
function normaliseSettings(settings = {}) {
const costLimit = Number(settings.costLimit);
return {
autoApplyOnDownload: typeof settings.autoApplyOnDownload === 'boolean'
? settings.autoApplyOnDownload
: DEFAULT_SETTINGS.autoApplyOnDownload,
costLimit: Number.isFinite(costLimit) && costLimit >= 0
? costLimit
: DEFAULT_SETTINGS.costLimit,
overLimitAction: OVER_LIMIT_ACTIONS.has(settings.overLimitAction)
? settings.overLimitAction
: DEFAULT_SETTINGS.overLimitAction,
};
}
function writeSettings(settings) {
settingsCache = normaliseSettings(settings);
GM_setValue(SETTINGS_KEY, settingsCache);
}
function showInfoDialog(title, message) {
return showChoiceDialog({
title,
message,
buttons: BUTTON_SETS.ok,
});
}
function showChoiceDialog({ title, message, buttons }) {
return new Promise(resolve => {
document.querySelector('.u2-magic-dialog-backdrop')?.remove();
const backdrop = createNode('div', { className: 'u2-magic-dialog-backdrop' });
const dialog = createNode('div', {
className: 'u2-magic-dialog',
role: 'dialog',
ariaModal: 'true',
});
const buttonRow = createNode('div', { className: 'u2-magic-dialog-buttons' });
const close = value => {
backdrop.remove();
document.removeEventListener('keydown', onKeyDown, true);
resolve(value);
};
const onKeyDown = event => {
if (event.key !== 'Escape') return;
event.preventDefault();
close('cancel');
};
for (const button of buttons) {
const node = createNode('button', {
type: 'button',
className: `u2-magic-dialog-button${button.primary ? ' u2-magic-dialog-button-primary' : ''}`,
textContent: button.label,
});
node.addEventListener('click', () => close(button.value));
buttonRow.append(node);
}
dialog.append(
createNode('h2', { textContent: title }),
createMessageBlock(message),
buttonRow
);
backdrop.append(dialog);
document.body.append(backdrop);
document.addEventListener('keydown', onKeyDown, true);
dialog.querySelector('.u2-magic-dialog-button-primary, .u2-magic-dialog-button')?.focus();
});
}
function createMessageBlock(message) {
const body = createNode('div', { className: 'u2-magic-dialog-body' });
const lines = Array.isArray(message) ? message : String(message).split('\n');
for (const line of lines) {
body.append(createNode('p', { textContent: line || '\u00a0' }));
}
return body;
}
function createNode(tag, props = {}) {
const node = document.createElement(tag);
for (const [key, value] of Object.entries(props)) {
if (key === 'className') node.className = value;
else if (key === 'textContent') node.textContent = value;
else if (key === 'ariaModal') node.setAttribute('aria-modal', value);
else node[key] = value;
}
return node;
}
function withPrimary(buttons, primaryValue) {
return buttons.map(button => ({ ...button, primary: button.value === primaryValue }));
}
function buildMagicUrl(torrentId) {
const url = new URL('/promotion.php', location.origin);
url.searchParams.set('action', 'magic');
url.searchParams.set('torrent', torrentId);
return url.toString();
}
function getTorrentIdFromUrl(url) {
try {
const id = new URL(url, location.href).searchParams.get('id');
return id && /^\d+$/.test(id) ? id : null;
} catch {
return null;
}
}
function isAllowedPage() {
const url = new URL(location.href);
if (!ALLOWED_PATHS.has(url.pathname)) return false;
return url.pathname !== '/promotion.php' || url.searchParams.get('action') === 'magic';
}
function isMagicPage() {
const url = new URL(location.href);
return url.pathname === '/promotion.php' && url.searchParams.get('action') === 'magic';
}
function isMagicCached(torrentId) {
return Boolean(readFreshCache(getTorrentKey(torrentId), MAGIC_CACHE_MS));
}
function writeTorrentCache(torrentId, reason) {
writeCache(getTorrentKey(torrentId), { reason, torrentId });
}
function readFreshCache(key, maxAgeMs) {
const cached = GM_getValue(key, null);
if (!cached?.checkedAt) return null;
return Date.now() - cached.checkedAt <= maxAgeMs ? cached : null;
}
function writeCache(key, value) {
GM_setValue(key, { ...value, checkedAt: Date.now() });
}
function pruneOldCaches() {
for (const key of safeListValues()) {
if (!key.startsWith(TORRENT_CACHE_PREFIX)) continue;
if (!readFreshCache(key, MAGIC_CACHE_MS)) GM_deleteValue(key);
}
}
function safeListValues() {
try {
return typeof GM_listValues === 'function' ? GM_listValues() : [];
} catch (error) {
console.error(`[${SCRIPT_NAME}] Failed to list userscript storage values:`, error);
return [];
}
}
function getTorrentKey(torrentId) {
return `${TORRENT_CACHE_PREFIX}${torrentId}`;
}
function setFieldValue(field, value) {
field.value = value;
field.dispatchEvent(new Event('input', { bubbles: true }));
field.dispatchEvent(new Event('change', { bubbles: true }));
}
function waitFor(callback, timeoutMs) {
return new Promise((resolve, reject) => {
const startedAt = Date.now();
const tick = () => {
try {
const result = callback();
if (result !== null && result !== undefined) {
resolve(result);
return;
}
if (Date.now() - startedAt >= timeoutMs) {
resolve(null);
return;
}
window.setTimeout(tick, 100);
} catch (error) {
reject(error);
}
};
tick();
});
}
function normaliseColumnName(text) {
return normaliseText(text)
.replace(/\s+/g, '')
.replace(/[::]/g, '')
.toLowerCase();
}
function normaliseText(text) {
return String(text || '')
.replace(/\u00a0/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function formatUCoins(value) {
return Number(value).toLocaleString(undefined, { maximumFractionDigits: 2 });
}
function formatUCoinsWithUnit(value) {
const amount = Number(value);
return `${formatUCoins(amount)} ${amount === 1 ? 'ucoin' : 'ucoins'}`;
}
function errorMessage(error) {
return error instanceof Error && error.message ? error.message : 'unknown error';
}
function injectStyles() {
if (document.getElementById('u2-magic-form-style')) return;
const style = createNode('style', { id: 'u2-magic-form-style' });
style.textContent = `
.u2-magic-hidden-frame {
position: fixed !important;
left: -10000px !important;
top: -10000px !important;
width: 1px !important;
height: 1px !important;
border: 0 !important;
opacity: 0 !important;
pointer-events: none !important;
}
.u2-magic-dialog-backdrop {
position: fixed;
z-index: 2147483647;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: rgba(0, 0, 0, 0.55);
}
.u2-magic-dialog {
box-sizing: border-box;
width: min(520px, 100%);
max-height: min(720px, 90vh);
overflow: auto;
padding: 18px;
border: 1px solid #555;
border-radius: 6px;
background: #f8f8f8;
color: #222;
box-shadow: 0 10px 36px rgba(0, 0, 0, 0.35);
font: 13px/1.45 Arial, Helvetica, sans-serif;
}
.u2-magic-dialog h2 {
margin: 0 0 12px;
padding: 0;
border: 0;
background: transparent;
color: #111;
font-size: 17px;
line-height: 1.3;
}
.u2-magic-dialog-body {
margin: 0 0 16px;
}
.u2-magic-dialog-body p {
margin: 0 0 4px;
}
.u2-magic-dialog-buttons {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
.u2-magic-dialog-button {
cursor: pointer;
padding: 6px 10px;
border: 1px solid #888;
border-radius: 4px;
background: #fff;
color: #222;
font: inherit;
}
.u2-magic-dialog-button:hover,
.u2-magic-dialog-button:focus {
border-color: #444;
background: #eee;
outline: none;
}
.u2-magic-dialog-button-primary {
border-color: #24578a;
background: #2f6fa8;
color: #fff;
}
.u2-magic-dialog-button-primary:hover,
.u2-magic-dialog-button-primary:focus {
border-color: #183c5f;
background: #24578a;
}
`;
document.head.append(style);
}
function hours(value) {
return value * 60 * 60 * 1000;
}
})();