Greasy Fork

[RED] Cover Inspector

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

目前为 2022-06-05 提交的版本。查看 最新版本

// ==UserScript==
// @name         [RED] Cover Inspector
// @namespace    https://greasyfork.org/users/321857-anakunda
// @version      1.13.2
// @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
// @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
// @grant        GM_openInTab
// @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/'];
const preferredTypes = ['jpeg', 'webp', 'gif'];

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 reason = 'HTTP error ' + response.status;
	if (response.status == 0) reason += '/' + response.readyState;
	let statusText = response.statusText;
	if (response.response) try {
		if (typeof response.response.error == 'string') statusText = response.response.error;
	} catch(e) { }
	if (statusText) reason += ' (' + statusText + ')';
	return reason;
}
function defaultTimeoutHandler(response) {
	console.error('HTTP timeout:', response);
	let reason = 'HTTP timeout';
	if (response.timeout) reason += ' (' + response.timeout + ')';
	return reason;
}

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*;(.+?))?\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) + '\xA0B'
		: size < 1024**2 ? (Math.round(size * 10 / 2**10) / 10) + '\xA0KiB'
		: size < 1024**3 ? (Math.round(size * 100 / 2**20) / 100) + '\xA0MiB'
		: size < 1024**4 ? (Math.round(size * 100 / 2**30) / 100) + '\xA0GiB'
		: size < 1024**5 ? (Math.round(size * 100 / 2**40) / 100) + '\xA0TiB'
		: (Math.round(size * 100 / 2**50) / 100) + '\xA0PiB';
}

const imageHostHelper = 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(node) {
		console.assert(node instanceof HTMLElement);
		const propName = node.getAttribute('propertyname');
		console.assert(propName);
		return unsafeWindow[propName] || Promise.reject('Assertion failed: \'' + propName + '\' not in unsafeWindow');
	});
})() : Promise.reject('AJAX API key not configured or unsupported site');

if (!document.tooltipster) document.tooltipster = typeof jQuery.fn.tooltipster == 'function' ?
		Promise.resolve(jQuery.fn.tooltipster) : new Promise(function(resolve, reject) {
	const script = document.createElement('SCRIPT');
	script.src = '/static/functions/tooltipster.js';
	script.type = 'text/javascript';
	script.onload = function(evt) {
		//console.log('tooltipster.js was successfully loaded', evt);
		if (typeof jQuery.fn.tooltipster == 'function') resolve(jQuery.fn.tooltipster);
			else reject('tooltipster.js loaded but core function was not found');
	};
	script.onerror = evt => { reject('Error loading tooltipster.js') };
	document.head.append(script);
	['style.css'/*, 'custom.css', 'reset.css'*/].forEach(function(css) {
		const styleSheet = document.createElement('LINK');
		styleSheet.rel = 'stylesheet';
		styleSheet.type = 'text/css';
		styleSheet.href = '/static/styles/tooltipster/' + css;
		//styleSheet.onload = evt => { console.log('style.css was successfully loaded', evt) };
		styleSheet.onerror = evt => { (css == 'style.css' ? reject : console.warn)('Error loading ' + css) };
		document.head.append(styleSheet);
	});
});

function getPreference(key, defVal) {
	let value = GM_getValue(key);
	if (value == undefined) GM_setValue(key, value = defVal);
	return value;
}

const acceptableSize = getPreference('acceptable_cover_size', 4 * 2**10);
const acceptableResolution = getPreference('acceptable_cover_resolution', 300);

