Greasy Fork is available in English.

[RED] Cover Inspector

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

נכון ליום 16-02-2023. ראה הגרסה האחרונה.

// ==UserScript==
// @name         [RED] Cover Inspector
// @namespace    https://greasyfork.org/users/321857-anakunda
// @version      1.14.04
// @run-at       document-end
// @description  Easify & speed-up finding, lookup and updating of invalid, missing or non optimal album covers on site
// @author       Anakunda
// @copyright    2023, Anakunda (https://greasyfork.org/users/321857-anakunda)
// @license      GPL-3.0-or-later
// @iconURL      https://i.ibb.co/4gpP2J4/clouseau.png
// @match        https://redacted.ch/torrents.php
// @match        https://redacted.ch/torrents.php?*
// @match        https://redacted.ch/artist.php?id=*
// @match        https://redacted.ch/collages.php?id=*
// @match        https://redacted.ch/collages.php?page=*&id=*
// @match        https://redacted.ch/collage.php?id=*
// @match        https://redacted.ch/collage.php?page=*&id=*
// @match        https://redacted.ch/userhistory.php?action=subscribed_collages
// @match        https://redacted.ch/userhistory.php?page=*&action=subscribed_collages
// @match        https://redacted.ch/top10.php
// @match        https://redacted.ch/top10.php?*
// @match        https://redacted.ch/better.php?method=*
// @match        https://redacted.ch/better.php?page=*&method=*
// @connect      *
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_openInTab
// @grant        GM_registerMenuCommand
// @grant        GM_getTabs
// @grant        GM_saveTab
// @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?:\/\/\S+)$/i;
const preferredHosts = {
	'redacted.ch': ['ptpimg.me'/*, 'i.imgur.com'*/],
}[document.domain];
const preferredTypes = GM_getValue('preferred_types', ['jpeg', 'png', 'gif', 'jpg'].map(type => 'image/' + type));

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

const uaVersions = { };
function setUserAgent(params, suffixLen = 8) {
	if (params && typeof params == 'object' && httpParser.test(params.url)) try {
		const url = new URL(params.url);
		if ([document.location.hostname, 'ptpimg.me'].includes(url.hostname)) return;
		//return ['dzcdn.', 'mzstatic.com'].some(pattern => hostname.includes(pattern));
		params.anonymous = true;
		if (!navigator.userAgent) return;
		if (!uaVersions[url.hostname] || ++uaVersions[url.hostname].usageCount > 16) uaVersions[url.hostname] = {
			versionSuffix: Math.floor(Math.random() * Math.pow(2, suffixLen * 4)).toString(16).padStart(suffixLen, '0'),
			usageCount: 1,
		};
		if (!params.headers) params.headers = { };
		params.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 + '.' + uaVersions[url.hostname].versionSuffix);
	} catch(e) { console.warn('Invalid url:', params.url) }
}

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 = 'imageHostHelper' in unsafeWindow ? unsafeWindow.imageHostHelper ? Promise.resolve(unsafeWindow.imageHostHelper)
		: Promise.reject('Assertion failed: void unsafeWindow.imageHostHelper') : new Promise(function(resolve, reject) {
	function listener(evt) {
		clearTimeout(timeout);
		unsafeWindow.removeEventListener('imageHostHelper', listener);
		//console.log('imageHostHelper exports triggered:', evt);
		if (evt.data) resolve(evt.data); else if (unsafeWindow.imageHostHelper) resolve(unsafeWindow.imageHostHelper);
			else reject('Assertion failed: void unsafeWindow.imageHostHelper');
	}

	unsafeWindow.addEventListener('imageHostHelper', listener);
	const timeout = setTimeout(function() {
		unsafeWindow.removeEventListener('imageHostHelper', listener);
		reject('Timeout reached');
	}, 15000);
});

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

const maxOpenTabs = GM_getValue('max_open_tabs', 25), autoCloseTimeout = GM_getValue('tab_auto_close_timeout', 0);
let openedTabs = [ ], tabsQueueRecovery = [ ], lastOnQueue;
function openTabLimited(endpoint, params, hash) {
	function updateQueueInfo() {
		const id = 'waiting-tabs-counter';
		let counter = document.getElementById(id);
		if (counter == null) {
			if (tabsQueueRecovery.length <= 0) return;
			const queueInfo = document.createElement('DIV');
			queueInfo.style = `
position: fixed; left: 10pt; bottom: 10pt; padding: 5pt; z-index: 999;
font-size: 8pt; color: white; background-color: sienna;
border: thin solid black; box-shadow: 2pt 2pt 5pt black; cursor: default;
	`;
			const tooltip = 'By closing this tab the queue will be discarded';
			if (typeof jQuery.fn.tooltipster == 'function') $(queueInfo).tooltipster({ content: tooltip });
				else queueInfo.title = tooltip;
			counter = document.createElement('SPAN');
			counter.id = id;
			counter.style.fontWeight = 'bold';
			queueInfo.append(counter, ' release group(s) queued to view');
			document.body.append(queueInfo);
		} else if (tabsQueueRecovery.length <= 0) {
			document.body.removeChild(counter.parentNode);
			return;
		}
		counter.textContent = tabsQueueRecovery.length;
	}

	if (typeof GM_openInTab != 'function') return Promise.reject('Not supported');
	if (!endpoint) return Promise.reject('Invalid argument');
	const saveQueue = () => localStorage.setItem('coverInspectorTabsQueue', JSON.stringify(tabsQueueRecovery));
	let recoveryEntry;
	if (maxOpenTabs > 0) {
		tabsQueueRecovery.push(recoveryEntry = { endpoint: endpoint, params: params || null, hash: hash || '' });
		if (openedTabs.length >= maxOpenTabs) updateQueueInfo();
		saveQueue();
	}
	const waitFreeSlot = () => (maxOpenTabs > 0 && openedTabs.length >= maxOpenTabs ?
			Promise.race(openedTabs.map(tabHandler => new Promise(function(resolve) {
		console.assert(!tabHandler.closed);
		if (!tabHandler.closed) tabHandler.resolver = resolve; //else resolve(tabHandler);
	}))) : Promise.resolve(null)).then(function(tabHandler) {
		console.assert(openedTabs.length <= maxOpenTabs);
		const url = new URL(endpoint + '.php', document.location.origin);
		if (params) for (let param in params) url.searchParams.set(param, params[param]);
		if (hash) url.hash = hash;
		(tabHandler = GM_openInTab(url.href, true)).onclose = function() {
			console.assert(this.closed);
			if (this.autoCloseTimer >= 0) clearTimeout(this.autoCloseTimer);
			const index = openedTabs.indexOf(this);
			console.assert(index >= 0);
			if (index >= 0) openedTabs.splice(index, 1);
				else openedTabs = openedTabs.filter(opernGroup => !opernGroup.closed);
			if (typeof this.resolver == 'function') this.resolver(this);
		}.bind(tabHandler);
		if (autoCloseTimeout > 0) tabHandler.autoCloseTimer = setTimeout(tabHandler =>
			{ if (!tabHandler.closed) tabHandler.close() }, autoCloseTimeout * 1000, tabHandler);
		openedTabs.push(tabHandler);
		if (maxOpenTabs > 0) {
			const index = tabsQueueRecovery.indexOf(recoveryEntry);
			console.assert(index >= 0);
			if (index >= 0) tabsQueueRecovery.splice(index, 1);
			updateQueueInfo();
			saveQueue();
		}
		return tabHandler;
	});
	return (lastOnQueue = lastOnQueue instanceof Promise ? lastOnQueue.then(waitFreeSlot) : waitFreeSlot());
}
const openTabParams = { }, tabData = { torrentGroups: { } };
if (GM_getValue('view_group_with_google_search', true)) openTabParams['embed-google-image-search'] = 1;
if (GM_getValue('view_group_with_desc_source', false)) openTabParams['embed-desc-link-source'] = 1;
if (GM_getValue('view_group_with_desc_images', true)) openTabParams['desc-links-image-preview'] = 1;
if (GM_getValue('view_group_presearch_bandcamp', true)) openTabParams['presearch-bandcamp'] = 1;
function openGroup(torrentGroup) {
	if (!torrentGroup) throw 'Invalid argument';
	if (!(torrentGroup.group.id > 0)) return null;
	tabData.torrentGroups[torrentGroup.group.id] = torrentGroup;
	GM_saveTab(tabData);
	return openTabLimited('torrents', Object.assign({ id: torrentGroup.group.id }, openTabParams));
}

function checkSavedRecovery() {
	if ('coverInspectorTabsQueue' in localStorage) try {
		const savedQueue = JSON.parse(localStorage.getItem('coverInspectorTabsQueue'));
		if (!Array.isArray(savedQueue) || savedQueue.length <= 0) return true;
		const unloadedCount = savedQueue.filter(item1 => !tabsQueueRecovery.some(function(item2) {
			if (item1.endpoint != item2.endpoint || item1.hash != item2.hash) return false;
			if ((item1.params == null) != (item2.params == null)) return false;
			return item1.params == null || Object.keys(item1.params).every(key => item2[key] == item1[key])
				&& Object.keys(item2.params).every(key => item1[key] == item2[key]);
		})).length;
		if (unloadedCount <= 0) return true;
		return confirm('Saved queue (' + (unloadedCount < savedQueue.length ? unloadedCount + '/' + savedQueue.length
			: savedQueue.length) + ' tabs to open) will be lost, continue?');
	}catch(e) { console.warn(e) }
	return true;
}

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 fineResolution = getPreference('fine_cover_resolution', 500);
let acceptableResolution = getPreference('acceptable_cover_resolution', 300);
if (fineResolution > 0 && acceptableResolution > fineResolution) acceptableResolution = fineResolution;

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'],
		'AcousticSounds': ['acousticsounds.com'],
		'Abload': ['abload.de'],
		'AllMusic': ['rovicorp.com'],
		'AllThePics': ['allthepics.net'],
		'Amazon': ['media-amazon.com', 'ssl-images-amazon.com', 'amazonaws.com'],
		'AnimeBytes': ['animebytes.tv'],
		'Apple': ['mzstatic.com'],
		'Archive': ['archive.org'],
		'Bandcamp': ['bcbits.com'],
		'Bangumi': ['bgm.tv'],
		'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'],
		'e-onkyo': ['e-onkyo.com'],
		'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'],
		'Goodreads': ['i.gr-assets.com'],
		'GooPics': ['goopics.net'],
		'HDtracks': ['cdn.hdtracks.com'],
		'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'],
		'MixCloud': ['mixcloud.com'],
		'Mobilism': ['mobilism.org'],
		'Mora': ['mora.jp'],
		'MusicBrainz': ['coverartarchive.org'],
		'Naxos': ['cdn.naxos.com'],
		'NetEase': ['126.net'],
		'NoelShack': ['noelshack.com'],
		'OTOTOY': ['ototoy.jp'],
		'Photobucket': ['photobucket.com'],
		'PicaBox': ['picabox.ru'],
		'PicLoad': ['free-picload.com', 'picload.org'],
		'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'],
		'RA': ['residentadvisor.net'],
		'RYM': ['e.snmc.io'],
		'SavePhoto': ['savephoto.ru'],
		'Shopify': ['shopify.com'],
		'Slowpoke': ['slow.pics'],
		'SoundCloud': ['sndcdn.com'],
		'Stereogum': ['stereogum.com'],
		'SM.MS': ['sm.ms', 'loli.net'],
		'Stereogum': ['stereogum.com'],
		'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;
}

function noCoverHere(url) {
	if (!url || !url.protocol.startsWith('http')) return true;
	let str = url.hostname.toLowerCase();
	if ([
		document.location.hostname,
		'redacted.ch', 'orpheus.network', 'apollo.rip', 'notwhat.cd', 'dicmusic.club', 'what.cd',
		'jpopsuki.eu', 'rutracker.net',
		'github.com', 'gitlab.com',
		'db.etree.org', 'youri-egoro', 'dr.loudness-war.info',
		'ptpimg.me', 'imgur.com',
		'2i.cz', 'abload.de', 'allthepics.net', 'bilder-upload.eu', 'casimages.com', 'catbox.moe', 'cubeupload.com',
		'dibpic.com', 'discordapp.net', 'extraimage.org', 'fastpic.ru', 'fastpic.org', 'forumbilder.com', 'freeimage.host',
		'funkyimg.com', 'ge.tt', 'geekpic.net', 'getapic.me', 'gifyu.com', 'goopics.net', 'image.cx', 'imageban.ru',
		'imagekit.io', 'imagensbrasil.org', 'imageride.com', 'imagetot.com', 'imagevenue.com', 'imgbank.cz', 'ibb.co',
		'imgbox.com', 'imgcdn.dev', 'imgoo.com', 'imgpile.com', 'imgsha.com', 'png8.com', 'ipev.ru', 'jerking.empornium.ph',
		'lensdump.com', 'prntscr.com', 'lostpic.net', 'lut.im', 'noelshack.com', 'photobucket.com', 'picabox.ru',
		'free-picload.com', 'pimpandhost.com', 'pinimg.com', 'pixhost.to', 'pomf.cat', 'postimg.cc', 'thesungod.xyz',
		'radikal.ru', 'savephoto.ru', 'slow.pics', 'sm.ms', 'svgshare.com', 'twimg.com', 'upimager.com', 'uupload.ir',
		'vgy.me', 'z4a.net', 'imgchr.com',
	].concat(GM_getValue('no_covers_here', [ ])).some(hostName => hostName
		&& (str == (hostName = hostName.toLowerCase()) || str.endsWith('.' + hostName)))) return true;
	str = url.pathname.toLowerCase();
	const pathParts = {
		'discogs.com': ['artist', 'label', 'user'].map(folder => '/' + folder + '/'),
	};
	for (let domain in pathParts) if ((url.hostname == domain || url.hostname.endsWith('.' + domain))
			&& pathParts[domain].some(pathPart => str.includes(pathPart.toLowerCase()))) return true;
	return false;
}

