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-07-05 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 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.13
// @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
// @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=*
// @match        https://redacted.ch/collage.php?id=*
// @match        https://redacted.ch/collage.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');

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);
const replaceableResolution = getPreference('replaceable_cover_resolution', Math.min(acceptableResolution, 250));

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 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 singleResultGetter = 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 => ihh.verifyImageUrl(imageUrl)));
}

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

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

let userAuth = document.body.querySelector('input[name="auth"]');
userAuth = userAuth != null ? userAuth.value : null;

const badCoverCollages = {
	'redacted.ch': [20036, 31445, 31735],
}[document.domain] || [ ];

function addToCollage(collageId, groupId) {
	if (!(collageId > 0) || !(groupId > 0)) throw 'Invalid argument';
	return ajaxApiKey ? queryAjaxAPI('addtocollage', { collageid: collageId }, { groupids: groupId }).then(function(response) {
		if (response.groupsadded.includes(groupId)) return Promise.resolve('Added');
		if (response.groupsrejected.includes(groupId)) return Promise.reject('Rejected');
		if (response.groupsduplicated.includes(groupId)) return Promise.reject('Duplicated');
		return Promise.reject('Unknown status');
	}) : Promise.reject('API key not set');
}

function removeFromCollage(collageId, groupId) {
	if (!(collageId > 0) || !(groupId > 0)) throw 'Invalid argument';
	return userAuth ? new Promise(function(resolve, reject) {
		const xhr = new XMLHttpRequest, payLoad = new URLSearchParams({
			action: 'manage_handle',
			collageid: collageId,
			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);
	}) : Promise.reject('Not supported on this page');
}
function removeFromCollages(groupId) {
	if (!(groupId > 0)) throw 'Invalid argument';
	return Promise.all((replaceableResolution > 0 ? badCoverCollages : badCoverCollages.slice(0, 2))
		.map(collageId => removeFromCollage(collageId, groupId)));
}

const testImageQuality = imageUrl => replaceableResolution > 0 ? getImageDetails(imageUrl)
	.then(imageDetails => Math.min(imageDetails.width, imageDetails.height) < replaceableResolution ?
		Promise.reject('Poor image resolution') : imageDetails.width * imageDetails.height) : Promise.resolve(-1);

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

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 counterDecrement(id) {
	if (!id) throw 'Invalid argument';
	let elem = document.getElementById(id);
	console.assert(elem != null);
	if (elem == null || !(elem.count > 0)) return;
	if (--elem.count > 0) elem.textContent = elem.count; else {
		(elem = elem.parentNode).textContent = 'Batch completed';
		elem.style.color = 'green';
		elem.style.fontWeight = 'bold';
		setTimeout(elem => { elem.remove() }, 5000, elem);
	}
}

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), sticker;
	isSecondaryCover = Boolean(isSecondaryCover) && !(parseInt(isSecondaryCover[1]) > 0);
	if (groupId && isSecondaryCover) groupId = undefined;

	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; justify-content: flex-end;
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);
			const bannedFromClick2Go = Array.isArray(hostsBlacklist) && hostsBlacklist.some(str =>
				(str = str.toLowerCase()) == imageDetails.src.hostname.toLowerCase()
					|| imageDetails.src.hostname.toLowerCase().endsWith('.' + str));
			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);
			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);
			let host, convert, lookup;
			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);
				if (!isResolutionOK) if (groupId > 0 && replaceableResolution > 0 && (imageDetails.width < replaceableResolution
						|| imageDetails.height < replaceableResolution)) lookup = resolution;
					else setTooltip(resolution, 'Poor resolution image');
				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) 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) 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 (lookup instanceof HTMLElement) setClick2Go(lookup, function(evt) {
						evt.stopPropagation();
						if (evt.currentTarget.disabled) return false; else evt.currentTarget.disabled = true;
						lookup = evt.currentTarget;
						img.style.opacity = 0.3;
						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);
							setNewSrc(img, imageUrl);
							setSticker(imageUrl).then(function(status) {
								if ((status & 0b100010) == 0) addToCollage(badCoverCollages[2], torrentGroup.group.id);
							});
							if (inListing && autoOpenSucceed) openGroup(torrentGroup.group.id);
						})))).catch(function(reason) {
							ihh.logFail(`groupId ${groupId} cover lookup failed: ${reason}`);
							addToCollage(badCoverCollages[2], groupId);
							img.style.opacity = 1;
							lookup.disabled = false;
						}).then(() => { counterDecrement('cover-inspector-counter') });
					}, 'Poor resolution image\n(lookup different version on click)');
					else if (host instanceof HTMLElement && (!inListing || !bannedFromClick2Go)) 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 => setGroupImage(groupId, rehostedImgUrl, 'Automated cover rehost').then(function(response) {
								console.log('[Cover Inspector]', response);
								setNewSrc(img, rehostedImgUrl);
								setSticker(rehostedImgUrl);
							})).catch(function(reason) {
								ihh.logFail(`groupId ${groupId} cover rehost failed: ${reason}`);
								img.style.opacity = 1;
								host.disabled = false;
							}).then(() => { counterDecrement('cover-inspector-counter') });
					}, 'Hosted at ' + imageDetails.src.hostname + '\n(rehost to preferred image host on click)');
					else if (convert instanceof HTMLElement && (!inListing || !bannedFromClick2Go)) 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 => output.size < imageDetails.size ?
								ihh.rehostImages([output.uri]).then(ihh.singleImageGetter).then(function(rehostedImgUrl) {
							let summary = 'Automated cover downsize';
							if (!isSizeOK) summary += ` (${formattedSize(imageDetails.size)} → ${formattedSize(output.size)})`;
							return setGroupImage(groupId, rehostedImgUrl, summary).then(function(response) {
								console.log('[Cover Inspector]', response);
								setNewSrc(img, rehostedImgUrl);
								setSticker(rehostedImgUrl);
							});
						}) : Promise.reject('Converted image not smaller')).catch(function(reason) {
							ihh.logFail(`groupId ${groupId} cover downsize failed: ${reason}`);
							img.style.opacity = 1;
							convert.disabled = false;
						}).then(() => { counterDecrement('cover-inspector-counter') });
					}, 'Downsize on click');
				});
			}

			sticker.title = imageDetails.src.href; //setTooltip(sticker, imageDetails.src.href);
			sticker.onmouseenter = img.onmouseenter = evt => { sticker.style.opacity = 1 };
			img.insertAdjacentElement('afterend', sticker);
			const status =  1 << 7 | 1 << 6 | (![host, convert, lookup].some(elem => elem instanceof HTMLElement)) << 5
				| !isProxied << 4 | isPreferredHost << 3 | isSizeOK << 2 | isResolutionOK << 1 | isTypeOK << 0;
			img.dataset.statusFlags = status.toString(2).padStart(8, '0');
			return status;
		}).catch(function(reason) {
			img.hidden = true;
			sticker.style = `
position: static; padding: 10pt; box-sizing: border-box; width: ${inListing ? '90px' : '100%'}; z-index: 1;
text-align: center; background-color: red; font: 700 auto "Segoe UI", sans-serif;
`;
			sticker.append(span('INVALID'));
			if (groupId > 0 && !isSecondaryCover) editOnClick(sticker, true);
			setTooltip(sticker, reason);
			img.insertAdjacentElement('afterend', sticker);
			img.dataset.statusFlags = (1 << 7).toString(2).padStart(8, '0');
			return 1 << 7;
		});
	}

	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 =>
						setGroupImage(groupId, imageUrl, 'Cover update from external link').then(function(response) {
					console.log('[Cover Inspector]', response);
					setNewSrc(img, imageUrl);
					setSticker(imageUrl);
				})).catch(function(reason) {
					ihh.logFail(`groupId ${groupId} cover update failed: ${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);
}

const dcApiRateControl = { }, dcApiRequestsCache = new Map;

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());
	const audioFileCount = torrent => torrent && torrent.fileList ? 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 : 0;

	// 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(singleResultGetter, 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: { 'Accept': 'application/json', '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;
								return audioFileCount(torrent) == 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() { // 1
			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() { // 2
			function addImportance(importance, maxArtists = 3) {
				if (importance && Array.isArray(torrentGroup.group.musicInfo[importance])
						&& torrentGroup.group.musicInfo[importance].length > 0)
					Array.prototype.push.apply(artistNames,
						torrentGroup.group.musicInfo[importance].slice(0, maxArtists).map(artist => artist.name));
			}

			let artistNames = [ ], albumTitle = bareReleaseTitle(torrentGroup.group.name);
			addImportance('dj');
			if (artistNames.length <= 0 && torrentGroup.group.releaseType != 7) {
				addImportance('artists');
				if (torrentGroup.group.tags && torrentGroup.group.tags.includes('classical')) {
					addImportance('conductor');
					//addImportance('composers');
				}
			}
			if (artistNames.length <= 0) return Promise.reject('Cover lookup by artist/title/year not available');
			return apiQuery('search', {
				term: artistNames.map(artistName => '"' + artistName + '"').join(' ') + ' "' + albumTitle + '"',
				attribute: 'mixTerm',
			}, artistNames.join(' & ').toLowerCase() == albumTitle.toLowerCase()
				|| artistNames.join('').length + albumTitle.length < 15);
		});
	}
	// Ext. lookup at MusicBrainz
	if (torrentGroup.group.categoryId == 1) {
		const search = (type, queryParams, strictReleaseMatch = false) => 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: { 'Accept': 'application/json', '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: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
				responseType: 'json',
				onload: function(response) {
					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).then(resolve)
							: Promise.reject('No release group');
					}

					if (response.status >= 200 && response.status < 400) if (response.response.count > 0) switch (type) {
						case 'release': {
							let releases = response.response.releases, releaseGroupIds;
							if (!releases) return reject('No matches (renounced)');
							const getReleaseGroupIds = releases => (releaseGroupIds = new Set(releases.map(release =>
								release['release-group'] && release['release-group'].id)));
							if ((strictReleaseMatch || getReleaseGroupIds(releases).size > 1) && getReleaseGroupIds(releases = releases.filter(function(release) {
								let releaseYear = new Date(release.date);
								if (!isNaN(releaseYear)) releaseYear = releaseYear.getFullYear(); else return false;
								return torrentGroup.torrents.some(function(torrent) {
									if (torrent.fileCount < release['track-count'] || torrent.remasterYear != releaseYear) return false;
									return audioFileCount(torrent) == release['track-count'];
								});
							})).size > 1) reject('Ambiguous results'); else getFromRG(releaseGroupIds).catch(function(reason) {
								if (releases.length > 0) Promise.all(releases.map(release =>
										getFrontCovers('release', release.id).then(singleResultGetter, reason => null))).then(function(frontCovers) {
									if ((frontCovers = frontCovers.filter(Boolean)).length > 0) resolve(frontCovers);
										else reject('None of results has front cover');
								}, reject); else reject('No matches');
							});
							break;
						}
						case 'release-group': {
							let releaseGroups = response.response['release-groups'];
							if (!releaseGroups) return reject('No matches (renounced)');
							getFromRG(new Set(releaseGroups.map(releaseGroup => releaseGroup.id))).catch(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() { // 3
			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() { // 4
			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() { // 5
			let artistName = torrentGroup.group.musicInfo.dj && torrentGroup.group.musicInfo.dj[0];
			if (!artistName) artistName = torrentGroup.group.musicInfo.artists && torrentGroup.group.musicInfo.artists[0];
			if (!artistName) return Promise.reject('Cover lookup by artist/album/year not available');
			return search('release-group', {
				artistname: artistName.name,
				releasegroup: bareReleaseTitle(torrentGroup.group.name),
				firstreleasedate: torrentGroup.group.year,
			});
		});
	}
	// Ext. lookup at Discogs, requ. credentials
	if (torrentGroup.group.categoryId == 1 && dcAuth) {
		function search(type, queryParams, strictReleaseMatch = false) {
			if (!type || !queryParams) throw 'Invalid argument';
			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());
			url.searchParams.sort = 'score';
			url.searchParams.sort_order = 'desc';
			const cacheKey = url.pathname.slice(1) + url.search;
			if (dcApiRequestsCache.has(cacheKey)) return dcApiRequestsCache.get(cacheKey);
			let retryCounter = 0;
			const request = new Promise((resolve, reject) => (function request() {
				const now = Date.now();
				const postpone = () => { setTimeout(request, dcApiRateControl.timeFrameExpiry - now) };
				if (!dcApiRateControl.timeFrameExpiry || now > dcApiRateControl.timeFrameExpiry) {
					dcApiRateControl.timeFrameExpiry = now + 60 * 1000 + 500;
					if (dcApiRateControl.requestDebt > 0) {
						dcApiRateControl.requestCounter = Math.min(60, dcApiRateControl.requestDebt);
						dcApiRateControl.requestDebt -= dcApiRateControl.requestCounter;
						console.assert(dcApiRateControl.requestDebt >= 0, 'dcApiRateControl.requestDebt >= 0');
					} else dcApiRateControl.requestCounter = 0;
				}
				if (++dcApiRateControl.requestCounter <= 60) GM_xmlhttpRequest({
					method: 'GET',
					url: url,
					headers: {
						'Accept': 'application/json',
						'X-Requested-With': 'XMLHttpRequest',
						'Authorization': 'Discogs ' + dcAuth,
					},
					responseType: 'json',
					onload: function(response) {
						function getFromResults(results) {
							if (!results || results.length <= 0) return reject('No matches');
							const coverImages = results.map(result => result.cover_image || singleResultGetter(result.images))
								.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) > 0)) return Promise.reject('No master release');
							return ihh.imageUrlResolver('https://www.discogs.com/master/' + masterIds)
								.then(singleResultGetter).then(resolve);
						}

						let requestsUsed = /^(?:x-discogs-ratelimit):\s*(\d+)\b/im.exec(response.responseHeaders);
						requestsUsed = /^(?:x-discogs-ratelimit-used):\s*(\d+)\b/im.exec(response.responseHeaders);
						if (requestsUsed != null && (requestsUsed = parseInt(requestsUsed[1]) + 1) > dcApiRateControl.requestCounter) {
							dcApiRateControl.requestCounter = requestsUsed;
							dcApiRateControl.requestDebt = Math.max(requestsUsed - 60, 0);
						}
						if (response.status >= 200 && response.status < 400) {
							let results = response.response.results, masterIds;
							if (results && results.length > 0) switch (type) {
								case 'release': {
									function verifiedResult(result) {
										if (!result) return false;
										let releaseYear = new Date(result.year);
										if (!isNaN(releaseYear)) releaseYear = releaseYear.getFullYear(); else return false;
										return torrentGroup.torrents.some(function(torrent) {
											if (!torrent || torrent.remasterYear != releaseYear) return false;
											if (!result.tracklist) return true;
											if (torrent.fileCount < result.tracklist.length) return false;
											return audioFileCount(torrent) == result.tracklist.length;
										});
									}

									const getMasterIds = () => new Set(results.map(result => result.master_id));
									if (strictReleaseMatch)
										results = results.filter(result => result.master_id > 0 ? false : verifiedResult(result));
									else if (getMasterIds().size > 1) results = results.filter(verifiedResult);
									if (results.length > 1) {
										console.info('[Cover Inspector] Ambiguous Discogs results for lookup query (type=%s, queryParams=%o)',
											type, queryParams);
										if (strictReleaseMatch) return reject('Ambiguous results');
									}
									if ((masterIds = getMasterIds()).size > 1) reject('Ambiguous results');
										else getFromMR(masterIds).catch(reason => { getFromResults(results) });
									break;
								}
								case 'master':
									if (results.length > 1) reject('Ambiguous results'); else getFromResults(results);
									break;
								default: reject('Unsupported search type');
							} else reject('No matches');
						} else if (response.status == 429) {
							console.warn(defaultErrorHandler(response), response.response.message, '(' + retryCounter + ')',
								`Rate limit used: ${requestsUsed}/60`);
							postpone();
						} else reject(defaultErrorHandler(response));
					},
					onerror: response => { reject(defaultErrorHandler(response)) },
					ontimeout: response => { reject(defaultTimeoutHandler(response)) },
				}); else postpone();
			})());
			dcApiRequestsCache.set(cacheKey, request);
			return request;
		}

		lookupWorkers.push(function lookupCoversByBarcode() { // 6
			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() { // 7
			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,
				};
				if ([6, 7].includes(torrentGroup.group.releaseType)) queryParams.format = 'Compilation';
				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() { // 8
			let artistName = torrentGroup.group.musicInfo.dj && torrentGroup.group.musicInfo.dj[0];
			if (!artistName && torrentGroup.group.releaseType != 7 && torrentGroup.group.musicInfo.artists)
				artistName = torrentGroup.group.musicInfo.artists[0];
			if (!artistName && torrentGroup.group.releaseType != 7)
				return Promise.reject('Cover lookup by artist/album/year not available');
			const queryParams = { };
			if (artistName) queryParams.artist = artistName.name;
			queryParams.release_title = bareReleaseTitle(torrentGroup.group.name);
			queryParams.year = torrentGroup.group.year;
			if ([6, 7].includes(torrentGroup.group.releaseType)) queryParams.format = 'Compilation';
			queryParams.strict = true; //!artistName
			return search('master', queryParams);
		}, function lookupCoversByTitleRlsYear() { // 9
			let artistName = torrentGroup.group.musicInfo.dj && torrentGroup.group.musicInfo.dj[0];
			if (!artistName && torrentGroup.group.releaseType != 7 && torrentGroup.group.musicInfo.artists)
				artistName = torrentGroup.group.musicInfo.artists[0];
			if (!artistName/* && torrentGroup.group.releaseType != 7*/)
				return Promise.reject('Cover lookup by artist/album/year not available');
			const queryParams = { };
			if (artistName) queryParams.artist = artistName.name;
			queryParams.release_title = bareReleaseTitle(torrentGroup.group.name);
			if ([6, 7].includes(torrentGroup.group.releaseType)) queryParams.format = 'Compilation';
			queryParams.strict = true; //!artistName
			return search('release', queryParams, true);
		});
	}
	return (function lookupMethod(index = 0) {
		if (index < lookupWorkers.length) return lookupWorkers[index]().then(function(results) {
			console.log('[Cover Inspector] Covers lookup successfull for', torrentGroup, ', method index:', index);
			return results;
		}, reason => lookupMethod(index + 1));
		return Promise.reject('None of release identifiers was sufficient to find the cover');
	})();
}

function setGroupImage(groupId, imageUrl, summary = 'Automated attempt to lookup cover from release identifiers') {
	if (!(groupId > 0) || !imageUrl) throw 'Invalid argument';
	return queryAjaxAPI('groupedit', { id: groupId }, { image: imageUrl, summary: summary });
}

function findReleaseCover(groupId) {
	if (!(groupId > 0)) throw 'Invalid argument';
	return 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)
			.then(status => { if ((status & 0b100010) == 0) addToCollage(badCoverCollages[2], torrentGroup.group.id) });
	})))));
}

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);
		return 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;
	}
	function changeToCounter(elem, id) {
		if (!(elem instanceof HTMLElement) || !id) throw 'Invalid argument';
		if (!elem.count) {
			elem.remove();
			return null;
		}
		elem.onclick = null;
		elem.textContent = ' releases remaining';
		elem.style.cursor = 'default';
		elem.removeAttribute('title');
		const counter = document.createElement('SPAN');
		counter.id = id;
		counter.textContent = counter.count = elem.count;
		counter.style.fontWeight = 'bold';
		elem.prepend(counter);
		delete elem.count;
		return elem;
	}

	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) {
		if (!evt.currentTarget.disabled) evt.currentTarget.disabled = true; else return false;
		evt.currentTarget.textContent = '…wait…';
		evt.currentTarget.dataset.color = 'orange';
		const currentTarget = evt.currentTarget, inspectWorkers = [ ], autoFix = document.getElementById('auto-fix-covers');
		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'))));
		}
		if (autoFix != null && inspectWorkers.length > 0) autoFix.hidden = true;
		(inspectWorkers.length > 0 ? imageHostHelper.then(ihh => Promise.all(inspectWorkers).then(function(statuses) {
			if (autoFix != null) if (statuses.some(status => (status >> 6 & 0b11) == 0b10)) {
				autoFix.hidden = false;
				autoFix.count = statuses.filter(status => (status & 1 << 6) == 0).length;
				autoFix.title = autoFix.count.toString() + ' releases to process';
			} else autoFix.remove();
			const click2Gos = () => table.querySelectorAll('div.cover-inspector > span.click2go:not([disabled])');
			if ((currentTarget.count = click2Gos().length) > 0) {
				currentTarget.id = 'process-all-covers';
				currentTarget.onclick = function processAll(evt) {
					changeToCounter(evt.currentTarget, 'cover-inspector-counter');
					for (let elem of click2Gos()) elem.click();
				};
				currentTarget.style.color = currentTarget.dataset.color = 'dodgerblue';
				currentTarget.textContent = 'Rehost/reduce all';
				currentTarget.disabled = false;
				currentTarget.title = currentTarget.count.toString() + ' releases to process';
			} else return Promise.reject('Nothing to process');
		})) : 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).then(function(status) {
						if ((status & 0b100010) == 0) addToCollage(badCoverCollages[2], torrentGroup.group.id);
					}, reason => { console.warn('[Cover Inspector] inspectImage(', img, ') failed with reason', inspectImage) });
				} else testImageQuality(imageUrl).catch(reason => { addToCollage(badCoverCollages[2], torrentGroup.group.id) });
				console.log('[Cover Inspector]', response);
				if (autoOpenSucceed) openGroup(torrentGroup.group.id);
				return imageUrl;
			}))).catch(function(reason) {
				if (autoOpenWithLink && Array.isArray(torrentGroup.torrents) && torrentGroup.torrents.length > 0)
					Promise.all(torrentGroup.torrents.filter(torrent => /\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) openGroup(torrentGroup.group.id);
					});
				ihh.logFail(`groupId ${torrentGroup.group.id} cover lookup failed: ${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) {
			if (evt.currentTarget.count > 0) changeToCounter(evt.currentTarget, 'missing-lookup-counter');
				else 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).then(() => { counterDecrement('missing-lookup-counter') }) });
				} else queryAjaxAPI('torrentgroup', { id: groupId }).then(function(torrentGroup) {
					if (!torrentGroup.group.wikiImage) setCoverFromTorrentGroup(torrentGroup)
						.then(() => { counterDecrement('missing-lookup-counter') });
				});
			});
		}, 'auto-add-covers', missingImages > 0 ? (missingImages + ' covers missing') : undefined).count = missingImages;
		addHeaderButton('Fix invalid covers', function autoFix(evt) {
			if (evt.currentTarget.count > 0) changeToCounter(evt.currentTarget, 'broken-lookup-counter');
				else evt.currentTarget.remove();
			const 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(function(reason) {
						console.log('[Cover Inspector] Invalid or missing cover for groupId %d, reason:', groupId, reason);
						queryAjaxAPI('torrentgroup', { id: groupId }).then(torrentGroup =>
							{ setCoverFromTorrentGroup(torrentGroup, img).then(() => { counterDecrement('broken-lookup-counter') }) });
					});
				else queryAjaxAPI('torrentgroup', { id: groupId }).then(torrentGroup =>
					ihh.verifyImageUrl(torrentGroup.group.wikiImage).catch(function(reason) {
						console.log('[Cover Inspector] Invalid or missing cover for groupId %d, reason:', groupId, reason);
						setCoverFromTorrentGroup(torrentGroup).then(() => { counterDecrement('broken-lookup-counter') });
					}));
			});
		}, '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(singleResultGetter).then(function(imageUrl) {
						if (img != null) img.style.opacity = 0.3;
						return ihh.rehostImageLinks(imageUrl, true, false, false).then(ihh.singleImageGetter)
								.then(rehostedImage => setGroupImage(id, rehostedImage, 'Cover update from description link').then(function(response) {
							console.log(response);
							if (img != null) {
								setNewSrc(img, rehostedImage);
								inspectImage(img, id);
							} else document.location.reload();
						}));
					}).catch(function(reason) {
						ihh.logFail('Setting cover from link source failed: ' + 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':
	case '/collage.php': {
		function getAllCovers(groupId) {
			if (!(groupId > 0)) throw 'Invalid argument';
			return 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();
			});
		}

		if (!badCoverCollages.includes(id)) break;
		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(newStatus, ...addedText) {
						if ((td = tr.querySelector('td.status')) == null) return; // assertion failed
						td.textContent = (status = Number(newStatus) || 0) > 1 ? 'success' : 'failed';
						td.className = 'status ' + td.textContent + ' status-code-' + status;
						if (addedText.length > 0) Array.prototype.push.apply(tooltips, addedText);
						if (tooltips.length > 0) td.title = tooltips.join('\n'); else td.removeAttribute('title');
						//setTooltip(td, tooltips.join('\n'));
						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;
					}

					let status, tooltips = [ ];
					const inspectGroupId = groupId => queryAjaxAPI('torrentgroup', { id: groupId }).then(torrentGroup =>
							ihh.verifyImageUrl(torrentGroup.group.wikiImage).then(imageUrl => testImageQuality(imageUrl).then(function(mpix) {
						setStatus(3, 'This release seems to have a valid image');
						const rfc = () => removeFromCollage(id, torrentGroup.group.id)
							.then(statusCode => { setStatus(status, '(removed from collage)') });
						if (torrentGroup.group.categoryId == 1) getAllCovers(torrentGroup.group.id).then(imageUrls =>
								Promise.all(imageUrls.slice(1).map(ihh.verifyImageUrl)).then(rfc, function(reason) {
							setStatus(1, '(invalid additional cover(s) require attention)', reason);
						}), reason => { setStatus(2, 'Could not count additiona covers (' + reason + ')') }); else rfc();
						if (new URL(imageUrl).hostname != 'ptpimg.me/') ihh.rehostImageLinks(imageUrl, true, false, true)
								.then(ihh.singleImageGetter).then(imageUrl =>
									setGroupImage(torrentGroup.group.id, imageUrl, 'Automated cover rehost').then(function(response) {
							setStatus(status, '(' + response + ')');
							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) {
						setStatus(3, response, '(reminder - release may contain additional covers to review)');
						if (imageUrls.length > 1) setStatus(2, '(more external links in description require attention)');
						console.log('[Cover Inspector]', response);
						if (autoOpenSucceed) openGroup(torrentGroup.group.id);
						const rfc = () => removeFromCollage(id, torrentGroup.group.id)
							.then(statusCode => { setStatus(status, '(removed from collage)') });
						if (id != badCoverCollages[2]) rfc();
						return testImageQuality(imageUrls[0]).then(mpix => { if (id == badCoverCollages[2]) rfc() }, function(reason) {
							if (id == badCoverCollages[2]) return Promise.reject(reason); else {
								setStatus(2, 'However the image resolution is poor');
								addToCollage(badCoverCollages[2], torrentGroup.group.id)
									.then(result => { setStatus(status, '(added to poor quality covers collage)') });
							}
						});
						// 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();
					}))).catch(reason => Array.isArray(torrentGroup.torrents) && torrentGroup.torrents.length > 0 ?
							Promise.all(torrentGroup.torrents.filter(torrent => /\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;
	}
}