qBittorrent Selected Size + Count

Show selected torrent count and total size in qBittorrent WebUI footer (old/new versions)

// ==UserScript==
// @name         qBittorrent Selected Size + Count
// @namespace    http://tampermonkey.net/
// @version      1.2
// @description  Show selected torrent count and total size in qBittorrent WebUI footer (old/new versions)
// @match http://localhost:8080/*
// @author       me + others
// @run-at       document-idle
// @grant        none
// @license      MIT

// ==/UserScript==

/*
  Notes:
  - !!! Make sure to adjust the @match above to your WebUI address if needed !!!
  - This version was adjusted by ChatGPT in October 2025 and is confirmed to work on qBittorrent WebUI v5.0.4 - v5.1.2
  - Adds selected torrent count alongside the total size
  - Updates original v0.5 logic to get full old + new WebUI compatibility (#statusBar + #desktopFooter)
  - Replaces 1s polling with debounced MutationObservers (lightweight, real-time)
  - Dynamically detects “Size” column instead of hard-coded index (future-proof)
  - Accurate byte-based math replaces mixed-unit summation
  - Enhanced lightweight multi-language label detection (one time at startup, no performance cost)
  - Keeps English size units (KiB, MiB, GiB, TiB) for reliable parsing across locales
  - Privacy checked to be safe and running 100% locally (no data collection, no external requests)
*/

(() => {
  'use strict';

  const FOOTER_ID = 'tmSelectedSizeTotal';
  const DEBOUNCE = 80;
  let footerEl = null, sizeColIndex = null, timer = null;

  // --- Lightweight language detection (only affects label text)
  const helpLang = {
    "Help": "en", "Ajutor": "ro", "Aide": "fr", "Ayuda": "es", "Справка": "ru",
    "Aiuto": "it", "帮助": "zh", "Hilfe": "de", "Βοήθεια": "el",
    "Ajuda": "pt", "日本語": "ja"
  };
  const labelMap = {
    en: 'Selected Torrents:', ro: 'Torrente selectate:', fr: 'Torrents sélectionnés:',
    es: 'Torrents seleccionados:', ru: 'Выбранные торренты:', it: 'Torrent selezionati:',
    zh: '选中的种子:', de: 'Ausgewählte Torrents:', el: 'Επιλεγμένα Torrents:',
    pt: 'Torrents Selecionados:', ja: '選択したトレント:'
  };
  const uiText = document.querySelector('#desktopNavbar a:last-child')?.innerText.trim() || 'Help';
  const LABEL = labelMap[helpLang[uiText] || 'en'];

  const waitFor = (sel, cb, tries = 0) => {
    const el = document.querySelector(sel);
    if (el) return cb(el);
    if (tries < 50) setTimeout(() => waitFor(sel, cb, tries + 1), 200);
  };

  const ensureFooter = (row) => {
    if (footerEl = document.getElementById(FOOTER_ID)) return;
    const td = document.createElement('td');
    td.id = FOOTER_ID;
    td.textContent = `${LABEL} 0 (0.00 MiB)`;
    const sep = document.createElement('td');
    sep.className = 'statusBarSeparator';
    row.insertBefore(td, row.firstElementChild);
    row.insertBefore(sep, td.nextElementSibling); // replaced comma operator
    footerEl = td;
  };

  const toBytes = (t) => {
    const m = t.replace(/\u00A0/g, ' ').replace(/,/g, '').trim().match(/^([\d.]+)\s*(B|KiB|MiB|GiB|TiB)$/i);
    if (!m) return 0;
    const v = parseFloat(m[1]), u = m[2].toUpperCase();
    return v * (u === 'B' ? 1 : u === 'KIB' ? 1024 : u === 'MIB' ? 1024**2 : u === 'GIB' ? 1024**3 : 1024**4);
  };

  const fmt = (b) => {
    const u = ['B','KiB','MiB','GiB','TiB'];
    let i = 0;
    while (b >= 1024 && i < u.length - 1) { b /= 1024; i++; } // replaced comma operator with braces
    return `${b.toFixed(2)} ${u[i]}`;
  };

  const findSizeCol = () => {
    const ths = document.querySelectorAll('#torrentsTableDiv thead th');
    for (const [i, th] of ths.entries()) {
      if (th.classList.contains('column_size') || /size/i.test(th.textContent)) return i;
    }
    return null;
  };

  const update = () => {
    clearTimeout(timer);
    timer = setTimeout(() => {
      if (!footerEl) return;
      if (sizeColIndex == null) sizeColIndex = findSizeCol();
      const rows = document.querySelectorAll('#torrentsTableDiv tbody tr.selected');
      let total = 0;
      for (const r of rows) {
        const cell = r.children[sizeColIndex];
        if (cell) total += toBytes(cell.textContent);
      }
      footerEl.textContent = `${LABEL} ${rows.length} (${fmt(total)})`;
    }, DEBOUNCE);
  };

  const start = () => {
    const tableDiv = document.querySelector('#torrentsTableDiv');
    if (!tableDiv) return;
    const tb = tableDiv.querySelector('tbody');
    if (tb) new MutationObserver(update).observe(tb, { subtree: true, attributes: true, childList: true });
    new MutationObserver(() => { sizeColIndex = null; update(); })
      .observe(document.body, { subtree: true, childList: true });
    document.addEventListener('click', update, true);
    document.addEventListener('keyup', update, true);
    update();
  };

  waitFor('#desktopFooter tr, #statusBar table tr', ensureFooter);
  waitFor('#torrentsTableDiv', start);
})();