// ==UserScript== // @name Nyaa - Personal Tweaks // @namespace github.com/Decicus // @match https://nyaa.si/* // @grant GM_setClipboard // @version 1.5.0 // @author Decicus // @description Adds some extra functionality to Nyaa. // ==/UserScript== let copyAmount = 5; const multipliers = { Bytes: 1, KiB: 1024, MiB: 1024 * 1024, GiB: 1024 * 1024 * 1024, TiB: 1024 * 1024 * 1024 * 1024, }; const sizesRegex = /(Bytes|KiB|MiB|GiB|TiB)/g; function getElements(linkType = 'download') { const elements = document.querySelectorAll(`.fa-${linkType}`); return Array.from(elements) .map(x => x.parentElement.href) .filter(x => x && x.trim() !== ''); } function getLinks() { return getElements('download'); } function getMagnets() { return getElements('magnet'); } /** * Calculate the approximate total size of all torrents listed in the table and append it as an information row at the bottom of the table */ function calculateTotalSize() { const timestamps = document.querySelectorAll('.torrent-list td[data-timestamp]'); let totalBytes = 0; for (const timestamp of timestamps) { const sizeCell = timestamp.previousElementSibling; const size = sizeCell.textContent.trim(); let suffix = size.match(sizesRegex); if (!suffix || suffix.length < 1) { console.error('Could not find size in element', sizeCell); continue; } suffix = suffix[0]; const multiplier = multipliers[suffix]; const sizeNumber = parseFloat(size.replace(` ${suffix}`, '').trim()); const torrentBytes = sizeNumber * multiplier; totalBytes += torrentBytes; } const multiplierKeys = Object.keys(multipliers).reverse(); let totalSize = `${totalBytes} Bytes`; for (const key of multiplierKeys) { if (totalBytes === 0) { break; } const divideBy = multipliers[key]; const result = totalBytes / divideBy; if (result > 1) { totalSize = `${result.toFixed(2)} ${key}`; break; } } const torrentList = document.querySelector('.torrent-list tbody'); if (!torrentList) { return; } const sampleTorrent = "https://nyaa.si/download/222409.torrent"; const sampleMagnet = "magnet:?xt=urn:btih:db0ffe8174317b0b0ee4beb7b54f558bb9089746&dn=%5Beoy%5D%20dark%20dragoon%20-%2001.txt&tr=http%3A%2F%2Fnyaa.tracker.wf%3A7777%2Fannounce&tr=udp%3A%2F%2Fopen.stealth.si%3A80%2Fannounce&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337%2Fannounce&tr=udp%3A%2F%2Fexodus.desync.com%3A6969%2Fannounce&tr=udp%3A%2F%2Ftracker.torrent.eu.org%3A451%2Fannounce"; const html = ` 💽 Total size ${totalSize} `; torrentList.insertAdjacentHTML('beforeend', html); } /** * Handler for copying download links (torrents) */ function copyLinks(amount) { let links = getLinks(); if (amount && typeof amount === 'number') { amount = amount > links.length ? links.length : amount; links = links.slice(0, amount); } const list = links.join('\n'); GM_setClipboard(list); } /** * Handler for copying magnets */ function copyMagnets(amount) { let magnets = getMagnets(); if (amount && typeof amount === 'number') { amount = amount > magnets.length ? magnets.length : amount; magnets = magnets.slice(0, amount); } const list = magnets.join('\n'); GM_setClipboard(list); } /** * Changes the download links for torrents/magnets to trigger a copy, * instead of default handling (download for torrents, torrent *client* for magnets). */ function copyOnClick(ev) { ev.preventDefault(); const target = ev.target; const isAnchor = target.nodeName === 'A'; const parent = isAnchor ? target : target.parentElement; GM_setClipboard(parent.href + '\n'); const checkIcon = 'fa-check'; const originalIcon = parent.href.includes('/download/') ? 'fa-download' : 'fa-magnet'; const iconElement = !target.classList.contains('fa') ? target.querySelector('.fa') : target; console.log(iconElement); if (iconElement) { iconElement.classList.replace(originalIcon, checkIcon); setTimeout( function() { iconElement.classList.replace(checkIcon, originalIcon); }, 1500 ); } } /** * Changes said magnet/torrent links to use the `copyOnClick` handler. */ function changeToCopy() { const links = document.querySelectorAll('.fa-magnet, .fa-download'); for (const link of links) { link.parentElement.addEventListener('click', copyOnClick); } } /** * List of regex replacements so that I can copy titles as directory names, with my personally preferred naming scheme. */ const titleReplacements = [ { comment: 'Remove quoted series/movie title from the end', search: / \"[A-z0-9-!\s]+\"$/, replacement: '', }, // { // comment: 'Move release group to the end', // search: /^\[([-\w ]+)\] (.+)$/, // replacement: '$2 [$1]', // }, { comment: 'Colon space to space dash space', search: /: /g, replacement: ' - ', }, { comment: 'Ensure seasons have two numbers', search: / \(Season (\d)\)/g, replacement: ' (Season 0$1)', }, { comment: 'Season naming so Sonarr doesnt get confused because its dumb', search: / \(Season (\d+)\)/g, replacement: ' - S$1', }, { comment: 'No slashes, please', search: /\//g, replacement: '-', }, { comment: 'Closing => Opening bracket spacing between each "tag"', search: /\]\[/g, replacement: '] [', }, { comment: 'Final replacement: Replace 2+ spaces with one space', search: /\s{2,}/g, replacement: ' ', }, ]; /** * Uses `titleReplacements` to "fix" torrent name when "Copy torrent name" button is clicked. */ function fixTitle(input) { let finalString = input.trim(); for (const replace of titleReplacements) { const { comment, search, replacement } = replace; const match = finalString.match(search); if (match === null) { continue; } console.log(`\nFound: ${search}\nComment: ${comment}\nMatches:\n- ${match.join('\n- ')}`); finalString = finalString.replace(search, replacement || ''); } // Deal with any excess whitespace finalString = finalString.trim(); console.log(`Original input: ${input}\nFinal string: ${finalString}`); return finalString; } /** * Adds copy button for torrent titles on torrent pages * Implements `fixTitle()` */ function titleHandler() { const title = document.querySelector('h3.panel-title'); if (!title) { return; } const titleParent = title.parentElement; const buttonParent = document.createElement('div'); buttonParent.setAttribute('class', 'btn-group pull-right'); const button = document.createElement('button'); button.textContent = '📋'; button.setAttribute('class', 'btn btn-primary btn-sm'); button.addEventListener('click', function() { const origText = button.textContent; button.textContent = '✔'; button.setAttribute('disabled', '1'); const fixedTitle = fixTitle(title.textContent.trim()); GM_setClipboard(fixedTitle); setTimeout( () => { button.textContent = origText; button.removeAttribute('disabled'); }, 1500 ); }); titleParent.classList.add('clearfix'); buttonParent.insertAdjacentElement('afterbegin', button); titleParent.insertAdjacentElement('afterbegin', buttonParent); } /** * Used by `copyFilenameHandler()` to do the actual copying */ function copyFilenameTrigger(ev) { const { target } = ev; // Trust no one, not even yourself if (!target) { return; } // We clone it here because I want to remove the file size that's normally at the end // If I simply do a `remove()` on the usual element, it will actually remove it from the DOM. const parent = target.parentElement.cloneNode(true); const fileSize = parent.querySelector('.file-size'); if (fileSize) { fileSize.remove(); } const filename = parent.textContent.trim(); target.classList.remove('fa-file'); target.classList.add('fa-check'); GM_setClipboard(filename); setTimeout(() => { target.classList.remove('fa-check'); target.classList.add('fa-file'); }, 1500); } /** * Clicking the file icon in front of each file will allow you to copy the filename */ async function copyFilenameHandler() { const fileList = document.querySelector('.torrent-file-list'); if (!fileList) { return; } const fileIcons = fileList.querySelectorAll('.fa-file'); for (const icon of fileIcons) { icon.addEventListener('click', copyFilenameTrigger); } } function bbcodeConverter(html) { html = html.replace(/(.*?)<\/pre>/gmi, "[code]$2[/code]"); html = html.replace(/(.*?)<\/h[1-7]>/, "\n[h]$2[/h]\n"); //paragraph handling: //- if a paragraph opens on the same line as another one closes, insert an extra blank line //- opening tag becomes two line breaks //- closing tags are just removed // html += html.replace(/<\/p>

