Greasy Fork

Greasy Fork is available in English.

[RED] Cover Inspector

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

当前为 2022-06-24 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         [RED] Cover Inspector
// @namespace    http://greasyfork.icu/users/321857-anakunda
// @version      1.13.10
// @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 (http://greasyfork.icu/users/321857-anakunda)
// @license      GPL-3.0-or-later
// @iconURL      https://i.ibb.co/4gpP2J4/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 = {
	'redacted.ch': ['ptpimg.me'],
}[document.domain];
const preferredTypes = ['jpeg', 'webp', '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 getRemoteFileSize(url) {
	if (!httpParser.test(url)) return Promise.reject('Not a valid URL');
	const getByXHR = (method = 'GET') => new Promise(function(resolve, reject) {
		const params = { method: method, url: url, binary: true, responseType: 'blob', headers: { } };
		if (needsUniqueUA(url)) {
			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'))*/;
}

function getRemoteFileType(url) {
	if (!httpParser.test(url)) return Promise.reject('getRemoteFileType: parameter not valid URL');
	const getByXHR = (method = 'GET') => new Promise(function(resolve, reject) {
		const params = { method: method, url: url, headers: { } };
		if (needsUniqueUA(url)) {
			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));
}

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

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;
	}),
	getRemoteFileSize(imageUrl).catch(function(reason) {
		console.warn(`[Cover Inspector] Failed to get remote image size (${imageUrl}):`, reason);
		return null;
	}),
	getRemoteFileType(imageUrl).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 = domParser.parseFromString(descBody, 'text/html').getElementsByTagName('A')).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 && !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(false);
	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: 0; font-size: 6pt; text-align: right;'
			: '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) 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(host, 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 true;
		}).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 false;
		});
	}

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