const hostSubstitutions = {
	'pro.beatport.com': 'www.beatport.com',
};

const musicResourceDomains = [
	'7static.com', 'archive.org', 'bcbits.com', 'beatport.com', 'boomkat.com', 'cloudfront.net', 'coverartarchive.org',
	'discogs.com', 'dzcdn.net', 'ebayimg.com', 'genius.com', 'highresaudio.com', 'i.gr-assets.com', 'junodownload.com',
	'last.fm', 'lastfm.freetls.fastly.net', 'media-amazon.com', 'metal-archives.com', 'mora.jp', 'mzstatic.com',
	'progarchives.com', 'qobuz.com', 'rovicorp.com', 'sndcdn.com', 'ssl-images-amazon.com', 'tidal.com',
	'traxsource.com', 'vgm.io', 'vgmdb.net', 'wikimedia.org', 'residentadvisor.net', 'hdtracks.com', 'acousticsounds.com',
	'naxos.com', 'deejay.de', 'mixcloud.com', 'cdjapan.co.jp', 'ototoy.jp', 'rateyourmusic.com', 'e.snmc.io',
	'thewire.co.uk', 'bgm.tv', '126.net', 'e-onkyo.com', 'stereogum.com',
];

const click2goHostLists = [
	GM_getValue('click2go_blacklist', ['imgur.com', 'amazonaws.com']),
	GM_getValue('click2go_whitelist', musicResourceDomains.concat([
		'discordapp.net', 'forumbilder.com', 'jpopsuki.eu', 'pinimg.com', 'shopify.com', 'twimg.com',
	])),
	GM_getValue('click2go_badlist', ['photobucket.com', 'imgs.onl', 'sli.mg', 'lut.im', 'picload.org']),
].map(click2goHostList => click2goHostList.filter((e, n, a) => a.indexOf(e) == n).sort());
const getDomainListIndex = (domain, listNdx) => domain && Array.isArray(listNdx = click2goHostLists[listNdx]) ?
	(domain = domain.toLowerCase(), listNdx.findIndex(domain2 => (domain2 = domain2.toLowerCase()) == domain
		|| domain.endsWith('.' + domain2))) : -1;
const isOnDomainList = (domain, listNdx) => getDomainListIndex(domain, listNdx) >= 0;

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'; else if (!hasArtworkSet(img)) return '';
	if (img.hasAttribute('onclick')) {
		const src = /\blightbox\.init\('(https?:\/\/.+?)',\s*\d+\)/.exec(img.getAttribute('onclick'));
		if (src != null) try { var imageUrl = new URL(src[1]) } catch(e) { console.warn(e) }
	}
	if (!imageUrl) try { imageUrl = new URL(img.src) } catch(e) {
		console.warn('Invalid IMG source: img.src');
		return undefined;
	}
	if (imageUrl.hostname.endsWith('.imgur.com'))
		imageUrl.pathname = imageUrl.pathname.replace(/\/(\w{7,})m\.(\w+)$/, '/$1.$2');
	return imageUrl.href;
}

function deProxifyImgSrc(imageUrl) {
	if (!imageUrl) throw 'Invalid argument';
	if (httpParser.test(imageUrl)) try {
		imageUrl = new URL(imageUrl);
		if (imageUrl.hostname == document.location.hostname && imageUrl.pathname == '/image.php'
				&& (imageUrl = imageUrl.searchParams.get('i')) && httpParser.test(imageUrl)) return imageUrl;
	} catch (e) { console.warn(e) }
}

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 ihh.verifyImageUrl(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 || typeof imageDetailsCache != 'object') imageDetailsCache = { };

function getImageDetails(imageUrl) {
	if (!imageUrl) throw 'Invalid argument';
	if (!httpParser.test(imageUrl)) return Promise.reject('Invalid URL');
	return imageUrl in imageDetailsCache ? Promise.resolve(imageDetailsCache[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 loading error (' + image.src + ')') };
			image.loading = 'eager';
			image.referrerPolicy = 'same-origin';
			image.src = imageUrl;
		}), (function getRemoteFileSize() {
			const getByXHR = (method = 'GET') => new Promise(function(resolve, reject) {
				const params = { method: method, url: imageUrl, binary: true, timeout: 90e3, responseType: 'blob' };
				setUserAgent(params);
				let size, hXHR = GM_xmlhttpRequest(Object.assign(params, {
					onreadystatechange: function(response) {
						if (size > 0 || response.readyState < XMLHttpRequest.HEADERS_RECEIVED) return;
						if ((size = /^(?:Content-Length)\s*:\s*(\d+)\b/im.exec(response.responseHeaders)) != null && (size = parseInt(size[1])) > 0) {
							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 ((size = response.responseText) && (size = size.length) > 0
									|| (size = response.response) && (size = size.size) > 0) resolve(size);
							else reject('Image content not loaded');
						} 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, timeout: 90e3 };
				setUserAgent(params);
				let contentType, hXHR = GM_xmlhttpRequest(Object.assign(params, {
					onreadystatechange: function(response) {
						if (contentType != undefined || response.readyState < XMLHttpRequest.HEADERS_RECEIVED) return;
						const abort = () => { if (!hXHR) return; if (method != 'HEAD') hXHR.abort(); hXHR = undefined; }
						if (response.status < 200 || response.status >= 400) {
							reject(defaultErrorHandler(response));
							return abort();
						}
						const invalidUrls = [
							'imgur.com/removed.png',
							'gtimg.cn/music/photo_new/T001M000003kfNgb0XXvgV_0.jpg',
							'//discogs.com/8ce89316e3941a67b4829ca9778d6fc10f307715/images/spacer.gif',
							'amazon.com/images/I/31CTP6oiIBL.jpg',
							'amazon.com/images/I/31zMd62JpyL.jpg',
							'amazon.com/images/I/01RmK+J4pJL.gif',
							'/0dc61986-bccf-49d4-8fad-6b147ea8f327.jpg',
							'/ab2d1d04-233d-4b08-8234-9782b34dcab8.jpg',
							'postimg.cc/wkn3jcyn9/image.jpg',
							'tinyimg.io/notfound',
							'hdtracks.com/img/logo.jpg',
							'vgy.me/Dr3kmf.jpg',
							'/images/no-cover.png',
						];
						if (invalidUrls.some(invalidUrl => response.finalUrl.endsWith(invalidUrl))) {
							reject('Dummy image (placeholder): ' + response.finalUrl);
							return abort();
						}
						const invalidEtags = [
							'd835884373f4d6c8f24742ceabe74946',
							'25d628d3d3a546cc025b3685715e065f42f9cbb735688b773069e82aac16c597f03617314f78375d143876b6d8421542109f86ccd02eab6ba8b0e469b67dc953',
							'"55fade2068e7503eae8d7ddf5eb6bd09"',
							'"1580238364"',
							'"rbFK6Ned4SXbK7Fsn+EfdgKVO8HjvrmlciYi8ZvC9Mc"',
							'7ef77ea97052c1abcabeb44ad1d0c4fce4d269b8a4f439ef11050681a789a1814fc7085a96d23212af594b6b2855c99f475b8b61d790f22b9d71490425899efa',
						];
						const Etag = /^(?:Etag)\s*:\s*(.+?)\s*$/im.exec(response.responseHeaders);
						if (Etag != null && invalidEtags.some(etag => etag.toLowerCase() == Etag[1].toLowerCase())) {
							reject('Dummy image (placeholder): ' + response.finalUrl);
							return abort();
						}
						contentType = /^(?:Content-Type)\s*:\s*(.+?)(?:\s*;(.+?))?\s*$/im.exec(response.responseHeaders);
						resolve(contentType != null ? contentType[1].toLowerCase() : null);
						abort();
					},
					onerror: response => { reject(defaultErrorHandler(response)) },
					ontimeout: response => { reject(defaultTimeoutHandler(response)) },
				}));
			});
			return getByXHR('HEAD').catch(reason => /^HTTP error (?:400|403|405|406|416)\b/.test(reason) ?
				getByXHR('GET') : Promise.reject(reason));
		})(),
	]).then(([image, size, mimeType]) => ({
		src: image.src,
		width: image.naturalWidth,
		height: image.naturalHeight,
		size: size,
		mimeType: mimeType,
		localProxy: false,
	})).then(function(imageDetails) {
		if (imageDetails.width <= 0 || imageDetails.height <= 0) return Promise.reject('Zero area');
		const deproxiedSrc = deProxifyImgSrc(imageDetails.src);
		if (deproxiedSrc) return getImageDetails(deproxiedSrc)
			.then(imageDetails => Object.assign({ }, imageDetails, { localProxy: true }));
		// if (imageDetails.size < 2 * 2**10 && imageDetails.width == 400 && imageDetails.height == 100)
		// 	return Promise.reject('Known placeholder image');
		// if (imageDetails.size == 503) return Promise.reject('Known placeholder image');
		if (!(imageUrl in imageDetailsCache)) {
			imageDetailsCache[imageUrl] = imageDetails;
			try { sessionStorage.setItem('imageDetailsCache', JSON.stringify(imageDetailsCache)) }
				catch(e) { console.warn(e) }
		}
		return imageDetails;
	});
}

const bb2Html = bbBody => queryAjaxAPI('preview', undefined, { body: bbBody });
let userAuth = document.body.querySelector('input[name="auth"]');
if (userAuth != null) userAuth = userAuth.value; else if ((userAuth = document.body.querySelector('#nav_logout > a')) != null) {
	userAuth = new URLSearchParams(userAuth.search);
	userAuth = userAuth.get('auth') || null;
}
if (!userAuth) console.warn('[Cover Inspector] Failed to extract user auth key, removal from collages will be unavailable');

const coverRelatedCollages = {
	'redacted.ch': {
		bad: [31445],
		poor: [31735, 33306, 33307, 33308, 33309, 33310, 33311, 33312, 33313, 33314],
		investigate: [20036],
	},
}[document.domain];
const inCoversCollage = (collageIndex, torrentGroup) => coverRelatedCollages
	&& Array.isArray(coverRelatedCollages[collageIndex]) && coverRelatedCollages[collageIndex][0] > 0
	&& torrentGroup && Array.isArray(torrentGroup.group.collages)
	&& torrentGroup.group.collages.some(collage => coverRelatedCollages[collageIndex].includes(collage.id));

function addToCoversCollage(collageIndex, groupId) {
	if (!coverRelatedCollages || !(Array.isArray(coverRelatedCollages[collageIndex]))
			|| !(coverRelatedCollages[collageIndex][0] > 0))
		return Promise.reject('Cover related collages not defined for current site');
	if (!(groupId > 0)) throw 'Invalid argument';
	return ajaxApiKey ? queryAjaxAPI('addtocollage', {
		collageid: coverRelatedCollages[collageIndex][0],
	}, { 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 (this.readyState < XMLHttpRequest.DONE) return;
			if (this.status >= 200 && this.status < 400) resolve(this.status); else reject(defaultErrorHandler(this));
		};
		xhr.onerror = function() { reject(defaultErrorHandler(this)) };
		xhr.ontimeout = function() { reject(defaultTimeoutHandler(this)) };
		xhr.send(payLoad);
	}) : Promise.reject('Not supported on this page');
}
const removeFromCoversCollage = (collageIndex, torrentGroup) => coverRelatedCollages
	&& Array.isArray(coverRelatedCollages[collageIndex]) ? Promise.all(torrentGroup.group.collages.filter(collage =>
		coverRelatedCollages[collageIndex].includes(collage.id)).map(collage =>
			removeFromCollage(collage.id, torrentGroup.group.id))).then(statuses => statuses.length)
	: Promise.reject('Cover related collages not defined for current site');

const testImageQuality = imageUrl => acceptableResolution > 0 ? getImageDetails(imageUrl)
	.then(imageDetails => Math.min(imageDetails.width, imageDetails.height) < acceptableResolution ?
		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').body;
	if (descBody instanceof HTMLElement) 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 {
			const url = new URL(a), hostNorm = url.hostname.toLowerCase();
			if (hostNorm in hostSubstitutions) url.hostname = hostSubstitutions[hostNorm];
			return url;
		} catch(e) { console.warn(e) }
		return null;
	}).filter(url => url instanceof URL && !noCoverHere(url));
	return descBody.length > 0 ? descBody : null;
}
function isMusicResource(imageUrl) {
	if (imageUrl) try {
		imageUrl = new URL(imageUrl);
		const domain = imageUrl.hostname.split('.').slice(-2).join('.').toLowerCase();
		return musicResourceDomains.some(domain2 => domain2.toLowerCase() == domain);
	} catch (e) { console.warn(e) }
	return false;
}

