TorrentBD Peer Rank

Auto-refreshes peer ranks every 10s, highlights your username neon cyan across the entire peer row, and adds glow for top three.

// ==UserScript==
// @name         TorrentBD Peer Rank
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  Auto-refreshes peer ranks every 10s, highlights your username neon cyan across the entire peer row, and adds glow for top three.
// @author       gaara
// @match        https://*.torrentbd.net/torrents-details.php*
// @grant        none
// @license       GPL-3.0-or-later
// ==/UserScript==

(function() {
    'use strict';

    const style = document.createElement('style');
    style.textContent = `
        .top-peer td {
            font-weight: bold !important;
            background-color: #2c3e50 !important; /* Dark blue-grey for dark theme */
            color: #e0e0e0 !important;
        }
        body.light-scheme .top-peer td {
            background-color: #e8f5e9 !important; /* Light green for light theme */
            color: #1b5e20 !important;
        }
        .top-peer-1 td {
            color: #FF00FF !important; /* 1st: Neon Magenta */
            font-size: 1.1em !important;
        }
        .top-peer-2 td {
            color: #00FFFF !important; /* 2nd: Neon Cyan */
            font-size: 1.05em !important;
        }
        .top-peer-3 td {
            color: #39FF14 !important; /* 3rd: Neon Green */
        }

        /* Neon cyan highlight for the logged-in username (exact text only) */
        .tbd-neon-user {
            color: #00ffff !important;
            text-shadow:
                0 0 2px #00ffff,
                0 0 4px #00e5ff,
                0 0 8px #00c3ff,
                0 0 12px #00aaff;
            font-weight: 700;
            animation: tbd-neon-pulse 1.8s ease-in-out infinite;
        }
        @keyframes tbd-neon-pulse {
            0%, 100% { text-shadow:
                0 0 1px #00ffff,
                0 0 3px #00e5ff,
                0 0 6px #00c3ff,
                0 0 9px #00aaff; }
            50% { text-shadow:
                0 0 3px #00ffff,
                0 0 6px #00e5ff,
                0 0 12px #00c3ff,
                0 0 18px #00aaff; }
        }

        /* Glow for full row of logged-in user */
        .tbd-row-glow td {
            color: #00ffff !important;
            text-shadow:
                0 0 2px #00ffff,
                0 0 4px #00e5ff,
                0 0 8px #00c3ff,
                0 0 12px #00aaff;
            animation: tbd-neon-pulse 1.8s ease-in-out infinite;
        }

        /* Add glow effect to the top three rows in addition to their color */
        .top-peer-1 td,
        .top-peer-2 td,
        .top-peer-3 td {
            text-shadow:
                0 0 1px currentColor,
                0 0 3px currentColor,
                0 0 6px currentColor;
            animation: tbd-top3-pulse 2.2s ease-in-out infinite;
        }
        @keyframes tbd-top3-pulse {
            0%, 100% { text-shadow:
                0 0 1px currentColor,
                0 0 3px currentColor,
                0 0 6px currentColor; }
            50% { text-shadow:
                0 0 2px currentColor,
                0 0 6px currentColor,
                0 0 10px currentColor; }
        }
    `;
    document.head.appendChild(style);

    function parseToBytes(uploadStr) {
        if (!uploadStr) return 0;
        const parts = uploadStr.trim().split(' ');
        if (parts.length < 2) return 0;

        let value = parseFloat(parts[0].replace(/,/g, ''));
        const unit = parts[1].toUpperCase();

        switch (unit) {
            case 'TIB':
                return value * Math.pow(1024, 4);
            case 'GIB':
                return value * Math.pow(1024, 3);
            case 'MIB':
                return value * Math.pow(1024, 2);
            case 'KIB':
                return value * 1024;
            case 'B':
                return value;
            default:
                return 0;
        }
    }

    let refreshIntervalId = null;
    let cachedUsername = null;

    /**
     * Extract the logged-in username from the left profile card.
     * We read the visible card-title first, then fallback to the card-reveal.
     * Any inline icons/images are ignored; we use textContent trimmed.
     */
    function getLoggedInUsername() {
        if (cachedUsername) return cachedUsername;
        try {
            // Primary: left-block card title -> .tbdrank span contains username (with optional <img>)
            const primary = document.querySelector('#left-block-container .card .card-content .card-title .tbdrank');
            if (primary && primary.textContent) {
                const name = primary.textContent.trim();
                if (name) {
                    cachedUsername = name;
                    return cachedUsername;
                }
            }
            // Fallback: card-reveal title (e.g., <span class="card-title">gaara</span>)
            const fallback = document.querySelector('#left-block-container .card .card-reveal .card-title');
            if (fallback && fallback.textContent) {
                const name2 = fallback.textContent.trim();
                if (name2) {
                    cachedUsername = name2;
                    return cachedUsername;
                }
            }
        } catch (e) {
            // ignore
        }
        return null;
    }

    /**
     * Remove prior neon wrappers to keep idempotency across refreshes.
     */
    function removePreviousUsernameHighlights(scopeEl) {
        scopeEl.querySelectorAll('.tbd-neon-user').forEach(span => {
            const parent = span.parentNode;
            if (!parent) return;
            // Replace the wrapper span with a plain text node of its text
            const textNode = document.createTextNode(span.textContent);
            parent.replaceChild(textNode, span);
            parent.normalize(); // merge adjacent text nodes
        });
    }

    /**
     * Wrap exact username text occurrences inside text nodes with a span.
     * Only operates within the provided root element.
     * Excludes text inside script/style.
     */
    function wrapExactText(root, text, className) {
        if (!root || !text) return;
        const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
            acceptNode: (node) => {
                // Skip empty/whitespace
                if (!node.nodeValue || !node.nodeValue.trim()) return NodeFilter.FILTER_REJECT;
                // Skip inside script/style
                const p = node.parentNode;
                if (!p) return NodeFilter.FILTER_REJECT;
                const tag = p.nodeName.toLowerCase();
                if (tag === 'script' || tag === 'style') return NodeFilter.FILTER_REJECT;
                // Skip if already inside our highlight
                if (p.classList && p.classList.contains('tbd-neon-user')) return NodeFilter.FILTER_REJECT;
                // Consider
                return NodeFilter.FILTER_ACCEPT;
            }
        });

        const pattern = new RegExp(`\\b${escapeRegExp(text)}\\b`, 'g'); // word-bounded exact match
        const nodesToProcess = [];
        while (walker.nextNode()) nodesToProcess.push(walker.currentNode);

        nodesToProcess.forEach(node => {
            const value = node.nodeValue;
            pattern.lastIndex = 0;
            let match;
            let lastIndex = 0;
            const fragments = [];
            while ((match = pattern.exec(value)) !== null) {
                if (match.index > lastIndex) {
                    fragments.push(document.createTextNode(value.slice(lastIndex, match.index)));
                }
                const span = document.createElement('span');
                span.className = className;
                span.textContent = match[0];
                fragments.push(span);
                lastIndex = pattern.lastIndex;
            }
            if (fragments.length) {
                if (lastIndex < value.length) {
                    fragments.push(document.createTextNode(value.slice(lastIndex)));
                }
                const parent = node.parentNode;
                fragments.forEach(f => parent.insertBefore(f, node));
                parent.removeChild(node);
                parent.normalize();
            }
        });
    }

    function escapeRegExp(str) {
        return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    }

    /**
     * Apply neon highlight for the logged-in username within the peers table.
     * Wrap exact username text and also glow the entire row that contains the username.
     */
    function applyNeonHighlightInPeersTable() {
        const username = getLoggedInUsername();
        if (!username) return;

        // Locate the peers table by the 'UL' header
        const ulHeader = Array.from(document.querySelectorAll('th')).find(th => th.textContent.trim() === 'UL');
        if (!ulHeader) return;
        const peerTable = ulHeader.closest('table');
        if (!peerTable) return;

        // Remove previous text highlights within this table
        removePreviousUsernameHighlights(peerTable);

        // Wrap exact username text in all cells for extra emphasis
        peerTable.querySelectorAll('td, th').forEach(cell => {
            wrapExactText(cell, username, 'tbd-neon-user');
        });

        // Add full-row glow for any row that contains the username
        peerTable.querySelectorAll('tbody tr').forEach(tr => {
            tr.classList.remove('tbd-row-glow');
            const text = tr.textContent || '';
            if (new RegExp(`\\b${escapeRegExp(username)}\\b`).test(text)) {
                tr.classList.add('tbd-row-glow');
            }
        });
    }

    function processPeerTable() {
        const ulHeader = Array.from(document.querySelectorAll('th')).find(th => th.textContent.trim() === 'UL');
        if (!ulHeader) return;

        const headerRow = ulHeader.parentElement;
        const peerTable = headerRow.closest('table');
        const tbody = peerTable.querySelector('tbody');
        if (!headerRow || !peerTable || !tbody) return;

        // --- Cleanup phase: Restore table to a clean state before processing ---
        const oldRankHeader = headerRow.querySelector('.peer-rank-header');
        if (oldRankHeader) oldRankHeader.remove();

        const rows = Array.from(tbody.querySelectorAll('tr'));
        rows.forEach(row => {
            const rankCell = row.querySelector('.peer-rank-cell');
            if (rankCell) rankCell.remove();
            row.className = row.className.replace(/top-peer(-\d)?/g, '').trim();
            row.classList.remove('tbd-row-glow');
        });

        // --- Processing phase: Now that the table is clean ---
        const originalHeaders = Array.from(headerRow.children);
        const ulColumnIndex = originalHeaders.findIndex(h => h.textContent.trim() === 'UL');
        if (ulColumnIndex === -1) return;

        const rankHeader = document.createElement('th');
        rankHeader.className = 'peer-rank-header';
        rankHeader.textContent = 'Rank';
        headerRow.insertBefore(rankHeader, headerRow.firstChild);

        const peers = [];
        rows.forEach(row => {
            const cells = row.cells;
            if (cells.length > ulColumnIndex) {
                const ulText = cells[ulColumnIndex].textContent;
                const ulBytes = parseToBytes(ulText);
                peers.push({ row, ul: ulBytes });
            } else {
                peers.push({ row, ul: 0 });
            }
        });

        peers.sort((a, b) => b.ul - a.ul);

        peers.forEach((peer, index) => {
            if (index < 5) {
                peer.row.classList.add('top-peer');
                if (index === 0) peer.row.classList.add('top-peer-1');
                else if (index === 1) peer.row.classList.add('top-peer-2');
                else if (index === 2) peer.row.classList.add('top-peer-3');
            }

            const rankCell = document.createElement('td');
            rankCell.className = 'peer-rank-cell';
            rankCell.textContent = (index + 1).toString();
            rankCell.style.textAlign = 'center';
            peer.row.insertBefore(rankCell, peer.row.firstChild);

            tbody.appendChild(peer.row);
        });

        // After processing and reordering, apply neon highlight for the logged-in username
        applyNeonHighlightInPeersTable();
    }

    // Periodically detect the table and manage refreshes
    setInterval(() => {
        const ulHeader = Array.from(document.querySelectorAll('th')).find(th => th.textContent.trim() === 'UL');

        if (ulHeader && !refreshIntervalId) {
            console.log("TBD Peer Rank: Peer table detected. Starting auto-refresh cycle.");
            // Ensure username is cached early if available
            getLoggedInUsername();
            processPeerTable();
            refreshIntervalId = setInterval(processPeerTable, 10000);
        } else if (!ulHeader && refreshIntervalId) {
            console.log("TBD Peer Rank: Peer table not found. Stopping auto-refresh cycle.");
            clearInterval(refreshIntervalId);
            refreshIntervalId = null;
        } else if (ulHeader && refreshIntervalId) {
            // Table still present; ensure highlight persists even if nothing else changed
            applyNeonHighlightInPeersTable();
        }
    }, 500);
})();