// ==UserScript== // @name Magnet Link to Real-Debrid // @version 2.6.0 // @description Automatically send magnet links to Real-Debrid // @author Journey Over // @license MIT // @match *://*/* // @require https://cdn.jsdelivr.net/gh/StylusThemes/Userscripts@c185c2777d00a6826a8bf3c43bbcdcfeba5a9566/libs/gm/gmcompat.min.js // @require https://cdn.jsdelivr.net/gh/StylusThemes/Userscripts@c185c2777d00a6826a8bf3c43bbcdcfeba5a9566/libs/utils/utils.min.js // @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 // @downloadURL https://github.com/StylusThemes/Userscripts/raw/main/userscripts/magnet-link-to-real-debrid.user.js // @updateURL https://github.com/StylusThemes/Userscripts/raw/main/userscripts/magnet-link-to-real-debrid.user.js // ==/UserScript== (function() { 'use strict'; let logger; // 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'], manualFileSelection: false, debugEnabled: false }; // Custom error for configuration problems class ConfigurationError extends Error { constructor(message) { super(message); this.name = 'ConfigurationError'; } } // Custom error for Real-Debrid API issues class RealDebridError extends Error { constructor(message, statusCode = null, errorCode = null) { super(message); this.name = 'RealDebridError'; this.statusCode = statusCode; this.errorCode = errorCode; } } // Handles loading and saving user configuration class ConfigManager { static _safeParse(value) { if (!value) return null; try { return typeof value === 'string' ? JSON.parse(value) : value; } catch (err) { logger.error('[Config] Failed to parse stored configuration, resetting to defaults.', err); return null; } } static async getConfig() { const stored = await GMC.getValue(STORAGE_KEY); const parsed = this._safeParse(stored) || {}; return { ...DEFAULTS, ...parsed }; } static async saveConfig(cfg) { if (!cfg || !cfg.apiKey) throw new ConfigurationError('API Key is required'); await GMC.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'); if (typeof cfg.manualFileSelection !== 'boolean') errors.push('manualFileSelection must be a boolean'); if (typeof cfg.debugEnabled !== 'boolean') errors.push('debugEnabled must be a boolean'); return errors; } } // Manages interactions with the Real-Debrid API class RealDebridService { #apiKey; // Cross-tab reservation settings static RATE_STORE_KEY = 'realDebrid_rate_counter'; static RATE_LIMIT = 250; // max requests per 60s static RATE_HEADROOM = 5; // leave a small headroom static RATE_WINDOW_MS = 60 * 1000; static _sleep(ms) { return new Promise(res => setTimeout(res, ms)); } // Implements rate limiting by reserving slots in a sliding window using GM storage static async _reserveRequestSlot() { const key = RealDebridService.RATE_STORE_KEY; const limit = RealDebridService.RATE_LIMIT - RealDebridService.RATE_HEADROOM; const windowMs = RealDebridService.RATE_WINDOW_MS; const maxRetries = 8; let attempt = 0; while (attempt < maxRetries) { const now = Date.now(); let obj = null; try { const raw = await GMC.getValue(key); obj = raw ? JSON.parse(raw) : null; } catch (e) { obj = null; } if (!obj || typeof obj !== 'object' || !obj.windowStart || (now - obj.windowStart) >= windowMs) { const fresh = { windowStart: now, count: 1 }; try { await GMC.setValue(key, JSON.stringify(fresh)); return; } catch (e) { attempt += 1; await RealDebridService._sleep(40 * attempt); continue; } } if ((obj.count || 0) < limit) { obj.count = (obj.count || 0) + 1; try { await GMC.setValue(key, JSON.stringify(obj)); return; } catch (e) { attempt += 1; await RealDebridService._sleep(40 * attempt); continue; } } const earliest = obj.windowStart; const waitFor = Math.max(50, windowMs - (now - earliest) + 50); logger.warn(`[Real-Debrid API] Rate limit window full (${obj.count}/${RealDebridService.RATE_LIMIT}), waiting ${Math.round(waitFor)}ms`); await RealDebridService._sleep(waitFor); attempt += 1; } throw new Error('Failed to reserve request slot'); } constructor(apiKey) { if (!apiKey) throw new ConfigurationError('API Key required'); this.#apiKey = apiKey; } // Handles API requests with retry logic for rate limits and errors #request(method, endpoint, data = null) { const maxAttempts = 5; const baseDelay = 500; // initial backoff delay in ms const attemptRequest = async (attempt) => { try { await RealDebridService._reserveRequestSlot(); } catch (err) { logger.error('Request slot reservation failed, proceeding (will rely on backoff)', err); } return new Promise((resolve, reject) => { const url = `${API_BASE}${endpoint}`; const payload = data ? new URLSearchParams(data).toString() : null; logger.debug(`[Real-Debrid API] ${method} ${endpoint} (attempt ${attempt + 1})`); GMC.xmlHttpRequest({ method, url, headers: { Authorization: `Bearer ${this.#apiKey}`, Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' }, data: payload, onload: (resp) => { logger.debug(`[Real-Debrid API] Response: ${resp.status}`); if (!resp || typeof resp.status === 'undefined') { return reject(new RealDebridError('Invalid API response')); } if (resp.status < 200 || resp.status >= 300) { if (resp.status === 429 && attempt < maxAttempts) { const retryAfter = (() => { try { const parsed = JSON.parse(resp.responseText || '{}'); return parsed.retry_after || null; } catch (e) { return null; } })(); const jitter = Math.random() * 200; const backoff = retryAfter ? (retryAfter * 1000) : (baseDelay * Math.pow(2, attempt) + jitter); logger.warn(`[Real-Debrid API] Rate limited (429). Retrying in ${Math.round(backoff)}ms (attempt ${attempt + 1}/${maxAttempts})`); return setTimeout(() => { attemptRequest(attempt + 1).then(resolve).catch(reject); }, backoff); } let errorMsg = `HTTP ${resp.status}`; let errorCode = null; if (resp.responseText) { try { const parsed = JSON.parse(resp.responseText.trim()); if (parsed.error) { errorMsg = parsed.error; errorCode = parsed.error_code || null; } else { errorMsg = resp.responseText; } } catch (e) { errorMsg = resp.responseText; } } return reject(new RealDebridError(`API Error: ${errorMsg}`, resp.status, errorCode)); } if (resp.status === 204 || !resp.responseText) return resolve({}); try { const parsed = JSON.parse(resp.responseText.trim()); return resolve(parsed); } catch (err) { logger.error('[Real-Debrid API] Failed to parse JSON response', err); return reject(new RealDebridError(`Failed to parse API response: ${err.message}`, resp.status)); } }, onerror: (err) => { logger.error('[Real-Debrid API] Network request failed', err); return reject(new RealDebridError('Network request failed')); }, ontimeout: () => { logger.warn('[Real-Debrid API] Request timed out'); return reject(new RealDebridError('Request timed out')); } }); }); }; return attemptRequest(0); } async addMagnet(magnet) { logger.debug('[Real-Debrid API] Adding magnet link'); return this.#request('POST', '/torrents/addMagnet', { magnet }); } async getTorrentInfo(torrentId) { logger.debug(`[Real-Debrid API] Fetching info for torrent ${torrentId}`); return this.#request('GET', `/torrents/info/${torrentId}`); } async selectFiles(torrentId, filesCsv) { const fileCount = filesCsv.split(',').length; logger.debug(`[Real-Debrid API] Selecting ${fileCount} files for torrent ${torrentId}`); return this.#request('POST', `/torrents/selectFiles/${torrentId}`, { files: filesCsv }); } async deleteTorrent(torrentId) { logger.debug(`[Real-Debrid API] Deleting torrent ${torrentId}`); return this.#request('DELETE', `/torrents/delete/${torrentId}`); } // Fetches all existing torrents by paginating through API results async getExistingTorrents() { const all = []; const limit = 2500; let pageNum = 1; while (true) { try { logger.debug(`[Real-Debrid API] Fetching torrents page ${pageNum} (limit=${limit})`); const page = await this.#request('GET', `/torrents?page=${pageNum}&limit=${limit}`); if (!Array.isArray(page) || page.length === 0) { logger.warn(`[Real-Debrid API] No torrents returned for page ${pageNum}`); break; } all.push(...page); if (page.length < limit) { logger.debug(`[Real-Debrid API] Last page reached (${pageNum}) with ${page.length} items`); break; } pageNum += 1; } catch (err) { if (err instanceof RealDebridError && err.statusCode === 429) throw err; logger.error('[Real-Debrid API] Failed to fetch existing torrents page', err); break; } } logger.debug(`[Real-Debrid API] Fetched total ${all.length} existing torrents`); return all; } } // Represents the file structure of a torrent for selection class FileTree { constructor(files) { this.root = { name: 'Torrent Contents', children: [], type: 'folder', path: '', expanded: false }; this.buildTree(files); } // Builds a hierarchical tree from flat file list with paths buildTree(files) { files.forEach(file => { const pathParts = file.path.split('/').filter(part => part.trim() !== ''); let current = this.root; for (let i = 0; i < pathParts.length; i++) { const part = pathParts[i]; const isFile = i === pathParts.length - 1; if (isFile) { current.children.push({ ...file, name: part, type: 'file', checked: false }); } else { let folder = current.children.find(child => child.name === part && child.type === 'folder'); if (!folder) { folder = { name: part, type: 'folder', children: [], checked: false, expanded: false, path: pathParts.slice(0, i + 1).join('/') }; current.children.push(folder); } current = folder; } } }); } countFiles(node = this.root) { if (node.type === 'file') return 1; let count = 0; if (node.children) { node.children.forEach(child => { count += this.countFiles(child); }); } return count; } getAllFiles() { const files = []; const traverse = (node) => { if (node.type === 'file') { files.push(node); } if (node.children) { node.children.forEach(traverse); } }; traverse(this.root); return files; } getSelectedFiles() { return this.getAllFiles().filter(file => file.checked).map(file => file.id); } } // Processes magnet links, checks for duplicates, filters files class MagnetLinkProcessor { #config; #api; #existing = []; constructor(config, api) { this.#config = config; this.#api = api; } async initialize() { try { this.#existing = await this.#api.getExistingTorrents(); logger.debug(`[Magnet Processor] Loaded ${this.#existing.length} existing torrents`); } catch (err) { logger.error('[Magnet Processor] Failed to load existing torrents', err); this.#existing = []; } } // Extracts the torrent hash from a magnet link's xt parameter 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 (err) { 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); } // Filters files by allowed extensions and excludes those matching keywords or regex 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 (err) { // invalid regex: ignore it } } if (path.includes(kw.toLowerCase()) || name.includes(kw.toLowerCase())) return false; } return true; }); } // Adds magnet to Real-Debrid, selects files, and handles cleanup on failure 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 : []; let chosen; if (this.#config.manualFileSelection) { if (files.length === 1) { chosen = [files[0].id]; } else { chosen = await UIManager.createFileSelectionDialog(files); if (chosen === null) { await this.#api.deleteTorrent(torrentId); throw new RealDebridError('File selection cancelled'); } if (!chosen.length) { await this.#api.deleteTorrent(torrentId); throw new RealDebridError('No files selected'); } } } else { chosen = this.filterFiles(files).map(f => f.id); if (!chosen.length) { await this.#api.deleteTorrent(torrentId); throw new RealDebridError('No matching files found after filtering'); } } logger.debug(`[Magnet Processor] Selected files: ${chosen.map(id => files.find(f => f.id === id)?.path || `ID:${id}`).join(', ')}`); await this.#api.selectFiles(torrentId, chosen.join(',')); return chosen.length; } } // Handles user interface elements like dialogs and toasts class UIManager { // Icon state management static setIconState(icon, state) { switch (state) { case 'default': icon.src = ICON_SRC; icon.style.filter = ''; icon.style.opacity = ''; icon.title = ''; break; case 'processing': icon.style.opacity = '0.5'; break; case 'added': case 'existing': icon.style.filter = 'grayscale(100%)'; icon.style.opacity = '0.65'; icon.title = state === 'existing' ? 'Already on Real-Debrid' : 'Added to Real-Debrid'; break; } } static createConfigDialog(currentConfig) { const dialog = document.createElement('div'); dialog.innerHTML = `
`; this.injectStyles(); document.body.appendChild(dialog); const saveBtn = dialog.querySelector('#saveBtn'); const cancelBtn = dialog.querySelector('#cancelBtn'); const cancelBtnTop = dialog.querySelector('#cancelBtnTop'); const manualCheckbox = dialog.querySelector('#manualFileSelection'); const extensionsTextarea = dialog.querySelector('#extensions'); const keywordsTextarea = dialog.querySelector('#keywords'); const toggleFiltering = () => { const disabled = manualCheckbox.checked; extensionsTextarea.disabled = disabled; keywordsTextarea.disabled = disabled; extensionsTextarea.style.opacity = disabled ? '0.5' : '1'; keywordsTextarea.style.opacity = disabled ? '0.5' : '1'; }; manualCheckbox.addEventListener('change', toggleFiltering); toggleFiltering(); const close = () => { if (dialog.parentNode) dialog.parentNode.removeChild(dialog); document.removeEventListener('keydown', escHandler); }; const escHandler = (e) => { if (e.key === 'Escape') close(); }; document.addEventListener('keydown', escHandler); saveBtn.addEventListener('click', async () => { 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), manualFileSelection: dialog.querySelector('#manualFileSelection').checked, debugEnabled: dialog.querySelector('#debugEnabled').checked }; try { await ConfigManager.saveConfig(newCfg); close(); this.showToast('Configuration saved successfully!', 'success'); location.reload(); } catch (error) { this.showToast(error.message, 'error'); } }); cancelBtn.addEventListener('click', close); cancelBtnTop.addEventListener('click', close); const apiInput = dialog.querySelector('#apiKey'); if (apiInput) apiInput.focus(); return dialog; } // Creates a dialog for manual file selection with tree view static createFileSelectionDialog(files) { return new Promise((resolve) => { const fileTree = new FileTree(files); const totalAllSize = fileTree.getAllFiles().reduce((sum, file) => sum + (file.bytes || 0), 0); const dialog = document.createElement('div'); dialog.innerHTML = ` `; this.injectStyles(); document.body.appendChild(dialog); const treeContainer = dialog.querySelector('#fileTreeContainer'); const toggleAllBtn = dialog.querySelector('#toggleAllBtn'); const fileStats = dialog.querySelector('#fileStats'); const okBtn = dialog.querySelector('#okBtn'); const cancelBtn = dialog.querySelector('#cancelBtn'); const cancelBtnTop = dialog.querySelector('#cancelBtnTop'); // Function to recursively set folder checked state const setFolderChecked = (folder, checked) => { folder.checked = checked; if (folder.children) { folder.children.forEach(child => { child.checked = checked; if (child.type === 'folder') { setFolderChecked(child, checked); } }); } }; // Function to update parent states based on children const updateParentStates = (node = fileTree.root) => { if (node.type === 'file') return node.checked; if (node.children) { const childrenStates = node.children.map(updateParentStates); const allChecked = childrenStates.every(state => state === true); const someChecked = childrenStates.some(state => state === true); node.checked = allChecked; node.indeterminate = !allChecked && someChecked; return someChecked; } return false; }; // Function to count selected files const countSelectedFiles = () => { return fileTree.getAllFiles().filter(file => file.checked).length; }; // Function to update the UI const updateUI = () => { updateParentStates(); const selectedCount = countSelectedFiles(); const totalCount = fileTree.getAllFiles().length; const selectedFiles = fileTree.getAllFiles().filter(file => file.checked); const totalSize = selectedFiles.reduce((sum, file) => sum + (file.bytes || 0), 0); fileStats.textContent = `${selectedCount} of ${totalCount} files selected (${UIManager.formatBytes(totalSize)} / ${UIManager.formatBytes(totalAllSize)})`; const allSelected = totalCount > 0 && selectedCount === totalCount; toggleAllBtn.textContent = allSelected ? 'Select None' : 'Select All'; }; // Recursive function to render the file tree const renderTree = (node, level = 0) => { const element = document.createElement('div'); element.className = `rd-tree-item rd-tree-level-${level}`; if (node.type === 'folder') { const fileCount = fileTree.countFiles(node); element.innerHTML = `