function setGroupImage(groupId, imageUrl, summary = 'Automated attempt to lookup cover') {
	if (!(groupId > 0) || !imageUrl) throw 'Invalid argument';
	return queryAjaxAPI('groupedit', { id: groupId }, { image: imageUrl, summary: summary });
}
function autoLookupSummary(reason) {
	const summary = 'Automated attempt to lookup cover';
	if (/^(?:not set|unset|missing)$/i.test(reason)) reason = 'missing';
		else if (/\b(?:error|timeout)\b/i.test(reason)) reason = 'link broken';
	return reason ? summary + ' (' + reason + ')' : summary;
}

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, tableIndex) {
	if (!id) throw 'Invalid argument';
	let elem = 'div.cover-inspector';
	if (tableIndex) elem += '-' + tableIndex;
	elem += ' span.' + id;
	if ((elem = document.body.querySelector(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(function(elem) {
			elem.style.transition = 'opacity 2s ease-in-out';
			elem.style.opacity = 0;
			setTimeout(elem => { elem.remove() }, 2000, elem);
		}, 4000, 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);
	const inListing = (function() {
		for (let elem = img; elem != null; elem = elem.parentNode) if (elem.tagName == 'DIV') {
			if (elem.classList.contains('group_image')) return true;
			if (elem.classList.contains('box_image')) return false;
		}
		throw 'Unexpected cover context';
	})();
	const isAlternateCover = !inListing && groupId > 0 && (id => (id = /^cover_(\d+)$/.exec(id)) != null && parseInt(id[1]) > 0)(img.id);
	let sticker;

	function editOnClick(elem, lookupFirst = false) {
		if (!(elem instanceof HTMLElement)) return;
		if (!ajaxApiKey) lookupFirst = false;
		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';
		if (elem.hasAttribute('onclick')) elem.removeAttribute('onclick');
		elem.onclick = function(evt) {
			if (evt.currentTarget.disabled) return false; else evt.currentTarget.disabled = true;
			(lookupFirst ? findCover(groupId, img) : Promise.reject('Lookup disabled')).catch(function() {
				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);
			});
			return false;
		};
		if (lookupFirst) imageHostHelper.then(ihh => { setTooltip(img, 'Auto cover lookup on click') });
	}

	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 (isAlternateCover && groupId > 0) 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');
			return getImageDetails(imageUrl);
		})().then(function(imageDetails) {
			function isOutside(evt) {
				console.assert(evt instanceof MouseEvent);
				for (let tgt = evt.relatedTarget; tgt instanceof HTMLElement; tgt = tgt.parentNode)
					if (tgt == evt.currentTarget) 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);
			}

			imageUrl = new URL(imageDetails.src || imageUrl);
			if (imageDetails.localProxy) setNewSrc(img, imageUrl);
			const isPreferredHost = Array.isArray(preferredHosts) && preferredHosts.includes(imageUrl.hostname);
			const isSizeOK = !(acceptableSize > 0) || imageDetails.size <= acceptableSize * 2**10;
			const isResolutionAcceptable = !(acceptableResolution > 0) || ((document.location.pathname == '/artist.php'
				|| imageDetails.width >= acceptableResolution) && imageDetails.height >= acceptableResolution);
			const isResolutionFine = isResolutionAcceptable && (!(fineResolution > 0) || ((document.location.pathname == '/artist.php'
				|| imageDetails.width >= fineResolution) && imageDetails.height >= fineResolution));
			const isTypeOK = !imageDetails.mimeType
				|| preferredTypes.some(type => imageDetails.mimeType.toLowerCase() == type);
			const friendlyHost = getHostFriendlyName(imageUrl.href);
			const resolution = span(imageDetails.width + '×' + imageDetails.height, 'resolution', isResolutionFine),
						size = span(formattedSize(imageDetails.size), 'size', isSizeOK),
						type = span(imageDetails.mimeType, 'mime-type', isTypeOK);
			const domain = imageUrl.hostname.split('.').slice(-2).join('.');
			let host, downsize, lookup;
			addStickerItems(1, resolution, size);
			if (isPreferredHost && isSizeOK && isResolutionFine && isTypeOK) {
				sticker.style.backgroundColor = 'teal';
				sticker.style.opacity = 0;
				sticker.onmouseleave = img.onmouseleave = evt => { if (isOutside(evt)) sticker.style.opacity = 0 };
				if (imageDetails.mimeType) addStickerItems(1, type);
			} else {
				function keyHandlers(evt) {
					if (evt.ctrlKey) evt.stopImmediatePropagation(); else return;
					const listIndexes = click2goHostLists.map((_, listNdx) => getDomainListIndex(imageUrl.hostname, listNdx));
					const dialog = document.createElement('DIALOG');
					dialog.innerHTML = `
<form method="dialog">
<div><span><b>List entry:</b></span>&nbsp;<div class="domain" style="font-family: monospace; display: inline-block;" /></div><br><br>
<div>
	<b>On lists:</b>&nbsp;
	<label style="cursor: pointer;" title="Blacklisted domains are always excluded from batch processing. Doesn't apply to downsizing and lookup tasks."><input name="lists" value="blacklist" type="radio" /> Blacklist</label>&nbsp;
	<label style="cursor: pointer;" title="Whitelisted domains are included in batch processing."><input name="lists" value="whitelist" type="radio" /> Whitelist</label>&nbsp;
	<label style="cursor: pointer;" title="Images at bad domains are always considered invalid and an attempt to lookup new cover image is made."><input name="lists" value="badlist" type="radio" /> Badlist</label>
</div><br>
<div>
	<input value="Update" type="button"><input value="Remove from all lists" type="button"><input value="Close" type="button">
</div>
</form>`;
					dialog.style = 'padding: 1rem; position: fixed; top: 40%; left: 0; right: 0; margin-left: auto; margin-right: auto; z-index: 9999; box-shadow: 2pt 2pt 5pt gray;';
					dialog.onclose = evt => { document.body.removeChild(evt.currentTarget) };
					const radios = dialog.querySelectorAll('input[name="lists"][type="radio"]'),
								buttons = dialog.querySelectorAll('input[type="button"]'),
								domain = dialog.querySelector('div.domain');
					function updateParts(index) {
						domain.dataset.index = index;
						for (let dp of domainParts) {
							const active = parseInt(dp.dataset.index) >= index;
							dp.style.opacity = active ? 1 : 0.5;
							dp.style.fontWeight = active ? 'bold' : 'normal';
							dp.style.color = active ? 'mediumblue' : null;
						}
					}
					imageUrl.hostname.split('.').forEach(function(domainPart, index, arr) {
						const span = document.createElement('SPAN'), notLast = index < arr.length - 1;
						span.textContent = domainPart;
						span.dataset.index = index;
						if (notLast) {
							span.style.cursor = 'pointer';
							span.onclick = evt => { updateParts(parseInt(evt.currentTarget.dataset.index)) };
						}
						domain.append(span);
						if (notLast) domain.append('.');
					});
					const domainParts = domain.getElementsByTagName('SPAN');
					updateParts(Math.min(Math.max(domainParts.length - 2, 0), 1));
					const inList = listIndexes.findIndex(index => index >= 0);
					if (inList >= 0) radios[inList].checked = true; else {
						buttons[0].disabled = true;
						for (let radio of radios) radio.onchange = () => { buttons[0].disabled = false };
					}
					buttons[0].onclick = function(evt) {
						radios.forEach(function(radio, index) {
							if (radio.checked && listIndexes[index] < 0) {
								click2goHostLists[index].push(Array.prototype.slice.call(domainParts, parseInt(domain.dataset.index))
									.map(domainPart => domainPart.textContent).join('.'));
								GM_setValue('click2go_' + radio.value, click2goHostLists[index].sort());
							} else if (!radio.checked && listIndexes[index] >= 0) {
								click2goHostLists[index].splice(listIndexes[index], 1);
								GM_setValue('click2go_' + radio.value, click2goHostLists[index]);
							}
						});
						dialog.close();
					};
					if (inList < 0) buttons[1].style.display = 'none';
					buttons[1].onclick = function(evt) {
						radios.forEach(function(radio, index) {
							if (listIndexes[index] >= 0) {
								click2goHostLists[index].splice(listIndexes[index], 1);
								GM_setValue('click2go_' + radio.value, click2goHostLists[index]);
							}
						});
						dialog.close();
					};
					buttons[2].onclick = evt => { dialog.close() };
					document.body.append(dialog);
					dialog.showModal();
					return false;
				}
				function getHostTooltip() {
					let tooltip = 'Hosted at ' + imageUrl.hostname;
					if (imageDetails.localProxy) tooltip += ' (locally proxied)';
					if (isOnDomainList(imageUrl.hostname, 2)) tooltip += ' (bad host)';
					else if (isOnDomainList(imageUrl.hostname, 0)) tooltip += ' (blacklisted from batch rehosting)';
					else if (isOnDomainList(imageUrl.hostname, 1)) tooltip += ' (whitelisted for batch rehosting)';
					if (isOnDomainList(imageUrl.hostname, 2)) tooltip += '\n(look up different version on simple click)';
					else if (!inListing || !isOnDomainList(imageUrl.hostname, 0))
						tooltip += '\n(rehost to site preferred host on simple click)';
					return tooltip + `

Ctrl + click to manage lists
(Ctrl +) middle click to open (full) image domain in new window`;
				}

				sticker.style.backgroundColor = '#ae2300';
				sticker.style.opacity = 2/3;
				sticker.onmouseleave = img.onmouseleave = evt => { if (isOutside(evt)) sticker.style.opacity = 2/3 };
				if (inListing && !isAlternateCover && groupId > 0) editOnClick(sticker);
				if (!isResolutionFine) if (isResolutionAcceptable) {
					let color = acceptableResolution > 0 ? acceptableResolution : 0;
					color = (Math.min(imageDetails.width, imageDetails.height) - color) / (fineResolution - color);
					color = 0xFFFF90 + Math.round((0xC0 - 0x90) * color);
					resolution.style.color = '#' + color.toString(16);
					setTooltip(resolution, 'Mediocre image quality (resolution)');
				} else if (!isAlternateCover && groupId > 0) lookup = resolution;
				if (!isPreferredHost) {
					host = span(friendlyHost || 'XTRN', 'unclassified-host', false);
					if (imageDetails.localProxy) host.classList.add('local-proxy');
				}
				if (host instanceof HTMLElement) {
					if (isOnDomainList(imageUrl.hostname, 0)) {
						host.style.color = '#ffd';
						if (inListing) host.classList.add('blacklisted-from-click2go');
					} else if (isOnDomainList(imageUrl.hostname, 1)) {
						if (inListing) host.classList.add('whitelisted');
					} else if (!isOnDomainList(imageUrl.hostname, 2)) host.style.color = '#ffa';
					setTooltip(host, getHostTooltip());
					host.onclick = keyHandlers;
					host.onauxclick = function(evt) {
						if (evt.button != 1) return;
						GM_openInTab(evt.ctrlKey ? imageUrl.origin : imageUrl.protocol + '//' + domain, false);
						evt.preventDefault();
						return false;
					};
					addStickerItems(-1, host);
				}
				if (!isTypeOK) {
					type.onclick = function(evt) {
						if (!evt.shiftKey || !confirm(`This will add "${imageDetails.mimeType}" to whitelisted image types`))
							return false;
						preferredTypes.push(imageDetails.mimeType);
						GM_setValue('preferred_types', preferredTypes);
						alert('MIME types whitelist successfully updated. The change will apply on next page load.');
						return false;
					};
					setTooltip(type, 'Shift + click to whitelist mimietype');
					addStickerItems(1, type);
				}
				if (!imageDetails.localProxy && !isSizeOK && imageDetails.mimieType != 'image/gif') downsize = size;
				if (!isAlternateCover && groupId > 0) imageHostHelper.then(function(ihh) {
					function setClick2Go(elem, clickHandler, tooltip) {
						if (!(elem instanceof HTMLElement) || elem.classList.contains('blacklisted-from-click2go')) return null;
						if (typeof clickHandler != 'function') throw 'Invalid argument';
						elem.classList.add('click2go');
						elem.style.cursor = 'pointer';
						elem.style.transitionDuration = '0.25s';
						elem.onmouseenter = elem.onmouseleave = function(evt) {
							if (evt.relatedTarget == evt.currentTarget) return false;
							evt.currentTarget.style.textShadow = evt.type == 'mouseenter' ? '0 0 5px lime' : null;
						};
						elem.onclick = clickHandler;
						if (tooltip) setTooltip(elem, tooltip);
						return elem;
					}

					let summary, tableIndex;
					if ('tableIndex' in img.dataset) tableIndex = parseInt(img.dataset.tableIndex);
					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 =>
								(lookup == resolution ? getImageDetails(imageUrls[0]).catch(reason => null) : Promise.resolve(null)).then(function(newImageDetails) {
							switch (lookup) {
								case resolution:
									console.assert(acceptableResolution > 0);
									if (newImageDetails != null && newImageDetails.width * newImageDetails.height < imageDetails.width * imageDetails.height)
										return Promise.reject(`New cover found in lower resolution (${newImageDetails.width}×${newImageDetails.height})`);
									summary = 'Automated attempt to lookup better quality cover';
									// if (newImageDetails != null)
									// 	summary += ` (${imageDetails.width}×${imageDetails.height} → ${newImageDetails.width}×${newImageDetails.height})`;
									break;
								default:
									summary = 'Automated attempt to lookup cover';
							}
							return ihh.rehostImageLinks([newImageDetails.src]).then(ihh.singleImageGetter, reason => newImageDetails.src)
									.then(imageUrl => setGroupImage(torrentGroup.group.id, imageUrl, summary).then(function(response) {
								console.log('[Cover Inspector]', response);
								setNewSrc(img, imageUrl);
								const status = setSticker(imageUrl);
								if (inListing && autoOpenSucceed) openGroup(torrentGroup);
								if (newImageDetails == null) return status.then(function(status) {
									if ((status & 0b100) == 0) return Promise.reject('Found image in poor resolution');
									if (inCoversCollage('poor', torrentGroup)) removeFromCoversCollage('poor', torrentGroup);
								}); else if (Math.min(newImageDetails.width, newImageDetails.height) < acceptableResolution)
									return Promise.reject('New cover found in poor quality');
								if (inCoversCollage('poor', torrentGroup)) removeFromCoversCollage('poor', torrentGroup);
							}));
						})).catch(function(reason) {
							if (torrentGroup.group.categoryId == 1 && !inCoversCollage('poor', torrentGroup))
								addToCoversCollage('poor', torrentGroup.group.id);
							return Promise.reject(reason);
						})).catch(function(reason) {
							ihh.logFail(`groupId ${groupId} cover lookup failed: ${reason}`);
							img.style.opacity = 1;
							lookup.disabled = false;
						}).then(() => { counterDecrement('process-covers-countdown', tableIndex) });
					}, lookup == resolution ? 'Poor image quality (resolution)' : undefined ) || setClick2Go(downsize, function(evt) {
						evt.stopPropagation();
						if (evt.currentTarget.disabled) return false; else evt.currentTarget.disabled = true;
						downsize = evt.currentTarget;
						img.style.opacity = 0.3;
						ihh.reduceImageSize(imageUrl.href, 2160, 90).then(output => output.size < imageDetails.size ?
								ihh.rehostImages([output.uri]).then(ihh.singleImageGetter).then(function(imageUrl) {
							summary = 'Automated cover downsize';
							if (!isSizeOK) summary += ` (${formattedSize(imageDetails.size)} → ${formattedSize(output.size)})`;
							return setGroupImage(groupId, imageUrl, summary).then(function(response) {
								console.log('[Cover Inspector]', response);
								setNewSrc(img, imageUrl);
								setSticker(imageUrl);
							});
						}) : Promise.reject('Converted image not smaller')).catch(function(reason) {
							ihh.logFail(`groupId ${groupId} cover downsize failed: ${reason}`);
							img.style.opacity = 1;
							downsize.disabled = false;
						}).then(() => { counterDecrement('process-covers-countdown', tableIndex) });
					}, 'Downsize on click') || setClick2Go(host, function(evt) {
						evt.stopImmediatePropagation();
						if (evt.ctrlKey) return keyHandlers(evt);
						if (evt.currentTarget.disabled) return false; else evt.currentTarget.disabled = true;
						host = evt.currentTarget;
						img.style.opacity = 0.3;
						summary = 'Automated cover rehost';
						//summary += ' (' + imageUrl.hostname + ')';
						getImageMax(imageUrl.href).then(maxImgUrl => ihh.rehostImageLinks([maxImgUrl]).then(ihh.singleImageGetter))
							.then(imageUrl => setGroupImage(groupId, imageUrl, summary).then(function(response) {
								console.log('[Cover Inspector]', response);
								setNewSrc(img, imageUrl);
								setSticker(imageUrl);
							})).catch(function(reason) {
								ihh.logFail(`groupId ${groupId} cover rehost failed: ${reason}`);
								img.style.opacity = 1;
								host.disabled = false;
							}).then(() => { counterDecrement('process-covers-countdown', tableIndex) });
					});
				});
			}

			sticker.title = imageUrl.href; //setTooltip(sticker, imageUrl.href);
			sticker.onmouseenter = img.onmouseenter = evt => { sticker.style.opacity = 1 };
			img.insertAdjacentElement('afterend', sticker);
			const status =  1 << 8 | 1 << 7
				| (![host, downsize, lookup].some(elem => elem instanceof HTMLElement)) << 6
				| !imageDetails.localProxy << 5 | isPreferredHost << 4 | isSizeOK << 3
				| isResolutionAcceptable << 2 | isResolutionFine << 1 | isTypeOK << 0;
			img.dataset.statusFlags = status.toString(2).padStart(9, '0');
			return status;
		}).catch(function(reason) {
			img.hidden = true;
			if (groupId > 0) if (!isAlternateCover) editOnClick(sticker, true); else if (GM_getValue('auto_remove_invalid', true)) {
				let div = img.parentNode;
				if (div != null && (div = div.parentNode) != null) {
					let rmCmd = div.querySelector('span.remove_cover_art > a');
					if (rmCmd != null && (rmCmd.parentNode.previousSibling == null || !rmCmd.parentNode.previousSibling.textContent.trim())
							&& typeof rmCmd.onclick == 'function' && (rmCmd = /\bajax\.get\((.+?)\)/.exec(rmCmd.onclick.toString())) != null) {
						eval(rmCmd[0]);
						div.remove();
						return -1;
					}
				}
			}
			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'));
			setTooltip(sticker, reason);
			img.insertAdjacentElement('afterend', sticker);
			img.dataset.statusFlags = (1 << 8).toString(2).padStart(9, '0');
			return 1 << 8;
		});
	}

	if (groupId > 0 && !isAlternateCover) imageHostHelper.then(function(ihh) {
		img.classList.add('drop');
		img.ondragover = evt => false;
		if (img.clientWidth > 100) img.ondragenter = img[`ondrag${'ondragexit' in img ? 'exit' : 'leave'}`] = function(evt) {
			if (evt.relatedTarget == evt.currentTarget) return false;
			evt.currentTarget.parentNode.parentNode.style.backgroundColor = evt.type == 'dragenter' ? '#7fff0040' : 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));
	img.dataset.statusFlags = (0).toString(2).padStart(8, '0');
	if (!isAlternateCover && groupId > 0) editOnClick(img, true);
	return Promise.resolve(0);
}

