// ==UserScript==
// @name UNIT3D Table Sorter
// @namespace https://github.com/flowerey/unit3d-scripts
// @version 1.3.2
// @description Sort torrent tables by size, age, seeders, and more.
// @author blueberry
// @match https://*/torrents
// @match https://*/torrents/*
// @downloadURL https://raw.githubusercontent.com/flowerey/unit3d-scripts/main/table-sorting.user.js
// @updateURL https://raw.githubusercontent.com/flowerey/unit3d-scripts/main/table-sorting.user.js.meta.js
// @grant none
// @run-at document-end
// ==/UserScript==
(function () {
'use strict';
const SORT_STORAGE_KEY = 'unit3d-sort-prefs';
const Utils = {
parseSize(text) {
const units = { 'B': 1, 'KB': 1e3, 'MB': 1e6, 'GB': 1e9, 'TB': 1e12, 'KiB': 1024, 'MiB': 1048576, 'GiB': 1073741824, 'TiB': 1099511627776 };
const match = text.match(/([\d.]+)\s*([A-Za-z]+)/);
if (!match) return 0;
const [_, val, unit] = match;
return parseFloat(val) * (units[unit] || 1);
},
parseNumber(text) {
return parseInt(text.replace(/[^0-9]/g, '')) || 0;
},
parseDate(el) {
const time = el.querySelector('time');
return time ? new Date(time.getAttribute('datetime')).getTime() : 0;
}
};
class TableSorter {
constructor(table, prefs) {
this.table = table;
this.tbody = table.querySelector('tbody');
this.headers = Array.from(table.querySelectorAll('thead th'));
this.currentSort = { index: -1, desc: true };
this.secondarySort = { index: -1, desc: true };
this.prefs = prefs;
this.config = [
{ name: 'Type', parser: el => el.textContent.trim(), type: 'string' },
{ name: 'Name', parser: el => el.textContent.trim(), type: 'string' },
{ name: 'Size', parser: el => Utils.parseSize(el.textContent), type: 'number' },
{ name: 'Age', parser: el => Utils.parseDate(el), type: 'number' },
{ name: 'Seeders', parser: el => Utils.parseNumber(el.textContent), type: 'number' },
{ name: 'Leechers', parser: el => Utils.parseNumber(el.textContent), type: 'number' },
{ name: 'Completed', parser: el => Utils.parseNumber(el.textContent), type: 'number' }
];
this.init();
}
// UNIT3D v8+ renders the Seeders/Leechers/Completed column headers as icons
// only (no visible text). Resolve the column identity from the visible text,
// the `title` attribute, or the header class (`torrent-search--list__*-header`).
headerName(th) {
const text = (th.textContent || '').trim();
if (text) return text;
if (th.getAttribute('title')) return th.getAttribute('title').trim();
const cls = Array.from(th.classList).find(c => c.startsWith('torrent-search--list__') && c.endsWith('-header'));
if (cls) {
const word = cls.replace('torrent-search--list__', '').replace('-header', '');
if (word === 'seeders') return 'Seeders';
if (word === 'leechers') return 'Leechers';
if (word === 'completed') return 'Completed';
if (word === 'name') return 'Name';
if (word === 'size') return 'Size';
if (word === 'age') return 'Age';
return word;
}
return text;
}
init() {
this.headers.forEach((th, i) => {
const name = this.headerName(th);
const spec = this.config.find(c => name.includes(c.name));
if (spec) {
th.style.cursor = 'pointer';
th.style.userSelect = 'none';
th.title = `Sort by ${spec.name} (Shift+Click for secondary)`;
th.addEventListener('click', (e) => this.sort(i, spec, e.shiftKey));
}
});
if (this.prefs && this.prefs.index >= 0 && this.prefs.index < this.headers.length) {
const spec = this.config.find(c => this.headerName(this.headers[this.prefs.index]).includes(c.name));
if (spec) {
this.sort(this.prefs.index, spec, false, this.prefs.desc);
}
}
}
sort(index, spec, isSecondary, forceDesc) {
const rows = Array.from(this.tbody.querySelectorAll('tr'));
if (isSecondary) {
const isDesc = forceDesc !== undefined ? forceDesc : (this.secondarySort.index === index ? !this.secondarySort.desc : true);
this.secondarySort = { index, desc: isDesc };
this.performSort(rows);
this.updateUI();
return;
}
const isDesc = forceDesc !== undefined ? forceDesc : (this.currentSort.index === index ? !this.currentSort.desc : true);
this.currentSort = { index, desc: isDesc };
this.secondarySort = { index: -1, desc: true };
this.performSort(rows);
this.updateUI();
this.savePrefs(index, isDesc);
}
performSort(rows) {
const primary = this.currentSort;
const secondary = this.secondarySort;
rows.sort((a, b) => {
let result = this.compareRows(a, b, primary);
if (result === 0 && secondary.index >= 0) {
result = this.compareRows(a, b, secondary);
}
return result;
});
rows.forEach(row => this.tbody.appendChild(row));
}
compareRows(a, b, sort) {
if (sort.index < 0) return 0;
const spec = this.config.find(c => this.headerName(this.headers[sort.index]).includes(c.name));
if (!spec) return 0;
const cellA = a.children[sort.index];
const cellB = b.children[sort.index];
if (!cellA || !cellB) return 0;
const valA = spec.parser(cellA);
const valB = spec.parser(cellB);
let result;
if (spec.type === 'string') {
result = valA.localeCompare(valB);
} else {
result = valA - valB;
}
return sort.desc ? -result : result;
}
savePrefs(index, desc) {
try {
localStorage.setItem(SORT_STORAGE_KEY, JSON.stringify({ index, desc }));
} catch (e) { /* ignore */ }
}
updateUI() {
this.headers.forEach((th, i) => {
const indicator = th.querySelector('.sort-indicator');
if (indicator) indicator.remove();
if (i === this.currentSort.index) {
const span = document.createElement('span');
span.className = 'sort-indicator';
span.textContent = this.currentSort.desc ? ' \u25BC' : ' \u25B2';
span.style.color = '#2ecc71';
th.appendChild(span);
} else if (i === this.secondarySort.index) {
const span = document.createElement('span');
span.className = 'sort-indicator';
span.textContent = this.secondarySort.desc ? ' \u25B2' : ' \u25BC';
span.style.color = '#888';
span.style.fontSize = '10px';
th.appendChild(span);
}
});
}
}
class QuickFilter {
constructor() {
this.filterInput = null;
this.filterBtn = null;
this.active = false;
}
inject() {
const panel = document.querySelector('.panel__heading');
if (!panel || document.getElementById('quick-filter-container')) return;
const container = document.createElement('div');
container.id = 'quick-filter-container';
Object.assign(container.style, {
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
marginLeft: '12px',
verticalAlign: 'middle'
});
this.filterInput = document.createElement('input');
this.filterInput.type = 'text';
this.filterInput.placeholder = 'Filter torrents...';
this.filterInput.id = 'quick-filter-input';
Object.assign(this.filterInput.style, {
padding: '4px 10px',
border: '1px solid #444',
borderRadius: '4px',
backgroundColor: '#1a1a1a',
color: '#ddd',
fontSize: '11px',
width: '180px',
outline: 'none',
transition: 'border-color 0.2s'
});
this.filterInput.onfocus = () => this.filterInput.style.borderColor = '#2ecc71';
this.filterInput.onblur = () => this.filterInput.style.borderColor = '#444';
this.filterBtn = document.createElement('button');
this.filterBtn.innerHTML = '';
this.filterBtn.title = 'Toggle filter';
Object.assign(this.filterBtn.style, {
padding: '4px 8px',
backgroundColor: '#444',
color: '#fff',
border: 'none',
borderRadius: '4px',
fontSize: '11px',
cursor: 'pointer',
transition: 'background 0.2s'
});
this.filterBtn.onmouseover = () => { if (!this.active) this.filterBtn.style.backgroundColor = '#555'; };
this.filterBtn.onmouseout = () => { if (!this.active) this.filterBtn.style.backgroundColor = '#444'; };
this.filterBtn.onclick = () => {
this.active = !this.active;
if (this.active) {
this.filterBtn.style.backgroundColor = '#2ecc71';
this.applyFilter();
} else {
this.filterBtn.style.backgroundColor = '#444';
this.clearFilter();
}
};
this.filterInput.addEventListener('input', () => {
if (this.active) this.applyFilter();
});
const clearBtn = document.createElement('button');
clearBtn.innerHTML = '';
clearBtn.title = 'Clear filter';
Object.assign(clearBtn.style, {
padding: '4px 8px',
backgroundColor: 'transparent',
color: '#888',
border: '1px solid #444',
borderRadius: '4px',
fontSize: '11px',
cursor: 'pointer',
transition: 'color 0.2s'
});
clearBtn.onmouseover = () => clearBtn.style.color = '#e74c3c';
clearBtn.onmouseout = () => clearBtn.style.color = '#888';
clearBtn.onclick = () => {
this.filterInput.value = '';
this.active = false;
this.filterBtn.style.backgroundColor = '#444';
this.clearFilter();
};
container.appendChild(this.filterInput);
container.appendChild(this.filterBtn);
container.appendChild(clearBtn);
panel.appendChild(container);
this.filterInput.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
this.filterInput.value = '';
this.active = false;
this.filterBtn.style.backgroundColor = '#444';
this.clearFilter();
this.filterInput.blur();
}
});
}
applyFilter() {
const query = this.filterInput.value.toLowerCase().trim();
const tables = document.querySelectorAll(SORTABLE_TABLES);
tables.forEach(table => {
const rows = table.querySelectorAll('tbody tr');
rows.forEach(row => {
const text = row.textContent.toLowerCase();
row.style.display = text.includes(query) ? '' : 'none';
});
});
}
clearFilter() {
const tables = document.querySelectorAll(SORTABLE_TABLES);
tables.forEach(table => {
const rows = table.querySelectorAll('tbody tr');
rows.forEach(row => { row.style.display = ''; });
});
}
}
const SORTABLE_TABLES = [
'table.data-table',
'.similar-torrents__torrents'
].join(', ');
let savedPrefs = null;
try {
savedPrefs = JSON.parse(localStorage.getItem(SORT_STORAGE_KEY));
} catch (e) { /* ignore */ }
const filter = new QuickFilter();
const setupUI = () => {
const panel = document.querySelector('.panel__heading');
if (!panel || document.getElementById('enable-sort-btn')) return;
const btn = document.createElement('button');
btn.id = 'enable-sort-btn';
btn.innerHTML = ' Enable Sorting';
Object.assign(btn.style, {
marginLeft: '15px',
padding: '4px 12px',
backgroundColor: '#2ecc71',
color: '#fff',
border: 'none',
borderRadius: '4px',
fontSize: '11px',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'background 0.2s'
});
btn.onmouseover = () => btn.style.backgroundColor = '#27ae60';
btn.onmouseout = () => btn.style.backgroundColor = '#2ecc71';
btn.onclick = () => {
const tables = document.querySelectorAll(SORTABLE_TABLES);
tables.forEach(t => new TableSorter(t, savedPrefs));
btn.remove();
};
panel.appendChild(btn);
filter.inject();
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', setupUI);
} else {
setupUI();
}
})();