Greasy Fork

[RED] Cover Inspector

Easify & speed-up of finding and updating of invalid, missing or not optimal album covers on site

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

// ==UserScript==
// @name         [RED] Cover Inspector
// @namespace    https://greasyfork.org/users/321857-anakunda
// @version      1.13.12
// @run-at       document-end
// @description  Easify & speed-up of finding and updating of invalid, missing or not optimal album covers on site
// @author       Anakunda
// @copyright    2020-22, Anakunda (https://greasyfork.org/users/321857-anakunda)
// @license      GPL-3.0-or-later
// @iconURL      https://i.ibb.co/4gpP2J4/clouseau.png
// @match        https://redacted.ch/torrents.php
// @match        https://redacted.ch/torrents.php?*
// @match        https://redacted.ch/artist.php?id=*
// @match        https://redacted.ch/collages.php?id=*
// @match        https://redacted.ch/collages.php?page=*&id=*
// @connect      *
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_openInTab
// @grant        GM_registerMenuCommand
// @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 = {
	'redacted.ch': ['ptpimg.me'],
}[document.domain];
const preferredTypes = ['jpeg', 'gif'];

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 needsUniqueUA(url) {
	if (httpParser.test(url)) try {
		const hostname = new URL(url).hostname;
		return ['dzcdn.', 'mzstatic.com'].some(pattern => hostname.includes(pattern));
	} catch(e) { }
	return false
}
function setUniqueUA(headers, divisor = 2, period = 60 * 60) {
	if (!headers || typeof headers != 'object' || !navigator.userAgent) return;
	period = Math.floor(period * Math.pow(10, 3 - (divisor = Math.floor(divisor))));
	divisor = Math.pow(10, divisor);
	headers['User-Agent'] = navigator.userAgent.replace(/\b(Gecko|\w*WebKit|Blink|Goanna|Flow|\w*HTML|Servo|NetSurf)\/(\d+(\.\d+)*)\b/,
		(match, engine, engineVersion) => engine + '/' + engineVersion + '.' +
			(Math.floor(Date.now() / divisor) % period).toString().padStart(period.toString().length, '0'));
}

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');
		}, 15000, 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 setTooltip(elem, tooltip, params) {
	if (!(elem instanceof HTMLElement)) throw 'Invalid argument';
	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 });
	}).catch(function(reason) {
		if (tooltip) elem.title = tooltip; else elem.removeAttribute('title');
	});
}

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'],
		'7digital': ['7static.com'],
		'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'],
		'Dibpic': ['dibpic.com'],
		'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'],
		'Genius': ['genius.com'],
		'GetaPic': ['getapic.me'],
		'Gifyu': ['gifyu.com'],
		'GooPics': ['goopics.net'],
		'HRA': ['highresaudio.com'],
		'imageCx': ['image.cx'],
		'ImageBan': ['imageban.ru'],
		'ImageKit': ['imagekit.io'],
		'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'],
		'ProgArchives': ['progarchives.com'],
		'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'],
		'VGMdb': ['vgm.io', 'vgmdb.net'],
		'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 noCoverHeres = [
	document.location.hostname,
	'redacted.ch', 'orpheus.network', 'apollo.rip', 'notwhat.cd', 'dicmusic.club', 'what.cd',
	'jpopsuki.eu', 'rutracker.net',
	'github.com', 'gitlab.com',
	'ptpimg.me', 'imgur.com',
].concat(GM_getValue('no_covers_here', [ ]));

const hostsBlacklist = GM_getValue('banned_from_click2go', [ ]);
const bannedFromClick2Go = friendlyHost => friendlyHost && Array.isArray(hostsBlacklist)
	&& hostsBlacklist.some(fh => fh.toLowerCase() == friendlyHost.toLowerCase());
const domParser = new DOMParser;
const autoOpenSucceed = GM_getValue('auto_open_succeed', true);
const autoOpenWithLink = GM_getValue('auto_open_with_link', true);
const hasArtworkSet = img => img instanceof HTMLImageElement && img.src && !img.src.includes('/static/common/noartwork/');
const singleImageGetter = result => Array.isArray(result) ? result[0] : result;

function realImgSrc(img) {
	if (!(img instanceof HTMLImageElement)) throw 'Invalid argument';
	if (img.hasAttribute('onclick')) {
		const src = /\blightbox\.init\('(https?:\/\/.+?)',\s*\d+\)/.exec(img.getAttribute('onclick'));
		if (src != null) return src[1];
	}
	return !img.src.startsWith('https://i.imgur.com/') ? img.src : img.src.replace(/\/(\w{7,})m\.(\w+)$/, '/$1.$2');
}

const openGroup = groupId =>
	{ if (groupId > 0) GM_openInTab(`${document.location.origin}/torrents.php?id=${groupId}`, true) }

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

