// ==UserScript==
// @name UNIT3D Torrent Group Sorter
// @namespace https://tampermonkey.net/
// @license MIT
// @version 2.0
// @description Sort torrents by completed downloads, seeders, age, size, or type - click button to activate. Works on UNIT3D trackers, such as seedpool, fearnopeer, yu-scene and darkpeers.
// @author boisterous-larva
// @match https://*/torrents/similar/*
// ==/UserScript==
(function() {
'use strict';
// Wait for page to load
window.addEventListener('load', function() {
// Add activation button
addActivationButton();
});
function addActivationButton() {
const panelHeading = document.querySelector('.panel__heading');
if (!panelHeading) return;
const button = document.createElement('button');
button.textContent = '🔢 Enable Table Sorting';
button.style.cssText = `
margin-left: 15px;
padding: 5px 10px;
background: #4CAF50;
color: white;
border: none;
border-radius: 3px;
cursor: pointer;
font-size: 12px;
font-weight: bold;
vertical-align: middle;
`;
button.addEventListener('click', function() {
// Remove the button
button.remove();
// Initialize the table transformation and sorting
initializeTableSorting();
});
panelHeading.appendChild(button);
}
function initializeTableSorting() {
// First, duplicate row headers for each row while keeping tbody elements
const tableData = prepareTableForSorting();
// Then set up the sorting for all columns
const completedHeader = document.querySelector('.similar-torrents__completed-header');
const seedersHeader = document.querySelector('.similar-torrents__seeders-header');
const ageHeader = document.querySelector('.similar-torrents__age-header');
const sizeHeader = document.querySelector('.similar-torrents__size-header');
const typeHeader = document.querySelector('.similar-torrents__type-header');
if (!completedHeader || !seedersHeader || !ageHeader || !sizeHeader || !typeHeader) return;
// Store original content before modifying
const originalCompletedContent = completedHeader.innerHTML;
const originalSeedersContent = seedersHeader.innerHTML;
const originalAgeContent = ageHeader.innerHTML;
const originalSizeContent = sizeHeader.innerHTML;
const originalTypeContent = typeHeader.innerHTML;
// Set up completed header
completedHeader.style.cursor = 'pointer';
completedHeader.title = 'Click to sort by completed downloads';
// Set up seeders header
seedersHeader.style.cursor = 'pointer';
seedersHeader.title = 'Click to sort by seeders';
// Set up age header
ageHeader.style.cursor = 'pointer';
ageHeader.title = 'Click to sort by age';
// Set up size header
sizeHeader.style.cursor = 'pointer';
sizeHeader.title = 'Click to sort by size';
// Set up type header
typeHeader.style.cursor = 'pointer';
typeHeader.title = 'Click to sort by type';
let currentSort = { column: 'completed', direction: 'desc' };
completedHeader.addEventListener('click', function() {
if (currentSort.column === 'completed') {
// Toggle direction if same column
currentSort.direction = currentSort.direction === 'desc' ? 'asc' : 'desc';
} else {
// Switch to completed column with default desc direction
currentSort = { column: 'completed', direction: 'desc' };
}
sortTable(tableData, currentSort.column, currentSort.direction);
updateSortIndicators(currentSort, originalCompletedContent, originalSeedersContent, originalAgeContent, originalSizeContent, originalTypeContent);
});
seedersHeader.addEventListener('click', function() {
if (currentSort.column === 'seeders') {
// Toggle direction if same column
currentSort.direction = currentSort.direction === 'desc' ? 'asc' : 'desc';
} else {
// Switch to seeders column with default desc direction
currentSort = { column: 'seeders', direction: 'desc' };
}
sortTable(tableData, currentSort.column, currentSort.direction);
updateSortIndicators(currentSort, originalCompletedContent, originalSeedersContent, originalAgeContent, originalSizeContent, originalTypeContent);
});
ageHeader.addEventListener('click', function() {
if (currentSort.column === 'age') {
// Toggle direction if same column
currentSort.direction = currentSort.direction === 'desc' ? 'asc' : 'desc';
} else {
// Switch to age column with default desc direction (newest first)
currentSort = { column: 'age', direction: 'desc' };
}
sortTable(tableData, currentSort.column, currentSort.direction);
updateSortIndicators(currentSort, originalCompletedContent, originalSeedersContent, originalAgeContent, originalSizeContent, originalTypeContent);
});
sizeHeader.addEventListener('click', function() {
if (currentSort.column === 'size') {
// Toggle direction if same column
currentSort.direction = currentSort.direction === 'desc' ? 'asc' : 'desc';
} else {
// Switch to size column with default desc direction (largest first)
currentSort = { column: 'size', direction: 'desc' };
}
sortTable(tableData, currentSort.column, currentSort.direction);
updateSortIndicators(currentSort, originalCompletedContent, originalSeedersContent, originalAgeContent, originalSizeContent, originalTypeContent);
});
typeHeader.addEventListener('click', function() {
if (currentSort.column === 'type') {
// Toggle direction if same column
currentSort.direction = currentSort.direction === 'desc' ? 'asc' : 'desc';
} else {
// Switch to type column with default asc direction (alphabetical)
currentSort = { column: 'type', direction: 'asc' };
}
sortTable(tableData, currentSort.column, currentSort.direction);
updateSortIndicators(currentSort, originalCompletedContent, originalSeedersContent, originalAgeContent, originalSizeContent, originalTypeContent);
});
function prepareTableForSorting() {
const table = document.querySelector('.similar-torrents__torrents');
const tableBodies = Array.from(table.querySelectorAll('tbody'));
const tableData = {
bodies: [],
allRows: []
};
tableBodies.forEach(tbody => {
const allRows = Array.from(tbody.querySelectorAll('tr'));
// Find the group header row (the one with the spanning th)
const groupHeaderRow = allRows.find(row => {
const headerCell = row.querySelector('.similar-torrents__type[rowspan]');
return headerCell && parseInt(headerCell.getAttribute('rowspan')) > 1;
});
if (groupHeaderRow) {
const groupHeaderCell = groupHeaderRow.querySelector('.similar-torrents__type[rowspan]');
const headerText = groupHeaderCell.textContent.trim();
// Remove the group header cell from its original row
groupHeaderCell.remove();
// Process all data rows in this tbody
allRows.forEach(row => {
// Remove any existing header cells in this row
const existingHeader = row.querySelector('.similar-torrents__type');
if (existingHeader) {
existingHeader.remove();
}
// Create and insert new header cell for every row
const newHeader = document.createElement('th');
newHeader.className = 'similar-torrents__type';
newHeader.textContent = headerText;
newHeader.scope = 'row';
row.insertBefore(newHeader, row.firstChild);
// Store row reference for sorting
tableData.allRows.push(row);
});
// Store the tbody reference
tableData.bodies.push(tbody);
} else {
// This tbody doesn't have a spanning group header
allRows.forEach(row => {
// Ensure existing headers have proper scope
const existingHeader = row.querySelector('.similar-torrents__type');
if (existingHeader) {
existingHeader.scope = 'row';
}
tableData.allRows.push(row);
});
tableData.bodies.push(tbody);
}
});
console.log(`Prepared ${tableData.allRows.length} rows for sorting`);
return tableData;
}
function sortTable(tableData, column, direction) {
const rows = tableData.allRows;
if (rows.length === 0) {
console.error('No rows found for sorting');
return;
}
console.log(`Sorting ${rows.length} rows by ${column} in ${direction} order`);
rows.sort((a, b) => {
let valueA, valueB;
if (column === 'completed') {
valueA = getCompletedValue(a);
valueB = getCompletedValue(b);
} else if (column === 'seeders') {
valueA = getSeedersValue(a);
valueB = getSeedersValue(b);
} else if (column === 'age') {
valueA = getAgeValue(a);
valueB = getAgeValue(b);
} else if (column === 'size') {
valueA = getSizeValue(a);
valueB = getSizeValue(b);
} else if (column === 'type') {
valueA = getTypeValue(a);
valueB = getTypeValue(b);
}
// For string comparisons (type), we need to handle differently
if (column === 'type') {
if (direction === 'desc') {
return valueB.localeCompare(valueA);
} else {
return valueA.localeCompare(valueB);
}
} else {
// For numeric comparisons
return direction === 'desc' ? valueB - valueA : valueA - valueB;
}
});
// Clear all tbody elements first
tableData.bodies.forEach(tbody => {
while (tbody.firstChild) {
tbody.removeChild(tbody.firstChild);
}
});
// Redistribute sorted rows back into tbody elements
const firstTbody = tableData.bodies[0];
rows.forEach(row => firstTbody.appendChild(row));
// Remove empty tbody elements (except the first one)
for (let i = 1; i < tableData.bodies.length; i++) {
const tbody = tableData.bodies[i];
if (tbody.parentNode && tbody.children.length === 0) {
tbody.remove();
}
}
}
function getCompletedValue(row) {
const completedLink = row.querySelector('.torrent-search--grouped__completed .text-orange');
if (completedLink) {
const text = completedLink.textContent.trim();
return parseInt(text) || 0;
}
// Fallback: try to find any completed value in the completed cell
const completedCell = row.querySelector('.torrent-search--grouped__completed');
if (completedCell) {
const text = completedCell.textContent.trim();
const match = text.match(/\d+/);
return match ? parseInt(match[0]) : 0;
}
return 0;
}
function getSeedersValue(row) {
// Try multiple selectors to find the seeders value
let seedersText = '';
// First, try the specific seeders cell
const seedersCell = row.querySelector('.similar-torrents__seeders');
if (seedersCell) {
seedersText = seedersCell.textContent.trim();
} else {
// Try to find any cell that might contain seeders information
// Look for the cell in the same position as the seeders header
const allCells = row.querySelectorAll('td');
const seedersHeader = document.querySelector('.torrent-search--grouped__seeders .type-green');
if (seedersHeader) {
// Find the index of the seeders header in the table header row
const headerRow = seedersHeader.closest('tr');
if (headerRow) {
const headerCells = Array.from(headerRow.querySelectorAll('th, td'));
const seedersIndex = headerCells.indexOf(seedersHeader);
if (seedersIndex !== -1 && allCells[seedersIndex]) {
seedersText = allCells[seedersIndex].textContent.trim();
}
}
}
// If still not found, try to find any cell with seeders-like content
if (!seedersText) {
for (let cell of allCells) {
const text = cell.textContent.trim();
// Look for cells that only contain a number (likely seeders/leechers)
if (/^\d+$/.test(text)) {
seedersText = text;
break;
}
}
}
}
// Extract the numeric value
if (seedersText) {
const match = seedersText.match(/\d+/);
return match ? parseInt(match[0]) : 0;
}
return 0;
}
function getAgeValue(row) {
// Find the age cell and extract the datetime attribute
const ageCell = row.querySelector('.torrent-search--grouped__age');
if (ageCell) {
const timeElement = ageCell.querySelector('time');
if (timeElement && timeElement.hasAttribute('datetime')) {
const datetime = timeElement.getAttribute('datetime');
// Convert the datetime string to a timestamp for sorting
return new Date(datetime).getTime();
}
}
// Fallback: if no datetime attribute, return a very old date
return 0;
}
function getSizeValue(row) {
// Find the size cell and extract the byte value from the title attribute
const sizeCell = row.querySelector('.torrent-search--grouped__size');
if (sizeCell) {
const spanElement = sizeCell.querySelector('span');
if (spanElement && spanElement.hasAttribute('title')) {
const title = spanElement.getAttribute('title');
// Extract the numeric byte value from the title (e.g., "55955516318 B")
const match = title.match(/(\d+)\s*B/);
if (match) {
return parseInt(match[1]);
}
}
// Fallback: if no title attribute, try to parse the displayed text
const sizeText = sizeCell.textContent.trim();
return parseFileSize(sizeText);
}
return 0;
}
function getTypeValue(row) {
// Find the type header cell in the row
const typeCell = row.querySelector('.similar-torrents__type');
if (typeCell) {
return typeCell.textContent.trim();
}
return '';
}
function parseFileSize(sizeString) {
// Parse human-readable file sizes like "52.11 GiB", "1.2 MiB", etc.
const units = {
'B': 1,
'KiB': 1024,
'MiB': 1024 * 1024,
'GiB': 1024 * 1024 * 1024,
'TiB': 1024 * 1024 * 1024 * 1024,
'KB': 1000,
'MB': 1000 * 1000,
'GB': 1000 * 1000 * 1000,
'TB': 1000 * 1000 * 1000 * 1000
};
const match = sizeString.match(/([\d.]+)\s*([KMGTP]?i?B)/);
if (match) {
const value = parseFloat(match[1]);
const unit = match[2];
return Math.round(value * (units[unit] || 1));
}
return 0;
}
function updateSortIndicators(sortState, originalCompleted, originalSeeders, originalAge, originalSize, originalType) {
const completedHeader = document.querySelector('.similar-torrents__completed-header');
const seedersHeader = document.querySelector('.similar-torrents__seeders-header');
const ageHeader = document.querySelector('.similar-torrents__age-header');
const sizeHeader = document.querySelector('.similar-torrents__size-header');
const typeHeader = document.querySelector('.similar-torrents__type-header');
// Reset all headers to their original content
completedHeader.innerHTML = originalCompleted;
seedersHeader.innerHTML = originalSeeders;
ageHeader.innerHTML = originalAge;
sizeHeader.innerHTML = originalSize;
typeHeader.innerHTML = originalType;
// Add indicator to active column by appending arrow to existing content
const arrow = sortState.direction === 'desc' ? ' ↓' : ' ↑';
if (sortState.column === 'completed') {
completedHeader.innerHTML += arrow;
} else if (sortState.column === 'seeders') {
seedersHeader.innerHTML += arrow;
} else if (sortState.column === 'age') {
ageHeader.innerHTML += arrow;
} else if (sortState.column === 'size') {
sizeHeader.innerHTML += arrow;
} else if (sortState.column === 'type') {
typeHeader.innerHTML += arrow;
}
// Restore cursor styles and titles
completedHeader.style.cursor = 'pointer';
completedHeader.title = 'Click to sort by completed downloads';
seedersHeader.style.cursor = 'pointer';
seedersHeader.title = 'Click to sort by seeders';
ageHeader.style.cursor = 'pointer';
ageHeader.title = 'Click to sort by age';
sizeHeader.style.cursor = 'pointer';
sizeHeader.title = 'Click to sort by size';
typeHeader.style.cursor = 'pointer';
typeHeader.title = 'Click to sort by type';
}
// Initial sort by completed (descending)
sortTable(tableData, currentSort.column, currentSort.direction);
updateSortIndicators(currentSort, originalCompletedContent, originalSeedersContent, originalAgeContent, originalSizeContent, originalTypeContent);
// Show a quick confirmation
showNotification('Table sorting enabled! Click the Completed, Seeders, Age, Size, or Type column headers to sort.');
}
function showNotification(message) {
const notification = document.createElement('div');
notification.textContent = message;
notification.style.cssText = `
position: fixed;
top: 100px;
right: 20px;
z-index: 10000;
padding: 10px 15px;
background: #2196F3;
color: white;
border-radius: 5px;
font-size: 14px;
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
`;
document.body.appendChild(notification);
// Remove after 3 seconds
setTimeout(() => {
notification.remove();
}, 3000);
}
})();