function imageLookup(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 stripRlsSuffix = title => title && [
		/\s+(?:EP|E\.\s?P\.|-\s+(?:EP|E\.\s?P\.|Single|Live))$/i,
		/\s+\((?:EP|E\.\s?P\.|Single|Live)\)$/i,
		/\s+\[(?:EP|E\.\s?P\.|Single|Live)\]$/i,
		// /\s+\(.+\s(?:Remix|Mix|RMX|Edit)|(?:feat\.|ft\.|featuring\s).+\)$/i,
		// /\s+\[.+\s(?:Remix|Mix|RMX|Edit)|(?:feat\.|ft\.|featuring\s).+\]$/i,
	].reduce((title, rx) => title.replace(rx, ''), title.trim());

	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).catch(reason => null)))
			.then(imageUrls => imageUrls.filter(getHostFriendlyName)).then(imageUrls =>
				imageUrls.length > 0 ? imageUrls : Promise.reject('No covers could be looked up by links in wiki body'));
	}];
	// Ext. lookup at iTunes
	lookupWorkers.push(function getImagesFromUPCs() {
		if (torrentGroup.group.categoryId != 1 || !Array.isArray(torrentGroup.torrents)
				|| torrentGroup.torrents.length <= 0) return Promise.reject('No torrents with UPC');
		let upcs = torrentGroup.torrents.map(function(torrent) {
			let catNos = torrent.remasterCatalogueNumber.split('/').map(catNo => catNo.replace(/\s+/g, ''));
			return (catNos = catNos.filter(RegExp.prototype.test.bind(/^(\d{9,13})$/))).length > 0 ? catNos : null;
		});
		if ((upcs = upcs.filter(Boolean)).length > 0) upcs = Array.prototype.concat.apply([ ], upcs);
			else return Promise.reject('No torrents with UPC');
		return Promise.all(upcs.map(upc => new Promise(function(resolve, reject) {
			if (isNaN(upc = parseInt(upc)) || upc < 1e8) return reject('Cover lookup by UPC code not available');
			const url = new URL('https://itunes.apple.com/lookup');
			url.searchParams.set('upc', upc);
			url.searchParams.set('media', 'music');
			url.searchParams.set('entity', 'album');
			GM_xmlhttpRequest({
				method: 'GET',
				url: url.href,
				headers: { 'X-Requested-With': 'XMLHttpRequest' },
				responseType: 'json',
				onload: function(response) {
					if (response.status >= 200 && response.status < 400) if (response.response.resultCount > 0) {
						let artworkUrls = response.response.results.map(function(result) {
							const imageUrl = result.artworkUrl100 || result.artworkUrl60;
							return imageUrl && imageUrl.replace(/\/(\d+)x(\d+)/, '/100000x100000');
						});
						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)) },
			});
		}).catch(reason => null))).then(artworkUrls => (artworkUrls = artworkUrls.filter(Boolean)).length > 0 ?
			Array.prototype.concat.apply([ ], artworkUrls) : Promise.reject('No covers found by UPC code(s)'));
	});
	// Ext. lookup at MusicBrainz
	const mbSearch = (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()) + '/');
		url.searchParams.set('query', Object.keys(queryParams).map(field =>
			`${field}:"${queryParams[field]}"`).join(' AND '));
		url.searchParams.set('fmt', 'json');
		GM_xmlhttpRequest({
			method: 'GET',
			url: url.href,
			headers: { 'X-Requested-With': 'XMLHttpRequest' },
			responseType: 'json',
			onload: function(response) {
				const getFromR = () => Promise.all(response.response.releases.map(release => getFrontCovers('release', release.id)
						.then(coverImages => coverImages && coverImages[0], 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 getImagesFromUPCs() {
		if (torrentGroup.group.categoryId != 1 || !Array.isArray(torrentGroup.torrents)
				|| torrentGroup.torrents.length <= 0) return Promise.reject('Cover lookup by UPC code not available');
		let upcs = torrentGroup.torrents.map(function(torrent) {
			let catNos = torrent.remasterCatalogueNumber.split('/').map(catNo => catNo.replace(/\s+/g, ''));
			return (catNos = catNos.filter(RegExp.prototype.test.bind(/^(\d{9,13})$/))).length > 0 ? catNos : null;
		});
		if ((upcs = upcs.filter(Boolean)).length > 0) upcs = Array.prototype.concat.apply([ ], upcs);
			else return Promise.reject('No torrents with UPC code');
		return Promise.all(upcs.map(upc => mbSearch('release', { barcode: upc }).catch(reason => null)))
			.then(imageUrls => (imageUrls = imageUrls.filter(Boolean)).length > 0 ? Array.prototype.concat.apply([ ], imageUrls)
				: Promise.reject('No covers found by UPC code'));
	}, function getImagesFromCatNos() {
		if (torrentGroup.group.categoryId != 1 || !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 => mbSearch('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 getImagesFromTitleYear() {
		if (torrentGroup.group.categoryId != 1 || !torrentGroup.group.musicInfo.artists
				|| torrentGroup.group.musicInfo.artists.length <= 0)
			return Promise.reject('Cover lookup by artist/album/year not available');
		return mbSearch('release-group', {
			artistname: torrentGroup.group.musicInfo.artists[0].name,
			releasegroup: stripRlsSuffix(torrentGroup.group.name),
			firstreleasedate: torrentGroup.group.year,
		});
	});
	// Ext. lookup at Discogs, requ. credentials
	if (dcAuth) {
		const dcSearch = (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.href,
				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(result => [Array.isArray(result) ? result[0] : result]);
					}

					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 || 0));
							if (masterIds.size > 1) reject('Ambiguous results');
								else getFromMR(masterIds).then(resolve, getFromResults);
							break;
						case 'master':
							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 getImagesFromUPCs() {
			if (torrentGroup.group.categoryId != 1 || !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, ''));
				return (catNos = catNos.filter(RegExp.prototype.test.bind(/^(\d{9,13})$/))).length > 0 ? catNos : null;
			});
			if ((upcs = upcs.filter(Boolean)).length > 0) upcs = Array.prototype.concat.apply([ ], upcs);
				else return Promise.reject('No torrents with UPC code');
			return Promise.all(upcs.map(upc => dcSearch('release', { barcode: upc }).catch(reason => null))).then(imageUrls =>
				(imageUrls = imageUrls.filter(Boolean)).length > 0 ? Array.prototype.concat.apply([ ], imageUrls)
					: Promise.reject('No covers found by UPC code'));
		}, function getImagesFromCatNos() {
			if (torrentGroup.group.categoryId != 1 || !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 => dcSearch('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 getImagesFromTitleYear() {
			if (torrentGroup.group.categoryId != 1 || !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: stripRlsSuffix(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 dcSearch('master', queryParams);
		});
	}
	return (function lookup(index = 0) {
		if (index < lookupWorkers.length) return lookupWorkers[index]().catch(reason => lookup(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 => imageLookup(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)) 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 imageLookup(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';
					ihh.rehostImageLinks([a.href], true).then(ihh.singleImageGetter).then(rehostedImage => queryAjaxAPI('groupedit', { id: id }, {
						image: rehostedImage,
						summary: 'Cover update',
					}).then(function(response) {
						console.log(response);
						const img = document.body.querySelector('div#covers img');
						if (img != null) setNewSrc(img, rehostedImage); else return document.location.reload();
						inspectImage(img, id);
					})).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;
						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 => imageLookup(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;
	}
}