const getImageDetails = imageUrl => httpParser.test(imageUrl) ? Promise.all([
	new Promise(function(resolve, reject) {
		const image = new Image;
		image.onload = evt => { resolve(evt.currentTarget) };
		image.onerror = evt => { reject(evt.message || 'Image load error (' + evt.currentTarget.src + ')') };
		image.src = imageUrl;
	}), (function getRemoteFileSize() {
		const getByXHR = (method = 'GET') => new Promise(function(resolve, reject) {
			const params = { method: method, url: imageUrl, binary: true, responseType: 'blob', headers: { } };
			if (needsUniqueUA(imageUrl)) {
				params.anonymous = true;
				setUniqueUA(params.headers, 1);
			}
			let size, hXHR = GM_xmlhttpRequest(Object.assign(params, {
				onreadystatechange: function(response) {
					if (size > 0 || response.readyState < XMLHttpRequest.HEADERS_RECEIVED) return;
					size = /^(?:Content-Length)\s*:\s*(\d+)\b/im.exec(response.responseHeaders);
					if (size != null && (size = parseInt(size[1])) > 0) {
						resolve(size);
						if (method != 'HEAD') hXHR.abort();
					} else if (method == 'HEAD') reject('Content size missing or invalid in header');
				},
				onload: function(response) { // fail-safe
					if (size > 0) return; else if (response.status >= 200 && response.status < 400) {
						/*if (response.response) {
							size = response.response.size;
							resolve(size);
						} else */if (response.responseText && (size = response.responseText.length) > 0) resolve(size);
							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'))*/;
	})().catch(function(reason) {
		console.warn(`[Cover Inspector] Failed to get remote image size (${imageUrl}):`, reason);
		return null;
	}), (function getRemoteFileType() {
		const getByXHR = (method = 'GET') => new Promise(function(resolve, reject) {
			const params = { method: method, url: imageUrl, headers: { } };
			if (needsUniqueUA(imageUrl)) {
				params.anonymous = true;
				setUniqueUA(params.headers, 1);
			}
			let contentType, hXHR = GM_xmlhttpRequest(Object.assign(params, {
				onreadystatechange: function(response) {
					if (contentType !== undefined || response.readyState < XMLHttpRequest.HEADERS_RECEIVED) return;
					contentType = /^(?:Content-Type)\s*:\s*(.+?)(?:\s*;(.+?))?\s*$/im.exec(response.responseHeaders);
					if (contentType != null) resolve(contentType[1].toLowerCase()); 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));
	})().catch(function(reason) {
		console.warn(`[Cover Inspector] Failed to get remote image type (${imageUrl}):`, reason);
		return null;
	}),
]).then(results => ({
	src: results[0].src,
	width: results[0].naturalWidth,
	height: results[0].naturalHeight,
	size: results[1],
	mimeType: results[2],
})) : Promise.reject('Void or invalid URL');

const bb2Html = bbBody => queryAjaxAPI('preview', undefined, { body: bbBody });

function getLinks(descBody) {
	if (!descBody) return null;
	if (typeof descBody == 'string') descBody = domParser.parseFromString(descBody, 'text/html');
	if (descBody instanceof Document) descBody = descBody.getElementsByTagName('A'); else throw 'Invalid argument';
	if (descBody.length > 0) descBody = Array.from(descBody, function(a) {
		if (a.href && a.target == '_blank') try { return new URL(a) } catch(e) { console.warn(e) }
		return null;
	}).filter(url => url instanceof URL && !noCoverHeres.includes(url.hostname));
	return descBody.length > 0 ? descBody : null;
}

if ('imageDetailsCache' in sessionStorage) try {
	var imageDetailsCache = JSON.parse(sessionStorage.getItem('imageDetailsCache'));
} catch(e) { console.warn(e) }
if (!imageDetailsCache) imageDetailsCache = { };

function setNewSrc(img, src) {
	if (!(img instanceof HTMLImageElement) || !src) throw 'Invalid argument';
	img.onload = function(evt) {
		if (evt.currentTarget.style.opacity < 1) evt.currentTarget.style.opacity = 1;
		evt.currentTarget.hidden = false;
	}
	img.onerror = evt => { evt.currentTarget.hidden = true };
	if (img.hasAttribute('onclick')) img.removeAttribute('onclick');
	img.onclick = evt => { lightbox.init(evt.currentTarget.src, 220) };
	img.src = src;
}

function inspectImage(img, groupId) {
	if (!(img instanceof HTMLImageElement)) throw 'Invalid argument';
	if (img.parentNode != null) img.parentNode.style.position = 'relative'; else return Promise.resolve(-1);
	for (var inListing = img; inListing != null; inListing = inListing.parentNode) if (inListing.nodeName == 'DIV')
		if (inListing.classList.contains('group_image')) {
			inListing = true;
			break;
		} else if (inListing.classList.contains('box_image')) {
			inListing = false;
			break;
		}
	if (typeof inListing != 'boolean') throw 'Unexpected cover context';
	let isSecondaryCover = !inListing && /^cover_(\d+)$/.test(img.id);
	isSecondaryCover = Boolean(isSecondaryCover) && !(parseInt(isSecondaryCover[1]) > 0);
	if (groupId && isSecondaryCover) groupId = undefined;
	let sticker;

	function editOnClick(elem, lookupFirst = false) {
		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 = function editOnClick(evt) {
			function openEditpage() {
				const url = new URL('torrents.php', document.location.origin);
				url.searchParams.set('action', 'editgroup');
				url.searchParams.set('groupid', groupId);
				if ((evt.shiftKey || evt.ctrlKey) && typeof GM_openInTab == 'function')
					GM_openInTab(url.href, evt.shiftKey); else document.location.assign(url);
			}

			evt.stopPropagation();
			evt.preventDefault();
			if (evt.currentTarget.disabled) return false; else evt.currentTarget.disabled = true;
			//document.getSelection().removeAllRanges();
			if (lookupFirst) findReleaseCover(groupId).catch(openEditpage); else openEditpage();
			return false;
		};
	}

	function setSticker(imageUrl) {
		if ((sticker = img.parentNode.querySelector('div.cover-inspector')) != null) sticker.remove();
		sticker = document.createElement('DIV');
		sticker.className = 'cover-inspector';
		sticker.style = `position: absolute; display: flex; 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 ?
			'flex-flow: column; right: 0; bottom: 0; padding: 1pt 0 2pt; font-size: 6.5pt; text-align: right; line-height: 8pt;'
			: 'flex-flow: row wrap; right: -3pt; bottom: -7pt; padding: 1px; font-size: 8.5pt; max-width: 98%;'}`
		if (isSecondaryCover) sticker.style.bottom = '7pt';

		function span(content, className, isOK = false, tooltip) {
			const span = document.createElement('SPAN');
			if (className) span.className = className;
			span.style = `padding: 0 ${inListing ? '2px' : '4px'};`;
			if (!isOK) span.style.color = 'yellow';
			span.textContent = content;
			if (tooltip) setTooltip(span, tooltip);
			return span;
		}

		return (function() {
			if (!imageUrl) return Promise.reject('Void image URL');
			if (!httpParser.test(imageUrl)) return Promise.reject('Invalid image URL');
			if (imageUrl in imageDetailsCache) return Promise.resolve(imageDetailsCache[imageUrl]);
			return getImageDetails(imageUrl);
		})().then(function(imageDetails) {
			function isOutside(target, related) {
				if (target instanceof HTMLElement) {
					target = target.parentNode;
					while (related instanceof HTMLElement) if ((related = related.parentNode) == target) return false;
				}
				return true;
			}
			function addStickerItems(direction = 1, ...elements) {
				if (direction && elements.length > 0) direction = direction > 0 ? 'append' : 'prepend'; else return;
				if (!inListing) for (let element of direction == 'append' ? elements : elements.reverse()) {
					if (sticker.firstChild != null) sticker[direction]('/');
					sticker[direction](element);
				} else sticker[direction](...elements);
			}

			if (!(imageUrl in imageDetailsCache)) {
				imageDetailsCache[imageUrl] = imageDetails;
				try { sessionStorage.setItem('imageDetailsCache', JSON.stringify(imageDetailsCache)) }
					catch(e) { console.warn(e) }
			}
			imageDetails.src = new URL(imageDetails.src || imageUrl);
			if (imageDetails.width <= 0 || imageDetails.height <= 0
					|| imageDetails.size < 2 * 2**10 && imageDetails.width == 400 && imageDetails.height == 100
					|| imageDetails.size == 503) return Promise.reject('Image is invalid');
			const isProxied = imageDetails.src.hostname == document.location.hostname
				&& imageDetails.src.pathname == '/image.php';
			const isPreferredHost = Array.isArray(preferredHosts) && preferredHosts.includes(imageDetails.src.hostname);
			const isSizeOK = acceptableSize == 0 || imageDetails.size <= acceptableSize * 2**10;
			const isResolutionOK = acceptableResolution == 0 || ((document.location.pathname == '/artist.php'
				|| imageDetails.width >= acceptableResolution) && imageDetails.height >= acceptableResolution);
			const isTypeOK = !imageDetails.mimeType || preferredTypes.some(format => imageDetails.mimeType == 'image/' + format);
			sticker.onmouseenter = img.onmouseenter = evt => { sticker.style.opacity = 1 };
			const friendlyHost = getHostFriendlyName(imageDetails.src.href);
			const resolution = span(imageDetails.width + '×' + imageDetails.height, 'resolution', isResolutionOK),
						size = span(formattedSize(imageDetails.size), 'size', isSizeOK),
						type = span(imageDetails.mimeType, 'mime-type', isTypeOK);
			addStickerItems(1, resolution, size);
			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 (imageDetails.mimeType) addStickerItems(1, type);
			} else {
				sticker.style.backgroundColor = '#ae2300';
				sticker.style.opacity = 2/3;
				sticker.onmouseleave = img.onmouseleave =
					evt => { if (isOutside(evt.currentTarget, evt.relatedTarget)) sticker.style.opacity = 2/3 };
				if (inListing && groupId > 0) editOnClick(sticker);
				let host, convert;
				if (isProxied) {
					host = span('PROXY', 'proxy');
					try {
						imageDetails.src = new URL(imageDetails.src.searchParams.get('i')
							|| imageDetails.src.searchParams.values().next().value
							|| decodeURIComponent(imageDetails.src.search.slice(1)));
					} catch(e) { console.warn(e) }
				} else if (!isPreferredHost) {
					host = span(friendlyHost || 'XTRN', 'external-host', false, 'Image at unpreferred image host')
					if (bannedFromClick2Go(friendlyHost)) host.style.color = 'orange';
				}
				if (host instanceof HTMLElement) addStickerItems(-1, host);
				if (!isTypeOK) addStickerItems(1, type);
				if (!isSizeOK) convert = size; else if (!isTypeOK && isResolutionOK) convert = type;
				if (groupId > 0 && (!inListing || !bannedFromClick2Go(friendlyHost))) imageHostHelper.then(function(ihh) {
					function setClick2Go(elem, clickHandler, tooltip) {
						if (!(elem instanceof HTMLElement) || typeof clickHandler != 'function') throw 'Invalid argument';
						elem.style.cursor = 'pointer';
						elem.classList.add('click2go');
						elem.style.transitionDuration = '0.25s';
						elem.onmouseenter = evt => { evt.currentTarget.style.textShadow = '0 0 5px lime' };
						elem.onmouseleave = evt => { evt.currentTarget.style.textShadow = null };
						elem.onclick = clickHandler;
						if (tooltip) setTooltip(elem, tooltip);
					}

					if (host instanceof HTMLElement) setClick2Go(host, function(evt) {
						evt.stopPropagation();
						if (evt.currentTarget.disabled) return false; else evt.currentTarget.disabled = true;
						host = evt.currentTarget;
						img.style.opacity = 0.3;
						getImageMax(imageDetails.src.href).then(maxImgUrl => ihh.rehostImageLinks(maxImgUrl, true).then(ihh.singleImageGetter))
							.then(rehostedImgUrl => queryAjaxAPI('groupedit', { id: groupId }, {
								image: rehostedImgUrl,
								summary: 'Cover rehost',
							}).then(function(response) {
								console.log(response);
								setNewSrc(img, rehostedImgUrl);
								setSticker(rehostedImgUrl);
							})).catch(function(reason) {
								if ('logFail' in ihh) ihh.logFail(reason);
								img.style.opacity = 1;
								host.disabled = false;
							});
					}, 'Hosted at ' + imageDetails.src.hostname + '\n(rehost to preferred image host on click)');
					else if (convert instanceof HTMLElement) setClick2Go(convert, function(evt) {
						evt.stopPropagation();
						if (evt.currentTarget.disabled) return false; else evt.currentTarget.disabled = true;
						convert = evt.currentTarget;
						img.style.opacity = 0.3;
						ihh.reduceImageSize(imageDetails.src.href, 2160, 90)
							.then(output => ihh.rehostImages([output.uri]).then(ihh.singleImageGetter)
							.then(rehostedImgUrl => queryAjaxAPI('groupedit', { id: groupId }, {
								image: rehostedImgUrl,
								summary: 'Cover downsize', //+ ' (' + formattedSize(imageDetails.size) + ' => ' + formattedSize(output.size) + ')',
							}).then(function(response) {
								console.log(response);
								setNewSrc(img, rehostedImgUrl);
								setSticker(rehostedImgUrl);
							}))).catch(function(reason) {
								if ('logFail' in ihh) ihh.logFail(reason);
								img.style.opacity = 1;
								convert.disabled = false;
							});
					}, 'Downsize on click');
				});
			}
			sticker.title = imageDetails.src.href; //setTooltip(sticker, imageDetails.src.href);
			img.insertAdjacentElement('afterend', sticker);
			return 2;
		}).catch(function(reason) {
			img.hidden = true;
			sticker.style = `position: static; text-align: center; background-color: red; width: ${inListing ? '90px' : '100%'}; font-family: "Segoe UI", sans-serif; font-weight: 700; z-index: 1;`;
			sticker.append(span('INVALID'));
			if (groupId > 0 && !isSecondaryCover) editOnClick(sticker, true);
			setTooltip(sticker, reason);
			img.insertAdjacentElement('afterend', sticker);
			return 1;
		});
	}

	if (groupId > 0) 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) {
				sticker = evt.currentTarget.parentNode.querySelector('div.cover-inspector');
				if (sticker != null) sticker.disabled = true;
				img.style.opacity = 0.3;
				endPoint([items[0]], true, false, true, {
					ctrlKey: evt.ctrlKey,
					shiftKey: evt.shiftKey,
					altKey: evt.altKey,
				}).then(ihh.singleImageGetter).then(imageUrl => queryAjaxAPI('groupedit', { id: groupId }, {
					image: imageUrl,
					summary: 'Cover update',
				}).then(function(response) {
					console.log(response);
					setNewSrc(img, imageUrl);
					setSticker(imageUrl);
				})).catch(function(reason) {
					if ('logFail' in ihh) ihh.logFail(reason);
					if (sticker != null) sticker.disabled = false;
					img.style.opacity = 1;
				});
			}

			evt.stopPropagation();
			let 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(ihh.rehostImageLinks);
			} else if (evt.dataTransfer.files.length > 0) {
				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;
		};
	});
	if (hasArtworkSet(img)) return setSticker(realImgSrc(img));
	if (groupId > 0) editOnClick(img, true);
	return Promise.resolve(0);
}

function coverLookup(torrentGroup, ihh) {
	if (!torrentGroup || !ihh) throw 'Invalid argument';
	const dcApiToken = GM_getValue('discogs_api_token'),
				dcApiConsumerKey = GM_getValue('discogs_api_consumerkey'),
				dcApiConsumerSecret = GM_getValue('discogs_api_consumersecret');
	const dcAuth = dcApiToken ? 'token=' + dcApiToken : dcApiConsumerKey && dcApiConsumerSecret ?
		`key=${dcApiConsumerKey}, secret=${dcApiConsumerSecret}` : null;
	const bareRecordLabel = label => label && label.replace(/\s+(?:Records|Recordings)$/i, '');
	const bareReleaseTitle = title => title && [
		/\s+(?:EP|E\.\s?P\.|-\s+(?:EP|E\.\s?P\.))$/i,
		/\s+\((?:EP|E\.\s?P\.|Live)\)$/i, /\s+\[(?:EP|E\.\s?P\.|Live)\]$/i,
		///\s+\((?:feat\.|ft\.|featuring\s).+\)$/i, /\s+\[(?:feat\.|ft\.|featuring\s).+\]$/i,
	].reduce((title, rx) => title.replace(rx, ''), title.trim());

	// Extract from desc. links
	const lookupWorkers = [function getImagesFromWikiBody() {
		const links = getLinks(torrentGroup.group.wikiBody);
		if (!links) return Promise.reject('No active external links found in dscriptions');
		return Promise.all(links.map(url => ihh.imageUrlResolver(url.href).then(singleImageGetter, reason => null)))
			.then(imageUrls => (imageUrls = imageUrls.filter(imageUrl =>
				torrentGroup.group.categoryId == 1 ? getHostFriendlyName(imageUrl) : imageUrl)).length > 0 ? imageUrls
					: Promise.reject('No cover images could be extracted from links in wiki body'));
	}];
	// Ext. lookup at iTunes
	if (torrentGroup.group.categoryId == 1) {
		const apiQuery = (endpoint, queryParams, noAmbiguity = false) => endpoint && queryParams ? new Promise(function(resolve, reject) {
			endpoint = new URL(endpoint.toLowerCase(), 'https://itunes.apple.com');
			for (let field in queryParams) endpoint.searchParams.set(field, queryParams[field]);
			endpoint.searchParams.set('media', 'music');
			endpoint.searchParams.set('entity', 'album');
			GM_xmlhttpRequest({
				method: 'GET',
				url: endpoint,
				headers: { 'X-Requested-With': 'XMLHttpRequest' },
				responseType: 'json',
				onload: function(response) {
					if (response.status >= 200 && response.status < 400) if (response.response.resultCount > 0) {
						let results = response.response.results;
						if (endpoint.pathname != '/lookup' && (results = results.filter(function(result) {
							let releaseYear = new Date(result.releaseDate);
							if (!isNaN(releaseYear)) releaseYear = releaseYear.getFullYear(); else return false;
							return torrentGroup.torrents.some(function(torrent) {
								if (torrent.fileCount < result.trackCount || torrent.remasterYear != releaseYear) return false;
								const audioFileCount = torrent.fileList.split('|||').filter(file =>
									/^(.+\.(?:flac|mp3|m4[ab]|aac|dts(?:hd)?|truehd|ac3|ogg|opus|wv|ape))\{{3}(\d+)\}{3}$/i.test(file)).length;
								return audioFileCount == result.trackCount;
							});
						})).length <= 0) return reject('No matches'); else if (results.length > 1) {
							console.info('[Cover Inspector] Ambiguous iTunes results for lookup query (endpoint=%s, queryParams=%o)',
								endpoint.pathname, queryParams);
							if (noAmbiguity) return reject('Ambiguous results');
						}
						let artworkUrls = results.map(function(result) {
							const imageUrl = result.artworkUrl100 || result.artworkUrl60;
							return imageUrl && imageUrl.replace(/\/(\d+)x(\d+)/, '/10000x10000');
						});
						if ((artworkUrls = artworkUrls.filter(Boolean)).length > 0) resolve(artworkUrls); else reject('No matches');
					} else reject('No matches'); else reject(defaultErrorHandler(response));
				},
				onerror: response => { reject(defaultErrorHandler(response)) },
				ontimeout: response => { reject(defaultTimeoutHandler(response)) },
			});
		}) : Promise.reject('Invalid argument');
		lookupWorkers.push(function lookupCoversByUPC() {
			if (!Array.isArray(torrentGroup.torrents) || torrentGroup.torrents.length <= 0)
				return Promise.reject('Cover lookup by UPC not available');
			let upcs = torrentGroup.torrents.map(function(torrent) {
				let catNos = torrent.remasterCatalogueNumber.split('/').map(catNo => catNo.replace(/\s+/g, ''));
				catNos = catNos.filter(RegExp.prototype.test.bind(/^(\d{9,13})$/)).map(catNo => parseInt(catNo));
				return (catNos = catNos.filter(catNo => catNo >= 1e8)).length > 0 ? catNos : null;
			});
			if ((upcs = upcs.filter(Boolean)).length <= 0) return Promise.reject('No torrents with UPC');
			upcs = Array.prototype.concat.apply([ ], upcs);
			return Promise.all(upcs.map(upc => apiQuery('lookup', { upc: upc }).catch(reason => null))).then(artworkUrls =>
				(artworkUrls = artworkUrls.filter(Boolean)).length > 0 ? Array.prototype.concat.apply([ ], artworkUrls)
					: Promise.reject('No covers found by UPC'));
		}, function lookupCoversByTitleYear() {
			if (torrentGroup.group.releaseType == 7 || !torrentGroup.group.musicInfo.artists
					|| torrentGroup.group.musicInfo.artists.length <= 0)
				return Promise.reject('Cover lookup by artist/title/year not available');
			let artistNames = torrentGroup.group.musicInfo.artists.slice(0, 3).map(artist => artist.name),
					albumTitle = bareReleaseTitle(torrentGroup.group.name);
			const weakTerm = artistNames.join(' & ').toLowerCase() == albumTitle.toLowerCase()
				|| artistNames.join(', ').length + albumTitle.length < 15;
			artistNames = artistNames.map(artistName => '"' + artistName + '"').join(' ');
			return apiQuery('search', { term: artistNames + ' "' + albumTitle + '"'/*, attribute: 'mixTerm'*/ }, weakTerm);
		});
	}
	// Ext. lookup at MusicBrainz
	if (torrentGroup.group.categoryId == 1) {
		const search = (type, queryParams) => type && queryParams ? new Promise(function(resolve, reject) {
			const getFrontCovers = (type, id) => type && id ? new Promise(function(resolve, reject) {
				GM_xmlhttpRequest({
					method: 'GET',
					url: 'http://coverartarchive.org/' + type + '/' + id,
					headers: { 'X-Requested-With': 'XMLHttpRequest' },
					responseType: 'json',
					onload: function(response) {
						if (response.status >= 200 && response.status < 400) {
							if (!response.response.images || response.response.images.length <= 0) return reject('No artwork for this id');
							let coverImages = response.response.images.filter(image =>
								image.front || image.types && image.types.includes('Front'));
							//if (coverImages.length <= 0) coverImages = response.response.images;
							coverImages = coverImages.map(image => image.image).filter(Boolean);
							if (coverImages.length > 0) resolve(coverImages); else reject('No front cover for this id');
						} else reject(defaultErrorHandler(response));
					},
					onerror: response => { reject(defaultErrorHandler(response)) },
					ontimeout: response => { reject(defaultTimeoutHandler(response)) },
				});
			}) : Promise.reject('Invalid argument');

			const url = new URL('http://musicbrainz.org/ws/2/' + (type = type.toLowerCase()) + '/');
			queryParams = Object.keys(queryParams).map(field => `${field}:"${queryParams[field]}"`).join(' AND ');
			url.searchParams.set('query', queryParams);
			url.searchParams.set('fmt', 'json');
			GM_xmlhttpRequest({
				method: 'GET',
				url: url,
				headers: { 'X-Requested-With': 'XMLHttpRequest' },
				responseType: 'json',
				onload: function(response) {
					const getFromR = () => Promise.all(response.response.releases.map(release =>
							getFrontCovers('release', release.id).then(singleImageGetter, reason => null))).then(function(coverImages) {
						if ((coverImages = coverImages.filter(Boolean)).length > 0) resolve(coverImages);
							else reject('None of results has cover');
					}, reject);
					function getFromRG(releaseGroupIds) {
						if (!releaseGroupIds || releaseGroupIds.size <= 0) return Promise.reject('No matches');
						if (releaseGroupIds.size > 1) return Promise.reject('Ambiguous results');
						releaseGroupIds = releaseGroupIds.values().next().value;
						return releaseGroupIds ? getFrontCovers('release-group', releaseGroupIds) : Promise.reject('No release group');
					}

					if (response.status >= 200 && response.status < 400) if (response.response.count > 0) switch (type) {
						case 'release':
							var releaseGroupIds = new Set(response.response.releases.map(release =>
								release['release-group'] && release['release-group'].id));
							if (releaseGroupIds.size > 1) reject('Ambiguous results');
								else getFromRG(releaseGroupIds).then(resolve, getFromR);
							break;
						case 'release-group':
							releaseGroupIds = new Set(response.response['release-groups'].map(releaseGroup => releaseGroup.id));
							getFromRG(releaseGroupIds).then(resolve, reject);
							break;
						default: reject('Unsupported search type');
					} else reject('No matches'); else reject(defaultErrorHandler(response));
				},
				onerror: response => { reject(defaultErrorHandler(response)) },
				ontimeout: response => { reject(defaultTimeoutHandler(response)) },
			});
		}) : Promise.reject('Invalid argument');
		lookupWorkers.push(function lookupCoversByBarcode() {
			if (!Array.isArray(torrentGroup.torrents) || torrentGroup.torrents.length <= 0)
				return Promise.reject('Cover lookup by barcode not available');
			let barcodes = torrentGroup.torrents.map(function(torrent) {
				let catNos = torrent.remasterCatalogueNumber.split('/').map(catNo => catNo.replace(/\s+/g, ''));
				catNos = catNos.filter(RegExp.prototype.test.bind(/^(\d{9,13})$/)).map(catNo => parseInt(catNo));
				return (catNos = catNos.filter(catNo => catNo >= 1e8)).length > 0 ? catNos : null;
			});
			if ((barcodes = barcodes.filter(Boolean)).length <= 0) return Promise.reject('No torrents with barcode');
			barcodes = Array.prototype.concat.apply([ ], barcodes);
			return Promise.all(barcodes.map(barcode => search('release', { barcode: barcode }).catch(reason => null)))
				.then(imageUrls => (imageUrls = imageUrls.filter(Boolean)).length > 0 ? Array.prototype.concat.apply([ ], imageUrls)
					: Promise.reject('No covers found by barcode'));
		}, function lookupCoversByCatNo() {
			if (!Array.isArray(torrentGroup.torrents) || torrentGroup.torrents.length <= 0)
				return Promise.reject('Cover lookup by label/cat.bo. not available');
			let queryParams = torrentGroup.torrents.map(function(torrent) {
				if (!torrent.remasterRecordLabel || torrent.remasterRecordLabel.includes('/')) return null;
				if (!torrent.remasterCatalogueNumber || torrent.remasterCatalogueNumber.includes('/')) return null;
				return {
					label: bareRecordLabel(torrent.remasterRecordLabel),
					catno: torrent.remasterCatalogueNumber,
				};
			});
			if ((queryParams = queryParams.filter(Boolean)).length <= 0)
				return Promise.reject('No torrents with label/cat.no.');
			return Promise.all(queryParams.map(queryParams => search('release', queryParams).catch(reason => null)))
				.then(imageUrls => (imageUrls = imageUrls.filter(Boolean)).length > 0 ? Array.prototype.concat.apply([ ], imageUrls)
					: Promise.reject('No covers found by label/cat.bo.'));
		}, function lookupCoversByTitleYear() {
			if (!torrentGroup.group.musicInfo.artists || torrentGroup.group.musicInfo.artists.length <= 0)
				return Promise.reject('Cover lookup by artist/album/year not available');
			return search('release-group', {
				artistname: torrentGroup.group.musicInfo.artists[0].name,
				releasegroup: bareReleaseTitle(torrentGroup.group.name),
				firstreleasedate: torrentGroup.group.year,
			});
		});
	}
	// Ext. lookup at Discogs, requ. credentials
	if (torrentGroup.group.categoryId == 1 && dcAuth) {
		const search = (type, queryParams) => type && queryParams ? new Promise(function(resolve, reject) {
			const url = new URL('https://api.discogs.com/database/search');
			for (let field in queryParams) url.searchParams.set(field, queryParams[field]);
			if (type) url.searchParams.set('type', type = type.toLowerCase());
			GM_xmlhttpRequest({
				method: 'GET',
				url: url,
				headers: { 'X-Requested-With': 'XMLHttpRequest' },
				responseType: 'json',
				headers: { 'Authorization': 'Discogs ' + dcAuth },
				onload: function(response) {
					function getFromResults() {
						if (!response.response.results || response.response.results.length <= 0) return reject('No matches');
						const coverImages = response.response.results.map(result => result.cover_image)
							.filter(coverImage => coverImage && !coverImage.endsWith('/spacer.gif'));
						if (coverImages.length > 0) resolve(coverImages); else reject('None of results has cover');
					}
					function getFromMR(masterIds) {
						if (!masterIds || masterIds.size <= 0) return Promise.reject('No matches');
						if (masterIds.size > 1) return Promise.reject('Ambiguous results');
						if (!(masterIds = masterIds.values().next().value)) return Promise.reject('No master release');
						return ihh.imageUrlResolver('https://www.discogs.com/master/' + masterIds).then(singleImageGetter);
					}

					if (response.status < 200 || response.status >= 400) reject(defaultErrorHandler(response));
					else if (response.response.results && response.response.results.length > 0) switch (type) {
						case 'release':
							var masterIds = new Set(response.response.results.map(result => result.master_id));
							if (masterIds.size > 1) reject('Ambiguous results');
								else getFromMR(masterIds).then(resolve, getFromResults);
							break;
						case 'master':
							if (response.response.results.length > 1) reject('Ambiguous results'); else getFromResults();
							break;
						default: reject('Unsupported search type');
					} else reject('No matches');
				},
				onerror: response => { reject(defaultErrorHandler(response)) },
				ontimeout: response => { reject(defaultTimeoutHandler(response)) },
			});
		}) : Promise.reject('Invalid argument');
		lookupWorkers.push(function lookupCoversByBarcode() {
			if (!Array.isArray(torrentGroup.torrents) || torrentGroup.torrents.length <= 0)
				return Promise.reject('Cover lookup by barcode not available');
			let barcodes = torrentGroup.torrents.map(function(torrent) {
				let catNos = torrent.remasterCatalogueNumber.split('/').map(catNo => catNo.replace(/\s+/g, ''));
				catNos = catNos.filter(RegExp.prototype.test.bind(/^(\d{9,13})$/)).map(catNo => parseInt(catNo));
				return (catNos = catNos.filter(catNo => catNo >= 1e8)).length > 0 ? catNos : null;
			});
			if ((barcodes = barcodes.filter(Boolean)).length <= 0) return Promise.reject('No torrents with barcode');
			barcodes = Array.prototype.concat.apply([ ], barcodes);
			return Promise.all(barcodes.map(barcode => search('release', { barcode: barcode }).catch(reason => null))).then(imageUrls =>
				(imageUrls = imageUrls.filter(Boolean)).length > 0 ? Array.prototype.concat.apply([ ], imageUrls)
					: Promise.reject('No covers found by barcode'));
		}, function lookupCoversByCatNo() {
			if (!Array.isArray(torrentGroup.torrents) || torrentGroup.torrents.length <= 0)
				return Promise.reject('Cover lookup by label/cat.bo. not available');
			let queryParams = torrentGroup.torrents.map(function(torrent) {
				if (!torrent.remasterRecordLabel || torrent.remasterRecordLabel.includes('/')) return null;
				if (!torrent.remasterCatalogueNumber || torrent.remasterCatalogueNumber.includes('/')) return null;
				const queryParams = {
					label: bareRecordLabel(torrent.remasterRecordLabel),
					catno: torrent.remasterCatalogueNumber,
				};
				switch (torrentGroup.group.releaseType) {
					case 6: // Anthology
					case 7: // Compilation
						queryParams.format = 'Compilation'; break;
				}
				return queryParams;
			});
			if ((queryParams = queryParams.filter(Boolean)).length <= 0)
				return Promise.reject('No torrents with label/cat.no.');
			return Promise.all(queryParams.map(queryParams => search('release', queryParams).catch(reason => null)))
				.then(imageUrls => (imageUrls = imageUrls.filter(Boolean)).length > 0 ? Array.prototype.concat.apply([ ], imageUrls)
					: Promise.reject('No covers found by label/cat.bo.'));
		}, function lookupCoversByTitleYear() {
			if (!torrentGroup.group.musicInfo.artists || torrentGroup.group.musicInfo.artists.length <= 0)
				return Promise.reject('Cover lookup by artist/album/year not available');
			const queryParams = {
				artist: torrentGroup.group.musicInfo.artists[0].name,
				release_title: bareReleaseTitle(torrentGroup.group.name),
				year: torrentGroup.group.year,
			};
			switch (torrentGroup.group.releaseType) {
				case 7: delete queryParams.artist; // Compilation
				case 6: queryParams.format = 'Compilation'; break; // Anthologuy
			}
			return search('master', queryParams);
		});
	}
	return (function lookupMethod(index = 0) {
		if (index < lookupWorkers.length) return lookupWorkers[index]().catch(reason => lookupMethod(index + 1));
		return Promise.reject('Cover lookup by release details found nothing');
	})();
}

const setGroupImage = (groupId, imageUrl) => queryAjaxAPI('groupedit', { id: groupId }, {
	image: imageUrl,
	summary: 'Automated attempt to lookup cover from release details',
});

const findReleaseCover = groupId => groupId > 0 ? imageHostHelper.then(ihh => queryAjaxAPI('torrentgroup', { id: groupId })
		.then(torrentGroup => coverLookup(torrentGroup, ihh).then(imageUrls =>
			ihh.rehostImageLinks(imageUrls[0], true, false, false).then(ihh.singleImageGetter).then(imageUrl =>
				setGroupImage(torrentGroup.group.id, imageUrl).then(function(response) {
	console.log('[Cover Inspector]', response);
	const img = document.body.querySelector('div#covers img');
	if (img != null) setNewSrc(img, imageUrl); else return;
	inspectImage(img, torrentGroup.group.id);
}))))) : Promise.reject('Invalid argument');

function addTableHandlers(table, parent, style) {
	function addHeaderButton(caption, clickHandler, id, tooltip) {
		if (!caption || typeof clickHandler != 'function') return;
		const elem = document.createElement('SPAN');
		if (id) elem.id = id;
		elem.className = 'brackets';
		elem.style = 'margin-right: 5pt; cursor: pointer; font-weight: normal; transition: color 0.25s;';
		elem.textContent = caption;
		elem.onmouseenter = evt => { evt.currentTarget.style.color = 'orange' };
		elem.onmouseleave = evt => { evt.currentTarget.style.color = evt.currentTarget.dataset.color || null };
		elem.onclick = clickHandler;
		if (tooltip) elem.title = tooltip; //setTooltip(tooltip);
		container.append(elem);
	}
	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;
	}

	if (!(table instanceof HTMLElement) || !(parent instanceof HTMLElement)) return;
	const images = table.querySelectorAll('tbody > tr div.group_image > img');
	if (!(images.length > 0)) return;
	const container = document.createElement('DIV');
	container.className = 'cover-inspector';
	if (style) container.style = style;
	addHeaderButton('Inspect all covers', function inspectAll(evt) {
		evt.currentTarget.hidden = true;
		const currentTarget = evt.currentTarget, inspectWorkers = [ ], autoFix = document.getElementById('auto-fix-covers');
		if (autoFix != null) autoFix.hidden = true;
		for (let tr of table.querySelectorAll('tbody > tr.group, tbody > tr.torrent')) {
			const img = tr.querySelector('div.group_image > img');
			if (img != null) inspectWorkers.push(inspectImage(img, getGroupId(tr.querySelector('div.group_info'))));
		}
		(inspectWorkers.length > 0 ? imageHostHelper.then(ihh => Promise.all(inspectWorkers).then(function(results) {
			if (autoFix != null && results.some(result => result == 1)) autoFix.hidden = false;
			currentTarget.id = 'process-all-covers';
			currentTarget.textContent = 'Rehost/reduce all';
			currentTarget.onclick = function processAll(evt) {
				evt.currentTarget.remove();
				for (let elem of table.querySelectorAll('div.cover-inspector > span.click2go:not([disabled])')) elem.click();
			};
			currentTarget.style.color = currentTarget.dataset.color = 'dodgerblue';
			currentTarget.hidden = false;
		})) : Promise.reject('Nothing to rehost')).catch(reason => { currentTarget.remove() });
	}, 'inspect-all-covers');
	imageHostHelper.then(function(ihh) {
		function setCoverFromTorrentGroup(torrentGroup, img) {
			if (!torrentGroup) throw 'Invalid argument';
			return coverLookup(torrentGroup, ihh).then(imageUrls => ihh.rehostImageLinks(imageUrls[0], true, false, false)
					.then(ihh.singleImageGetter).then(imageUrl => setGroupImage(torrentGroup.group.id, imageUrl).then(function(response) {
				if (img instanceof HTMLImageElement) {
					setNewSrc(img, imageUrl);
					inspectImage(img, torrentGroup.group.id);
				}
				console.log('[Cover Inspector]', response);
				if (autoOpenSucceed) openGroup(torrentGroup.group.id);
			}))).catch(reason => autoOpenWithLink && Array.isArray(torrentGroup.torrents)
					&& torrentGroup.torrents.length > 0 ? 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)).length <= 0) return Promise.reject(reason);
				console.log('[Cover Inspector] Links found in torrent descriptions for %d:', torrentGroup.group.id, urls);
				openGroup(torrentGroup.group.id);
			}) : Promise.reject(reason));
		}

		const missingImages = Array.prototype.filter.call(images, img => !hasArtworkSet(img)).length;
		if (images.length <= 0 || missingImages > 0) addHeaderButton('Add missing covers', function autoAdd(evt) {
			evt.currentTarget.remove();
			table.querySelectorAll('tbody > tr.group, tbody > tr.torrent').forEach(function(tr) {
				const groupId = getGroupId(tr.querySelector('div.group_info')), img = tr.querySelector('div.group_image > img');
				if (groupId > 0) if (img instanceof HTMLImageElement) {
					if (!hasArtworkSet(img)) queryAjaxAPI('torrentgroup', { id: groupId })
						.then(torrentGroup => setCoverFromTorrentGroup(torrentGroup, img));
				} else queryAjaxAPI('torrentgroup', { id: groupId }).then(function(torrentGroup) {
					if (!torrentGroup.group.wikiImage) return setCoverFromTorrentGroup(torrentGroup);
				});
			});
		}, 'auto-add-covers', missingImages + ' missing covers');
		addHeaderButton('Fix invalid covers', function autoFix(evt) {
			evt.currentTarget.remove();
			let elem = document.getElementById('auto-add-covers');
			if (elem != null) elem.remove();
			table.querySelectorAll('tbody > tr.group, tbody > tr.torrent').forEach(function(tr) {
				const groupId = getGroupId(tr.querySelector('div.group_info')), img = tr.querySelector('div.group_image > img');
				if (groupId > 0) if (img instanceof HTMLImageElement)
					(hasArtworkSet(img) ? ihh.verifyImageUrl(img.src) : Promise.reject('Not set')).catch(reason =>
						queryAjaxAPI('torrentgroup', { id: groupId }).then(torrentGroup => setCoverFromTorrentGroup(torrentGroup, img)));
				else queryAjaxAPI('torrentgroup', { id: groupId }).then(torrentGroup =>
					ihh.verifyImageUrl(torrentGroup.group.wikiImage).catch(reason => setCoverFromTorrentGroup(torrentGroup)));
			});

		}, 'auto-fix-covers');
	});
	parent.append(container);
}

