Last active 1753547450

Miscelleanous tweaks applied to Nyaa.si

Revision 0a19aac4b8b3d91ddcc53b4ce87b7528887528d1

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.1
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: 'Season naming so Sonarr doesnt get confused because its dumb',
209 search: / \(Season (\d+)\)/g,
210 replacement: ' - S$1',
211 },
212 {
213 comment: 'No slashes, please',
214 search: /\//g,
215 replacement: '-',
216 },
217 {
218 comment: 'Closing => Opening bracket spacing between each "tag"',
219 search: /\]\[/g,
220 replacement: '] [',
221 },
222 {
223 comment: 'Final replacement: Replace 2+ spaces with one space',
224 search: /\s{2,}/g,
225 replacement: ' ',
226 },
227];
228
229/**
230 * Uses `titleReplacements` to "fix" torrent name when "Copy torrent name" button is clicked.
231 */
232function fixTitle(input)
233{
234 let finalString = input.trim();
235 for (const replace of titleReplacements)
236 {
237 const { comment, search, replacement } = replace;
238
239 const match = finalString.match(search);
240 if (match === null) {
241 continue;
242 }
243
244 console.log(`\nFound: ${search}\nComment: ${comment}\nMatches:\n- ${match.join('\n- ')}`);
245 finalString = finalString.replace(search, replacement || '');
246 }
247
248 // Deal with any excess whitespace
249 finalString = finalString.trim();
250 console.log(`Original input: ${input}\nFinal string: ${finalString}`);
251
252 return finalString;
253}
254
255/**
256 * Adds copy button for torrent titles on torrent pages
257 * Implements `fixTitle()`
258 */
259function titleHandler()
260{
261 const title = document.querySelector('h3.panel-title');
262
263 if (!title) {
264 return;
265 }
266
267 const titleParent = title.parentElement;
268
269 const buttonParent = document.createElement('div');
270 buttonParent.setAttribute('class', 'btn-group pull-right');
271
272 const button = document.createElement('button');
273 button.textContent = '📋';
274 button.setAttribute('class', 'btn btn-primary btn-sm');
275 button.addEventListener('click', function() {
276 const origText = button.textContent;
277
278 button.textContent = '✔';
279 button.setAttribute('disabled', '1');
280
281 const fixedTitle = fixTitle(title.textContent.trim());
282 GM_setClipboard(fixedTitle);
283
284 setTimeout(
285 () => {
286 button.textContent = origText;
287 button.removeAttribute('disabled');
288 }, 1500
289 );
290 });
291
292 titleParent.classList.add('clearfix');
293 buttonParent.insertAdjacentElement('afterbegin', button);
294 titleParent.insertAdjacentElement('afterbegin', buttonParent);
295}
296
297function bbcodeConverter(html) {
298 html = html.replace(/<pre(.*?)>(.*?)<\/pre>/gmi, "[code]$2[/code]");
299 html = html.replace(/<h[1-7](.*?)>(.*?)<\/h[1-7]>/, "\n[h]$2[/h]\n");
300 //paragraph handling:
301 //- if a paragraph opens on the same line as another one closes, insert an extra blank line
302 //- opening tag becomes two line breaks
303 //- closing tags are just removed
304 // html += html.replace(/<\/p><p/<\/p>\n<p/gi;
305 // html += html.replace(/<p[^>]*>/\n\n/gi;
306 // html += html.replace(/<\/p>//gi;
307
308 html = html.replace(/<\/p>\n<p>/gi, "\n\n\n");
309
310 html = html.replace(/<br(.*?)>/gi, "\n");
311 html = html.replace(/<textarea(.*?)>(.*?)<\/textarea>/gmi, "\[code]$2\[\/code]");
312 html = html.replace(/<b>/gi, "[b]");
313 html = html.replace(/<i>/gi, "[i]");
314 html = html.replace(/<u>/gi, "[u]");
315 html = html.replace(/<\/b>/gi, "[/b]");
316 html = html.replace(/<\/i>/gi, "[/i]");
317 html = html.replace(/<\/u>/gi, "[/u]");
318 html = html.replace(/<em>/gi, "[b]");
319 html = html.replace(/<\/em>/gi, "[/b]");
320 html = html.replace(/<strong>/gi, "[b]");
321 html = html.replace(/<\/strong>/gi, "[/b]");
322 html = html.replace(/<cite>/gi, "[i]");
323 html = html.replace(/<\/cite>/gi, "[/i]");
324 html = html.replace(/<font color="(.*?)">(.*?)<\/font>/gmi, "[color=$1]$2[/color]");
325 html = html.replace(/<font color=(.*?)>(.*?)<\/font>/gmi, "[color=$1]$2[/color]");
326 html = html.replace(/<link(.*?)>/gi, "");
327 html = html.replace(/<li(.*?)>(.*?)<\/li>/gi, "[*]$2");
328 html = html.replace(/<ul(.*?)>/gi, "[list]");
329 html = html.replace(/<\/ul>/gi, "[/list]");
330 html = html.replace(/<div>/gi, "\n");
331 html = html.replace(/<\/div>/gi, "\n");
332 html = html.replace(/<td(.*?)>/gi, " ");
333 html = html.replace(/<tr(.*?)>/gi, "\n");
334
335 html = html.replace(/<img(.*?)src="(.*?)"(.*?)>/gi, "[img]$2[/img]");
336 html = html.replace(/<a(.*?)href="(.*?)"(.*?)>(.*?)<\/a>/gi, "[url=$2]$4[/url]");
337
338 html = html.replace(/<head>(.*?)<\/head>/gmi, "");
339 html = html.replace(/<object>(.*?)<\/object>/gmi, "");
340 html = html.replace(/<script(.*?)>(.*?)<\/script>/gmi, "");
341 html = html.replace(/<style(.*?)>(.*?)<\/style>/gmi, "");
342 html = html.replace(/<title>(.*?)<\/title>/gmi, "");
343 html = html.replace(/<!--(.*?)-->/gmi, "\n");
344
345 html = html.replace(/\/\//gi, "/");
346 html = html.replace(/http:\//gi, "http://");
347
348 html = html.replace(/<(?:[^>'"]*|(['"]).*?\1)*>/gmi, "");
349 html = html.replace(/\r\r/gi, "");
350 html = html.replace(/\[img]\//gi, "[img]");
351 html = html.replace(/\[url=\//gi, "[url=");
352
353 html = html.replace(/(\S)\n/gi, "$1 ");
354
355 return html;
356}
357
358function descriptionHandler()
359{
360 const descriptionElem = document.querySelector('#torrent-description');
361 if (!descriptionElem) {
362 return;
363 }
364
365 console.log(bbcodeConverter(descriptionElem.innerHTML));
366
367 const button = document.createElement('button');
368 button.textContent = '📋 Copy description as BBCode';
369 button.setAttribute('class', 'btn btn-primary');
370 button.addEventListener('click', function() {
371 const origText = button.textContent;
372
373 button.textContent = '✔ Copied!';
374 button.setAttribute('disabled', '1');
375
376 const descriptionBBcode = bbcodeConverter(descriptionElem.innerHTML).trim();
377 GM_setClipboard(descriptionBBcode);
378
379 setTimeout(
380 () => {
381 button.textContent = origText;
382 button.removeAttribute('disabled');
383 }, 1500
384 );
385 });
386
387 const panelHeading = document.createElement('div');
388 panelHeading.setAttribute('class', 'panel-heading');
389 panelHeading.insertAdjacentElement('beforeend', button);
390
391 descriptionElem.insertAdjacentElement('beforebegin', panelHeading);
392}
393
394/**
395 * Adds a clipboard icon (using Font Awesome 4) to each row that is marked for "mass copy".
396 */
397function addClipboardIconToRow()
398{
399 const rows = document.querySelectorAll('.torrent-list > tbody > tr > td[colspan="2"]');
400
401 let currentAmount = 0;
402
403 for (const row of rows)
404 {
405 currentAmount++;
406 const iconExists = row.querySelector('a.clipboard-icon');
407 if (iconExists) {
408 iconExists.remove();
409 }
410
411 // Check if this row is past the point of "don't receive icon"
412 if (currentAmount > copyAmount) {
413 // `continue`, not `break` - If we break, then when we lower `copyAmount`, there will be extra rows with clipboard icon.
414 continue;
415 }
416
417 row.insertAdjacentHTML('afterbegin', '<a href="#" class="comments clipboard-icon" style="margin-left: 0.5em;"><i class="fa fa-clipboard"></i></a>');
418 }
419}
420
421/**
422 * Adds buttons and number input fields above torrent table, for "mass copying" links/magnets.
423 */
424function torrentListViewHandler()
425{
426 const parentElement = document.querySelector('.table-responsive');
427
428 if (!parentElement) {
429 return;
430 }
431
432 addClipboardIconToRow();
433
434 /**
435 * Start: Copy all links/magnets
436 */
437 const element = document.createElement('button');
438 element.innerHTML = '📋 Copy all download links <i class="fa fa-fw fa-download"></i>';
439 element.setAttribute('class', 'btn btn-default');
440 element.addEventListener('click', function() {
441 const origText = element.innerHTML;
442
443 element.innerHTML = '✔';
444 element.setAttribute('disabled', '1');
445 copyLinks();
446
447 setTimeout(() => {
448 element.innerHTML = origText;
449 element.removeAttribute('disabled');
450 }, 1500);
451 });
452
453 const magnetCopy = document.createElement('button');
454 magnetCopy.innerHTML = '📋 Copy all magnets <i class="fa fa-fw fa-magnet"></i>';
455 magnetCopy.setAttribute('class', 'btn btn-default');
456 magnetCopy.addEventListener('click', function() {
457 const origText = magnetCopy.innerHTML;
458
459 magnetCopy.innerHTML = '✔';
460 magnetCopy.setAttribute('disabled', '1');
461 copyMagnets();
462
463 setTimeout(() => {
464 magnetCopy.innerHTML = origText;
465 magnetCopy.removeAttribute('disabled');
466 }, 1500);
467 });
468
469 /**
470 * End: Copy all links/magnets
471 */
472
473 /**
474 * Start: Copy X links/magnets
475 */
476 const linkAmount = (getLinks()).length;
477 copyAmount = linkAmount < 5 ? linkAmount : 5;
478 const amountSelect = document.createElement('input');
479 amountSelect.setAttribute('type', 'number');
480 amountSelect.setAttribute('min', '2');
481 amountSelect.setAttribute('max', linkAmount);
482 amountSelect.setAttribute('value', copyAmount);
483 amountSelect.setAttribute('class', 'pull-right');
484
485 const amountCopyLinks = document.createElement('button');
486 amountCopyLinks.innerHTML = `📋 Copy ${copyAmount} download links <i class="fa fa-fw fa-download"></i>`;
487 amountCopyLinks.setAttribute('class', 'btn btn-default pull-right');
488 amountCopyLinks.addEventListener('click', function() {
489 const origText = amountCopyLinks.innerHTML;
490
491 amountCopyLinks.innerHTML = '✔';
492 amountCopyLinks.setAttribute('disabled', '1');
493 copyLinks(copyAmount);
494
495 setTimeout(() => {
496 amountCopyLinks.innerHTML = origText;
497 amountCopyLinks.removeAttribute('disabled');
498 }, 1500);
499 });
500
501 const amountCopyMagnets = document.createElement('button');
502 amountCopyMagnets.innerHTML = `📋 Copy ${copyAmount} magnets <i class="fa fa-fw fa-magnet"></i>`;
503 amountCopyMagnets.setAttribute('class', 'btn btn-default pull-right');
504 amountCopyMagnets.addEventListener('click', function() {
505 const origText = amountCopyMagnets.innerHTML;
506
507 amountCopyMagnets.innerHTML = '✔';
508 amountCopyMagnets.setAttribute('disabled', '1');
509 copyMagnets(copyAmount);
510
511 setTimeout(() => {
512 amountCopyMagnets.innerHTML = origText;
513 amountCopyMagnets.removeAttribute('disabled');
514 }, 1500);
515 });
516
517 amountSelect.addEventListener('change', function() {
518 copyAmount = parseInt(amountSelect.value, 10);
519 amountCopyLinks.innerHTML = `📋 Copy ${copyAmount} download links <i class="fa fa-fw fa-download"></i>`;
520 amountCopyMagnets.innerHTML = `📋 Copy ${copyAmount} magnets <i class="fa fa-fw fa-magnet"></i>`;
521
522 addClipboardIconToRow();
523 });
524 /**
525 * End: Copy X links/magnets
526 */
527
528 /**
529 * Append all these buttons and inputs to the DOM.
530 */
531 parentElement.insertAdjacentElement('beforebegin', element);
532 parentElement.insertAdjacentElement('beforebegin', magnetCopy);
533
534 parentElement.insertAdjacentElement('beforebegin', amountSelect);
535 parentElement.insertAdjacentElement('beforebegin', amountCopyMagnets);
536 parentElement.insertAdjacentElement('beforebegin', amountCopyLinks);
537}
538
539/**
540 * This previously changed some `.container` classes to `.container-fluid`,
541 * but since that breaks other userscripts, such as NyaaBlue from SeaDex,
542 * I figured it'd be better to simply override the CSS width that .container usually applies.
543 */
544function widerContainers()
545{
546 const styleDoc = document.createElement('style');
547 styleDoc.setAttribute('type', 'text/css');
548
549 styleDoc.textContent = `
550 .container {
551 width: auto !important;
552 }
553 `;
554
555 document.head.insertAdjacentElement('beforeend', styleDoc);
556}
557
558function init() {
559 changeToCopy();
560 titleHandler();
561 descriptionHandler();
562 torrentListViewHandler();
563 calculateTotalSize();
564 widerContainers();
565}
566
567init();