const dcApiRateControl = { }, dcApiRequestsCache = new Map;

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

function coverLookup(torrentGroup, ihh) {
	if (!torrentGroup || !ihh) throw 'Invalid argument';
	const dcAuth = (function() {
		const [token, consumerKey, consumerSecret] =
			['discogs_api_token', 'discogs_api_consumerkey', 'discogs_api_consumersecret'].map(name => GM_getValue(name));
		return token ? 'token=' + token : consumerKey && consumerSecret ?
			`key=${consumerKey}, secret=${consumerSecret}` : null;
	})(), spfAuth = (function() {
		const [clientId, clientSecret] = ['spotify_client_id', 'spotify_client_secret'].map(name => GM_getValue(name));
		return clientId && clientSecret && btoa(clientId + ':' + clientSecret);
	})();
	const strippers = [
		/^(?:Not On Label|Self[\s\-]Released|None)$|(?:\s+\b(?:Record(?:ing)?s)\b|,?\s+(?:Ltd|Inc|Co)\.?)+$|[\s\-]+/ig,
		/^(?:None)$|[\s\-]+/ig,
	].map(rx => idStr => idStr && idStr.trim().replace(rx, '').toLowerCase() || undefined);
	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;
	const lookupWorkers = [ ];

	function getMainArtist() {
		let mainArtist = 'dj' in torrentGroup.group.musicInfo && torrentGroup.group.musicInfo.dj[0];
		if (!mainArtist && torrentGroup.group.releaseType != 7 && 'artists' in torrentGroup.group.musicInfo)
			mainArtist = torrentGroup.group.musicInfo.artists[0];
		if (mainArtist) return mainArtist.name;
	}
	function getAllLabelsCatNos() {
		const queryParams = torrentGroup.torrents.map(function(torrent) {
			if (!torrent.remasterRecordLabel || !torrent.remasterCatalogueNumber) return null;
			const [labels, catNos] = [torrent.remasterRecordLabel, torrent.remasterCatalogueNumber].map(value =>
				(value = value.split('/').map(value => value.trim()).filter(Boolean)).length > 0 ? value : null).filter(Boolean);
			return labels.length > 0 && catNos.length == labels.length ? labels.map((label, index) => ({
				label: label.replace(/(?:\s+Record(?:s|ings)|,?\s+(?:Inc|Ltd|GmBH|a\.?s|s\.?r\.?o)\.?)+$/i, ''),
				catno: catNos[index],
			})) : null;
		}).filter(Boolean);
		return queryParams.length > 0 ? Array.prototype.concat.apply([ ], queryParams).filter((qp1, ndx, arr) =>
			arr.findIndex(qp2 => Object.keys(qp2).every(key => qp2[key] == qp1[key])) == ndx) : null;
	}

	// 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');
			const request = (retryCounter = 0) => 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 == '/search' && (results = results.filter(function(result) {
							let releaseYear = new Date(result.releaseDate);
							releaseYear = isNaN(releaseYear) ? undefined : releaseYear.getUTCFullYear();
							return torrentGroup.torrents.some(function(torrent) {
								if (torrent.remasterYear > 0 && releaseYear > 0 && torrent.remasterYear != releaseYear) return false;
								const torrentTracks = audioFileCount(torrent);
								if (torrentTracks > 0 && result.trackCount > 0 && torrentTracks != result.trackCount) return false;
								return torrent.remasterYear > 0 && releaseYear > 0 && torrentTracks > 0 && result.trackCount > 0;
							});
						})).length <= 0) return reject('No matches'); else if (results.length > 1) {
							if (noAmbiguity) return reject('Ambiguous results');
							console.info('[Cover Inspector] Ambiguous iTunes results for lookup query (endpoint=%s, queryParams=%o)',
								endpoint.pathname, queryParams);
						}
						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 if (response.status == 403 && retryCounter < 100)
						setTimeout(request, 1000, retryCounter + 1);
					else reject(defaultErrorHandler(response));
				},
				onerror: response => { reject(defaultErrorHandler(response)) },
				ontimeout: response => { reject(defaultTimeoutHandler(response)) },
			});
			request();
		}) : Promise.reject('Invalid argument');
		lookupWorkers.push(function lookupCoversByUPC() { // 0
			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() { // 1
			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 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);
		});
	}
	// Extract from desc. links
	lookupWorkers.push(function getImagesFromWikiBody() { // 2
		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(isMusicResource)).length > 0 ? imageUrls
				: Promise.reject('No cover images could be extracted from links in wiki body'));
	});
	// 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: 'https://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('https://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');
					}
					function rgFilter(releaseGroup) {
						if ((function rgFilter(releaseGroup) {
							if (!releaseGroup) return false;
							const isPrimaryType = primaryType => releaseGroup['primary-type'] == primaryType;
							const hasSecondaryType = secondaryType => 'secondary-types' in releaseGroup
								&& releaseGroup['secondary-types'].includes(secondaryType);
							//if (['Audiobook', 'Spokenword', 'Audio drama'].some(hasSecondaryType) return false;
							const releaseType = {
								1: 'Album', 3: 'Soundtrack', 5: 'EP', 6: 'Anthology', 7: 'Compilation', 9: 'Single',
								11: 'Live album', 13: 'Remix', 14: 'Bootleg', 15: 'Interview', 16: 'Mixtape', 17: 'Demo',
								18: 'Concert Recording', 19: 'DJ Mix', 21: 'Unknown',
							}[torrentGroup.group.releaseType];
							console.assert(releaseType != undefined);
							switch (releaseType) {
								case 'Album': return ['Album', 'EP'].some(isPrimaryType)/* && !hasSecondaryType('Compilation')*/;
								case 'Single': return ['Single', 'EP'].some(isPrimaryType);
								case 'EP': return ['EP', 'Single'].some(isPrimaryType);
								case 'Live album': case 'Concert Recording': return !isPrimaryType('Single') && hasSecondaryType('Live');
								case 'Soundtrack': return /*!isPrimaryType('Single') && */hasSecondaryType('Soundtrack');
								case 'Anthology': case 'Compilation': return !isPrimaryType('Single') && hasSecondaryType('Compilation');
								case 'Remix': return /*!isPrimaryType('Single') && */hasSecondaryType('Remix');
								case 'DJ Mix': return /*!isPrimaryType('Single') && */hasSecondaryType('DJ-mix');
								case 'Demo': return /*!isPrimaryType('Single') && */hasSecondaryType('Demo');
								case 'Mixtape': return /*!isPrimaryType('Single') && */hasSecondaryType('Mixtape/Street');
								case 'Interview': return /*!isPrimaryType('Single') && */hasSecondaryType('Interview');
								case 'Bootleg': //return !isPrimaryType('Single')/* && hasSecondaryType('Bootleg')*/;
							}
							return Boolean(releaseType);
						})(releaseGroup)) return true;
						console.log('[Cover Inspector] rgFilter(%o) returns false: releaseType=%d',
							releaseGroup, torrentGroup.group.releaseType,
							document.location.origin + '/torrents.php?id=' + torrentGroup.group.id,
							'https://musicbrainz.org/release-group/' + releaseGroup.id);
						return false;
					}
					function releaseFilter(release) {
						if ((function releaseFilter(release) {
							if (strictReleaseMatch && 'release-group' in release && !rgFilter(release['release-group'])) return false;
							let releaseYear = new Date(release.date);
							releaseYear = isNaN(releaseYear) ? undefined : releaseYear.getUTCFullYear();
							return torrentGroup.torrents.some(function(torrent) {
								if (torrent.remasterYear > 0 && releaseYear > 0 && torrent.remasterYear != releaseYear) return false;
								const torrentIds = ['remasterRecordLabel', 'remasterCatalogueNumber']
									.map((prop, index) => torrent[prop].split('/').map(strippers[index]).filter(Boolean));
								if ('label-info' in release && release['label-info'].some(function(labelInfo) {
									labelInfo = [labelInfo.label.name, labelInfo['catalog-number']].map((prop, index) => strippers[index](prop));
									return labelInfo.some(Boolean) && labelInfo.every((id, index) => !id || torrentIds[index].includes(id));
								})) return true;
								const torrentTracks = audioFileCount(torrent);
								if (torrentTracks > 0 && release['track-count'] > 0 && torrentTracks != release['track-count']) return false;
								return torrent.remasterYear > 0 && releaseYear > 0 || torrentTracks > 0 && release['track-count'] > 0;
							});
						})(release)) return true;
						console.log('[Cover Inspector] releaseFilter(%o) returns false: torrents=%o',
							release, torrentGroup.torrents,
							document.location.origin + '/torrents.php?id=' + torrentGroup.group.id,
							'https://musicbrainz.org/release/' + release.id);
						return false;
					}
					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'].id)));
							if ((strictReleaseMatch || getReleaseGroupIds(releases).size > 1)
									&& getReleaseGroupIds(releases = releases.filter(releaseFilter)).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)');
							if (strictReleaseMatch) releaseGroups = releaseGroups.filter(rgFilter);
							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.no. not available');
			const queryParams = getAllLabelsCatNos();
			if (queryParams == null) return Promise.reject('No torrents with label/cat.no.');
			return Promise.all(queryParams.map(queryParams => search('release', queryParams, true).catch(reason => null)))
				.then(imageUrls => (imageUrls = imageUrls.filter(Boolean)).length > 0 ? Array.prototype.concat.apply([ ], imageUrls)
					: Promise.reject('No covers found by label/cat.no.'));
		}, function lookupCoversByTitleYear() { // 5
			const queryParams = { }, mainArtist = getMainArtist();
			if (mainArtist) queryParams.artistname = mainArtist; else if (torrentGroup.group.releaseType != 7)
				return Promise.reject('Cover lookup by artist/album/year not available');
			queryParams.releasegroup = bareReleaseTitle(torrentGroup.group.name);
			queryParams.firstreleasedate = torrentGroup.group.year;
			return search('release-group', queryParams, true);
		});
	}
	// Ext. lookup at Bandcamp
	if (torrentGroup.group.categoryId == 1) {
		const search = (searchTerm, itemType = 'a') => searchTerm ? new Promise(function(resolve, reject) {
			const url = new URL('https://bandcamp.com/search');
			url.searchParams.set('q', searchTerm);
			url.searchParams.set('item_type', itemType = itemType.toLowerCase());
			GM_xmlhttpRequest({ method: 'GET', url: url, responseType: 'document', headers: { 'X-Requested-With': 'XMLHttpRequest' },
				onload: function(response) {
					if (response.status >= 200 && response.status < 400) {
						let results = response.response.body.querySelectorAll('div.results > ul.result-items > li.searchresult');
						if (results.length <= 0 || (results = Array.prototype.filter.call(results, function(result) {
							let [title, artist, releaseYear, releaseTracks] = ['heading', 'subhead', 'released', 'length']
								.map(className => result.querySelector('div.' + className));
							if (title != null) title = title.textContent.trim(); else return false;
							//if (bareReleaseTitle(title).toLowerCase() != torrentGroup.group.name.toLowerCase()) return false;
							if (artist != null) artist = /^by (.+)$/i.exec(artist.textContent.trim());
							if (artist != null) artist = artist[1]; else return false;
							if (releaseYear != null) releaseYear = /\b(\d{4})\b/.exec(releaseYear.textContent);
							releaseYear = releaseYear != null ? parseInt(releaseYear[1]) : undefined;
							if (itemType == 't') releaseTracks = 1; else if (itemType == 'a') {
								if (releaseTracks != null) releaseTracks = /\b(\d+)\s+tracks?\b/i.exec(releaseTracks.textContent);
								releaseTracks = releaseTracks != null ? parseInt(releaseTracks[1]) : undefined;
							}
							return torrentGroup.torrents.some(function(torrent) {
								if (torrent.remasterYear > 0 && releaseYear > 0 && torrent.remasterYear != releaseYear) return false;
								const torrentTracks = audioFileCount(torrent);
								if (torrentTracks > 0 && releaseTracks > 0 && torrentTracks != releaseTracks) return false;
								return torrent.remasterYear > 0 && releaseYear > 0 && torrentTracks > 0 && releaseTracks > 0;
							});
						})).length <= 0) return reject('No matches'); else if (results.size > 1) {
							console.info('[Cover Inspector] Ambiguous Bandcamp results for lookup query', searchTerm);
							//return reject('Ambiguous results');
						}
						//console.debug('[Cover Inspector] Bandcamp search results for %o:', searchTerm, results);
						Promise.all(results.map(function(result) {
							const image = result.querySelector('a.artcont img');
							return image != null && getImageMax(image.src).catch(reason => image);
						})).then(results = results.filter(Boolean))
							.then(results => { if (results.length > 0) resolve(results); 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 lookupCoversByArtistTitle() { // 6
			let searchTerm = [getMainArtist(), bareReleaseTitle(torrentGroup.group.name)];
			if (!searchTerm[0]) return Promise.reject('Cover lookup by artist/album not available');
			return search((searchTerm = searchTerm.map(name => '"' + name + '"').join(' ')), 'a').catch(reason =>
				reason == 'No matches' && torrentGroup.torrents.map(audioFileCount).some(afc => afc == 1) ?
					search(searchTerm, 't') : Promise.reject(reason));
		});
	}
	// Ext. lookup at Spotify
	if (torrentGroup.group.categoryId == 1) {
		const requestEndpoint = (server, endpoint, auth, params) => server && endpoint && auth ? new Promise(function(resolve, reject) {
			const url = new URL(endpoint, 'https://' + server + '.spotify.com'), isPost = server.toLowerCase() != 'api';
			if (params) if (isPost) var payload = new URLSearchParams(params);
				else for (let param in params) url.searchParams.set(param, params[param]);
			GM_xmlhttpRequest({ method: isPost ? 'POST' : 'GET', url: url.href,
				headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest', 'Authorization': auth },
				responseType: 'json',
				onload: function(response) {
					if (response.status >= 200 && response.status < 400) resolve(response.response);
						else reject(defaultErrorHandler(response));
				},
				onerror: response => { reject(defaultErrorHandler(response)) },
				ontimeout: response => { reject(defaultTimeoutHandler(response)) },
				data: payload,
			});
		}) : Promise.reject('Invalid argument');
		const search = queryParams => queryParams && typeof queryParams == 'object' ? (function setOAuth2Token() {
			const isTokenValid = accessToken => typeof accessToken == 'object' && accessToken.token_type
				&& accessToken.access_token && accessToken.expires_at >= Date.now() + 30 * 1000;
			if ('spotifyAccessToken' in localStorage) try {
				const accessToken = JSON.parse(localStorage.getItem('spotifyAccessToken'));
				if (isTokenValid(accessToken)) return Promise.resolve(accessToken);
			} catch(e) { console.warn(e) }
			const timeStamp = Date.now();
			return spfAuth ? requestEndpoint('accounts', 'api/token', 'Basic ' + spfAuth, { 'grant_type': 'client_credentials' }).then(function(accessToken) {
				if (!accessToken.timestamp) accessToken.timestamp = timeStamp;
				if (!accessToken.expires_at) accessToken.expires_at = accessToken.timestamp +
					(accessToken.expires_in_ms || accessToken.expires_in * 1000);
				if (!isTokenValid(accessToken)) {
					console.warn('Received invalid Spotify token:', accessToken);
					return Promise.reject('invalid token received');
				}
				localStorage.setItem('spotifyAccessToken', JSON.stringify(accessToken));
				return accessToken;
			}) : Promise.reject('Basic authorization not fully configured');
		})().then(accessToken => requestEndpoint('api', 'v1/search', accessToken.token_type + ' ' + accessToken.access_token, {
			q: Object.keys(queryParams).map(param => `${param}:"${queryParams[param]}"`).join(' '),
			type: 'album',
			limit: 50,
		}).then(function(results) {
			if (results.albums.total > 0) results = results.albums.items; else return Promise.reject('No matches');
			//console.debug('[Cover Inspector] Spotify search results for %o:', queryParams, results);
			if (!Object.keys(queryParams).includes('upc')) results = results.filter(function(result) {
				if (result.album_type == 'single' ? ![9, 5].includes(torrentGroup.group.releaseType)
						: torrentGroup.group.releaseType == 9) return false;
				if ((result.album_type == 'compilation') != [6, 7].includes(torrentGroup.group.releaseType)) return false;
				let releaseYear = new Date(result.release_date);
				releaseYear = isNaN(releaseYear) ? undefined : releaseYear.getUTCFullYear();
				return torrentGroup.torrents.some(function(torrent) {
					if (torrent.remasterYear > 0 && releaseYear > 0 && torrent.remasterYear != releaseYear) return false;
					const torrentFiles = audioFileCount(torrent);
					if (torrentFiles > 0 && result.total_tracks > 0 && torrentFiles != result.total_tracks) return false;
					return torrentFiles > 0 && result.total_tracks > 0 || torrent.remasterYear > 0 && releaseYear > 0;
				});
			});
			if (results.length <= 0) return Promise.reject('No matches'); else if (results.length > 1) {
				//return reject('Ambiguous results');
				console.info('[Cover Inspector] Ambiguous Spotify results for lookup query (queryParams=%o)', queryParams);
			}
			return (results = results.map(function(result) {
				if (!result.images) return null;
				let highest = Math.max(...result.images.map(image => image.width * image.height));
				highest = result.images.find(image => image.width * image.height == highest);
				return highest && highest.url;
			}).filter(Boolean)).length > 0 ? results : Promise.reject('No covers');
		})) : Promise.reject('No query provided');
		lookupWorkers.push(function lookupCoversByUPC() { // 7
			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) upcs = Array.prototype.concat.apply([ ], upcs);
				else return Promise.reject('No torrents with UPC');
			return Promise.all(upcs.map(upc => search({ 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() { // 8
			const queryParams = { }, mainArtist = getMainArtist();
			if (mainArtist) queryParams.artist = mainArtist; else if (torrentGroup.group.releaseType != 7)
				return Promise.reject('Cover lookup by artist/album not available');
			queryParams.album = bareReleaseTitle(torrentGroup.group.name);
			return search(queryParams);
		});
	}
	// Ext. lookup at Discogs, req. credentials
	if (torrentGroup.group.categoryId == 1 && dcAuth) {
		function apiRequest(endPoint, params) {
			if (endPoint) endPoint = new URL(endPoint, 'https://api.discogs.com'); else return Promise.reject('No endpoint provided');
			if (params instanceof URLSearchParams) endPoint.search = params;
			else if (typeof params == 'object') for (let key in params) endPoint.searchParams.set(key, params[key]);
			else if (params) endPoint.search = new URLSearchParams(params);
			const cacheKey = endPoint.pathname.slice(1) + endPoint.search;
			if (dcApiRequestsCache.has(cacheKey)) return dcApiRequestsCache.get(cacheKey);
			const reqHeaders = {
				'Accept': 'application/json',
				'X-Requested-With': 'XMLHttpRequest',
				'Authorization': 'Discogs ' + dcAuth,
			};
			const recoverableHttpErrors = [0, 500, 502, 503, 504, 520, /*521, */522, 523, 524, 525, /*526, */527, 530];
			let retryCounter = 0;
			const request = new Promise((resolve, reject) => (function request() {
				const now = Date.now(), 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: endPoint,
					responseType: 'json',
					headers: reqHeaders,
					onload: function(response) {
						let 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) resolve(response.response);
						else if (response.status == 429/* && ++retryCounter < xhrLibmaxRetries*/) {
							console.warn(defaultErrorHandler(response), response.response.message, '(' + retryCounter + ')', `Rate limit used: ${requestsUsed}/60`);
							postpone();
						} else if (recoverableHttpErrors.includes(response.status) && ++retryCounter < 60) setTimeout(request, 1000);
						else reject(defaultErrorHandler(response));
					},
					onerror: function(response) {
						if (recoverableHttpErrors.includes(response.status) && ++retryCounter < 60) setTimeout(request, 1000);
						else reject(defaultErrorHandler(response));
					},
					ontimeout: response => { reject(defaultTimeoutHandler(response)) },
				}); else postpone();
			})());
			dcApiRequestsCache.set(cacheKey, request);
			return request;
		}
		function search(type, queryParams, strictReleaseMatch = false) {
			if (!type || !queryParams) throw 'Invalid argument';
			const searchParams = new URLSearchParams(queryParams);
			if (type) searchParams.set('type', type = type.toLowerCase());
			searchParams.set('sort', 'score');
			searchParams.set('sort_order', 'desc');
			searchParams.set('per_page', 100);
			return apiRequest('database/search', searchParams).then(function(response) {
				function getFromResults(results) {
					if (!results || results.length <= 0) return Promise.reject('No matches');
					const coverImages = results.map(result => result.cover_image || singleResultGetter(result.images))
						.filter(coverImage => coverImage && !coverImage.endsWith('/spacer.gif'));
					return coverImages.length > 0 ? coverImages : Promise.reject('None of matched 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 apiRequest('masters/' + masterIds).then(masterRelease => masterRelease.images && masterRelease.images.length > 0 ?
						masterRelease.images.map(image => image.uri || image.resource_url) : Promise.reject('No cover image for master release'));
					//return ihh.imageUrlResolver('https://www.discogs.com/master/' + masterIds).then(singleResultGetter);
				}

				let results = response.results, masterIds;
				if (results && results.length > 0) switch (type) {
					case 'release': {
						const getMasterIds = results => new Set(results.map(result => result.master_id));
						if (strictReleaseMatch || getMasterIds(results).size > 1) results = results.map(result =>
									apiRequest('releases/' + result.id).catch(reason => null).then(function(release) {
								const releaseYear = release != null && release.year || new Date(result.year).getUTCFullYear();
								const releaseTracks = release != null && release.tracklist ? release.tracklist.filter(track =>
									['track', 'index'].includes(track.type_)/* && !/^\d+\.\d+/.test(track.position)*/).length : -1;
								if (torrentGroup.torrents.some(function(torrent) {
									if (torrent.remasterYear > 0 && releaseYear > 0 && torrent.remasterYear != releaseYear) return false;
									const torrentIds = ['remasterRecordLabel', 'remasterCatalogueNumber']
										.map((prop, index) => torrent[prop].split('/').map(strippers[index]).filter(Boolean));
									if (release != null && release.labels && release.labels.some(function(label) {
										label = ['name', 'catno'].map((prop, index) => strippers[index](label[prop]));
										return label.some(Boolean) && label.every((id, index) => !id || torrentIds[index].includes(id));
									})) return true;
									const torrentTracks = audioFileCount(torrent);
									if (torrentTracks > 0 && releaseTracks > 0 && torrentTracks != releaseTracks) return false;
									return torrent.remasterYear > 0 && releaseYear > 0 || torrentTracks > 0 && releaseTracks > 0;
								})) return result;
							}));
						return Promise.all(results).then(results => results.filter(Boolean)).then(function(results) {
							if (results.length > 1) {
								//if (strictReleaseMatch) return Promise.reject('Ambiguous results');
								console.info('[Cover Inspector] Ambiguous Discogs results for lookup query (type=%s, queryParams=%o)', type, queryParams);
							}
							if ((masterIds = getMasterIds(results)).size > 1) return Promise.reject('Ambiguous results');
							return getFromMR(masterIds).catch(reason => getFromResults(results));
						});
						break;
					}
					case 'master':
						return results.length > 1 ? Promise.reject('Ambiguous results') : getFromResults(results);
						break;
					default: return Promise.reject('Unsupported search type');
				} else return Promise.reject('No matches');
			});
		}

		lookupWorkers.push(function lookupCoversByBarcode() { // 9
			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() { // 10
			if (!Array.isArray(torrentGroup.torrents) || torrentGroup.torrents.length <= 0)
				return Promise.reject('Cover lookup by label/cat.no. not available');
			const queryParams = getAllLabelsCatNos();
			if (queryParams == null) return Promise.reject('No torrents with label/cat.no.');
			return Promise.all(queryParams.map(queryParams => search('release', queryParams, true).catch(reason => null)))
				.then(imageUrls => (imageUrls = imageUrls.filter(Boolean)).length > 0 ? Array.prototype.concat.apply([ ], imageUrls)
					: Promise.reject('No covers found by label/cat.no.'));
		}, function lookupCoversByTitleYear() { // 11
			const queryParams = { }, mainArtist = getMainArtist();
			if (mainArtist) queryParams.artist = mainArtist; else if (torrentGroup.group.releaseType != 7)
				return Promise.reject('Cover lookup by artist/album/year not available');
			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() { // 12
			const queryParams = { }, mainArtist = getMainArtist();
			if (mainArtist) queryParams.artist = mainArtist; else if (torrentGroup.group.releaseType != 7)
				return Promise.reject('Cover lookup by artist/album not available');
			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);
		});
	}
	// Ext. lookup at Goodreads - for ebooks only
	if (torrentGroup.group.categoryId == 3) {
		function search(queryParams, noAmbiguity = true) {
			if (!queryParams) throw 'Invalid argument';
			return new Promise(function(resolve, reject) {
				const requestUrl = new URL('https://www.goodreads.com/search');
				for (let param in queryParams) requestUrl.searchParams.set(param, queryParams[param]);
				requestUrl.searchParams.set('search_type', 'books');
				GM_xmlhttpRequest({
					method: 'GET',
					url: requestUrl,
					headers: { 'Accept': 'text/html', 'X-Requested-With': 'XMLHttpRequest' },
					responseType: 'document',
					onload: function(response) {
						if (response.status < 200 || response.status >= 400) return reject(defaultErrorHandler(response));
						const grImageMax = src => src && src.replace(/\._(?:\w+\d+_)+\./ig, '.');
						const dummyCover = coverUrl => coverUrl && ['/nophoto/', '/books/1570622405l/50809027', '/images/no-cover.png']
							.some(pattern => coverUrl.includes(pattern));
						let results = ['div.BookCover__image img', 'div.editionCover > img', 'img#coverImage']
							.reduce((elem, selector) => elem || response.response.body.querySelector(selector), null);
						if (results != null && httpParser.test(results = results.src)) {
							if (!dummyCover(results)) return resolve([grImageMax(results)]);
						} else if ((results = response.response.querySelectorAll('table.tableList > tbody > tr')).length > 0) {
							if (results.length > 1) {
								if (noAmbiguity) return reject('Ambiguous results');
								console.warn('[Cover Inspector] Goodreads ambiguous results');
							}
							if ((results = Array.prototype.map.call(results, function(result) {
								let coverUrl = result.querySelector('img[itemprop="image"]');
								if (coverUrl != null && httpParser.test(coverUrl = coverUrl.src) && !dummyCover(coverUrl))
									return grImageMax(coverUrl);
							}).filter(Boolean)).length > 0) return resolve(results);
						} else return reject('No matches');
						reject('No valid cover image for matched ebooks');
					},
					onerror: response => { reject(defaultErrorHandler(response)) },
					ontimeout: response => { reject(defaultTimeoutHandler(response)) },
				});
			});
		}
		function findByIdentifier(rx, minLength) {
			if (!(rx instanceof RegExp) || !(minLength >= 0)) throw 'Invalid argument';
			let id = rx.exec(descBody.textContent);
			if (id != null && (id = id[2].replace(/\W/g, '')).length >= minLength) lookupWorkers.push(() => search({ q: id }));
		}

		const descBody = domParser.parseFromString(torrentGroup.group.wikiBody, 'text/html').body;
		findByIdentifier(/\b(ISBN-?13)\b.+?\b(\d+(?:\-\d+)*)\b/m, 12);
		findByIdentifier(/\b(ISBN(?:-?10)?)\b.+?\b(\d+(?:\-\d+)*)\b/m, 9);
		findByIdentifier(/\b(EAN(?:-?13)?)\b.+?\b(\d+(?:\-\d+)*)\b/m, 12);
		findByIdentifier(/\b(UPC(?:-A)?)\b.+?\b(\d+(?:\-\d+)*)\b/m, 11);
		findByIdentifier(/\b(ASIN)\b.+?\b([A-Z\d]{10})\b/m, 11);
		const rx = [
			/(?:\s+(?:\((?:19|2\d)\d{2}\)|\[(?:19|2\d)\d{2}\]|\((?:epub|mobi|pdf)\)|\[(?:epub|mobi|pdf)\]))+$/ig,
			/(?:\s+(?:\([^\(\)]+\)|\[[^\[\]]+\]))+$/ig,
		], titles = [torrentGroup.group.name.replace(rx[0], '')];
		lookupWorkers.push(() => search({ q: titles[0] }));
		titles.push(titles[0].replace(rx[1], ''));
		if (titles[1].length < titles[0].length) lookupWorkers.push(() => search({ q: titles[1] }));
	}
	return (function lookupMethod(index = 0) {
		if (index < lookupWorkers.length) return lookupWorkers[index]().then(results =>
				Promise.all(results.map(result => ihh.verifyImageUrl(result).catch(reason => null)))).then(function(results) {
			if ((results = results.filter(Boolean)).length <= 0) return Promise.reject('No valid image');
			console.log('[Cover Inspector] Covers lookup successfull for', torrentGroup, ', method index:', index);
			return results;
		}).catch(reason => lookupMethod(index + 1));
		return Promise.reject('None of release identifiers was sufficient to find the cover');
	})();
}

function findCover(groupId, img) {
	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]]).then(ihh.singleImageGetter, reason => imageUrls[0]).then(imageUrl =>
					setGroupImage(torrentGroup.group.id, imageUrl).then(function(response) {
		console.log('[Cover Inspector]', response);
		for (let collageIndex of ['missing', 'bad', 'investigate']) if (inCoversCollage(collageIndex, torrentGroup))
			removeFromCoversCollage(collageIndex, torrentGroup);
		if (!(img instanceof HTMLImageElement)) img = document.body.querySelector('div#covers img');
		if (img instanceof HTMLImageElement) {
			setNewSrc(img, imageUrl);
			inspectImage(img, torrentGroup.group.id).then(function(status) {
				if ((status & 0b100) != 0) {
					if (inCoversCollage('poor', torrentGroup)) removeFromCoversCollage('poor', torrentGroup);
				} else if (torrentGroup.group.categoryId == 1 && !inCoversCollage('poor', torrentGroup))
					addToCoversCollage('poor', torrentGroup.group.id);
			}, reason => { console.warn('[Cover Inspector] inspectImage(', img, ') failed with reason', reason) });
		} else testImageQuality(imageUrl).then(mpix =>
				{ if (inCoversCollage('poor', torrentGroup)) removeFromCoversCollage('poor', torrentGroup) }, reason =>
			{ if (torrentGroup.group.categoryId == 1 && !inCoversCollage('poor', torrentGroup)) addToCoversCollage('poor', torrentGroup.group.id) });
	}))).catch(function(reason) {
		if (!torrentGroup.group.wikiImage && !inCoversCollage('missing', torrentGroup))
			ihh.verifyImageUrl(torrentGroup.group.wikiImage).catch(reason => { addToCoversCollage('missing', torrentGroup.group.id) });
		if (torrentGroup.group.wikiImage && !inCoversCollage('bad', torrentGroup))
			ihh.verifyImageUrl(torrentGroup.group.wikiImage).catch(reason => { addToCoversCollage('bad', torrentGroup.group.id) });
		return Promise.reject(reason);
	})));
}

function getGroupId(root) {
	if (root instanceof HTMLElement) for (let a of root.getElementsByTagName('A')) {
		if (a.origin != document.location.origin || a.pathname != '/torrents.php') continue;
		a = new URLSearchParams(a.search);
		if (a.has('id') && !a.has('action') && (a = parseInt(a.get('id'))) > 0) return a;
	}
	console.warn('[Cover Inspector] Failed to find group id:', root);
}

function addTableHandlers(table, parent, style, index) {
	function addHeaderButton(caption, clickHandler, id, tooltip) {
		if (!caption || typeof clickHandler != 'function') return;
		const elem = document.createElement('SPAN');
		if (id) elem.classList.add(id);
		elem.classList.add('brackets');
		elem.style = 'margin-right: 5pt; cursor: pointer; font-weight: normal; transition: color 0.25s;';
		elem.textContent = caption;
		elem.onmouseenter = elem.onmouseleave = function(evt) {
			if (evt.relatedTarget == evt.currentTarget) return false;
			evt.currentTarget.style.color = evt.type == 'mouseenter' ? 'orange' : evt.currentTarget.dataset.color || null;
		};
		elem.onclick = clickHandler;
		if (tooltip) elem.title = tooltip; //setTooltip(tooltip);
		container.append(elem);
		return elem;
	}
	function iterateReleaseGroups(callback) {
		for (const tr of table.querySelectorAll('tbody > tr.group, tbody > tr.torrent')) {
			const groupId = getGroupId(tr.querySelector('div.group_info'));
			console.assert(groupId > 0, 'Failed to extract group id:', tr)
			if (groupId > 0) callback(groupId, tr.querySelector('div.group_image > img'), function failhandler(reason) {
				if (this && this.logFail) this.logFail(`groupId ${groupId} cover lookup failed: ${reason}`);
				console.log('[Cover Inspector] groupId', groupId, 'cover lookup failed:', reason);
				return reason;
			});
		}
	}
	function getGroupCreationTime(elem) {
		if (!(elem instanceof HTMLElement) || !((elem = getGroupId(elem.querySelector('div.group_info'))) > 0)) return;
		if ((elem = document.body.querySelectorAll(`tr.group_torrent.groupid_${elem} *.time[title]`)).length <= 0) return;
		if ((elem = Array.from(elem, elem => new Date(elem.title)).filter(date => !isNaN(date))).length <= 0) return;
		return Math.min(...elem.map(date => date.getTime()));
	}
	function changeToCounter(elem, id) {
		if (!(elem instanceof HTMLElement) || !id) throw 'Invalid argument';
		if (!elem.count) {
			elem.remove();
			return null;
		}
		elem.onclick = elem.onmouseenter = elem.onmouseleave = null;
		elem.style.color = 'orange';
		elem.style.cursor = null;
		elem.textContent = ' groups remaining';
		elem.removeAttribute('title');
		const counter = document.createElement('SPAN');
		counter.className = 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 (index) for (let img of images) img.dataset.tableIndex = index;
	const container = document.createElement('DIV');
	container.className = index ? 'cover-inspector-' + index : 'cover-inspector';
	if (style) container.style = style;
	if (images.length > 0) addHeaderButton('Inspect all covers', function inspectAll(evt) {
		if (!evt.currentTarget.disabled) evt.currentTarget.disabled = true; else return false;
		evt.currentTarget.style.color = evt.currentTarget.dataset.color = 'orange';
		evt.currentTarget.textContent = '…wait…';
		evt.currentTarget.style.cursor = null;
		const currentTarget = evt.currentTarget, inspectWorkers = [ ];
		let autoFix = parent.querySelector('span.auto-fix-covers');
		iterateReleaseGroups((groupId, img) => { if (img != null) inspectWorkers.push(inspectImage(img, groupId)) });
		if (autoFix != null && inspectWorkers.length > 0) autoFix.hidden = true;
		(inspectWorkers.length > 0 ? imageHostHelper.then(ihh => Promise.all(inspectWorkers).then(function(statuses) {
			const failedToLoad = statuses.filter(status => (status >> 7 & 0b11) == 0b10).length;
			if (autoFix != null || (autoFix = parent.querySelector('span.auto-fix-covers')) != null) if (failedToLoad > 0) {
				autoFix.hidden = false;
				autoFix.count = statuses.filter(status => (status >> 7 & 0b01) == 0).length;
				autoFix.title = autoFix.count.toString() + ' covers to lookup (missing covers included)';
			} else autoFix.remove();
			const minimumRehostAge = GM_getValue('minimum_age_for_rehost');
			const getClick2Gos = () => Array.prototype.filter.call(table.querySelectorAll('div.cover-inspector > span.click2go:not([disabled])'), function(elem) {
				if (elem.classList.contains('whitelisted')) return true;
				if (elem.classList.contains('unclassified-host')) {
					if (!(minimumRehostAge > 0)) return false;
					while (elem != null && elem.nodeName != 'TR') elem = elem.parentNode;
					if (!((elem = getGroupCreationTime(elem)) > 0)) return false;
					return elem < Date.now() - minimumRehostAge * 24 * 60 * 60 * 1000;
				}
				return true;
			});
			if ((currentTarget.count = getClick2Gos().length) > 0) {
				currentTarget.id = 'process-all-covers';
				currentTarget.onclick = function processAll(evt) {
					if (evt.currentTarget.disabled || !checkSavedRecovery()) return false;
					if (failedToLoad > 0 && evt.ctrlKey) return inspectAll(evt);
					const click2Gos = getClick2Gos();
					evt.currentTarget.count = click2Gos.length;
					changeToCounter(evt.currentTarget, 'process-covers-countdown');
					for (let elem of click2Gos) elem.click();
				};
				currentTarget.style.color = currentTarget.dataset.color = 'mediumseagreen';
				currentTarget.textContent = 'Process existing covers';
				currentTarget.style.cursor = 'pointer';
				currentTarget.disabled = false;
				currentTarget.title = currentTarget.count.toString() + ' releases to process';
				console.log('[Cover Inspector] Page scan completed, %d images cached', Object.keys(imageDetailsCache).length);
				if (failedToLoad > 0) currentTarget.title += `\n(${failedToLoad} covers failed to load, scan again on Ctrl + click)`;
			} else return Promise.reject('Nothing to process');
		})) : Promise.reject('Nothing to process')).catch(reason => { currentTarget.remove() });
	}, 'inspect-all-covers');
	imageHostHelper.then(function(ihh) {
		function setCoverFromTorrentGroup(torrentGroup, img, reason) {
			if (!torrentGroup) throw 'Invalid argument';
			return coverLookup(torrentGroup, ihh).then(imageUrls =>
					ihh.rehostImageLinks([imageUrls[0]]).then(ihh.singleImageGetter, reason => imageUrls[0]).then(imageUrl =>
						setGroupImage(torrentGroup.group.id, imageUrl, autoLookupSummary(reason)).then(function(response) {
				console.log('[Cover Inspector]', response);
				for (let collageIndex of ['missing', 'bad', 'investigate'])
					if (inCoversCollage(collageIndex, torrentGroup)) removeFromCoversCollage(collageIndex, torrentGroup);
				if (img instanceof HTMLImageElement) {
					setNewSrc(img, imageUrl);
					inspectImage(img, torrentGroup.group.id).then(function(status) {
						if ((status & 0b100) != 0) {
							if (inCoversCollage('poor', torrentGroup)) removeFromCoversCollage('poor', torrentGroup);
						} else if (torrentGroup.group.categoryId == 1 && !inCoversCollage('poor', torrentGroup))
							addToCoversCollage('poor', torrentGroup.group.id);
					}, reason => { console.warn('[Cover Inspector] inspectImage(', img, ') failed with reason', reason) });
				} else testImageQuality(imageUrl).then(mpix =>
						{ if (inCoversCollage('poor', torrentGroup)) removeFromCoversCollage('poor', torrentGroup) }, reason =>
					{ if (torrentGroup.group.categoryId == 1 && !inCoversCollage('poor', torrentGroup)) addToCoversCollage('poor', torrentGroup.group.id) });
				if (autoOpenSucceed) openGroup(torrentGroup);
				return imageUrl;
			}))).catch(function(reason) {
				if (!torrentGroup.group.wikiImage && !inCoversCollage('missing', torrentGroup)) ihh.verifyImageUrl(torrentGroup.group.wikiImage)
					.catch(reason => { addToCoversCollage('missing', torrentGroup.group.id) });
				if (torrentGroup.group.wikiImage && !inCoversCollage('bad', torrentGroup)) ihh.verifyImageUrl(torrentGroup.group.wikiImage)
					.catch(reason => { addToCoversCollage('bad', torrentGroup.group.id) });
				if (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).map(urls => urls.filter(isMusicResource)).filter(urls => urls.length > 0)).length <= 0) return;
						if (autoOpenWithLink) openGroup(torrentGroup);
						console.log('[Cover Inspector] Links found in torrent descriptions for', torrentGroup, ':', urls);
					});
				return Promise.reject(reason);
			});
		}

		const missingImages = Array.prototype.filter.call(images, img => !hasArtworkSet(img));
		if (images.length <= 0 || missingImages.length > 0) addHeaderButton('Add missing covers', function autoAdd(evt) {
			if (!checkSavedRecovery()) return false;
			if (images.length <= 0 || (evt.currentTarget.count = Array.prototype.filter.call(images, img => !hasArtworkSet(img)).length) <= 0) {
				evt.currentTarget.remove();
				if (images.length > 0) return;
			} else changeToCounter(evt.currentTarget, 'missing-covers-countdown');
			iterateReleaseGroups(function(groupId, img, failHandler) {
				if (img instanceof HTMLImageElement) {
					if (!hasArtworkSet(img)) queryAjaxAPI('torrentgroup', { id: groupId }).then(torrentGroup =>
						setCoverFromTorrentGroup(torrentGroup, img, 'missing')).catch(failHandler.bind(ihh))
							.then(status => { counterDecrement('missing-covers-countdown', index) });
				} else queryAjaxAPI('torrentgroup', { id: groupId }).then(torrentGroup =>
						torrentGroup.group.wikiImage ? true : setCoverFromTorrentGroup(torrentGroup, null, 'missing'))
					.catch(failHandler.bind(ihh)).then(status =>
						{ if (status != true) counterDecrement('missing-covers-countdown', index) });
			});
		}, 'covers-auto-lookup', missingImages.length > 0 ? (missingImages.length + ' covers missing') : undefined);
		addHeaderButton('Fix invalid covers', function autoFix(evt) {
			if (!checkSavedRecovery()) return false;
			if (evt.currentTarget.count > 0) changeToCounter(evt.currentTarget, 'invalid-covers-countdown');
				else evt.currentTarget.remove();
			const autoAdd = parent.querySelector('span.covers-auto-lookup');
			if (autoAdd != null) autoAdd.remove();
			iterateReleaseGroups(function(groupId, img, failHandler) {
				function validateImage(imageUrl) {
					if (!httpParser.test(imageUrl)) return Promise.reject('unset or invalid format');
					const deproxiedSrc = deProxifyImgSrc(imageUrl);
					return (deproxiedSrc ? setGroupImage(groupId, deproxiedSrc, 'Deproxied release image (not working anymore)')
						.then(result => ihh.verifyImageUrl(deproxiedSrc)): ihh.verifyImageUrl(imageUrl)).then(verifiedImageUrl => true);
				}

				if (img instanceof HTMLImageElement) validateImage(realImgSrc(img)).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, reason).catch(failHandler.bind(ihh))
							.then(status => { counterDecrement('invalid-covers-countdown', index) }));
				}); else queryAjaxAPI('torrentgroup', { id: groupId }).then(torrentGroup =>
						validateImage(torrentGroup.group.wikiImage).catch(function(reason) {
					console.log('[Cover Inspector] Invalid or missing cover for groupId %d, reason:', groupId, reason);
					return setCoverFromTorrentGroup(torrentGroup, null, reason);
				})).catch(failHandler.bind(ihh)).then(status =>
					{ if (status != true) counterDecrement('invalid-covers-countdown', index) });
			});
		}, 'auto-fix-covers', 'Missing covers lookup included');
		if (missingImages.length > 0) for (const img of missingImages) {
			img.removeAttribute('onclick');
			const groupId = getGroupId(img.parentNode.parentNode.querySelector('div.group_info'));
			if (groupId > 0) img.onclick = function(evt) {
				findCover(groupId, evt.currentTarget).catch(reason =>
					{ ihh.logFail(`groupId ${groupId} cover lookup failed: ${reason}`) });
				return false;
			}
		}
	});
	// addHeaderButton('Open all in tabs', function inspectAll(evt) {
	// 	iterateReleaseGroups(groupIdc => { openGroup(groupIdc) });
	// }, 'test-tabs-control');
	parent.append(container);
	if (images.length > 0 && GM_getValue('auto_inspect_cached', false) && Object.keys(imageDetailsCache).length > 0)
		iterateReleaseGroups(function(groupId, img) {
			if (img != null && hasArtworkSet(img) && realImgSrc(img) in imageDetailsCache) inspectImage(img, groupId);
		});
}

