Greasy Fork

[RED] Cover Inspector

Adds cover sticker if needs updating for unsupported host / big size / small resolution

目前为 2021-07-14 提交的版本。查看 最新版本

// ==UserScript==
// @name         [RED] Cover Inspector
// @namespace    https://greasyfork.org/users/321857-anakunda
// @version      1.11.4
// @run-at       document-end
// @description  Adds cover sticker if needs updating for unsupported host / big size / small resolution
// @author       Anakunda
// @copyright    2020, Anakunda (https://greasyfork.org/users/321857-anakunda)
// @license      GPL-3.0-or-later
// @match        https://redacted.ch/torrents.php?id=*
// @match        https://redacted.ch/artist.php?id=*
// @connect      *
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @require      https://openuserjs.org/src/libs/Anakunda/libLocks.min.js
// @require      https://openuserjs.org/src/libs/Anakunda/gazelleApiLib.min.js
// ==/UserScript==
// ==UserScript==
// @name         [RED] Cover Inspector
// @namespace    https://greasyfork.org/users/321857-anakunda
// @version      1.11.3
// @run-at       document-end
// @description  Adds cover sticker if needs updating for unsupported host / big size / small resolution
// @author       Anakunda
// @copyright    2020, Anakunda (https://greasyfork.org/users/321857-anakunda)
// @license      GPL-3.0-or-later
// @match        https://redacted.ch/torrents.php?id=*
// @match        https://redacted.ch/torrents.php
// @match        https://redacted.ch/torrents.php?action=advanced&*
// @match        https://redacted.ch/torrents.php?*&action=advanced
// @match        https://redacted.ch/torrents.php?*&action=advanced&*
// @match        https://redacted.ch/torrents.php?action=basic&*
// @match        https://redacted.ch/torrents.php?*&action=basic
// @match        https://redacted.ch/torrents.php?*&action=basic&*
// @match        https://redacted.ch/torrents.php?page=*
// @match        https://redacted.ch/torrents.php?action=notify
// @match        https://redacted.ch/torrents.php?action=notify&*
// @match        https://redacted.ch/torrents.php?type=*
// @match        https://redacted.ch/artist.php?id=*
// @connect      *
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @require      https://openuserjs.org/src/libs/Anakunda/libLocks.min.js
// @require      https://openuserjs.org/src/libs/Anakunda/gazelleApiLib.js
// ==/UserScript==

const isFirefox = /\b(?:Firefox)\b/.test(navigator.userAgent) || Boolean(window.InstallTrigger);
const httpParser = /^(https?:\/\/.+)*$/i;
const preferredHosts = ['https://ptpimg.me/'];

function getRemoteFileSize(url, forced = true) {
	return httpParser.test(url) ? new Promise(function(resolve, reject) {
		let size, abort = GM_xmlhttpRequest({ method: forced ? 'GET' : 'HEAD', url: url, //responseType: 'blob',
		 onreadystatechange: function(response) {
			 if (size >= 0 || response.readyState < XMLHttpRequest.HEADERS_RECEIVED) return;
			 if (/^(?:Content-Length)\s*:\s*(\d+)\b/im.test(response.responseHeaders) && (size = parseInt(RegExp.$1)) >= 0)
				 resolve(size);
			 else if (!forced) reject(undefined); else return;
			 abort.abort();
		 },
		 onload: function(response) { // fail-safe
			 if (size >= 0) return;
			 if (response.status < 200 || response.status >= 400) return reject('File not accessible');
			 //console.debug('responseText.length:', response.responseText.length);
			 resolve(response.responseText.length);
			 // console.time('GM_xmlhttpRequest response size getter');
			 // size = response.response.size; // response.responseText.length;
			 // console.timeEnd('GM_xmlhttpRequest response size getter');
			 // console.debug('response.size:', size);
			 // resolve(size);
		 },
		 onerror: response => { reject('File not accessible') },
		 ontimeout: response => { reject('File not accessible') },
		});
	}) : Promise.reject('getRemoteFileSize: parameter not valid URL');
}

function formattedSize(size) {
  return size >= 0 ? size < 1024**1 ? Math.round(size) + ' B'
		: size < 1024**2 ? (Math.round(size * 10 / 2**10) / 10) + ' KiB'
		: size < 1024**3 ? (Math.round(size * 100 / 2**20) / 100) + ' MiB'
		: size < 1024**4 ? (Math.round(size * 100 / 2**30) / 100) + ' GiB'
		: size < 1024**5 ? (Math.round(size * 100 / 2**40) / 100) + ' TiB'
		: (Math.round(size * 100 / 2**50) / 100) + ' PiB' : NaN;
}

