Greasy Fork

Greasy Fork is available in English.

[RED] Cover Inspector

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

当前为 2022-09-07 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

op// ==UserScript==
// @name         [RED] Cover Inspector
// @namespace    http://greasyfork.icu/users/321857-anakunda
// @version      1.13.17
// @run-at       document-end
// @description  Easify & speed-up finding and updating of invalid, missing or non optimal album covers on site
// @author       Anakunda
// @copyright    2020-22, Anakunda (http://greasyfork.icu/users/321857-anakunda)
// @license      GPL-3.0-or-later
// @iconURL      https://i.ibb.co/4gpP2J4/clouseau.png
// @match        https://redacted.ch/torrents.php
// @match        https://redacted.ch/torrents.php?*
// @match        https://redacted.ch/artist.php?id=*
// @match        https://redacted.ch/collages.php?id=*
// @match        https://redacted.ch/collages.php?page=*&id=*
// @match        https://redacted.ch/collage.php?id=*
// @match        https://redacted.ch/collage.php?page=*&id=*
// @match        https://redacted.ch/userhistory.php?action=subscribed_collages
// @match        https://redacted.ch/userhistory.php?page=*&action=subscribed_collages
// @connect      *
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_openInTab
// @grant        GM_registerMenuCommand
// @require      https://openuserjs.org/src/libs/Anakunda/libLocks.min.js
// @require      https://openuserjs.org/src/libs/Anakunda/gazelleApiLib.min.js
// ==/UserScript==