const params = new URLSearchParams(document.location.search), id = parseInt(params.get('id')) || undefined;

switch (document.location.pathname) {
	case '/artist.php': {
		if (!(id > 0)) break;
		document.body.querySelectorAll('div.box_image > div > img').forEach(inspectImage);
		const table = document.getElementById('discog_table');
		if (table != null) addTableHandlers(table, table.querySelector(':scope > div.box'),
			'display: block; text-align: right;'); //color: cornsilk; background-color: slategrey;'
		break;
	}
	case '/torrents.php': {
		if (id > 0) {
			for (let img of document.body.querySelectorAll('div#covers img')) inspectImage(img, id);
			imageHostHelper.then(function(ihh) {
				function setCoverFromLink(a) {
					console.assert(a instanceof HTMLAnchorElement, 'a instanceof HTMLAnchorElement');
					if (!(a instanceof HTMLAnchorElement)) throw 'Invalid invoker';
					const img = document.body.querySelector('div#covers img');
					ihh.imageUrlResolver(a.href).then(singleImageGetter).then(function(imageUrl) {
						if (img != null) img.style.opacity = 0.3;
						return ihh.rehostImageLinks(imageUrl, true, false, false).then(ihh.singleImageGetter)
								.then(rehostedImage => queryAjaxAPI('groupedit', { id: id }, {
							image: rehostedImage,
							summary: 'Cover update',
						}).then(function(response) {
							console.log(response);
							if (img != null) {
								setNewSrc(img, rehostedImage);
								inspectImage(img, id);
							} else document.location.reload();
						}));
					}).catch(function(reason) {
						ihh.logFail(reason);
						if (img != null && img.style.opacity < 1) img.style.opacity = 1;
					});
				}

				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;
						setTooltip(a, 'Alt + click to set release cover from this URL (or use context menu command)');
					}
					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);

				GM_registerMenuCommand('Auto add cover', () => { findReleaseCover(id).catch(alert) }, 'A');
			});
		} else {
			const table = document.body.querySelector('table.torrent_table');
			if (table != null) {
				const parent = Array.prototype.find.call(table.querySelectorAll(':scope > tbody > tr.colhead > td'),
					td => /^\s*(?:Torrents?|Name)\b/.test(td.textContent));
				if (parent) addTableHandlers(table, parent, 'display: inline-block; margin-left: 3em;');
			}
		}
		break;
	}
	case '/collages.php': {
		if (![20036, 31445].includes(id)) break;
		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'), realImgSrc));
						else reject(defaultErrorHandler(this));
			};
			xhr.onerror = function() { reject(defaultErrorHandler(xhr)) };
			xhr.ontimeout = function() { reject(defaultTimeoutHandler(xhr)) };
			xhr.send();
		}) : Promise.reject('Invalid argument');
		imageHostHelper.then(function(ihh) {
			function fixCollagePage(evt) {
				evt.currentTarget.remove();
				const autoHideFailed = GM_getValue('auto_hide_failed', false);
				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.getElementById('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(torrentGroup.group.id).then(function(statusCode) {
							tooltip.push('(removed from collage)');
							setStatus(status, tooltip);
						});
						if (torrentGroup.group.categoryId == 1) getAllCovers(torrentGroup.group.id).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(ihh.singleImageGetter).then(imageUrl => queryAjaxAPI('groupedit', { id: torrentGroup.group.id }, {
							image: imageUrl,
							summary: 'Automated cover rehost',
						}).then(function(response) {
							tooltip.push('(' + response + ')');
							setStatus(status, tooltip);
							console.log('[Cover Inspector]', response);
						}));
						if (autoOpenSucceed) openGroup(torrentGroup.group.id);
					}).catch(reason => coverLookup(torrentGroup, ihh).then(imageUrls =>
							ihh.rehostImageLinks(imageUrls[0], true, false, false).then(results =>
								results.map(ihh.directLinkGetter)).then(imageUrls =>
									setGroupImage(torrentGroup.group.id, imageUrls[0]).then(function(response) {
						let status = 3;
						const 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(torrentGroup.group.id).then(function(status) {
							tooltip.push('(removed from collage)');
							setStatus(status, tooltip);
						});
						/*if (torrentGroup.group.categoryId == 1) getAllCovers(torrentGroup.group.id).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(torrentGroup.group.id);
					})))).catch(reason => Array.isArray(torrentGroup.torrents) && torrentGroup.torrents.length > 0 ?
							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)).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', torrentGroup, ':', urls);
						if (autoOpenWithLink) openGroup(torrentGroup.group.id);
					}) : Promise.reject(reason))).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) {
				function addButton(caption, clickHandler, id, color = 'currentcolor', visible = true, tooltip) {
					if (!caption || typeof clickHandler != 'function') throw 'Invalid argument';
					const elem = document.createElement('SPAN');
					if (id) elem.id = id;
					elem.className = 'brackets';
					elem.textContent = caption;
					elem.style = `float: right; margin-right: 1em; cursor: pointer; color: ${color};`;
					elem.onclick = clickHandler;
					if (!visible) elem.hidden = true;
					if (tooltip) elem.title = tooltip;
					td.append(elem);
					return elem;
				}

				addButton('Try to add covers', fixCollagePage, 'auto-add-covers', 'gold');
				addButton('Hide failed', function(evt) {
					evt.currentTarget.hidden = true;
					document.body.querySelectorAll('table#discog_table > tbody > tr[id] td.status.status-code-0')
						.forEach(td => { td.parentNode.hidden = true })
				}, 'hide-status-failed', undefined, false);
			}
		});
		break;
	}
}