Real-Debrid Premium Link Converter

Convert links using Real-Debrid. Uses /hosts/regex for accurate matching. Collapsed toolbox, selection/context conversion, improved results panel with textarea for successful links.

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Real-Debrid Premium Link Converter
// @version      5.4.6
// @grant        GM.xmlHttpRequest
// @grant        GM_registerMenuCommand
// @grant        GM_setValue
// @grant        GM_getValue
// @namespace    https://greasyfork.org/en/users/807108-jeremy-r
// @include      *://*
// @exclude      https://real-debrid.com/*
// @description  Convert links using Real-Debrid. Uses /hosts/regex for accurate matching. Collapsed toolbox, selection/context conversion, improved results panel with textarea for successful links.
// @icon         https://icons.duckduckgo.com/ip2/real-debrid.com.ico
// @run-at       document-end
// @author       JRem
// @license      MIT
// ==/UserScript==

(() => {
    'use strict';

    // ---- storage/state ----
    let targetRegexStrings = GM_getValue('targetRegexStrings', []) || [];
    let token = GM_getValue('api_token', '') || '';
    const processedURLs = new Set();

    // compiled regex objects: [{ pattern: string, global: RegExp, test: RegExp }]
    let compiledRegexes = [];

    // results UI state: map url -> entry element
    const resultsEntries = new Map();
    const successfulDownloads = []; // list of download URLs added to textarea

    // ---- helpers ----
    function showToast(message, ms = 3000) {
        const toast = document.createElement('div');
        toast.textContent = message;
        Object.assign(toast.style, {
            position: 'fixed', top: '20px', left: '50%', transform: 'translateX(-50%)',
            backgroundColor: '#333', color: '#fff', padding: '8px 14px', borderRadius: '6px',
            zIndex: '9999999', fontSize: '13px'
        });
        document.body.appendChild(toast);
        setTimeout(() => toast.remove(), ms);
    }

    function gmRequest(options) {
        return new Promise((resolve, reject) => {
            options.onload = options.onload || (r => resolve(r));
            options.onerror = options.onerror || (e => reject(e));
            try {
                GM.xmlHttpRequest(options);
            } catch (e) {
                reject(e);
            }
        });
    }

    // ---- regex compile/load ----
    function compileServerRegexString(s) {
        if (!s || typeof s !== 'string') return null;
        let pattern = s;
        let flags = '';
        if (pattern.startsWith('/')) {
            const lastSlash = pattern.lastIndexOf('/');
            if (lastSlash > 0) {
                flags = pattern.slice(lastSlash + 1);
                pattern = pattern.slice(1, lastSlash);
            } else {
                pattern = pattern.slice(1);
            }
        }
        try {
            const global = new RegExp(pattern, 'ig'); // scanning
            const test = new RegExp(pattern, 'i');    // single test
            return { pattern, global, test, original: s };
        } catch (e) {
            console.warn('Failed to compile RD regex:', s, e);
            return null;
        }
    }

    function compileAllRegexes() {
        compiledRegexes = [];
        if (!Array.isArray(targetRegexStrings)) return;
        for (const s of targetRegexStrings) {
            const comp = compileServerRegexString(s);
            if (comp) compiledRegexes.push(comp);
        }
    }

    function isRegexListLoaded() { return compiledRegexes.length > 0; }

    function urlDomain(url) {
        try {
            const u = new URL(url);
            return u.hostname.replace(/^www\./i, '').toLowerCase();
        } catch (e) {
            const m = url.match(/https?:\/\/([^\/]+)/i);
            return m ? m[1].replace(/^www\./i, '').toLowerCase() : '';
        }
    }

    function extractUrlsUsingRegexesFromText(text) {
        if (!text || !isRegexListLoaded()) return [];
        const set = new Set();
        for (const comp of compiledRegexes) {
            try {
                comp.global.lastIndex = 0;
                let m;
                while ((m = comp.global.exec(text)) !== null) {
                    const candidate = m[0].trim();
                    if (candidate) set.add(candidate);
                }
            } catch (e) {
                console.warn('regex scan failed for pattern', comp.pattern, e);
            }
        }
        return Array.from(set);
    }

    function urlMatchesAnyRegex(href) {
        if (!href || !isRegexListLoaded()) return false;
        for (const comp of compiledRegexes) {
            try {
                if (comp.test.test(href)) return true;
            } catch (e) {}
        }
        return false;
    }

    // ---- RD API call ----
    async function rdUnrestrict(link) {
        if (!token) return { success: false, error: 'No API token' };
        try {
            const response = await gmRequest({
                method: 'POST',
                url: 'https://app.real-debrid.com/rest/1.0/unrestrict/link',
                headers: {
                    'Authorization': `Bearer ${token}`,
                    'Content-Type': 'application/x-www-form-urlencoded'
                },
                data: `link=${encodeURIComponent(link)}&password=`
            });
            if (!response || !response.responseText) {
                return { success: false, error: `Empty response (status ${response ? response.status : 'n/a'})` };
            }
            if (response.status >= 200 && response.status < 300) {
                const json = JSON.parse(response.responseText);
                if (json && json.download) return { success: true, download: json.download, filename: json.filename, raw: json };
                return { success: false, error: JSON.stringify(json) };
            } else {
                let parsed = response.responseText;
                try { parsed = JSON.parse(response.responseText); } catch (e) {}
                return { success: false, error: `Status ${response.status}: ${JSON.stringify(parsed)}`, errmsg: `${parsed.error}` };
            }
        } catch (e) {
            return { success: false, error: e && e.message ? e.message : String(e) };
        }
    }

    // ---- find matched hosts on page ----
    function findMatchingHostsOnPage() {
        const map = new Map();
        // anchors
        document.querySelectorAll('a[href]').forEach(a => {
            try {
                const href = a.href;
                if (!href || !(href.startsWith('http://') || href.startsWith('https://'))) return;
                if (!urlMatchesAnyRegex(href)) return;
                const host = urlDomain(href);
                if (!map.has(host)) map.set(host, { urls: new Set(), anchors: new Set(), sources: new Set() });
                map.get(host).urls.add(href);
                map.get(host).anchors.add(a);
                map.get(host).sources.add('link');
            } catch (e) {}
        });
        // textareas
        document.querySelectorAll('textarea').forEach(t => {
            const urls = extractUrlsUsingRegexesFromText(t.value || '');
            urls.forEach(u => {
                const host = urlDomain(u);
                if (!map.has(host)) map.set(host, { urls: new Set(), anchors: new Set(), sources: new Set() });
                map.get(host).urls.add(u);
                map.get(host).sources.add('textarea');
            });
        });
        // pre/code
        document.querySelectorAll('pre, code').forEach(el => {
            const txt = el.innerText || el.textContent || '';
            const urls = extractUrlsUsingRegexesFromText(txt);
            urls.forEach(u => {
                const host = urlDomain(u);
                if (!map.has(host)) map.set(host, { urls: new Set(), anchors: new Set(), sources: new Set() });
                map.get(host).urls.add(u);
                map.get(host).sources.add('pre');
            });
        });
        // body text fallback
        const bodyText = document.body ? (document.body.innerText || '') : '';
        extractUrlsUsingRegexesFromText(bodyText).forEach(u => {
            const host = urlDomain(u);
            if (!map.has(host)) map.set(host, { urls: new Set(), anchors: new Set(), sources: new Set() });
            map.get(host).urls.add(u);
            map.get(host).sources.add('text');
        });
        const result = {};
        for (const [host, data] of map.entries()) {
            result[host] = { urls: Array.from(data.urls), anchors: Array.from(data.anchors), sources: Array.from(data.sources) };
        }
        return result;
    }

    // ---- Results panel UI (top list + successful textarea) ----
    let toolboxWrapper = null;
    let toolboxContent = null;
    let resultsPanel = null;
    let convertSelectionBtn = null;

    function createResultsPanel() {
        if (resultsPanel) return resultsPanel;

        resultsPanel = document.createElement('div');
        Object.assign(resultsPanel.style, {
            position: 'fixed',
            right: '10px',
            bottom: '10px',
            zIndex: '9999999',
            background: 'rgba(111,111,111,0.98)',
            color: '#000',
            fontSize: '14px',
            padding: '10px',
            borderRadius: '8px',
            width: '560px',
            maxHeight: '80vh',       // limit panel height relative to viewport
            overflow: 'auto',        // allow scrolling of the panel when content exceeds maxHeight
            boxShadow: '0 8px 30px rgba(0,0,0,0.25)'
        });

        // Header
        const header = document.createElement('div');
        Object.assign(header.style, { display: 'fixed', justifyContent: 'space-between', alignItems: 'center' });
        const title = document.createElement('div'); title.textContent = 'RD Conversion Results'; title.style.fontWeight = '700';
        const controls = document.createElement('div');
        const closeBtn = document.createElement('button'); closeBtn.textContent = 'Close'; closeBtn.onclick = () => resultsPanel.style.display = 'none';
        controls.appendChild(closeBtn);
        header.appendChild(title); header.appendChild(controls); resultsPanel.appendChild(header);


        // Top pane: list of URLs and statuses
        const topPane = document.createElement('div');
        topPane.id = 'rd-results-top';
        Object.assign(topPane.style, {
            display: 'fixed',
            marginTop: '4px',
            background: '#111',
            color: '#fff',
            fontSize: '14px',
            padding: '4px',
            borderRadius: '6px',
            maxHeight: '30vh',   // limit top pane height so bottom pane stays visible
            overflow: 'auto'     // internal scrollbar for the list
        });
        // instruction
        const topInfo = document.createElement('div');
        topInfo.textContent = 'Links and statuses (updated in place):';
        topInfo.style.marginBottom = '6px';
        topPane.appendChild(topInfo);
        const list = document.createElement('div'); list.id = 'rd-results-list';
        topPane.appendChild(list);
        resultsPanel.appendChild(topPane);

        // Bottom pane: textarea for successful downloads and buttons
        const bottomPane = document.createElement('div');
        bottomPane.id = 'rd-results-bottom';
        Object.assign(bottomPane.style, { marginTop: '10px' });

        const bottomInfo = document.createElement('div');
        bottomInfo.textContent = 'Successful download links (copyable):';
        bottomInfo.style.marginBottom = '6px';
        bottomPane.appendChild(bottomInfo);

        const textarea = document.createElement('textarea');
        textarea.id = 'rd-success-textarea';
        Object.assign(textarea.style, {
            display: 'fixed',
            width: '100%',
            height: '160px',       // fixed height for textarea
            maxHeight: '30vh',     // prevent bottom pane from growing too large
            boxSizing: 'border-box',
            padding: '8px',
            fontSize: '12px',
            overflow: 'auto'
        });
        textarea.readOnly = false;
        bottomPane.appendChild(textarea);

        const btnRow = document.createElement('div');
        Object.assign(btnRow.style, { display: 'flex', gap: '8px', marginTop: '6px' });
        const copyBtn = document.createElement('button'); copyBtn.textContent = 'Copy All'; copyBtn.onclick = () => {
            textarea.select();
            document.execCommand('copy');
            showToast('Copied to clipboard');
        };
        const clearBtn = document.createElement('button'); clearBtn.textContent = 'Clear'; clearBtn.onclick = () => {
            textarea.value = '';
            successfulDownloads.length = 0;
            showToast('Cleared successful links');
        };
        btnRow.appendChild(copyBtn); btnRow.appendChild(clearBtn);
        bottomPane.appendChild(btnRow);
        resultsPanel.appendChild(bottomPane);

        resultsPanel.style.display = 'none';
        document.body.appendChild(resultsPanel);
        return resultsPanel;
    }

    // Backwards-compat helper used by older code paths.
    // Adds a simple log line to the top results list. Keeps old appendResultLine calls working.
    function appendResultLine(text, success = null) {
        createResultsPanel();
        const list = document.getElementById('rd-results-list');
        if (!list) return;
        const line = document.createElement('div');
        line.textContent = text;
        line.style.marginBottom = '6px';
        if (success === true) line.style.color = 'green';
        else if (success === false) line.style.color = 'red';
        else line.style.color = '#fff';
        // Prepend for newest-first
        list.insertBefore(line, list.firstChild);
    }

    function ensureResultEntry(url) {
        createResultsPanel();
        if (resultsEntries.has(url)) return resultsEntries.get(url);
        const list = document.getElementById('rd-results-list');

        const row = document.createElement('div');
        row.style.display = 'flex';
        row.style.alignItems = 'center';
        row.style.justifyContent = 'space-between';
        row.style.padding = '6px';
        row.style.borderBottom = '1px solid rgba(255,255,255,0.06)';

        const left = document.createElement('div');
        left.style.display = 'flex';
        left.style.alignItems = 'center';
        left.style.gap = '8px';
        const a = document.createElement('a');
        a.href = url;
        a.textContent = url.length > 80 ? url.slice(0, 77) + '…' : url;
        a.title = url;
        a.target = '_blank';
        a.style.color = '#fff';
        a.style.textDecoration = 'underline';
        a.style.wordBreak = 'break-all';
        left.appendChild(a);
        row.appendChild(left);

        const status = document.createElement('div');
        status.className = 'rd-status';
        status.textContent = 'pending';
        status.style.color = '#ffffff';
        status.style.fontWeight = '700';
        status.style.marginLeft = '8px';
        row.appendChild(status);

        // Attach to list and map
        list.insertBefore(row, list.firstChild);
        resultsEntries.set(url, { row, linkEl: a, statusEl: status });
        return resultsEntries.get(url);
    }

    function updateResultStatus(url, state, extraText) {
        // state: 'pending' | 'ok' | 'fail' | 'skip'
        const entry = ensureResultEntry(url);
        const statusEl = entry.statusEl;
        if (!statusEl) return;
        if (state === 'pending') {
            statusEl.textContent = extraText || 'pending';
            statusEl.style.color = '#ffffff';
        } else if (state === 'ok') {
            statusEl.textContent = extraText || 'OK';
            statusEl.style.color = 'green';
        } else if (state === 'fail') {
            statusEl.textContent = extraText || 'FAILED';
            statusEl.style.color = 'red';
        } else if (state === 'skip') {
            statusEl.textContent = extraText || 'SKIPPED';
            statusEl.style.color = 'gray';
        }
    }

    function appendSuccessfulDownload(downloadUrl) {
        const ta = document.getElementById('rd-success-textarea');
        if (!ta) {
            // ensure panel created
            createResultsPanel();
        }
        const downloadToAdd = downloadUrl.trim();
        if (!downloadToAdd) return;
        // avoid duplicates
        if (successfulDownloads.includes(downloadToAdd)) return;
        successfulDownloads.push(downloadToAdd);
        const textarea = document.getElementById('rd-success-textarea');
        if (textarea) {
            textarea.value = successfulDownloads.join('\n');
        }
    }

    // ---- toolbox UI and domain buttons (regex-based) ----
    function createToolbox() {
        if (toolboxWrapper) return toolboxWrapper;

        toolboxWrapper = document.createElement('div');
        Object.assign(toolboxWrapper.style, {
            position: 'fixed',
            zIndex: '999999',
            fontFamily: 'Arial, sans-serif',
            userSelect: 'none',
            touchAction: 'none'
        });

        // ---- saved-percent helpers (same as before) ----
        function getSavedPositionPct() {
            try { return GM_getValue('rd_button_pos_pct', null); } catch (e) { return null; }
        }
        function savePositionPctFromPx(leftPx, topPx) {
            try {
                const leftPct = Math.round((leftPx / window.innerWidth) * 10000) / 10000;
                const topPct = Math.round((topPx / window.innerHeight) * 10000) / 10000;
                GM_setValue('rd_button_pos_pct', { leftPct, topPct });
            } catch (e) { console.warn('Failed to save position pct', e); }
        }
        function clearSavedPositionPct() { try { GM_setValue('rd_button_pos_pct', null); } catch (e) {} }

        function clampToViewportPx(leftPx, topPx, w = 60, h = 60) {
            const minLeft = 10;
            const minTop = 10;
            const maxLeft = Math.max(minLeft, window.innerWidth - w - 10);
            const maxTop = Math.max(minTop, window.innerHeight - h - 10);
            const clampedLeft = Math.min(Math.max(minLeft, leftPx), maxLeft);
            const clampedTop = Math.min(Math.max(minTop, topPx), maxTop);
            return { left: clampedLeft, top: clampedTop };
        }

        // Apply saved pct (converted to px)
        const savedPct = getSavedPositionPct();
        if (savedPct && typeof savedPct.leftPct === 'number' && typeof savedPct.topPct === 'number') {
            let leftPx = Math.round(savedPct.leftPct * window.innerWidth);
            let topPx = Math.round(savedPct.topPct * window.innerHeight);
            const clamped = clampToViewportPx(leftPx, topPx, 60, 60);
            toolboxWrapper.style.left = clamped.left + 'px';
            toolboxWrapper.style.top = clamped.top + 'px';
        } else {
            const defaultLeft = Math.max(10, window.innerWidth - 54);
            toolboxWrapper.style.left = defaultLeft + 'px';
            toolboxWrapper.style.top = '10px';
        }

        // Collapsed button
        const collapsedBtn = document.createElement('button');
        collapsedBtn.textContent = 'RD';
        collapsedBtn.title = 'Open Real-Debrid Tools (drag to move)';
        Object.assign(collapsedBtn.style, {
            width: '44px', height: '44px', borderRadius: '50%', border: 'none',
            background: '#111', color: '#fff', boxShadow: '0 4px 12px rgba(0,0,0,0.35)',
            cursor: 'grab', fontWeight: '700', fontSize: '14px', display: 'inline-block'
        });
        toolboxWrapper.appendChild(collapsedBtn);

        // Expanded content (positioned relative to wrapper)
        toolboxContent = document.createElement('div');
        Object.assign(toolboxContent.style, {
            display: 'none',
            position: 'absolute',
            left: '0px',
            top: '52px',
            marginTop: '6px',
            background: 'rgba(0,0,0,0.85)',
            color: '#fff',
            padding: '10px',
            borderRadius: '8px',
            width: '340px',
            boxShadow: '0 6px 20px rgba(0,0,0,0.4)'
        });

        // Header + drag handle (always draggable)
        const contentHeader = document.createElement('div');
        Object.assign(contentHeader.style, { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px', cursor: 'move' });
        const title = document.createElement('div'); title.textContent = 'Real-Debrid Tools'; title.style.fontWeight = '700';
        const dragHandle = document.createElement('div'); dragHandle.textContent = '⇳'; dragHandle.title = 'Drag to move';
        Object.assign(dragHandle.style, { cursor: 'move', paddingLeft: '6px' });
        contentHeader.appendChild(title); contentHeader.appendChild(dragHandle);
        toolboxContent.appendChild(contentHeader);

        // Controls (Refresh, Token, Reset Position)
        const controls = document.createElement('div');
        controls.style.display = 'flex'; controls.style.gap = '6px'; controls.style.flexWrap = 'wrap';
        const refreshBtn = document.createElement('button'); refreshBtn.textContent = 'Refresh Regexes'; refreshBtn.onclick = async () => { await updateRDRegexes().catch(e => console.error(e)); buildDomainButtons(); showToast('Regex list refreshed'); };
        const updateTokenBtn = document.createElement('button'); updateTokenBtn.textContent = 'Update Token'; updateTokenBtn.onclick = () => updatetoken();
        const resetPosBtn = document.createElement('button'); resetPosBtn.textContent = 'Reset Position'; resetPosBtn.title = 'Reset floating button to default position';
        resetPosBtn.onclick = () => { clearSavedPositionPct(); const defaultLeft = Math.max(10, window.innerWidth - 54); toolboxWrapper.style.left = defaultLeft + 'px'; toolboxWrapper.style.top = '10px'; showToast('Position reset'); };
        controls.appendChild(refreshBtn); controls.appendChild(updateTokenBtn); controls.appendChild(resetPosBtn);
        toolboxContent.appendChild(controls);

        // Convert Selection button
        convertSelectionBtn = document.createElement('button');
        convertSelectionBtn.textContent = 'Convert Selection';
        convertSelectionBtn.style.display = 'block';
        convertSelectionBtn.style.marginTop = '8px';
        convertSelectionBtn.disabled = true;
        convertSelectionBtn.onclick = async () => {
            const sel = window.getSelection();
            const urls = extractUrlsFromSelection(sel);
            if (!urls || urls.length === 0) { showToast('No matching RD URLs in selection.'); return; }
            const grouped = {};
            urls.forEach(u => { const d = urlDomain(u); if (!grouped[d]) grouped[d] = []; grouped[d].push(u); });
            for (const d of Object.keys(grouped)) await convertDomainLinks(d, grouped[d], []);
        };
        toolboxContent.appendChild(convertSelectionBtn);

        // Domain buttons container & other controls
        const container = document.createElement('div'); container.id = 'rd-domain-buttons-container'; container.style.marginTop = '8px'; container.style.maxHeight = '48vh'; container.style.overflow = 'auto';
        toolboxContent.appendChild(container);
        const toggleResults = document.createElement('button'); toggleResults.textContent = 'Show/Hide Results'; toggleResults.style.display = 'block'; toggleResults.style.marginTop = '8px';
        toggleResults.onclick = () => { if (!resultsPanel) createResultsPanel(); resultsPanel.style.display = resultsPanel.style.display === 'none' ? 'block' : 'none'; };
        toolboxContent.appendChild(toggleResults);
        const collapseBtn = document.createElement('button'); collapseBtn.textContent = 'Collapse'; collapseBtn.style.display = 'block'; collapseBtn.style.marginTop = '8px'; collapseBtn.onclick = () => toggleToolbox(false);
        toolboxContent.appendChild(collapseBtn);

        toolboxWrapper.appendChild(toolboxContent);
        document.body.appendChild(toolboxWrapper);

        // Toggle function that ensures content stays visible
        function toggleToolbox(expand) {
            if (expand === undefined) expand = toolboxContent.style.display === 'none';
            if (!expand) {
                toolboxContent.style.display = 'none';
                collapsedBtn.style.display = 'inline-block';
                return;
            }
            toolboxContent.style.display = 'block';
            collapsedBtn.style.display = 'none';

            const wrapperRect = toolboxWrapper.getBoundingClientRect();
            toolboxContent.style.visibility = 'hidden';
            toolboxContent.style.display = 'block';
            const contentRect = toolboxContent.getBoundingClientRect();

            let relLeft = 0;
            const overflowRight = wrapperRect.left + contentRect.width + 10 - window.innerWidth;
            if (overflowRight > 0) relLeft = -overflowRight;
            if (wrapperRect.left + relLeft < 10) relLeft = 10 - wrapperRect.left;

            const spaceBelow = window.innerHeight - (wrapperRect.top + wrapperRect.height) - 10;
            const spaceAbove = wrapperRect.top - 10;
            let relTop;
            if (contentRect.height <= spaceBelow) relTop = wrapperRect.height + 6;
            else if (contentRect.height <= spaceAbove) relTop = -contentRect.height - 6;
            else {
                const maxTop = window.innerHeight - contentRect.height - 10 - wrapperRect.top;
                relTop = Math.max(-contentRect.height, maxTop);
            }

            toolboxContent.style.left = relLeft + 'px';
            toolboxContent.style.top = relTop + 'px';
            toolboxContent.style.visibility = 'visible';
        }

        // ---- Dragging (edge/movement threshold logic) ----
        // Parameters:
        const edgeDragWidth = 10;   // px from the circular button edge that allows immediate drag
        const moveThreshold = 8;    // px movement required to begin drag when not on edge

        let pointerDown = false;
        let startClientX = 0, startClientY = 0;
        let startLeft = 0, startTop = 0;
        let dragging = false;
        let dragAllowedImmediate = false; // true when started on edge or handle
        let movedDuringInteraction = false;

        function startInteraction(clientX, clientY) {
            pointerDown = true;
            startClientX = clientX;
            startClientY = clientY;
            const rect = toolboxWrapper.getBoundingClientRect();
            startLeft = rect.left;
            startTop = rect.top;
            dragging = false;
            movedDuringInteraction = false;
            dragAllowedImmediate = false;
        }

        function beginActualDrag() {
            dragging = true;
            collapsedBtn.style.cursor = 'grabbing';
            // if the content panel is open, hide it while dragging to avoid odd measuring issues
            toolboxContent.style.display = 'none';
        }

        function doInteractionMove(clientX, clientY) {
            if (!pointerDown) return;
            const dx = clientX - startClientX;
            const dy = clientY - startClientY;
            const dist = Math.sqrt(dx * dx + dy * dy);
            movedDuringInteraction = movedDuringInteraction || dist > 0;

            if (!dragging) {
                // If immediate allowed (start on edge or handle), start drag as soon as there's any movement
                if (dragAllowedImmediate) {
                    beginActualDrag();
                } else if (dist >= moveThreshold) {
                    // movement threshold passed -> start drag
                    beginActualDrag();
                } else {
                    // not enough movement yet; don't move wrapper
                    return;
                }
            }

            // dragging is active: apply new position
            const newLeft = startLeft + dx;
            const newTop = startTop + dy;
            const clamped = clampToViewportPx(newLeft, newTop, toolboxWrapper.offsetWidth, toolboxWrapper.offsetHeight);
            toolboxWrapper.style.left = clamped.left + 'px';
            toolboxWrapper.style.top = clamped.top + 'px';
            toolboxWrapper.style.right = 'auto';
        }

        function endInteraction() {
            if (!pointerDown) return;
            pointerDown = false;
            // If we were dragging, save percentage and finish
            if (dragging) {
                dragging = false;
                collapsedBtn.style.cursor = 'grab';
                const rect = toolboxWrapper.getBoundingClientRect();
                savePositionPctFromPx(rect.left, rect.top);
                showToast('Position saved');
                // small delay to avoid immediate re-opening from the same click
                setTimeout(() => { movedDuringInteraction = false; }, 50);
                return;
            }
            // Not dragging -> this was a click/tap (only if pointer down/up without having moved past threshold)
            movedDuringInteraction = false;
            // Click behavior will be handled by the click listener on collapsedBtn
        }

        // Pointer/mouse/touch event handlers: unify using pointer events where available
        function onPointerDownForButton(e) {
            // Accept mouse and touch pointer coordinates
            const clientX = (e.touches && e.touches[0]) ? e.touches[0].clientX : e.clientX;
            const clientY = (e.touches && e.touches[0]) ? e.touches[0].clientY : e.clientY;
            startInteraction(clientX, clientY);

            // Edge detection: check if pointer started near the circular button edge
            const btnRect = collapsedBtn.getBoundingClientRect();
            const radius = btnRect.width / 2;
            const centerX = btnRect.left + radius;
            const centerY = btnRect.top + radius;
            const distFromCenter = Math.hypot(clientX - centerX, clientY - centerY);
            // if pointer is within edgeDragWidth of the outer edge, allow immediate drag
            if (distFromCenter >= Math.max(0, radius - edgeDragWidth)) {
                dragAllowedImmediate = true;
            } else {
                dragAllowedImmediate = false;
            }

            // Add move/up listeners on document to track dragging even if pointer leaves button
            document.addEventListener('mousemove', pointerMoveHandler);
            document.addEventListener('mouseup', pointerUpHandler);
            document.addEventListener('touchmove', pointerMoveHandler, { passive: false });
            document.addEventListener('touchend', pointerUpHandler);
            // prevent default to avoid accidental text selection on desktop
            e.preventDefault && e.preventDefault();
        }

        function onPointerDownForHandle(e) {
            // handle in header: allow immediate drag
            const clientX = (e.touches && e.touches[0]) ? e.touches[0].clientX : e.clientX;
            const clientY = (e.touches && e.touches[0]) ? e.touches[0].clientY : e.clientY;
            startInteraction(clientX, clientY);
            dragAllowedImmediate = true; // always allow when starting from the header handle
            document.addEventListener('mousemove', pointerMoveHandler);
            document.addEventListener('mouseup', pointerUpHandler);
            document.addEventListener('touchmove', pointerMoveHandler, { passive: false });
            document.addEventListener('touchend', pointerUpHandler);
            e.preventDefault && e.preventDefault();
        }

        function pointerMoveHandler(e) {
            const clientX = (e.touches && e.touches[0]) ? e.touches[0].clientX : e.clientX;
            const clientY = (e.touches && e.touches[0]) ? e.touches[0].clientY : e.clientY;
            doInteractionMove(clientX, clientY);
            if (e.cancelable) e.preventDefault();
        }

        function pointerUpHandler(e) {
            // remove listeners
            document.removeEventListener('mousemove', pointerMoveHandler);
            document.removeEventListener('mouseup', pointerUpHandler);
            document.removeEventListener('touchmove', pointerMoveHandler);
            document.removeEventListener('touchend', pointerUpHandler);
            endInteraction();
        }

        // Start drag on collapsed button or header handle
        collapsedBtn.addEventListener('mousedown', onPointerDownForButton);
        collapsedBtn.addEventListener('touchstart', onPointerDownForButton, { passive: false });
        dragHandle.addEventListener('mousedown', onPointerDownForHandle);
        dragHandle.addEventListener('touchstart', onPointerDownForHandle, { passive: false });

        // collapsed button click toggles toolbox only if we didn't start a drag
        collapsedBtn.addEventListener('click', (ev) => {
            // If pointerDown triggers drag or movement happened, ignore click
            // movedDuringInteraction is set when movement occurs; pointerDown false here means interaction ended without dragging
            // However to be safe, check dragging state - if a drag was started recently we suppressed click by not performing open
            if (dragging || movedDuringInteraction) {
                // reset moved flag and ignore
                movedDuringInteraction = false;
                return;
            }
            // otherwise open toolbox
            toggleToolbox(true);
            buildDomainButtons();
        });

        // close toolbox when clicking outside
        document.addEventListener('click', (ev) => {
            if (!toolboxWrapper.contains(ev.target) && toolboxContent.style.display === 'block') {
                toolboxContent.style.display = 'none';
                collapsedBtn.style.display = 'inline-block';
            }
        });

        // On window resize: reapply saved percentages (if any) or clamp current px and save
        window.addEventListener('resize', () => {
            const saved = getSavedPositionPct();
            if (saved && typeof saved.leftPct === 'number' && typeof saved.topPct === 'number') {
                let leftPx = Math.round(saved.leftPct * window.innerWidth);
                let topPx = Math.round(saved.topPct * window.innerHeight);
                const clamped = clampToViewportPx(leftPx, topPx, 60, 60);
                toolboxWrapper.style.left = clamped.left + 'px';
                toolboxWrapper.style.top = clamped.top + 'px';
            } else {
                const rect = toolboxWrapper.getBoundingClientRect();
                const clamped = clampToViewportPx(rect.left, rect.top, rect.width, rect.height);
                toolboxWrapper.style.left = clamped.left + 'px';
                toolboxWrapper.style.top = clamped.top + 'px';
                savePositionPctFromPx(clamped.left, clamped.top);
            }
        });

        return toolboxWrapper;
    }

    // ---- domain buttons ----
    function clearDomainButtons() {
        const container = document.getElementById('rd-domain-buttons-container');
        if (container) container.innerHTML = '';
    }

    function buildDomainButtons() {
        createToolbox();
        clearDomainButtons();
        const container = document.getElementById('rd-domain-buttons-container');

        if (!isRegexListLoaded()) {
            const msg = document.createElement('div'); msg.textContent = 'Regex list not loaded. Click "Refresh Regexes".'; container.appendChild(msg); return;
        }

        const found = findMatchingHostsOnPage();
        const hosts = Object.keys(found);
        if (!hosts.length) {
            const none = document.createElement('div'); none.textContent = 'No matching RD links detected on this page.'; container.appendChild(none); return;
        }

        hosts.forEach(host => {
            const info = found[host];
            const btn = document.createElement('button');
            btn.textContent = `${host} (${info.urls.length})`;
            btn.style.display = 'block'; btn.style.marginTop = '6px';
            btn.onclick = async () => { await convertDomainLinks(host, info.urls, info.anchors); };
            container.appendChild(btn);
        });

        const allBtn = document.createElement('button'); allBtn.textContent = 'Convert ALL matched links on page'; allBtn.style.display = 'block'; allBtn.style.marginTop = '8px';
        allBtn.onclick = async () => { for (const host of hosts) await convertDomainLinks(host, found[host].urls, found[host].anchors); };
        container.appendChild(allBtn);
    }

    // ---- conversion routine (updates results UI in-place) ----
    async function convertDomainLinks(domain, urls, anchors = []) {
        if (!Array.isArray(urls) || urls.length === 0) { showToast(`No links to convert for ${domain}`); return; }
        showToast(`Converting ${urls.length} links for ${domain}...`, 2000);

        // ensure top entries exist and set pending
        urls.forEach(u => updateResultStatus(u, 'pending', 'pending'));

        for (const url of urls) {
            if (processedURLs.has(url)) {
                updateResultStatus(url, 'skip', 'skipped');
                continue;
            }
            updateResultStatus(url, 'pending', 'processing');
            const res = await rdUnrestrict(url);
            if (res.success) {
                updateResultStatus(url, 'ok', 'OK');
                // update anchors on page
                anchors.forEach(a => { try { if (a.href === url) { a.href = res.download; if (res.filename) a.textContent = res.filename; a.setAttribute('data-rd-converted', '1'); } } catch (e) {} });
                // replace in textareas / pre blocks
                document.querySelectorAll('textarea').forEach(t => { if (t.value && t.value.includes(url)) t.value = t.value.split(url).join(res.download); });
                document.querySelectorAll('pre, code').forEach(el => { if ((el.textContent || '').includes(url)) el.textContent = (el.textContent || '').split(url).join(res.download); });
                processedURLs.add(url);
                // add the download link to successful textarea
                appendSuccessfulDownload(res.download);
            } else {
                updateResultStatus(url, 'fail', 'FAILED');
                // include error as title on status for tooltip
                const entry = resultsEntries.get(url);
                if (entry && entry.statusEl) entry.statusEl.title = res.error;
            }
            await new Promise(r => setTimeout(r, 180));
        }
        showToast(`Done converting ${domain}`);
    }

    // ---- selection/context menu extraction with regexes ----
    function extractUrlsFromSelection(sel) {
        const urls = new Set();
        if (!sel || !isRegexListLoaded()) return [];
        try {
            if (sel.rangeCount && sel.rangeCount > 0) {
                for (let i = 0; i < sel.rangeCount; i++) {
                    const range = sel.getRangeAt(i);
                    const frag = range.cloneContents();
                    if (frag.querySelectorAll && frag.querySelectorAll('a[href]').length) {
                        frag.querySelectorAll('a[href]').forEach(a => {
                            let href = a.getAttribute('href') || '';
                            if (!href) return;
                            try { href = new URL(href, document.baseURI).href; } catch (e) {}
                            if (urlMatchesAnyRegex(href)) urls.add(href);
                        });
                    }
                    const txt = (frag.textContent || '').trim();
                    if (txt) extractUrlsUsingRegexesFromText(txt).forEach(u => urls.add(u));
                    else { const plain = sel.toString(); if (plain) extractUrlsUsingRegexesFromText(plain).forEach(u => urls.add(u)); }
                }
            } else {
                const plain = sel.toString();
                if (plain) extractUrlsUsingRegexesFromText(plain).forEach(u => urls.add(u));
            }
        } catch (e) {
            const plain = sel.toString();
            if (plain) extractUrlsUsingRegexesFromText(plain).forEach(u => urls.add(u));
        }
        return Array.from(urls);
    }

    let customMenu = null;
    function hideCustomMenu() { if (customMenu && customMenu.parentNode) customMenu.parentNode.removeChild(customMenu); customMenu = null; }

    function onContextMenu(e) {
        const sel = window.getSelection();
        const urls = extractUrlsFromSelection(sel);
        if (!urls || !urls.length) { hideCustomMenu(); return; }
        e.preventDefault();
        hideCustomMenu();
        customMenu = document.createElement('div');
        Object.assign(customMenu.style, { position: 'fixed', zIndex: '99999999', left: `${e.clientX}px`, top: `${e.clientY}px`, background: '#111', color: '#fff', padding: '8px', borderRadius: '6px', boxShadow: '0 6px 20px rgba(0,0,0,0.4)', fontSize: '13px' });
        const title = document.createElement('div'); title.textContent = `Convert ${urls.length} selected RD link(s)`; title.style.fontWeight = '700'; title.style.marginBottom = '6px';
        customMenu.appendChild(title);

        const allBtn = document.createElement('button'); allBtn.textContent = 'Convert all selected links'; allBtn.style.display = 'block';
        allBtn.onclick = async () => { hideCustomMenu(); const grouped = {}; urls.forEach(u => { const d = urlDomain(u); if (!grouped[d]) grouped[d] = []; grouped[d].push(u); }); for (const d of Object.keys(grouped)) await convertDomainLinks(d, grouped[d], []); };
        customMenu.appendChild(allBtn);

        const grouped = {};
        urls.forEach(u => { const d = urlDomain(u); if (!grouped[d]) grouped[d] = []; grouped[d].push(u); });

        Object.keys(grouped).forEach(d => {
            const btn = document.createElement('button'); btn.textContent = `Convert ${d} (${grouped[d].length})`; btn.style.display = 'block'; btn.style.marginTop = '6px';
            btn.onclick = async () => { hideCustomMenu(); await convertDomainLinks(d, grouped[d], []); };
            customMenu.appendChild(btn);
        });

        const cancelBtn = document.createElement('button'); cancelBtn.textContent = 'Cancel'; cancelBtn.style.display = 'block'; cancelBtn.style.marginTop = '6px'; cancelBtn.onclick = () => hideCustomMenu();
        customMenu.appendChild(cancelBtn);

        document.body.appendChild(customMenu);
    }

    function installSelectionContextHandler() {
        document.addEventListener('contextmenu', onContextMenu);
        document.addEventListener('click', () => hideCustomMenu());
        window.addEventListener('blur', () => hideCustomMenu());
        document.addEventListener('selectionchange', () => {
            if (!convertSelectionBtn) return;
            const sel = window.getSelection();
            const urls = extractUrlsFromSelection(sel);
            if (urls && urls.length) { convertSelectionBtn.disabled = false; convertSelectionBtn.textContent = `Convert Selection (${urls.length})`; }
            else { convertSelectionBtn.disabled = true; convertSelectionBtn.textContent = 'Convert Selection'; }
        });
    }

    // ---- per-link buttons (only for matches) ----
    function createFastDownloadButton(linkElement, fileURL) {
        if (!linkElement || linkElement.getAttribute('realdebrid')) return;
        if (!urlMatchesAnyRegex(fileURL)) return;
        const button = document.createElement('button');
        button.innerHTML = 'Send to RD';
        Object.assign(button.style, { marginLeft: '6px', padding: '2px 6px', backgroundColor: '#000', color: '#fff', borderRadius: '6px', border: 'none', cursor: 'pointer' });
        button.onclick = async (ev) => {
            ev.preventDefault(); ev.stopPropagation();
            button.disabled = true; button.textContent = 'Sending...';
            // ensure result entry present
            updateResultStatus(fileURL, 'pending', 'sending');
            const res = await rdUnrestrict(fileURL);
            if (res.success) {
                try { linkElement.href = res.download; if (res.filename) linkElement.textContent = res.filename; } catch (e) {}
                updateResultStatus(fileURL, 'ok', 'OK');
                appendSuccessfulDownload(res.download);
                button.remove();
            } else {
                updateResultStatus(fileURL, 'fail', 'FAILED');
                const entry = resultsEntries.get(fileURL);
                if (entry && entry.statusEl) entry.statusEl.title = res.error;
                button.textContent = 'Failed - ' + res.errmsg;
                setTimeout(() => button.disabled = false, 2000);
            }
        };
        linkElement.setAttribute('realdebrid', 'true');
        linkElement.insertAdjacentElement('afterend', button);
    }

    function createMagnetButton(linkElement, fileURL) {
        if (!linkElement || linkElement.getAttribute('realdebrid-magnet')) return;
        let button = document.createElement('button');
        button.innerHTML = 'Send Magnet to RD';
        button.style.marginLeft = '6px';
        button.style.padding = '2px 6px';
        button.style.backgroundColor = 'green';
        button.style.color = '#fff';
        button.style.borderRadius = '6px';
        button.style.border = 'none';
        button.style.cursor = 'pointer';
        button.onclick = async (ev) => {
            ev.preventDefault();
            ev.stopPropagation();
            button.disabled = true;
            // Mark the magnet in results UI
            updateResultStatus(fileURL, 'pending', 'adding magnet');
            appendResultLine && appendResultLine(`[magnet] adding ${fileURL}...`);

            try {
                const addResp = await gmRequest({
                    method: 'POST',
                    url: 'https://api.real-debrid.com/rest/1.0/torrents/addMagnet',
                    headers: {
                        'Authorization': `Bearer ${token}`,
                        'Content-Type': 'application/x-www-form-urlencoded'
                    },
                    data: `magnet=${encodeURIComponent(fileURL)}`
            });

            if (addResp.status === 201) {
                const json = JSON.parse(addResp.responseText);
                const torrentId = json.id;
                updateResultStatus(fileURL, 'ok', 'magnet added');
                appendResultLine && appendResultLine(`[magnet] added ID ${torrentId}`, true);
                showToast('Magnet successfully added to Real-Debrid.');

                // Now attempt to select all files
                try {
                    const selectResp = await gmRequest({
                        method: 'POST',
                        url: `https://api.real-debrid.com/rest/1.0/torrents/selectFiles/${torrentId}`,
                        headers: {
                            'Authorization': `Bearer ${token}`,
                            'Content-Type': 'application/x-www-form-urlencoded'
                        },
                        data: 'files=all'
                    });

                    if (selectResp.status === 200 || selectResp.status === 204) {
                        updateResultStatus(fileURL, 'ok', 'files selected');
                        appendResultLine && appendResultLine(`[magnet] All files selected for ${torrentId}`, true);
                        showToast('All files selected for download.');
                    } else {
                        updateResultStatus(fileURL, 'fail', `select failed (${selectResp.status})`);
                        appendResultLine && appendResultLine(`[magnet] Failed to select files: ${selectResp.status}`, false);
                    }
                } catch (e) {
                    updateResultStatus(fileURL, 'fail', 'select error');
                    appendResultLine && appendResultLine(`[magnet] Error selecting files: ${e && e.message ? e.message : e}`, false);
                }

            } else {
                // Add magnet failed
                let err = addResp.responseText || `status ${addResp.status}`;
                updateResultStatus(fileURL, 'fail', 'add failed');
                appendResultLine && appendResultLine(`[magnet] Failed to add magnet: ${addResp.status} ${err}`, false);
                showToast('Failed to add magnet link.');
            }
        } catch (e) {
            updateResultStatus(fileURL, 'fail', 'error');
            appendResultLine && appendResultLine(`[magnet] Error adding magnet: ${e && e.message ? e.message : e}`, false);
            showToast('Error adding magnet link.');
        } finally {
            // remove the button from UI
            try { button.remove(); } catch (e) {}
        }
    };
        linkElement.setAttribute('realdebrid-magnet', 'true');
        linkElement.insertAdjacentElement('afterend', button);
    }

    // ---- process page links & pre blocks ----
    function processLinks() {
        document.querySelectorAll('a[href]').forEach(link => {
            try {
                const href = link.href;
                if (!href) return;
                if (href.startsWith('magnet:?')) { if (!link.hasAttribute('realdebrid-magnet')) createMagnetButton(link, href); }
                else { if (!link.hasAttribute('realdebrid') && urlMatchesAnyRegex(href)) createFastDownloadButton(link, href); }
            } catch (e) {}
        });

        document.querySelectorAll('pre').forEach(pre => {
            if (pre.getAttribute('rd-processed')) return;
            const txt = pre.textContent || '';
            const urls = extractUrlsUsingRegexesFromText(txt);
            if (!urls || !urls.length) { pre.setAttribute('rd-processed', '1'); return; }
            const container = document.createElement('div');
            urls.forEach(u => {
                const a = document.createElement('a'); a.href = u; a.textContent = u; a.style.display = 'block'; a.style.wordBreak = 'break-all';
                container.appendChild(a);
                createFastDownloadButton(a, u);
            });
            pre.parentNode.insertBefore(container, pre);
            pre.setAttribute('rd-processed', '1');
        });
    }

    // ---- mutation observer ----
    function debounce(fn, wait = 450) { let t = null; return (...args) => { clearTimeout(t); t = setTimeout(() => fn.apply(this, args), wait); }; }
    const observer = new MutationObserver(debounce(() => { processLinks(); buildDomainButtons(); }, 450));

    // ---- RD regex fetch ----
    async function updateRDRegexes() {
        try {
            const bearer = GM_getValue('api_token', '') || token || '';
            if (!bearer) { showToast('No API token. Use Update Token.'); return []; }
            const response = await gmRequest({ method: 'GET', url: 'https://api.real-debrid.com/rest/1.0/hosts/regex', headers: { 'Authorization': `Bearer ${bearer}` } });
            if (response.status === 200) {
                const arr = JSON.parse(response.responseText);
                if (Array.isArray(arr) && arr.length) {
                    targetRegexStrings = arr;
                    GM_setValue('targetRegexStrings', targetRegexStrings);
                    GM_setValue('lastUpdateTimestamp', Date.now());
                    compileAllRegexes();
                    return arr;
                } else { showToast('No regexes returned.'); return []; }
            } else {
                showToast(`Failed to fetch regexes: ${response.status}`);
                return [];
            }
        } catch (e) {
            console.error(e);
            showToast('Error updating regex list');
            throw e;
        }
    }

    // ---- token update ----
    async function updatetoken() {
        try {
            const response = await gmRequest({ method: 'GET', url: 'https://real-debrid.com/apitoken' });
            if (response.status === 200) {
                const text = response.responseText || '';
                const match = text.match(/document\.querySelectorAll\('input\[name=private_token\]'\)\[0\]\.value\s*=\s*'([^']+)'/);
                if (match && match[1]) {
                    token = match[1];
                    GM_setValue('api_token', token);
                    showToast('API token updated automatically.');
                    return token;
                } else {
                    const manual = prompt('API token not found automatically. Please paste your Real-Debrid API token:');
                    if (manual) { token = manual.trim(); GM_setValue('api_token', token); showToast('API token saved.'); return token; }
                    showToast('API token not set.'); return null;
                }
            } else { showToast('Failed to fetch token page.'); return null; }
        } catch (e) { console.error(e); showToast('Error updating token.'); return null; }
    }

    function ensureUpdateDDLDomains() {
        const last = GM_getValue('lastUpdateTimestamp', 0);
        const now = Date.now();
        const msPerDay = 24 * 60 * 60 * 1000;
        if (now - last >= msPerDay) updateRDRegexes().catch(e => console.error(e));
    }

    // ---- init ----
    function init() {
        token = GM_getValue('api_token', '') || token;
        targetRegexStrings = GM_getValue('targetRegexStrings', targetRegexStrings || []);
        compileAllRegexes();
        createToolbox();
        buildDomainButtons();
        processLinks();
        installSelectionContextHandler();
        observer.observe(document.body, { childList: true, subtree: true });
        ensureUpdateDDLDomains();
        try { GM_registerMenuCommand('Update API Token', updatetoken); GM_registerMenuCommand('Refresh RD Regexes', updateRDRegexes); } catch (e) {}
    }

    if (document.readyState === 'loading') window.addEventListener('DOMContentLoaded', init); else init();

})();