function getHostFriendlyName(imageUrl) {
	if (httpParser.test(imageUrl)) try { imageUrl = new URL(imageUrl) } catch(e) { console.error(e) }
	if (imageUrl instanceof URL) imageUrl = imageUrl.hostname.toLowerCase(); else return;
	const knownHosts = {
		'2i': ['2i.cz'],
		'Abload': ['abload.de'],
		'AllMusic': ['rovicorp.com'],
		'AllThePics': ['allthepics.net'],
		'Amazon': ['media-amazon.com', 'ssl-images-amazon.com', 'amazonaws.com'],
		'Apple': ['mzstatic.com'],
		'Archive': ['archive.org'],
		'Bandcamp': ['bcbits.com'],
		'Beatport': ['beatport.com'],
		'BilderUpload': ['bilder-upload.eu'],
		'Boomkat': ['boomkat.com'],
		'CasImages': ['casimages.com'],
		'Catbox': ['catbox.moe'],
		'CloudFront': ['cloudfront.net'],
		'CubeUpload': ['cubeupload.com'],
		'Deezer': ['dzcdn.net'],
		'Discogs': ['discogs.com'],
		'Discord': ['discordapp.net'],
		'eBay': ['ebayimg.com'],
		'Extraimage': ['extraimage.org'],
		'FastPic': ['fastpic.ru', 'fastpic.org'],
		'Forumbilder': ['forumbilder.com'],
		'FreeImageHost': ['freeimage.host'],
		'FunkyImg': ['funkyimg.com'],
		'GeTt': ['ge.tt'],
		'GeekPic': ['geekpic.net'],
		'GetaPic': ['getapic.me'],
		'Gifyu': ['gifyu.com'],
		'GooPics': ['goopics.net'],
		'HRA': ['highresaudio.com'],
		'imageCx': ['image.cx'],
		'ImageBan': ['imageban.ru'],
		'ImagensBrasil': ['imagensbrasil.org'],
		'ImageRide': ['imageride.com'],
		'ImageToT': ['imagetot.com'],
		'ImageVenue': ['imagevenue.com'],
		'ImgBank': ['imgbank.cz'],
		'ImgBB': ['ibb.co'],
		'ImgBox': ['imgbox.com'],
		'ImgCDN': ['imgcdn.dev'],
		'Imgoo': ['imgoo.com'],
		'ImgPile': ['imgpile.com'],
		'imgsha': ['imgsha.com'],
		'Imgur': ['imgur.com'],
		'ImgURL': ['png8.com'],
		'IpevRu': ['ipev.ru'],
		'Jerking': ['jerking.empornium.ph'],
		'JPopsuki': ['jpopsuki.eu'],
		'Juno': ['junodownload.com'],
		'Last.fm': ['lastfm.freetls.fastly.net', 'last.fm'],
		'Lensdump': ['lensdump.com'],
		'LightShot': ['prntscr.com'],
		'LostPic': ['lostpic.net'],
		'Lutim': ['lut.im'],
		'MetalArchives': ['metal-archives.com'],
		'Mobilism': ['mobilism.org'],
		'Mora': ['mora.jp'],
		'MusicBrainz': ['coverartarchive.org'],
		'NoelShack': ['noelshack.com'],
		'Photobucket': ['photobucket.com'],
		'PicaBox': ['picabox.ru'],
		'PicLoad': ['free-picload.com'],
		'PimpAndHost': ['pimpandhost.com'],
		'Pinterest': ['pinimg.com'],
		'PixHost': ['pixhost.to'],
		'PomfCat': ['pomf.cat'],
		'PostImg': ['postimg.cc'],
		'PTPimg': ['ptpimg.me'],
		'Qobuz': ['qobuz.com'],
		'Ra': ['thesungod.xyz'],
		'Radikal': ['radikal.ru'],
		'SavePhoto': ['savephoto.ru'],
		'Shopify': ['shopify.com'],
		'Slowpoke': ['slow.pics'],
		'SoundCloud': ['sndcdn.com'],
		'SM.MS': ['sm.ms'],
		'SVGshare': ['svgshare.com'],
		'Tidal': ['tidal.com'],
		'Traxsource': ['traxsource.com'],
		'Twitter': ['twimg.com'],
		'Upimager': ['upimager.com'],
		'Uupload.ir': ['uupload.ir'],
		'VgyMe': ['vgy.me'],
		'Wiki': ['wikimedia.org'],
		'Z4A': ['z4a.net'],
		'路过图床': ['imgchr.com'],
	};
	for (let name in knownHosts) if (knownHosts[name].some(function(domain) {
		domain = domain.toLowerCase();
		return imageUrl == domain || imageUrl.endsWith('.' + domain);
	})) return name;
}

const hostsBlacklist = GM_getValue('banned_from_click2go', [ ]);
const bannedFromClick2Go = friendlyHost => friendlyHost && Array.isArray(hostsBlacklist)
	&& hostsBlacklist.some(fh => fh.toLowerCase() == friendlyHost.toLowerCase());