const isFirefox = /\b(?:Firefox)\b/.test(navigator.userAgent) || Boolean(window.InstallTrigger);
const httpParser = /^(https?:\/\/\S+)$/i;
const preferredHosts = {
	'redacted.ch': ['ptpimg.me'/*, 'i.imgur.com'*/],
}[document.domain];
const preferredTypes = GM_getValue('preferred_types', ['jpeg', 'png', 'gif'].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 = ajaxApiKey ? (function() {
	const input = document.head.querySelector('meta[name="ImageHostHelper"]');
	return (input != null ? Promise.resolve(input) : new Promise(function(resolve, reject) {
		const mo = new MutationObserver(function(mutationsList, mo) {
			for (let mutation of mutationsList) for (let node of mutation.addedNodes) {
				if (node.nodeName != 'META' || node.name != 'ImageHostHelper') continue;
				clearTimeout(timer); mo.disconnect();
				return resolve(node);
			}
		}), timer = setTimeout(function(mo) {
			mo.disconnect();
			reject('Timeout reached');
		}, 15000, mo);
		mo.observe(document.head, { childList: true });
	})).then(function(node) {
		console.assert(node instanceof HTMLElement);
		const propName = node.getAttribute('propertyname');
		console.assert(propName);
		return unsafeWindow[propName] || Promise.reject(`Assertion failed: '${propName}' not in unsafeWindow`);
	});
})() : Promise.reject('Ajax API key not configured');

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

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

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 = { };
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;
const openGroup = groupId => groupId > 0 ? openTabLimited('torrents', Object.assign({ id: groupId }, openTabParams)) : null;

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'],
		'Apple': ['mzstatic.com'],
		'Archive': ['archive.org'],
		'Bandcamp': ['bcbits.com'],
		'Beatport': ['beatport.com'],
		'BilderUpload': ['bilder-upload.eu'],
		'Boomkat': ['boomkat.com'],
		'CasImages': ['casimages.com'],
		'Catbox': ['catbox.moe'],
		'CloudFront': ['cloudfront.net'],
		'CubeUpload': ['cubeupload.com'],
		'Deezer': ['dzcdn.net'],
		'Dibpic': ['dibpic.com'],
		'Discogs': ['discogs.com'],
		'Discord': ['discordapp.net'],
		'eBay': ['ebayimg.com'],
		'Extraimage': ['extraimage.org'],
		'FastPic': ['fastpic.ru', 'fastpic.org'],
		'Forumbilder': ['forumbilder.com'],
		'FreeImageHost': ['freeimage.host'],
		'FunkyImg': ['funkyimg.com'],
		'GeTt': ['ge.tt'],
		'GeekPic': ['geekpic.net'],
		'Genius': ['genius.com'],
		'GetaPic': ['getapic.me'],
		'Gifyu': ['gifyu.com'],
		'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'],
		'NoelShack': ['noelshack.com'],
		'OTOTOY': ['ototoy.jp'],
		'Photobucket': ['photobucket.com'],
		'PicaBox': ['picabox.ru'],
		'PicLoad': ['free-picload.com'],
		'PimpAndHost': ['pimpandhost.com'],
		'Pinterest': ['pinimg.com'],
		'PixHost': ['pixhost.to'],
		'PomfCat': ['pomf.cat'],
		'PostImg': ['postimg.cc'],
		'ProgArchives': ['progarchives.com'],
		'PTPimg': ['ptpimg.me'],
		'Qobuz': ['qobuz.com'],
		'Ra': ['thesungod.xyz'],
		'Radikal': ['radikal.ru'],
		'RA': ['residentadvisor.net'],
		'RYM': ['e.snmc.io'],
		'SavePhoto': ['savephoto.ru'],
		'Shopify': ['shopify.com'],
		'Slowpoke': ['slow.pics'],
		'SoundCloud': ['sndcdn.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',
];

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']),
];
const getDomainListIndex = (domain, listNdx) => domain && Array.isArray(listNdx = click2goHostLists[listNdx]) ?
	(domain = domain.toLowerCase(), listNdx.findIndex(domain2 => domain2.toLowerCase() == domain)) : -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;
						size = /^(?:Content-Length)\s*:\s*(\d+)\b/im.exec(response.responseHeaders);
						if (size != null && (size = parseInt(size[1])) > 0) {
							resolve(size);
							if (method != 'HEAD') hXHR.abort();
						} else if (method == 'HEAD') reject('Content size missing or invalid in header');
					},
					onload: function(response) { // fail-safe
						if (size > 0) return; else if (response.status >= 200 && response.status < 400) {
							/*if (response.response) {
								size = response.response.size;
								resolve(size);
							} else */if (response.responseText && (size = response.responseText.length) > 0) resolve(size);
								else reject('Body missing');
						} else reject(defaultErrorHandler(response));
					},
					onerror: response => { reject(defaultErrorHandler(response)) },
					ontimeout: response => { reject(defaultTimeoutHandler(response)) },
				}));
			});
			return getByXHR('GET')/*.catch(reason => getByXHR('GET'))*/;
		})().catch(function(reason) {
			console.warn(`[Cover Inspector] Failed to get remote image size (${imageUrl}):`, reason);
			return null;
		}), (function getRemoteFileType() {
			const getByXHR = (method = 'GET') => new Promise(function(resolve, reject) {
				const params = { method: method, url: imageUrl, 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',
						];
						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(results => ({
		src: results[0].src,
		width: results[0].naturalWidth,
		height: results[0].naturalHeight,
		size: results[1],
		mimeType: results[2],
		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 badCoverCollages = {
	'redacted.ch': [20036, 31445, 31735],
}[document.domain] || [ ];
const inCollage = (torrentGroup, collageIndex) => Array.isArray(badCoverCollages) && badCoverCollages[collageIndex] > 0
	&& torrentGroup && Array.isArray(torrentGroup.group.collages)
	&& torrentGroup.group.collages.some(collage => collage.id == badCoverCollages[collageIndex]);

function addToCollage(collageIndex, groupId) {
	if (!Array.isArray(badCoverCollages)) return Promise.reject('Cover related collages not defined for current site');
	if (!(badCoverCollages[collageIndex] > 0) || !(groupId > 0)) throw 'Invalid argument';
	return ajaxApiKey ? queryAjaxAPI('addtocollage', { collageid: badCoverCollages[collageIndex] }, { groupids: groupId }).then(function(response) {
		if (response.groupsadded.includes(groupId)) return Promise.resolve('Added');
		if (response.groupsrejected.includes(groupId)) return Promise.reject('Rejected');
		if (response.groupsduplicated.includes(groupId)) return Promise.reject('Duplicated');
		return Promise.reject('Unknown status');
	}) : Promise.reject('API key not set');
}

function removeFromCollage(collageId, groupId) {
	if (!(collageId > 0) || !(groupId > 0)) throw 'Invalid argument';
	return userAuth ? new Promise(function(resolve, reject) {
		const xhr = new XMLHttpRequest, payLoad = new URLSearchParams({
			action: 'manage_handle',
			collageid: collageId,
			groupid: groupId,
			auth: userAuth,
			submit: 'Remove',
		});
		xhr.open('POST', '/collages.php', true);
		xhr.onreadystatechange = function() {
			if (xhr.readyState < XMLHttpRequest.DONE) return;
			if (xhr.status >= 200 && xhr.status < 400) resolve(xhr.status); else reject(defaultErrorHandler(xhr));
		};
		xhr.onerror = function() { reject(defaultErrorHandler(xhr)) };
		xhr.ontimeout = function() { reject(defaultTimeoutHandler(xhr)) };
		xhr.send(payLoad);
	}) : Promise.reject('Not supported on this page');
}

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);
	for (var inListing = img; inListing != null; inListing = inListing.parentNode) if (inListing.nodeName == 'DIV')
		if (inListing.classList.contains('group_image')) {
			inListing = true;
			break;
		} else if (inListing.classList.contains('box_image')) {
			inListing = false;
			break;
		}
	if (typeof inListing != 'boolean') throw 'Unexpected cover context';
	let isSecondaryCover = !inListing && /^cover_(\d+)$/.test(img.id), sticker;
	isSecondaryCover = Boolean(isSecondaryCover) && !(parseInt(isSecondaryCover[1]) > 0);
	if (groupId && isSecondaryCover) groupId = undefined;

	function editOnClick(elem, lookupFirst = false) {
		if (!(elem instanceof HTMLElement)) return;
		elem.classList.add('edit');
		elem.style.cursor = 'pointer';
		elem.style.userSelect = 'none';
		elem.style['-webkit-user-select'] = 'none';
		elem.style['-moz-user-select'] = 'none';
		elem.style['-ms-user-select'] = 'none';
		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;
		};
	}

	function setSticker(imageUrl) {
		if ((sticker = img.parentNode.querySelector('div.cover-inspector')) != null) sticker.remove();
		sticker = document.createElement('DIV');
		sticker.className = 'cover-inspector';
		sticker.style = `position: absolute; display: flex; color: white; border: thin solid lightgray;
font-family: "Segoe UI", sans-serif; font-weight: 700; justify-content: flex-end;
cursor: default; transition-duration: 0.25s; z-index: 1; ${inListing ?
			'flex-flow: column; right: 0; bottom: 0; padding: 1pt 0 2pt; font-size: 6.5pt; text-align: right; line-height: 8pt;'
			: 'flex-flow: row wrap; right: -3pt; bottom: -7pt; padding: 1px; font-size: 8.5pt; max-width: 98%;'}`
		if (isSecondaryCover) sticker.style.bottom = '7pt';

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

		return (function() {
			if (!imageUrl) return Promise.reject('Void image URL');
			if (!httpParser.test(imageUrl)) return Promise.reject('Invalid image URL');
			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);
			}

			if (imageDetails.localProxy) setNewSrc(img, imageDetails.src);
			imageDetails.src = new URL(imageDetails.src || imageUrl);
			const isPreferredHost = Array.isArray(preferredHosts) && preferredHosts.includes(imageDetails.src.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(imageDetails.src.href);
			const resolution = span(imageDetails.width + '×' + imageDetails.height, 'resolution', isResolutionFine),
						size = span(formattedSize(imageDetails.size), 'size', isSizeOK),
						type = span(imageDetails.mimeType, 'mime-type', isTypeOK);
			let domain = imageDetails.src.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.altKey) {
						if (!click2goHostLists.some((_, listNdx) => isOnDomainList(domain, listNdx))
								|| !confirm(`This will remove "${domain}" from all domain lists for batch processing`))
							return false;
						for (let listNdx of click2goHostLists.keys()) {
							const domainNdx = getDomainListIndex(domain, listNdx);
							if (domainNdx < 0) continue;
							click2goHostLists[listNdx].splice(domainNdx, 1);
							GM_setValue('click2go_' + ['black', 'white', 'bad'][listNdx] + 'list', click2goHostLists[listNdx]);
						}
						alert('All host lists successfully updated. The change will apply on next batch scan.');
					} else if (evt.ctrlKey || evt.shiftKey) {
						const listNdx = (evt.ctrlKey << 1 | evt.shiftKey << 0) - 1;
						if (isOnDomainList(domain, listNdx) || !confirm([
							`This will exclude "${domain}" from batch rehosting`,
							`This will force include "${domain}" in batch rehosting`,
							`This will consider "${domain}" bad host (new cover will be looked up)`,
						][listNdx])) return false;
						click2goHostLists[listNdx].push(domain);
						GM_setValue('click2go_' + ['black', 'white', 'bad'][listNdx] + 'list', click2goHostLists[listNdx]);
						alert([
							'Hosts blacklist successfully updated. The change will apply on next batch scan.',
							'Hosts whitelist successfully updated.',
							'Hosts badlist successfully updated. The change will apply on next batch scan.',
						][listNdx]);
					}
					return false;
				}
				function getHostTooltip() {
					let tooltip = 'Hosted at ' + imageDetails.src.hostname;
					if (imageDetails.localProxy) tooltip += ' (locally proxied)';
					if (isOnDomainList(domain, 2)) tooltip += ' (bad host)';
					else if (isOnDomainList(domain, 0)) tooltip += ' (blacklisted from batch rehosting)';
					else if (isOnDomainList(domain, 1)) tooltip += ' (whitelisted for batch rehosting)';
					if (isOnDomainList(domain, 2)) tooltip += '\n(look up different version on simple click)';
					else if (!inListing || !isOnDomainList(domain, 0))
						tooltip += '\n(rehost to site preferred host on simple click)';
					return tooltip + `

For host classification:
Shift + click to ban domain from batch rehosts
Ctrl + click to whitelist domain in batch rehosts
Ctrl + Shift + click to mark domain as bad (will be replaced regardless of link validity)
Alt + click to remove domain from all 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 && 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 (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(domain, 0)) {
						host.style.color = '#ffd';
						if (inListing) host.classList.add('blacklisted-from-click2go');
					} else if (isOnDomainList(domain, 1)) {
						if (inListing) host.classList.add('whitelisted');
					} else if (!isOnDomainList(domain, 2)) host.style.color = '#ffa';
					setTooltip(host, getHostTooltip());
					host.onclick = keyHandlers;
					host.onauxclick = function(evt) {
						if (evt.button != 1) return;
						GM_openInTab(evt.ctrlKey ? imageDetails.src.origin : imageDetails.src.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 (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;
						if (lookup == resolution) summary = 'Automated attempt to lookup better quality cover';
						queryAjaxAPI('torrentgroup', { id: groupId }).then(torrentGroup => coverLookup(torrentGroup, ihh)
							.then(imageUrls => ihh.rehostImageLinks(imageUrls[0], true, false, false).then(ihh.singleImageGetter)
								.then(imageUrl => setGroupImage(torrentGroup.group.id, imageUrl, summary).then(function(response) {
							console.log('[Cover Inspector]', response);
							setNewSrc(img, imageUrl);
							setSticker(imageUrl).then(function(status) {
								if ((status & 0b100) != 0) {
									if (inCollage(torrentGroup, 2)) removeFromCollage(badCoverCollages[2], torrentGroup.group.id);
								} else if (torrentGroup.group.categoryId == 1 && !inCollage(torrentGroup, 2))
									addToCollage(2, torrentGroup.group.id);
							});
							if (inListing && autoOpenSucceed) openGroup(torrentGroup.group.id);
						})))).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(imageDetails.src.href, 2160, 90).then(output => output.size < imageDetails.size ?
								ihh.rehostImages([output.uri]).then(ihh.singleImageGetter).then(function(rehostedImgUrl) {
							summary = 'Automated cover downsize';
							if (!isSizeOK) summary += ` (${formattedSize(imageDetails.size)} → ${formattedSize(output.size)})`;
							return setGroupImage(groupId, rehostedImgUrl, summary).then(function(response) {
								console.log('[Cover Inspector]', response);
								setNewSrc(img, rehostedImgUrl);
								setSticker(rehostedImgUrl);
							});
						}) : Promise.reject('Converted image not smaller')).catch(function(reason) {
							ihh.logFail(`groupId ${groupId} cover downsize failed: ${reason}`);
							img.style.opacity = 1;
							downsize.disabled = false;
						}).then(() => { counterDecrement('process-covers-countdown', tableIndex) });
					}, 'Downsize on click') || setClick2Go(host, function(evt) {
						evt.stopPropagation();
						if (evt.shiftKey || evt.ctrlKey || evt.altKey) 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 += ' (' + imageDetails.src.hostname + ')';
						getImageMax(imageDetails.src.href).then(maxImgUrl => ihh.rehostImageLinks(maxImgUrl, true).then(ihh.singleImageGetter))
							.then(rehostedImgUrl => setGroupImage(groupId, rehostedImgUrl, summary).then(function(response) {
								console.log('[Cover Inspector]', response);
								setNewSrc(img, rehostedImgUrl);
								setSticker(rehostedImgUrl);
							})).catch(function(reason) {
								ihh.logFail(`groupId ${groupId} cover rehost failed: ${reason}`);
								img.style.opacity = 1;
								host.disabled = false;
							}).then(() => { counterDecrement('process-covers-countdown', tableIndex) });
					});
				});
			}

			sticker.title = imageDetails.src.href; //setTooltip(sticker, imageDetails.src.href);
			sticker.onmouseenter = img.onmouseenter = evt => { sticker.style.opacity = 1 };
			img.insertAdjacentElement('afterend', sticker);
			const status =  1 << 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;
			sticker.style = `
position: static; padding: 10pt; box-sizing: border-box; width: ${inListing ? '90px' : '100%'}; z-index: 1;
text-align: center; background-color: red; font: 700 auto "Segoe UI", sans-serif;
`;
			sticker.append(span('INVALID'));
			if (groupId > 0 && !isSecondaryCover) editOnClick(sticker, true);
			setTooltip(sticker, reason);
			img.insertAdjacentElement('afterend', sticker);
			img.dataset.statusFlags = (1 << 8).toString(2).padStart(9, '0');
			return 1 << 8;
		});
	}

	if (groupId > 0) 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 (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 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 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 != '/lookup' && (results = results.filter(function(result) {
							let releaseYear = new Date(result.releaseDate);
							if (!isNaN(releaseYear)) releaseYear = releaseYear.getFullYear(); else return false;
							return torrentGroup.torrents.some(function(torrent) {
								if (torrent.fileCount < result.trackCount || torrent.remasterYear != releaseYear) return false;
								return audioFileCount(torrent) == result.trackCount;
							});
						})).length <= 0) return reject('No matches'); else if (results.length > 1) {
							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/year not available');
			return apiQuery('search', {
				term: artistNames.map(artistName => '"' + artistName + '"').join(' ') + ' "' + albumTitle + '"',
				attribute: 'mixTerm',
			}, artistNames.join(' & ').toLowerCase() == albumTitle.toLowerCase()
				|| artistNames.join('').length + albumTitle.length < 15);
		});
	}
	// 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: 'http://coverartarchive.org/' + type + '/' + id,
					headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
					responseType: 'json',
					onload: function(response) {
						if (response.status >= 200 && response.status < 400) {
							if (!response.response.images || response.response.images.length <= 0)
								return reject('No artwork for this id');
							let coverImages = response.response.images.filter(image =>
								image.front || image.types && image.types.includes('Front'));
							//if (coverImages.length <= 0) coverImages = response.response.images;
							coverImages = coverImages.map(image => image.image).filter(Boolean);
							if (coverImages.length > 0) resolve(coverImages); else reject('No front cover for this id');
						} else reject(defaultErrorHandler(response));
					},
					onerror: response => { reject(defaultErrorHandler(response)) },
					ontimeout: response => { reject(defaultTimeoutHandler(response)) },
				});
			}) : Promise.reject('Invalid argument');

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

					if (response.status >= 200 && response.status < 400) if (response.response.count > 0) switch (type) {
						case 'release': {
							let releases = response.response.releases, releaseGroupIds;
							if (!releases) return reject('No matches (renounced)');
							const getReleaseGroupIds = releases => (releaseGroupIds = new Set(releases.map(release =>
								release['release-group'] && release['release-group'].id)));
							if ((strictReleaseMatch || getReleaseGroupIds(releases).size > 1) && getReleaseGroupIds(releases = releases.filter(function(release) {
								let releaseYear = new Date(release.date);
								if (!isNaN(releaseYear)) releaseYear = releaseYear.getFullYear(); else return false;
								return torrentGroup.torrents.some(function(torrent) {
									if (torrent.fileCount < release['track-count'] || torrent.remasterYear != releaseYear) return false;
									return audioFileCount(torrent) == release['track-count'];
								});
							})).size > 1) reject('Ambiguous results'); else getFromRG(releaseGroupIds).catch(function(reason) {
								if (releases.length > 0) Promise.all(releases.map(release =>
										getFrontCovers('release', release.id).then(singleResultGetter, reason => null))).then(function(frontCovers) {
									if ((frontCovers = frontCovers.filter(Boolean)).length > 0) resolve(frontCovers);
										else reject('None of results has front cover');
								}, reject); else reject('No matches');
							});
							break;
						}
						case 'release-group': {
							let releaseGroups = response.response['release-groups'];
							if (!releaseGroups) return reject('No matches (renounced)');
							getFromRG(new Set(releaseGroups.map(releaseGroup => releaseGroup.id))).catch(reject);
							break;
						}
						default: reject('Unsupported search type');
					} else reject('No matches'); else reject(defaultErrorHandler(response));
				},
				onerror: response => { reject(defaultErrorHandler(response)) },
				ontimeout: response => { reject(defaultTimeoutHandler(response)) },
			});
		}) : Promise.reject('Invalid argument');
		lookupWorkers.push(function lookupCoversByBarcode() { // 3
			if (!Array.isArray(torrentGroup.torrents) || torrentGroup.torrents.length <= 0)
				return Promise.reject('Cover lookup by barcode not available');
			let barcodes = torrentGroup.torrents.map(function(torrent) {
				let catNos = torrent.remasterCatalogueNumber.split('/').map(catNo => catNo.replace(/\s+/g, ''));
				catNos = catNos.filter(RegExp.prototype.test.bind(/^(\d{9,13})$/)).map(catNo => parseInt(catNo));
				return (catNos = catNos.filter(catNo => catNo >= 1e8)).length > 0 ? catNos : null;
			});
			if ((barcodes = barcodes.filter(Boolean)).length <= 0) return Promise.reject('No torrents with barcode');
			barcodes = Array.prototype.concat.apply([ ], barcodes);
			return Promise.all(barcodes.map(barcode => search('release', { barcode: barcode }).catch(reason => null)))
				.then(imageUrls => (imageUrls = imageUrls.filter(Boolean)).length > 0 ? Array.prototype.concat.apply([ ], imageUrls)
					: Promise.reject('No covers found by barcode'));
		}, function lookupCoversByCatNo() { // 4
			if (!Array.isArray(torrentGroup.torrents) || torrentGroup.torrents.length <= 0)
				return Promise.reject('Cover lookup by label/cat.bo. not available');
			const queryParams = getAllLabelsCatNos();
			if (queryParams == null) return Promise.reject('No torrents with label/cat.no.');
			return Promise.all(queryParams.map(queryParams => search('release', queryParams).catch(reason => null)))
				.then(imageUrls => (imageUrls = imageUrls.filter(Boolean)).length > 0 ? Array.prototype.concat.apply([ ], imageUrls)
					: Promise.reject('No covers found by label/cat.bo.'));
		}, function lookupCoversByTitleYear() { // 5
			let artistName = torrentGroup.group.musicInfo.dj && torrentGroup.group.musicInfo.dj[0];
			if (!artistName) artistName = torrentGroup.group.musicInfo.artists && torrentGroup.group.musicInfo.artists[0];
			if (!artistName) return Promise.reject('Cover lookup by artist/album/year not available');
			return search('release-group', {
				artistname: artistName.name,
				releasegroup: bareReleaseTitle(torrentGroup.group.name),
				firstreleasedate: torrentGroup.group.year,
			});
		});
	}
	// Ext. lookup at 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, trackCount] = ['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);
							if (releaseYear != null) releaseYear = parseInt(releaseYear[1]); else return false;
							if (itemType == 'a') {
								if (trackCount != null) trackCount = /\b(\d+)\s+tracks?\b/i.exec(trackCount.textContent);
								if (trackCount != null) trackCount = parseInt(trackCount[1]); else return false;
							}
							return torrentGroup.torrents.some(function(torrent) {
								if (torrent.remasterYear != releaseYear) return false; else if (itemType != 'a') return true;
								return torrent.fileCount >= trackCount && audioFileCount(torrent) == trackCount;
							});
						})).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);
						if ((results = results.map(function(result) {
							const image = result.querySelector('a.artcont img');
							return image != null && getImageMax(image.src) || null;
						}).filter(Boolean)).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 lookupCoversByTitleYear() { // 6
			let searchTerm = [ ], mainArtist = torrentGroup.group.musicInfo.dj && torrentGroup.group.musicInfo.dj[0];
			if (!mainArtist && torrentGroup.group.releaseType != 7 && torrentGroup.group.musicInfo.artists)
				mainArtist = torrentGroup.group.musicInfo.artists[0];
			if (mainArtist) searchTerm.push(mainArtist.name);
				else return Promise.reject('Cover lookup by artist/album/year not available');
			searchTerm.push(bareReleaseTitle(torrentGroup.group.name));
			const searchType = torrentGroup.torrents.map(audioFileCount).every(afc => afc == 1) ? 't' : 'a';
			return search(searchTerm.map(name => '"' + name + '"').join(' '), searchType);
		});
	}
	// 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);
				if (isNaN(releaseYear)) return false; else releaseYear = releaseYear.getFullYear();
				return torrentGroup.torrents.some(torrent => torrent.fileCount >= result.total_tracks
					&& torrent.remasterYear == releaseYear && audioFileCount(torrent) == result.total_tracks);
			});
			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);
			}
			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);
			return results.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 = { };
			let artistName = torrentGroup.group.musicInfo.dj && torrentGroup.group.musicInfo.dj[0];
			if (!artistName && torrentGroup.group.releaseType != 7 && torrentGroup.group.musicInfo.artists)
				artistName = torrentGroup.group.musicInfo.artists[0];
			if (artistName) queryParams.artist = artistName;
				else if (torrentGroup.group.releaseType != 7)
					return Promise.reject('Cover lookup by artist/album/year not available');
			queryParams.album = bareReleaseTitle(torrentGroup.group.name);
			return search(queryParams);
		});
	}
	// Ext. lookup at Discogs, requ. credentials
	if (torrentGroup.group.categoryId == 1 && dcAuth) {
		function search(type, queryParams, strictReleaseMatch = false) {
			if (!type || !queryParams) throw 'Invalid argument';
			const url = new URL('https://api.discogs.com/database/search');
			for (let field in queryParams) url.searchParams.set(field, queryParams[field]);
			if (type) url.searchParams.set('type', type = type.toLowerCase());
			url.searchParams.sort = 'score';
			url.searchParams.sort_order = 'desc';
			const cacheKey = url.pathname.slice(1) + url.search;
			if (dcApiRequestsCache.has(cacheKey)) return dcApiRequestsCache.get(cacheKey);
			let retryCounter = 0;
			const request = new Promise((resolve, reject) => (function request() {
				const now = Date.now();
				const postpone = () => { setTimeout(request, dcApiRateControl.timeFrameExpiry - now) };
				if (!dcApiRateControl.timeFrameExpiry || now > dcApiRateControl.timeFrameExpiry) {
					dcApiRateControl.timeFrameExpiry = now + 60 * 1000 + 500;
					if (dcApiRateControl.requestDebt > 0) {
						dcApiRateControl.requestCounter = Math.min(60, dcApiRateControl.requestDebt);
						dcApiRateControl.requestDebt -= dcApiRateControl.requestCounter;
						console.assert(dcApiRateControl.requestDebt >= 0, 'dcApiRateControl.requestDebt >= 0');
					} else dcApiRateControl.requestCounter = 0;
				}
				if (++dcApiRateControl.requestCounter <= 60) GM_xmlhttpRequest({
					method: 'GET',
					url: url,
					headers: {
						'Accept': 'application/json',
						'X-Requested-With': 'XMLHttpRequest',
						'Authorization': 'Discogs ' + dcAuth,
					},
					responseType: 'json',
					onload: function(response) {
						function getFromResults(results) {
							if (!results || results.length <= 0) return reject('No matches');
							const coverImages = results.map(result => result.cover_image || singleResultGetter(result.images))
								.filter(coverImage => coverImage && !coverImage.endsWith('/spacer.gif'));
							if (coverImages.length > 0) resolve(coverImages); else reject('None of results has cover');
						}
						function getFromMR(masterIds) {
							if (!masterIds || masterIds.size <= 0) return Promise.reject('No matches');
							if (masterIds.size > 1) return Promise.reject('Ambiguous results');
							if (!((masterIds = masterIds.values().next().value) > 0)) return Promise.reject('No master release');
							return ihh.imageUrlResolver('https://www.discogs.com/master/' + masterIds)
								.then(singleResultGetter).then(resolve);
						}

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

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

		lookupWorkers.push(function lookupCoversByBarcode() { // 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.bo. 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).catch(reason => null)))
				.then(imageUrls => (imageUrls = imageUrls.filter(Boolean)).length > 0 ? Array.prototype.concat.apply([ ], imageUrls)
					: Promise.reject('No covers found by label/cat.bo.'));
		}, function lookupCoversByTitleYear() { // 11
			let artistName = torrentGroup.group.musicInfo.dj && torrentGroup.group.musicInfo.dj[0];
			if (!artistName && torrentGroup.group.releaseType != 7 && torrentGroup.group.musicInfo.artists)
				artistName = torrentGroup.group.musicInfo.artists[0];
			if (!artistName && torrentGroup.group.releaseType != 7)
				return Promise.reject('Cover lookup by artist/album/year not available');
			const queryParams = { };
			if (artistName) queryParams.artist = artistName.name;
			queryParams.release_title = bareReleaseTitle(torrentGroup.group.name);
			queryParams.year = torrentGroup.group.year;
			if ([6, 7].includes(torrentGroup.group.releaseType)) queryParams.format = 'Compilation';
			queryParams.strict = true; //!artistName
			return search('master', queryParams);
		}, function lookupCoversByTitleRlsYear() { // 12
			let artistName = torrentGroup.group.musicInfo.dj && torrentGroup.group.musicInfo.dj[0];
			if (!artistName && torrentGroup.group.releaseType != 7 && torrentGroup.group.musicInfo.artists)
				artistName = torrentGroup.group.musicInfo.artists[0];
			if (!artistName/* && torrentGroup.group.releaseType != 7*/)
				return Promise.reject('Cover lookup by artist/album/year not available');
			const queryParams = { };
			if (artistName) queryParams.artist = artistName.name;
			queryParams.release_title = bareReleaseTitle(torrentGroup.group.name);
			if ([6, 7].includes(torrentGroup.group.releaseType)) queryParams.format = 'Compilation';
			queryParams.strict = true; //!artistName
			return search('release', queryParams, true);
		});
	}
	// 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/book/', '/books/1570622405l/50809027']
							.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], true, false, false).then(ihh.singleImageGetter).then(imageUrl =>
					setGroupImage(torrentGroup.group.id, imageUrl).then(function(response) {
		console.log('[Cover Inspector]', response);
		if (badCoverCollages) for (let collageIndex of [0, 1]) if (inCollage(torrentGroup, collageIndex))
			removeFromCollage(badCoverCollages[collageIndex], torrentGroup.group.id);
		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 (inCollage(torrentGroup, 2)) removeFromCollage(badCoverCollages[2], torrentGroup.group.id);
				} else if (torrentGroup.group.categoryId == 1 && !inCollage(torrentGroup, 2))
					addToCollage(2, torrentGroup.group.id);
			}, reason => { console.warn('[Cover Inspector] inspectImage(', img, ') failed with reason', reason) });
		} else testImageQuality(imageUrl).then(mpix =>
				{ if (inCollage(torrentGroup, 2)) removeFromCollage(badCoverCollages[2], torrentGroup.group.id) }, reason =>
			{ if (torrentGroup.group.categoryId == 1 && !inCollage(torrentGroup, 2)) addToCollage(2, torrentGroup.group.id) });
	}))).catch(function(reason) {
		if (torrentGroup.group.wikiImage && !inCollage(torrentGroup, 1))
			ihh.verifyImageUrl(torrentGroup.group.wikiImage).catch(reason => { addToCollage(1, 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 = ' releases 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], true, false, false).then(ihh.singleImageGetter).then(imageUrl =>
						setGroupImage(torrentGroup.group.id, imageUrl, autoLookupSummary(reason)).then(function(response) {
				console.log('[Cover Inspector]', response);
				if (badCoverCollages) for (let collageIndex of [0, 1]) if (inCollage(torrentGroup, collageIndex))
					removeFromCollage(badCoverCollages[collageIndex], torrentGroup.group.id);
				if (img instanceof HTMLImageElement) {
					setNewSrc(img, imageUrl);
					inspectImage(img, torrentGroup.group.id).then(function(status) {
						if ((status & 0b100) != 0) {
							if (inCollage(torrentGroup, 2)) removeFromCollage(badCoverCollages[2], torrentGroup.group.id);
						} else if (torrentGroup.group.categoryId == 1 && !inCollage(torrentGroup, 2))
							addToCollage(2, torrentGroup.group.id);
					}, reason => { console.warn('[Cover Inspector] inspectImage(', img, ') failed with reason', reason) });
				} else testImageQuality(imageUrl).then(mpix =>
						{ if (inCollage(torrentGroup, 2)) removeFromCollage(badCoverCollages[2], torrentGroup.group.id) }, reason =>
					{ if (torrentGroup.group.categoryId == 1 && !inCollage(torrentGroup, 2)) addToCollage(2, torrentGroup.group.id) });
				if (autoOpenSucceed) openGroup(torrentGroup.group.id);
				return imageUrl;
			}))).catch(function(reason) {
				if (torrentGroup.group.wikiImage && !inCollage(torrentGroup, 1)) ihh.verifyImageUrl(torrentGroup.group.wikiImage)
					.catch(reason => { addToCollage(1, 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.group.id);
						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) });
			});
		}, 'auto-add-covers', 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.auto-add-covers');
			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);
}

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

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) {
			for (let img of document.body.querySelectorAll('div#covers img')) inspectImage(img, id);
			imageHostHelper.then(function(ihh) {
				function setCoverFromLink(a) {
					console.assert(a instanceof HTMLAnchorElement, 'a instanceof HTMLAnchorElement');
					if (!(a instanceof HTMLAnchorElement)) throw 'Invalid invoker';
					const img = document.body.querySelector('div#covers img');
					ihh.imageUrlResolver(a.href).then(singleResultGetter).then(function(imageUrl) {
						if (img != null) img.style.opacity = 0.3;
						return ihh.rehostImageLinks(imageUrl, true, false, false).then(ihh.singleImageGetter)
								.then(rehostedImage => setGroupImage(id, rehostedImage, 'Cover update from description link').then(function(response) {
							console.log(response);
							if (img != null) {
								setNewSrc(img, rehostedImage);
								inspectImage(img, id);
							} else document.location.reload();
						}));
					}).catch(function(reason) {
						ihh.logFail('Setting cover from link source failed: ' + reason);
						if (img != null && img.style.opacity < 1) img.style.opacity = 1;
					});
				}

				const contextId = '522a6889-27d6-4ea6-a878-20dec4362fbd', menu = document.createElement('menu');
				menu.type = 'context';
				menu.id = contextId;
				menu.className = 'cover-inspector';
				let menuInvoker;
				const setMenuInvoker = evt => { menuInvoker = evt.currentTarget };

				function addMenuItem(label, callback) {
					if (label) {
						const menuItem = document.createElement('MENUITEM');
						menuItem.label = label;
						if (typeof callback == 'function') menuItem.onclick = callback;
						menu.append(menuItem);
					}
					return menu.children.length;
				}

				addMenuItem('Set cover image from this source', evt => { setCoverFromLink(menuInvoker) });
				document.body.append(menu);

				function clickHandler(evt) {
					if (evt.altKey) evt.preventDefault(); else return true;
					if (confirm('Set torrent group cover from this source?')) setCoverFromLink(evt.currentTarget);
					return false;
				}

				function setAnchorHandlers(a) {
					console.assert(a instanceof HTMLAnchorElement, 'a instanceof HTMLAnchorElement');
					if (!(a instanceof HTMLAnchorElement)) return false;
					a.setAttribute('contextmenu', contextId);
					a.oncontextmenu = setMenuInvoker;
					if (a.protocol.startsWith('http') && !a.onclick) {
						a.onclick = clickHandler;
						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': {
		function getAllCovers(groupId) {
			if (!(groupId > 0)) throw 'Invalid argument';
			return new Promise(function(resolve, reject) {
				const xhr = new XMLHttpRequest;
				xhr.open('GET', 'torrents.php?' + new URLSearchParams({ id: groupId }).toString(), true);
				xhr.responseType = 'document';
				xhr.onload = function() {
					if (this.status >= 200 && this.status < 400)
						resolve(Array.from(this.response.querySelectorAll('div#covers div > p > img'), realImgSrc));
							else reject(defaultErrorHandler(this));
				};
				xhr.onerror = function() { reject(defaultErrorHandler(xhr)) };
				xhr.ontimeout = function() { reject(defaultTimeoutHandler(xhr)) };
				xhr.send();
			});
		}

		if (badCoverCollages.includes(id)) imageHostHelper.then(function(ihh) {
			function fixCollagePage(evt) {
				if (checkSavedRecovery()) evt.currentTarget.remove(); else return false;
				const autoHideFailed = GM_getValue('auto_hide_failed', false);
				document.body.querySelectorAll('table#discog_table > tbody > tr').forEach(function(tr) {
					function setStatus(newStatus, ...addedText) {
						if ((td = tr.querySelector('td.status')) == null) return; // assertion failed
						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;
					}

					let status, tooltips = [ ];
					const inspectGroupId = groupId => queryAjaxAPI('torrentgroup', { id: groupId }).then(function(torrentGroup) {
						function removeFromThisCollage(verifyAdditionalCovers = false) {
							const removeFromThisCollage = () => removeFromCollage(id, torrentGroup.group.id)
								.then(statusCode => { setStatus(status, '(removed from collage)') });
							if (verifyAdditionalCovers && torrentGroup.group.categoryId == 1
									&& [0, 1].includes(badCoverCollages.indexOf(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 && !inCollage(torrentGroup, 2))
								addToCollage(2, 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[0], true, false, false).then(results =>
									results.map(ihh.directLinkGetter)).then(function(imageUrls) {
							if (id == badCoverCollages[2]) 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();
								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(resolution) {
							function finalizeExisting() {
								removeFromThisCollage(true);
								if (Array.isArray(preferredHosts) && preferredHosts.includes(hostname)
										|| isOnDomainList(domain, 0)/* || !isOnDomainList(domain, 1)*/)
									return Promise.reject('Not rehostable');
								return ihh.rehostImageLinks(imageUrl, true, false, true).then(ihh.singleImageGetter)
										.then(imageUrl => setGroupImage(torrentGroup.group.id, imageUrl, 'Automated cover rehost').then(function(response) {
									setStatus(status, '(' + response + ')');
									console.log('[Cover Inspector]', response);
									return imageUrl;
								}));
							}

							const hostname = new URL(imageUrl).hostname.toLowerCase(),
										domain = hostname.split('.').slice(-2).join('.');
							if (isOnDomainList(domain, 2)) return Promise.reject('Unacknowledged host');
							setStatus(3, 'This release seems to have a valid image');
							if (id != badCoverCollages[2]) {
								if (resolution > 0 && inCollage(torrentGroup, 2)) removeFromCollage(badCoverCollages[2], torrentGroup.group.id)
									.then(statusCode => { setStatus(status, '(removed from ppor quality covers collage)') });
								if (resolution <= 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 (resolution <= 0) {
								setStatus(status, 'However the image quality is poor (resolution)');
								return Promise.reject('Insufficient image quality');
							}
							if (resolution > 0) finalizeExisting();
							if (autoOpenSucceed) openGroup(torrentGroup.group.id);
						})).catch(reason => (function() {
							if (status >= 0) setStatus(status, reason);
							return lookup(reason, true);
						})().then(() => { if (autoOpenSucceed) openGroup(torrentGroup.group.id) }, 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.group.id);
							console.log('[Cover Inspector] Links found in torrent descriptions for', torrentGroup, ':', urls);
						}) : Promise.reject(reason)));
					}).catch(reason => { setStatus(0, reason) });

					let td = document.createElement('TD');
					tr.append(td);
					if (tr.classList.contains('colhead_dark')) {
						td.textContent = 'Status';
						const tooltip = 'Result of attempt to add missing/broken cover\nHover the mouse over status for more details';
						td.title = tooltip; //setTooltip(td, tooltip);
					} else if (/^group_(\d+)$/.test(tr.id)) {
						td.className = 'status';
						td.style.opacity = 0.3;
						td.textContent = 'unknown';
						const groupId = getGroupId(tr);
						if (groupId > 0) inspectGroupId(groupId); else setStatus(0, 'Could not extract torrent id');
					}
				});
			}

			const td = document.body.querySelector('table#discog_table > tbody > tr.colhead_dark > td:nth-of-type(3)');
			if (td != null) {
				function addButton(caption, clickHandler, id, color = 'currentcolor', visible = true, tooltip) {
					if (!caption || typeof clickHandler != 'function') throw 'Invalid argument';
					const elem = document.createElement('SPAN');
					if (id) elem.id = id;
					elem.className = 'brackets';
					elem.textContent = caption;
					elem.style = `float: right; margin-right: 1em; cursor: pointer; color: ${color};`;
					elem.onclick = clickHandler;
					if (!visible) elem.hidden = true;
					if (tooltip) elem.title = tooltip;
					td.append(elem);
					return elem;
				}

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