const urlParams = new URLSearchParams(document.location.search), id = parseInt(urlParams.get('id')) || undefined;
const findParent = table => table instanceof HTMLElement
	&& Array.prototype.find.call(table.querySelectorAll(':scope > tbody > tr:first-of-type > td'),
		td => /^(?:Torrents?|Name)\b/.test(td.textContent.trim())) || null;

// Crash recovery
if ('coverInspectorTabsQueue' in localStorage) try {
	const savedQueue = JSON.parse(localStorage.getItem('coverInspectorTabsQueue'));
	if (Array.isArray(savedQueue) && savedQueue.length > 0) {
		GM_registerMenuCommand('Restore open tabs queue', function() {
			if (!confirm('Process saved queue? (' + savedQueue.length + ' tabs to open)')) return;
			for (let queuedEntry of savedQueue) openTabLimited(queuedEntry.endpoint, queuedEntry.params, queuedEntry.hash);
		});
		GM_registerMenuCommand('Load saved queue for later', function() {
			if (confirm('Saved queue (' + savedQueue.length + ' tabs to open) will be prepended to current, continue?'))
				tabsQueueRecovery = savedQueue.concat(tabsQueueRecovery);
		});
	}
} catch(e) { console.warn(e) }

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

function resolveTorrentRow(tr, ihh) {
	function setStatus(newStatus, ...addedText) {
		let td = tr.querySelector('td.status');
		console.assert(td != null); if (td == null) return; // assertion failed
		if (typeof newStatus == 'number' && (status == undefined || newStatus < status)) status = newStatus;
		if (status == undefined) return;
		td.textContent = status > 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;
	}

	const groupId = getGroupId(tr), tooltips = [ ];
	let status;
	if (!(groupId > 0)) return setStatus(0, 'Could not extract torrent id');
	const autoHideFailed = GM_getValue('auto_hide_failed', false);
	queryAjaxAPI('torrentgroup', { id: groupId }).then(function(torrentGroup) {
		function removeFromThisCollage(verifyAdditionalCovers = false) {
			if (!(id > 0)) return; // No this collage
			const removeFromThisCollage = () => removeFromCollage(id, torrentGroup.group.id)
				.then(statusCode => { setStatus(status, '(removed from collage)') });
			if (verifyAdditionalCovers && torrentGroup.group.categoryId == 1 && coverRelatedCollages
					&& ['missing', 'bad', 'investigate'].map(index => coverRelatedCollages[index]).includes(id))
				getAllCovers(torrentGroup.group.id).then(imageUrls =>
						Promise.all(imageUrls.slice(1).map(ihh.verifyImageUrl)).then(removeFromThisCollage, function(reason) {
					setStatus(1, '(invalid additional cover(s) require attention)', reason);
				}), reason => { setStatus(2, 'Could not count additiona covers (' + reason + ')') });
			else removeFromThisCollage();
		}
		function poorQuality(reason) {
			setStatus(2, 'However the image quality is poor (resolution)');
			if (torrentGroup.group.categoryId == 1 && !inCoversCollage('poor', torrentGroup))
				addToCoversCollage('poor', torrentGroup.group.id).then(_status =>
					{ setStatus(status, '(added to poor quality covers collage)') });
		}
		const lookup = (reason, acceptAnyQuality = true) => coverLookup(torrentGroup, ihh).then(imageUrls =>
				ihh.rehostImageLinks(imageUrls).then(results => results.map(ihh.directLinkGetter), reason => imageUrls).then(function(imageUrls) {
			if (coverRelatedCollages && id == coverRelatedCollages.poor) acceptAnyQuality = false;
			let imgQualityWorker = testImageQuality(imageUrls[0]);
			if (acceptAnyQuality) imgQualityWorker = imgQualityWorker.catch(reason => 0);
			return imgQualityWorker.then(resolution => setGroupImage(torrentGroup.group.id, imageUrls[0],
					autoLookupSummary(reason)).then(function(response) {
				console.log('[Cover Inspector]', response);
				setStatus(3, response, '(reminder - release may contain additional covers to review)');
				if (resolution <= 0) poorQuality();
				if (id > 0) removeFromThisCollage(false);
				if (imageUrls.length > 1) setStatus(2, '(more external links require attention)');
				return imageUrls[0];
			}));
		}));
		return (function() {
			if (!torrentGroup.group.wikiImage) return Promise.reject('not set');
			const deproxiedSrc = deProxifyImgSrc(torrentGroup.group.wikiImage);
			return deproxiedSrc ? setGroupImage(groupId, deproxiedSrc, 'Deproxied release image (not working anymore)')
				.then(result => ihh.verifyImageUrl(deproxiedSrc)) : ihh.verifyImageUrl(torrentGroup.group.wikiImage);
		})().then(imageUrl => testImageQuality(imageUrl).catch(reason => 0).then(function(mpix) {
			function finalizeExisting() {
				if (id > 0) removeFromThisCollage(true);
				if (Array.isArray(preferredHosts) && preferredHosts.includes(hostname)
						|| isOnDomainList(imageUrl.hostname, 0)/* || !isOnDomainList(imageUrl.hostname, 1)*/)
					return Promise.reject('Not rehostable');
				return ihh.rehostImageLinks([imageUrl]).then(ihh.singleImageGetter, reason => imageUrl)
						.then(imageUrl => setGroupImage(torrentGroup.group.id, imageUrl, 'Automated cover rehost').then(function(response) {
					setStatus(status, '(' + response + ')');
					console.log('[Cover Inspector]', response);
					return imageUrl;
				}));
			}

			const hostname = new URL(imageUrl).hostname.toLowerCase(), domain = hostname.split('.').slice(-2).join('.');
			if (isOnDomainList(imageUrl.hostname, 2)) return Promise.reject('Unacknowledged host');
			setStatus(3, 'This release seems to have a valid image');
			if (coverRelatedCollages && id != coverRelatedCollages.poor) {
				if (mpix > 0 && inCoversCollage('poor', torrentGroup)) removeFromCoversCollage('poor', torrentGroup)
					.then(count => { if (count > 0) setStatus(status, '(removed from ppor quality covers collage)') });
				if (mpix <= 0) ([1, 3].includes(torrentGroup.group.categoryId) ? (
					setStatus(status, 'Poor image quality - looking up better one'),
					lookup('poor quality', false)
				) : Promise.reject('No lookup for this category')).catch(function(reason) {
					poorQuality(reason);
					finalizeExisting();
				});
			} else if (mpix <= 0) {
				setStatus(status, 'However the image quality is poor (resolution)');
				return Promise.reject('Insufficient image quality');
			}
			if (mpix > 0) finalizeExisting();
			if (autoOpenSucceed) openGroup(torrentGroup);
		})).catch(reason => (function() {
			if (status >= 0) setStatus(status, reason);
			return lookup(reason, true);
		})().then(() => { if (autoOpenSucceed) openGroup(torrentGroup) }, 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).map(urls => urls.filter(isMusicResource)).filter(urls =>
					urls.length > 0)).length <= 0) return Promise.reject(reason);
			setStatus(1, 'No active external links in album description,\nbut release descriptions contain some:\n\n' +
				(urls = Array.prototype.concat.apply([ ], urls)).join('\n'));
			if (autoOpenWithLink) openGroup(torrentGroup);
			console.log('[Cover Inspector] Links found in torrent descriptions for', torrentGroup, ':', urls);
		}) : Promise.reject(reason)));
	}).catch(reason => { setStatus(0, reason) });
}