function getImageMax(imageUrl) {
	const friendlyName = getHostFriendlyName(imageUrl);
	return imageHostHelper.then(ihh => (function() {
		const func = friendlyName && {
			'Deezer': 'getDeezerImageMax',
			'Discogs': 'getDiscogsImageMax',
		}[friendlyName];
		return func && func in ihh ? ihh[func](imageUrl) : Promise.reject('No imagemax function');
	})().catch(function(reason) {
		let sub = friendlyName && {
			'Bandcamp': [/_\d+(?=\.(\w+)$)/, '_10'],
			'Deezer': ihh.dzrImageMax,
			'Apple': ihh.itunesImageMax,
			'Qobuz': [/_\d{3}(?=\.(\w+)$)/, '_org'],
			'Boomkat': [/\/(?:large|medium|small)\//i, '/original/'],
			'Beatport': [/\/image_size\/\d+x\d+\//i, '/image/'],
			'Tidal': [/\/(\d+x\d+)(?=\.(\w+)$)/, '/1280x1280'],
			'Amazon': [/\._\S+?_(?=\.)/, ''],
			'HRA': [/_(\d+x\d+)(?=\.(\w+)$)/, ''],
		}[friendlyName];
		if (sub) sub = String(imageUrl).replace(...sub); else return Promise.reject('No imagemax substitution');
		return 'verifyImageUrl' in ihh ? ihh.verifyImageUrl(sub) : sub;
	}).catch(reason => 'verifyImageUrl' in ihh ? ihh.verifyImageUrl(imageUrl) : imageUrl));
}

function inspectImage(img, id) {
	console.assert(img instanceof HTMLImageElement, 'img instanceof HTMLImageElement');
	if (!(img instanceof HTMLImageElement)) return;
	img.onload = evt => { evt.currentTarget.hidden = false };
	img.parentNode.style.position = 'relative';
	const inListing = img.clientWidth > 0 && img.clientWidth < 100, image = new Image;
	if (id && /^(?:cover_(\d+))$/.test(img.id) & parseInt(RegExp.$1) > 0) id = undefined;
	const loadHandler = evt => { if (evt.currentTarget.style.opacity < 1) evt.currentTarget.style.opacity = 1 };
	const clickHandler = evt => { lightbox.init(evt.currentTarget.src, 220) };

	function editOnClick(elem) {
		function editOnClick(evt) {
			evt.stopPropagation();
			evt.preventDefault();
			//document.getSelection().removeAllRanges();
			const url = '/torrents.php?' + new URLSearchParams({
				action: 'editgroup',
				groupid: id,
			}).toString();
			if ((evt.shiftKey || evt.ctrlKey) && typeof GM_openInTab == 'function')
				GM_openInTab(document.location.origin + url, evt.shiftKey); else document.location.assign(url);
			return false;
		}
		console.assert(elem instanceof HTMLElement, 'elem instanceof HTMLElement');
		if (!(elem instanceof HTMLElement)) return;
		elem.classList.add('edit');
		elem.style.cursor = 'pointer';
		elem.style.userSelect = 'none';
		elem.style['-webkit-user-select'] = 'none';
		elem.style['-moz-user-select'] = 'none';
		elem.style['-ms-user-select'] = 'none';
		elem.onclick = editOnClick;
	}

	function imgsrcHandler(imageUrl) {
		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: 700;
cursor: default; transition-duration: 0.25s; z-index: 1;
		` + (inListing ? 'right: 1px; bottom: 1px; padding: 0; font-size: 6pt; text-align: right;'
			: ' right: -3pt; bottom: -7pt; padding: 1px; font-size: 8.5pt;');

		function span(content, className, isOK = false, tooltip) {
			const span = document.createElement('SPAN');
			if (className) span.className = className;
			if (tooltip) {
				span.title = tooltip;
				document.tooltipster.then(() => { $(span).tooltipster({ content: tooltip.replace(/\r?\n/g, '<br>') }) })
					.catch(reason => { console.warn(reason) });
			}
			span.style.padding = inListing ? '0 2px' : '0 4px';
			if (!isOK) span.style.color = 'yellow';
			span.textContent = content;
			return span;
		}

		Promise.all([
			new Promise(function(resolve, reject) {
				image.onload = evt => { resolve(evt.currentTarget) };
				image.onerror = evt => { reject(evt.message || 'Image load error (' + evt.currentTarget.src + ')') };
				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 = acceptableSize == 0 || results[1] <= acceptableSize * 2**10,
						isResolutionOK = acceptableResolution == 0
							|| ((document.location.pathname == '/artist.php' || results[0].naturalWidth >= acceptableResolution)
								&& results[0].naturalHeight >= acceptableResolution),
						isTypeOK = !results[2] || preferredTypes.some(format => results[2] == 'image/' + format);

			const divisor = () => inListing ? document.createElement('BR') : '/';
			function isOutside(target, related) {
				if (target instanceof HTMLElement) {
					target = target.parentNode;
					while (related instanceof HTMLElement) if ((related = related.parentNode) == target) return false;
				}
				return true;
			}

			sticker.onmouseenter = img.onmouseenter = evt => { sticker.style.opacity = 1 };
			let rehost, convert;
			const friendlyHost = getHostFriendlyName(imageUrl);
			if (isPreferredHost && isSizeOK && isResolutionOK && isTypeOK) {
				sticker.style.backgroundColor = 'teal';
				sticker.style.opacity = 0;
				sticker.onmouseleave = img.onmouseleave =
					evt => { if (isOutside(evt.currentTarget, evt.relatedTarget)) sticker.style.opacity = 0 };
				if (results[2]) sticker.append(divisor(), span(results[2], 'mime-type', true));
			} else {
				sticker.style.backgroundColor = '#ae2300';
				sticker.style.opacity = 0.75;
				sticker.onmouseleave = img.onmouseleave =
					evt => { if (isOutside(evt.currentTarget, evt.relatedTarget)) sticker.style.opacity = 0.75 };
				if (inListing && id) editOnClick(sticker);
				if (!isTypeOK) {
					sticker.append(divisor(), convert = span(results[2], 'mime-type', false));
					if (id && httpParser.test(imageUrl) && (!inListing || !bannedFromClick2Go(friendlyHost))) imageHostHelper.then(function(ihh) {
						convert.style.cursor = 'pointer';
						convert.onclick = function(evt) {
							evt.stopPropagation();
							if (evt.currentTarget.hasAttribute('disabled')) return false;
							convert.setAttribute('disabled', 1);
							img.style.opacity = 0.3;
							ihh.reduceImageSize(imageUrl, 2160, 90)
								.then(output => ihh.rehostImages([output.uri]).then(ihh.singleImageGetter)
								.then(rehostedImgUrl => queryAjaxAPI('groupedit', { id: id }, {
									image: rehostedImgUrl,
									summary: 'Cover downsize', //+ ' (' + formattedSize(results[1]) + ' => ' + formattedSize(output.size) + ')',
								}).then(function(response) {
									console.log(response);
									sticker.remove();
									img.onload = loadHandler;
									if (typeof lightbox == 'object') img.onclick = clickHandler; //else document.location.reload();
									Promise.resolve(img.src = rehostedImgUrl).then(imgsrcHandler);
								}))).catch(function(reason) {
									if ('logFail' in ihh) ihh.logFail(reason);
									img.style.opacity = 1;
									convert.removeAttribute('disabled');
								});
						};
						convert.style.transitionDuration = '0.25s';
						convert.onmouseenter = evt => { evt.currentTarget.style.textShadow = '0 0 5px lime' };
						convert.onmouseleave = evt => { evt.currentTarget.style.textShadow = null };
						convert.title = 'Click to convert it to JPG';
						document.tooltipster.then(() => { $(convert).tooltipster() }).catch(reason => { console.warn(reason) });
					});
				}
			}
			sticker.prepend(span(results[0].naturalWidth + '×' + results[0].naturalHeight, 'resolution', isResolutionOK),
				divisor(), rehost = span(formattedSize(results[1]), 'size', isSizeOK));
			if (isProxied) {
				sticker.prepend(rehost = span('PROXY', 'proxy'), divisor());
				imageUrl = new URL(imageUrl);
				imageUrl = imageUrl.searchParams.get('i') || imageUrl.searchParams.values().next().value
					|| decodeURIComponent(imageUrl.search.slice(1));
			} else if (!isPreferredHost) {
				sticker.prepend(rehost = span(friendlyHost || 'XTRN',
					'external-host', false, 'Image at unpreferred image host'), divisor());
				if (bannedFromClick2Go(friendlyHost)) rehost.style.color = 'orange';
			} else if (isSizeOK) {
				rehost = null;
				if (convert instanceof HTMLElement && (!inListing || !bannedFromClick2Go(friendlyHost)))
						convert.classList.add('click2go');
			}
			if (rehost instanceof HTMLElement && id && httpParser.test(imageUrl)
					&& (!inListing || !bannedFromClick2Go(friendlyHost))) imageHostHelper.then(function(ihh) {
				rehost.classList.add('click2go');
				rehost.style.cursor = 'pointer';
				rehost.onclick = function(evt) {
					evt.stopPropagation();
					if (evt.currentTarget.hasAttribute('disabled')) return false;
					rehost.setAttribute('disabled', 1);
					img.style.opacity = 0.3;
					getImageMax(imageUrl).then(maxImgUrl => ihh.rehostImageLinks([maxImgUrl], true).then(ihh.singleImageGetter))
						.then(rehostedImgUrl => queryAjaxAPI('groupedit', { id: id }, {
							image: rehostedImgUrl,
							summary: 'Cover rehost',
						}).then(function(response) {
							console.log(response);
							sticker.remove();
							img.onload = loadHandler;
							if (typeof lightbox == 'object') img.onclick = clickHandler; //else document.location.reload();
							Promise.resolve(img.src = rehostedImgUrl).then(imgsrcHandler);
						})).catch(function(reason) {
							if ('logFail' in ihh) ihh.logFail(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)';
				document.tooltipster.then(() => { $(rehost).tooltipster() }).catch(reason => { console.warn(reason) });
			});
			sticker.title = imageUrl;
			document.tooltipster.then(() => { $(sticker).tooltipster() }).catch(reason => { console.warn(reason) });
			img.insertAdjacentElement('afterend', sticker);
			return false;
		}).catch(function(reason) {
			sticker.append(span('INVALID'));
			sticker.style.left = 0;
			sticker.style.bottom = '-11pt';
			sticker.style.width = '100%';
			sticker.style.backgroundColor = 'red';
			if (id) editOnClick(sticker);
			sticker.title = reason;
			document.tooltipster.then(() => { $(sticker).tooltipster({ content: reason }) }).catch(reason => { console.warn(reason) });
			if (!/^cover_(\d+)$/.test(img.id) || !(parseInt(RegExp.$1) > 0)) img.insertAdjacentElement('afterend', sticker);
			img.hidden = true; //img.remove();
		});
	}

	if (id) imageHostHelper.then(function(ihh) {
		img.classList.add('drop');
		img.ondragover = evt => false;
		if (img.clientWidth > 100) {
			img.ondragenter = evt => { evt.currentTarget.parentNode.parentNode.style.backgroundColor = '#7fff0040' };
			img[`ondrag${isFirefox ? 'exit' : 'leave'}`] =
				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.3;
				if (sticker != null) sticker.disabled = true;
				endPoint([items[0]], true, false, true, {
					ctrlKey: evt.ctrlKey,
					shiftKey: evt.shiftKey,
					altKey: evt.altKey,
				}).then(ihh.singleImageGetter).then(imageUrl => queryAjaxAPI('groupedit', { id: id }, {
					image: imageUrl,
					summary: 'Cover update',
				}).then(function(response) {
					console.log(response);
					if (sticker != null) sticker.remove();
					img.onload = loadHandler;
					if (typeof lightbox == 'object') img.onclick = clickHandler; //else document.location.reload();
					Promise.resolve(img.src = imageUrl).then(imgsrcHandler);
				})).catch(function(reason) {
					if ('logFail' in ihh) ihh.logFail(reason);
					if (sticker != null) sticker.disabled = false;
					img.style.opacity = 1;
				});
			}

			evt.stopPropagation();
			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 && 'rehostImageLinks' in ihh) {
				if (confirm('Update torrent cover from the dropped URL?\n\n' + items[0]))
					dataSendHandler(ihh.rehostImageLinks);
			} else if (evt.dataTransfer.files.length > 0 && 'uploadFiles' in ihh) {
				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(ihh.uploadFiles);
			}
			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);
	if (!imgSrc.includes('static/common/noartwork/')) imgsrcHandler(imgSrc); else if (id) editOnClick(img);
}

function setTooltip(elem, tooltip) {
	if (elem instanceof HTMLElement) document.tooltipster.then(function() {
		if (tooltip) tooltip = tooltip.replace(/\r?\n/g, '<br>')
		if ($(elem).data('plugin_tooltipster'))
			if (tooltip) $(elem).tooltipster('update', tooltip).tooltipster('enable');
				else $(elem).tooltipster('disable');
		else if (tooltip) $(elem).tooltipster({ content: tooltip });
	});
}

function setTableHandlers(table, hdr, marginLeft) {
	if (!(table instanceof HTMLElement) || !(hdr instanceof HTMLElement)) return;

	function getGroupId(root) {
		if (root instanceof HTMLElement) for (let a of root.getElementsByTagName('A'))
			if (a.origin == document.location.origin && a.pathname == '/torrents.php') {
				const urlParams = new URLSearchParams(a.search);
				if (urlParams.has('action')) continue;
				const id = parseInt(urlParams.get('id'));
				if (id >= 0) return id;
			}
		return null;
	}

	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, tbody > tr.torrent')) {
			const img = tr.querySelector('div.group_image > img');
			if (img != null) inspectImage(img, getGroupId(tr.querySelector('div.group_info')));
		}
		const currentTarget = evt.currentTarget;
		imageHostHelper.then(function(ihh) {
			currentTarget.textContent = 'Rehost all';
			currentTarget.onclick = function(evt) {
				evt.currentTarget.remove();
				for (let div of table.querySelectorAll('div.cover-inspector > span.click2go:not([disabled])')) div.click();
				return false;
			};
			setTimeout(a => { a.style.visibility = 'visible' }, 1000, currentTarget);
		}, reason => { currentTarget.remove() });
		return false;
	};
	hdr.append(a);
}

const params = new URLSearchParams(document.location.search), id = parseInt(params.get('id')) || undefined;
const noCoverHeres = [
	document.location.hostname,
	'redacted.ch', 'orpheus.network', 'notwhat.cd', 'dicmusic.club',
	'jpopsuki.eu',
	'github.com', 'gitlab.com',
	'ptpimg.me', 'imgur.com',
].concat(GM_getValue('no_covers_here', [ ]));

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': {
		if (id > 0) imageHostHelper.then(function(ihh) {
			function setCoverFromLink(a) {
				console.assert(a instanceof HTMLAnchorElement, 'a instanceof HTMLAnchorElement');
				if (!(a instanceof HTMLAnchorElement)) throw 'Invalid invoker';
				ihh.rehostImageLinks([a.href], true).then(rehostedImages => queryAjaxAPI('groupedit', { id: id }, {
					image: ihh.singleImageGetter(rehostedImages),
					summary: 'Cover update',
				}).then(function(response) {
					console.log(response);
					document.location.reload();
				})).catch(ihh.logFail);
			}

			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 source', evt => { setCoverFromLink(menuInvoker) });
			document.body.append(menu);

			function clickHandler(evt) {
				if (evt.altKey) evt.preventDefault(); else return true;
				if (confirm('Set torrent group cover from this source?')) 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)';
					document.tooltipster.then(() => { $(a).tooltipster() }).catch(reason => { console.warn(reason) });
				}
				return true;
			}

			for (let a of document.body.querySelectorAll([
				'div.torrent_description > div.body a',
				'table#torrent_details > tbody > tr.torrentdetails > td > blockquote a',
			].join(', '))) if (!noCoverHeres.includes(a.hostname)) setAnchorHandlers(a);
		});
		const table = document.body.querySelector('table.torrent_table');
		if (table != null) {
			const a = table.querySelector(':scope > tbody > tr.colhead > td > a');
			if (a != null) setTableHandlers(table, a.parentNode, '5em');
		}
		break;
	}
	case '/collages.php': {
		if (![20036, 31445].includes(id)) return;
		let userAuth = document.body.querySelector('input[name="auth"]');
		if (userAuth != null) userAuth = userAuth.value; else throw 'User auth could not be located';
		const removeFromCollage = groupId => new Promise(function(resolve, reject) {
			const xhr = new XMLHttpRequest, payLoad = new URLSearchParams({
				action: 'manage_handle',
				collageid: id,
				groupid: groupId,
				auth: userAuth,
				submit: 'Remove',
			});
			xhr.open('POST', 'collages.php', true);
			xhr.onreadystatechange = function() {
				if (xhr.readyState < XMLHttpRequest.DONE) return;
				if (xhr.status >= 200 && xhr.status < 400) resolve(xhr.status); else reject(defaultErrorHandler(xhr));
			};
			xhr.onerror = function() { reject(defaultErrorHandler(xhr)) };
			xhr.ontimeout = function() { reject(defaultTimeoutHandler(xhr)) };
			xhr.send(payLoad);
		});
		const getAllCovers = groupId => groupId > 0 ? new Promise(function(resolve, reject) {
			const xhr = new XMLHttpRequest;
			xhr.open('GET', 'torrents.php?' + new URLSearchParams({ id: groupId }).toString(), true);
			xhr.responseType = 'document';
			xhr.onload = function() {
				if (this.status >= 200 && this.status < 400) resolve(Array.from(this.response.querySelectorAll('div#covers div > p > img'), function(img) {
					if (img.hasAttribute('onclick')) {
						const src = /\blightbox\.init\('(https?:\/\/.+?)',\s*\d+\)/.exec(img.getAttribute('onclick'));
						if (src != null) return src[1];
					}
					return img.src;
				})); else reject(defaultErrorHandler(this));
			};
			xhr.onerror = function() { reject(defaultErrorHandler(xhr)) };
			xhr.ontimeout = function() { reject(defaultTimeoutHandler(xhr)) };
			xhr.send();
		}) : Promise.reject('Invalid argument');
		const openGroup = groupId =>
			{ if (groupId > 0) GM_openInTab(`${document.location.origin}/torrents.php?id=${groupId}`, true) }
		imageHostHelper.then(function(ihh) {
			function fixCollagePage(evt) {
				function getLinks(html) {
					if ((html = domParser.parseFromString(html, 'text/html').getElementsByTagName('A')).length > 0)
						html = Array.prototype.map.call(html, a => a.target == '_blank' ? new URL(a) : null).filter(Boolean);
					return html.length > 0 ? html : null;
				}

				evt.currentTarget.remove();
				const domParser = new DOMParser;
				const bb2Html = bbBody => queryAjaxAPI('preview', undefined, { body: bbBody });
				const autoHideFailed = GM_getValue('auto_hide_failed', false);
				const autoOpenSucceed = GM_getValue('auto_open_succeed', true);
				const autoOpenWithLink = GM_getValue('auto_open_with_link', true);
				document.body.querySelectorAll('table#discog_table > tbody > tr').forEach(function(tr) {
					function setStatus(status, tooltip) {
						if ((td = tr.querySelector('td.status')) == null) return; // assertion failed
						td.textContent = (status = Number(status) || 0) >= 2 ? 'success' : 'failed';
						td.className = 'status ' + td.textContent + ' status-code-' + status;
						if (Array.isArray(tooltip)) tooltip = tooltip.join('\n');
						if (tooltip) td.title = tooltip; else td.removeAttribute('title');
						//setTooltip(td, tooltip);
						td.style.color = ['red', 'orange', '#adad00', 'green'][status];
						td.style.opacity = 1;
						if (status <= 0) if (autoHideFailed) tr.hidden = true;
							else if ((td = document.body.querySelector('span.hide-status-failed')) != null) td.hidden = false;
					}

					const inspectGroupId = groupId => queryAjaxAPI('torrentgroup', { id: groupId }).then(torrentGroup =>
							ihh.verifyImageUrl(torrentGroup.group.wikiImage).then(function(imageUrl) {
						let status = 3, tooltip = ['This release seems to have a valid image'];
						setStatus(status, tooltip);
						const rfc = () => removeFromCollage(groupId).then(function(statusCode) {
							tooltip.push('(removed from collage)');
							setStatus(status, tooltip);
						});
						if (torrentGroup.categoryId == 1) getAllCovers(groupId).then(imageUrls =>
								Promise.all(imageUrls.slice(1).map(ihh.verifyImageUrl)).then(rfc, function(reason) {
							tooltip.push('(invalid additional cover(s) require attention)', reason);
							setStatus(status = 1, tooltip);
						}), function(reason) {
							tooltip.push('Could not count additiona covers (' + reason + ')');
							setStatus(status = 2, tooltip);
						}); else rfc();
						if (new URL(imageUrl).hostname != 'ptpimg.me/') ihh.rehostImageLinks([imageUrl], true, false, true)
								.then(imageUrls => queryAjaxAPI('groupedit', { id: groupId }, {
							image: imageUrls[0],
							summary: 'Automated cover rehost',
						}).then(function(response) {
							tooltip.push('(' + response + ')');
							setStatus(status, tooltip);
							console.log('[Cover Inspector]', response);
						}));
						if (autoOpenSucceed) openGroup(groupId);
					}).catch(function(reason) { // lookup cover in description
						const links = getLinks(torrentGroup.group.wikiBody);
						return links ? Promise.all(links.map(url => ihh.imageUrlResolver(url.href).catch(reason => null)))
								.then(imageUrls => imageUrls.filter(getHostFriendlyName)).then(function(imageUrls) {
							if (imageUrls.length <= 0) return Promise.reject('No of description links could extract a valid image');
							return ihh.rehostImageLinks(imageUrls, true, false, false).then(imageUrls => queryAjaxAPI('groupedit', { id: groupId }, {
								image: imageUrls[0],
								summary: 'Automated attempt to add cover from description link',
							}).then(function(response) {
								const status = 3, tooltip = [response, '(reminder - release may contain additional covers to review)'];
								if (imageUrls.length > 1) {
									status = 2;
									tooltip.push('(more external links in description require attention)');
								}
								setStatus(status, tooltip);
								console.log('[Cover Inspector]', response);
								const rfc = () => removeFromCollage(groupId).then(function(status) {
									tooltip.push('(removed from collage)');
									setStatus(status, tooltip);
								});
								/*if (torrentGroup.categoryId == 1) getAllCovers(groupId).then(imageUrls =>
										Promise.all(imageUrls.slice(1).map(ihh.verifyImageUrl)).then(rfc, function(reason) {
									tooltip.push('(invalid additional cover(s) require attention)');
									setStatus(status = 1, tooltip);
								}), function(reason) {
									tooltip.push('Could not count additiona covers (' + reason + ')');
									setStatus(status = 2, tooltip);
								}); else */rfc();
								if (autoOpenSucceed) openGroup(groupId);
							}));
						}) : Promise.reject('No active external links found in dscriptions');
					}).catch(function(reason) {
						if (!Array.isArray(torrentGroup.torrents) || torrentGroup.torrents.length <= 0)
							return Promise.reject(reason);
						return Promise.all(torrentGroup.torrents.filter(torrent =>
								torrent.description && /\b(?:https?):\/\//i.test(torrent.description)).map(torrent =>
									bb2Html(torrent.description).then(getLinks, reason => null))).then(function(urls) {
							if ((urls = urls.filter(Boolean).map(urls => urls.filter(url =>
									!noCoverHeres.includes(url.hostname))).filter(urls => urls.length > 0)).length <= 0)
								return Promise.reject(reason);
							setStatus(1, 'No active external links in album description,\nbut release descriptions contain some:\n\n' +
								(urls = Array.prototype.concat.apply([ ], urls)).join('\n'));
							console.log('[Cover Inspector] Links found in torrent descriptions for', groupId, ':', urls);
							if (autoOpenWithLink) openGroup(groupId);
						});
					})).catch(reason => { setStatus(0, reason) });

					let td = document.createElement('TD');
					tr.append(td);
					if (tr.classList.contains('colhead_dark')) {
						td.textContent = 'Status';
						const tooltip = 'Result of attempt to add missing/broken cover\nHover the mouse over status for more details';
						td.title = tooltip; //setTooltip(td, tooltip);
					} else if (/^group_\d+$/.test(tr.id)) {
						td.className = 'status';
						td.style.opacity = 0.3;
						td.textContent = 'unknown';
						let groupId = tr.querySelector(':scope > td > strong > a:last-of-type');
						if (groupId != null && (groupId = parseInt(new URLSearchParams(groupId.search).get('id'))) > 0
								|| (groupId = /^group_\d+$/.exec(tr.id)) != null && (groupId = parseInt(groupId[1])) > 0) {
							inspectGroupId(groupId);
						} else setStatus(0, 'Could not extract torrent id');
					}
				});
			}

			const td = document.body.querySelector('table#discog_table > tbody > tr.colhead_dark > td:nth-of-type(3)');
			if (td != null) {
				let elem = document.createElement('SPAN');
				elem.className = 'brackets auto-add-covers';
				elem.textContent = 'Try to add covers';
				elem.style = 'float: right; margin-right: 1em; cursor: pointer; color: gold;';
				elem.onclick = fixCollagePage;
				td.append(elem);
				elem = document.createElement('SPAN');
				elem.className = 'brackets hide-status-failed';
				elem.textContent = 'Hide failed';
				elem.style = 'float: right; margin-right: 1em; cursor: pointer;';
				elem.onclick = function hideFailed(evt) {
					evt.currentTarget.hidden = true;
					document.body.querySelectorAll('table#discog_table > tbody > tr[id^="group_"] td.status.status-code-0')
						.forEach(td => { td.parentNode.hidden = true })
				};
				elem.hidden = true;
				td.append(elem);
			}
		});
		break;
	}
}

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);