[RED] Cover Inspector

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

Installer dette script?
Skaberens foreslåede script

Du vil måske også kunne lide Image Host Helper

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

{

'use strict';

const isFirefox = /\b(?:Firefox)\b/.test(navigator.userAgent) || Boolean(window.InstallTrigger);
const httpParser = /^(https?:\/\/\S+)$/i;
const preferredHosts = {
	'redacted.ch': ['ptpimg.me'],
}[document.domain];
const preferredTypes = GM_getValue('preferred_types', ['jpeg', 'png', 'gif', 'jpg'].map(type => 'image/' + type));

function defaultErrorHandler(response) {
	console.error('HTTP error:', response);
	let reason = 'HTTP error ' + response.status;
	if (response.status == 0) reason += '/' + response.readyState;
	let statusText = response.statusText;
	if (response.response) try {
		if (typeof response.response.error == 'string') statusText = response.response.error;
	} catch(e) { }
	if (statusText) reason += ' (' + statusText + ')';
	return reason;
}
function defaultTimeoutHandler(response) {
	console.error('HTTP timeout:', response);
	let reason = 'HTTP timeout';
	if (response.timeout) reason += ' (' + response.timeout + ')';
	return reason;
}

const uaVersions = { };
function setUserAgent(params, suffixLen = 8) {
	if (params && typeof params == 'object' && httpParser.test(params.url)) try {
		const url = new URL(params.url);
		if ([document.location.hostname, 'ptpimg.me'].includes(url.hostname)) return;
		//return ['dzcdn.', 'mzstatic.com'].some(pattern => hostname.includes(pattern));
		params.anonymous = true;
		if (!navigator.userAgent) return;
		if (!uaVersions[url.hostname] || ++uaVersions[url.hostname].usageCount > 16) uaVersions[url.hostname] = {
			versionSuffix: Math.floor(Math.random() * Math.pow(2, suffixLen * 4)).toString(16).padStart(suffixLen, '0'),
			usageCount: 1,
		};
		if (!params.headers) params.headers = { };
		params.headers['User-Agent'] = navigator.userAgent.replace(/\b(Gecko|\w*WebKit|Blink|Goanna|Flow|\w*HTML|Servo|NetSurf)\/(\d+(\.\d+)*)\b/,
			(match, engine, engineVersion) => engine + '/' + engineVersion + '.' + uaVersions[url.hostname].versionSuffix);
	} catch(e) { console.warn('Invalid url:', params.url) }
}

function formattedSize(size) {
	const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB'];
	const e = (size = Math.max(size, 0)) > 0 ? Math.min(Math.floor(Math.log2(size) / 10), units.length - 1) : 0;
	return `${(size / Math.pow(2, e * 10)).toFixed(Math.min(e, 3))}\xA0${units[e]}`;
}

const imageHostHelper = 'imageHostHelper' in unsafeWindow ? unsafeWindow.imageHostHelper ? Promise.resolve(unsafeWindow.imageHostHelper)
		: Promise.reject('Assertion failed: void unsafeWindow.imageHostHelper') : new Promise(function(resolve, reject) {
	function listener(evt) {
		clearTimeout(timeout);
		unsafeWindow.removeEventListener('imageHostHelper', listener);
		//console.log('imageHostHelper exports triggered:', evt);
		if (evt.data) resolve(evt.data); else if (unsafeWindow.imageHostHelper) resolve(unsafeWindow.imageHostHelper);
			else reject('Assertion failed: void unsafeWindow.imageHostHelper');
	}

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

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

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

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

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

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

const acceptableSize = { 'redacted.ch': GM_getValue('acceptable_cover_size') }[document.domain] || 4 * 2**10;
const fineResolution = { 'redacted.ch': 500 }[document.domain] || 500;
let acceptableResolution = { 'redacted.ch': GM_getValue('acceptable_cover_resolution') }[document.domain] || 300;
if (fineResolution > 0 && acceptableResolution > fineResolution) acceptableResolution = fineResolution;
let hqResolution = { 'redacted.ch': GM_getValue('hq_cover_resolution') }[document.domain] || 1024;
if (fineResolution > 0 && hqResolution < fineResolution) hqResolution = fineResolution;

function getHostFriendlyName(imageUrl) {
	if (httpParser.test(imageUrl)) try { imageUrl = new URL(imageUrl) } catch(e) { console.error(e) }
	if (imageUrl instanceof URL) imageUrl = imageUrl.hostname.toLowerCase(); else return;
	const knownHosts = {
		'2i': ['2i.cz'],
		'7digital': ['7static.com'],
		'AcousticSounds': ['acousticsounds.com'],
		'Abload': ['abload.de'],
		'AllMusic': ['rovicorp.com'],
		'AllThePics': ['allthepics.net'],
		'Amazon': ['media-amazon.com', 'ssl-images-amazon.com', 'amazonaws.com'],
		'AnimeBytes': ['animebytes.tv'],
		'Apple': ['mzstatic.com'],
		'Archive': ['archive.org'],
		'Bandcamp': ['bcbits.com'],
		'Bangumi': ['bgm.tv'],
		'Beatport': ['beatport.com'],
		'BilderUpload': ['bilder-upload.eu'],
		'Boomkat': ['boomkat.com'],
		'CasImages': ['casimages.com'],
		'Catbox': ['catbox.moe'],
		'CloudFront': ['cloudfront.net'],
		'CubeUpload': ['cubeupload.com'],
		'Deezer': ['dzcdn.net'],
		'Dibpic': ['dibpic.com'],
		'Discogs': ['discogs.com'],
		'Discord': ['discordapp.net', 'discordapp.com'],
		'e-onkyo': ['e-onkyo.com'],
		'eBay': ['ebayimg.com'],
		'Extraimage': ['extraimage.org'],
		'FastPic': ['fastpic.ru', 'fastpic.org'],
		'Forumbilder': ['forumbilder.com'],
		'FreeImageHost': ['freeimage.host'],
		'FunkyImg': ['funkyimg.com'],
		'GeTt': ['ge.tt'],
		'GeekPic': ['geekpic.net'],
		'Genius': ['genius.com'],
		'GetaPic': ['getapic.me'],
		'Gifyu': ['gifyu.com'],
		'Goodreads': ['i.gr-assets.com'],
		'GooPics': ['goopics.net'],
		'HDtracks': ['cdn.hdtracks.com'],
		'HRA': ['highresaudio.com'],
		'imageCx': ['image.cx'],
		'ImageBan': ['imageban.ru'],
		'ImageKit': ['imagekit.io'],
		'ImagensBrasil': ['imagensbrasil.org'],
		'ImageRide': ['imageride.com'],
		'ImageToT': ['imagetot.com'],
		'ImageVenue': ['imagevenue.com'],
		'ImgBank': ['imgbank.cz'],
		'ImgBB': ['ibb.co'],
		'ImgBox': ['imgbox.com'],
		'ImgCDN': ['imgcdn.dev'],
		'Imgoo': ['imgoo.com'],
		'ImgPile': ['imgpile.com'],
		'imgsha': ['imgsha.com'],
		'Imgur': ['imgur.com'],
		'ImgURL': ['png8.com'],
		'IpevRu': ['ipev.ru'],
		'Jerking': ['jerking.empornium.ph'],
		'JPopsuki': ['jpopsuki.eu'],
		'Juno': ['junodownload.com'],
		'Last.fm': ['lastfm.freetls.fastly.net', 'last.fm'],
		'Lensdump': ['lensdump.com'],
		'LightShot': ['prntscr.com'],
		'LostPic': ['lostpic.net'],
		'LutIm': ['lut.im'],
		'MetalArchives': ['metal-archives.com'],
		'MixCloud': ['mixcloud.com'],
		'Mobilism': ['mobilism.org'],
		'Mora': ['mora.jp'],
		'MusicBrainz': ['coverartarchive.org'],
		'Naxos': ['cdn.naxos.com'],
		'NetEase': ['126.net'],
		'NoelShack': ['noelshack.com'],
		'OTOTOY': ['ototoy.jp'],
		'Photobucket': ['photobucket.com'],
		'PicaBox': ['picabox.ru'],
		'PicLoad': ['free-picload.com', 'picload.org'],
		'PimpAndHost': ['pimpandhost.com'],
		'Pinterest': ['pinimg.com'],
		'PixHost': ['pixhost.to'],
		'PomfCat': ['pomf.cat'],
		'PostImg': ['postimg.cc'],
		'ProgArchives': ['progarchives.com'],
		'PTPimg': ['ptpimg.me'],
		'Qobuz': ['qobuz.com'],
		'QQ音乐': ['qq.com'],
		'Ra': ['thesungod.xyz'],
		'Radikal': ['radikal.ru'],
		'RA': ['residentadvisor.net'],
		'RYM': ['e.snmc.io'],
		'SavePhoto': ['savephoto.ru'],
		'Shopify': ['shopify.com'],
		'Slowpoke': ['slow.pics'],
		'SoundCloud': ['sndcdn.com'],
		'Spotify': ['scdn.co'],
		'Stereogum': ['stereogum.com'],
		'SM.MS': ['sm.ms', 'loli.net'],
		'Stereogum': ['stereogum.com'],
		'SVGshare': ['svgshare.com'],
		'Tidal': ['tidal.com'],
		'Traxsource': ['traxsource.com'],
		'Twitter': ['twimg.com'],
		'Upimager': ['upimager.com'],
		'Uupload.ir': ['uupload.ir'],
		'VGMdb': ['vgm.io', 'vgmdb.net'],
		'VgyMe': ['vgy.me'],
		'Wiki': ['wikimedia.org'],
		'Z4A': ['z4a.net'],
		'路过图床': ['imgchr.com'],
	};
	for (let name in knownHosts) if (knownHosts[name].some(domain =>
		imageUrl == (domain = domain.toLowerCase()) || 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', 'dicmusic.com', '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', 'extraimage.org', 'fastpic.ru', 'fastpic.org', 'forumbilder.com', 'freeimage.host',
		'funkyimg.com', 'ge.tt', 'geekpic.net', 'getapic.me', 'gifyu.com', 'goopics.net', 'image.cx', 'imageban.ru',
		'imagekit.io', 'imagensbrasil.org', 'imageride.com', 'imagetot.com', 'imagevenue.com', 'imgbank.cz', 'ibb.co',
		'imgbox.com', 'imgcdn.dev', 'imgoo.com', 'imgpile.com', 'imgsha.com', 'png8.com', 'ipev.ru', 'jerking.empornium.ph',
		'lensdump.com', 'prntscr.com', 'lostpic.net', 'lut.im', 'noelshack.com', 'photobucket.com', 'picabox.ru',
		'free-picload.com', 'pimpandhost.com', 'pinimg.com', 'pixhost.to', 'pomf.cat', 'postimg.cc', 'thesungod.xyz',
		'radikal.ru', 'savephoto.ru', 'slow.pics', 'sm.ms', 'svgshare.com', 'twimg.com', 'upimager.com', 'uupload.ir',
		'vgy.me', 'z4a.net', 'imgchr.com',
	].concat(GM_getValue('no_covers_here', [ ])).some(hostName => hostName
		&& (str == (hostName = hostName.toLowerCase()) || str.endsWith('.' + hostName)))) return true;
	str = url.pathname.toLowerCase();
	const pathParts = {
		'discogs.com': ['artist', 'label', 'user'].map(folder => '/' + folder + '/'),
	};
	for (let domain in pathParts) if ((url.hostname == domain || url.hostname.endsWith('.' + domain))
			&& pathParts[domain].some(pathPart => str.includes(pathPart.toLowerCase()))) return true;
	return false;
}

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

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

const click2goHostLists = [
	GM_getValue('click2go_blacklist', ['amazonaws.com', 'coverartarchive.org', 'archive.org']),
	GM_getValue('click2go_whitelist', musicResourceDomains.concat([
		'imgur.com', 'forumbilder.com', 'jpopsuki.eu', 'pinimg.com', 'shopify.com',
		'twimg.com', 'discordapp.com', 'discordapp.net',
	])),
	GM_getValue('click2go_badlist', [
		'photobucket.com', 'imgs.onl', 'sli.mg', 'picload.org', 'kprofiles.com',
	]),
].map(click2goHostList => click2goHostList.filter((e, n, a) => a.indexOf(e) == n).sort());
if (click2goHostLists[0].some(hostName => /\b(?:imgur\.com)$/i.test(hostName))) GM_setValue('click2go_blacklist',
	click2goHostLists[0] = click2goHostLists[0].filter(hostName => !/\b(?:imgur\.com)$/i.test(hostName)));
if (!click2goHostLists[1].includes('imgur.com')) {
	click2goHostLists[1].push('imgur.com');
	GM_setValue('click2go_whitelist', click2goHostLists[1].sort());
}

const getDomainListIndex = (domain, listNdx) => domain && Array.isArray(listNdx = click2goHostLists[listNdx]) ?
	(domain = domain.toLowerCase(), listNdx.findIndex(domain2 => (domain2 = domain2.toLowerCase()) == domain
		|| domain.endsWith('.' + domain2))) : -1;
const isOnDomainList = (domain, listNdx) => getDomainListIndex(domain, listNdx) >= 0;

const domParser = new DOMParser;
const autoOpenSucceed = GM_getValue('auto_open_succeed', true);
const autoOpenWithLink = GM_getValue('auto_open_with_link', true);
const hasArtworkSet = img => img instanceof HTMLImageElement && img.src && !img.src.includes('/static/common/noartwork/');
const singleResultGetter = result => Array.isArray(result) ? result[0] : result;

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

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

function getImageMax(imageUrl) {
	const friendlyName = getHostFriendlyName(imageUrl);
	return imageHostHelper.then(ihh => (function() {
		const func = friendlyName && {
			'Deezer': 'getDeezerImageMax',
			'Discogs': 'getDiscogsImageMax',
		}[friendlyName];
		return func && func in ihh ? ihh[func](imageUrl) : Promise.reject('No imagemax function');
	})().catch(function(reason) {
		let sub = friendlyName && {
			'Bandcamp': [/_\d+(?=\.(\w+)$)/, '_10'],
			'Deezer': ihh.dzrImageMax,
			'Apple': ihh.itunesImageMax,
			'Qobuz': [/_\d{3}(?=\.(\w+)$)/, '_org'],
			'Boomkat': [/\/(?:large|medium|small)\//i, '/original/'],
			'Beatport': [/\/image_size\/\d+x\d+\//i, '/image/'],
			'Tidal': [/\/(\d+x\d+)(?=\.(\w+)$)/, '/1280x1280'],
			'Amazon': [/\._\S+?_(?=\.)/, ''],
			'HRA': [/_(\d+x\d+)(?=\.(\w+)$)/, ''],
		}[friendlyName];
		if (sub) sub = String(imageUrl).replace(...sub); else return Promise.reject('No imagemax substitution');
		return ihh.verifyImageUrl(sub);
	}).catch(reason => ihh.verifyImageUrl(imageUrl)));
}

if ('imageDetailsCache' in sessionStorage) try {
	var imageDetailsCache = JSON.parse(sessionStorage.getItem('imageDetailsCache'));
} catch(e) { console.warn(e) }
if (!imageDetailsCache || typeof imageDetailsCache != 'object') imageDetailsCache = { };
const imgLoadCache = new Map, imgRequestCache = new Map;

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([
		(function(imageUrl) {
			if (imgLoadCache.has(imageUrl)) return imgLoadCache.get(imageUrl);
			const loadWorker = new Promise(function(resolve, reject) {
				const image = new Image;
				image.onload = evt => { resolve({
					src: evt.currentTarget.src,
					width: evt.currentTarget.naturalWidth,
					height: evt.currentTarget.naturalHeight,
				}) };
				image.onerror = evt => { reject(evt.message || 'Image loading error (' + image.src + ')') };
				image.loading = 'eager';
				image.referrerPolicy = 'same-origin';
				image.src = imageUrl;
			});
			imgLoadCache.set(imageUrl, loadWorker);
			return loadWorker;
		})(imageUrl),
		(function(imageUrl) {
			if (imgRequestCache.has(imageUrl)) return imgRequestCache.get(imageUrl);
			const getByXHR = (method = 'GET') => new Promise(function(resolve, reject) {
				const params = { method: method, url: imageUrl, binary: true, timeout: 90e3, responseType: 'blob' };
				setUserAgent(params);
				let hdrSize, hdrType, hXHR = GM_xmlhttpRequest(Object.assign(params, {
					onreadystatechange: function(response) {
						if (hdrSize >= 0 && type || response.readyState < XMLHttpRequest.HEADERS_RECEIVED) return;
						if (response.status < 200 || response.status >= 400) {
							reject(defaultErrorHandler(response));
							return hXHR.abort();
						}
						if ([
							'imgur.com/removed.png',
							'gtimg.cn/music/photo_new/T001M000003kfNgb0XXvgV_0.jpg',
							'//discogs.com/8ce89316e3941a67b4829ca9778d6fc10f307715/images/spacer.gif',
							'amazon.com/images/I/31CTP6oiIBL.jpg',
							'amazon.com/images/I/31zMd62JpyL.jpg',
							'amazon.com/images/I/01RmK+J4pJL.gif',
							'/0dc61986-bccf-49d4-8fad-6b147ea8f327.jpg',
							'/ab2d1d04-233d-4b08-8234-9782b34dcab8.jpg',
							'postimg.cc/wkn3jcyn9/image.jpg',
							'tinyimg.io/notfound',
							'hdtracks.com/img/logo.jpg',
							'vgy.me/Dr3kmf.jpg',
							'/images/no-cover.png',
						].some(invalidUrl => response.finalUrl.endsWith(invalidUrl))) {
							reject('Dummy image (placeholder): ' + response.finalUrl);
							return hXHR.abort();
						}
						const Etag = /^(?:Etag)\s*:\s*(.+?)\s*$/im.exec(response.responseHeaders);
						if (Etag != null && [
							'd835884373f4d6c8f24742ceabe74946',
							'25d628d3d3a546cc025b3685715e065f42f9cbb735688b773069e82aac16c597f03617314f78375d143876b6d8421542109f86ccd02eab6ba8b0e469b67dc953',
							'"55fade2068e7503eae8d7ddf5eb6bd09"',
							'"1580238364"',
							'"rbFK6Ned4SXbK7Fsn+EfdgKVO8HjvrmlciYi8ZvC9Mc"',
							'7ef77ea97052c1abcabeb44ad1d0c4fce4d269b8a4f439ef11050681a789a1814fc7085a96d23212af594b6b2855c99f475b8b61d790f22b9d71490425899efa',
						].some(etag => etag.toLowerCase() == Etag[1].toLowerCase())) {
							reject('Dummy image (placeholder): ' + response.finalUrl);
							return hXHR.abort();
						}
						hdrSize = /^(?:Content-Length)\s*:\s*(\d+)\b/im.exec(response.responseHeaders);
						hdrSize = hdrSize != null ? parseInt(hdrSize[1]) : undefined;
						hdrType = /^(?:Content-Type)\s*:\s*(.+?)(?:\s*;(.+?))?\s*$/im.exec(response.responseHeaders);
						hdrType = hdrType != null ? hdrType[1] : undefined;
						if (hdrSize >= 0 && hdrType) {
							resolve({ size: hdrSize, type: hdrType });
							if (method != 'HEAD') hXHR.abort();
						} else if (method == 'HEAD') reject('Content size/type missing or invalid in header');
					},
					onload: function(response) {
						if (response.status >= 200 && response.status < 400) try {
							if ((hdrSize = response.response) instanceof Blob) resolve({
								size: hdrSize.size,
								type: hdrSize.type || hdrType,
							}); else if ((hdrSize = new Blob([response.responseText])) instanceof Blob) resolve({
								size: hdrSize.size,
								type: hdrType,
							}); else reject('Image content could not be loaded');
						} catch(e) { reject(e) } else reject(defaultErrorHandler(response));
					},
					onerror: response => { reject(defaultErrorHandler(response)) },
					ontimeout: response => { reject(defaultTimeoutHandler(response)) },
				}));
			});
			const loadWorker = getByXHR('GET'); //getByXHR('HEAD').catch(reason => getByXHR('GET'));
			imgRequestCache.set(imageUrl, loadWorker);
			return loadWorker;
		})(imageUrl).catch(function(reason) {
			console.warn(`[Cover Inspector] Failed to request remote image (${imageUrl}):`, reason);
			return null;
		}),
	]).then(workers => Object.assign({ localProxy: false }, workers[0], workers[1])).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');
let noEditPerms = document.getElementById('nav_userclass'), cseSearchMenu;
noEditPerms = noEditPerms != null && ['User', 'Member', 'Power User'].includes(noEditPerms.textContent.trim());
const [readOnly, noBatchProcessing] = ['read_only', 'no_batch_processing'].map(prefName => GM_getValue(prefName, false));
const noAutoLookups = GM_getValue('no_auto_lookups', true);

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

function addToCoversCollage(collageIndex, groupId) {
	if (!coverRelatedCollages || !(Array.isArray(coverRelatedCollages[collageIndex]))
			|| coverRelatedCollages[collageIndex].length <= 0)
		return Promise.reject('Cover related collage not defined for current site');
	if (!(groupId > 0)) throw 'Invalid argument';
	return ajaxApiKey ? queryAjaxAPI('addtocollage', {
		collageid: coverRelatedCollages[collageIndex][GM_getValue('indifferent_collages', true) ?
			Math.floor(Math.random() * coverRelatedCollages[collageIndex].length) : 0],
	}, { groupids: groupId }).then(function(response) {
		if (response.groupsadded.includes(groupId)) return Promise.resolve('Added');
		if (response.groupsrejected.includes(groupId)) return Promise.reject('Rejected');
		if (response.groupsduplicated.includes(groupId)) return Promise.reject('Duplicated');
		return Promise.reject('Unknown status');
	}) : Promise.reject('API key not set');
}

function removeFromCollage(collageId, groupId) {
	if (!(collageId > 0) || !(groupId > 0)) throw 'Invalid argument';
	return userAuth ? new Promise(function(resolve, reject) {
		const xhr = new XMLHttpRequest, payLoad = new URLSearchParams({
			action: 'manage_handle',
			collageid: collageId,
			groupid: groupId,
			auth: userAuth,
			submit: 'Remove',
		});
		xhr.open('POST', '/collages.php', true);
		xhr.onreadystatechange = function() {
			if (this.readyState < XMLHttpRequest.DONE) return;
			if (this.status >= 200 && this.status < 400) resolve(this.status); else reject(defaultErrorHandler(this));
		};
		xhr.onerror = function() { reject(defaultErrorHandler(this)) };
		xhr.ontimeout = function() { reject(defaultTimeoutHandler(this)) };
		xhr.send(payLoad);
	}) : Promise.reject('Not supported on this page');
}
const removeFromCoversCollage = (collageIndex, torrentGroup) => coverRelatedCollages
	&& Array.isArray(coverRelatedCollages[collageIndex]) ? Promise.all(torrentGroup.group.collages
		.map(collage => collage.id).filter(collageId => coverRelatedCollages[collageIndex].includes(collageId))
		.map(collageId => removeFromCollage(collageId, torrentGroup.group.id))).then(statuses => statuses.length)
	: Promise.reject('Cover related collages not defined for current site');

const testImageQuality = (imageUrl, acceptableLevel = 0) => getImageDetails(imageUrl).then(function(imageDetails) {
	const loDim = Math.min(imageDetails.width, imageDetails.height);
	const level = loDim < acceptableResolution ? 0 : loDim < fineResolution ? 1 : loDim < hqResolution ? 2 : 3;
	return level < acceptableLevel ? Promise.reject('Poor image resolution') : level;
});

function getLinks(descBody) {
	if (!descBody) return null;
	if (typeof descBody == 'string') descBody = domParser.parseFromString(descBody, 'text/html').body;
	if (descBody instanceof HTMLElement) descBody = descBody.getElementsByTagName('A'); else throw 'Invalid argument';
	if (descBody.length > 0) descBody = Array.from(descBody, function(a) {
		if (a.href && a.target == '_blank') try {
			const url = new URL(a), hostNorm = url.hostname.toLowerCase();
			if (hostNorm in hostSubstitutions) url.hostname = hostSubstitutions[hostNorm];
			return url;
		} catch(e) { console.warn(e) }
		return null;
	}).filter(url => url instanceof URL && !noCoverHere(url));
	return descBody.length > 0 ? descBody : null;
}
function isMusicResource(imageUrl) {
	if (imageUrl) try {
		imageUrl = new URL(imageUrl);
		const domain = imageUrl.hostname.split('.').slice(-2).join('.').toLowerCase();
		return musicResourceDomains.some(domain2 => domain2.toLowerCase() == domain);
	} catch (e) { console.warn(e) }
	return false;
}

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

function setNewSrc(img, src) {
	if (!(img instanceof HTMLImageElement) || !src) throw 'Invalid argument';
	img.onload = function(evt) {
		if (evt.currentTarget.style.opacity < 1) evt.currentTarget.style.opacity = 1;
		evt.currentTarget.hidden = false;
	}
	img.onerror = evt => { evt.currentTarget.hidden = true };
	if (img.hasAttribute('onclick')) img.removeAttribute('onclick');
	img.onclick = evt => { lightbox.init(evt.currentTarget.src, 220) };
	img.src = src;
}

function counterDecrement(id, tableIndex) {
	if (!id) throw 'Invalid argument';
	let elem = 'div.cover-inspector';
	if (tableIndex) elem += '-' + tableIndex;
	elem += ' span.' + id;
	if ((elem = document.body.querySelector(elem)) == null || !(elem.count > 0)) return;
	if (--elem.count > 0) elem.textContent = elem.count; else {
		(elem = elem.parentNode).textContent = 'Batch completed';
		elem.style.color = 'green';
		elem.style.fontWeight = 'bold';
		setTimeout(function(elem) {
			elem.style.transition = 'opacity 2s ease-in-out';
			elem.style.opacity = 0;
			setTimeout(elem => { elem.remove() }, 2000, elem);
		}, 4000, elem);
	}
}

function inspectImage(img, groupId) {
	if (!(img instanceof HTMLImageElement)) throw 'Invalid argument';
	if (img.parentNode != null) img.parentNode.style.position = 'relative'; else return Promise.resolve(-1);
	const inListing = (function() {
		for (let elem = img; elem != null; elem = elem.parentNode) if (elem.tagName == 'DIV') {
			if (elem.classList.contains('group_image')) return true;
			if (elem.classList.contains('box_image')) return false;
		}
		throw 'Unexpected cover context';
	})();
	const isAlternateCover = !inListing && groupId > 0 && (id => (id = /^cover_(\d+)$/.exec(id)) != null && parseInt(id[1]) > 0)(img.id);
	let sticker;

	function editOnClick(elem, lookupFirst = false) {
		if (!(elem instanceof HTMLElement)) return;
		if (noEditPerms || !ajaxApiKey || readOnly || noAutoLookups) lookupFirst = false;
		elem.classList.add('edit');
		elem.style.cursor = 'pointer';
		elem.style.userSelect = 'none';
		elem.style['-webkit-user-select'] = 'none';
		elem.style['-moz-user-select'] = 'none';
		elem.style['-ms-user-select'] = 'none';
		if (elem.hasAttribute('onclick')) elem.removeAttribute('onclick');
		elem.onclick = function(evt) {
			if (evt.currentTarget.disabled) return false; else evt.currentTarget.disabled = true;
			(lookupFirst ? findCover(groupId, img) : Promise.reject('Lookup disabled')).catch(function() {
				const url = new URL('torrents.php', document.location.origin);
				url.searchParams.set('action', 'editgroup');
				url.searchParams.set('groupid', groupId);
				if ((evt.shiftKey || evt.ctrlKey) && typeof GM_openInTab == 'function')
					GM_openInTab(url.href, evt.shiftKey); else document.location.assign(url);
			});
			return false;
		};
		if (lookupFirst) imageHostHelper.then(ihh => { setTooltip(img, 'Auto cover lookup on click') });
	}

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

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

		return (function() {
			if (!imageUrl) return Promise.reject('Void image URL');
			if (!httpParser.test(imageUrl)) return Promise.reject('Invalid image URL');
			return getImageDetails(imageUrl);
		})().then(function(imageDetails) {
			function isOutside(evt) {
				console.assert(evt instanceof MouseEvent);
				for (let tgt = evt.relatedTarget; tgt instanceof HTMLElement; tgt = tgt.parentNode)
					if (tgt == evt.currentTarget) return false;
				return true;
			}
			function addStickerItems(direction = 1, ...elements) {
				if (direction && elements.length > 0) direction = direction > 0 ? 'append' : 'prepend'; else return;
				if (!inListing) for (let element of direction == 'append' ? elements : elements.reverse()) {
					if (sticker.firstChild != null) sticker[direction]('/');
					sticker[direction](element);
				} else sticker[direction](...elements);
			}

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

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

				sticker.style.backgroundColor = '#ae2300';
				sticker.style.opacity = 2/3;
				sticker.onmouseleave = img.onmouseleave = evt => { if (isOutside(evt)) sticker.style.opacity = 2/3 };
				if (inListing && !isAlternateCover && groupId > 0) editOnClick(sticker);
				if (!isResolutionFine) {
					if (isResolutionAcceptable) {
						let color = acceptableResolution > 0 ? acceptableResolution : 0;
						color = (Math.min(imageDetails.width, imageDetails.height) - color) / (fineResolution - color);
						color = 0xFFFF20 + Math.round((0xC0 - 0x20) * color);
						resolution.style.color = '#' + color.toString(16).padStart(6, '0');
					}
					if (/*!isResolutionAcceptable && */!isAlternateCover && groupId > 0 && !readOnly && !noAutoLookups)
						lookup = resolution;
					else setTooltip(resolution, (isResolutionAcceptable ? 'Mediocre' : 'Poor') + ' image quality (resolution)');
				}
				if (!isPreferredHost) {
					host = span(friendlyHost || 'XTRN', 'unpreferred-host', false);
					if (imageDetails.localProxy) host.classList.add('local-proxy');
					if (isOnDomainList(imageUrl.hostname, 0)) {
						host.style.color = '#ffd';
						if (inListing) host.classList.add('blacklisted-from-click2go');
					} else if (isOnDomainList(imageUrl.hostname, 1)) {
						if (inListing) host.classList.add('whitelisted');
					} else if (!isOnDomainList(imageUrl.hostname, 2)) host.style.color = '#ffa';
					setTooltip(host, getHostTooltip());
					host.onclick = keyHandlers;
					host.onauxclick = function(evt) {
						if (evt.button != 1) return;
						GM_openInTab(evt.ctrlKey ? imageUrl.origin : imageUrl.protocol + '//' + domain, false);
						evt.preventDefault();
						return false;
					};
					addStickerItems(-1, host);
					if (!readOnly) rehost = host;
				}
				if (!isTypeOK) {
					type.onclick = function(evt) {
						if (!evt.shiftKey || !confirm(`This will add "${imageDetails.type}" to whitelisted image types`))
							return false;
						preferredTypes.push(imageDetails.type);
						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' && !readOnly)
					downsize = size;
				if (!isAlternateCover && groupId > 0 && !noEditPerms && ajaxApiKey) 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 = '250ms';
						elem.onmouseenter = elem.onmouseleave = function(evt) {
							if (evt.relatedTarget == evt.currentTarget) return false;
							evt.currentTarget.style.textShadow = evt.type == 'mouseenter' ? '0 0 5px lime' : null;
						};
						elem.onclick = clickHandler;
						if (tooltip) setTooltip(elem, tooltip);
						return elem;
					}

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

			sticker.title = imageUrl.href; //setTooltip(sticker, imageUrl.href);
			sticker.onmouseenter = img.onmouseenter = evt => { sticker.style.opacity = 1 };
			img.after(sticker);
			const status = 1 << 8 | 1 << 7
				| (![host, downsize, lookup].some(elem => elem instanceof HTMLElement)) << 6
				| !imageDetails.localProxy << 5 | isPreferredHost << 4 | isSizeOK << 3
				| isResolutionAcceptable << 2 | isResolutionFine << 1 | isTypeOK << 0;
			img.dataset.statusFlags = status.toString(2).padStart(9, '0');
			return status;
		}).catch(function(reason) {
			img.hidden = true;
			if (groupId > 0) if (!isAlternateCover) editOnClick(sticker, true); else if (GM_getValue('auto_remove_invalid', true)) {
				let div = img.parentNode;
				if (div != null && (div = div.parentNode) != null) {
					let rmCmd = div.querySelector('span.remove_cover_art > a');
					if (rmCmd != null && (rmCmd.parentNode.previousSibling == null || !rmCmd.parentNode.previousSibling.textContent.trim())
							&& typeof rmCmd.onclick == 'function' && (rmCmd = /\bajax\.get\((.+?)\)/.exec(rmCmd.onclick.toString())) != null) {
						eval(rmCmd[0]);
						div.remove();
						return -1;
					}
				}
			}
			sticker.style = `
position: static; padding: 10pt; box-sizing: border-box; width: ${inListing ? '90px' : '100%'}; z-index: 1;
text-align: center; background-color: red; font: 700 auto "Segoe UI", sans-serif;
`;
			sticker.append(span('INVALID'));
			setTooltip(sticker, reason);
			img.after(sticker);
			img.dataset.statusFlags = (1 << 8).toString(2).padStart(9, '0');
			return 1 << 8;
		});
	}

	if (groupId > 0 && !isAlternateCover) imageHostHelper.then(function(ihh) {
		img.classList.add('drop');
		img.ondragover = evt => false;
		if (img.clientWidth > 100) img.ondragenter = img[`ondrag${'ondragexit' in img ? 'exit' : 'leave'}`] = function(evt) {
			if (evt.relatedTarget == evt.currentTarget) return false;
			evt.currentTarget.parentNode.parentNode.style.backgroundColor = evt.type == 'dragenter' ? '#7fff0040' : null;
		};
		img.ondrop = function(evt) {
			function dataSendHandler(endPoint) {
				sticker = evt.currentTarget.parentNode.querySelector('div.cover-inspector');
				if (sticker != null) sticker.disabled = true;
				img.style.opacity = 0.3;
				endPoint([items[0]], true, false, true, {
					ctrlKey: evt.ctrlKey,
					shiftKey: evt.shiftKey,
					altKey: evt.altKey,
				}).then(ihh.singleImageGetter).then(imageUrl =>
						setGroupImage(groupId, imageUrl, 'Cover update from external link').then(function(response) {
					console.log('[Cover Inspector]', response);
					setNewSrc(img, imageUrl);
					setSticker(imageUrl).then(status => { updateCoverCollages(status, groupId) });
				})).catch(function(reason) {
					ihh.logFail(`groupId ${groupId} cover update failed: ${reason}`);
					if (sticker != null) sticker.disabled = false;
					img.style.opacity = 1;
				});
			}

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

const recoverableHttpErrors = [/*0, */500, 502, 503, 504, 520, /*521, */522, 523, 524, 525, /*526, */527, 530];
const dcApiRateControl = { }, dcApiRequestsCache = new Map, bpRequestsCache = new Map;
const requestsCache = new Map, mbRequestsCache = new Map, caRequestsCache = new Map;
let mbLastRequest = null, bpAccessToken = null, spfAccessToken = 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());

function cseSearch(album, artist, country = GM_getValue('cse_country', 'us')) {
	if (!album) return Promise.reject('Album missing');
	const origin = 'https://covers.musichoarders.xyz', getAuth = () => (async function(au, cu) {
		const e = Math.floor(Math.floor(new Date().getTime() / 1000) / cu);
		const t = await crypto.subtle.importKey('raw', new TextEncoder().encode(au),
			{ name: 'HMAC', hash: { name: 'SHA-256' } }, !0, ['sign']);
		const n = await crypto.subtle.sign('HMAC', t, new Int32Array([e]).buffer);
		return btoa(String.fromCharCode(...new Uint8Array(n)));
	})('yUzpZk3QDoq6ztdbTB9Zx9MwTB4SSwtj', 150), apiHeaders = headers => Object.assign({
		'Accept': 'application/json', 'Referer': origin + '/', 'Origin': origin,
		'X-Session': crypto.randomUUID().replaceAll('-', ''), 'X-Page-Query': '', 'X-Page-Referrer': '',
		'X-Requested-With': 'XMLHttpRequest',
	}, headers);
	return new Promise(function(resolve, reject) {
		GM_xmlhttpRequest({
			method: 'GET', url: origin + '/api/info', responseType: 'json', headers: apiHeaders(),
			onload: function(response) {
				if (response.status >= 200 && response.status < 400) try {
					if (!response.response.sources || response.response.sources.length <= 0)
						throw 'Assertion failed: Sources not found';
					console.log('CSE API info:', response.response);
					GM_setValue('cse_api_info', response.response);
					resolve(response.response);
				} catch(e) { reject(e) } else reject(defaultErrorHandler(response));
			},
			onerror: response => { reject(defaultErrorHandler(response)) },
			ontimeout: response => { reject(defaultTimeoutHandler(response)) },
		});
	}).catch(function(reason) {
		console.warn('CSE API Info failed:', reason);
		return GM_getValue('cse_api_info', null);
	}).then(function(apiInfo) {
		const search = sources => getAuth().then(auth => new Promise((resolve, reject) => (function request(retry = 0) {
			if (retry < 60) GM_xmlhttpRequest({
				method: 'POST', url: origin + '/api/search', responseType: 'text', headers: apiHeaders({
					'Accept': 'text/plain',
					'Content-Type': 'application/json',
					'Authorization': 'Bearer ' + auth,
				}),
				data: JSON.stringify({ album: album, artist: artist || '', country: country, sources: sources }),
				onload: function(response) {
					if (response.status >= 200 && response.status < 400) resolve(response.responseText.split(/(?:\r?\n)+/).map(function(line) {
						try { if (line.trim() && (line = JSON.parse(line)).type == 'cover') {
							for (let prop of ['type', 'cache']) delete line[prop];
							return line;
						} } catch(e) { console.warn(e, 'Line:', line) }
					}).filter(Boolean));
					else if (recoverableHttpErrors.includes(response.status)) setTimeout(request, 1000, retry + 1);
					else reject(defaultErrorHandler(response));
				},
				onerror: response => { reject(defaultErrorHandler(response)) },
				ontimeout: response => { reject(defaultTimeoutHandler(response)) },
			}); else reject('CSE request retry limit reached');
		})()));
		let sources = apiInfo != null && apiInfo.sources ?
			apiInfo.sources.filter(source => source.enabled).map(source => source.id) : [ ];
		if (sources.length <= 0) sources = [
			'applemusic', 'itunes', 'qobuz', 'deezer', 'tidal', 'amazonmusic', 'bandcamp',
			'gracenote', 'soundcloud', 'musicbrainz', 'spotify', 'vgmdb', 'bugs', 'flo', 'netease',
			'linemusic', 'recochoku', 'kugou', 'gaana', 'discogs', 'soulseek', 'lastfm',
			'metalarchives', 'fanarttv', 'melon', 'ototoy', 'kkbox', 'beatport', 'booth', 'thwiki',
		];
		let batchSize = apiInfo != null && apiInfo.activeSourceLimit || 12;
		batchSize = Math.ceil(sources.length / Math.ceil(sources.length / batchSize));
		if (!country || apiInfo != null && apiInfo.countries && apiInfo.countries.length > 0
				&& !apiInfo.countries.some(c => c.toLowerCase() == country.toLowerCase())) country = 'us';
		const searchWorkers = [ ];
		for (let offset = 0; offset < sources.length; offset += batchSize)
			searchWorkers.push(search(sources.slice(offset, offset + batchSize)));
		return Promise.all(searchWorkers).then(results =>
			(results = Array.prototype.concat.apply([ ], results)).length > 0 ? results : Promise.reject('No results'));
	});
}

function coverLookup(torrentGroup, ihh, qualityAccent = false) {
	if (!torrentGroup || !ihh) throw 'Invalid argument';
	const namedFn = (fn, name) => Object.defineProperty(fn, 'name', { value: name, configurable: true });
	const namesToSearchTerm = names => names.filter(Boolean).map(name => '"' + name + '"').join(' ');
	let qA = GM_getValue('image_lookup_quality_accent');
	if (qA && typeof (qA = { 'always': true, 'never': false }[qA.toLowerCase()]) == 'Boolean') qualityAccent = qA;
	let lookupWorkers = [ ], workersOrder;

	switch (torrentGroup.group.categoryId) {
		case 1: { // Music category
			const strippers = [
				/^(?:Not On Label|Self[\s\-]Released|None)$|(?:\s+\b(?:Record(?:ing)?s)\b|,?\s+(?:Ltd|Inc|Co)\.?)+$|[\s\-]+/ig,
				/^(?:None)$|[\s\-]+/ig,
			].map(rx => idStr => idStr && idStr.trim().replace(rx, '').toLowerCase() || undefined);
			const audioFileCount = torrent => torrent && torrent.fileList ? torrent.fileList.split('|||').filter(file =>
				/^(.+\.(?:flac|mp3|m4[ab]|aac|dts(?:hd)?|truehd|ac3|ogg|opus|wv|ape))\{{3}(\d+)\}{3}$/i.test(file)).length : 0;
			const mainArtist = (function() {
				let mainArtist = 'dj' in torrentGroup.group.musicInfo && torrentGroup.group.musicInfo.dj[0];
				if (!mainArtist && torrentGroup.group.releaseType != 7 && 'artists' in torrentGroup.group.musicInfo)
					mainArtist = torrentGroup.group.musicInfo.artists[0];
				if (mainArtist) return mainArtist.name;
			})();
			const barcodes = Array.isArray(torrentGroup.torrents) && torrentGroup.torrents.length > 0 ? (function() {
				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;
				});
				return (barcodes = barcodes.filter(Boolean)).length > 0 ?
					Promise.resolve(Array.prototype.concat.apply([ ], barcodes)) : Promise.reject('No torrents with barcode/UPC');
			})() : Promise.reject('Cover lookup by barcode/UPC not available');
			const allLabelsCatNos = (function() {
				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;
			})();
			workersOrder = qualityAccent ? [
				'itunesSearchByUPC', 'bpSearchByUPC', 'spfSearchByUPC', 'mbSearchByBarcode', 'dcSearchByBarcode',
				'mbSearchByDiscId', 'bpSearchByArtistAlbumStrict',
				'mbSearchByLabelCatno', 'dcSearchByLabelCatno',
				'getImagesFromWikiBodyLinks',
				'mbSearchByArtistAlbum', 'dcSearchByArtistMaster', 'dcSearchByArtistRelease',
				'spfSearchByArtistAlbum', 'itunesSearchByArtistAlbum', 'bcSearchByArtistAlbum', 'bpSearchByArtistAlbum',
			] : [
				'itunesSearchByUPC', 'bpSearchByUPC', 'mbSearchByBarcode', 'spfSearchByUPC',
				'itunesSearchByArtistAlbum',
				'bpSearchByArtistAlbumStrict', 'mbSearchByDiscId',
				'mbSearchByLabelCatno', 'dcSearchByBarcode', 'bcSearchByArtistAlbum',
				'getImagesFromWikiBodyLinks',
				'dcSearchByLabelCatno',
				'mbSearchByArtistAlbum', 'spfSearchByArtistAlbum',
				'dcSearchByArtistMaster', 'dcSearchByArtistRelease',
				'bpSearchByArtistAlbum',
			];
			// ####################################### Extract from desc. links #######################################
			lookupWorkers.push(function getImagesFromWikiBodyLinks() {
				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 Discogs, req. credentials ###############################
			const dcAuth = (function() {
				const [token, consumerKey, consumerSecret] =
					['discogs_api_token', 'discogs_api_consumerkey', 'discogs_api_consumersecret'].map(name => GM_getValue(name));
				return token ? 'token=' + token : consumerKey && consumerSecret ?
					`key=${consumerKey}, secret=${consumerSecret}` : undefined;
			})();
			if (dcAuth) {
				function apiRequest(endPoint, params) {
					if (endPoint) endPoint = new URL(endPoint, 'https://api.discogs.com'); else return Promise.reject('No endpoint provided');
					if (params instanceof URLSearchParams) endPoint.search = params;
					else if (typeof params == 'object') for (let key in params) endPoint.searchParams.set(key, params[key]);
					else if (params) endPoint.search = new URLSearchParams(params);
					const cacheKey = endPoint.pathname.slice(1) + endPoint.search;
					if (dcApiRequestsCache.has(cacheKey)) return dcApiRequestsCache.get(cacheKey);
					const reqHeaders = {
						'Accept': 'application/json',
						'X-Requested-With': 'XMLHttpRequest',
						'Authorization': 'Discogs ' + dcAuth,
					};
					const request = new Promise((resolve, reject) => (function request(retry = 0) {
						if (retry >= 60) return reject('Retry limit exceeded');
						const now = Date.now(), postpone = () => { setTimeout(request, dcApiRateControl.timeFrameExpiry - now) };
						if (!dcApiRateControl.timeFrameExpiry || now > dcApiRateControl.timeFrameExpiry) {
							dcApiRateControl.timeFrameExpiry = now + 60 * 1000 + 500;
							if (dcApiRateControl.requestDebt > 0) {
								dcApiRateControl.requestCounter = Math.min(60, dcApiRateControl.requestDebt);
								dcApiRateControl.requestDebt -= dcApiRateControl.requestCounter;
								console.assert(dcApiRateControl.requestDebt >= 0, 'dcApiRateControl.requestDebt >= 0');
							} else dcApiRateControl.requestCounter = 0;
						}
						if (++dcApiRateControl.requestCounter <= 60) GM_xmlhttpRequest({
							method: 'GET', url: endPoint, responseType: 'json', headers: reqHeaders,
							onload: function(response) {
								let requestsUsed = /^(?:x-discogs-ratelimit-used):\s*(\d+)\b/im.exec(response.responseHeaders);
								if (requestsUsed != null && (requestsUsed = parseInt(requestsUsed[1]) + 1) > dcApiRateControl.requestCounter) {
									dcApiRateControl.requestCounter = requestsUsed;
									dcApiRateControl.requestDebt = Math.max(requestsUsed - 60, 0);
								}
								if (response.status >= 200 && response.status < 400) resolve(response.response);
								else if (response.status == 429) {
									console.warn(defaultErrorHandler(response), response.response.message, '(' + retry + ')', `Rate limit used: ${requestsUsed}/60`);
									postpone();
								} else if (recoverableHttpErrors.includes(response.status)) setTimeout(request, 1000, retry + 1);
								else reject(defaultErrorHandler(response));
							},
							onerror: response => { reject(defaultErrorHandler(response)) },
							ontimeout: response => { reject(defaultTimeoutHandler(response)) },
						}); else postpone();
					})());
					dcApiRequestsCache.set(cacheKey, request);
					return request;
				}
				function search(type, queryParams, strictReleaseMatch = false) {
					if (!type || !queryParams) throw 'Invalid argument';
					const searchParams = new URLSearchParams(queryParams);
					if (type) searchParams.set('type', type = type.toLowerCase());
					searchParams.set('sort', 'score');
					searchParams.set('sort_order', 'desc');
					searchParams.set('per_page', 100);
					return apiRequest('database/search', searchParams).then(function(response) {
						function getFromResults(results) {
							if (!results || results.length <= 0) return Promise.reject('No matches');
							const coverImages = results.map(result => result.cover_image || singleResultGetter(result.images))
								.filter(coverImage => coverImage && !coverImage.endsWith('/spacer.gif'));
							return coverImages.length > 0 ? coverImages : Promise.reject('None of matched results has cover');
						}
						function getFromMR(masterIds) {
							if (!masterIds || masterIds.size <= 0) return Promise.reject('No matches');
							if (masterIds.size > 1) return Promise.reject('Ambiguous results');
							if (!((masterIds = masterIds.values().next().value) > 0)) return Promise.reject('No master release');
							return apiRequest('masters/' + masterIds).then(masterRelease => masterRelease.images && masterRelease.images.length > 0 ?
								masterRelease.images.map(image => image.uri || image.resource_url) : Promise.reject('No cover image for master release'));
							//return ihh.imageUrlResolver('https://www.discogs.com/master/' + masterIds).then(singleResultGetter);
						}

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

				const dcLookupWorkers = [
					namedFn(() => barcodes.then(barcodes => 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'))), 'dcSearchByBarcode'),
					function dcSearchByLabelCatno() {
						if (!Array.isArray(torrentGroup.torrents) || torrentGroup.torrents.length <= 0)
							return Promise.reject('Cover lookup by label/cat.no. not available');
						if (allLabelsCatNos == null) return Promise.reject('No torrents with label/cat.no.');
						return Promise.all(allLabelsCatNos.map(queryParams => search('release', queryParams, true).catch(reason => null)))
							.then(imageUrls => (imageUrls = imageUrls.filter(Boolean)).length > 0 ? Array.prototype.concat.apply([ ], imageUrls)
								: Promise.reject('No covers found by label/cat.no.'));
					},
					function dcSearchByArtistMaster() {
						const queryParams = { };
						if (mainArtist) queryParams.artist = mainArtist; else if (torrentGroup.group.releaseType != 7)
							return Promise.reject('Cover lookup by artist/album/year not available');
						queryParams.release_title = bareReleaseTitle(torrentGroup.group.name);
						queryParams.year = torrentGroup.group.year;
						if ([6, 7].includes(torrentGroup.group.releaseType)) queryParams.format = 'Compilation';
						queryParams.strict = true; //!artistName
						return search('master', queryParams);
					},
					function dcSearchByArtistRelease() {
						const queryParams = { };
						if (mainArtist) queryParams.artist = mainArtist; else if (torrentGroup.group.releaseType != 7)
							return Promise.reject('Cover lookup by artist/album not available');
						queryParams.release_title = bareReleaseTitle(torrentGroup.group.name);
						if ([6, 7].includes(torrentGroup.group.releaseType)) queryParams.format = 'Compilation';
						queryParams.strict = true; //!artistName
						return search('release', queryParams, true);
					},
				];

				if (qualityAccent) {
					const discogsSearch = () => (function searchMethod(index = 0) {
						return index < dcLookupWorkers.length ? dcLookupWorkers[index]().then(function(results) {
							namedFn(discogsSearch, dcLookupWorkers[index].name);
							return results;
						}, reason => searchMethod(index + 1)) : Promise.reject('No matches');
					})();
					lookupWorkers.push(discogsSearch);
				} else Array.prototype.push.apply(lookupWorkers, dcLookupWorkers);
			} { // #################################### Ext. lookup at MusicBrainz ####################################
				function apiRequest(endPoint, params) {
					if (!endPoint) throw 'Endpoint is missing';
					const url = new URL('/ws/2/' + endPoint.replace(/^\/+/g, ''), 'https://musicbrainz.org');
					if (params) for (let key in params) url.searchParams.set(key, params[key]);
					const cacheKey = url.pathname.slice(6) + url.search;
					if (mbRequestsCache.has(cacheKey)) return mbRequestsCache.get(cacheKey);
					url.searchParams.set('fmt', 'json');
					const request = new Promise(function(resolve, reject) { (function request(reqCounter = 1) {
						if (reqCounter > 60) return reject('Request retry limit exceeded');
						if (mbLastRequest == Infinity) return setTimeout(request, 100, reqCounter);
						const now = Date.now();
						if (now <= mbLastRequest + 1000) return setTimeout(request, mbLastRequest + 1000 - now, reqCounter);
						mbLastRequest = Infinity;
						GM_xmlhttpRequest({ method: 'GET', url: url, responseType: 'json',
							headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
							onload: function(response) {
								mbLastRequest = Date.now();
								if (response.status >= 200 && response.status < 400) resolve(response.response);
								else if ([429, 430].includes(response.status)) setTimeout(request, 1000, reqCounter + 1);
								else reject(defaultErrorHandler(response));
							},
							onerror: response => { reject(defaultErrorHandler(response)) },
							ontimeout: response => { reject(defaultTimeoutHandler(response)) },
						});
					})() });
					mbRequestsCache.set(cacheKey, request);
					return request;
				}
				function getFrontCovers(type, id) {
					if (!type || !id) return Promise.reject('Invalid argument');
					const key = type + '/' + id;
					if (caRequestsCache.has(key)) return caRequestsCache.get(key);
					const request = new Promise(function(resolve, reject) {
						GM_xmlhttpRequest({ method: 'GET', url: `https://coverartarchive.org/${type}/${id}`, responseType: 'json',
							headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
							onload: function(response) {
								if (response.status >= 200 && response.status < 400)
									if ((response = response.response).images && response.images.length > 0) {
										let frontCovers = response.images.filter(image =>
											image.front || image.types && image.types.includes('Front'));
										//if (frontCovers.length <= 0) frontCovers = response.images;
										frontCovers = frontCovers.map(image => image.image).filter(Boolean);
										if (frontCovers.length > 0) resolve(frontCovers); else reject('This entity has no front cover');
									} else reject('No artwork for this id');
								else reject(response.status == 404 ? 'No images for this entity' : defaultErrorHandler(response));
							},
							onerror: response => { reject(defaultErrorHandler(response)) },
							ontimeout: response => { reject(defaultTimeoutHandler(response)) },
						});
					});
					caRequestsCache.set(key, request);
					return request;
				}
				function search(type, queryParams, strictReleaseMatch = false) {
					if (!type || !queryParams) throw 'Invalid argument';
					queryParams = Object.keys(queryParams).map(field => `${field}:"${queryParams[field]}"`).join(' AND ');
					return apiRequest((type = type.toLowerCase()) + '/', { query: queryParams }).then(function(response) {
						function getFromRG(releaseGroupIds) {
							if (!releaseGroupIds || releaseGroupIds.size <= 0) return Promise.reject('No matches');
							if (releaseGroupIds.size > 1) return Promise.reject('Ambiguous results');
							return (releaseGroupIds = releaseGroupIds.values().next().value) ?
								getFrontCovers('release-group', releaseGroupIds) : Promise.reject('No release group');
						}
						function rgFilter(releaseGroup) {
							if ((function rgFilter(releaseGroup) {
								if (!releaseGroup) return false;
								const isPrimaryType = primaryType => releaseGroup['primary-type'] == primaryType;
								const hasSecondaryType = secondaryType => 'secondary-types' in releaseGroup
									&& releaseGroup['secondary-types'].includes(secondaryType);
								//if (['Audiobook', 'Spokenword', 'Audio drama'].some(hasSecondaryType) return false;
								const releaseType = {
									1: 'Album', 3: 'Soundtrack', 5: 'EP', 6: 'Anthology', 7: 'Compilation', 9: 'Single',
									11: 'Live album', 13: 'Remix', 14: 'Bootleg', 15: 'Interview', 16: 'Mixtape', 17: 'Demo',
									18: 'Concert Recording', 19: 'DJ Mix', 21: 'Unknown',
								}[torrentGroup.group.releaseType];
								console.assert(releaseType != undefined);
								switch (releaseType) {
									case 'Album': return ['Album', 'EP'].some(isPrimaryType)/* && !hasSecondaryType('Compilation')*/;
									case 'Single': return ['Single', 'EP'].some(isPrimaryType);
									case 'EP': return ['EP', 'Single'].some(isPrimaryType);
									case 'Live album': case 'Concert Recording': return !isPrimaryType('Single') && hasSecondaryType('Live');
									case 'Soundtrack': return /*!isPrimaryType('Single') && */hasSecondaryType('Soundtrack');
									case 'Anthology': case 'Compilation': return !isPrimaryType('Single') && hasSecondaryType('Compilation');
									case 'Remix': return /*!isPrimaryType('Single') && */hasSecondaryType('Remix');
									case 'DJ Mix': return /*!isPrimaryType('Single') && */hasSecondaryType('DJ-mix');
									case 'Demo': return /*!isPrimaryType('Single') && */hasSecondaryType('Demo');
									case 'Mixtape': return /*!isPrimaryType('Single') && */hasSecondaryType('Mixtape/Street');
									case 'Interview': return /*!isPrimaryType('Single') && */hasSecondaryType('Interview');
									case 'Bootleg': //return !isPrimaryType('Single')/* && hasSecondaryType('Bootleg')*/;
								}
								return Boolean(releaseType);
							})(releaseGroup)) return true;
							console.log('[Cover Inspector] rgFilter(%o) returns false: releaseType=%d',
								releaseGroup, torrentGroup.group.releaseType,
								document.location.origin + '/torrents.php?id=' + torrentGroup.group.id,
								'https://musicbrainz.org/release-group/' + releaseGroup.id);
							return false;
						}

						if (response.count > 0) switch (type) {
							case 'release': {
								let releases = response.releases, releaseGroupIds;
								if (!releases) return Promise.reject('No matches (renounced)');
								const getReleaseGroupIds = releases =>
									(releaseGroupIds = new Set(releases.map(release => release['release-group'].id)));
								if ((strictReleaseMatch || getReleaseGroupIds(releases).size > 1)
										&& getReleaseGroupIds(releases = releases.filter(function releaseFilter(release) {
									if ((function releaseFilter(release) {
										if (strictReleaseMatch && 'release-group' in release && !rgFilter(release['release-group'])) return false;
										let releaseYear = new Date(release.date);
										releaseYear = isNaN(releaseYear) ? undefined : releaseYear.getUTCFullYear();
										return torrentGroup.torrents.some(function(torrent) {
											if (torrent.remasterYear > 0 && releaseYear > 0 && torrent.remasterYear != releaseYear) return false;
											const torrentIds = ['remasterRecordLabel', 'remasterCatalogueNumber']
												.map((prop, index) => torrent[prop].split(/[\/\|]+/).map(strippers[index]).filter(Boolean));
											if ('label-info' in release && release['label-info'].some(function(labelInfo) {
												labelInfo = [labelInfo.label && labelInfo.label.name, labelInfo['catalog-number']]
													.map((prop, index) => strippers[index](prop));
												return labelInfo[1] && labelInfo.every((id, index) => !id || torrentIds[index].includes(id));
											})) return true;
											const torrentTracks = audioFileCount(torrent);
											if (torrentTracks > 0 && release['track-count'] > 0 && torrentTracks != release['track-count']) return false;
											return torrent.remasterYear > 0 && releaseYear > 0 || torrentTracks > 0 && release['track-count'] > 0;
										});
									})(release)) return true;
									console.log('[Cover Inspector] releaseFilter(%o) returns false: torrents=%o', release, torrentGroup.torrents,
										document.location.origin + '/torrents.php?id=' + torrentGroup.group.id, 'https://musicbrainz.org/release/' + release.id);
									return false;
								})).size > 1) return Promise.reject('Ambiguous results');
								return getFromRG(releaseGroupIds).catch(reason => releases.length > 0 ? Promise.all(releases.map(function(release) {
									const coverArtArchive = release['cover-art-archive'];
									if (coverArtArchive && coverArtArchive.count <= 0) return Promise.resolve(null);
									return getFrontCovers('release', release.id).then(singleResultGetter, reason => null);
								})).then(frontCovers => (frontCovers = frontCovers.filter(Boolean)).length > 0 ?
									frontCovers : Promise.reject('None of results has front cover')) : Promise.reject('No matches'));
							}
							case 'release-group': {
								let releaseGroups = response['release-groups'];
								if (!releaseGroups) return Promise.reject('No matches (renounced)');
								if (strictReleaseMatch) releaseGroups = releaseGroups.filter(rgFilter);
								return getFromRG(new Set(releaseGroups.map(releaseGroup => releaseGroup.id)));
							}
							default: return Promise.reject('Unsupported search type');
						} else return Promise.reject('No matches');
					});
				}

				const mbLookupWorkers = [
					namedFn(() => barcodes.then(barcodes => 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'))), 'mbSearchByBarcode'),
					function mbSearchByDiscId() {
						if (typeof unsafeWindow != 'object' || !['lookupByToc', 'tocEntriesToMbTOC', 'mbComputeDiscID']
								.every(prop => typeof(unsafeWindow[prop]) == 'function'))
							return Promise.reject('CD TOC lookup endpoints not available');
						const torrents = torrentGroup.torrents.filter(torrent => torrent.media == 'CD'
							&& torrent.format == 'FLAC' && torrent.encoding == 'Lossless' && torrent.hasLog);
						if (torrents.length <= 0) return Promise.reject('Cover lookup by CD TOC not available');
						const safeRgId = release => release && 'release-group' in release ? release['release-group'].id : undefined;
						const isConsistent = (results, ndx, arr) => safeRgId(results[0]) == safeRgId(arr[0][0]);
						const mediaCD = media => !media.format || /\b(?:H[DQ])?CD\b/.test(media.format);
						return Promise.all(torrents.map(torrent => unsafeWindow.lookupByToc(torrent.id, function(tocEntries, volumeNdx, totalDiscs) {
							const mbTOC = unsafeWindow.tocEntriesToMbTOC(tocEntries);
							if (mbTOC.length != mbTOC[1] - mbTOC[0] + 4) return Promise.reject('Missing or invalid TOC');
							return apiRequest('discid/' + unsafeWindow.mbComputeDiscID(mbTOC), {
								'media-format': 'all',
								'inc': ['release-groups'].join('+'),
							}).then(result => result.id && Array.isArray(result.releases) && (result = result.releases.filter(release =>
									!release.media || release.media.filter(mediaCD).length == totalDiscs)).length > 0 ?
								result.every((release, ndx, arr) => safeRgId(release) == safeRgId(arr[0])) ?
									result : Promise.reject('Inconsistent results') : Promise.reject('No matches'));
						}).then(results => (results = results.filter(Boolean)).length > 0 && results.every(isConsistent) ?
								Array.prototype.concat.apply([ ], results) : null))).then(function(results) {
							if ((results = results.filter(Boolean)).length <= 0) return Promise.reject('No matches');
							if (!results.every(isConsistent)) return Promise.reject('Inconsistent results');
							results = Array.prototype.concat.apply([ ], results);
							return getFrontCovers('release-group', safeRgId(results[0])).catch(reason => (function doIndex(index = 0) {
								if (index < results.length) {
									const coverArtArchive = results[index]['cover-art-archive'];
									if (coverArtArchive && coverArtArchive.count <= 0) return doIndex(index + 1);
									return getFrontCovers('release', results[index].id).catch(reason => doIndex(index + 1));
								} else return Promise.reject('No covers found by CD TOC');
							})());
						});
					},
					function mbSearchByLabelCatno() {
						if (!Array.isArray(torrentGroup.torrents) || torrentGroup.torrents.length <= 0)
							return Promise.reject('Cover lookup by label/cat.no. not available');
						if (allLabelsCatNos == null) return Promise.reject('No torrents with label/cat.no.');
						return Promise.all(allLabelsCatNos.map(queryParams => search('release', queryParams, true).catch(reason => null)))
							.then(imageUrls => (imageUrls = imageUrls.filter(Boolean)).length > 0 ? Array.prototype.concat.apply([ ], imageUrls)
								: Promise.reject('No covers found by label/cat.no.'));
					},
					function mbSearchByArtistAlbum() {
						const queryParams = { };
						if (mainArtist) queryParams.artistname = mainArtist; else if (torrentGroup.group.releaseType != 7)
							return Promise.reject('Cover lookup by artist/album/year not available');
						queryParams.releasegroup = bareReleaseTitle(torrentGroup.group.name);
						queryParams.firstreleasedate = torrentGroup.group.year;
						return search('release-group', queryParams, true);
					},
				];
				if (qualityAccent) {
					const mbSearch = () => (function searchMethod(index = 0) {
						return index < mbLookupWorkers.length ? mbLookupWorkers[index]().then(function(results) {
							namedFn(mbSearch, mbLookupWorkers[index].name);
							return results;
						}, reason => searchMethod(index + 1)) : Promise.reject('No matches');
					})();
					lookupWorkers.push(mbSearch);
				} else Array.prototype.push.apply(lookupWorkers, mbLookupWorkers);
			} { // ####################################### Ext. lookup at iTunes #######################################
				const apiRequest = (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');
					(function request(retry = 0) {
						if (retry < 100) GM_xmlhttpRequest({
							method: 'GET',
							url: endpoint,
							headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
							responseType: 'json',
							onload: function(response) {
								if (response.status >= 200 && response.status < 400) if (response.response.resultCount > 0) {
									let results = response.response.results;
									if (endpoint.pathname == '/search' && (results = results.filter(function(result) {
										let releaseYear = new Date(result.releaseDate);
										releaseYear = isNaN(releaseYear) ? undefined : releaseYear.getUTCFullYear();
										return torrentGroup.torrents.some(function(torrent) {
											if (torrent.remasterYear > 0 && releaseYear > 0 && torrent.remasterYear != releaseYear) return false;
											const torrentTracks = audioFileCount(torrent);
											if (torrentTracks > 0 && result.trackCount > 0 && torrentTracks != result.trackCount) return false;
											return torrent.remasterYear > 0 && releaseYear > 0 && torrentTracks > 0 && result.trackCount > 0;
										});
									})).length <= 0) return reject('No matches'); else if (results.length > 1) {
										if (noAmbiguity) return reject('Ambiguous results');
										console.info('[Cover Inspector] Ambiguous iTunes results for lookup query (endpoint=%s, queryParams=%o)',
											endpoint.pathname, queryParams);
									}
									let artworkUrls = results.map(function(result) {
										const imageUrl = result.artworkUrl100 || result.artworkUrl60;
										return imageUrl && imageUrl.replace(/\/(\d+)x(\d+)/, '/4000x4000');
									});
									if ((artworkUrls = artworkUrls.filter(Boolean)).length > 0) resolve(artworkUrls); else reject('No matches');
								} else reject('No matches'); else if (response.status == 403) setTimeout(request, 1000, retry + 1);
								else reject(defaultErrorHandler(response));
							},
							onerror: response => { reject(defaultErrorHandler(response)) },
							ontimeout: response => { reject(defaultTimeoutHandler(response)) },
						}); else reject('Retry limit exceeded');
					})();
				}) : Promise.reject('Invalid argument');

				lookupWorkers.push(namedFn(() => barcodes.then(upcs => Promise.all(upcs.map(upc =>
						apiRequest('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'))), 'itunesSearchByUPC'));
				lookupWorkers.push(function itunesSearchByArtistAlbum() {
					function addImportance(importance, maxArtists = 3) {
						if (importance && Array.isArray(torrentGroup.group.musicInfo[importance])
								&& torrentGroup.group.musicInfo[importance].length > 0)
							Array.prototype.push.apply(artistNames,
								torrentGroup.group.musicInfo[importance].slice(0, maxArtists).map(artist => artist.name));
					}

					let artistNames = [ ], albumTitle = bareReleaseTitle(torrentGroup.group.name);
					addImportance('dj');
					if (artistNames.length <= 0 && torrentGroup.group.releaseType != 7) {
						addImportance('artists');
						if (torrentGroup.group.tags && torrentGroup.group.tags.includes('classical')) {
							addImportance('conductor');
							//addImportance('composers');
						}
					}
					if (artistNames.length <= 0) return Promise.reject('Cover lookup by artist/title not available');
					return apiRequest('search', {
						term: namesToSearchTerm(artistNames.concat(albumTitle)),
						attribute: 'mixTerm',
						limit: 15,
					}, artistNames.join(' & ').toLowerCase() == albumTitle.toLowerCase()
						|| artistNames.join('').length + albumTitle.length < 15);
				});
			} { // ###################################### Ext. lookup at Spotify ######################################
				const spfAuth = (function() {
					const [clientId, clientSecret] = ['spotify_client_id', 'spotify_client_secret'].map(name => GM_getValue(name));
					return clientId && clientSecret ? btoa(clientId + ':' + clientSecret) : undefined;
				})();
				const requestEndpoint = (server, endpoint, auth, params) => server && endpoint && auth ? new Promise(function(resolve, reject) {
					const url = new URL(endpoint, 'https://' + server + '.spotify.com'), isPost = server.toLowerCase() != 'api';
					if (params) if (isPost) var payload = new URLSearchParams(params);
						else for (let param in params) url.searchParams.set(param, params[param]);
					GM_xmlhttpRequest({ method: isPost ? 'POST' : 'GET', url: url.href,
						headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest', 'Authorization': auth },
						responseType: 'json',
						onload: function(response) {
							if (response.status >= 200 && response.status < 400) resolve(response.response);
								else reject(defaultErrorHandler(response));
						},
						onerror: response => { reject(defaultErrorHandler(response)) },
						ontimeout: response => { reject(defaultTimeoutHandler(response)) },
						data: payload,
					});
				}) : Promise.reject('Invalid argument');
				const search = queryParams => queryParams && typeof queryParams == 'object' ? (function getAuthToken() {
					const isTokenValid = accessToken => typeof accessToken == 'object' && accessToken.token_type
						&& accessToken.access_token && accessToken.expires_at >= Date.now() + 30 * 1000;
					if ('spotifyAccessToken' in localStorage) try {
						const accessToken = JSON.parse(localStorage.getItem('spotifyAccessToken'));
						if (!isTokenValid(accessToken)) throw 'Throwing expired or otherwise invalid Spotify cached token';
						// console.info('[Cover Inspector] Re-using cached Spotify access token:', accessToken,
						// 	'expires at', new Date(accessToken.expires_at).toTimeString(),
						// 	'(+' + ((accessToken.expires_at - Date.now()) / 1000 / 60).toFixed(2) + 'm)');
						return Promise.resolve(accessToken);
					} catch(e) {
						console.info('[Cover Inspector]', e);
						localStorage.removeItem('spotifyAccessToken');
					}
					if (spfAccessToken instanceof Promise) return spfAccessToken.then(accessToken => isTokenValid(accessToken) ?
							Promise.resolve(accessToken) : Promise.reject('auth session failed')).catch(function(reason) {
						spfAccessToken = null;
						console.info('[Cover Inspector] Discarding Spotify access token:', reason);
						return getAuthToken();
					});
					const timeStamp = Date.now();
					return (spfAccessToken = spfAuth ? requestEndpoint('accounts', 'api/token', 'Basic ' + spfAuth,
							{ 'grant_type': 'client_credentials' }).then(function(accessToken) {
						if (!accessToken.timestamp) accessToken.timestamp = timeStamp;
						if (!accessToken.expires_at) accessToken.expires_at = accessToken.timestamp +
							(accessToken.expires_in_ms || accessToken.expires_in * 1000);
						if (isTokenValid(accessToken)) {
							try { localStorage.setItem('spotifyAccessToken', JSON.stringify(accessToken)) } catch(e) { console.warn(e) }
							spfAccessToken = null;
							console.log('[Cover Inspector] Spotify access token successfully set:',
								accessToken, `(+${(Date.now() - accessToken.timestamp) / 1000}s)`);
						} else {
							spfAccessToken = null;
							console.warn('Received invalid Spotify token:', accessToken);
							return Promise.reject('invalid token received');
						}
						return accessToken;
					}) : Promise.reject('Basic authorization not fully configured'));
				})().then(authToken => requestEndpoint('api', 'v1/search', authToken.token_type + ' ' + authToken.access_token, {
					q: Object.keys(queryParams).map(param => `${param}:"${queryParams[param]}"`).join(' '),
					type: 'album',
					limit: 50,
				}).then(function(results) {
					if (results.albums.total > 0) results = results.albums.items; else return Promise.reject('No matches');
					//console.debug('[Cover Inspector] Spotify search results for %o:', queryParams, results);
					if (!Object.keys(queryParams).includes('upc')) results = results.filter(function(result) {
						if (result.album_type == 'single' ? ![9, 5].includes(torrentGroup.group.releaseType)
								: torrentGroup.group.releaseType == 9) return false;
						if ((result.album_type == 'compilation') != [6, 7].includes(torrentGroup.group.releaseType)) return false;
						let releaseYear = new Date(result.release_date);
						releaseYear = isNaN(releaseYear) ? undefined : releaseYear.getUTCFullYear();
						return torrentGroup.torrents.some(function(torrent) {
							if (torrent.remasterYear > 0 && releaseYear > 0 && torrent.remasterYear != releaseYear) return false;
							const torrentFiles = audioFileCount(torrent);
							if (torrentFiles > 0 && result.total_tracks > 0 && torrentFiles != result.total_tracks) return false;
							return torrentFiles > 0 && result.total_tracks > 0 || torrent.remasterYear > 0 && releaseYear > 0;
						});
					});
					if (results.length <= 0) return Promise.reject('No matches'); else if (results.length > 1) {
						//return reject('Ambiguous results');
						console.info('[Cover Inspector] Ambiguous Spotify results for lookup query (queryParams=%o)', queryParams);
					}
					return (results = results.map(function(result) {
						if (!result.images) return null;
						let highest = Math.max(...result.images.map(image => image.width * image.height));
						highest = result.images.find(image => image.width * image.height == highest);
						return highest && highest.url;
					}).filter(Boolean)).length > 0 ? results : Promise.reject('No covers');
				})) : Promise.reject('No query provided');

				lookupWorkers.push(namedFn(() => barcodes.then(upcs => Promise.all(upcs.map(upc => search({ upc: upc }) // 7
						.catch(reason => null))) .then(artworkUrls => (artworkUrls = artworkUrls.filter(Boolean)).length > 0 ?
							Array.prototype.concat.apply([ ], artworkUrls) : Promise.reject('No covers found by UPC'))), 'spfSearchByUPC'));
				lookupWorkers.push(function spfSearchByArtistAlbum() {
					const queryParams = { };
					if (mainArtist) queryParams.artist = mainArtist; else if (torrentGroup.group.releaseType != 7)
						return Promise.reject('Cover lookup by artist/album not available');
					queryParams.album = bareReleaseTitle(torrentGroup.group.name);
					return search(queryParams);
				});
			} { // ###################################### Ext. lookup at Bandcamp ######################################
				const search = (searchTerm, itemType = 'a') => searchTerm ? new Promise(function(resolve, reject) {
					const url = new URL('https://bandcamp.com/search');
					url.searchParams.set('q', searchTerm);
					url.searchParams.set('item_type', itemType = itemType.toLowerCase());
					GM_xmlhttpRequest({ method: 'GET', url: url, responseType: 'document', headers: { 'X-Requested-With': 'XMLHttpRequest' },
						onload: function(response) {
							if (response.status >= 200 && response.status < 400) {
								let results = response.response.body.querySelectorAll('div.results > ul.result-items > li.searchresult');
								if (results.length <= 0 || (results = Array.prototype.filter.call(results, function(result) {
									let [title, artist, releaseYear, releaseTracks] = ['heading', 'subhead', 'released', 'length']
										.map(className => result.querySelector('div.' + className));
									if (title != null) title = title.textContent.trim(); else return false;
									//if (bareReleaseTitle(title).toLowerCase() != torrentGroup.group.name.toLowerCase()) return false;
									if (artist != null) artist = /^by (.+)$/i.exec(artist.textContent.trim());
									if (artist != null) artist = artist[1]; else return false;
									if (releaseYear != null) releaseYear = /\b(\d{4})\b/.exec(releaseYear.textContent);
									releaseYear = releaseYear != null ? parseInt(releaseYear[1]) : undefined;
									if (itemType == 't') releaseTracks = 1; else if (itemType == 'a') {
										if (releaseTracks != null) releaseTracks = /\b(\d+)\s+tracks?\b/i.exec(releaseTracks.textContent);
										releaseTracks = releaseTracks != null ? parseInt(releaseTracks[1]) : undefined;
									}
									return torrentGroup.torrents.some(function(torrent) {
										if (torrent.remasterYear > 0 && releaseYear > 0 && torrent.remasterYear != releaseYear) return false;
										const torrentTracks = audioFileCount(torrent);
										if (torrentTracks > 0 && releaseTracks > 0 && torrentTracks != releaseTracks) return false;
										return torrent.remasterYear > 0 && releaseYear > 0 && torrentTracks > 0 && releaseTracks > 0;
									});
								})).length <= 0) return reject('No matches'); else if (results.size > 1) {
									console.info('[Cover Inspector] Ambiguous Bandcamp results for lookup query', searchTerm);
									//return reject('Ambiguous results');
								}
								//console.debug('[Cover Inspector] Bandcamp search results for %o:', searchTerm, results);
								Promise.all(results.map(function(result) {
									const image = result.querySelector('a.artcont img');
									return image != null && getImageMax(image.src).catch(reason => image);
								})).then(results = results.filter(Boolean))
									.then(results => { if (results.length > 0) resolve(results); else reject('No matches') });
							} else reject(defaultErrorHandler(response));
						},
						onerror: response => { reject(defaultErrorHandler(response)) },
						ontimeout: response => { reject(defaultTimeoutHandler(response)) },
					});
				}) : Promise.reject('Invalid argument');

				lookupWorkers.push(function bcSearchByArtistAlbum() {
					let searchTerm = [mainArtist, bareReleaseTitle(torrentGroup.group.name)];
					if (!searchTerm[0]) return Promise.reject('Cover lookup by artist/album not available');
					return search((searchTerm = namesToSearchTerm(searchTerm)), 'a').catch(reason =>
						reason == 'No matches' && torrentGroup.torrents.map(audioFileCount).some(afc => afc == 1) ?
							search(searchTerm, 't') : Promise.reject(reason));
				});
			} { // ###################################### Ext. lookup at Beatport ######################################
				const queryAPI = (endPoint, params) => endPoint ? (function setAccessToken() {
					const isTokenValid = accessToken => accessToken && accessToken.token_type
						&& accessToken.access_token && accessToken.expires_at >= Date.now() + 30 * 1000;
					return bpAccessToken instanceof Promise ? bpAccessToken.then(accessToken =>
							isTokenValid(accessToken) ? accessToken : Promise.reject('expired or otherwise invalid')).catch(function(reason) {
						bpAccessToken = null;
						console.info('Discarding Beatsource access token:', reason);
						return setAccessToken();
					}) : (bpAccessToken = new Promise(function(resolve, reject) {
						function haveToken({token}) {
							if (!(token = {
								token_type: token.tokenType,
								access_token: token.accessToken,
								timestamp: timeStamp,
								expires_in: token.expiresIn,
								expires_at: token.accessTokenExpires,
							}).expires_at) token.expires_at = token.timestamp + (token.expires_in_ms || token.expires_in * 1000);
							if (!isTokenValid(token)) {
								console.warn('Received invalid Beatport token:', token);
								return reject('invalid token received');
							}
							try { localStorage.setItem('beatportAccessToken', JSON.stringify(token)) } catch(e) { console.warn(e) }
							console.log('Beatport access token successfully set:',
								token, `(+${(Date.now() - token.timestamp) / 1000}s)`);
							resolve(token);
						}

						if ('beatportAccessToken' in localStorage) try {
							const accessToken = JSON.parse(localStorage.getItem('beatportAccessToken'));
							if (!isTokenValid(accessToken)) throw 'Expired or otherwise invalid';
							console.info('Re-using cached Beatport access token:', accessToken,
								'expires at', new Date(accessToken.expires_at).toTimeString(),
								'(+' + ((accessToken.expires_at - Date.now()) / 1000 / 60).toFixed(2) + 'm)');
							return resolve(accessToken);
						} catch(e) { localStorage.removeItem('beatportAccessToken') }
						const timeStamp = Date.now(), urlBase = 'https://www.beatport.com/api/auth';
						GM_xmlhttpRequest({ method: 'GET', url: urlBase + '/session', responseType: 'json',
							headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
							onload: function(response) {
								const getCookie = (responseHeaders, cookie) =>
									(cookie = new RegExp(`^(?:set-cookie):\\s*${cookie}=(.+)$`, 'im')
										.exec(responseHeaders)) && cookie[1].split(';').map(val => val.trim());
								let cookie = getCookie(response.responseHeaders, '__Secure-next-auth\\.session-token');
								if (cookie != null) return haveToken(response.response);
								const postData = { };
								if ((cookie = getCookie(response.responseHeaders, '__Host-next-auth\\.csrf-token')) != null)
									postData.csrfToken = cookie[0].split('|')[0];
								else return reject('Cookie not received');
								if ((cookie = getCookie(response.responseHeaders, '__Secure-next-auth\\.callback-url')) != null)
									postData.callbackUrl = cookie[0];
								else return reject('Cookie not received');
								GM_xmlhttpRequest({ method: 'POST', url: urlBase + '/callback/anonymous',
									headers: { 'X-Requested-With': 'XMLHttpRequest' },
									data: new URLSearchParams(Object.assign(postData, { json: true })),
									onload: function(response) {
										if ((cookie = getCookie(responseHeaders, '__Secure-next-auth\\.session-token')) == null)
											return reject('Cookie not received');
										GM_xmlhttpRequest({ method: 'GET', url: urlBase + '/session', responseType: 'json',
											cookie: '__Secure-next-auth.session-token=' + cookie[1],
											headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
											onload: ({response}) => { haveToken(response) },
											onerror: response => { reject(defaultErrorHandler(response)) },
											ontimeout: response => { reject(defaultTimeoutHandler(response)) },
										});
									},
									onerror: response => { reject(defaultErrorHandler(response)) },
									ontimeout: response => { reject(defaultTimeoutHandler(response)) },
								});
							},
							onerror: response => { reject(defaultErrorHandler(response)) },
							ontimeout: response => { reject(defaultTimeoutHandler(response)) },
						});
					}).catch(function(reason) {
						if ('beatsourceAccessToken' in localStorage) try {
							const accessToken = JSON.parse(localStorage.getItem('beatsourceAccessToken'));
							if (!isTokenValid(accessToken)) throw 'Throwing expired or otherwise invalid Beatsource cached token';
							// console.info('[Cover Inspector] Re-using cached Beatsource access token:', accessToken,
							// 	'expires at', new Date(accessToken.expires_at).toTimeString(),
							// 	'(+' + ((accessToken.expires_at - Date.now()) / 1000 / 60).toFixed(2) + 'm)');
							return Promise.resolve(accessToken);
						} catch(e) { localStorage.removeItem('beatsourceAccessToken') }
						if (bpAccessToken instanceof Promise) return bpAccessToken.then(accessToken => isTokenValid(accessToken) ?
								Promise.resolve(accessToken) : Promise.reject('auth session failed')).catch(function(reason) {
							bpAccessToken = null;
							console.info('[Cover Inspector] Discarding Beatsource access token:', reason);
							return getAuthToken();
						});
						const timeStamp = Date.now();
						return new Promise(function(resolve, reject) {
							GM_xmlhttpRequest({ method: 'GET', url: 'https://www.beatsource.com/', responseType: 'document',
								headers: { 'Accept': 'text/html', 'X-Requested-With': 'XMLHttpRequest' },
								onload: function(response) {
									if (response.status < 200 || response.status >= 400) reject(defaultErrorHandler(response)); else {
										let accessToken = response.response.getElementById('__NEXT_DATA__');
										if (accessToken != null) try {
											accessToken = JSON.parse(accessToken.text);
											return resolve(Object.assign(accessToken.props.rootStore.authStore.user, {
												apiHost: accessToken.runtimeConfig.API_HOST,
												clientId: accessToken.runtimeConfig.API_CLIENT_ID,
												recurlyPublicKey: accessToken.runtimeConfig.RECURLY_PUBLIC_KEY,
											}));
										} catch(e) { console.warn(e) }
										if ((accessToken = /\b(?:btsrcSession)=([^\s\;]+)/m.exec(response.responseHeaders)) != null) try {
											accessToken = JSON.parse(decodeURIComponent(accessToken[1]));
											let sessionId = /\b(?:sessionId)=([^\s\;]+)/m.exec(response.responseHeaders);
											if (sessionId != null) try { accessToken.sessionId = decodeURIComponent(sessionId[1]) }
												catch(e) { console.warn(e) }
											return resolve(accessToken);
										} catch(e) { console.warn(e) }
										reject('Beatsource OAuth2 access token could not be extracted');
									}
								},
								onerror: response => { reject(defaultErrorHandler(response)) },
								ontimeout: response => { reject(defaultTimeoutHandler(response)) },
							});
						}).then(function(accessToken) {
							if (!accessToken.timestamp) accessToken.timestamp = timeStamp;
							if (!accessToken.expires_at) accessToken.expires_at = accessToken.timestamp +
								(accessToken.expires_in_ms || accessToken.expires_in * 1000);
							if (isTokenValid(accessToken)) try {
								localStorage.setItem('beatsourceAccessToken', JSON.stringify(accessToken));
								bpAccessToken = null;
								console.log('[Cover Inspector] Beatsource access token successfully set:',
									accessToken, `(+${(Date.now() - accessToken.timestamp) / 1000}s)`);
							} catch(e) { console.warn(e) } else {
								bpAccessToken = null;
								console.warn('[Cover Inspector] Received invalid Beatsource token:', accessToken);
								return Promise.reject('invalid token received');
							}
							return accessToken;
						});
					}));
				})().then(function(authToken) {
					const catRoot = '/v4/catalog/';
					const url = new URL(`${catRoot}${endPoint.replace(/^\/+|\/+$/g, '')}/`, 'https://api.beatport.com');
					if (params) url.search = new URLSearchParams(params);
					const cacheKey = url.pathname.slice(url.pathname.indexOf(catRoot) + catRoot.length) + url.search;
					if (bpRequestsCache.has(cacheKey)) return bpRequestsCache.get(cacheKey);
					const request = new Promise(function(resolve, reject) {
						GM_xmlhttpRequest({ method: 'GET', url: url, responseType: 'json',
							headers: {
								'Accept': 'application/json',
								'Authorization': authToken.token_type + ' ' + authToken.access_token,
								'X-Requested-With': 'XMLHttpRequest',
							},
							onload: function(response) {
								if (response.status >= 200 && response.status < 400) resolve(response.response);
									else reject(defaultErrorHandler(response));
							},
							onerror: response => { reject(defaultErrorHandler(response)) },
							ontimeout: response => { reject(defaultTimeoutHandler(response)) },
						});
					});
					bpRequestsCache.set(cacheKey, request);
					return request;
				}) : Promise.reject('Endpoint is missing');
				const search = (searchTerm, strict = true) => searchTerm ? queryAPI('search', {
					q: searchTerm,
					type: 'releases',
					per_page: strict ? 50 : 1,
				}).then(function(results) {
					if (results.count <= 0 || (results = results.releases.filter(function(release) {
						if ('release_type' in release && (parseInt(release.release_type) == 1 ?
								![9, 5].includes(torrentGroup.group.releaseType) : torrentGroup.group.releaseType == 9)) return false;
						let releaseYear = new Date(release.release_date || release.new_release_date || release.publish_date);
						if (isNaN(releaseYear)) return false; else releaseYear = releaseYear.getUTCFullYear();
						return torrentGroup.torrents.some(function(torrent) {
							if (torrent.remasterYear > 0 && torrent.remasterYear != releaseYear) return false;
							const torrentFiles = audioFileCount(torrent);
							if (torrentFiles > 0 && release.track_count > 0 && torrentFiles != release.track_count) return false;
							if (!strict) return torrent.remasterYear > 0 && releaseYear > 0
								&& torrentFiles > 0 && release.track_count > 0;
							if (!(torrent.remasterYear > 0 && releaseYear > 0 || torrentFiles > 0 && release.track_count > 0))
								return false;
							const torrentIds = ['remasterRecordLabel', 'remasterCatalogueNumber']
								.map((prop, index) => torrent[prop].split(/[\/\|]+/).map(strippers[index]).filter(Boolean));
							const releaseIds = [release.label && release.label.name, release.catalog_number]
								.map((prop, index) => strippers[index](prop));
							return releaseIds[1] && releaseIds.every((id, index) => !id || torrentIds[index].includes(id));
						});
					})).length <= 0) return Promise.reject('No matches'); else if (results.length > 1) {
						//return reject('Ambiguous results');
						console.info('[Cover Inspector] Ambiguous Beatport results for lookup query (searchTerm=%s)', searchTerm);
					}
					//console.debug('[Cover Inspector] Beatport search results for %s:', searchTerm, results);
					return (results = results.filter(release => release.image && release.image.uri && ![
						'0dc61986-bccf-49d4-8fad-6b147ea8f327', 'ab2d1d04-233d-4b08-8234-9782b34dcab8',
					].some(imgId => release.image.uri.toLowerCase().endsWith(`/${imgId.toLowerCase()}.jpg`)))).length > 0 ?
						results.map(release => release.image.uri.replace(/\/image_size\/\d+x\d+\//i, '/image/'))
							: Promise.reject('No covers in matching releases found');
				}) : Promise.reject('Search term is missing');
				if ([
					'acid', 'acid.house', 'bass', 'beats', 'breakbeat', 'breakcore', 'breaks', 'chillout', 'chillwave',
					'chiptune', 'dance', 'dark.psytrance', 'deep.house', 'deep.tech', 'downtempo', 'drum.and.bass', 'dub',
					'dub.techno', 'dubstep', 'ebm', 'electro', 'electro.house', 'electronic', 'garage.house', 'glitch',
					'goa.trance', 'grime', 'hard.techno', 'hard.trance', 'hardcore.dance', 'house', 'idm', 'jungle',
					'leftfield', 'minimal.house', 'minimal.techno', 'nu.disco', 'progressive.house', 'progressive.trance',
					'psybient', 'psytrance', 'synth', 'tech.house', 'techno', 'trance', 'trap', 'tribal', 'trip.hop',
					'uk.garage', 'uplifting.trance', 'vaporwave',
				].some(tag => torrentGroup.group.tags.includes(tag))) {
					lookupWorkers.push(namedFn(() => barcodes.then(upcs => Promise.all(upcs.map(upc => search(upc, false)
						.catch(reason => null))).then(artworkUrls => (artworkUrls = artworkUrls.filter(Boolean)).length > 0 ?
							Array.prototype.concat.apply([ ], artworkUrls) : Promise.reject('No covers found by UPC'))), 'bpSearchByUPC'));
					const keywords = [mainArtist, bareReleaseTitle(torrentGroup.group.name)];
					lookupWorkers.push(namedFn(() => search(namesToSearchTerm(keywords), true), 'bpSearchByArtistAlbumStrict'));
					if (keywords[0] && torrentGroup.group.releaseType != 7)
						lookupWorkers.push(namedFn(() => search(namesToSearchTerm(keywords), false), 'bpSearchByArtistAlbum'));
				}
			} { // ###################################### Ext. lookup at CSE ######################################
				// if (mainArtist && torrentGroup.group.releaseType != 7) lookupWorkers.push(namedFn(() =>
				// 	Promise.all(['us', 'jp'].map(country => cseSearch(bareReleaseTitle(torrentGroup.group.name), mainArtist, country))
				// 		.map(promise => promise.catch(reason => null))).then(function(results) {
				// 	if ((results = Array.prototype.concat.apply([ ], results.filter(Boolean)).filter((result, ndx, arr) =>
				// 			result.bigCoverUrl && arr.findIndex(result2 => result2.bigCoverUrl == result.bigCoverUrl) == ndx)).length <= 0)
				// 		return Promise.reject('No matches');
				// 	// TODO: verify & return best match
				// }), 'cseSearchByArtistAlbum'));
			}
			break;
		}
		case 3: { // Ebooks
			qualityAccent = false;
			workersOrder = ['grSearchById', 'grSearchByTitle1', 'grSearchByTitle2'];
			{ // ###################################### Ext. lookup at Goodreads ######################################
				function search(queryParams, noAmbiguity = true) {
					if (!queryParams) throw 'Invalid argument';
					return new Promise(function(resolve, reject) {
						const requestUrl = new URL('https://www.goodreads.com/search');
						for (let param in queryParams) requestUrl.searchParams.set(param, queryParams[param]);
						requestUrl.searchParams.set('search_type', 'books');
						GM_xmlhttpRequest({
							method: 'GET',
							url: requestUrl,
							headers: { 'Accept': 'text/html', 'X-Requested-With': 'XMLHttpRequest' },
							responseType: 'document',
							onload: function(response) {
								if (response.status < 200 || response.status >= 400) return reject(defaultErrorHandler(response));
								const grImageMax = src => src && src.replace(/\._(?:\w+\d+_)+\./ig, '.');
								const dummyCover = coverUrl => coverUrl && ['/nophoto/', '/books/1570622405l/50809027', '/images/no-cover.png']
									.some(pattern => coverUrl.includes(pattern));
								let results = ['div.BookCover__image img', 'div.editionCover > img', 'img#coverImage']
									.reduce((elem, selector) => elem || response.response.body.querySelector(selector), null);
								if (results != null && httpParser.test(results = results.src)) {
									if (!dummyCover(results)) return resolve([grImageMax(results)]);
								} else if ((results = response.response.querySelectorAll('table.tableList > tbody > tr')).length > 0) {
									if (results.length > 1) {
										if (noAmbiguity) return reject('Ambiguous results');
										console.warn('[Cover Inspector] Goodreads ambiguous results');
									}
									if ((results = Array.prototype.map.call(results, function(result) {
										let coverUrl = result.querySelector('img[itemprop="image"]');
										if (coverUrl != null && httpParser.test(coverUrl = coverUrl.src) && !dummyCover(coverUrl))
											return grImageMax(coverUrl);
									}).filter(Boolean)).length > 0) return resolve(results);
								} else return reject('No matches');
								reject('No valid cover image for matched ebooks');
							},
							onerror: response => { reject(defaultErrorHandler(response)) },
							ontimeout: response => { reject(defaultTimeoutHandler(response)) },
						});
					});
				}
				function findByIdentifier(rx, minLength) {
					if (!(rx instanceof RegExp) || !(minLength >= 0)) throw 'Invalid argument';
					let id = rx.exec(descBody.textContent);
					if (id != null && (id = id[2].replace(/\W/g, '')).length >= minLength)
						lookupWorkers.push(namedFn(() => search({ q: id }), 'grSearchById'));
				}

				const descBody = domParser.parseFromString(torrentGroup.group.wikiBody, 'text/html').body;
				findByIdentifier(/\b(ISBN-?13)\b.+?\b(\d+(?:\-\d+)*)\b/m, 12);
				findByIdentifier(/\b(ISBN(?:-?10)?)\b.+?\b(\d+(?:\-\d+)*)\b/m, 9);
				findByIdentifier(/\b(EAN(?:-?13)?)\b.+?\b(\d+(?:\-\d+)*)\b/m, 12);
				findByIdentifier(/\b(UPC(?:-A)?)\b.+?\b(\d+(?:\-\d+)*)\b/m, 11);
				findByIdentifier(/\b(ASIN)\b.+?\b([A-Z\d]{10})\b/m, 11);
				const rx = [
					/(?:\s+(?:\((?:19|2\d)\d{2}\)|\[(?:19|2\d)\d{2}\]|\((?:epub|mobi|pdf)\)|\[(?:epub|mobi|pdf)\]))+$/ig,
					/(?:\s+(?:\([^\(\)]+\)|\[[^\[\]]+\]))+$/ig,
				], titles = [torrentGroup.group.name.replace(rx[0], '')];
				lookupWorkers.push(namedFn(() => search({ q: titles[0] }), 'grSearchByTitle1'));
				titles.push(titles[0].replace(rx[1], ''));
				if (titles[1].length < titles[0].length)
					lookupWorkers.push(namedFn(() => search({ q: titles[1] }), 'grSearchByTitle2'));
			}
			break;
		}
	}
	if ((lookupWorkers = lookupWorkers.filter(worker => typeof worker == 'function')).length <= 0)
		return Promise.reject('No available lookup methods for this release group');
	if (qualityAccent) {
		const results = lookupWorkers.map(lookupWorker => lookupWorker().then(results =>
			(results = results.filter(Boolean)).length > 0 ? results : Promise.reject('No valid images found')));
		return Promise.all(results.map(result => result.then(imageUrls =>
				testImageQuality(imageUrls[0])).catch(reason => -1))).then(function(qRanks) {
			const maxRank = Math.max(...qRanks);
			// console.debug('[Cover Inspector] Quality ranking for group id %d lookup workers:', torrentGroup.group.id,
			// 	maxRank, Object.assign.apply({ }, qRanks.map((qRank, index) => ({ [lookupWorkers[index].name]: qRank }))));
			if (maxRank < 0) return Promise.reject('None of release identifiers was sufficient to find the cover');
			let index = Math.min(...qRanks.map((qR, ndx) => qR < maxRank
				|| (ndx = workersOrder.indexOf(lookupWorkers[ndx].name)) < 0 ? Infinity : ndx));
			index = lookupWorkers.findIndex(lookupWorker => lookupWorker.name == workersOrder[index]);
			console.assert(index >= 0, index);
			if (index < 0 && (index = qRanks.indexOf(maxRank)) < 0)
				return Promise.reject('Assertion failed: lookup worker index doesnot exist');
			console.log('[Cover Inspector] Group id', torrentGroup.group.id, torrentGroup,
				'covers lookup successfull, method name:', lookupWorkers[index].name, '[' + index + ']');
			return results[index];
		});
	} else {
		lookupWorkers.sort((a, b) => workersOrder.indexOf(a.name) - workersOrder.indexOf(b.name));
		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 images found');
				console.log('[Cover Inspector] Group id', torrentGroup.group.id, torrentGroup,
					'covers lookup successfull, method name:', lookupWorkers[index].name, '[' + index + '/' +
					lookupWorkers.length + '], results:', results);
				return results;
			}).catch(reason => lookupMethod(index + 1));
			return Promise.reject('None of release identifiers was sufficient to find the cover');
		})();
	}
}

function updateCoverCollages(status, torrentGroup) {
	(typeof torrentGroup == 'object' ? Promise.resolve(torrentGroup)
			: queryAjaxAPI('torrentgroup', { id: torrentGroup || id })).then(function(torrentGroup) {
		if ((status >> 7 & 0b11) == 0b11) for (let collageIndex of ['missing', 'invalid', 'investigate'])
			if (inCoversCollage(collageIndex, torrentGroup)) removeFromCoversCollage(collageIndex, torrentGroup);
		if ((status & 0b100) == 0 || (status & 0b10) == 0 && [1].includes(torrentGroup.group.categoryId)) {
			if (!inCoversCollage('poor', torrentGroup)) addToCoversCollage('poor', torrentGroup.group.id);
		} else if (inCoversCollage('poor', torrentGroup)) removeFromCoversCollage('poor', torrentGroup);
	});
}

function updateImage(imageUrl, torrentGroup, img) {
	if (!imageUrl || !torrentGroup) throw 'Invalid argument';
	for (let collageIndex of ['missing', 'invalid', 'investigate'])
		if (inCoversCollage(collageIndex, torrentGroup)) removeFromCoversCollage(collageIndex, torrentGroup);
	if (img instanceof HTMLImageElement) {
		setNewSrc(img, imageUrl);
		inspectImage(img, torrentGroup.group.id).then(function(status) {
			if ((status & 0b100) != 0 && ((status & 0b10) != 0 || ![1].includes(torrentGroup.group.categoryId))) {
				if (inCoversCollage('poor', torrentGroup)) removeFromCoversCollage('poor', torrentGroup);
			} else if ([1].includes(torrentGroup.group.categoryId))
				if (!inCoversCollage('poor', torrentGroup)) addToCoversCollage('poor', torrentGroup.group.id);
		}, reason => { console.warn('[Cover Inspector] inspectImage(', img, ') failed with reason', reason) });
	} else testImageQuality(imageUrl).then(function(q) {
		if (q >= ([1].includes(torrentGroup.group.categoryId) ? 2 : 1)) {
			if (inCoversCollage('poor', torrentGroup)) removeFromCoversCollage('poor', torrentGroup);
		} else if ([1].includes(torrentGroup.group.categoryId))
			if (!inCoversCollage('poor', torrentGroup)) addToCoversCollage('poor', torrentGroup.group.id);
	});
}

function findCover(groupId, img) {
	if (!(groupId > 0)) throw 'Invalid argument';
	return imageHostHelper.then(ihh => queryAjaxAPI('torrentgroup', { id: groupId }).then(torrentGroup =>
			coverLookup(torrentGroup, ihh).then(imageUrls =>
				ihh.rehostImageLinks([imageUrls[0]]).then(ihh.singleImageGetter).then(imageUrl =>
					setGroupImage(torrentGroup.group.id, imageUrl).then(function(response) {
		console.log('[Cover Inspector]', response);
		if (!(img instanceof HTMLImageElement)) img = document.body.querySelector('div#covers img');
		updateImage(imageUrl, torrentGroup, img);
	}))).catch(function(reason) {
		if (!torrentGroup.group.wikiImage && !inCoversCollage('missing', torrentGroup))
			addToCoversCollage('missing', torrentGroup.group.id);
		if (torrentGroup.group.wikiImage && !inCoversCollage('invalid', torrentGroup))
			ihh.verifyImageUrl(torrentGroup.group.wikiImage).catch(reason =>
				{ addToCoversCollage('invalid', torrentGroup.group.id) });
		return Promise.reject(reason);
	})));
}

function setFocus(elem) {
	if (!(elem instanceof HTMLElement)) throw 'Invalid argument';
	elem.scrollIntoView({ behavior: 'smooth', block: 'start' });
}

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

function addTableHandlers(table, parent, style, index) {
	function addHeaderButton(caption, clickHandler, id, tooltip) {
		if (!caption || typeof clickHandler != 'function') return;
		const elem = document.createElement('SPAN');
		if (id) elem.classList.add(id);
		elem.classList.add('brackets');
		elem.style = 'margin-right: 5pt; cursor: pointer; font-weight: normal; transition: color 0.25s;';
		elem.textContent = caption;
		elem.onmouseenter = elem.onmouseleave = function(evt) {
			if (evt.relatedTarget == evt.currentTarget) return false;
			evt.currentTarget.style.color = evt.type == 'mouseenter' ? 'orange' : evt.currentTarget.dataset.color || null;
		};
		elem.onclick = clickHandler;
		if (tooltip) elem.title = tooltip; //setTooltip(tooltip);
		container.append(elem);
		return elem;
	}
	function iterateReleaseGroups(callback) {
		for (const tr of table.querySelectorAll('tbody > tr.group, tbody > tr.torrent')) {
			const groupId = getGroupId(tr.querySelector('div.group_info'));
			console.assert(groupId > 0, 'Failed to extract group id:', tr)
			if (groupId > 0) callback(groupId, tr.querySelector('div.group_image > img'), function failhandler(reason) {
				if (this && this.logFail) this.logFail(`groupId ${groupId} cover lookup failed: ${reason}`);
				console.log('[Cover Inspector] groupId', groupId, 'cover lookup failed:', reason);
				return reason;
			});
		}
	}
	function getGroupCreationTime(elem) {
		if (!(elem instanceof HTMLElement) || !((elem = getGroupId(elem.querySelector('div.group_info'))) > 0)) return;
		if ((elem = document.body.querySelectorAll(`tr.group_torrent.groupid_${elem} *.time[title]`)).length <= 0) return;
		if ((elem = Array.from(elem, elem => new Date(elem.title)).filter(date => !isNaN(date))).length <= 0) return;
		return Math.min(...elem.map(date => date.getTime()));
	}
	function changeToCounter(elem, id) {
		if (!(elem instanceof HTMLElement) || !id) throw 'Invalid argument';
		if (!elem.count) {
			elem.remove();
			return null;
		}
		elem.onclick = elem.onmouseenter = elem.onmouseleave = null;
		elem.style.color = 'orange';
		elem.style.cursor = null;
		elem.textContent = ' groups remaining';
		elem.removeAttribute('title');
		const counter = document.createElement('SPAN');
		counter.className = id;
		counter.textContent = counter.count = elem.count;
		counter.style.fontWeight = 'bold';
		elem.prepend(counter);
		delete elem.count;
		return elem;
	}

	if (!(table instanceof HTMLElement) || !(parent instanceof HTMLElement)) return;
	const images = table.querySelectorAll('tbody > tr div.group_image > img');
	if (index) for (let img of images) img.dataset.tableIndex = index;
	const container = document.createElement('DIV');
	container.className = index ? 'cover-inspector-' + index : 'cover-inspector';
	if (style) container.style = style;
	if (images.length > 0) addHeaderButton('Inspect all covers', function inspectAll(evt) {
		if (!evt.currentTarget.disabled) evt.currentTarget.disabled = true; else return false;
		setFocus(parent.parentNode.parentNode);
		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;
		Promise.all(inspectWorkers).then(statuses => !noEditPerms && ajaxApiKey && !readOnly && !noBatchProcessing ? imageHostHelper.then(function(ihh) {
			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('unpreferred-host') && minimumRehostAge > 0) {
					while (elem != null && elem.nodeName != 'TR') elem = elem.parentNode;
					if (!((elem = getGroupCreationTime(elem)) > 0)) return false;
					return elem < Date.now() - minimumRehostAge * 24 * 60 * 60 * 1000;
				}
				return true;
			});
			if ((currentTarget.count = getClick2Gos().length) > 0) {
				currentTarget.id = 'process-all-covers';
				currentTarget.onclick = function processAll(evt) {
					if (evt.currentTarget.disabled || !checkSavedRecovery()) return false;
					setFocus(parent.parentNode.parentNode);
					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('No editing permissions')).catch(reason => { currentTarget.remove() });
	}, 'inspect-all-covers');
	if (!noEditPerms && ajaxApiKey && !readOnly && !noBatchProcessing && !noAutoLookups) imageHostHelper.then(function(ihh) {
		function setCoverFromTorrentGroup(torrentGroup, img, reason) {
			if (!torrentGroup) throw 'Invalid argument';
			return coverLookup(torrentGroup, ihh).then(imageUrls =>
					ihh.rehostImageLinks([imageUrls[0]]).then(ihh.singleImageGetter).then(imageUrl =>
						setGroupImage(torrentGroup.group.id, imageUrl, autoLookupSummary(reason)).then(function(response) {
				console.log('[Cover Inspector]', response);
				updateImage(imageUrl, torrentGroup, img);
				if (autoOpenSucceed) openGroup(torrentGroup);
				return imageUrl;
			}))).catch(function(reason) {
				if (!torrentGroup.group.wikiImage && !inCoversCollage('missing', torrentGroup))
					addToCoversCollage('missing', torrentGroup.group.id);
				if (torrentGroup.group.wikiImage && !inCoversCollage('invalid', torrentGroup))
					ihh.verifyImageUrl(torrentGroup.group.wikiImage)
						.catch(reason => { addToCoversCollage('invalid', torrentGroup.group.id) });
				if (Array.isArray(torrentGroup.torrents) && torrentGroup.torrents.length > 0)
					Promise.all(torrentGroup.torrents.filter(torrent => /\b(?:https?):\/\//i.test(torrent.description))
							.map(torrent => bb2Html(torrent.description).then(getLinks, reason => null))).then(function(urls) {
						if ((urls = urls.filter(Boolean).map(urls => urls.filter(isMusicResource)).filter(urls => urls.length > 0)).length <= 0) return;
						if (autoOpenWithLink) openGroup(torrentGroup);
						console.log('[Cover Inspector] Links found in torrent descriptions for', torrentGroup, ':', urls);
					});
				return Promise.reject(reason);
			});
		}

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

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

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

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

function getAllCovers(groupId) {
	if (!(groupId > 0)) throw 'Invalid argument';
	return new Promise(function(resolve, reject) {
		const xhr = new XMLHttpRequest;
		xhr.open('GET', 'torrents.php?' + new URLSearchParams({ id: groupId }).toString(), true);
		xhr.responseType = 'document';
		xhr.onload = function() {
			if (this.status >= 200 && this.status < 400)
				resolve(Array.from(this.response.querySelectorAll('div#covers div > p > img'), realImgSrc));
					else reject(defaultErrorHandler(this));
		};
		xhr.onerror = function() { reject(defaultErrorHandler(xhr)) };
		xhr.ontimeout = function() { reject(defaultTimeoutHandler(xhr)) };
		xhr.send();
	});
}

function resolveTorrentRow(tr, ihh) {
	function setStatus(newStatus, ...addedText) {
		let td = tr.querySelector('td.status');
		console.assert(td != null); if (td == null) return; // assertion failed
		if (typeof newStatus == 'number') status = newStatus; else if (status == undefined) status = 2;
		td.className = 'status ' + td.textContent + ' status-code-' + status;
		if (status != 2 && typeof td.flashTimer == 'number') {
			clearInterval(td.flashTimer);
			delete td.flashTimer;
			td.style.transition = null;
		}
		td.style.color = ['red', 'orange', '#cc0', '#8a0', '#0a0'][status];
		td.textContent = status > 2 ? 'success' : status < 2 ? 'failed' : 'resolving';
		td.style.opacity = 1;
		if (status == 2) {
			td.style.transition = 'opacity 50ms';
			if (typeof td.flashTimer != 'number') td.flashTimer = setInterval(elem =>
				{ elem.style.opacity = elem.style.opacity < 1 ? 1 : 0.25 }, 500, td);
		}
		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'));
		if (status <= 0) if (autoHideFailed) tr.hidden = true;
			else if ((td = document.getElementById('hide-status-failed')) != null) td.hidden = false;
	}

	const groupId = getGroupId(tr), tooltips = [ ];
	let status;
	if (!(groupId > 0)) return setStatus(0, 'Could not extract torrent id');
	const autoHideFailed = GM_getValue('auto_hide_failed', false);
	queryAjaxAPI('torrentgroup', { id: groupId }).then(function(torrentGroup) {
		function removeFromCollages(...collageIndexes) {
			for (let collageIndex of collageIndexes) if (inCoversCollage(collageIndex, torrentGroup))
				removeFromCoversCollage(collageIndex, torrentGroup).then(() =>
					{ setStatus(status, `(removed from all ${collageIndex} covers collages)`) });
		}

		const qualityEmphasisCategory = [1].includes(torrentGroup.group.categoryId);
		const isCollagePage = id > 0 && ['/collages.php', '/collage.php'].includes(document.location.pathname);
		const isMissingCoverCollage = isCollagePage && coverRelatedCollages && ['missing', 'invalid', 'investigate']
			.some(index => Array.isArray(coverRelatedCollages[index]) && coverRelatedCollages[index].includes(id));
		const isPoorCoverCollage = isCollagePage && coverRelatedCollages && ['poor'].some(index =>
			Array.isArray(coverRelatedCollages[index]) && coverRelatedCollages[index].includes(id));
		let q0 = -1;
		return (function() {
			if (!torrentGroup.group.wikiImage) return Promise.reject('none set');
			const deproxiedSrc = deProxifyImgSrc(torrentGroup.group.wikiImage);
			return deproxiedSrc ? setGroupImage(torrentGroup.group.id, deproxiedSrc, 'Deproxied release image (not working anymore)')
				.then(result => ihh.verifyImageUrl(deproxiedSrc)) : ihh.verifyImageUrl(torrentGroup.group.wikiImage);
		})().then(function(imageUrl) {
			const hostname = new URL(imageUrl).hostname, domain = hostname.split('.').slice(-2).join('.');
			return !isOnDomainList(hostname, 2) ? testImageQuality(imageUrl).then(function(q) {
				if ((q0 = q) < (qualityEmphasisCategory ? 2 : 1))
					return Promise.reject('low resolution of existing image');
				setStatus(4, 'This release group seems to have a valid image');
				removeFromCollages('missing', 'invalid', 'investigate');
				if (!isPoorCoverCollage) removeFromCollages('poor');
				else if ('autoOpenSucceed') openGroup(torrentGroup);
				if (Array.isArray(preferredHosts) && preferredHosts.includes(hostname)
						|| isOnDomainList(imageUrl.hostname, 0)/* || !isOnDomainList(imageUrl.hostname, 1)*/)
					return null;
				return ihh.rehostImageLinks([imageUrl], true, false, true).then(ihh.singleImageGetter).then(imageUrl =>
						setGroupImage(torrentGroup.group.id, imageUrl, 'Automated cover rehost').then(function(response) {
					setStatus(status, '(' + response + ')');
					console.log('[Cover Inspector]', response);
					return imageUrl;
				})).catch(function(reason) {
					setStatus(status, 'Cover rehost failed: ' + reason);
					console.log('[Cover Inspector]', reason);
					return null;
				});
			}) : Promise.reject('Release group having image at bad host');
		}).catch(reason => (setStatus(2, `Looking for a new cover image (${reason})`),
				coverLookup(torrentGroup, ihh, isPoorCoverCollage)).then(imageUrls => testImageQuality(imageUrls[0]).then(function(q) {
			if (q <= q0 || q < (/*qualityEmphasisCategory ? 2 : */1) && isPoorCoverCollage)
				return Promise.reject('Image found is poor quality (resolution)');
			return ihh.rehostImageLinks(imageUrls, true, false, true)
					.then(results => results.map(ihh.directLinkGetter), function(reason) {
				setStatus(status, 'Cover rehost failed: ' + reason);
				return imageUrls;
			}).then(imageUrls => setGroupImage(torrentGroup.group.id, imageUrls[0], autoLookupSummary(reason)).then(function(response) {
				setStatus(4, response);
				console.log('[Cover Inspector]', response);
				removeFromCollages('missing', 'invalid', 'investigate');
				if (q < 1 || (q < 2 && qualityEmphasisCategory)) {
					if (!inCoversCollage('poor', torrentGroup)) addToCoversCollage('poor', torrentGroup).then(_status =>
						{ setStatus(status, '(added to poor quality covers collage)') });
					setStatus(3, 'However the quality is poor (resolution)');
				} else removeFromCollages('poor');
				if (imageUrls.length > 1) setStatus(3, '(more external links require attention)');
				if (autoOpenSucceed) openGroup(torrentGroup);
				return imageUrls[0];
			}));
		}))).catch(function(reason) {
			if (!Array.isArray(torrentGroup.torrents) || torrentGroup.torrents.length <= 0) return Promise.reject(reason);
			return 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, reason, 'No active external links in album description,\nbut release descriptions contain some:\n\n' +
					(urls = Array.prototype.concat.apply([ ], urls)).join('\n'));
				if (autoOpenWithLink) openGroup(torrentGroup);
				console.log('[Cover Inspector] Links found in torrent descriptions for', torrentGroup, ':', urls);
			});
		});
	}).catch(reason => { setStatus(0, reason) });
}

switch (document.location.pathname) {
	case '/artist.php':
		if (id > 0) {
			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) {
			function embedCSESearch(gridSize = 178) {
				function killButton(color = '#0a0', retVal = false) {
					if (button != null) {
						if (color) button.style.color = color;
						setTimeout(function(elem) {
							elem.style.opacity = 0;
							if (elem.parentNode.parentNode != null) setTimeout(elem => { elem.remove() }, 2000, elem.parentNode);
						}, 5000, button);
					}
					return retVal;
				}

				console.assert(album.title, album);
				const button = document.body.querySelector('span.cse-search > a');
				if (button != null) if (button.disabled) return false; else button.disabled = true;
				if (!album.title || document.body.querySelector('div.main_column > div.box.cse-search-results') != null)
					return killButton('red');
				let anchor = document.body.querySelector('div.main_column > table.torrent_table');
				if (anchor == null) return killButton('red');
				const getArtists = (importance, maxArtists = 2) =>
					importance in album.artists && album.artists[importance].slice(0, maxArtists).join(' & ') || undefined;
				if (album.releaseType != 'Compilation') var artist = getArtists('artists_dj') || getArtists('artist_main');
				if (!artist && ['Compilation', 'DJ Mix'].includes(album.releaseType)) artist = 'Various';
				[button.style.color, button.style.cursor, button.textContent] = ['orange', 'progress', 'Waiting...'];
				const animation = button.parentNode.animate([
					{ offset: 0.0, opacity: 1 },
					{ offset: 0.4, opacity: 1 },
					{ offset: 0.5, opacity: 0.1 },
					{ offset: 0.9, opacity: 0.1 },
				], { duration: 600, iterations: Infinity });
				setTooltip(button);
				cseSearch(bareReleaseTitle(album.title), artist).then(function(results) {
					//console.debug('CSE Results:', results);
					const [box, body] = ['DIV', 'DIV'].map(Document.prototype.createElement.bind(document));
					box.className = 'box cse-search-results';
					box.innerHTML = '<div class="head"><a href="#">↑</a>&nbsp;<strong>Cover Search Engine search results</strong></div>';
					body.className = 'body';
					body.style = `
padding: 15px; width: 100%; max-height: 90vh; box-sizing: border-box;
display: grid; gap: 5px; grid-template-columns: repeat(3, auto);
overflow-y: auto; scrollbar-gutter: auto;
`;
					results.forEach(function(result, index) {
						function addLabelInfo(caption, value) {
							if (!caption || !value) return;
							const [entry, b] = ['SPAN', 'SPAN'].map(Document.prototype.createElement.bind(document));
							entry.style = 'overflow: hidden; text-overflow: ellipsis; white-space: nowrap;';
							entry.className = 'detail';
							if (caption.toLowerCase().startsWith('<svg')) {
								b.innerHTML = caption;
								b.firstElementChild.setAttribute('fill', 'white');
								b.firstElementChild.setAttribute('height', '1em');
								b.firstElementChild.style.marginBottom = '-1pt';
								b.style = 'display: inline-block; min-width: 1.4em;';
								entry.append(b);
							} else {
								b.textContent = caption;
								b.style = 'font-weight: bold;';
								entry.append(b, ': ');
							}
							entry.append(value);
							label.append(entry);
						}

						const [item, img, label] = ['DIV', 'IMG', 'DIV'].map(Document.prototype.createElement.bind(document));
						item.className = 'cse-search-result';
						item.dataset.result = JSON.stringify(result);
						item.dataset.imageUrl = result.bigCoverUrl || result.smallCoverUrl;
						item.onmouseenter = item.onmouseleave = function(evt) {
							if (evt.relatedTarget == evt.currentTarget) return false;
							evt.currentTarget.lastElementChild.style.opacity = evt.type == 'mouseenter' ? 1 : 0;
							evt.currentTarget.style.boxShadow = evt.type == 'mouseenter' ? '0 0 5px 5px yellow' : 'none';
							evt.currentTarget.style.zIndex = evt.type == 'mouseenter' ? 2 : 'auto';
							//evt.currentTarget.style.transform = evt.type == 'mouseenter' ? 'scale(1.05)' : 'none';
						};
						item.style = `position: relative; width: ${gridSize}px; height: ${gridSize}px; cursor: pointer; transition: box-shadow, transform 200ms;`;
						item.draggable = true;
						img.className = 'cse-search-preview';
						img.loading = 'eager'; img.referrerPolicy = 'same-origin';
						img.src = result.smallCoverUrl || result.bigCoverUrl;
						img.width = img.height = gridSize;
						label.className = 'cse-search-result-label';
						label.draggable = true;
						label.style = `
position: absolute; bottom: 0; left: 0; width: 100%; padding: 5px; box-sizing: border-box;
display: flex; flex-flow: column;
color: white; background-color: #0008; font: normal 7pt "Noto Sans", sans-serif;
opacity: 0; transition: opacity 200ms;
overflow: hidden; text-overflow: ellipsis;
`;
						addLabelInfo('<svg viewBox="0 0 1000 1000" enable-background="new 0 0 1000 1000"><path d="M500,990c-251.9,0-456.2-82.2-456.2-183.7l0,0V702.9c0,101.5,204.3,164.6,456.2,164.6c251.9,0,456.2-63.1,456.2-164.6v103.4l0,0C956.2,907.8,751.9,990,500,990L500,990z M500,806.3c-251.9,0-456.2-82.3-456.2-183.8V519.1c0,101.5,204.3,164.6,456.2,164.6c251.9,0,456.2-63.1,456.2-164.6v103.4C956.2,724,751.9,806.3,500,806.3L500,806.3z M500,622.5c-251.9,0-456.2-82.3-456.2-183.8V335.4C43.8,436.9,248.1,500,500,500c251.9,0,456.2-63.1,456.2-164.6v103.4C956.2,540.2,751.9,622.5,500,622.5L500,622.5z M500,438.8c-251.9,0-456.2-82.3-456.2-183.8v-61.3C43.8,92.3,248.1,10,500,10c251.9,0,456.2,82.3,456.2,183.8V255C956.2,356.5,751.9,438.8,500,438.8L500,438.8z"/></svg>', result.source.toUpperCase());
						if (result.releaseInfo) {
							item.dataset.url = result.releaseInfo.url;
							addLabelInfo('<svg viewBox="0 0 512 512"><path d="M0 256a256 256 0 1 1 512 0A256 256 0 1 1 0 256zm256 32a32 32 0 1 1 0-64 32 32 0 1 1 0 64zm-96-32a96 96 0 1 0 192 0 96 96 0 1 0 -192 0zM96 240c0-35 17.5-71.1 45.2-98.8S205 96 240 96c8.8 0 16-7.2 16-16s-7.2-16-16-16c-45.4 0-89.2 22.3-121.5 54.5S64 194.6 64 240c0 8.8 7.2 16 16 16s16-7.2 16-16z"></path></svg>', result.releaseInfo.title);
							addLabelInfo('<svg viewBox="0 0 448 512"><path d="M224 256A128 128 0 1 0 224 0a128 128 0 1 0 0 256zm-45.7 48C79.8 304 0 383.8 0 482.3C0 498.7 13.3 512 29.7 512H418.3c16.4 0 29.7-13.3 29.7-29.7C448 383.8 368.2 304 269.7 304H178.3z"></path></svg>', result.releaseInfo.artist);
							addLabelInfo('<svg viewBox="0 0 448 512"><path d="M96 32V64H48C21.5 64 0 85.5 0 112v48H448V112c0-26.5-21.5-48-48-48H352V32c0-17.7-14.3-32-32-32s-32 14.3-32 32V64H160V32c0-17.7-14.3-32-32-32S96 14.3 96 32zM448 192H0V464c0 26.5 21.5 48 48 48H400c26.5 0 48-21.5 48-48V192z"></path></svg>', result.releaseInfo.date);
							if (result.releaseInfo.tracks > 0)
								addLabelInfo('<svg viewBox="0 0 512 512"><path d="M499.1 6.3c8.1 6 12.9 15.6 12.9 25.7v72V368c0 44.2-43 80-96 80s-96-35.8-96-80s43-80 96-80c11.2 0 22 1.6 32 4.6V147L192 223.8V432c0 44.2-43 80-96 80s-96-35.8-96-80s43-80 96-80c11.2 0 22 1.6 32 4.6V200 128c0-14.1 9.3-26.6 22.8-30.7l320-96c9.7-2.9 20.2-1.1 28.3 5z"></path></svg>', `${result.releaseInfo.tracks} ${result.releaseInfo.tracks > 1 ? 'tracks' : 'track'}`);
							addLabelInfo('<svg viewBox="0 0 576 512"><path d="M64 32C28.7 32 0 60.7 0 96V416c0 35.3 28.7 64 64 64H512c35.3 0 64-28.7 64-64V96c0-35.3-28.7-64-64-64H64zm80 256h64c44.2 0 80 35.8 80 80c0 8.8-7.2 16-16 16H80c-8.8 0-16-7.2-16-16c0-44.2 35.8-80 80-80zm-32-96a64 64 0 1 1 128 0 64 64 0 1 1 -128 0zm256-32H496c8.8 0 16 7.2 16 16s-7.2 16-16 16H368c-8.8 0-16-7.2-16-16s7.2-16 16-16zm0 64H496c8.8 0 16 7.2 16 16s-7.2 16-16 16H368c-8.8 0-16-7.2-16-16s7.2-16 16-16zm0 64H496c8.8 0 16 7.2 16 16s-7.2 16-16 16H368c-8.8 0-16-7.2-16-16s7.2-16 16-16z"></path></svg>', result.releaseInfo.catalog);
							addLabelInfo('<svg viewBox="0 0 512 512"><path d="M24 32C10.7 32 0 42.7 0 56V456c0 13.3 10.7 24 24 24H40c13.3 0 24-10.7 24-24V56c0-13.3-10.7-24-24-24H24zm88 0c-8.8 0-16 7.2-16 16V464c0 8.8 7.2 16 16 16s16-7.2 16-16V48c0-8.8-7.2-16-16-16zm72 0c-13.3 0-24 10.7-24 24V456c0 13.3 10.7 24 24 24h16c13.3 0 24-10.7 24-24V56c0-13.3-10.7-24-24-24H184zm96 0c-13.3 0-24 10.7-24 24V456c0 13.3 10.7 24 24 24h16c13.3 0 24-10.7 24-24V56c0-13.3-10.7-24-24-24H280zM448 56V456c0 13.3 10.7 24 24 24h16c13.3 0 24-10.7 24-24V56c0-13.3-10.7-24-24-24H472c-13.3 0-24 10.7-24 24zm-64-8V464c0 8.8 7.2 16 16 16s16-7.2 16-16V48c0-8.8-7.2-16-16-16s-16 7.2-16 16z"></path></svg>', result.releaseInfo.barcode);
						}
						getImageDetails(item.dataset.imageUrl).then(function(imageDetails) {
							addLabelInfo('<svg viewBox="0 0 62.89 62.89"><path d="M3.88 0l55.13 0c2.14,0 3.88,1.75 3.88,3.88l0 55.13c0,2.14 -1.74,3.88 -3.88,3.88l-55.13 0c-2.13,0 -3.88,-1.74 -3.88,-3.88l0 -55.13c0,-2.13 1.75,-3.88 3.88,-3.88zm50.61 52.62l0 0 0 -5.6 0 -18.67c0,-1.02 -0.84,-1.86 -1.87,-1.86l-5.6 0c-1.02,0 -1.86,0.84 -1.86,1.86l0 10.21 -20.82 -20.82 10.2 0c1.03,0 1.87,-0.84 1.87,-1.87l0 -5.6c0,-1.03 -0.84,-1.87 -1.87,-1.87l-18.67 0 -5.6 0 0 0 -0.05 0 0 0 -0.05 0 -0.04 0.01 0 0 -0.05 0 0 0 -0.05 0.01 0 0 -0.04 0 -0.05 0.01 0 0 -0.05 0.01 -0.04 0.01 0 0 -0.05 0.01 -0.04 0.01 -0.05 0.02 0 0 -0.04 0.01 0 0 -0.04 0.02 0 0 -0.04 0.01 0 0 -0.05 0.02 -0.04 0.02 -0.04 0.02 0 0 -0.04 0.02 0 0 -0.04 0.02 0 0 -0.04 0.02 0 0 -0.04 0.02 -0.04 0.03 -0.03 0.02 -0.04 0.03 -0.04 0.02 0 0 -0.03 0.03 -0.04 0.03 0 0 -0.03 0.03 -0.04 0.03 -0.03 0.03 0 0 -0.03 0.03 0 0 -0.03 0.03 0 0 -0.03 0.03 -0.03 0.04 -0.03 0.03 0 0 -0.03 0.04 -0.03 0.03 0 0 -0.02 0.04 -0.03 0.04 -0.02 0.03 -0.03 0.04 -0.02 0.04 0 0 -0.02 0.04 0 0 -0.02 0.04 0 0 -0.02 0.04 0 0 -0.02 0.04 -0.02 0.04 -0.02 0.05 0 0 -0.01 0.04 0 0 -0.02 0.04 0 0 -0.01 0.04 0 0 -0.02 0.05 -0.01 0.04 -0.01 0.05 0 0 -0.01 0.04 -0.01 0.05 0 0 -0.01 0.05 0 0.04 0 0 -0.01 0.05 0 0 0 0.05 0 0 -0.01 0.04 0 0.05 0 0 0 0.05 0 0 0 5.6 0 18.67c0,1.03 0.84,1.87 1.87,1.87l5.6 0c1.03,0 1.87,-0.84 1.87,-1.87l0 -10.2 20.82 20.82 -10.21 0c-1.02,0 -1.86,0.84 -1.86,1.86l0 5.6c0,1.03 0.84,1.87 1.86,1.87l18.67 0 5.6 0 0 0 0.05 0 0 0 0.05 0 0.05 0 0 0 0.05 -0.01 0 0 0.04 0 0 0 0.05 -0.01 0.04 -0.01 0 0 0.05 -0.01 0.05 -0.01 0 0 0.04 -0.01 0.04 -0.01 0.05 -0.01 0 0 0.04 -0.02 0 0 0.05 -0.01 0 0 0.04 -0.02 0 0 0.04 -0.02 0.04 -0.01 0.04 -0.02 0 0 0.04 -0.02 0 0 0.04 -0.02 0 0 0.04 -0.03 0 0 0.04 -0.02 0.04 -0.02 0.04 -0.03 0.03 -0.02 0.04 -0.03 0 0 0.04 -0.03 0.03 -0.03 0 0 0.03 -0.03 0.04 -0.03 0.03 -0.03 0 0 0.03 -0.03 0 0 0.03 -0.03 0 0 0.04 -0.03 0.03 -0.04 0.02 -0.03 0 0 0.03 -0.03 0.03 -0.04 0 0 0.03 -0.04 0.02 -0.03 0.03 -0.04 0.02 -0.04 0.02 -0.04 0 0 0.03 -0.04 0 0 0.02 -0.04 0 0 0.02 -0.04 0 0 0.02 -0.04 0.01 -0.04 0.02 -0.04 0 0 0.02 -0.05 0 0 0.01 -0.04 0 0 0.02 -0.04 0 0 0.01 -0.05 0.01 -0.04 0.01 -0.05 0 0 0.01 -0.04 0.01 -0.05 0 0 0.01 -0.04 0.01 -0.05 0 0 0 -0.05 0 0 0.01 -0.04 0 0 0 -0.05 0 -0.05 0 0 0 -0.05z"/></svg>', imageDetails.width + '×' + imageDetails.height);
							addLabelInfo('<svg viewBox="0 0 20 20"><path d="M17 12v5H3v-5H1v5a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-5z"/><path d="M10 15l5-6h-4V1H9v8H5l5 6z"/></svg>', formattedSize(imageDetails.size));
						});
						if (label.childElementCount > 0) item.append(img, label);
						item.onclick = function(evt) {
							const target = evt.currentTarget;
							(!evt.ctrlKey && !noEditPerms && ajaxApiKey ? imageHostHelper.then(function(ihh) {
								const img = document.body.querySelector('div#covers img');
								ihh.verifyImageUrl(target.dataset.imageUrl).then(function(imageUrl) {
									if (!confirm('Current cover is going to be replaced by\n' + imageUrl)) return false;
									if (img != null) img.style.opacity = 0.3;
									return ihh.rehostImageLinks([imageUrl]).then(ihh.singleImageGetter) .then(imageUrl =>
											setGroupImage(id, imageUrl, 'Non-automated cover update from external source').then(function(response) {
										console.log(response);
										if (img != null) {
											setNewSrc(img, imageUrl);
											inspectImage(img, id).then(status => { updateCoverCollages(status, id) });
										} else document.location.reload();
									}));
								}).catch(function(reason) {
									ihh.logFail('Setting cover from result url failed: ' + reason);
									if (img != null && img.style.opacity < 1) img.style.opacity = 1;
								});
							}) : Promise.reject('Site edit not available')).catch(reason =>
								{ GM_openInTab(target.dataset[evt.shiftKey ? 'imageUrl' : 'url'], false) });
						};
						item.onauxclick = function(evt) {
							if (evt.button != 1) return true;
							GM_openInTab(evt.currentTarget.dataset.url, false)
							return false;
						};
						(!noEditPerms && ajaxApiKey && !readOnly ? imageHostHelper.then(function() {
							item.title = `Use simple click to set album cover from this source
Use Ctrl+click to open source page in new window
Use Ctrl+Shift+click to open cover image in new window
Drag anywhere to drop full resolution direct link

` + item.dataset.imageUrl;
						}) : Promise.reject('Site edit not available')).catch(function() {
							item.title = `Use simple click to open source page in new window
Use Shift+click to open cover image in new window
Drag anywhere to drop full resolution direct link

` + item.dataset.imageUrl;
						});
						item.ondragstart = function(evt) {
							if (!evt.dataTransfer) return;
							evt.dataTransfer.clearData();
							evt.dataTransfer.setData('text/uri-list', evt.currentTarget.dataset.imageUrl);
							evt.dataTransfer.setData('text/plain', evt.currentTarget.dataset.imageUrl);
							evt.dataTransfer.setData('text/imageUrl', evt.currentTarget.dataset.imageUrl);
							if (item.dataset.url) evt.dataTransfer.setData('text/url', evt.currentTarget.dataset.url);
							// GM_xmlhttpRequest({ method: 'GET', url: evt.currentTarget.dataset.imageUrl, responseType: 'blob',
							// 	onload: function(response) {
							// 		if (response.status < 200 || response.status >= 400) return;
							// 		draggedImage = new File([response.response], 'cover.jpg');
							// 	},
							// 	context: evt.dataTransfer,
							// });
						};
						body.append(item);
					});
					box.append(body);
					anchor.after(box);
					[button.textContent, button.style.cursor] = ['Success', 'default'];
					killButton('#0a0');
					setFocus(box);
					if (cseSearchMenu) GM_unregisterMenuCommand(cseSearchMenu);
				}).catch(function(reason) {
					[button.textContent, button.style.cursor, button.style.color, button.disabled] =
						['Error', null, 'red', false];
					setTooltip(button, reason);
					//killButton('red');
				}).then(() => { if (animation) animation.cancel() });
				return true;
			}

			if (document.body.querySelector('div#content div.sidebar > div.box.box_artists') != null) {
				var album = { title: document.body.querySelector('div#content div.header > h2'), artists: { } };
				if (album.title != null && (album.releaseType = /\[(\d+)\] \[(.+)\]/.exec(album.title.lastChild.textContent.trim())) != null) Object.assign(album, {
					title: album.title.lastElementChild.textContent.trim(),
					year: parseInt(album.releaseType[1]),
					releaseType: album.releaseType[2],
				});
				for (let a of document.body.querySelectorAll('ul#artist_list > li > a[dir="ltr"]')) {
					if (!(a.parentNode.className in album.artists)) album.artists[a.parentNode.className] = [ ];
					album.artists[a.parentNode.className].push(a.textContent.trim());
				}
				console.debug('Music album:', album);
			}
			if (typeof GM_getTabs == 'function') GM_getTabs(function(tabs) {
				for (let tab in tabs) if ((tab = tabs[tab]) && 'torrentGroups' in tab) try {
					if (!(tab = tab.torrentGroups[id])) continue;
					console.info('Torrent group %d found in tabs data', id);
					unsafeWindow.torrentGroup = tab;
					unsafeWindow.dispatchEvent(Object.assign(new Event('torrentGroup'), { data: tab }));
				} catch (e) { console.warn(e) }
			});

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

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

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

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

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

				function setAnchorHandlers(a) {
					console.assert(a instanceof HTMLAnchorElement, 'a instanceof HTMLAnchorElement');
					if (!(a instanceof HTMLAnchorElement)) return false;
					a.setAttribute('contextmenu', contextId);
					a.oncontextmenu = setMenuInvoker;
					if (a.protocol.startsWith('http') && !a.onclick) {
						a.onclick = clickHandler;
						const tooltip = 'Alt + click to set release cover from this URL (or use context menu command)';
						a.title = tooltip;
						if (GM_getValue('tooltip_desc_links_image', false) && typeof jQuery.fn.tooltipster == 'function') imageHostHelper.then(function(ihh) {
							ihh.imageUrlResolver(a.href).then(singleResultGetter).then(linkImage => { $(a).tooltipster({
								content: '<img src="' + linkImage + '" width="225" referrerpolicy="same-origin" /><div style="margin-top: 5pt; max-width: 225px;">' + tooltip + '</div>',
							}) });
						});
					}
					return true;
				}

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

				if (!noAutoLookups) GM_registerMenuCommand('Cover auto lookup', () => { findCover(id).catch(alert) }, 'A');
				cseSearchMenu = GM_registerMenuCommand('Search cover (manual)', () => { embedCSESearch() }, 'M');
			});

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

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

			// Embed Cover Search Engine results
			if (album) if (Boolean(urlParams.get('embed-cse-search'))) embedCSESearch(); else {
				let anchor = document.getElementById('add_cover_div');
				if (anchor != null || (anchor = document.querySelector('div.sidebar > div.box_image > div.head')) != null) {
					const [span, a] = ['SPAN', 'A'].map(Document.prototype.createElement.bind(document));
					span.className = 'cse-search';
					span.style = 'float: right; margin-left: 5pt; font-weight: normal;';
					a.href = '#';
					a.textContent = anchor.id == 'add_cover_div' ? 'Search cover' : 'Search';
					a.className = 'brackets';
					a.style.transition = 'opacity 2s';
					a.onclick = function(evt) {
						evt.stopImmediatePropagation();
						embedCSESearch();
						return false;
					};
					span.append(a); anchor.firstElementChild.prepend(span);
				}
			}
			// Embed Google Image search
			if (album && Boolean(urlParams.get('embed-google-image-search')) && album.title && album.year > 0) {
				let query = '"' + bareReleaseTitle(album.title) + '" ' + album.year;
				const stringifyArtists = (importance, maxArtists = 3) => importance in album.artists ?
					album.artists[importance].slice(0, maxArtists).map(artist => '"' + artist + '"').join(' ') : undefined;
				if (album.releaseType != 'Compilation') {
					if ('artists_dj' in album.artists) query = stringifyArtists('artists_dj') + ' ' + query;
					else if ('artist_main' in album.artists) query = stringifyArtists('artist_main') + ' ' + query;
				} else query = (stringifyArtists('artists_dj') || '"Various Artists"') + ' ' + query;
				const embedUrl = new URL('https://www.google.com/search');
				embedUrl.searchParams.set('q', query);
				embedUrl.searchParams.set('tbm', 'isch');
				//embedUrl.hash = 'islmp';
				embedpage(embedUrl, 'Google image search results', 'google-image-search-results');
			}
			// Embed first description link if available
			if (Boolean(urlParams.get('embed-desc-link-source'))) {
				const links = getLinks(document.querySelector('div.torrent_description > div.body'));
				if (links != null) for (let link of links) embedpage(link, 'Description Link Preview', 'desc-link-preview');
			}
			// Embed description links image previews
			if (Boolean(urlParams.get('desc-links-image-preview'))) {
				const anchor = document.body.querySelector('div.sidebar > div.box_image');
				const links = getLinks(document.querySelector('div.torrent_description > div.body'));
				if (anchor != null && links != null) imageHostHelper.then(function(ihh) {
					let previewBox = document.createElement('DIV');
					previewBox.className = 'box description_link_image_preview';
					previewBox.innerHTML = '<div class="head"><strong>Description links image preview</strong></div><div class="pad"></div>';
					anchor.after(previewBox);
					previewBox = previewBox.querySelector('div.pad');
					previewBox.style = 'display: flex; flex-direction: column; gap: 5pt;';
					links.forEach(function(link, index) {
						const div = document.createElement('DIV');
						div.textContent = 'Resolving image...';
						div.dataset.url = link; div.dataset.index = index;
						previewBox.append(div);
						ihh.imageUrlResolver(link).then(singleResultGetter).then(function(linkImage) {
							const img = document.createElement('IMG');
							img.onload = function(evt) {
								evt.currentTarget.title = link + ' → ' + linkImage;
								evt.currentTarget.alt = linkImage;
								while (div.lastChild != null) div.removeChild(div.lastChild);
								div.append(evt.currentTarget);
							};
							img.onerror = function(evt) {
								div.textContent = 'Image loading error for ' + link;
								div.style.color = 'red';
							};
							img.width = 225; img.referrerPolicy = 'same-origin'; img.src = linkImage;
						}, function(reason) {
							div.textContent = 'No valid image for ' + link;
							div.style.color = 'red';
							div.title = reason;
						});
					});
				});
			}
			if (Boolean(urlParams.get('highlight-cover-collages')) && coverRelatedCollages)
				for (let a of document.querySelectorAll('table.collage_table > tbody > tr > td:first-of-type > a')) {
					const collageId = a.pathname == '/collages.php' && parseInt(new URLSearchParams(a.search).get('id'));
					if (collageId > 0 && Object.keys(coverRelatedCollages).some(key =>
							Array.isArray(coverRelatedCollages[key]) && coverRelatedCollages[key].includes(collageId))) {
						a.style = 'color: white; font-weight: bold;';
						a.parentNode.parentNode.style = 'color: white; background-color: darkorange;';
					}
				}
		} else { // not torrent group
			const useIndexes = urlParams.get('action') == 'notify';
			document.body.querySelectorAll('table.torrent_table').forEach(function(table, index) {
				const parent = findParent(table);
				if (parent) addTableHandlers(table, parent, 'display: inline-block; margin-left: 17pt;',
					useIndexes ? index + 1 : undefined);
			});
		}
		break;
	case '/collages.php':
	case '/collage.php':
		if (!noEditPerms && ajaxApiKey && !readOnly && !noAutoLookups && !noBatchProcessing && coverRelatedCollages
				&& Object.values(coverRelatedCollages).some(collageIds =>
					Array.isArray(collageIds) && collageIds.includes(id))) imageHostHelper.then(function(ihh) {
			const td = document.body.querySelector('table#discog_table > tbody > tr.colhead_dark > td:nth-of-type(3)');
			if (td != null) {
				function addButton(caption, clickHandler, id, color = 'currentcolor', visible = true, tooltip) {
					if (!caption || typeof clickHandler != 'function') throw 'Invalid argument';
					const elem = document.createElement('SPAN');
					if (id) elem.id = id;
					elem.className = 'brackets';
					elem.textContent = caption;
					elem.style = `float: right; margin-right: 1em; cursor: pointer; color: ${color};`;
					elem.onclick = clickHandler;
					if (!visible) elem.hidden = true;
					if (tooltip) elem.title = tooltip;
					td.append(elem);
					return elem;
				}

				addButton('Cover art auto lookup', function(evt) {
					if (checkSavedRecovery()) evt.currentTarget.remove(); else return false;
					const pager = document.body.querySelector('div.main_column > div.linkbox:not(.pager)');
					if (pager != null) setFocus(pager);
					const autoHideFailed = GM_getValue('auto_hide_failed', false);
					document.body.querySelectorAll('table#discog_table > tbody > tr').forEach(function(tr) {
						const td = document.createElement('TD');
						tr.append(td);
						if (tr.classList.contains('colhead_dark')) {
							td.textContent = 'Status';
							td.title = 'Result of attempt to add missing/broken/poor cover\nHover the mouse over status for more details'; //setTooltip(td, tooltip);
						} else if (/^group_(\d+)$/.test(tr.id)) {
							td.className = 'status';
							td.style.opacity = 0.3;
							td.textContent = 'unknown';
							resolveTorrentRow(tr, ihh);
						}
					});
				}, 'covers-auto-lookup', 'gold');
				addButton('Hide failed', function(evt) {
					evt.currentTarget.hidden = true;
					for (let td of document.body.querySelectorAll('table#discog_table > tbody > tr[id] > td.status.status-code-0'))
						td.parentNode.hidden = true;
				}, 'hide-status-failed', undefined, false);
			}
		});
		break;
	case '/userhistory.php':
	case '/top10.php':
		document.body.querySelectorAll('table.torrent_table').forEach(function(table, index) {
			const parent = findParent(table);
			if (parent) addTableHandlers(table, parent, 'display: inline-block; margin-left: 3em;', index + 1);
		});
		break;
	case '/better.php':
		if (!noEditPerms && ajaxApiKey && !readOnly && !noBatchProcessing && !noAutoLookups
				&& ['artwork'].includes(urlParams.get('method'))) imageHostHelper.then(function(ihh) {
			const linkBox = document.body.querySelector('div.header > div.linkbox');
			console.assert(linkBox != null);
			if (linkBox == null) throw 'linkbox not found';
			const a = document.createElement('A');
			a.id = 'covers-auto-lookup';
			a.className = 'brackets';
			a.textContent = 'Cover art auto lookup';
			a.href = '#';
			a.onclick = function(evt) {
				if (!checkSavedRecovery()) return false;
				evt.currentTarget.previousSibling.remove();
				evt.currentTarget.remove();
				const pager = document.body.querySelector('div.linkbox.pager');
				if (pager != null) setFocus(pager);
				const div = document.createElement('DIV'), span = document.createElement('SPAN');
				div.style = 'position: fixed; top: 10pt; right: 10pt; padding: 3pt; background-color: #2f4f4f8a; color: white; font-weight: 600; border-radius: 5pt; box-shadow: 2px 2px 3px black';
				div.id = 'hide-status-failed';
				div.hidden = true;
				span.textContent = 'Hide failed';
				span.style = 'display: inline-block; padding: 5pt; cursor: pointer; transition: color 250ms;';
				span.onclick = function(evt) {
					evt.currentTarget.parentNode.hidden = true;
					for (let td of document.body.querySelectorAll('table.torrent_table > tbody > tr.torrent > td.status.status-code-0'))
						td.parentNode.hidden = true;
				};
				span.onmouseenter = span.onmouseleave = function(evt) {
					if (evt.relatedTarget == evt.currentTarget) return false;
					evt.currentTarget.style.color = evt.type == 'mouseenter' ? 'orange' : 'white';
				};
				div.append(span);
				document.body.append(div);
				document.body.querySelectorAll('table.torrent_table > tbody > tr').forEach(function(tr) {
					const td = document.createElement('TD');
					tr.append(td);
					if (!(tr.classList.contains('torrent'))) return;
					td.className = 'status';
					td.style.opacity = 0.3;
					td.textContent = 'unknown';
					resolveTorrentRow(tr, ihh);
				});
				return false;
			};
			linkBox.append(' ', a);
		});
		break;
}

}