[RED] Cover Inspector

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

目前為 2022-08-20 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

op// ==UserScript==
// @name         [RED] Cover Inspector
// @namespace    https://greasyfork.org/users/321857-anakunda
// @version      1.13.16
// @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 (https://greasyfork.org/users/321857-anakunda)
// @license      GPL-3.0-or-later
// @iconURL      https://i.ibb.co/4gpP2J4/clouseau.png
// @match        https://redacted.ch/torrents.php
// @match        https://redacted.ch/torrents.php?*
// @match        https://redacted.ch/artist.php?id=*
// @match        https://redacted.ch/collages.php?id=*
// @match        https://redacted.ch/collages.php?page=*&id=*
// @match        https://redacted.ch/collage.php?id=*
// @match        https://redacted.ch/collage.php?page=*&id=*
// @match        https://redacted.ch/userhistory.php?action=subscribed_collages
// @match        https://redacted.ch/userhistory.php?page=*&action=subscribed_collages
// @connect      *
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_openInTab
// @grant        GM_registerMenuCommand
// @require      https://openuserjs.org/src/libs/Anakunda/libLocks.min.js
// @require      https://openuserjs.org/src/libs/Anakunda/gazelleApiLib.min.js
// ==/UserScript==

const isFirefox = /\b(?:Firefox)\b/.test(navigator.userAgent) || Boolean(window.InstallTrigger);
const httpParser = /^((?:https?):\/\/.+)$/i;
const preferredHosts = {
	'redacted.ch': ['ptpimg.me'/*, '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 = [ ], queueRecovery = new Set, lastOnQueue;
function openTabLimited(endpoint, params, hash) {
	function updateQueueInfo() {
		let counter = document.getElementById('waiting-tabs-counter');
		if (counter == null) {
			if (queueRecovery.size <= 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; 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 = 'waiting-tabs-counter';
			counter.style.fontWeight = 'bold';
			queueInfo.append(counter, ' release group(s) queued to view');
			document.body.append(queueInfo);
		} else if (queueRecovery.size <= 0) {
			document.body.removeChild(counter.parentNode);
			return;
		}
		counter.textContent = queueRecovery.size;
	}

	if (typeof GM_openInTab != 'function') return Promise.reject('Not supported');
	if (!endpoint) return Promise.reject('Invalid argument');
	const recoveryEntry = { endpoint: endpoint, params: params || null, hash: hash || '' };
	const saveQueue = () => localStorage.setItem('coverInspectorTabsQueue', JSON.stringify(Array.from(queueRecovery)));
	queueRecovery.add(recoveryEntry); 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);
		queueRecovery.delete(recoveryEntry);
		if (maxOpenTabs > 0) updateQueueInfo();
		saveQueue();
		return tabHandler;
	});
	if (maxOpenTabs > 0 && openedTabs.length >= maxOpenTabs) updateQueueInfo();
	return lastOnQueue = lastOnQueue instanceof Promise ? lastOnQueue.then(waitFreeSlot) : waitFreeSlot();
}
const openGroup = groupId => groupId > 0 ? openTabLimited('torrents', { id: groupId }) : null;

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'],
		'NoelShack': ['noelshack.com'],
		'Photobucket': ['photobucket.com'],
		'PicaBox': ['picabox.ru'],
		'PicLoad': ['free-picload.com'],
		'PimpAndHost': ['pimpandhost.com'],
		'Pinterest': ['pinimg.com'],
		'PixHost': ['pixhost.to'],
		'PomfCat': ['pomf.cat'],
		'PostImg': ['postimg.cc'],
		'ProgArchives': ['progarchives.com'],
		'PTPimg': ['ptpimg.me'],
		'Qobuz': ['qobuz.com'],
		'Ra': ['thesungod.xyz'],
		'Radikal': ['radikal.ru'],
		'RA': ['residentadvisor.net'],
		'SavePhoto': ['savephoto.ru'],
		'Shopify': ['shopify.com'],
		'Slowpoke': ['slow.pics'],
		'SoundCloud': ['sndcdn.com'],
		'SM.MS': ['sm.ms'],
		'SVGshare': ['svgshare.com'],
		'Tidal': ['tidal.com'],
		'Traxsource': ['traxsource.com'],
		'Twitter': ['twimg.com'],
		'Upimager': ['upimager.com'],
		'Uupload.ir': ['uupload.ir'],
		'VGMdb': ['vgm.io', 'vgmdb.net'],
		'VgyMe': ['vgy.me'],
		'Wiki': ['wikimedia.org'],
		'Z4A': ['z4a.net'],
		'路过图床': ['imgchr.com'],
	};
	for (let name in knownHosts) if (knownHosts[name].some(function(domain) {
		domain = domain.toLowerCase();
		return imageUrl == domain || imageUrl.endsWith('.' + domain);
	})) return name;
}

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',
];

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';
	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');
	if (descBody instanceof Document) descBody = descBody.getElementsByTagName('A'); else throw 'Invalid argument';
	if (descBody.length > 0) descBody = Array.from(descBody, function(a) {
		if (a.href && a.target == '_blank') try {
			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(target, related) {
				if (target instanceof HTMLElement) {
					target = target.parentNode;
					while (related instanceof HTMLElement) if ((related = related.parentNode) == target) return false;
				}
				return true;
			}
			function addStickerItems(direction = 1, ...elements) {
				if (direction && elements.length > 0) direction = direction > 0 ? 'append' : 'prepend'; else return;
				if (!inListing) for (let element of direction == 'append' ? elements : elements.reverse()) {
					if (sticker.firstChild != null) sticker[direction]('/');
					sticker[direction](element);
				} else sticker[direction](...elements);
			}

			if (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.currentTarget, evt.relatedTarget)) 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 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`;
				}

				sticker.style.backgroundColor = '#ae2300';
				sticker.style.opacity = 2/3;
				sticker.onmouseleave = img.onmouseleave =
					evt => { if (isOutside(evt.currentTarget, evt.relatedTarget)) sticker.style.opacity = 2/3 };
				if (inListing && groupId > 0) editOnClick(sticker);
				if (!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', 'xtrn-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;
					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 = evt => { evt.currentTarget.style.textShadow = '0 0 5px lime' };
						elem.onmouseleave = evt => { evt.currentTarget.style.textShadow = null };
						elem.onclick = clickHandler;
						if (tooltip) setTooltip(elem, tooltip);
						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 = evt => { evt.currentTarget.parentNode.parentNode.style.backgroundColor = '#7fff0040' };
			img[`ondrag${isFirefox ? 'exit' : 'leave'}`] =
				evt => { evt.currentTarget.parentNode.parentNode.style.backgroundColor = null };
		}
		img.ondrop = function(evt) {
			function dataSendHandler(endPoint) {
				sticker = evt.currentTarget.parentNode.querySelector('div.cover-inspector');
				if (sticker != null) sticker.disabled = true;
				img.style.opacity = 0.3;
				endPoint([items[0]], true, false, true, {
					ctrlKey: evt.ctrlKey,
					shiftKey: evt.shiftKey,
					altKey: evt.altKey,
				}).then(ihh.singleImageGetter).then(imageUrl =>
						setGroupImage(groupId, imageUrl, 'Cover update from external link').then(function(response) {
					console.log('[Cover Inspector]', response);
					setNewSrc(img, imageUrl);
					setSticker(imageUrl);
				})).catch(function(reason) {
					ihh.logFail(`groupId ${groupId} cover update failed: ${reason}`);
					if (sticker != null) sticker.disabled = false;
					img.style.opacity = 1;
				});
			}

			evt.stopPropagation();
			let items = evt.dataTransfer.getData('text/uri-list');
			if (items) items = items.split(/\r?\n/); else {
				items = evt.dataTransfer.getData('text/x-moz-url');
				if (items) items = items.split(/\r?\n/).filter((item, ndx) => ndx % 2 == 0);
					else if (items = evt.dataTransfer.getData('text/plain'))
						items = items.split(/\r?\n/).filter(RegExp.prototype.test.bind(httpParser));
			}
			if (Array.isArray(items) && items.length > 0) {
				if (confirm('Update torrent cover from the dropped URL?\n\n' + items[0]))
					dataSendHandler(ihh.rehostImageLinks);
			} else if (evt.dataTransfer.files.length > 0) {
				items = Array.from(evt.dataTransfer.files)
					.filter(file => file instanceof File && file.type.startsWith('image/'));
				if (items.length > 0 && confirm('Update torrent cover from the dropped file?'))
					dataSendHandler(ihh.uploadFiles);
			}
			if (img.clientWidth > 100) evt.currentTarget.parentNode.parentNode.style.backgroundColor = null;
			return false;
		};
	});
	if (hasArtworkSet(img)) return setSticker(realImgSrc(img));
	img.dataset.statusFlags = (0).toString(2).padStart(8, '0');
	if (groupId > 0) editOnClick(img, true);
	return Promise.resolve(0);
}

const dcApiRateControl = { }, dcApiRequestsCache = new Map;

function coverLookup(torrentGroup, ihh) {
	if (!torrentGroup || !ihh) throw 'Invalid argument';
	const dcApiToken = GM_getValue('discogs_api_token'),
				dcApiConsumerKey = GM_getValue('discogs_api_consumerkey'),
				dcApiConsumerSecret = GM_getValue('discogs_api_consumersecret');
	const dcAuth = dcApiToken ? 'token=' + dcApiToken : dcApiConsumerKey && dcApiConsumerSecret ?
		`key=${dcApiConsumerKey}, secret=${dcApiConsumerSecret}` : null;
	const bareReleaseTitle = title => title && [
		/\s+(?:EP|E\.\s?P\.|-\s+(?:EP|E\.\s?P\.))$/i,
		/\s+\((?:EP|E\.\s?P\.|Live)\)$/i, /\s+\[(?:EP|E\.\s?P\.|Live)\]$/i,
		///\s+\((?:feat\.|ft\.|featuring\s).+\)$/i, /\s+\[(?:feat\.|ft\.|featuring\s).+\]$/i,
	].reduce((title, rx) => title.replace(rx, ''), title.trim());
	const audioFileCount = torrent => torrent && torrent.fileList ? torrent.fileList.split('|||').filter(file =>
		/^(.+\.(?:flac|mp3|m4[ab]|aac|dts(?:hd)?|truehd|ac3|ogg|opus|wv|ape))\{{3}(\d+)\}{3}$/i.test(file)).length : 0;
	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) {
						alert('Retried HTTP error 403 on iTunes');
						setTimeout(request, 1000, retryCounter + 1);
					} else reject(defaultErrorHandler(response));
				},
				onerror: response => {
					if (response.status == 403) alert('Unhandled HTTP error 403 on iTunes');
					reject(defaultErrorHandler(response));
				},
				ontimeout: response => { reject(defaultTimeoutHandler(response)) },
			});
			request();
		}) : Promise.reject('Invalid argument');
		lookupWorkers.push(function lookupCoversByUPC() { // 1
			if (!Array.isArray(torrentGroup.torrents) || torrentGroup.torrents.length <= 0)
				return Promise.reject('Cover lookup by UPC not available');
			let upcs = torrentGroup.torrents.map(function(torrent) {
				let catNos = torrent.remasterCatalogueNumber.split('/').map(catNo => catNo.replace(/\s+/g, ''));
				catNos = catNos.filter(RegExp.prototype.test.bind(/^(\d{9,13})$/)).map(catNo => parseInt(catNo));
				return (catNos = catNos.filter(catNo => catNo >= 1e8)).length > 0 ? catNos : null;
			});
			if ((upcs = upcs.filter(Boolean)).length <= 0) return Promise.reject('No torrents with UPC');
			upcs = Array.prototype.concat.apply([ ], upcs);
			return Promise.all(upcs.map(upc => apiQuery('lookup', { upc: upc }).catch(reason => null))).then(artworkUrls =>
				(artworkUrls = artworkUrls.filter(Boolean)).length > 0 ? Array.prototype.concat.apply([ ], artworkUrls)
					: Promise.reject('No covers found by UPC'));
		}, function lookupCoversByTitleYear() { // 2
			function addImportance(importance, maxArtists = 3) {
				if (importance && Array.isArray(torrentGroup.group.musicInfo[importance])
						&& torrentGroup.group.musicInfo[importance].length > 0)
					Array.prototype.push.apply(artistNames,
						torrentGroup.group.musicInfo[importance].slice(0, maxArtists).map(artist => artist.name));
			}

			let artistNames = [ ], albumTitle = bareReleaseTitle(torrentGroup.group.name);
			addImportance('dj');
			if (artistNames.length <= 0 && torrentGroup.group.releaseType != 7) {
				addImportance('artists');
				if (torrentGroup.group.tags && torrentGroup.group.tags.includes('classical')) {
					addImportance('conductor');
					//addImportance('composers');
				}
			}
			if (artistNames.length <= 0) return Promise.reject('Cover lookup by artist/title/year not available');
			return apiQuery('search', {
				term: artistNames.map(artistName => '"' + artistName + '"').join(' ') + ' "' + albumTitle + '"',
				attribute: 'mixTerm',
			}, artistNames.join(' & ').toLowerCase() == albumTitle.toLowerCase()
				|| artistNames.join('').length + albumTitle.length < 15);
		});
	}
	// Extract from desc. links
	lookupWorkers.push(function getImagesFromWikiBody() {
		const links = getLinks(torrentGroup.group.wikiBody);
		if (!links) return Promise.reject('No active external links found in dscriptions');
		return Promise.all(links.map(url => ihh.imageUrlResolver(url.href).then(singleResultGetter, reason => null)))
			.then(imageUrls => (imageUrls = imageUrls.filter(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 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() { // 6
			if (!Array.isArray(torrentGroup.torrents) || torrentGroup.torrents.length <= 0)
				return Promise.reject('Cover lookup by barcode not available');
			let barcodes = torrentGroup.torrents.map(function(torrent) {
				let catNos = torrent.remasterCatalogueNumber.split('/').map(catNo => catNo.replace(/\s+/g, ''));
				catNos = catNos.filter(RegExp.prototype.test.bind(/^(\d{9,13})$/)).map(catNo => parseInt(catNo));
				return (catNos = catNos.filter(catNo => catNo >= 1e8)).length > 0 ? catNos : null;
			});
			if ((barcodes = barcodes.filter(Boolean)).length <= 0) return Promise.reject('No torrents with barcode');
			barcodes = Array.prototype.concat.apply([ ], barcodes);
			return Promise.all(barcodes.map(barcode => search('release', { barcode: barcode }).catch(reason => null)))
				.then(imageUrls => (imageUrls = imageUrls.filter(Boolean)).length > 0 ? Array.prototype.concat.apply([ ], imageUrls)
					: Promise.reject('No covers found by barcode'));
		}, function lookupCoversByCatNo() { // 7
			if (!Array.isArray(torrentGroup.torrents) || torrentGroup.torrents.length <= 0)
				return Promise.reject('Cover lookup by label/cat.bo. not available');
			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() { // 8
			let artistName = torrentGroup.group.musicInfo.dj && torrentGroup.group.musicInfo.dj[0];
			if (!artistName && torrentGroup.group.releaseType != 7 && torrentGroup.group.musicInfo.artists)
				artistName = torrentGroup.group.musicInfo.artists[0];
			if (!artistName && torrentGroup.group.releaseType != 7)
				return Promise.reject('Cover lookup by artist/album/year not available');
			const queryParams = { };
			if (artistName) queryParams.artist = artistName.name;
			queryParams.release_title = bareReleaseTitle(torrentGroup.group.name);
			queryParams.year = torrentGroup.group.year;
			if ([6, 7].includes(torrentGroup.group.releaseType)) queryParams.format = 'Compilation';
			queryParams.strict = true; //!artistName
			return search('master', queryParams);
		}, function lookupCoversByTitleRlsYear() { // 9
			let artistName = torrentGroup.group.musicInfo.dj && torrentGroup.group.musicInfo.dj[0];
			if (!artistName && torrentGroup.group.releaseType != 7 && torrentGroup.group.musicInfo.artists)
				artistName = torrentGroup.group.musicInfo.artists[0];
			if (!artistName/* && torrentGroup.group.releaseType != 7*/)
				return Promise.reject('Cover lookup by artist/album/year not available');
			const queryParams = { };
			if (artistName) queryParams.artist = artistName.name;
			queryParams.release_title = bareReleaseTitle(torrentGroup.group.name);
			if ([6, 7].includes(torrentGroup.group.releaseType)) queryParams.format = 'Compilation';
			queryParams.strict = true; //!artistName
			return search('release', queryParams, true);
		});
	}
	// 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, '.');
						let results = response.response.querySelector('div#imagecol img#coverImage')
						if (results != null && httpParser.test(results = results.src)) {
							if (!results.includes('/nophoto/book/')) return resolve([grImageMax(results)]);
						} else {
							results = response.response.querySelectorAll('table.tableList > tbody > tr');
							if (results.length <= 0) return reject('No matches');
							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) && ![
									'/nophoto/book/',
									'/books/1570622405l/50809027',
								].some(pattern => coverUrl.includes(pattern))) return grImageMax(coverUrl);
							}).filter(Boolean)).length > 0) return resolve(results);
						}
						reject('No valid cover image for matched ebook');
					},
					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);
		lookupWorkers.push(() => search({ q: torrentGroup.group.name
			.replace(/(?:\s+(?:\((?:19|2\d)\d{2}\)|\[(?:19|2\d)\d{2}\]|\((?:epub|mobi|pdf)\)|\[(?:epub|mobi|pdf)\]))+$/ig, '') }));
	}
	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 = evt => { evt.currentTarget.style.color = 'orange' };
		elem.onmouseleave = evt => { evt.currentTarget.style.color = evt.currentTarget.dataset.color || null };
		elem.onclick = clickHandler;
		if (tooltip) elem.title = tooltip; //setTooltip(tooltip);
		container.append(elem);
		return elem;
	}
	function 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 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('xtrn-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) 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);
					});
				ihh.logFail(`groupId ${torrentGroup.group.id} cover lookup failed: ${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 (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) {
				if (img instanceof HTMLImageElement) {
					if (!hasArtworkSet(img)) queryAjaxAPI('torrentgroup', { id: groupId }).then(torrentGroup =>
						{ setCoverFromTorrentGroup(torrentGroup, img, 'missing').then(() => { counterDecrement('missing-covers-countdown', index) }) });
				} else queryAjaxAPI('torrentgroup', { id: groupId }).then(function(torrentGroup) {
					if (!torrentGroup.group.wikiImage) setCoverFromTorrentGroup(torrentGroup, null, 'missing')
						.then(() => { counterDecrement('missing-covers-countdown', index) });
				});
			});
		}, 'auto-add-covers', missingImages.length > 0 ? (missingImages.length + ' covers missing') : undefined);
		addHeaderButton('Fix invalid covers', function autoFix(evt) {
			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) {
				if (img instanceof HTMLImageElement) (function() {
					if (!hasArtworkSet(img)) return Promise.reject('not set');
					const realImageUrl = realImgSrc(img), deproxiedSrc = deProxifyImgSrc(realImageUrl);
					return deproxiedSrc ? setGroupImage(groupId, deproxiedSrc, 'Deproxied release image (not working anymore)')
						.then(result => ihh.verifyImageUrl(deproxiedSrc)) : ihh.verifyImageUrl(realImageUrl);
				})().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).then(() => { counterDecrement('invalid-covers-countdown', index) }) }, ihh.logFail);
				}); else queryAjaxAPI('torrentgroup', { id: groupId }).then(torrentGroup => (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);
				})().catch(function(reason) {
					console.log('[Cover Inspector] Invalid or missing cover for groupId %d, reason:', groupId, reason);
					setCoverFromTorrentGroup(torrentGroup, null, reason).then(() => { counterDecrement('invalid-covers-countdown', index) });
				}), ihh.logFail);
			});
		}, '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 params = new URLSearchParams(document.location.search), id = parseInt(params.get('id')) || undefined;
const findParent = table => table instanceof HTMLElement
	&& Array.prototype.find.call(table.querySelectorAll(':scope > tbody > tr.colhead > td'),
		td => /^(?:Torrents?|Name)\b/.test(td.textContent.trim())) || null;

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;
						setTooltip(a, 'Alt + click to set release cover from this URL (or use context menu command)');
					}
					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');
			});
		} else {
			const useIndexes = params.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)) break;
		imageHostHelper.then(function(ihh) {
			function fixCollagePage(evt) {
				evt.currentTarget.remove();
				const autoHideFailed = GM_getValue('auto_hide_failed', false);
				document.body.querySelectorAll('table#discog_table > tbody > tr').forEach(function(tr) {
					function setStatus(newStatus, ...addedText) {
						if ((td = tr.querySelector('td.status')) == null) return; // assertion failed
						td.textContent = (status = Number(newStatus) || 0) > 1 ? 'success' : 'failed';
						td.className = 'status ' + td.textContent + ' status-code-' + status;
						if (addedText.length > 0) Array.prototype.push.apply(tooltips, addedText);
						if (tooltips.length > 0) td.title = tooltips.join('\n'); else td.removeAttribute('title');
						//setTooltip(td, tooltips.join('\n'));
						td.style.color = ['red', 'orange', '#adad00', 'green'][status];
						td.style.opacity = 1;
						if (status <= 0) if (autoHideFailed) tr.hidden = true;
							else if ((td = document.getElementById('hide-status-failed')) != null) td.hidden = false;
					}

					let status, tooltips = [ ];
					const inspectGroupId = groupId => queryAjaxAPI('torrentgroup', { id: groupId }).then(torrentGroup => (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 => (torrentGroup.group.categoryId == 1 ?
							testImageQuality(imageUrl) : Promise.resolve(-1)).then(function(mpix) {
						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');
						const rfc = () => removeFromCollage(id, torrentGroup.group.id)
							.then(statusCode => { setStatus(status, '(removed from collage)') });
						if (torrentGroup.group.categoryId == 1) getAllCovers(torrentGroup.group.id).then(imageUrls =>
								Promise.all(imageUrls.slice(1).map(ihh.verifyImageUrl)).then(rfc, function(reason) {
							setStatus(1, '(invalid additional cover(s) require attention)', reason);
						}), reason => { setStatus(2, 'Could not count additiona covers (' + reason + ')') }); else rfc();
						if ((!Array.isArray(preferredHosts) || !preferredHosts.includes(hostname))
								&& !isOnDomainList(domain, 0) && isOnDomainList(domain, 1)) {
							ihh.rehostImageLinks(imageUrl, true, false, true).then(ihh.singleImageGetter)
									.then(imageUrl => setGroupImage(torrentGroup.group.id, imageUrl, 'Automated cover rehost').then(function(response) {
								setStatus(status, '(' + response + ')');
								console.log('[Cover Inspector]', response);
							}));
						}
						if (autoOpenSucceed) openGroup(torrentGroup.group.id);
					})).catch(reason => coverLookup(torrentGroup, ihh).then(imageUrls =>
							ihh.rehostImageLinks(imageUrls[0], true, false, false).then(results =>
								results.map(ihh.directLinkGetter)).then(imageUrls =>
									setGroupImage(torrentGroup.group.id, imageUrls[0], autoLookupSummary(reason)).then(function(response) {
						setStatus(3, response, '(reminder - release may contain additional covers to review)');
						if (imageUrls.length > 1) setStatus(2, '(more external links in description require attention)');
						console.log('[Cover Inspector]', response);
						if (autoOpenSucceed) openGroup(torrentGroup.group.id);
						const rfc = () => removeFromCollage(id, torrentGroup.group.id)
							.then(statusCode => { setStatus(status, '(removed from collage)') });
						if (id != badCoverCollages[2]) rfc();
						return testImageQuality(imageUrls[0]).then(mpix => { if (id == badCoverCollages[2]) rfc() }, function(reason) {
							if (id == badCoverCollages[2]) return Promise.reject(reason); else {
								setStatus(2, 'However the image resolution is low');
								if (torrentGroup.group.categoryId == 1 && !inCollage(torrentGroup, 2))
									addToCollage(2, torrentGroup.group.id).then(result =>
										{ setStatus(status, '(added to poor quality covers collage)') });
							}
						});
						// if (torrentGroup.group.categoryId == 1) getAllCovers(torrentGroup.group.id).then(imageUrls =>
						// 		Promise.all(imageUrls.slice(1).map(ihh.verifyImageUrl)).then(rfc, function(reason) {
						// 	tooltip.push('(invalid additional cover(s) require attention)');
						// 	setStatus(status = 1, tooltip);
						// }), function(reason) {
						// 	tooltip.push('Could not count additiona covers (' + reason + ')');
						// 	setStatus(status = 2, tooltip);
						// }); else rfc();
					}))).catch(reason => Array.isArray(torrentGroup.torrents) && torrentGroup.torrents.length > 0 ?
							Promise.all(torrentGroup.torrents.filter(torrent => /\b(?:https?):\/\//i.test(torrent.description))
								.map(torrent => bb2Html(torrent.description).then(getLinks, reason => null))).then(function(urls) {
						if ((urls = urls.filter(Boolean).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':
		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;
}

// 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('Delete and process saved queue?')) return;
		localStorage.removeItem('coverInspectorTabsQueue');
		for (let queuedEntry of savedQueue) openTabLimited(queuedEntry.endpoint, queuedEntry.params, queuedEntry.hash);
	});
} catch(e) { console.warn(e) }