Last active 1753547450

Miscelleanous tweaks applied to Nyaa.si

Revision 5c617beb80be24e60e92f457a9189323d1c9299b

nyaa-shit.user.js Raw
1// ==UserScript==
2// @name Nyaa - Personal Tweaks
3// @namespace github.com/Decicus
4// @match https://nyaa.si/*
5// @grant GM_setClipboard
6// @version 1.5.0
7// @author Decicus
8// @description Adds some extra functionality to Nyaa.
9// ==/UserScript==
10
11let copyAmount = 5;
12const multipliers = {
13 Bytes: 1,
14 KiB: 1024,
15 MiB: 1024 * 1024,
16 GiB: 1024 * 1024 * 1024,
17 TiB: 1024 * 1024 * 1024 * 1024,
18};
19
20const sizesRegex = /(Bytes|KiB|MiB|GiB|TiB)/g;
21
22function getElements(linkType = 'download')
23{
24 const elements = document.querySelectorAll(`.fa-${linkType}`);
25 return Array.from(elements)
26 .map(x => x.parentElement.href)
27 .filter(x => x && x.trim() !== '');
28}
29
30function getLinks()
31{
32 return getElements('download');
33}
34
35function getMagnets()
36{
37 return getElements('magnet');
38}
39
40/**
41 * 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
42 */
43function calculateTotalSize()
44{
45 const timestamps = document.querySelectorAll('.torrent-list td[data-timestamp]');
46 let totalBytes = 0;
47 for (const timestamp of timestamps)
48 {
49 const sizeCell = timestamp.previousElementSibling;
50 const size = sizeCell.textContent.trim();
51 let suffix = size.match(sizesRegex);
52 if (!suffix || suffix.length < 1) {
53 console.error('Could not find size in element', sizeCell);
54 continue;
55 }
56
57 suffix = suffix[0];
58 const multiplier = multipliers[suffix];
59 const sizeNumber = parseFloat(size.replace(` ${suffix}`, '').trim());
60 const torrentBytes = sizeNumber * multiplier;
61
62 totalBytes += torrentBytes;
63 }
64
65 const multiplierKeys = Object.keys(multipliers).reverse();
66 let totalSize = `${totalBytes} Bytes`;
67 for (const key of multiplierKeys)
68 {
69 if (totalBytes === 0) {
70 break;
71 }
72
73 const divideBy = multipliers[key];
74 const result = totalBytes / divideBy;
75 if (result > 1) {
76 totalSize = `${result.toFixed(2)} ${key}`;
77 break;
78 }
79 }
80
81 const torrentList = document.querySelector('.torrent-list tbody');
82 if (!torrentList) {
83 return;
84 }
85
86 const sampleTorrent = "https://nyaa.si/download/222409.torrent";
87 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";
88
89 const html = `<tr class="default" id="all-torrents-size">
90 <td class="text-center">
91 <a href="#all-torrents-size" title="Total size">
92 💽
93 </a>
94 </td>
95 <td colspan="2">
96 <a href="#all-torrents-size">Total size</a>
97 </td>
98 <td class="text-center">
99 <!-- These elements purely exist to avoid any issues with extensions or userscripts that would normally expect valid torrent/magnet URIs -->
100 <!-- In my case I only found an issue with NyaaBlue from SeaDex -->
101 <a href="${sampleTorrent}" style="display: none;"><!-- Download torrent --></a>
102 <a href="${sampleMagnet}" style="display: none;"><!-- Magnet URI --></a>
103 </td>
104 <td class="text-center">${totalSize}</td>
105 <td class="text-center"><!-- Timestamp --></td>
106 <td class="text-center"><!-- Seeders --></td>
107 <td class="text-center"><!-- Leechers --></td>
108 <td class="text-center"><!-- Completed --></td>
109 </tr>`;
110
111 torrentList.insertAdjacentHTML('beforeend', html);
112}
113
114/**
115 * Handler for copying download links (torrents)
116 */
117function copyLinks(amount)
118{
119 let links = getLinks();
120 if (amount && typeof amount === 'number') {
121 amount = amount > links.length ? links.length : amount;
122 links = links.slice(0, amount);
123 }
124
125 const list = links.join('\n');
126 GM_setClipboard(list);
127}
128
129/**
130 * Handler for copying magnets
131 */
132function copyMagnets(amount)
133{
134 let magnets = getMagnets();
135 if (amount && typeof amount === 'number') {
136 amount = amount > magnets.length ? magnets.length : amount;
137 magnets = magnets.slice(0, amount);
138 }
139
140 const list = magnets.join('\n');
141 GM_setClipboard(list);
142}
143
144/**
145 * Changes the download links for torrents/magnets to trigger a copy,
146 * instead of default handling (download for torrents, torrent *client* for magnets).
147 */
148function copyOnClick(ev)
149{
150 ev.preventDefault();
151
152 const target = ev.target;
153 const isAnchor = target.nodeName === 'A';
154 const parent = isAnchor ? target : target.parentElement;
155 GM_setClipboard(parent.href + '\n');
156
157 const checkIcon = 'fa-check';
158 const originalIcon = parent.href.includes('/download/') ? 'fa-download' : 'fa-magnet';
159
160 const iconElement = !target.classList.contains('fa') ? target.querySelector('.fa') : target;
161 console.log(iconElement);
162
163 if (iconElement) {
164 iconElement.classList.replace(originalIcon, checkIcon);
165
166 setTimeout(
167 function() {
168 iconElement.classList.replace(checkIcon, originalIcon);
169 },
170 1500
171 );
172 }
173}
174
175/**
176 * Changes said magnet/torrent links to use the `copyOnClick` handler.
177 */
178function changeToCopy()
179{
180 const links = document.querySelectorAll('.fa-magnet, .fa-download');
181
182 for (const link of links)
183 {
184 link.parentElement.addEventListener('click', copyOnClick);
185 }
186}
187
188/**
189 * List of regex replacements so that I can copy titles as directory names, with my personally preferred naming scheme.
190 */
191const titleReplacements = [
192 {
193 comment: 'Remove quoted series/movie title from the end',
194 search: / \"[A-z0-9-!\s]+\"$/,
195 replacement: '',
196 },
197 // {
198 // comment: 'Move release group to the end',
199 // search: /^\[([-\w ]+)\] (.+)$/,
200 // replacement: '$2 [$1]',
201 // },
202 {
203 comment: 'Colon space to space dash space',
204 search: /: /g,
205 replacement: ' - ',
206 },
207 {
208 comment: 'Ensure seasons have two numbers',
209 search: / \(Season (\d)\)/g,
210 replacement: ' (Season 0$1)',
211 },
212 {
213 comment: 'Season naming so Sonarr doesnt get confused because its dumb',
214 search: / \(Season (\d+)\)/g,
215 replacement: ' - S$1',
216 },
217 {
218 comment: 'No slashes, please',
219 search: /\//g,
220 replacement: '-',
221 },
222 {
223 comment: 'Closing => Opening bracket spacing between each "tag"',
224 search: /\]\[/g,
225 replacement: '] [',
226 },
227 {
228 comment: 'Final replacement: Replace 2+ spaces with one space',
229 search: /\s{2,}/g,
230 replacement: ' ',
231 },
232];
233
234/**
235 * Uses `titleReplacements` to "fix" torrent name when "Copy torrent name" button is clicked.
236 */
237function fixTitle(input)
238{
239 let finalString = input.trim();
240 for (const replace of titleReplacements)
241 {
242 const { comment, search, replacement } = replace;
243
244 const match = finalString.match(search);
245 if (match === null) {
246 continue;
247 }
248
249 console.log(`\nFound: ${search}\nComment: ${comment}\nMatches:\n- ${match.join('\n- ')}`);
250 finalString = finalString.replace(search, replacement || '');
251 }
252
253 // Deal with any excess whitespace
254 finalString = finalString.trim();
255 console.log(`Original input: ${input}\nFinal string: ${finalString}`);
256
257 return finalString;
258}
259
260/**
261 * Adds copy button for torrent titles on torrent pages
262 * Implements `fixTitle()`
263 */
264function titleHandler()
265{
266 const title = document.querySelector('h3.panel-title');
267
268 if (!title) {
269 return;
270 }
271
272 const titleParent = title.parentElement;
273
274 const buttonParent = document.createElement('div');
275 buttonParent.setAttribute('class', 'btn-group pull-right');
276
277 const button = document.createElement('button');
278 button.textContent = '📋';
279 button.setAttribute('class', 'btn btn-primary btn-sm');
280 button.addEventListener('click', function() {
281 const origText = button.textContent;
282
283 button.textContent = '✔';
284 button.setAttribute('disabled', '1');
285
286 const fixedTitle = fixTitle(title.textContent.trim());
287 GM_setClipboard(fixedTitle);
288
289 setTimeout(
290 () => {
291 button.textContent = origText;
292 button.removeAttribute('disabled');
293 }, 1500
294 );
295 });
296
297 titleParent.classList.add('clearfix');
298 buttonParent.insertAdjacentElement('afterbegin', button);
299 titleParent.insertAdjacentElement('afterbegin', buttonParent);
300}
301
302/**
303 * Used by `copyFilenameHandler()` to do the actual copying
304 */
305function copyFilenameTrigger(ev)
306{
307 const { target } = ev;
308
309 // Trust no one, not even yourself
310 if (!target) {
311 return;
312 }
313
314 // We clone it here because I want to remove the file size that's normally at the end
315 // If I simply do a `remove()` on the usual element, it will actually remove it from the DOM.
316 const parent = target.parentElement.cloneNode(true);
317 const fileSize = parent.querySelector('.file-size');
318
319 if (fileSize) {
320 fileSize.remove();
321 }
322
323 const filename = parent.textContent.trim();
324
325 target.classList.remove('fa-file');
326 target.classList.add('fa-check');
327
328 GM_setClipboard(filename);
329
330 setTimeout(() => {
331 target.classList.remove('fa-check');
332 target.classList.add('fa-file');
333 }, 1500);
334}
335
336/**
337 * Clicking the file icon in front of each file will allow you to copy the filename
338 */
339async function copyFilenameHandler()
340{
341 const fileList = document.querySelector('.torrent-file-list');
342 if (!fileList) {
343 return;
344 }
345
346 const fileIcons = fileList.querySelectorAll('.fa-file');
347
348 for (const icon of fileIcons)
349 {
350 icon.addEventListener('click', copyFilenameTrigger);
351 }
352}
353
354function bbcodeConverter(html) {
355 html = html.replace(/<pre(.*?)>(.*?)<\/pre>/gmi, "[code]$2[/code]");
356 html = html.replace(/<h[1-7](.*?)>(.*?)<\/h[1-7]>/, "\n[h]$2[/h]\n");
357 //paragraph handling:
358 //- if a paragraph opens on the same line as another one closes, insert an extra blank line
359 //- opening tag becomes two line breaks
360 //- closing tags are just removed
361 // html += html.replace(/<\/p><p/<\/p>\n<p/gi;
362 // html += html.replace(/<p[^>]*>/\n\n/gi;
363 // html += html.replace(/<\/p>//gi;
364
365 html = html.replace(/<\/p>\n<p>/gi, "\n\n\n");
366
367 html = html.replace(/<br(.*?)>/gi, "\n");
368 html = html.replace(/<textarea(.*?)>(.*?)<\/textarea>/gmi, "\[code]$2\[\/code]");
369 html = html.replace(/<b>/gi, "[b]");
370 html = html.replace(/<i>/gi, "[i]");
371 html = html.replace(/<u>/gi, "[u]");
372 html = html.replace(/<\/b>/gi, "[/b]");
373 html = html.replace(/<\/i>/gi, "[/i]");
374 html = html.replace(/<\/u>/gi, "[/u]");
375 html = html.replace(/<em>/gi, "[b]");
376 html = html.replace(/<\/em>/gi, "[/b]");
377 html = html.replace(/<strong>/gi, "[b]");
378 html = html.replace(/<\/strong>/gi, "[/b]");
379 html = html.replace(/<cite>/gi, "[i]");
380 html = html.replace(/<\/cite>/gi, "[/i]");
381 html = html.replace(/<font color="(.*?)">(.*?)<\/font>/gmi, "[color=$1]$2[/color]");
382 html = html.replace(/<font color=(.*?)>(.*?)<\/font>/gmi, "[color=$1]$2[/color]");
383 html = html.replace(/<link(.*?)>/gi, "");
384 html = html.replace(/<li(.*?)>(.*?)<\/li>/gi, "[*]$2");
385 html = html.replace(/<ul(.*?)>/gi, "[list]");
386 html = html.replace(/<\/ul>/gi, "[/list]");
387 html = html.replace(/<div>/gi, "\n");
388 html = html.replace(/<\/div>/gi, "\n");
389 html = html.replace(/<td(.*?)>/gi, " ");
390 html = html.replace(/<tr(.*?)>/gi, "\n");
391
392 html = html.replace(/<img(.*?)src="(.*?)"(.*?)>/gi, "[img]$2[/img]");
393 html = html.replace(/<a(.*?)href="(.*?)"(.*?)>(.*?)<\/a>/gi, "[url=$2]$4[/url]");
394
395 html = html.replace(/<head>(.*?)<\/head>/gmi, "");
396 html = html.replace(/<object>(.*?)<\/object>/gmi, "");
397 html = html.replace(/<script(.*?)>(.*?)<\/script>/gmi, "");
398 html = html.replace(/<style(.*?)>(.*?)<\/style>/gmi, "");
399 html = html.replace(/<title>(.*?)<\/title>/gmi, "");
400 html = html.replace(/<!--(.*?)-->/gmi, "\n");
401
402 html = html.replace(/\/\//gi, "/");
403 html = html.replace(/http:\//gi, "http://");
404
405 html = html.replace(/<(?:[^>'"]*|(['"]).*?\1)*>/gmi, "");
406 html = html.replace(/\r\r/gi, "");
407 html = html.replace(/\[img]\//gi, "[img]");
408 html = html.replace(/\[url=\//gi, "[url=");
409
410 html = html.replace(/(\S)\n/gi, "$1 ");
411
412 return html;
413}
414
415function descriptionHandler()
416{
417 const descriptionElem = document.querySelector('#torrent-description');
418 if (!descriptionElem) {
419 return;
420 }
421
422 console.log(bbcodeConverter(descriptionElem.innerHTML));
423
424 const button = document.createElement('button');
425 button.textContent = '📋 Copy description as BBCode';
426 button.setAttribute('class', 'btn btn-primary');
427 button.addEventListener('click', function() {
428 const origText = button.textContent;
429
430 button.textContent = '✔ Copied!';
431 button.setAttribute('disabled', '1');
432
433 const descriptionBBcode = bbcodeConverter(descriptionElem.innerHTML).trim();
434 GM_setClipboard(descriptionBBcode);
435
436 setTimeout(
437 () => {
438 button.textContent = origText;
439 button.removeAttribute('disabled');
440 }, 1500
441 );
442 });
443
444 const panelHeading = document.createElement('div');
445 panelHeading.setAttribute('class', 'panel-heading');
446 panelHeading.insertAdjacentElement('beforeend', button);
447
448 descriptionElem.insertAdjacentElement('beforebegin', panelHeading);
449}
450
451/**
452 * Adds a clipboard icon (using Font Awesome 4) to each row that is marked for "mass copy".
453 */
454function addClipboardIconToRow()
455{
456 const rows = document.querySelectorAll('.torrent-list > tbody > tr > td[colspan="2"]');
457
458 let currentAmount = 0;
459
460 for (const row of rows)
461 {
462 currentAmount++;
463 const iconExists = row.querySelector('a.clipboard-icon');
464 if (iconExists) {
465 iconExists.remove();
466 }
467
468 // Check if this row is past the point of "don't receive icon"
469 if (currentAmount > copyAmount) {
470 // `continue`, not `break` - If we break, then when we lower `copyAmount`, there will be extra rows with clipboard icon.
471 continue;
472 }
473
474 row.insertAdjacentHTML('afterbegin', '<a href="#" class="comments clipboard-icon" style="margin-left: 0.5em;"><i class="fa fa-clipboard"></i></a>');
475 }
476}
477
478/**
479 * Adds buttons and number input fields above torrent table, for "mass copying" links/magnets.
480 */
481function torrentListViewHandler()
482{
483 const parentElement = document.querySelector('.table-responsive');
484
485 if (!parentElement) {
486 return;
487 }
488
489 addClipboardIconToRow();
490
491 /**
492 * Start: Copy all links/magnets
493 */
494 const element = document.createElement('button');
495 element.innerHTML = '📋 Copy all download links <i class="fa fa-fw fa-download"></i>';
496 element.setAttribute('class', 'btn btn-default');
497 element.addEventListener('click', function() {
498 const origText = element.innerHTML;
499
500 element.innerHTML = '✔';
501 element.setAttribute('disabled', '1');
502 copyLinks();
503
504 setTimeout(() => {
505 element.innerHTML = origText;
506 element.removeAttribute('disabled');
507 }, 1500);
508 });
509
510 const magnetCopy = document.createElement('button');
511 magnetCopy.innerHTML = '📋 Copy all magnets <i class="fa fa-fw fa-magnet"></i>';
512 magnetCopy.setAttribute('class', 'btn btn-default');
513 magnetCopy.addEventListener('click', function() {
514 const origText = magnetCopy.innerHTML;
515
516 magnetCopy.innerHTML = '✔';
517 magnetCopy.setAttribute('disabled', '1');
518 copyMagnets();
519
520 setTimeout(() => {
521 magnetCopy.innerHTML = origText;
522 magnetCopy.removeAttribute('disabled');
523 }, 1500);
524 });
525
526 /**
527 * End: Copy all links/magnets
528 */
529
530 /**
531 * Start: Copy X links/magnets
532 */
533 const linkAmount = (getLinks()).length;
534 copyAmount = linkAmount < 5 ? linkAmount : 5;
535 const amountSelect = document.createElement('input');
536 amountSelect.setAttribute('type', 'number');
537 amountSelect.setAttribute('min', '2');
538 amountSelect.setAttribute('max', linkAmount);
539 amountSelect.setAttribute('value', copyAmount);
540 amountSelect.setAttribute('class', 'pull-right');
541
542 const amountCopyLinks = document.createElement('button');
543 amountCopyLinks.innerHTML = `📋 Copy ${copyAmount} download links <i class="fa fa-fw fa-download"></i>`;
544 amountCopyLinks.setAttribute('class', 'btn btn-default pull-right');
545 amountCopyLinks.addEventListener('click', function() {
546 const origText = amountCopyLinks.innerHTML;
547
548 amountCopyLinks.innerHTML = '✔';
549 amountCopyLinks.setAttribute('disabled', '1');
550 copyLinks(copyAmount);
551
552 setTimeout(() => {
553 amountCopyLinks.innerHTML = origText;
554 amountCopyLinks.removeAttribute('disabled');
555 }, 1500);
556 });
557
558 const amountCopyMagnets = document.createElement('button');
559 amountCopyMagnets.innerHTML = `📋 Copy ${copyAmount} magnets <i class="fa fa-fw fa-magnet"></i>`;
560 amountCopyMagnets.setAttribute('class', 'btn btn-default pull-right');
561 amountCopyMagnets.addEventListener('click', function() {
562 const origText = amountCopyMagnets.innerHTML;
563
564 amountCopyMagnets.innerHTML = '✔';
565 amountCopyMagnets.setAttribute('disabled', '1');
566 copyMagnets(copyAmount);
567
568 setTimeout(() => {
569 amountCopyMagnets.innerHTML = origText;
570 amountCopyMagnets.removeAttribute('disabled');
571 }, 1500);
572 });
573
574 amountSelect.addEventListener('change', function() {
575 copyAmount = parseInt(amountSelect.value, 10);
576 amountCopyLinks.innerHTML = `📋 Copy ${copyAmount} download links <i class="fa fa-fw fa-download"></i>`;
577 amountCopyMagnets.innerHTML = `📋 Copy ${copyAmount} magnets <i class="fa fa-fw fa-magnet"></i>`;
578
579 addClipboardIconToRow();
580 });
581 /**
582 * End: Copy X links/magnets
583 */
584
585 /**
586 * Append all these buttons and inputs to the DOM.
587 */
588 parentElement.insertAdjacentElement('beforebegin', element);
589 parentElement.insertAdjacentElement('beforebegin', magnetCopy);
590
591 parentElement.insertAdjacentElement('beforebegin', amountSelect);
592 parentElement.insertAdjacentElement('beforebegin', amountCopyMagnets);
593 parentElement.insertAdjacentElement('beforebegin', amountCopyLinks);
594}
595
596/**
597 * This previously changed some `.container` classes to `.container-fluid`,
598 * but since that breaks other userscripts, such as NyaaBlue from SeaDex,
599 * I figured it'd be better to simply override the CSS width that .container usually applies.
600 */
601function widerContainers()
602{
603 const styleDoc = document.createElement('style');
604 styleDoc.setAttribute('type', 'text/css');
605
606 styleDoc.textContent = `
607 .container {
608 width: auto !important;
609 }
610 `;
611
612 document.head.insertAdjacentElement('beforeend', styleDoc);
613}
614
615function init() {
616 changeToCopy();
617 titleHandler();
618 descriptionHandler();
619 copyFilenameHandler();
620 torrentListViewHandler();
621 calculateTotalSize();
622 widerContainers();
623}
624
625init();