Greasy Fork

Greasy Fork is available in English.

[RED] Cover Inspector

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

当前为 2021-07-15 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         [RED] Cover Inspector
// @namespace    http://greasyfork.icu/users/321857-anakunda
// @version      1.11.6
// @run-at       document-end
// @description  Adds cover sticker if needs updating for unsupported host / big size / small resolution
// @author       Anakunda
// @copyright    2020, Anakunda (http://greasyfork.icu/users/321857-anakunda)
// @license      GPL-3.0-or-later
// @iconURL      https://i.ibb.co/mh2prQR/clouseau.png
// @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=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?*&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.min.js
// ==/UserScript==

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

try { var fileSizeCache = new Map(JSON.parse(sessionStorage.fileSizeCache)) } catch(e) { fileSizeCache = new Map }
try { var fileTypeCache = new Map(JSON.parse(sessionStorage.fileTypeCache)) } catch(e) { fileTypeCache = new Map }

function defaultErrorHandler(response) {
	console.error('HTTP error:', response);
	let e = 'HTTP error ' + response.status;
	if (response.statusText) e += ' (' + response.statusText + ')';
	if (response.error) e += ' (' + response.error + ')';
	return e;
}
function defaultTimeoutHandler(response) {
	console.error('HTTP timeout:', response);
	const e = 'HTTP timeout';
	return e;
}

function getRemoteFileSize(url) {
	if (!httpParser.test(url)) return Promise.reject('getRemoteFileSize(...): parameter not valid URL');
	if (fileSizeCache.has(url)) return Promise.resolve(fileSizeCache.get(url));
	const getByXHR = (method = 'GET') => new Promise(function(resolve, reject) {
		function success() {
			fileSizeCache.set(url, size);
			sessionStorage.fileSizeCache = JSON.stringify(Array.from(fileSizeCache));
			resolve(size);
		}
		let size, hXHR = GM_xmlhttpRequest({ method: method, url: url, binary: true, responseType: 'blob',
			onreadystatechange: function(response) {
				if (typeof size == 'number' && size >= 0 || response.readyState < XMLHttpRequest.HEADERS_RECEIVED) return;
				if ((size = /^(?:Content-Length)\s*:\s*(\d+)\b/im.exec(response.responseHeaders)) != null
						&& (size = parseInt(size[1])) >= 0) success();
					else if (method == 'HEAD') reject('Content size missing in header'); else return;
				if (method != 'HEAD') hXHR.abort();
			},
			onload: function(response) { // fail-safe
				if (typeof size == 'number' && size >= 0) return;
				if (response.status >= 200 && response.status < 400) {
					/*if (response.response) {
						size = response.response.size;
						success();
					} else */if (response.responseText) {
						size = response.responseText.length;
						success();
					} else reject('Body missing');
				} else reject(defaultErrorHandler(response));
			},
			onerror: response => { reject(defaultErrorHandler(response)) },
			ontimeout: response => { reject(defaultTimeoutHandler(response)) },
		});
	});
	return getByXHR('GET')/*.catch(reason => getByXHR('GET'))*/;
}

function getRemoteFileType(url) {
	if (!httpParser.test(url)) return Promise.reject('getRemoteFileType: parameter not valid URL');
	if (fileTypeCache.has(url)) return Promise.resolve(fileTypeCache.get(url));
	const getByXHR = (method = 'GET') => new Promise(function(resolve, reject) {
		let contentType, hXHR = GM_xmlhttpRequest({ method: method, url: url,
			onreadystatechange: function(response) {
				if (contentType !== undefined || response.readyState < XMLHttpRequest.HEADERS_RECEIVED) return;
				if ((contentType = /^(?:Content-Type)\s*:\s*(.+?)\s*$/im.exec(response.responseHeaders)) != null) {
					fileTypeCache.set(url, contentType = contentType[1].toLowerCase());
					sessionStorage.fileTypeCache = JSON.stringify(Array.from(fileTypeCache));
					resolve(contentType);
				} else reject('MIME type missing in header');
				if (method != 'HEAD') hXHR.abort();
			},
			onerror: response => { reject(defaultErrorHandler(response)) },
			ontimeout: response => { reject(defaultTimeoutHandler(response)) },
		});
	});
	return getByXHR('HEAD')
		.catch(reason => /^HTTP error (403|416)\b/.test(reason) ? getByXHR('GET') : Promise.reject(reason));
}

function formattedSize(size) {
	return 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';
}