switch (document.location.pathname) {
	case '/artist.php': {
		if (!(id > 0)) break;
		document.body.querySelectorAll('div.box_image 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;'
		// document.body.querySelectorAll('table.torrent_table').forEach(function(table, index) {
		// 	const parent = findParent(table);
		// 	if (parent) addTableHandlers(table, parent, 'display: inline-block; margin-left: 3em;', index + 1);
		// });
		break;
	}
	case '/torrents.php': {
		if (id > 0) {
			if (typeof GM_getTabs == 'function') GM_getTabs(function(tabs) {
				for (let tab in tabs) if ((tab = tabs[tab]) && 'torrentGroups' in tab) try {
					if (!(tab = tab.torrentGroups[id])) continue;
					console.info('Torrent group %d found in tabs data', id);
					unsafeWindow.torrentGroup = tab;
					unsafeWindow.dispatchEvent(Object.assign(new Event('torrentGroup'), { data: tab }));
				} catch (e) { console.warn(e) }
			});

			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]).then(ihh.singleImageGetter, reason => imageUrl)
								.then(imageUrl => setGroupImage(id, imageUrl, 'Cover update from description link').then(function(response) {
							console.log(response);
							if (img != null) {
								setNewSrc(img, imageUrl);
								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;
						const tooltip = 'Alt + click to set release cover from this URL (or use context menu command)';
						a.title = tooltip;
						if (GM_getValue('tooltip_desc_links_image', false) && typeof jQuery.fn.tooltipster == 'function') imageHostHelper.then(function(ihh) {
							ihh.imageUrlResolver(a.href).then(singleResultGetter).then(linkImage => { $(a).tooltipster({
								content: '<img src="' + linkImage + '" width="225" referrerpolicy="same-origin" /><div style="margin-top: 5pt; max-width: 225px;">' + tooltip + '</div>',
							}) });
						});
					}
					return true;
				}

				for (const root of [
					'div.torrent_description > div.body',
					'table#torrent_details > tbody > tr.torrentdetails > td > blockquote',
				]) for (let a of document.body.querySelectorAll(root + ' a')) if (!noCoverHere(a)) {
					const hostNorm = a.hostname.toLowerCase();
					if (hostNorm in hostSubstitutions) a.hostname = hostSubstitutions[hostNorm];
					setAnchorHandlers(a);
				}

				if (GM_getValue('auto_expand_extra_covers', true)) {
					const xtraCovers = document.body.querySelector('div.box_image span#cover_controls_0 > a.show_all_covers');
					if (xtraCovers != null) xtraCovers.click();
				}

				GM_registerMenuCommand('Cover auto lookup', () => { findCover(id).catch(alert) }, 'A');
			});

			function embedpage(url, title, className) {
				if (!url || !title) throw 'Invalid argument';
				const anchor = document.body.querySelector('div.main_column > div.torrent_description');
				if (anchor == null) return null;
				const [div, iframe] = ['DIV', 'IFRAME'].map(Document.prototype.createElement.bind(document));
				div.classList.add('box');
				if (className) div.classList.add(className);
				div.innerHTML = '<div class="head"><a href="#">↑</a>&nbsp;<strong>' + title + '</strong></div><div class="body"></div>';
				iframe.frameBorder = 0; iframe.referrerPolicy = 'same-origin';
				iframe.width = '100%'; iframe.height = '500';
				iframe.src = url;
				// iframe.onload = function(evt) {
				// 	const document = evt.currentTarget.contentDocument || evt.currentTarget.contentWindow.document;
				// 	if (document == null) return;
				// };
				iframe.onerror = evt => { anchor.removeChild(div) };
				div.querySelector('div.body').append(iframe);
				return anchor.insertAdjacentElement('beforebegin', div);
			}

			// Embed first description link if available
			if (urlParams.has('embed-desc-link-source')) {
				const links = getLinks(document.querySelector('div.torrent_description > div.body'));
				if (links != null) for (let link of links) embedpage(link, 'Description Link Preview', 'desc_link_preview');
			}
			// Embed Google Image search
			if (urlParams.has('embed-google-image-search')) {
				let title = document.body.querySelector('div.header > h2'), year, releaseType, query;
				if (title != null) {
					releaseType = /\[(\d+)\] \[(.+)\]/.exec(title.lastChild.textContent.trim());
					if (releaseType != null) { year = parseInt(releaseType[1]); releaseType = releaseType[2] }
					title = title.lastElementChild.textContent.trim();
				}
				if (title && year > 0) {
					query = '"' + bareReleaseTitle(title) + '" ' + year;
					const artists = { };
					for (let a of document.body.querySelectorAll('ul#artist_list > li > a[dir="ltr"]')) {
						if (!(a.parentNode.className in artists)) artists[a.parentNode.className] = [ ];
						artists[a.parentNode.className].push(a.textContent.trim());
					}
					const stringifyArtists = (importance, maxArtists = 3) => importance in artists ?
						artists[importance].slice(0, maxArtists).map(artist => '"' + artist + '"').join(' ') : undefined;
					if (releaseType != 'Compilation') {
						if ('artists_dj' in artists) query = stringifyArtists('artists_dj') + ' ' + query;
						else if ('artist_main' in artists) query = stringifyArtists('artist_main') + ' ' + query;
					} else query = (stringifyArtists('artists_dj') || '"Various Artists"') + ' ' + query;
					const embedUrl = new URL('https://www.google.com/search');
					embedUrl.searchParams.set('q', query);
					embedUrl.searchParams.set('tbm', 'isch');
					//embedUrl.hash = 'islmp';
					embedpage(embedUrl, 'Google image search results', 'google_image_search_results');
				}
			}
			if (urlParams.has('desc-links-image-preview')) {
				const anchor = document.body.querySelector('div.sidebar > div.box_image');
				const links = getLinks(document.querySelector('div.torrent_description > div.body'));
				if (anchor != null && links != null) imageHostHelper.then(function(ihh) {
					let previewBox = document.createElement('DIV');
					previewBox.className = 'box description_link_image_preview';
					previewBox.innerHTML = '<div class="head"><strong>Description links image preview</strong></div><div class="pad"></div>';
					anchor.insertAdjacentElement('afterend', previewBox);
					previewBox = previewBox.querySelector('div.pad');
					previewBox.style = 'display: flex; flex-direction: column; gap: 5pt;';
					links.forEach(function(link, index) {
						const div = document.createElement('DIV');
						div.textContent = 'Resolving image...';
						div.dataset.url = link; div.dataset.index = index;
						previewBox.append(div);
						ihh.imageUrlResolver(link).then(singleResultGetter).then(function(linkImage) {
							const img = document.createElement('IMG');
							img.onload = function(evt) {
								evt.currentTarget.title = link + ' → ' + linkImage;
								evt.currentTarget.alt = linkImage;
								while (div.lastChild != null) div.removeChild(div.lastChild);
								div.append(evt.currentTarget);
							};
							img.onerror = function(evt) {
								div.textContent = 'Image load error for ' + link;
								div.style.color = 'red';
							};
							img.width = 225; img.referrerPolicy = 'same-origin'; img.src = linkImage;
						}, function(reason) {
							div.textContent = 'No valid image for ' + link;
							div.style.color = 'red';
							div.title = reason;
						});
					});
				});
			}
		} else {
			const useIndexes = urlParams.get('action') == 'notify';
			document.body.querySelectorAll('table.torrent_table').forEach(function(table, index) {
				const parent = findParent(table);
				if (parent) addTableHandlers(table, parent, 'display: inline-block; margin-left: 17pt;',
					useIndexes ? index + 1 : undefined);
			});
		}
		break;
	}
	case '/collages.php':
	case '/collage.php':
		if (coverRelatedCollages && Object.values(coverRelatedCollages).includes(id)) imageHostHelper.then(function(ihh) {
			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('Cover art auto lookup', function(evt) {
					if (checkSavedRecovery()) evt.currentTarget.remove(); else return false;
					const pager = document.body.querySelector('div.main_column > div.linkbox:not(.pager)');
					if (pager != null) pager.scrollIntoView({ behavior: 'smooth', block: 'start' });
					const autoHideFailed = GM_getValue('auto_hide_failed', false);
					document.body.querySelectorAll('table#discog_table > tbody > tr').forEach(function(tr) {
						const td = document.createElement('TD');
						tr.append(td);
						if (tr.classList.contains('colhead_dark')) {
							td.textContent = 'Status';
							td.title = 'Result of attempt to add missing/broken/poor cover\nHover the mouse over status for more details'; //setTooltip(td, tooltip);
						} else if (/^group_(\d+)$/.test(tr.id)) {
							td.className = 'status';
							td.style.opacity = 0.3;
							td.textContent = 'unknown';
							resolveTorrentRow(tr, ihh);
						}
					});
				}, 'covers-auto-lookup', 'gold');
				addButton('Hide failed', function(evt) {
					evt.currentTarget.hidden = true;
					for (let td of document.body.querySelectorAll('table#discog_table > tbody > tr[id] > td.status.status-code-0'))
						td.parentNode.hidden = true;
				}, 'hide-status-failed', undefined, false);
			}
		});
		break;
	case '/userhistory.php':
	case '/top10.php':
		document.body.querySelectorAll('table.torrent_table').forEach(function(table, index) {
			const parent = findParent(table);
			if (parent) addTableHandlers(table, parent, 'display: inline-block; margin-left: 3em;', index + 1);
		});
		break;
	case '/better.php':
		if (['artwork'].includes(urlParams.get('method'))) imageHostHelper.then(function(ihh) {
			const linkBox = document.body.querySelector('div.header > div.linkbox');
			console.assert(linkBox != null);
			if (linkBox == null) throw 'linkbox not found';
			const a = document.createElement('A');
			a.id = 'covers-auto-lookup';
			a.className = 'brackets';
			a.textContent = 'Cover art auto lookup';
			a.href = '#';
			a.onclick = function(evt) {
				if (!checkSavedRecovery()) return false;
				evt.currentTarget.previousSibling.remove();
				evt.currentTarget.remove();
				const pager = document.body.querySelector('div.linkbox.pager');
				if (pager != null) pager.scrollIntoView({ behavior: 'smooth', block: 'start' });
				const div = document.createElement('DIV'), span = document.createElement('SPAN');
				div.style = 'position: fixed; top: 10pt; right: 10pt; padding: 3pt; background-color: #2f4f4f8a; color: white; font-weight: 600; border-radius: 5pt; box-shadow: 2px 2px 3px black';
				div.id = 'hide-status-failed';
				div.hidden = true;
				span.textContent = 'Hide failed';
				span.style = 'display: inline-block; padding: 5pt; cursor: pointer; transition: color 250ms;';
				span.onclick = function(evt) {
					evt.currentTarget.parentNode.hidden = true;
					for (let td of document.body.querySelectorAll('table.torrent_table > tbody > tr.torrent > td.status.status-code-0'))
						td.parentNode.hidden = true;
				};
				span.onmouseenter = span.onmouseleave = function(evt) {
					if (evt.relatedTarget == evt.currentTarget) return false;
					evt.currentTarget.style.color = evt.type == 'mouseenter' ? 'orange' : 'white';
				};
				div.append(span);
				document.body.append(div);
				document.body.querySelectorAll('table.torrent_table > tbody > tr').forEach(function(tr) {
					const td = document.createElement('TD');
					tr.append(td);
					if (!(tr.classList.contains('torrent'))) return;
					td.className = 'status';
					td.style = 'opacity: 0.3;';
					td.textContent = 'unknown';
					resolveTorrentRow(tr, ihh);
				});
				return false;
			};
			linkBox.append(' ', a);
		});
		break;
}