\n

]*>/\n\n/gi; // html += html.replace(/<\/p>//gi; html = html.replace(/<\/p>\n

/gi, "\n\n\n"); html = html.replace(//gi, "\n"); html = html.replace(/(.*?)<\/textarea>/gmi, "\[code]$2\[\/code]"); html = html.replace(//gi, "[b]"); html = html.replace(//gi, "[i]"); html = html.replace(//gi, "[u]"); html = html.replace(/<\/b>/gi, "[/b]"); html = html.replace(/<\/i>/gi, "[/i]"); html = html.replace(/<\/u>/gi, "[/u]"); html = html.replace(//gi, "[b]"); html = html.replace(/<\/em>/gi, "[/b]"); html = html.replace(//gi, "[b]"); html = html.replace(/<\/strong>/gi, "[/b]"); html = html.replace(//gi, "[i]"); html = html.replace(/<\/cite>/gi, "[/i]"); html = html.replace(/(.*?)<\/font>/gmi, "[color=$1]$2[/color]"); html = html.replace(/(.*?)<\/font>/gmi, "[color=$1]$2[/color]"); html = html.replace(//gi, ""); html = html.replace(/(.*?)<\/li>/gi, "[*]$2"); html = html.replace(//gi, "[list]"); html = html.replace(/<\/ul>/gi, "[/list]"); html = html.replace(/

/gi, "\n"); html = html.replace(/<\/div>/gi, "\n"); html = html.replace(//gi, " "); html = html.replace(//gi, "\n"); html = html.replace(//gi, "[img]$2[/img]"); html = html.replace(/(.*?)<\/a>/gi, "[url=$2]$4[/url]"); html = html.replace(/(.*?)<\/head>/gmi, ""); html = html.replace(/(.*?)<\/object>/gmi, ""); html = html.replace(/(.*?)<\/script>/gmi, ""); html = html.replace(/(.*?)<\/style>/gmi, ""); html = html.replace(/(.*?)<\/title>/gmi, ""); html = html.replace(/<!--(.*?)-->/gmi, "\n"); html = html.replace(/\/\//gi, "/"); html = html.replace(/http:\//gi, "http://"); html = html.replace(/<(?:[^>'"]*|(['"]).*?\1)*>/gmi, ""); html = html.replace(/\r\r/gi, ""); html = html.replace(/\[img]\//gi, "[img]"); html = html.replace(/\[url=\//gi, "[url="); html = html.replace(/(\S)\n/gi, "$1 "); return html; } function descriptionHandler() { const descriptionElem = document.querySelector('#torrent-description'); if (!descriptionElem) { return; } console.log(bbcodeConverter(descriptionElem.innerHTML)); const button = document.createElement('button'); button.textContent = '📋 Copy description as BBCode'; button.setAttribute('class', 'btn btn-primary'); button.addEventListener('click', function() { const origText = button.textContent; button.textContent = '✔ Copied!'; button.setAttribute('disabled', '1'); const descriptionBBcode = bbcodeConverter(descriptionElem.innerHTML).trim(); GM_setClipboard(descriptionBBcode); setTimeout( () => { button.textContent = origText; button.removeAttribute('disabled'); }, 1500 ); }); const panelHeading = document.createElement('div'); panelHeading.setAttribute('class', 'panel-heading'); panelHeading.insertAdjacentElement('beforeend', button); descriptionElem.insertAdjacentElement('beforebegin', panelHeading); } /** * Adds a clipboard icon (using Font Awesome 4) to each row that is marked for "mass copy". */ function addClipboardIconToRow() { const rows = document.querySelectorAll('.torrent-list > tbody > tr > td[colspan="2"]'); let currentAmount = 0; for (const row of rows) { currentAmount++; const iconExists = row.querySelector('a.clipboard-icon'); if (iconExists) { iconExists.remove(); } // Check if this row is past the point of "don't receive icon" if (currentAmount > copyAmount) { // `continue`, not `break` - If we break, then when we lower `copyAmount`, there will be extra rows with clipboard icon. continue; } row.insertAdjacentHTML('afterbegin', '<a href="#" class="comments clipboard-icon" style="margin-left: 0.5em;"><i class="fa fa-clipboard"></i></a>'); } } /** * Adds buttons and number input fields above torrent table, for "mass copying" links/magnets. */ function torrentListViewHandler() { const parentElement = document.querySelector('.table-responsive'); if (!parentElement) { return; } addClipboardIconToRow(); /** * Start: Copy all links/magnets */ const element = document.createElement('button'); element.innerHTML = '📋 Copy all download links <i class="fa fa-fw fa-download"></i>'; element.setAttribute('class', 'btn btn-default'); element.addEventListener('click', function() { const origText = element.innerHTML; element.innerHTML = '✔'; element.setAttribute('disabled', '1'); copyLinks(); setTimeout(() => { element.innerHTML = origText; element.removeAttribute('disabled'); }, 1500); }); const magnetCopy = document.createElement('button'); magnetCopy.innerHTML = '📋 Copy all magnets <i class="fa fa-fw fa-magnet"></i>'; magnetCopy.setAttribute('class', 'btn btn-default'); magnetCopy.addEventListener('click', function() { const origText = magnetCopy.innerHTML; magnetCopy.innerHTML = '✔'; magnetCopy.setAttribute('disabled', '1'); copyMagnets(); setTimeout(() => { magnetCopy.innerHTML = origText; magnetCopy.removeAttribute('disabled'); }, 1500); }); /** * End: Copy all links/magnets */ /** * Start: Copy X links/magnets */ const linkAmount = (getLinks()).length; copyAmount = linkAmount < 5 ? linkAmount : 5; const amountSelect = document.createElement('input'); amountSelect.setAttribute('type', 'number'); amountSelect.setAttribute('min', '2'); amountSelect.setAttribute('max', linkAmount); amountSelect.setAttribute('value', copyAmount); amountSelect.setAttribute('class', 'pull-right'); const amountCopyLinks = document.createElement('button'); amountCopyLinks.innerHTML = `📋 Copy ${copyAmount} download links <i class="fa fa-fw fa-download"></i>`; amountCopyLinks.setAttribute('class', 'btn btn-default pull-right'); amountCopyLinks.addEventListener('click', function() { const origText = amountCopyLinks.innerHTML; amountCopyLinks.innerHTML = '✔'; amountCopyLinks.setAttribute('disabled', '1'); copyLinks(copyAmount); setTimeout(() => { amountCopyLinks.innerHTML = origText; amountCopyLinks.removeAttribute('disabled'); }, 1500); }); const amountCopyMagnets = document.createElement('button'); amountCopyMagnets.innerHTML = `📋 Copy ${copyAmount} magnets <i class="fa fa-fw fa-magnet"></i>`; amountCopyMagnets.setAttribute('class', 'btn btn-default pull-right'); amountCopyMagnets.addEventListener('click', function() { const origText = amountCopyMagnets.innerHTML; amountCopyMagnets.innerHTML = '✔'; amountCopyMagnets.setAttribute('disabled', '1'); copyMagnets(copyAmount); setTimeout(() => { amountCopyMagnets.innerHTML = origText; amountCopyMagnets.removeAttribute('disabled'); }, 1500); }); amountSelect.addEventListener('change', function() { copyAmount = parseInt(amountSelect.value, 10); amountCopyLinks.innerHTML = `📋 Copy ${copyAmount} download links <i class="fa fa-fw fa-download"></i>`; amountCopyMagnets.innerHTML = `📋 Copy ${copyAmount} magnets <i class="fa fa-fw fa-magnet"></i>`; addClipboardIconToRow(); }); /** * End: Copy X links/magnets */ /** * Append all these buttons and inputs to the DOM. */ parentElement.insertAdjacentElement('beforebegin', element); parentElement.insertAdjacentElement('beforebegin', magnetCopy); parentElement.insertAdjacentElement('beforebegin', amountSelect); parentElement.insertAdjacentElement('beforebegin', amountCopyMagnets); parentElement.insertAdjacentElement('beforebegin', amountCopyLinks); } /** * This previously changed some `.container` classes to `.container-fluid`, * but since that breaks other userscripts, such as NyaaBlue from SeaDex, * I figured it'd be better to simply override the CSS width that .container usually applies. */ function widerContainers() { const styleDoc = document.createElement('style'); styleDoc.setAttribute('type', 'text/css'); styleDoc.textContent = ` .container { width: auto !important; } `; document.head.insertAdjacentElement('beforeend', styleDoc); } function init() { changeToCopy(); titleHandler(); descriptionHandler(); copyFilenameHandler(); torrentListViewHandler(); calculateTotalSize(); widerContainers(); } init();