const groupId = 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 image = new Image;

	function imgsrcHandler(imageUrl) {
		if (imgSrc.endsWith('static/common/noartwork/music.png')) return;
		const sticker = document.createElement('div');
		sticker.className = 'cover-inspector';
		sticker.style = `
position: absolute; color: white; border: thin solid lightgray;
font-family: "Segoe UI", sans-serif; font-weight: 600;
cursor: default; transition-duration: 0.25s; z-index: 1;
		` + (img.clientWidth > 0 && img.clientWidth < 100 ? 'right: 2px; bottom: 2px; padding: 0 1px; font-size: 4.1pt;'
			: ' right: -3pt; bottom: -7pt; padding: 1px; font-size: 8.5pt;');
		Promise.all([
			new Promise(function(resolve, reject) {
				image.onload = evt => { resolve(evt.currentTarget) };
				image.onerror = evt => { reject(evt.message || evt) };
				image.src = imageUrl;
			}),
			getRemoteFileSize(imageUrl).catch(function(reason) {
				console.warn('Failed to get remote image size (' + imageUrl + '):', reason);
				return undefined;
			}),
			getRemoteFileType(imageUrl).catch(function(reason) {
				console.warn('Failed to get remote image type (' + imageUrl + '):', 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 = imageUrl.startsWith(document.location.origin + '/image.php?'),
						isPreferredHost = preferredHosts.some(preferredHost => imageUrl.startsWith(preferredHost)),
						isSizeOK = acceptableCoverSize == 0 || results[1] <= acceptableCoverSize * 2**10,
						isResolutionOK = acceptableCoverResolution == 0
							|| (results[0].naturalWidth >= acceptableCoverResolution
								&& results[0].naturalHeight >= acceptableCoverResolution);

			function span(content, className, isOK = false, tooltip) {
				const span = document.createElement('SPAN');
				if (className) span.className = className;
				if (tooltip) span.title = tooltip;
				span.style.padding = img.clientWidth > 0 && img.clientWidth < 100 ? '0 2px' : '0 4px';
				if (!isOK) span.style.color = 'yellow';
				span.textContent = content;
				return span;
			}

			if (isPreferredHost && isSizeOK && isResolutionOK) {
				if (results[2]) sticker.append('/', span(results[2], 'mime-type', true));
				sticker.style.backgroundColor = 'teal';
				sticker.style.opacity = 0;
				img.onmouseleave = evt => { if (evt.relatedTarget != sticker) sticker.style.opacity = 0 };
			} else {
				sticker.style.backgroundColor = '#ae2300';
				sticker.style.opacity = 0.75;
				img.onmouseleave = evt => { if (evt.relatedTarget != sticker) sticker.style.opacity = 0.75 };
			}
			img.onmouseenter = img.onmouseover = evt =>
				{ if (![evt.currentTarget, sticker].some(e => evt.relatedTarget == e)) sticker.style.opacity = 1 };
			sticker.prepend(span(results[0].naturalWidth + '×' + results[0].naturalHeight, 'resolution', isResolutionOK),
				'/', span(formattedSize(results[1]), 'size', isSizeOK));
			let rehost = null;
			if (isProxied) {
				sticker.prepend(rehost = span('PROXY', 'proxy'), '/');
				imageUrl = new URL(imageUrl);
				imageUrl = imageUrl.searchParams.values().next().value || decodeURIComponent(imageUrl.search.slice(1));
			} else if (!isPreferredHost)
				sticker.prepend(rehost = span('XTRN', 'external-host', false, 'Image at unpreferred image host'), '/');
			if (rehost instanceof HTMLElement && httpParser.test(imageUrl) && 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([imageUrl], 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) };
					})).catch(function(reason) {
						unsafeWindow.ihhLogFail(reason);
						img.style.opacity = 1;
						rehost.removeAttribute('disabled');
					});
				};
				rehost.style.transitionDuration = '0.25s';
				rehost.onmouseenter = evt => { evt.currentTarget.style.textShadow = '0 0 5px lime'; };
				rehost.onmouseleave = evt => { evt.currentTarget.style.textShadow = null; };
				rehost.title = 'Hosted at: ' + new URL(imageUrl).hostname + ' (click to rehost to preferred image host)';
			});
			sticker.title = imageUrl;
			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);
					if (typeof lightbox == 'object') img.onclick = evt => { lightbox.init(evt.currentTarget.src, 220) };
						else document.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(httpParser));
			}
			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, groupId);

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[width="100%"]'), '5em');
		break;
	}
}

if (groupId) 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: groupId }, 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);
});