Last active 1753547450

Miscelleanous tweaks applied to Nyaa.si

Revision 0c3eb880f1791f9b39606cd7bd06facd2dbd126b

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.4.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 html = `<tr class="default" id="all-torrents-size">
87 <td class="text-center">
88 <a href="#all-torrents-size" title="Total size">
89 💽
90 </a>
91 </td>
92 <td colspan="2">
93 <a href="#all-torrents-size">Total size</a>
94 </td>
95 <td class="text-center"><!-- Download --></td>
96 <td class="text-center">${totalSize}</td>
97 <td class="text-center"><!-- Timestamp --></td>
98 <td class="text-center"><!-- Seeders --></td>
99 <td class="text-center"><!-- Leechers --></td>
100 <td class="text-center"><!-- Completed --></td>
101 </tr>`;
102
103 torrentList.insertAdjacentHTML('beforeend', html);
104}
105
106/**
107 * Handler for copying download links (torrents)
108 */
109function copyLinks(amount)
110{
111 let links = getLinks();
112 if (amount && typeof amount === 'number') {
113 amount = amount > links.length ? links.length : amount;
114 links = links.slice(0, amount);
115 }
116
117 const list = links.join('\n');
118 GM_setClipboard(list);
119}
120
121/**
122 * Handler for copying magnets
123 */
124function copyMagnets(amount)
125{
126 let magnets = getMagnets();
127 if (amount && typeof amount === 'number') {
128 amount = amount > magnets.length ? magnets.length : amount;
129 magnets = magnets.slice(0, amount);
130 }
131
132 const list = magnets.join('\n');
133 GM_setClipboard(list);
134}
135
136/**
137 * Changes the download links for torrents/magnets to trigger a copy,
138 * instead of default handling (download for torrents, torrent *client* for magnets).
139 */
140function copyOnClick(ev)
141{
142 ev.preventDefault();
143
144 const target = ev.target;
145 const isAnchor = target.nodeName === 'A';
146 const parent = isAnchor ? target : target.parentElement;
147 GM_setClipboard(parent.href + '\n');
148
149 const checkIcon = 'fa-check';
150 const originalIcon = parent.href.includes('/download/') ? 'fa-download' : 'fa-magnet';
151
152 const iconElement = !target.classList.contains('fa') ? target.querySelector('.fa') : target;
153 console.log(iconElement);
154
155 if (iconElement) {
156 iconElement.classList.replace(originalIcon, checkIcon);
157
158 setTimeout(
159 function() {
160 iconElement.classList.replace(checkIcon, originalIcon);
161 },
162 1500
163 );
164 }
165}
166
167/**
168 * Changes said magnet/torrent links to use the `copyOnClick` handler.
169 */
170function changeToCopy()
171{
172 const links = document.querySelectorAll('.fa-magnet, .fa-download');
173
174 for (const link of links)
175 {
176 link.parentElement.addEventListener('click', copyOnClick);
177 }
178}
179
180/**
181 * List of regex replacements so that I can copy titles as directory names, with my personally preferred naming scheme.
182 */
183const titleReplacements = [
184 {
185 comment: 'Remove quoted series/movie title from the end',
186 search: / \"[A-z0-9-!\s]+\"$/,
187 replacement: '',
188 },
189 {
190 comment: 'Move release group to the end',
191 search: /^\[([-\w ]+)\] (.+)$/,
192 replacement: '$2 [$1]',
193 },
194 {
195 comment: 'Colon space to space dash space',
196 search: /: /g,
197 replacement: ' - ',
198 },
199 {
200 comment: 'Season naming so Sonarr doesnt get confused because its dumb',
201 search: / \(Season (\d+)\)/g,
202 replacement: ' - S$1',
203 },
204 {
205 comment: 'No slashes, please',
206 search: /\//g,
207 replacement: '-',
208 },
209 {
210 comment: 'Closing => Opening bracket spacing between each "tag"',
211 search: /\]\[/g,
212 replacement: '] [',
213 },
214 {
215 comment: 'Final replacement: Replace 2+ spaces with one space',
216 search: /\s{2,}/g,
217 replacement: ' ',
218 },
219];
220
221/**
222 * Uses `titleReplacements` to "fix" torrent name when "Copy torrent name" button is clicked.
223 */
224function fixTitle(input)
225{
226 let finalString = input.trim();
227 for (const replace of titleReplacements)
228 {
229 const { comment, search, replacement } = replace;
230
231 const match = finalString.match(search);
232 if (match === null) {
233 continue;
234 }
235
236 console.log(`\nFound: ${search}\nComment: ${comment}\nMatches:\n- ${match.join('\n- ')}`);
237 finalString = finalString.replace(search, replacement || '');
238 }
239
240 // Deal with any excess whitespace
241 finalString = finalString.trim();
242 console.log(`Original input: ${input}\nFinal string: ${finalString}`);
243
244 return finalString;
245}
246
247/**
248 * Adds copy button for torrent titles on torrent pages
249 * Implements `fixTitle()`
250 */
251function titleHandler()
252{
253 const title = document.querySelector('h3.panel-title');
254
255 if (!title) {
256 return;
257 }
258
259 const titleParent = title.parentElement;
260
261 const buttonParent = document.createElement('div');
262 buttonParent.setAttribute('class', 'btn-group pull-right');
263
264 const button = document.createElement('button');
265 button.textContent = '📋';
266 button.setAttribute('class', 'btn btn-primary btn-sm');
267 button.addEventListener('click', function() {
268 const origText = button.textContent;
269
270 button.textContent = '✔';
271 button.setAttribute('disabled', '1');
272
273 const fixedTitle = fixTitle(title.textContent.trim());
274 GM_setClipboard(fixedTitle);
275
276 setTimeout(
277 () => {
278 button.textContent = origText;
279 button.removeAttribute('disabled');
280 }, 1500
281 );
282 });
283
284 titleParent.classList.add('clearfix');
285 buttonParent.insertAdjacentElement('afterbegin', button);
286 titleParent.insertAdjacentElement('afterbegin', buttonParent);
287}
288
289function bbcodeConverter(html) {
290 html = html.replace(/<pre(.*?)>(.*?)<\/pre>/gmi, "[code]$2[/code]");
291 html = html.replace(/<h[1-7](.*?)>(.*?)<\/h[1-7]>/, "\n[h]$2[/h]\n");
292 //paragraph handling:
293 //- if a paragraph opens on the same line as another one closes, insert an extra blank line
294 //- opening tag becomes two line breaks
295 //- closing tags are just removed
296 // html += html.replace(/<\/p><p/<\/p>\n<p/gi;
297 // html += html.replace(/<p[^>]*>/\n\n/gi;
298 // html += html.replace(/<\/p>//gi;
299
300 html = html.replace(/<\/p>\n<p>/gi, "\n\n\n");
301
302 html = html.replace(/<br(.*?)>/gi, "\n");
303 html = html.replace(/<textarea(.*?)>(.*?)<\/textarea>/gmi, "\[code]$2\[\/code]");
304 html = html.replace(/<b>/gi, "[b]");
305 html = html.replace(/<i>/gi, "[i]");
306 html = html.replace(/<u>/gi, "[u]");
307 html = html.replace(/<\/b>/gi, "[/b]");
308 html = html.replace(/<\/i>/gi, "[/i]");
309 html = html.replace(/<\/u>/gi, "[/u]");
310 html = html.replace(/<em>/gi, "[b]");
311 html = html.replace(/<\/em>/gi, "[/b]");
312 html = html.replace(/<strong>/gi, "[b]");
313 html = html.replace(/<\/strong>/gi, "[/b]");
314 html = html.replace(/<cite>/gi, "[i]");
315 html = html.replace(/<\/cite>/gi, "[/i]");
316 html = html.replace(/<font color="(.*?)">(.*?)<\/font>/gmi, "[color=$1]$2[/color]");
317 html = html.replace(/<font color=(.*?)>(.*?)<\/font>/gmi, "[color=$1]$2[/color]");
318 html = html.replace(/<link(.*?)>/gi, "");
319 html = html.replace(/<li(.*?)>(.*?)<\/li>/gi, "[*]$2");
320 html = html.replace(/<ul(.*?)>/gi, "[list]");
321 html = html.replace(/<\/ul>/gi, "[/list]");
322 html = html.replace(/<div>/gi, "\n");
323 html = html.replace(/<\/div>/gi, "\n");
324 html = html.replace(/<td(.*?)>/gi, " ");
325 html = html.replace(/<tr(.*?)>/gi, "\n");
326
327 html = html.replace(/<img(.*?)src="(.*?)"(.*?)>/gi, "[img]$2[/img]");
328 html = html.replace(/<a(.*?)href="(.*?)"(.*?)>(.*?)<\/a>/gi, "[url=$2]$4[/url]");
329
330 html = html.replace(/<head>(.*?)<\/head>/gmi, "");
331 html = html.replace(/<object>(.*?)<\/object>/gmi, "");
332 html = html.replace(/<script(.*?)>(.*?)<\/script>/gmi, "");
333 html = html.replace(/<style(.*?)>(.*?)<\/style>/gmi, "");
334 html = html.replace(/<title>(.*?)<\/title>/gmi, "");
335 html = html.replace(/<!--(.*?)-->/gmi, "\n");
336
337 html = html.replace(/\/\//gi, "/");
338 html = html.replace(/http:\//gi, "http://");
339
340 html = html.replace(/<(?:[^>'"]*|(['"]).*?\1)*>/gmi, "");
341 html = html.replace(/\r\r/gi, "");
342 html = html.replace(/\[img]\//gi, "[img]");
343 html = html.replace(/\[url=\//gi, "[url=");
344
345 html = html.replace(/(\S)\n/gi, "$1 ");
346
347 return html;
348}
349
350function descriptionHandler()
351{
352 const descriptionElem = document.querySelector('#torrent-description');
353 if (!descriptionElem) {
354 return;
355 }
356
357 console.log(bbcodeConverter(descriptionElem.innerHTML));
358
359 const button = document.createElement('button');
360 button.textContent = '📋 Copy description as BBCode';
361 button.setAttribute('class', 'btn btn-primary');
362 button.addEventListener('click', function() {
363 const origText = button.textContent;
364
365 button.textContent = '✔ Copied!';
366 button.setAttribute('disabled', '1');
367
368 const descriptionBBcode = bbcodeConverter(descriptionElem.innerHTML).trim();
369 GM_setClipboard(descriptionBBcode);
370
371 setTimeout(
372 () => {
373 button.textContent = origText;
374 button.removeAttribute('disabled');
375 }, 1500
376 );
377 });
378
379 const panelHeading = document.createElement('div');
380 panelHeading.setAttribute('class', 'panel-heading');
381 panelHeading.insertAdjacentElement('beforeend', button);
382
383 descriptionElem.insertAdjacentElement('beforebegin', panelHeading);
384}
385
386/**
387 * Adds a clipboard icon (using Font Awesome 4) to each row that is marked for "mass copy".
388 */
389function addClipboardIconToRow()
390{
391 const rows = document.querySelectorAll('.torrent-list > tbody > tr > td[colspan="2"]');
392
393 let currentAmount = 0;
394
395 for (const row of rows)
396 {
397 currentAmount++;
398 const iconExists = row.querySelector('a.clipboard-icon');
399 if (iconExists) {
400 iconExists.remove();
401 }
402
403 // Check if this row is past the point of "don't receive icon"
404 if (currentAmount > copyAmount) {
405 // `continue`, not `break` - If we break, then when we lower `copyAmount`, there will be extra rows with clipboard icon.
406 continue;
407 }
408
409 row.insertAdjacentHTML('afterbegin', '<a href="#" class="comments clipboard-icon" style="margin-left: 0.5em;"><i class="fa fa-clipboard"></i></a>');
410 }
411}
412
413/**
414 * Adds buttons and number input fields above torrent table, for "mass copying" links/magnets.
415 */
416function torrentListViewHandler()
417{
418 const parentElement = document.querySelector('.table-responsive');
419
420 if (!parentElement) {
421 return;
422 }
423
424 addClipboardIconToRow();
425
426 /**
427 * Start: Copy all links/magnets
428 */
429 const element = document.createElement('button');
430 element.innerHTML = '📋 Copy all download links <i class="fa fa-fw fa-download"></i>';
431 element.setAttribute('class', 'btn btn-default');
432 element.addEventListener('click', function() {
433 const origText = element.innerHTML;
434
435 element.innerHTML = '✔';
436 element.setAttribute('disabled', '1');
437 copyLinks();
438
439 setTimeout(() => {
440 element.innerHTML = origText;
441 element.removeAttribute('disabled');
442 }, 1500);
443 });
444
445 const magnetCopy = document.createElement('button');
446 magnetCopy.innerHTML = '📋 Copy all magnets <i class="fa fa-fw fa-magnet"></i>';
447 magnetCopy.setAttribute('class', 'btn btn-default');
448 magnetCopy.addEventListener('click', function() {
449 const origText = magnetCopy.innerHTML;
450
451 magnetCopy.innerHTML = '✔';
452 magnetCopy.setAttribute('disabled', '1');
453 copyMagnets();
454
455 setTimeout(() => {
456 magnetCopy.innerHTML = origText;
457 magnetCopy.removeAttribute('disabled');
458 }, 1500);
459 });
460
461 /**
462 * End: Copy all links/magnets
463 */
464
465 /**
466 * Start: Copy X links/magnets
467 */
468 const linkAmount = (getLinks()).length;
469 copyAmount = linkAmount < 5 ? linkAmount : 5;
470 const amountSelect = document.createElement('input');
471 amountSelect.setAttribute('type', 'number');
472 amountSelect.setAttribute('min', '2');
473 amountSelect.setAttribute('max', linkAmount);
474 amountSelect.setAttribute('value', copyAmount);
475 amountSelect.setAttribute('class', 'pull-right');
476
477 const amountCopyLinks = document.createElement('button');
478 amountCopyLinks.innerHTML = `📋 Copy ${copyAmount} download links <i class="fa fa-fw fa-download"></i>`;
479 amountCopyLinks.setAttribute('class', 'btn btn-default pull-right');
480 amountCopyLinks.addEventListener('click', function() {
481 const origText = amountCopyLinks.innerHTML;
482
483 amountCopyLinks.innerHTML = '✔';
484 amountCopyLinks.setAttribute('disabled', '1');
485 copyLinks(copyAmount);
486
487 setTimeout(() => {
488 amountCopyLinks.innerHTML = origText;
489 amountCopyLinks.removeAttribute('disabled');
490 }, 1500);
491 });
492
493 const amountCopyMagnets = document.createElement('button');
494 amountCopyMagnets.innerHTML = `📋 Copy ${copyAmount} magnets <i class="fa fa-fw fa-magnet"></i>`;
495 amountCopyMagnets.setAttribute('class', 'btn btn-default pull-right');
496 amountCopyMagnets.addEventListener('click', function() {
497 const origText = amountCopyMagnets.innerHTML;
498
499 amountCopyMagnets.innerHTML = '✔';
500 amountCopyMagnets.setAttribute('disabled', '1');
501 copyMagnets(copyAmount);
502
503 setTimeout(() => {
504 amountCopyMagnets.innerHTML = origText;
505 amountCopyMagnets.removeAttribute('disabled');
506 }, 1500);
507 });
508
509 amountSelect.addEventListener('change', function() {
510 copyAmount = parseInt(amountSelect.value, 10);
511 amountCopyLinks.innerHTML = `📋 Copy ${copyAmount} download links <i class="fa fa-fw fa-download"></i>`;
512 amountCopyMagnets.innerHTML = `📋 Copy ${copyAmount} magnets <i class="fa fa-fw fa-magnet"></i>`;
513
514 addClipboardIconToRow();
515 });
516 /**
517 * End: Copy X links/magnets
518 */
519
520 /**
521 * Append all these buttons and inputs to the DOM.
522 */
523 parentElement.insertAdjacentElement('beforebegin', element);
524 parentElement.insertAdjacentElement('beforebegin', magnetCopy);
525
526 parentElement.insertAdjacentElement('beforebegin', amountSelect);
527 parentElement.insertAdjacentElement('beforebegin', amountCopyMagnets);
528 parentElement.insertAdjacentElement('beforebegin', amountCopyLinks);
529}
530
531function changeContainers()
532{
533 const selectors = ['.navbar-static-top > .container', 'body > .container'];
534 for (const selector of selectors)
535 {
536 const element = document.querySelector(selector);
537 if (!element) {
538 console.error('Could not find any element with selector', selector);
539 continue;
540 }
541
542 element.classList.remove('container');
543 element.classList.add('container-fluid');
544 console.log('Element updated with container-fluid', selector, element);
545 }
546}
547
548function init() {
549 changeToCopy();
550 titleHandler();
551 descriptionHandler();
552 torrentListViewHandler();
553 calculateTotalSize();
554 changeContainers();
555}
556
557init();