qBittorrent Selected Size + Count

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

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==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);
})();