TorrentBD: Batch Torrent Downloader (Optimized)

Download torrents from multiple pages at once, with an option to download only inactive or size-limited torrents.

// ==UserScript==
// @name          TorrentBD: Batch Torrent Downloader (Optimized)
// @namespace     eLibrarian-userscripts
// @description   Download torrents from multiple pages at once, with an option to download only inactive or size-limited torrents.
// @version       0.4
// @author        gaara (optimized by AI)
// @license       GPL-3.0-or-later
// @match         https://*.torrentbd.net/download-history.php*
// @match         https://*.torrentbd.com/download-history.php*
// @match         https://*.torrentbd.org/download-history.php*
// @match         https://*.torrentbd.me/download-history.php*
// @require       https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js
// @grant         none
// @run-at        document-idle
// ==/UserScript==

(function() {
    'use strict';

    // --- Configuration ---
    const CONFIG = {
        delayBetweenPages: 500,    // (ms)
        delayBetweenDownloads: 100, // (ms)
        selectors: {
            pagination: '.pagination-block',
            tableRows: 'table.notif-table tbody tr',
            downloadLink: 'td.dl-btn-td a',
            sizeCell: 'td:nth-child(3)', // The 3rd column in the row contains the size
            inactiveRow: ':has(td.active-status-td span[title="No"])',
            totalPagesLink: '.pagination li a[href*="page="]',
        },
        styles: {
            button: `
                margin: 15px auto; display: block; padding: 8px 16px; color: white;
                border: none; border-radius: 4px; cursor: pointer; transition: background-color 0.3s;`,
            dialogOverlay: `
                position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.7);
                display: flex; justify-content: center; align-items: center; z-index: 10000;`,
            dialogContent: `
                width: 400px; background: #2d2d2d; padding: 20px 25px; border-radius: 8px;
                box-shadow: 0 4px 20px rgba(0,0,0,0.5); color: #fff;`,
            toast: `
                position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
                background: rgba(50, 50, 50, 0.95); color: white; padding: 20px; border-radius: 8px;
                z-index: 10001; text-align: center; min-width: 300px; box-shadow: 0 5px 25px rgba(0,0,0,0.5);`,
            progressBarContainer: `
                width: 100%; height: 5px; background: #555; margin-top: 10px; border-radius: 2px; overflow: hidden;`,
            progressBar: `
                width: 0%; height: 100%; background: #4b8b61; transition: width 0.3s ease-in-out;`
        }
    };

    // --- State Management ---
    const STATE = {
        isRunning: false,
        shouldStop: false,
        elements: {} // To cache DOM elements
    };

    // --- Main Logic ---

    function addDownloadButton() {
        const pagination = document.querySelector(CONFIG.selectors.pagination);
        if (!pagination || document.getElementById('batch-download-btn')) return;

        const button = document.createElement('button');
        button.id = 'batch-download-btn';
        button.textContent = 'Batch Download Torrents';
        button.style.cssText = CONFIG.styles.button;
        button.style.backgroundColor = '#4b8b61';

        button.addEventListener('click', () => {
            if (STATE.isRunning) {
                STATE.shouldStop = true;
                updateButtonState(false, 'Stopping...');
            } else {
                showSettingsDialog();
            }
        });

        STATE.elements.downloadButton = button;
        pagination.appendChild(button);
    }

    function updateButtonState(isRunning, text) {
        STATE.isRunning = isRunning;
        const button = STATE.elements.downloadButton;
        if (!button) return;

        button.disabled = (text === 'Stopping...');
        button.textContent = text || (isRunning ? 'Stop Download' : 'Batch Download Torrents');
        button.style.backgroundColor = isRunning ? '#ef5350' : '#4b8b61';
    }

    function showSettingsDialog() {
        const totalPages = detectTotalPages();
        if (totalPages === 0) {
            alert('Could not detect total pages. Ensure you are on the download history page.');
            return;
        }

        const existingDialog = document.getElementById('batch-dl-dialog');
        if (existingDialog) existingDialog.remove();

        const dialog = document.createElement('div');
        dialog.id = 'batch-dl-dialog';
        dialog.style.cssText = CONFIG.styles.dialogOverlay;
        dialog.innerHTML = `
            <div style="${CONFIG.styles.dialogContent}">
                <h3 style="margin: 0 0 20px; text-align: center;">Download Settings</h3>
                <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 20px;">
                    <div>
                        <label for="start-page" style="display: block; margin-bottom: 5px;">Start Page:</label>
                        <input type="number" id="start-page" min="1" max="${totalPages}" value="1" style="width: 100%; padding: 8px; box-sizing: border-box; background: #3d3d3d; border: 1px solid #555; border-radius: 4px; color: #fff;">
                    </div>
                    <div>
                        <label for="end-page" style="display: block; margin-bottom: 5px;">End Page:</label>
                        <input type="number" id="end-page" min="1" max="${totalPages}" value="${totalPages}" style="width: 100%; padding: 8px; box-sizing: border-box; background: #3d3d3d; border: 1px solid #555; border-radius: 4px; color: #fff;">
                    </div>
                </div>
                <div style="margin-bottom: 20px;">
                    <label for="max-size" style="display: block; margin-bottom: 5px;">Max Size (GB):</label>
                    <input type="number" id="max-size" min="0" step="0.1" placeholder="0 for no limit" style="width: 100%; padding: 8px; box-sizing: border-box; background: #3d3d3d; border: 1px solid #555; border-radius: 4px; color: #fff;">
                </div>
                <div style="margin-bottom: 20px;">
                    <input type="checkbox" id="inactive-only" style="vertical-align: middle;">
                    <label for="inactive-only" style="vertical-align: middle; cursor: pointer;">Download INACTIVE torrents only</label>
                </div>
                <div style="display: flex; justify-content: space-between;">
                    <button id="cancel-btn" style="${CONFIG.styles.button} margin: 0; background: #555;">Cancel</button>
                    <button id="start-btn" style="${CONFIG.styles.button} margin: 0; background: #4b8b61;">Start Download</button>
                </div>
            </div>`;

        document.body.appendChild(dialog);

        const closeDialog = () => document.body.removeChild(dialog);
        dialog.querySelector('#cancel-btn').onclick = closeDialog;
        dialog.querySelector('#start-btn').onclick = () => {
            const startPage = parseInt(document.getElementById('start-page').value, 10);
            const endPage = parseInt(document.getElementById('end-page').value, 10);
            const inactiveOnly = document.getElementById('inactive-only').checked;
            const maxSizeGB = parseFloat(document.getElementById('max-size').value) || 0;

            if (isNaN(startPage) || isNaN(endPage) || startPage > endPage || startPage < 1) {
                alert('Invalid page range.');
                return;
            }

            closeDialog();
            startDownload({ startPage, endPage, inactiveOnly, maxSizeGB });
        };
    }

    async function startDownload({ startPage, endPage, inactiveOnly, maxSizeGB }) {
        updateButtonState(true);
        STATE.shouldStop = false;
        const downloadedFiles = [];
        const totalPagesToProcess = endPage - startPage + 1;

        showToast('Starting download...', 0);

        for (let i = 0; i < totalPagesToProcess; i++) {
            if (STATE.shouldStop) break;

            const currentPage = startPage + i;
            const progress = Math.round((i / totalPagesToProcess) * 100);
            showToast(`Fetching page ${currentPage} of ${endPage}...`, progress);

            const torrents = await getTorrentsFromPage(currentPage, { inactiveOnly, maxSizeGB });
            if (torrents.length === 0) {
                 console.log(`No matching torrents found on page ${currentPage}.`);
                 await new Promise(r => setTimeout(r, 200));
                 continue;
            }

            for (let j = 0; j < torrents.length; j++) {
                if (STATE.shouldStop) break;
                const torrent = torrents[j];
                const itemProgress = Math.round(((i + (j + 1) / torrents.length) / totalPagesToProcess) * 100);
                showToast(`Page ${currentPage}: Downloading ${j + 1}/${torrents.length}<br>Total: ${downloadedFiles.length + 1} files`, itemProgress);
                try {
                    const file = await downloadTorrent(torrent.url);
                    if (file) {
                        downloadedFiles.push({ blob: file.blob, filename: torrent.name + '.torrent' });
                    }
                } catch (error) {
                    console.error(`Error downloading torrent: ${torrent.name}`, error);
                }
                await new Promise(r => setTimeout(r, CONFIG.delayBetweenDownloads));
            }
             if (i < totalPagesToProcess - 1) {
                await new Promise(r => setTimeout(r, CONFIG.delayBetweenPages));
            }
        }

        if (downloadedFiles.length > 0) {
            showToast('Creating ZIP file...', 100);
            await createZip(downloadedFiles);
            showToast(`Complete! Zipped ${downloadedFiles.length} torrents.`, -1);
        } else {
            showToast(STATE.shouldStop ? 'Download stopped.' : 'No matching torrents found.', -1);
        }

        setTimeout(hideToast, 4000);
        updateButtonState(false);
    }

    function parseSizeToGB(sizeString) {
        if (!sizeString) return Infinity;
        const parts = sizeString.match(/([\d.]+)\s*(KiB|MiB|GiB|TiB)/i);
        if (!parts) return Infinity;

        const value = parseFloat(parts[1]);
        const unit = parts[2].toLowerCase();

        switch (unit) {
            case 'tib': return value * 1024;
            case 'gib': return value;
            case 'mib': return value / 1024;
            case 'kib': return value / (1024 * 1024);
            default: return Infinity;
        }
    }

    async function getTorrentsFromPage(page, { inactiveOnly, maxSizeGB }) {
        const url = new URL(window.location.href);
        url.searchParams.set('page', page);

        try {
            const response = await fetch(url.href);
            if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
            const html = await response.text();
            const doc = new DOMParser().parseFromString(html, 'text/html');

            const rowSelector = inactiveOnly
                ? `${CONFIG.selectors.tableRows}${CONFIG.selectors.inactiveRow}`
                : CONFIG.selectors.tableRows;

            const rows = doc.querySelectorAll(rowSelector);

            const allTorrents = Array.from(rows).map(row => {
                const linkElement = row.querySelector(CONFIG.selectors.downloadLink);
                if (!linkElement) return null;

                const torrentName = row.querySelector('td:first-child a')?.textContent.trim().replace(/[\\/:*?"<>|]/g, '_') || `torrent_page_${page}`;
                const sizeText = row.querySelector(CONFIG.selectors.sizeCell)?.textContent.trim();
                const sizeGB = parseSizeToGB(sizeText);

                return { name: torrentName, url: linkElement.href, sizeGB };
            }).filter(Boolean);

            if (maxSizeGB > 0) {
                return allTorrents.filter(torrent => torrent.sizeGB < maxSizeGB);
            }

            return allTorrents;
        } catch (error) {
            console.error(`Failed to fetch or parse page ${page}:`, error);
            return [];
        }
    }

    async function downloadTorrent(url) {
        const response = await fetch(url);
        if (!response.ok) throw new Error(`Download failed: ${response.statusText}`);
        const blob = await response.blob();
        return { blob };
    }

    async function createZip(files) {
        const zip = new JSZip();
        files.forEach(file => zip.file(file.filename, file.blob));

        const content = await zip.generateAsync({ type: 'blob' });
        const date = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');

        const a = document.createElement('a');
        a.href = URL.createObjectURL(content);
        a.download = `TorrentBD_Batch_${date}.zip`;
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);
        URL.revokeObjectURL(a.href);
    }

    function detectTotalPages() {
        const links = document.querySelectorAll(CONFIG.selectors.totalPagesLink);
        if (links.length === 0) return 1;
        const pageNumbers = Array.from(links).map(a => parseInt(a.href.match(/page=(\d+)/)[1], 10));
        return Math.max(...pageNumbers, 1);
    }

    function showToast(message, progress = -1) {
        let toast = STATE.elements.toast;
        if (!toast) {
            toast = document.createElement('div');
            toast.id = 'batch-dl-toast';
            toast.style.cssText = CONFIG.styles.toast;
            toast.innerHTML = `<div>
                <div id="toast-message" style="margin-bottom: 15px;"></div>
                <div id="toast-progress-container" style="${CONFIG.styles.progressBarContainer}">
                    <div id="toast-progress-bar" style="${CONFIG.styles.progressBar}"></div>
                </div>
                <button id="toast-stop-btn" style="${CONFIG.styles.button} margin: 15px auto 0; background: #c62828; padding: 6px 12px; font-size: 0.9em;">Stop & Zip Current Files</button>
            </div>`;
            document.body.appendChild(toast);

            STATE.elements.toast = toast;
            STATE.elements.toastMessage = toast.querySelector('#toast-message');
            STATE.elements.toastProgressContainer = toast.querySelector('#toast-progress-container');
            STATE.elements.toastProgressBar = toast.querySelector('#toast-progress-bar');
            STATE.elements.toastStopBtn = toast.querySelector('#toast-stop-btn');

            STATE.elements.toastStopBtn.onclick = () => {
                STATE.shouldStop = true;
                updateButtonState(false, 'Stopping...');
            };
        }

        toast.style.display = 'block';
        STATE.elements.toastMessage.innerHTML = message;

        const showProgressUI = progress >= 0;
        STATE.elements.toastProgressContainer.style.display = showProgressUI ? 'block' : 'none';
        STATE.elements.toastStopBtn.style.display = showProgressUI ? 'block' : 'none';

        if (progress >= 0) {
            STATE.elements.toastProgressBar.style.width = `${progress}%`;
        }
    }

    function hideToast() {
        if (STATE.elements.toast) {
            STATE.elements.toast.style.display = 'none';
        }
    }

    // --- Initialization ---
    addDownloadButton();

})();