// ==UserScript==
// @name Magnet Link to Real-Debrid
// @version 2.3.0
// @description Automatically send magnet links to Real-Debrid
// @author Journey Over
// @license MIT
// @match *://*/*
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @connect api.real-debrid.com
// @icon https://www.google.com/s2/favicons?sz=64&domain=real-debrid.com
// @homepageURL https://github.com/StylusThemes/Userscripts
// @namespace https://greasyfork.org/users/32214
// ==/UserScript==
(() => {
'use strict';
/* Constants & Utilities */
const STORAGE_KEY = 'realDebridConfig';
const API_BASE = 'https://api.real-debrid.com/rest/1.0';
const ICON_SRC = 'https://fcdn.real-debrid.com/0830/favicons/favicon.ico';
const INSERTED_ICON_ATTR = 'data-rd-inserted';
const DEFAULTS = {
apiKey: '',
allowedExtensions: ['mp3', 'm4b', 'mp4', 'mkv', 'cbz', 'cbr'],
filterKeywords: ['sample', 'bloopers', 'trailer'],
debugMode: false
};
// Simple debounce helper for DOM mutation handling
const debounce = (fn, ms = 120) => {
let t;
return (...args) => {
clearTimeout(t);
t = setTimeout(() => fn(...args), ms);
};
};
/* Errors */
class ConfigurationError extends Error {
constructor(message) {
super(message);
this.name = 'ConfigurationError';
}
}
class RealDebridError extends Error {
constructor(message, statusCode = null) {
super(message);
this.name = 'RealDebridError';
this.statusCode = statusCode;
}
}
/* Config Manager */
class ConfigManager {
// Parse stored JSON safely, falling back to null on failure
static _safeParse(value) {
if (!value) return null;
try {
return typeof value === 'string' ? JSON.parse(value) : value;
} catch (e) {
console.warn('Config parse failed, resetting to defaults.', e);
return null;
}
}
static getConfig() {
const stored = GM_getValue(STORAGE_KEY);
const parsed = this._safeParse(stored) || {};
return { ...DEFAULTS, ...parsed };
}
// Persist configuration; API key required
static saveConfig(cfg) {
if (!cfg || !cfg.apiKey) throw new ConfigurationError('API Key is required');
GM_setValue(STORAGE_KEY, JSON.stringify(cfg));
}
static validateConfig(cfg) {
const errors = [];
if (!cfg || !cfg.apiKey) errors.push('API Key is missing');
if (!Array.isArray(cfg.allowedExtensions)) errors.push('allowedExtensions must be an array');
if (!Array.isArray(cfg.filterKeywords)) errors.push('filterKeywords must be an array');
return errors;
}
}
/* Real-Debrid Service (wraps GM_xmlhttpRequest) */
class RealDebridService {
#apiKey;
#debug;
constructor(apiKey, { debugMode = false } = {}) {
if (!apiKey) throw new ConfigurationError('API Key required');
this.#apiKey = apiKey;
this.#debug = Boolean(debugMode);
}
#log(...args) {
if (this.#debug) console.log('[RealDebridService]', ...args);
}
// Generic request wrapper: handles headers, encoding and JSON parsing/errors
#request(method, endpoint, data = null) {
return new Promise((resolve, reject) => {
const url = `${API_BASE}${endpoint}`;
const payload = data ? new URLSearchParams(data).toString() : null;
this.#log('request', method, url, data);
GM_xmlhttpRequest({
method,
url,
headers: {
Authorization: `Bearer ${this.#apiKey}`,
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded'
},
data: payload,
onload: (resp) => {
this.#log('response', resp.status, resp.responseText && resp.responseText.slice && resp.responseText.slice(0, 500));
if (!resp || typeof resp.status === 'undefined') {
return reject(new RealDebridError('Invalid API response'));
}
if (resp.status < 200 || resp.status >= 300) {
const msg = resp.responseText ? resp.responseText : `HTTP ${resp.status}`;
return reject(new RealDebridError(`API Error: ${msg}`, resp.status));
}
if (resp.status === 204 || !resp.responseText) return resolve({});
try {
const parsed = JSON.parse(resp.responseText.trim());
return resolve(parsed);
} catch (e) {
this.#log('parse error', e);
return reject(new RealDebridError(`Failed to parse API response: ${e.message}`, resp.status));
}
},
onerror: (err) => {
this.#log('network error', err);
return reject(new RealDebridError('Network request failed'));
},
ontimeout: () => {
this.#log('timeout');
return reject(new RealDebridError('Request timed out'));
}
});
});
}
addMagnet(magnet) {
return this.#request('POST', '/torrents/addMagnet', { magnet });
}
getTorrentInfo(torrentId) {
return this.#request('GET', `/torrents/info/${torrentId}`);
}
selectFiles(torrentId, filesCsv) {
return this.#request('POST', `/torrents/selectFiles/${torrentId}`, { files: filesCsv });
}
getExistingTorrents() {
// Gracefully return an empty array on failure
return this.#request('GET', '/torrents').catch(() => []);
}
}
/* Magnet Processing */
class MagnetLinkProcessor {
#config;
#api;
#existing = [];
constructor(config, api) {
this.#config = config;
this.#api = api;
}
async initialize() {
try {
this.#existing = await this.#api.getExistingTorrents();
if (this.#config.debugMode) console.log('[MagnetLinkProcessor] existing torrents', this.#existing);
} catch (e) {
console.warn('Failed to load existing torrents', e);
this.#existing = [];
}
}
// Extract BTIH (hash) from magnet link
static parseMagnetHash(magnetLink) {
if (!magnetLink || typeof magnetLink !== 'string') return null;
try {
const qIdx = magnetLink.indexOf('?');
const qs = qIdx >= 0 ? magnetLink.slice(qIdx + 1) : magnetLink;
const params = new URLSearchParams(qs);
const xt = params.get('xt');
if (xt) {
const match = xt.match(/urn:btih:([A-Za-z0-9]+)/i);
if (match) return match[1].toUpperCase();
}
const fallback = magnetLink.match(/xt=urn:btih:([A-Za-z0-9]+)/i);
if (fallback) return fallback[1].toUpperCase();
return null;
} catch (e) {
const m = magnetLink.match(/xt=urn:btih:([A-Za-z0-9]+)/i);
return m ? m[1].toUpperCase() : null;
}
}
isTorrentExists(hash) {
if (!hash) return false;
return Array.isArray(this.#existing) && this.#existing.some(t => (t.hash || '').toUpperCase() === hash);
}
// Filter torrent files by allowed extensions and filter keywords (supports regex-like /.../)
filterFiles(files = []) {
const allowed = new Set(this.#config.allowedExtensions.map(s => s.trim().toLowerCase()).filter(Boolean));
const keywords = (this.#config.filterKeywords || []).map(k => k.trim()).filter(Boolean);
return (files || []).filter(file => {
const path = (file.path || '').toLowerCase();
const name = path.split('/').pop() || '';
const ext = name.includes('.') ? name.split('.').pop() : '';
if (!allowed.has(ext)) return false;
for (const kw of keywords) {
if (!kw) continue;
if (kw.startsWith('/') && kw.endsWith('/')) {
try {
const re = new RegExp(kw.slice(1, -1), 'i');
if (re.test(path) || re.test(name)) return false;
} catch (e) {
// invalid regex: ignore it
}
}
if (path.includes(kw.toLowerCase()) || name.includes(kw.toLowerCase())) return false;
}
return true;
});
}
async processMagnetLink(magnetLink) {
const hash = MagnetLinkProcessor.parseMagnetHash(magnetLink);
if (!hash) throw new RealDebridError('Invalid magnet link');
if (this.isTorrentExists(hash)) throw new RealDebridError('Torrent already exists on Real-Debrid');
const addResult = await this.#api.addMagnet(magnetLink);
if (!addResult || typeof addResult.id === 'undefined') {
throw new RealDebridError('Failed to add magnet');
}
const torrentId = addResult.id;
const info = await this.#api.getTorrentInfo(torrentId);
const files = Array.isArray(info.files) ? info.files : [];
const chosen = this.filterFiles(files).map(f => f.id);
if (!chosen.length) throw new RealDebridError('No matching files found after filtering');
await this.#api.selectFiles(torrentId, chosen.join(','));
return chosen.length;
}
}
/* UI Manager */
class UIManager {
// Build and return modal dialog DOM. Caller must append it to document.
static createConfigDialog(currentConfig) {
const dialog = document.createElement('div');
dialog.innerHTML = `
<div style="position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.85);display:flex;justify-content:center;align-items:center;z-index:10000;font-family:Arial,sans-serif;">
<div style="background:#1e1e2f;color:#ffffff;padding:35px;border-radius:16px;max-width:500px;width:90%;box-shadow:0 10px 30px rgba(0,0,0,0.5);border:1px solid #2c2c3a;">
<h2 style="text-align:center;color:#4db6ac;margin-bottom:25px;border-bottom:2px solid #4db6ac;padding-bottom:12px;">Real-Debrid Configuration</h2>
<div style="margin-bottom:20px;">
<label style="display:block;margin-bottom:8px;font-weight:bold;color:#b2ebf2;">API Key</label>
<input type="text" id="apiKey" placeholder="Enter your Real-Debrid API Key" value="${currentConfig.apiKey}"
style="width:100%;padding:12px;border:1px solid #4db6ac;border-radius:8px;background-color:#2c2c3a;color:#ffffff;">
</div>
<div style="margin-bottom:20px;">
<label style="display:block;margin-bottom:8px;font-weight:bold;color:#b2ebf2;">Allowed Extensions</label>
<textarea id="extensions" placeholder="Enter file extensions to allow"
style="width:100%;padding:12px;border:1px solid #4db6ac;border-radius:8px;background-color:#2c2c3a;color:#ffffff;min-height:80px;">${currentConfig.allowedExtensions.join(',')}</textarea>
<small style="color:#80cbc4;display:block;margin-top:6px;">Separate extensions with commas (e.g., mp4,mkv,avi)</small>
</div>
<div style="margin-bottom:20px;">
<label style="display:block;margin-bottom:8px;font-weight:bold;color:#b2ebf2;">Filter Keywords</label>
<textarea id="keywords" placeholder="Enter keywords to filter out"
style="width:100%;padding:12px;border:1px solid #4db6ac;border-radius:8px;background-color:#2c2c3a;color:#ffffff;min-height:80px;">${currentConfig.filterKeywords.join(',')}</textarea>
<small style="color:#80cbc4;display:block;margin-top:6px;">Separate keywords with commas (e.g., sample, /trailer/, /featurette?s/)</small>
</div>
<div style="margin-bottom:20px;">
<label style="display:flex;align-items:center;color:#b2ebf2;">
<input type="checkbox" id="debugMode" ${currentConfig.debugMode ? 'checked' : ''}
style="margin-right:12px;width:18px;height:18px;border-radius:4px;background-color:#2c2c3a;border:2px solid #4db6ac;">
Enable Debug Mode
</label>
<small style="color:#80cbc4;display:block;margin-top:6px;">Provides additional logging in the browser console</small>
</div>
<div style="display:flex;justify-content:space-between;margin-top:25px;">
<button id="saveBtn" style="background:#4db6ac;color:#1e1e2f;border:none;padding:12px 24px;border-radius:8px;cursor:pointer;transition:all 0.3s ease-in-out;font-weight:bold;">Save</button>
<button id="cancelBtn" style="background:#e57373;color:#1e1e2f;border:none;padding:12px 24px;border-radius:8px;cursor:pointer;transition:all 0.3s ease-in-out;font-weight:bold;">Cancel</button>
</div>
</div>
</div>
`;
const saveBtn = dialog.querySelector('#saveBtn');
const cancelBtn = dialog.querySelector('#cancelBtn');
saveBtn.addEventListener('mouseover', () => saveBtn.style.background = '#2980b9');
saveBtn.addEventListener('mouseout', () => saveBtn.style.background = '#4db6ac');
cancelBtn.addEventListener('mouseover', () => cancelBtn.style.background = '#c0392b');
cancelBtn.addEventListener('mouseout', () => cancelBtn.style.background = '#e57373');
// ESC key handler: remove dialog on Escape
const escHandler = (e) => {
if (e.key === 'Escape') {
if (dialog.parentNode) dialog.parentNode.removeChild(dialog);
document.removeEventListener('keydown', escHandler);
}
};
document.addEventListener('keydown', escHandler);
dialog._escHandler = escHandler;
return dialog;
}
static showToast(message, type = 'info') {
const colors = { success: '#16a34a', error: '#dc2626', info: '#2563eb' };
const msgDiv = document.createElement('div');
Object.assign(msgDiv.style, {
position: 'fixed',
bottom: '20px',
left: '20px',
backgroundColor: colors[type] || colors.info,
color: 'white',
padding: '10px 14px',
borderRadius: '8px',
zIndex: 10000,
fontWeight: '600'
});
msgDiv.textContent = message;
document.body.appendChild(msgDiv);
setTimeout(() => msgDiv.remove(), 3000);
}
static createMagnetIcon() {
const icon = document.createElement('img');
icon.src = ICON_SRC;
icon.style.cursor = 'pointer';
icon.style.width = '16px';
icon.style.marginLeft = '5px';
icon.setAttribute(INSERTED_ICON_ATTR, '1');
return icon;
}
}
/* Page Integration: find magnet links & insert icons (one icon per unique magnet) */
class PageIntegrator {
constructor(processor = null) {
this.processor = processor;
this.observer = null;
this.config = ConfigManager.getConfig();
this.keyToIcon = new Map();
this._populateFromDOM();
}
setProcessor(processor) {
this.processor = processor;
}
_populateFromDOM() {
try {
const links = Array.from(document.querySelectorAll('a[href^="magnet:"]'));
links.forEach(link => {
const next = link.nextElementSibling;
if (next && next.getAttribute && next.getAttribute(INSERTED_ICON_ATTR)) {
const key = this._magnetKeyFor(link.href) || `href:${link.href.trim().toLowerCase()}`;
if (!this.keyToIcon.has(key)) this.keyToIcon.set(key, next);
}
});
} catch (e) {
// ignore DOM inspection errors
}
}
_magnetKeyFor(href) {
const hash = MagnetLinkProcessor.parseMagnetHash(href);
if (hash) return `hash:${hash}`;
try {
return `href:${href.trim().toLowerCase()}`;
} catch {
return `href:${String(href).trim().toLowerCase()}`;
}
}
// Attach click behavior to the icon: lazily initializes API and processes magnet
_attach(icon, link) {
icon.addEventListener('click', async (ev) => {
ev.preventDefault();
const key = this._magnetKeyFor(link.href);
const ok = await ensureApiInitialized();
if (!ok) {
UIManager.showToast('Real-Debrid API key not configured. Use the menu to set it.', 'info');
return;
}
if (key && key.startsWith('hash:') && this.processor && this.processor.isTorrentExists(key.split(':')[1])) {
UIManager.showToast('Torrent already exists on Real-Debrid', 'info');
icon.title = 'Already on Real-Debrid';
icon.style.filter = 'grayscale(100%)';
icon.style.opacity = '0.65';
return;
}
try {
const count = await this.processor.processMagnetLink(link.href);
UIManager.showToast(`Added to Real-Debrid — ${count} file(s) selected`, 'success');
icon.style.filter = 'grayscale(100%)';
icon.style.opacity = '0.65';
icon.title = 'Added to Real-Debrid';
} catch (err) {
UIManager.showToast(err && err.message ? err.message : 'Failed to process magnet', 'error');
console.error(err);
}
}, { once: false });
}
addIconsTo(documentRoot = document) {
const links = Array.from(documentRoot.querySelectorAll('a[href^="magnet:"]'));
const newlyAddedKeys = [];
links.forEach(link => {
if (!link.parentNode) return;
const next = link.nextElementSibling;
if (next && next.getAttribute && next.getAttribute(INSERTED_ICON_ATTR)) return;
const key = this._magnetKeyFor(link.href);
if (key && this.keyToIcon.has(key)) return;
const icon = UIManager.createMagnetIcon();
this._attach(icon, link);
link.parentNode.insertBefore(icon, link.nextSibling);
const storeKey = key || `href:${link.href.trim().toLowerCase()}`;
this.keyToIcon.set(storeKey, icon);
newlyAddedKeys.push(storeKey);
});
if (newlyAddedKeys.length) {
ensureApiInitialized().then(ok => {
if (ok) this.markExistingTorrents();
});
}
}
markExistingTorrents() {
if (!this.processor) return;
for (const [key, icon] of this.keyToIcon.entries()) {
if (!key.startsWith('hash:')) continue;
const hash = key.split(':')[1];
if (this.processor.isTorrentExists(hash)) {
icon.title = 'Already on Real-Debrid';
icon.style.filter = 'grayscale(100%)';
icon.style.opacity = '0.65';
}
}
}
startObserving() {
if (this.observer) return;
this.observer = new MutationObserver(debounce((mutations) => {
for (const m of mutations) {
if (m.addedNodes && m.addedNodes.length) {
this.addIconsTo(document);
break;
}
}
}, 180));
this.observer.observe(document.body, { childList: true, subtree: true });
}
stopObserving() {
if (!this.observer) return;
this.observer.disconnect();
this.observer = null;
}
}
/* Lazy API initialization - only when needed; cached promise so it runs once */
let _apiInitPromise = null;
let _apiAvailable = false;
let _realDebridService = null;
let _magnetProcessor = null;
let _integratorInstance = null;
async function ensureApiInitialized() {
if (_apiInitPromise) return _apiInitPromise;
const cfg = ConfigManager.getConfig();
if (!cfg.apiKey) {
_apiAvailable = false;
return Promise.resolve(false);
}
try {
_realDebridService = new RealDebridService(cfg.apiKey, cfg);
} catch (e) {
console.warn('RealDebridService not created:', e);
_apiAvailable = false;
return Promise.resolve(false);
}
_magnetProcessor = new MagnetLinkProcessor(cfg, _realDebridService);
_apiInitPromise = _magnetProcessor.initialize()
.then(() => {
_apiAvailable = true;
if (_integratorInstance) {
_integratorInstance.setProcessor(_magnetProcessor);
_integratorInstance.markExistingTorrents();
}
return true;
})
.catch(err => {
console.warn('Failed to initialize Real-Debrid integration', err);
_apiAvailable = false;
return false;
});
return _apiInitPromise;
}
/* Initialization & Menu */
async function init() {
try {
_integratorInstance = new PageIntegrator(null);
_integratorInstance.addIconsTo();
_integratorInstance.startObserving();
GM_registerMenuCommand('Configure Real-Debrid Settings', () => {
const currentCfg = ConfigManager.getConfig();
const dialog = UIManager.createConfigDialog(currentCfg);
document.body.appendChild(dialog);
const saveBtn = dialog.querySelector('#saveBtn');
const cancelBtn = dialog.querySelector('#cancelBtn');
saveBtn.addEventListener('click', () => {
const newCfg = {
apiKey: dialog.querySelector('#apiKey').value.trim(),
allowedExtensions: dialog.querySelector('#extensions').value.split(',').map(e => e.trim()).filter(Boolean),
filterKeywords: dialog.querySelector('#keywords').value.split(',').map(k => k.trim()).filter(Boolean),
debugMode: dialog.querySelector('#debugMode').checked
};
try {
ConfigManager.saveConfig(newCfg);
if (dialog.parentNode) document.body.removeChild(dialog);
if (dialog._escHandler) document.removeEventListener('keydown', dialog._escHandler);
UIManager.showToast('Configuration saved successfully!', 'success');
location.reload();
} catch (error) {
UIManager.showToast(error.message, 'error');
}
});
cancelBtn.addEventListener('click', () => {
if (dialog.parentNode) document.body.removeChild(dialog);
if (dialog._escHandler) document.removeEventListener('keydown', dialog._escHandler);
});
const apiInput = dialog.querySelector('#apiKey');
if (apiInput) apiInput.focus();
});
} catch (err) {
console.error('Initialization failed:', err);
}
}
// Run immediately
init();
})();