const id = document.location.pathname == '/torrents.php'
	&& parseInt(new URLSearchParams(document.location.search).get('id')) || undefined;

const rehostImageLinks = ajaxApiKey ? (function() {
	const input = document.head.querySelector('meta[name="ImageHostHelper"]');
	return (input != null ? Promise.resolve(input) : new Promise(function(resolve, reject) {
		const mo = new MutationObserver(function(mutationsList, mo) {
			for (let mutation of mutationsList) for (let node of mutation.addedNodes) {
				if (node.nodeName != 'META' || node.name != 'ImageHostHelper') continue;
				clearTimeout(timer); mo.disconnect();
				return resolve(node);
			}
		}), timer = setTimeout(function(mo) {
			mo.disconnect();
			reject('Timeout reached');
		}, 10000, mo);
		mo.observe(document.head, { childList: true });
	})).then(function(meta) {
		console.assert(typeof unsafeWindow.rehostImageLinks == 'function', "typeof unsafeWindow.rehostImageLinks == 'function'");
		return (typeof unsafeWindow.rehostImageLinks == 'function') ? unsafeWindow.rehostImageLinks
			: Promise.reject('rehostImageLinks not accessible'); // assertion failed!
	});
})() : Promise.reject('AJAX API key not configured or unsupported page');

let acceptableCoverSize = GM_getValue('acceptable_cover_size');
if (!(acceptableCoverSize >= 0)) GM_setValue('acceptable_cover_size', acceptableCoverSize = 2048);
let acceptableCoverResolution = GM_getValue('acceptable_cover_resolution');
if (!(acceptableCoverResolution >= 0)) GM_setValue('acceptable_cover_resolution', acceptableCoverResolution = 300);

