TorrentBD Download History Sorter

Adds a button to fetch, sort, filter, and paginate ALL pages of your download history with an improved UI.

// ==UserScript==
// @name         TorrentBD Download History Sorter
// @version      3.0
// @description  Adds a button to fetch, sort, filter, and paginate ALL pages of your download history with an improved UI.
// @author       5ifty6ix
// @namespace    5ifty6ix
// @match        https://*.torrentbd.com/download-history.php*
// @match        https://*.torrentbd.net/download-history.php*
// @match        https://*.torrentbd.org/download-history.php*
// @match        https://*.torrentbd.me/download-history.php*
// @grant        none
// @license      MIT
// @run-at       document-end
// ==/UserScript==

(function() {
    'use strict';

    const CONFIG = {
        rowsPerPage: 30,
        cacheDurationMinutes: 15,
        selectors: {
            table: 'table.notif-table',
            tableBody: 'table.notif-table tbody',
            paginationBlock: '.pagination-block',
            searchFormContainer: 'form[action="download-history.php"] .center-align',
            searchInput: 'input[name="torrent"]',
            searchButton: 'form[action="download-history.php"] button[type="submit"]'
        }
    };

    let allRowsCache = [];
    let filteredRows = [];
    let isFetching = false;
    let isSortingActive = false;
    let sortState = { colIndex: 3, isAsc: false };
    let currentPage = 1;
    let abortController = null;

    const table = document.querySelector(CONFIG.selectors.table);
    const tbody = document.querySelector(CONFIG.selectors.tableBody);
    const paginationBlock = document.querySelector(CONFIG.selectors.paginationBlock);
    const searchButtonContainer = document.querySelector(CONFIG.selectors.searchFormContainer);
    const originalSearchInput = document.querySelector(CONFIG.selectors.searchInput);
    const originalSearchButton = document.querySelector(CONFIG.selectors.searchButton);

    if (!table || !tbody || !searchButtonContainer || !originalSearchInput) {
        return;
    }

    function injectStyles() {
        const newStyles = document.createElement('style');
        newStyles.textContent = `
            #sorter-loading-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.85); color: white; display: flex; justify-content: center; align-items: center; z-index: 10000; font-size: 1.5em; font-family: sans-serif; flex-direction: column; text-align: center; }
            .sorter-loading-content { display: flex; flex-direction: column; align-items: center; justify-content: center; }
            #sorter-error-message { color: #f44336; font-size: 0.8em; margin-top: 15px; max-width: 80%; }
            .sorter-spinner { border: 5px solid #f3f3f3; border-top: 5px solid #4caf50; border-radius: 50%; width: 50px; height: 50px; animation: spin 1s linear infinite; margin-bottom: 20px; }
            @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
            #sorter-cancel-btn { margin-top: 25px; background-color: #f44336; color: white; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; font-size: 0.8em; }
            .sort-header-content { display: flex; align-items: center; justify-content: flex-start; }
            .sorted-history-table th:nth-child(6) .sort-header-content { justify-content: center; }
            .sort-icon { vertical-align: middle; font-size: 1.2em; margin-left: 5px; }
            .sorted-history-table { table-layout: fixed; width: 100%; }
            .sorted-history-table th, .sorted-history-table td { word-wrap: break-word; }
            .sorted-history-table th:nth-child(1), .sorted-history-table td:nth-child(1) { width: 62%; }
            .sorted-history-table th:nth-child(2), .sorted-history-table td:nth-child(2) { width: 5%; text-align: center; }
            .sorted-history-table th:nth-child(3), .sorted-history-table td:nth-child(3) { width: 8%; }
            .sorted-history-table th:nth-child(4), .sorted-history-table td:nth-child(4) { width: 12%; }
            .sorted-history-table th:nth-child(5), .sorted-history-table td:nth-child(5) { width: 6%; }
            .sorted-history-table th:nth-child(6), .sorted-history-table td:nth-child(6) { width: 7%; text-align: center; }
            .sorted-history-table th { white-space: nowrap; }
            .sorted-history-table tbody tr:hover { background-color: rgba(255, 255, 255, 0.05); }
            .pagireborn-form { display: flex; align-items: center; margin-right: 15px; }
            .pagireborn-num { width: 80px; text-align: center; background-color: #333; color: #fff; border: 1px solid #555; border-radius: 4px 0 0 4px; padding: 5px; height: 36px; }
            .pagireborn-btn { background-color: #4caf50; color: white; border: 1px solid #4caf50; border-left: none; border-radius: 0 4px 4px 0; padding: 0 15px; cursor: pointer; height: 36px; font-weight: bold; }
            .no-history-message { text-align: center; padding: 20px; font-style: italic; color: #888; }
        `;
        document.head.appendChild(newStyles);
    }

    const parsers = {
        parseName: (cell) => cell.querySelector('a')?.textContent.trim().toLowerCase() || '',
        parseSize: (cell) => {
            const text = cell.textContent.trim().replace(/,/g, '');
            if (!text) return 0;
            const units = { 'TB': 1024**4, 'GB': 1024**3, 'MB': 1024**2, 'KB': 1024, 'B': 1, 'TIB': 1024**4, 'GIB': 1024**3, 'MIB': 1024**2, 'KIB': 1024 };
            const match = text.match(/(\d+\.?\d*)\s*([TGMK]?I?B)/i);
            if (!match) return 0;
            const value = parseFloat(match[1]);
            const multiplier = units[match[2].toUpperCase()] || 1;
            return isNaN(value) ? 0 : value * multiplier;
        },
        parseDate: (cell) => {
            let text = cell.textContent.trim().toLowerCase();
            if (!text) return 0;
            let match = text.match(/(\d{4})-(\d{2})-(\d{2})\s+(\d{1,2}):(\d{2})\s+(am|pm)/);
            if (match) {
                let [, year, month, day, hourStr, minute, period] = match;
                let hour = parseInt(hourStr, 10);
                if (period === 'pm' && hour < 12) hour += 12;
                if (period === 'am' && hour === 12) hour = 0;
                const time = new Date(year, month - 1, day, hour, minute).getTime();
                return isNaN(time) ? 0 : time;
            }
            if (text.includes('ago')) {
                const date = new Date();
                const units = [
                    { key: 'y', val: 31536000 }, { key: 'mo', val: 2592000 },
                    { key: 'w', val: 604800 }, { key: 'd', val: 86400 },
                    { key: 'h', val: 3600 }, { key: 'm', val: 60 },
                    { key: 's', val: 1 }
                ];
                units.forEach(unit => {
                    const match = text.match(new RegExp(`(\\d+\\.?\\d*)\\s*${unit.key}`));
                    if (match) {
                        date.setSeconds(date.getSeconds() - parseFloat(match[1]) * unit.val);
                        text = text.replace(match[0], '');
                    }
                });
                return date.getTime();
            }
            const fallbackTime = new Date(text).getTime();
            return isNaN(fallbackTime) ? 0 : fallbackTime;
        },
        parseSeedTime: (cell) => {
            let text = cell.textContent.trim().toLowerCase();
            if (!text || text === '---') return 0;
            let totalSeconds = 0;
            const units = [
                { key: 'y', val: 31536000 }, { key: 'mo', val: 2592000 },
                { key: 'w', val: 604800 }, { key: 'd', val: 86400 },
                { key: 'h', val: 3600 }, { key: 'm', val: 60 },
                { key: 's', val: 1 }
            ];
            units.forEach(unit => {
                const match = text.match(new RegExp(`(\\d+\\.?\\d*)\\s*${unit.key}`));
                if (match) {
                    totalSeconds += parseFloat(match[1]) * unit.val;
                    text = text.replace(match[0], '');
                }
            });
            return totalSeconds;
        },
        parseActivity: (cell) => cell.querySelector('i.green-text') ? 1 : 0
    };

    const sortableColumns = {
        0: { name: 'Torrent', parser: parsers.parseName },
        2: { name: 'Size', parser: parsers.parseSize },
        3: { name: 'Completed on', parser: parsers.parseDate },
        4: { name: 'Seed time', parser: parsers.parseSeedTime },
        5: { name: 'Active', parser: parsers.parseActivity }
    };

    function showLoadingIndicator(message, error = '') {
        let overlay = document.getElementById('sorter-loading-overlay');
        if (!overlay) {
            overlay = document.createElement('div');
            overlay.id = 'sorter-loading-overlay';
            overlay.innerHTML = `<div class="sorter-loading-content"><div class="sorter-spinner"></div><div id="sorter-loading-message"></div><div id="sorter-error-message"></div></div><button id="sorter-cancel-btn">Cancel</button>`;
            document.body.appendChild(overlay);
            overlay.querySelector('#sorter-cancel-btn').onclick = () => abortController?.abort();
        }
        overlay.querySelector('#sorter-loading-message').textContent = message;
        overlay.querySelector('#sorter-error-message').textContent = error;
        overlay.style.display = 'flex';
    }

    function hideLoadingIndicator() {
        const overlay = document.getElementById('sorter-loading-overlay');
        if (overlay) overlay.style.display = 'none';
    }

    function renderPage(page) {
        currentPage = page;
        tbody.innerHTML = '';
        const start = (page - 1) * CONFIG.rowsPerPage;
        const end = start + CONFIG.rowsPerPage;
        const paginatedItems = filteredRows.slice(start, end);
        const fragment = document.createDocumentFragment();
        paginatedItems.forEach(rowObject => {
            const tr = document.createElement('tr');
            tr.innerHTML = rowObject.html;
            fragment.appendChild(tr);
        });
        tbody.appendChild(fragment);
        renderPaginationControls();
    }

    function renderPaginationControls() {
        if (!paginationBlock) return;
        paginationBlock.innerHTML = '';
        const pageCount = Math.ceil(filteredRows.length / CONFIG.rowsPerPage);
        if (pageCount <= 1) return;

        const container = document.createElement('div');
        container.style.cssText = 'display: flex; justify-content: center; align-items: center; margin-top: 15px;';
        const ul = document.createElement('ul');
        ul.className = 'pagination';
        ul.style.margin = '0';

        const createPageLink = (page, text, isDisabled = false, isActive = false) => {
            const li = document.createElement('li');
            li.className = isDisabled ? 'disabled' : (isActive ? 'active green' : 'waves-effect');
            const a = document.createElement('a');
            a.className = 'waves-effect' + (isActive ? ' white-text' : '');
            a.innerHTML = text || page;
            if (!isDisabled) a.onclick = () => renderPage(page);
            li.appendChild(a);
            return li;
        };

        ul.appendChild(createPageLink(currentPage - 1, '<i class="material-icons">chevron_left</i>', currentPage === 1));

        const maxPagesToShow = 9;
        let startPage = 1, endPage = pageCount;
        if (pageCount > maxPagesToShow) {
            let maxPagesBefore = Math.floor((maxPagesToShow - 1) / 2);
            if (currentPage > maxPagesBefore) {
                startPage = currentPage - maxPagesBefore;
            }
            endPage = startPage + maxPagesToShow - 1;
            if (endPage > pageCount) {
                endPage = pageCount;
                startPage = endPage - maxPagesToShow + 1;
            }
        }

        for (let i = startPage; i <= endPage; i++) {
            ul.appendChild(createPageLink(i, null, false, i === currentPage));
        }

        ul.appendChild(createPageLink(currentPage + 1, '<i class="material-icons">chevron_right</i>', currentPage === pageCount));
        container.appendChild(ul);
        paginationBlock.appendChild(container);
    }

    function updateHeaderVisuals() {
        const headers = table.querySelectorAll('thead th');
        headers.forEach((header, index) => {
            if (sortableColumns[index]) {
                if (!header.querySelector('.sort-header-content')) {
                    header.innerHTML = `<div class="sort-header-content"><span>${header.textContent.trim()}</span><i class="material-icons sort-icon"></i></div>`;
                }
                const icon = header.querySelector('.sort-icon');
                icon.textContent = (isSortingActive && index === sortState.colIndex)
                    ? (sortState.isAsc ? 'arrow_upward' : 'arrow_downward')
                    : '';
            }
        });
    }

    async function fetchAndCacheAllPages() {
        if (isFetching) return;
        isFetching = true;
        abortController = new AbortController();
        const { signal } = abortController;
        showLoadingIndicator('Starting fetch...');
        let tempRows = [];
        let page = 1;
        const urlParams = new URLSearchParams(window.location.search);
        const id = urlParams.get('id');
        const baseUrl = `${window.location.origin}${window.location.pathname}?id=${id}`;

        try {
            while (true) {
                if (signal.aborted) throw new Error('AbortError');
                showLoadingIndicator(`Fetching page ${page}...`);
                const response = await fetch(`${baseUrl}&page=${page}`, { signal });
                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 rows = doc.querySelectorAll(CONFIG.selectors.tableBody + ' tr');
                if (rows.length === 0) break;
                rows.forEach(row => {
                    const rowData = { html: row.innerHTML, values: {} };
                    for (const colIndex in sortableColumns) {
                        rowData.values[colIndex] = sortableColumns[colIndex].parser(row.cells[colIndex]);
                    }
                    tempRows.push(rowData);
                });
                page++;
            }
            allRowsCache = tempRows;
            const cacheData = { timestamp: Date.now(), rows: allRowsCache };
            sessionStorage.setItem('sorterCache', JSON.stringify(cacheData));
            return true;
        } catch (err) {
            if (err.message !== 'AbortError') {
                console.error(`Failed to fetch page ${page}:`, err);
                showLoadingIndicator(`Failed to fetch page ${page}.`, `Error: ${err.message}. Check console for details.`);
                await new Promise(resolve => setTimeout(resolve, 4000));
            }
            return false;
        } finally {
            isFetching = false;
            hideLoadingIndicator();
        }
    }

    function sortAndDisplay() {
        showLoadingIndicator('Sorting...');
        setTimeout(() => {
            const { colIndex, isAsc } = sortState;
            filteredRows.sort((a, b) => {
                const valA = a.values[colIndex];
                const valB = b.values[colIndex];
                const order = isAsc ? 1 : -1;
                if (typeof valA === 'string') return valA.localeCompare(valB) * order;
                return (valA - valB) * order;
            });
            localStorage.setItem('sorterLastSort', JSON.stringify(sortState));
            updateHeaderVisuals();
            renderPage(1);
            hideLoadingIndicator();
        }, 50);
    }

    function filterAndDisplay() {
        const filterText = originalSearchInput.value.toLowerCase();
        filteredRows = allRowsCache.filter(row => row.values[0].includes(filterText));
        sortAndDisplay();
    }

    function activateSorting() {
        if (isSortingActive) return;
        isSortingActive = true;
        table.classList.add('sorted-history-table');
        filteredRows = [...allRowsCache];

        if (allRowsCache.length === 0) {
            tbody.innerHTML = `<tr><td colspan="6" class="no-history-message">No download history found.</td></tr>`;
            if (paginationBlock) paginationBlock.innerHTML = '';
            originalSearchInput.placeholder = 'No torrents to filter.';
            return;
        }

        paginationBlock?.querySelector('.pagination')?.remove();
        const savedSort = localStorage.getItem('sorterLastSort');
        if (savedSort) sortState = JSON.parse(savedSort);

        table.querySelectorAll('thead th').forEach((header, index) => {
            if (sortableColumns[index]) {
                header.style.cursor = 'pointer';
                header.title = `Click to sort all pages by ${sortableColumns[index].name}`;
                header.addEventListener('click', () => {
                    const isSameColumn = sortState.colIndex === index;
                    sortState.isAsc = isSameColumn ? !sortState.isAsc : (index === 0);
                    sortState.colIndex = index;
                    sortAndDisplay();
                });
            }
        });

        originalSearchInput.placeholder = `Live filter ${allRowsCache.length} torrents...`;
        originalSearchInput.addEventListener('input', filterAndDisplay);
        if (originalSearchButton) originalSearchButton.style.display = 'none';
        sortAndDisplay();
    }

    function initialize() {
        injectStyles();
        const startButton = document.createElement('button');
        startButton.id = 'start-sort-btn';
        startButton.textContent = 'Sort All Torrents';
        startButton.className = 'btn green darken-2';
        startButton.type = 'button';
        startButton.style.marginLeft = '10px';

        const resetButton = document.createElement('button');
        resetButton.id = 'reset-sort-btn';
        resetButton.textContent = 'Reset';
        resetButton.className = 'btn red darken-2';
        resetButton.type = 'button';
        resetButton.style.marginLeft = '10px';
        resetButton.style.display = 'none';

        startButton.addEventListener('click', async () => {
            startButton.style.opacity = '0.5';
            startButton.style.cursor = 'default';
            startButton.disabled = true;

            const cachedData = JSON.parse(sessionStorage.getItem('sorterCache'));
            const now = Date.now();
            let fetchSuccess = false;

            if (cachedData && (now - cachedData.timestamp) < CONFIG.cacheDurationMinutes * 60 * 1000) {
                showLoadingIndicator('Loading from cache...');
                allRowsCache = cachedData.rows;
                hideLoadingIndicator();
                fetchSuccess = true;
            } else {
                fetchSuccess = await fetchAndCacheAllPages();
            }

            if (fetchSuccess) {
                activateSorting();
                startButton.textContent = 'Sorting Activated';
                resetButton.style.display = 'inline-block';
            } else {
                startButton.style.opacity = '1';
                startButton.style.cursor = 'pointer';
                startButton.disabled = false;
            }
        });

        resetButton.addEventListener('click', () => {
            sessionStorage.removeItem('sorterCache');
            localStorage.removeItem('sorterLastSort');
            window.location.reload();
        });

        searchButtonContainer.appendChild(startButton);
        searchButtonContainer.appendChild(resetButton);
    }

    initialize();

})();