TweaxPeerRank

Ranks peers by uploaded amount and highlights the top 5 peers while keeping your rank on top.

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

// ==UserScript==
// @name          TweaxPeerRank
// @namespace     eLibrarian-userscripts
// @version       1.0.1
// @author        eLibrarian
// @description   Ranks peers by uploaded amount and highlights the top 5 peers while keeping your rank on top.
// @license       GPL-3.0-or-later
// @match         https://*.torrentbd.net/*
// @match         https://*.torrentbd.com/*
// @match         https://*.torrentbd.org/*
// @match         https://*.torrentbd.me/*
// @grant         none
// @run-at        document-end
// ==/UserScript==

(function () {
  'use strict';

  const TABLE_SEL = 'table.peers-table';
  const CSS_ID = 'tbpr-peer-rank-css';
  const CLASS = {
    selfRow: 'tbpr-self-row',
    selfUser: 'tbpr-self-user',
    rankHeader: 'tbpr-rank-header',
    rankCell: 'tbpr-rank-cell',
    rankBadge: 'tbpr-rank-badge',
  };
  const DEFAULT_UPLOAD_COL_INDEX = 4;
  const REFRESH_MS = 10000;
  const DATA_ORIGINAL_ORDER = 'tbprOriginalOrder';
  const TOP_RANK_STYLE_LIMIT = 5;
  const DATA_APPLIED_PEER_RANK_CLASSES = 'tbprAppliedRankClasses';

  let refreshIntervalId = null;
  let cachedUsername = null;
  let cachedSelfRankClasses = '';
  let renderQueued = false;
  let peerPresenceObserver = null;
  let lastObservedPeerRowCount = -1;
  const userRankClassFetches = new Map();

  function cssOnce(id, text) {
    if (document.getElementById(id)) return;
    const style = document.createElement('style');
    style.id = id;
    style.textContent = text;
    document.head.appendChild(style);
  }

  function toBytes(text) {
    const raw = String(text || '').replace(/,/g, '').trim();
    if (!raw) return 0;
    const match = raw.match(/([0-9]*\.?[0-9]+)\s*([a-zA-Z]+)/);
    if (!match) return Number(raw) || 0;
    const value = Number(match[1]);
    const unit = String(match[2] || '').toUpperCase();
    const map = {
      B: 1,
      KB: 1000,
      MB: 1000 ** 2,
      GB: 1000 ** 3,
      TB: 1000 ** 4,
      PB: 1000 ** 5,
      KIB: 1024,
      MIB: 1024 ** 2,
      GIB: 1024 ** 3,
      TIB: 1024 ** 4,
      PIB: 1024 ** 5,
    };
    return Number.isFinite(value) ? value * (map[unit] || 1) : 0;
  }

  function safeRun(fn) {
    try { fn(); } catch (error) { console.error('[PeerRank]', error); }
  }

  function ensureStyles() {
    cssOnce(CSS_ID, `
      .${CLASS.rankBadge}{
        display:inline-block;
        padding:2px 8px;
        border-radius:4px;
        font-weight:700;
        color:#fff;
        text-shadow:0 1px 2px rgba(0,0,0,.4);
        font-size:.9em;
        line-height:1.2;
      }
      .${CLASS.rankBadge}.rank-1{ background-color:#d4af37; }
      .${CLASS.rankBadge}.rank-2{ background-color:#c0c0c0; color:#1a1a1a; text-shadow:none; }
      .${CLASS.rankBadge}.rank-3{ background-color:#cd7f32; }
      .${CLASS.rankBadge}.rank-4{ background-color:#4b8b61; }
      .${CLASS.rankBadge}.rank-5{ background-color:#2f9eac; }
      td.${CLASS.rankCell}{
        text-align:center !important;
        width:56px;
        min-width:56px;
        padding-left:6px;
        padding-right:6px;
        box-sizing:border-box;
      }
      .${CLASS.selfRow}{
        background:linear-gradient(90deg, rgba(0,255,255,.15) 0%, rgba(0,255,255,0) 20%);
        border-left:3px solid #00ffff;
      }
      .${CLASS.selfUser}{
        font-weight:700;
        text-shadow:0 0 6px rgba(0,255,255,.18);
      }
      .${CLASS.selfUser} a,
      .${CLASS.selfUser} .tbdrank,
      .${CLASS.selfUser} span{
        font-weight:700;
      }
    `);
  }

  function normalizeText(text) {
    return String(text || '').replace(/\s+/g, ' ').trim();
  }

  function readRankName(node) {
    const raw = (node?.childNodes?.[0]?.textContent || node?.textContent || '').trim();
    return raw ? raw.replace(/\s+/g, ' ').trim() : '';
  }

  function readRankClasses(node) {
    return Array.from(node?.classList || [])
      .filter((cls) => cls && cls !== 'tbdrank')
      .join(' ')
      .trim();
  }

  function findLoggedInRankNode() {
    const candidates = [
      '.card.margin-t-0 .card-content .card-title > .tbdrank',
      '.card.margin-t-0 .card-content .card-title .tbdrank',
      '.card-content .card-title > .tbdrank',
      '.card-content .card-title .tbdrank',
      'nav .tbdrank',
      'header .tbdrank',
      '.top-nav .tbdrank',
      '.sidenav .tbdrank'
    ];
    for (const selector of candidates) {
      const node = document.querySelector(selector);
      if (node) return node;
    }
    return null;
  }

  function detectLoggedInUsernameFromDom() {
    return readRankName(findLoggedInRankNode());
  }

  function detectLoggedInUserRankClassesFromDom() {
    return readRankClasses(findLoggedInRankNode());
  }

  function getLoggedInUsername() {
    const detected = detectLoggedInUsernameFromDom();
    if (detected) cachedUsername = normalizeText(detected);
    const rankClasses = detectLoggedInUserRankClassesFromDom();
    if (rankClasses) cachedSelfRankClasses = rankClasses;
    return cachedUsername;
  }

  function getLoggedInUserRankClasses() {
    const live = detectLoggedInUserRankClassesFromDom();
    if (live) {
      cachedSelfRankClasses = live;
      return cachedSelfRankClasses;
    }
    return cachedSelfRankClasses;
  }

  function clearSelfHighlight(peerTable) {
    peerTable.querySelectorAll('tbody tr.' + CLASS.selfRow).forEach((row) => row.classList.remove(CLASS.selfRow));
    peerTable.querySelectorAll('tbody td.' + CLASS.selfUser).forEach((cell) => cell.classList.remove(CLASS.selfUser));
  }

  function getPeerUserAnchor(userCell) {
    return userCell?.querySelector?.('a[href*="account-details.php?id="]') || null;
  }

  function getPeerUserIdFromCell(userCell) {
    const anchor = getPeerUserAnchor(userCell);
    const href = anchor?.getAttribute('href') || '';
    const match = href.match(/[?&]id=(\d+)/i);
    return match ? match[1] : '';
  }

  function getPeerUsernameFromCell(userCell) {
    if (!userCell) return '';
    const anchor = getPeerUserAnchor(userCell);
    const rankNode = anchor?.querySelector?.('.tbdrank');
    if (rankNode) return normalizeText(readRankName(rankNode));
    if (anchor) return normalizeText(anchor.textContent);
    return normalizeText(userCell.textContent);
  }

  function ensurePeerUserHoverTrigger(userCell) {
    const anchor = getPeerUserAnchor(userCell);
    if (!anchor) return;
    const userId = getPeerUserIdFromCell(userCell);
    if (!userId) return;

    let trigger = anchor.closest('.dl-sc-trg');
    if (!trigger || !userCell.contains(trigger)) {
      trigger = document.createElement('span');
      trigger.className = 'dl-sc-trg fx';
      anchor.parentNode.insertBefore(trigger, anchor);
      trigger.appendChild(anchor);
    } else if (!trigger.classList.contains('fx')) {
      trigger.classList.add('fx');
    }

    trigger.setAttribute('data-type', 'user');
    trigger.setAttribute('data-tid', userId);
    if (!trigger.hasAttribute('data-props')) trigger.setAttribute('data-props', '');
    if (!trigger.querySelector(':scope > .dl-sc')) {
      const popupHost = document.createElement('div');
      popupHost.className = 'dl-sc';
      trigger.appendChild(popupHost);
    }
  }

  function clearPeerUserRankClass(userCell) {
    const anchor = getPeerUserAnchor(userCell);
    if (!anchor) return;
    const applied = String(anchor.dataset?.[DATA_APPLIED_PEER_RANK_CLASSES] || '')
      .split(/\s+/)
      .filter(Boolean);
    if (applied.length) anchor.classList.remove('tbdrank', ...applied);
    if (anchor.dataset) delete anchor.dataset[DATA_APPLIED_PEER_RANK_CLASSES];
  }

  function applyPeerUserRankClass(userCell, rankClasses) {
    clearPeerUserRankClass(userCell);
    const anchor = getPeerUserAnchor(userCell);
    if (!anchor) return;
    const classes = String(rankClasses || '').split(/\s+/).filter(Boolean);
    if (!classes.length) return;
    anchor.classList.add('tbdrank', ...classes);
    if (anchor.dataset) anchor.dataset[DATA_APPLIED_PEER_RANK_CLASSES] = classes.join(' ');
  }

  function extractProfileUserRankClasses(html) {
    const doc = new DOMParser().parseFromString(String(html || ''), 'text/html');
    const node =
      doc.querySelector('.profile-tib-container h5 > .tbdrank') ||
      doc.querySelector('.profile-tib-container .tbdrank') ||
      doc.querySelector('#middle-block .tbdrank');
    if (!node) return '';
    return Array.from(node.classList || [])
      .filter((cls) => cls && cls !== 'tbdrank')
      .join(' ')
      .trim();
  }

  async function fetchUserRankClasses(userId) {
    const id = String(userId || '').trim();
    if (!id) return '';
    if (userRankClassFetches.has(id)) return userRankClassFetches.get(id);

    const promise = (async () => {
      try {
        const response = await fetch(`account-details.php?id=${encodeURIComponent(id)}`, { credentials: 'include' });
        if (!response.ok) return '';
        const html = await response.text();
        return extractProfileUserRankClasses(html);
      } catch {
        return '';
      } finally {
        userRankClassFetches.delete(id);
      }
    })();

    userRankClassFetches.set(id, promise);
    return promise;
  }

  function applyTopPeerRankClasses(peers, userCellIndex, selfUsername, selfRankClasses) {
    peers.forEach((peer, index) => {
      const userCell = peer.row.cells?.[userCellIndex] || peer.row.cells?.[peer.row.cells.length - 1] || null;
      if (!userCell) return;
      const isSelf = !!selfUsername && getPeerUsernameFromCell(userCell) === selfUsername;

      if (isSelf) {
        if (selfRankClasses) applyPeerUserRankClass(userCell, selfRankClasses);
        else clearPeerUserRankClass(userCell);
        return;
      }

      if (index >= TOP_RANK_STYLE_LIMIT) {
        clearPeerUserRankClass(userCell);
        return;
      }

      const userId = getPeerUserIdFromCell(userCell);
      if (!userId) {
        clearPeerUserRankClass(userCell);
        return;
      }

      void fetchUserRankClasses(userId).then((rankClasses) => {
        if (!peer.row.isConnected) return;
        const liveCell = peer.row.cells?.[userCellIndex] || peer.row.cells?.[peer.row.cells.length - 1] || null;
        if (!liveCell) return;
        if (getPeerUserIdFromCell(liveCell) !== userId) return;
        if (!rankClasses) return;
        applyPeerUserRankClass(liveCell, rankClasses);
      });
    });
  }

  function findUploadColumnIndex(headerRow) {
    const ths = Array.from(headerRow?.querySelectorAll('th') || []);
    const index = ths.findIndex((th) => /^(UL|Upload(?:ed)?)$/i.test((th.textContent || '').trim()));
    return index >= 0 ? index : DEFAULT_UPLOAD_COL_INDEX;
  }

  function findUsernameColumnIndex(headerRow) {
    const ths = Array.from(headerRow?.querySelectorAll('th') || []);
    const index = ths.findIndex((th) => /^username$/i.test((th.textContent || '').trim()));
    return index >= 0 ? index : Math.max(0, ths.length - 1);
  }

  function findIpv6ColumnIndex(headerRow) {
    const ths = Array.from(headerRow?.querySelectorAll('th') || []);
    return ths.findIndex((th) => /^ipv6$/i.test((th.textContent || '').trim()));
  }

  function stripExistingRankColumn(headerRow, rows) {
    headerRow.querySelector('.' + CLASS.rankHeader)?.remove();
    rows.forEach((row) => row.querySelector('.' + CLASS.rankCell)?.remove());
  }

  function makeRankCell(rank) {
    const cell = document.createElement('td');
    cell.className = CLASS.rankCell;
    if (rank <= 5) {
      const badge = document.createElement('span');
      badge.className = `${CLASS.rankBadge} rank-${rank}`;
      badge.textContent = String(rank);
      cell.appendChild(badge);
    } else {
      cell.textContent = String(rank);
    }
    return cell;
  }

  function ensureOriginalRowOrder(rows) {
    rows.forEach((row, index) => {
      if (!row?.dataset) return;
      if (row.dataset[DATA_ORIGINAL_ORDER] == null) row.dataset[DATA_ORIGINAL_ORDER] = String(index);
    });
  }

  function getCurrentPeerRowCount() {
    const table = document.querySelector(TABLE_SEL);
    if (!table) return -1;
    return table.querySelectorAll('tbody tr').length;
  }

  function pinSelfRowsToTop(tbody, selfUsername) {
    if (!tbody || !selfUsername) return;
    const rows = Array.from(tbody.querySelectorAll(':scope > tr'));
    if (!rows.length) return;

    const selfRows = [];
    const otherRows = [];
    rows.forEach((row) => {
      const cells = row.cells;
      const userCell = cells?.[cells.length - 1] || null;
      const isSelf = row.classList.contains(CLASS.selfRow) || getPeerUsernameFromCell(userCell) === selfUsername;
      if (isSelf) {
        row.classList.add(CLASS.selfRow);
        userCell?.classList?.add(CLASS.selfUser);
        selfRows.push(row);
      } else {
        otherRows.push(row);
      }
    });

    if (!selfRows.length) return;
    const fragment = document.createDocumentFragment();
    selfRows.forEach((row) => fragment.appendChild(row));
    otherRows.forEach((row) => fragment.appendChild(row));
    tbody.appendChild(fragment);
  }

  function processPeerTable() {
    const peerTable = document.querySelector(TABLE_SEL);
    if (!peerTable) return;

    const headerRow = peerTable.querySelector('thead tr');
    const tbody = peerTable.querySelector('tbody');
    if (!headerRow || !tbody) return;

    const rows = Array.from(tbody.querySelectorAll('tr')).filter((row) => row.cells && row.cells.length > 1);
    if (!rows.length) return;
    ensureOriginalRowOrder(rows);

    stripExistingRankColumn(headerRow, rows);
    clearSelfHighlight(peerTable);

    const uploadColIndex = findUploadColumnIndex(headerRow);
    const usernameColIndex = findUsernameColumnIndex(headerRow);
    const ipv6ColIndex = findIpv6ColumnIndex(headerRow);
    const userCellIndex = usernameColIndex + 1;
    const ipv6CellIndex = ipv6ColIndex >= 0 ? ipv6ColIndex + 1 : -1;
    const selfUsername = normalizeText(getLoggedInUsername());
    const selfRankClasses = String(getLoggedInUserRankClasses() || '').trim();

    const rankHeader = document.createElement('th');
    rankHeader.className = CLASS.rankHeader;
    rankHeader.textContent = 'Rank';
    headerRow.insertBefore(rankHeader, headerRow.firstChild);

    const peers = rows.map((row) => ({
      row,
      uploadedBytes: Number(toBytes(row.cells[uploadColIndex]?.textContent || '0 B')) || 0,
      originalOrder: Number(row.dataset?.[DATA_ORIGINAL_ORDER] || 0)
    }));

    peers.sort((a, b) => (b.uploadedBytes - a.uploadedBytes) || (a.originalOrder - b.originalOrder));

    const selfRows = [];
    const otherRows = [];
    peers.forEach((peer, index) => {
      const rank = index + 1;
      const rankCell = makeRankCell(rank);
      peer.row.insertBefore(rankCell, peer.row.firstChild);

      if (ipv6CellIndex >= 0) {
        const ipv6Cell = peer.row.cells?.[ipv6CellIndex] || null;
        if (ipv6Cell) ipv6Cell.style.textAlign = 'center';
      }

      const cells = peer.row.cells;
      const userCell = cells?.[userCellIndex] || cells?.[cells.length - 1] || null;
      ensurePeerUserHoverTrigger(userCell);
      const isSelf = !!selfUsername && getPeerUsernameFromCell(userCell) === selfUsername;
      if (isSelf) {
        peer.row.classList.add(CLASS.selfRow);
        userCell?.classList?.add(CLASS.selfUser);
        if (selfRankClasses) applyPeerUserRankClass(userCell, selfRankClasses);
        selfRows.push(peer.row);
      } else {
        otherRows.push(peer.row);
      }
    });

    const fragment = document.createDocumentFragment();
    selfRows.forEach((row) => fragment.appendChild(row));
    otherRows.forEach((row) => fragment.appendChild(row));
    tbody.appendChild(fragment);
    applyTopPeerRankClasses(peers, userCellIndex, selfUsername, selfRankClasses);
    pinSelfRowsToTop(tbody, selfUsername);
  }

  function scheduleProcess() {
    if (renderQueued) return;
    renderQueued = true;
    requestAnimationFrame(() => {
      renderQueued = false;
      safeRun(processPeerTable);
    });
  }

  function ensureRefreshLoop() {
    if (refreshIntervalId) return;
    refreshIntervalId = setInterval(() => {
      if (!document.querySelector(TABLE_SEL)) return;
      getLoggedInUsername();
      scheduleProcess();
    }, REFRESH_MS);
  }

  function ensurePresenceObserver() {
    if (peerPresenceObserver || !document.body) return;
    lastObservedPeerRowCount = getCurrentPeerRowCount();
    peerPresenceObserver = new MutationObserver(() => {
      const nextCount = getCurrentPeerRowCount();
      if (nextCount !== lastObservedPeerRowCount) {
        lastObservedPeerRowCount = nextCount;
        if (nextCount >= 0) {
          getLoggedInUsername();
          scheduleProcess();
        }
      }
    });
    peerPresenceObserver.observe(document.body, { childList: true, subtree: true });
  }

  function run() {
    ensureStyles();
    getLoggedInUsername();
    scheduleProcess();
    ensureRefreshLoop();
    ensurePresenceObserver();
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', run, { once: true });
  } else {
    run();
  }
})();