function inspectImage(img, id) {
	console.assert(img instanceof HTMLImageElement, 'img instanceof HTMLImageElement');
	if (!(img instanceof HTMLImageElement)) return;
	img.parentNode.style.position = 'relative';
	const _img = document.createElement('img');

	function imgsrcHandler(imgUrl) {
		if (imgSrc.endsWith('static/common/noartwork/music.png')) return;
		const sticker = document.createElement('div');
		sticker.className = 'cover-inspector';
		sticker.style = `
position: absolute; color: white; background-color: #ae2300; border: 1px solid whitesmoke;
font-weight: 700; font-family: "Segoe UI", sans-serif; cursor: default; z-index: 1; transition: 0.25s;
		` + (img.clientWidth > 0 && img.clientWidth < 100 ? 'right: 2px; bottom: 2px; padding: 1px 2px; font-size: 4pt;'
			: ' right: 4px; bottom: 4px; padding: 1px 5px; font-size: 8pt;');
		Promise.all([
			new Promise(function(resolve, reject) {
				_img.src = imgUrl;
				_img.onload = evt => { resolve(evt.currentTarget) };
				_img.onerror = evt => { reject(evt.message) };
			}),
			getRemoteFileSize(imgUrl).catch(function(reason) {
				console.warn('Failed to get remote image size (' + imgUrl + '):', reason);
				return undefined;
			}),
		]).then(function(results) {
			if (results[0].naturalWidth <= 0 || results[0].naturalHeight <= 0
					|| results[1] < 2 * 2**10 && results[0].naturalWidth == 400 && results[0].naturalHeight == 100
					|| results[1] == 503) return Promise.reject('Image is invalid');
			const isProxied = imgUrl.startsWith(document.location.origin + '/image.php?'),
						isPreferredHost = preferredHosts.some(preferredHost => imgUrl.startsWith(preferredHost)),
						isSizeOK = acceptableCoverSize == 0 || results[1] <= acceptableCoverSize * 2**10,
						isResolutionOK = acceptableCoverResolution == 0
							|| (results[0].naturalWidth >= acceptableCoverResolution
								&& results[0].naturalHeight >= acceptableCoverResolution);
			if (isPreferredHost && isSizeOK && isResolutionOK) return true;

			function span(content, className, isOK = false, tooltip) {
				const span = document.createElement('SPAN');
				if (className) span.className = className;
				if (tooltip) span.title = tooltip;
				if (!isOK) span.style.color = 'yellow';
				span.textContent = content;
				return span;
			}

			sticker.style.opacity = 0.8;
			sticker.append(span(formattedSize(results[1]), 'size', isSizeOK), ' / ',
				span(results[0].naturalWidth + '×' + results[0].naturalHeight, 'resolution', isResolutionOK));
			let rehost = null;
			if (isProxied) {
				sticker.prepend(rehost = span('PROXY', 'proxy'), ' / ');
				imgUrl = new URL(imgUrl);
				imgUrl = imgUrl.searchParams.values().next().value;
			} else if (!isPreferredHost)
				sticker.prepend(rehost = span('XTRN', 'external-host', false, 'Image at unpreferred image host'), ' / ');
			if (rehost instanceof HTMLElement && id) rehostImageLinks.then(function(rehostImageLinks) {
				rehost.classList.add('rehost');
				rehost.style.cursor = 'pointer';
				rehost.onclick = function(evt) {
					if (evt.currentTarget.hasAttribute('disabled')) return false;
					rehost.setAttribute('disabled', 1);
					img.style.opacity = 0.5;
					rehostImageLinks([imgUrl], true).then(rehostedImages => queryAjaxAPI('groupedit', { id: id }, new URLSearchParams({
						image: rehostedImages[0],
						summary: 'Cover rehost',
					})).then(function(response) {
						console.log(response);
						sticker.remove();
						Promise.resolve(img.src = rehostedImages[0]).then(imgsrcHandler);
						img.onclick = evt => { lightbox.init(evt.currentTarget.src, 220) };
						//cument.location.reload();
					})).catch(function(reason) {
						unsafeWindow.ihhLogFail(reason);
						img.style.opacity = 1;
						rehost.removeAttribute('disabled');
					});
				};
				rehost.onmouseenter = evt => { evt.currentTarget.style.textShadow = '0 0 5px lime'; };
				rehost.onmouseleave = evt => { evt.currentTarget.style.textShadow = null; };
				rehost.title = 'Click to rehost to preferred image host';
			});
			img.insertAdjacentElement('afterend', sticker);
			return false;
		}).catch(function(reason) {
			sticker.innerHTML = span('INVALID');
			sticker.title = reason;
			img.insertAdjacentElement('afterend', sticker);
			img.remove();
		});
	}

	img.onload = evt => { if (evt.currentTarget.style.opacity < 1) evt.currentTarget.style.opacity = 1 };
	if (id) rehostImageLinks.then(function(rehostImageLinks) {
		img.classList.add('drop');
		img.ondragover = evt => false;
		if (img.clientWidth > 100) {
			img.ondragenter = evt => { evt.currentTarget.parentNode.parentNode.style.backgroundColor = 'lawngreen' };
			img[isFirefox ? 'ondragexit' : 'ondragleave'] =
				evt => { evt.currentTarget.parentNode.parentNode.style.backgroundColor = null };
		}
		img.ondrop = function(evt) {
			function dataSendHandler(endPoint) {
				const sticker = evt.currentTarget.parentNode.querySelector('div.cover-inspector');
				img.style.opacity = 0.5;
				if (sticker != null) sticker.disabled = true;
				endPoint([items[0]], true).then(imageUrls => queryAjaxAPI('groupedit', { id: id }, new URLSearchParams({
					image: imageUrls[0],
					summary: 'Cover update',
				})).then(function(response) {
					console.log(response);
					if (sticker != null) sticker.remove();
					Promise.resolve(img.src = imageUrls[0]).then(imgsrcHandler);
					img.onclick = evt => { lightbox.init(evt.currentTarget.src, 220) };
					//cument.location.reload();
				})).catch(function(reason) {
					unsafeWindow.ihhLogFail(reason);
					if (sticker != null) sticker.disabled = false;
					img.style.opacity = 1;
				});
			}

			console.debug(evt.dataTransfer);
			var items = evt.dataTransfer.getData('text/uri-list');
			if (items) items = items.split(/\r?\n/); else {
				items = evt.dataTransfer.getData('text/x-moz-url');
				if (items) items = items.split(/\r?\n/).filter((item, ndx) => ndx % 2 == 0);
					else if (items = evt.dataTransfer.getData('text/plain'))
						items = items.split(/\r?\n/).filter(RegExp.prototype.test.bind(/^(https?:\/\/.+)$/i));
			}
			if (Array.isArray(items) && items.length > 0) {
				if (confirm('Update torrent cover from the dropped URL?\n\n' + items[0]))
					dataSendHandler(rehostImageLinks);
			} else if (evt.dataTransfer.files.length > 0 && typeof unsafeWindow.uploadImages == 'function') {
				items = Array.from(evt.dataTransfer.files)
					.filter(file => file instanceof File && file.type.startsWith('image/'));
				if (items.length > 0 && confirm('Update torrent cover from the dropped file?'))
					dataSendHandler(unsafeWindow.uploadImages);
			}
			if (img.clientWidth > 100) evt.currentTarget.parentNode.parentNode.style.backgroundColor = null;
			return false;
		};
	});
	let imgSrc = img.dataset.gazelleTempSrc || img.src;
	if (typeof img.onclick == 'function' && /\b(?:lightbox\.init)\('(.+?)'/.test(img.onclick.toString())) imgSrc = RegExp.$1
		else if (imgSrc.startsWith('https://i.imgur.com/')) imgSrc = imgSrc.replace(/\/(\w{7,})m\.(\w+)$/, '/$1.$2');
	console.debug('imgSrc:', imgSrc);
	imgsrcHandler(imgSrc);
}

for (let img of document.body.querySelectorAll([
	'div#covers p > img',
	'div.box_image > div > img',
].join(', '))) inspectImage(img, document.location.pathname == '/torrents.php' ? id : undefined);

function setTableHandlers(table, hdr, marginLeft) {
	if (!(table instanceof HTMLElement) || !(hdr instanceof HTMLElement)) return;
	const a = document.createElement('A');
	a.className = 'brackets';
	a.style.marginLeft = marginLeft;
	a.textContent = 'Inspect all covers';
	a.href = '#';
	a.onclick = function(evt) {
		evt.currentTarget.style.visibility = 'collapse';
		for (let tr of table.querySelectorAll('tbody > tr.group')) {
			const img = tr.querySelector('div.group_image > img'),
						a = tr.querySelector(('div.group_info a[href^="torrents.php?id="]'));
			if (img != null && a != null) inspectImage(img, parseInt(new URLSearchParams(a.search).get('id')));
		}
		evt.currentTarget.textContent = 'Rehost all';
		evt.currentTarget.onclick = function(evt) {
			evt.currentTarget.remove();
			for (let div of table.querySelectorAll('div.cover-inspector > span.rehost:not([disabled])')) div.click();
			return false;
		};
		setTimeout(a => { a.style.visibility = 'visible' }, 1000, evt.currentTarget);
		return false;
	};
	hdr.append(a);
}

switch (document.location.pathname) {
	case '/artist.php': {
		const table = document.getElementById('discog_table');
		if (table != null) setTableHandlers(table, table.querySelector(':scope > div.box'), '2em');
		break;
	}
	case '/torrents.php': {
		const table = document.getElementById('torrent_table');
		if (table == null) break;
		setTableHandlers(table, table.querySelector(':scope > tbody > tr.colhead > td:nth-of-type(3)'), '5em');
		break;
	}
}

if (id && document.location.pathname == '/torrents.php') rehostImageLinks.then(function(rehostImageLinks) {
	function setCoverFromLink(a) {
		console.assert(a instanceof HTMLAnchorElement, 'a instanceof HTMLAnchorElement');
		if (!(a instanceof HTMLAnchorElement)) throw 'Invalid invoker';
		rehostImageLinks([a.href], true).then(rehostedImages => queryAjaxAPI('groupedit', { id: id }, new URLSearchParams({
			image: rehostedImages[0],
			summary: 'Cover update',
		})).then(function(response) {
			console.log(response);
			document.location.reload();
		})).catch(unsafeWindow.ihhLogFail);
	}

	const contextId = '522a6889-27d6-4ea6-a878-20dec4362fbd', menu = document.createElement('menu');
	menu.type = 'context';
	menu.id = contextId;
	menu.className = 'cover-inspector';
	let menuInvoker;
	const setMenuInvoker = evt => { menuInvoker = evt.currentTarget };

	function addMenuItem(label, callback) {
		if (label) {
			const menuItem = document.createElement('MENUITEM');
			menuItem.label = label;
			if (typeof callback == 'function') menuItem.onclick = callback;
			menu.append(menuItem);
		}
		return menu.children.length;
	}

	addMenuItem('Set cover image from this link', evt => { setCoverFromLink(menuInvoker) });
	document.body.append(menu);

	function clickHandler(evt) {
		if (!evt.altKey) return true;
		evt.preventDefault();
		if (confirm('Set torrent group cover from this link?')) setCoverFromLink(evt.currentTarget);
		return false;
	}

	function setAnchorHandlers(a) {
		console.assert(a instanceof HTMLAnchorElement, 'a instanceof HTMLAnchorElement');
		if (!(a instanceof HTMLAnchorElement)) return false;
		a.setAttribute('contextmenu', contextId);
		a.oncontextmenu = setMenuInvoker;
		if (a.protocol.startsWith('http') && !a.onclick) {
			a.onclick = clickHandler;
			a.title = 'Alt + click to set torrent image from this URL (or use context menu command)';
		}
		return true;
	}

	document.body.querySelectorAll([
		'div.torrent_description > div.body a',
		'table#torrent_details > tbody > tr.torrentdetails > td > blockquote a',
	].join(', ')).forEach(setAnchorHandlers);
});