Greasy Fork

[GMT] Edition lookup by CD TOC

Lookup edition by CD TOC on MusicBrainz, GnuDb and in CUETools DB, seed new/update existing MusicBrainz releases based on the TOC

目前为 2023-11-20 提交的版本。查看 最新版本

// ==UserScript==
// @name         [GMT] Edition lookup by CD TOC
// @namespace    https://greasyfork.org/users/321857-anakunda
// @version      1.16.6
// @description  Lookup edition by CD TOC on MusicBrainz, GnuDb and in CUETools DB, seed new/update existing MusicBrainz releases based on the TOC
// @match        https://*/torrents.php?id=*
// @match        https://*/torrents.php?page=*&id=*
// @run-at       document-end
// @iconURL      https://ptpimg.me/5t8kf8.png
// @grant        GM_xmlhttpRequest
// @grant        GM_openInTab
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_getResourceText
// @grant        GM_getResourceURL
// @grant        GM_addValueChangeListener
// @grant        GM_removeValueChangeListener
// @connect      musicbrainz.org
// @connect      coverartarchive.org
// @connect      archive.org
// @connect      discogs.com
// @connect      db.cuetools.net
// @connect      db.cue.tools
// @connect      gnudb.org
// @connect      allmusic.com
// @connect      accuraterip.com
// @connect      api.translatedlabs.com
// @author       Anakunda
// @license      GPL-3.0-or-later
// @resource     mb_logo https://upload.wikimedia.org/wikipedia/commons/9/9e/MusicBrainz_Logo_%282016%29.svg
// @resource     mb_icon https://upload.wikimedia.org/wikipedia/commons/9/9a/MusicBrainz_Logo_Icon_%282016%29.svg
// @resource     mb_logo_text https://github.com/metabrainz/metabrainz-logos/raw/master/logos/MusicBrainz/SVG/MusicBrainz_logo.svg
// @resource     mb_text https://github.com/metabrainz/metabrainz-logos/raw/master/logos/MusicBrainz/SVG/MusicBrainz_logo_text_only.svg
// @resource     dc_icon https://upload.wikimedia.org/wikipedia/commons/6/69/Discogs_record_icon.svg
// @require      https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js
// @require      https://openuserjs.org/src/libs/Anakunda/xhrLib.min.js
// @require      https://openuserjs.org/src/libs/Anakunda/libLocks.min.js
// @require      https://openuserjs.org/src/libs/Anakunda/gazelleApiLib.min.js
// @require      https://openuserjs.org/src/libs/Anakunda/libStringDistance.min.js
// ==/UserScript==

{

'use strict';

const sessionsCache = new Map, mbRequestsCache = new Map;
let sessionsSessionCache, mbLastRequest = null, noEditPerms = document.getElementById('nav_userclass'), logScoresCache;
noEditPerms = noEditPerms != null && ['User', 'Member', 'Power User'].includes(noEditPerms.textContent.trim());
const [mbOrigin, dcOrigin] = ['https://musicbrainz.org', 'https://www.discogs.com'];
const toASCII = str => str && str.normalize('NFKD').replace(/[\x00-\x1F\u0300-\u036F]/gu, '');
const cmpNorm = str => str && toASCII(str).replace(/[\s\–\x00-\x2F\x3A-\x40\x5B-\x5E\x60\x7B-\x7F]+/g, '').toLowerCase();
const sameStringValues = (...strVals) => strVals.length > 0
	&& strVals.every((strVal, ndx, arr) => strVal && cmpNorm(strVal) == cmpNorm(arr[0]));
const similarStringValues = (strVal1, strVal2, threshold = 0.95) => strVal1 && strVal2
	&& (sameStringValues(strVal1, strVal2)
	|| jaroWinklerSimilarity(toASCII(strVal1).toLowerCase(), toASCII(strVal2).toLowerCase()) >= threshold);
const createElements = (...tagNames) => tagNames.map(Document.prototype.createElement.bind(document));
const debugLogging = GM_getValue('debug_logging', false);
const mbID = /([\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12})/i.source;
const rxMBID = new RegExp(`^${mbID}$`, 'i');

const mbRequestRate = 1000;
function mbApiRequest(endPoint, params) {
	if (!endPoint) throw 'Endpoint is missing';
	const url = new URL('/ws/2/' + endPoint.replace(/^\/+|\/+$/g, ''), mbOrigin);
	url.search = new URLSearchParams(Object.assign({ fmt: 'json' }, params));
	const cacheKey = url.pathname.slice(6) + url.search;
	if (mbRequestsCache.has(cacheKey)) return mbRequestsCache.get(cacheKey);
	const request = new Promise((resolve, reject) => { (function request(reqCounter = 1) {
		if (reqCounter > 60) return reject('Request retry limit exceeded');
		if (mbLastRequest == Infinity) return setTimeout(request, 50, reqCounter);
		const now = Date.now();
		if (now <= mbLastRequest + mbRequestRate)
			return setTimeout(request, mbLastRequest + mbRequestRate - now, reqCounter);
		mbLastRequest = Infinity;
		globalXHR(url, { responseType: 'json' }).then(function({response}) {
			mbLastRequest = Date.now();
			resolve(response);
		}, function(reason) {
			mbLastRequest = Date.now();
			reject(reason);
		});
	})() });
	mbRequestsCache.set(cacheKey, request);
	return request;
}

function mbIdExtractor(expr, entity) {
	if (!expr || !expr) return null;
	let mbId = rxMBID.exec(expr);
	if (mbId) return mbId[1].toLowerCase(); else if (!entity) return null;
	try { mbId = new URL(expr) } catch(e) { return null }
	return mbId.hostname.endsWith('musicbrainz.org')
		&& (mbId = new RegExp(`^\\/${entity}\\/${mbID}\\b`, 'i').exec(mbId.pathname)) != null ?
			mbId[1].toLowerCase() : null;
}

const dcApiRateControl = { }, dcApiRequestsCache = new Map;
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;
})();
let dcApiResponses, quotaExceeded = false;

function dcApiRequest(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);
	if (!dcApiResponses && 'discogsApiResponseCache' in sessionStorage) try {
		dcApiResponses = JSON.parse(sessionStorage.getItem('discogsApiResponseCache'));
	} catch(e) {
		sessionStorage.removeItem('discogsApiResponseCache');
		console.warn(e);
	}
	if (dcApiResponses && cacheKey in dcApiResponses) return Promise.resolve(dcApiResponses[cacheKey]);
	const reqHeader = { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' };
	if (dcAuth) reqHeader.Authorization = 'Discogs ' +
		Object.keys(dcAuth).map(key => key + '=' + dcAuth[key]).join(', ');
	// if (dcAuth) for (let key in dcAuth) endPoint.searchParams.set(key, dcAuth[key]);
	let requestsMax = dcAuth ? 60 : 25, retryCounter = 0;
	const request = new Promise((resolve, reject) => (function request() {
		const now = Date.now();
		const postpone = () => { setTimeout(request, dcApiRateControl.timeFrameExpiry - now) };
		if (!dcApiRateControl.timeFrameExpiry || now > dcApiRateControl.timeFrameExpiry) {
			dcApiRateControl.timeFrameExpiry = now + 60 * 1000 + 500;
			if (dcApiRateControl.requestDebt > 0) {
				dcApiRateControl.requestCounter = Math.min(requestsMax, dcApiRateControl.requestDebt);
				dcApiRateControl.requestDebt -= dcApiRateControl.requestCounter;
				console.assert(dcApiRateControl.requestDebt >= 0, 'dcApiRateControl.requestDebt >= 0');
			} else dcApiRateControl.requestCounter = 0;
		}
		if (++dcApiRateControl.requestCounter <= requestsMax) GM_xmlhttpRequest({ method: 'GET', url: endPoint,
			responseType: 'json', headers: reqHeader,
			onload: function(response) {
				let requestsUsed = /^(?:x-discogs-ratelimit):\s*(\d+)\b/im.exec(response.responseHeaders);
				if (requestsUsed != null && (requestsUsed = parseInt(requestsUsed[1])) > 0) requestsMax = requestsUsed;
				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 - requestsMax, 0);
				}
				if (response.status >= 200 && response.status < 400) {
					if (!quotaExceeded) try {
						if (!dcApiResponses) dcApiResponses = { };
						dcApiResponses[cacheKey] = response.response;
						sessionStorage.setItem('discogsApiResponseCache', JSON.stringify(dcApiResponses));
					} catch(e) {
						quotaExceeded = true;
						console.warn(e);
					}
					resolve(response.response);
				} else if (response.status == 429/* && ++retryCounter < xhrLibmaxRetries*/) {
					console.warn(defaultErrorHandler(response), response.response.message, '(' + retryCounter + ')',
						`Rate limit used: ${requestsUsed}/${requestsMax}`);
					postpone();
				} else if (recoverableHttpErrors.includes(response.status) && ++retryCounter < xhrLibmaxRetries)
					setTimeout(request, xhrRetryTimeout);
				else reject(defaultErrorHandler(response));
			},
			onerror: function(response) {
				if (recoverableHttpErrors.includes(response.status) && ++retryCounter < xhrLibmaxRetries)
					setTimeout(request, xhrRetryTimeout);
				else reject(defaultErrorHandler(response));
			},
			ontimeout: response => { reject(defaultTimeoutHandler(response)) },
		}); else postpone();
	})());
	dcApiRequestsCache.set(cacheKey, request);
	return request;
}

let arOffsets;
function getAccuripOffsets() {
	if (arOffsets instanceof Promise) return arOffsets;
	const cachedOffsets = GM_getValue('read_offsets');
	if (cachedOffsets && Object.keys(cachedOffsets).length <= 0) cachedOffsets = undefined;
	if (cachedOffsets) {
		const timeStamp = GM_getValue('read_offsets_time');
		if (timeStamp > 0 && Date.now() - timeStamp < 24 * 60 * 60 * 1000)
			return arOffsets = Promise.resolve(cachedOffsets);
	}
	return arOffsets = globalXHR('http://accuraterip.com/driveoffsets.htm').then(function({document}) {
		const offsets = Object.assign.apply({ }, Array.from(document.body.querySelectorAll('table table > tbody > tr:not(:first-of-type)'), function(tr) {
			const offset = {
				driveId: tr.cells[0].textContent.trim().replace(/\s+/g, ' ').replace(/^\s*-\s*/, ''),
				offset: parseInt(tr.cells[1].textContent),
				submits: parseInt(tr.cells[2].textContent),
				agreeRate: parseInt(tr.cells[3].textContent),
			};
			return offset.driveId && !isNaN(offset.offset) ? offset : null;
		}).filter(Boolean).map(offset => ({ [offset.driveId]: [offset.offset, offset.submits, offset.agreeRate] })));
		if (offsets.length <= 0) return Promise.reject('No drive offsets found');
		GM_setValue('read_offsets_time', Date.now());
		GM_setValue('read_offsets', offsets);
		return offsets;
	}).catch(reason => cachedOffsets || Promise.reject(reason));
}

const msf = 75, preGap = 2 * msf, msfTime = /(?:(\d+):)?(\d+):(\d+)[\.\:](\d+)/.source;
const msfToSector = time => Array.isArray(time) || (time = new RegExp('^\\s*' + msfTime + '\\s*$').exec(time)) != null ?
	(((time[1] ? parseInt(time[1]) : 0) * 60 + parseInt(time[2])) * 60 + parseInt(time[3])) * msf + parseInt(time[4]) : NaN;
const rxRangeRip = /^(?:Selected range|Выбранный диапазон|Âûáðàííûé äèàïàçîí|已选择范围|選択された範囲|Gewählter Bereich|Intervallo selezionato|Geselecteerd bereik|Utvalt område|Seleccionar gama|Избран диапазон|Wybrany zakres|Izabrani opseg|Vybraný rozsah)(?:[^\S\r\n]+\((?:Sectors|Секторы|扇区|Sektoren|Settori|Sektorer|Sectores|Сектори|Sektora|Sektory)[^\S\r\n]+(\d+)[^\S\r\n]*-[^\S\r\n]*(\d+)\))?$/m;
const sessionHeader = '(?:' + [
	'(?:EAC|XLD) (?:extraction logfile from |Auslese-Logdatei vom |extraheringsloggfil från |uitlezen log bestand van |log súbor extrakcie z |抓取日志文件从)',
	'File di log (?:EAC|XLD) per l\'estrazione del ', 'Archivo Log de extracciones desde ',
	'Отчёт (?:EAC|XLD) об извлечении, выполненном ', 'Отчет на (?:EAC|XLD) за извличане, извършено на ',
	'Protokol extrakce (?:EAC|XLD) z ', 'Sprawozdanie ze zgrywania programem (?:EAC|XLD) z ',
	'(?:EAC|XLD)-ov fajl dnevnika ekstrakcije iz ',
	'(?:Log created by: whipper|EZ CD Audio Converter) .+(?:\\r?\\n)+Log creation date: ',
	'morituri extraction logfile from ', 'Rip .+ Audio Extraction Log',
].join('|') + ')';
const rxTrackExtractor = /^(?:(?:(?:Track|Трек|Òðåê|音轨|Traccia|Spår|Pista|Трак|Utwór|Stopa)[^\S\r\n]+\d+\b.*|Track \d+ saved to\b.+)$(?:\r?\n^(?:[^\S\r\n]+.*)?$)*| +\d+:$\r?\n^ {4,}Filename:.+$(?:\r?\n^(?: {4,}.*)?$)*)/gm;

function getTocEntries(session) {
	if (!session) return null;
	const tocParsers = [
		'^\\s+' + ['\\d+', msfTime, msfTime, '\\d+', '\\d+'] // EAC / XLD
			.map(pattern => '(' + pattern + ')').join('\\s+\\|\\s+') + '\\s*$',
		'^\\s+\[X\]\\s+' + ['\\d+', msfTime, msfTime, '\\d+', '\\d+'] // EZ CD
			.map(pattern => '(' + pattern + ')').join('\\s+') + '\\b',
		// whipper
		'^ +(\\d+): *' + [['Start', msfTime], ['Length', msfTime], ['Start sector', '\\d+'], ['End sector', '\\d+']]
			.map(([label, capture]) => `\\r?\\n {4,}${label}: *(${capture})\\b *`).join(''),
		// Rip
		'^\\s+' + ['(\\d+)', '(' + msfTime + ')', '(?:(?:\\d+):)?\\d+:\\d+[\\.\\:]\\d+', '(' + msfTime + ')', '(\\d+)', '(\\d+)', '\\d+']
			.join('\\s+\\|\\s+') + '\\s*$',
	];
	let tocEntries = tocParsers.reduce((m, rx) => m || session.match(new RegExp(rx, 'gm')), null);
	if (tocEntries == null || (tocEntries = tocEntries.map(function(tocEntry, trackNdx) {
		if ((tocEntry = tocParsers.reduce((m, rx) => m || new RegExp(rx).exec(tocEntry), null)) == null)
			throw `assertion failed: track ${trackNdx + 1} ToC entry invalid format`;
		const [startSector, endSector] = [12, 13].map(index => parseInt(tocEntry[index]));
		console.assert(msfToSector(tocEntry[2]) == startSector && msfToSector(tocEntry[7]) == endSector + 1 - startSector
			&& endSector >= startSector, 'TOC table entry validation failed:', tocEntry);
		return { trackNumber: parseInt(tocEntry[1]), startSector: startSector, endSector: endSector };
	})).length <= 0) return null;
	if (!tocEntries.every((tocEntry, trackNdx) => tocEntry.trackNumber == trackNdx + 1)) {
		tocEntries = Object.assign.apply({ }, tocEntries.map(tocEntry => ({ [tocEntry.trackNumber]: tocEntry })));
		tocEntries = Object.keys(tocEntries).sort((a, b) => parseInt(a) - parseInt(b)).map(key => tocEntries[key]);
	}
	console.assert(tocEntries.every((tocEntry, trackNdx, tocEntries) => tocEntry.trackNumber == trackNdx + 1
		&& tocEntry.endSector >= tocEntry.startSector && (trackNdx <= 0 || tocEntry.startSector > tocEntries[trackNdx - 1].endSector)),
		'TOC table structure validation failed:', tocEntries);
	return tocEntries;
}

function getTrackDetails(session) {
	function extractValues(patterns, ...callbacks) {
		if (!Array.isArray(patterns) || patterns.length <= 0) return null;
		const rxs = patterns.map(pattern => new RegExp('^[^\\S\\r\\n]+' + pattern + '\\s*$', 'm'));
		return trackRecords.map(function(trackRecord, trackNdx) {
			trackRecord = rxs.map(rx => rx.exec(trackRecord));
			const index = trackRecord.findIndex(matches => matches != null);
			return index < 0 || typeof callbacks[index] != 'function' ? null : callbacks[index](trackRecord[index]);
		});
	}

	if (rxRangeRip.test(session)) return { }; // Nothing to extract from RR
	const trackRecords = session.match(rxTrackExtractor);
	if (trackRecords == null) return { };
	const h2i = m => parseInt(m[1], 16);
	return Object.assign({ crc32: extractValues([
		'(?:(?:Copy|复制|Kopie|Copia|Kopiera|Copiar|Копиран) CRC|CRC (?:копии|êîïèè|kopii|kopije|kopie|kópie)|コピーCRC)\\s+([\\da-fA-F]{8})', // 1272
		'(?:CRC32 hash|Copy CRC|CRC)\\s*:\\s*([\\da-fA-F]{8})',
	], h2i, h2i), peak: extractValues([
		'(?:Peak level|Пиковый уровень|Ïèêîâûé óðîâåíü|峰值电平|ピークレベル|Spitzenpegel|Pauze lengte|Livello di picco|Peak-nivå|Nivel Pico|Пиково ниво|Poziom wysterowania|Vršni nivo|[Šš]pičková úroveň)\\s+(\\d+(?:\\.\\d+)?)\\s*\\%', // 1217
		'(?:Peak(?: level)?)\\s*:\\s*(\\d+(?:\\.\\d+)?)',
	], m => [parseFloat(m[1]) * 10, 3], m => [parseFloat(m[1]) * 1000, 6]), preGap: extractValues([
		'(?:Pre-gap length|Длина предзазора|Äëèíà ïðåäçàçîðà|前间隙长度|Pausenlänge|Durata Pre-Gap|För-gap längd|Longitud Pre-gap|Дължина на предпразнина|Długość przerwy|Pre-gap dužina|[Dd]élka mezery|Dĺžka medzery pred stopou)\\s+' + msfTime, // 1270
		'(?:Pre-gap length)\\s*:\\s*' + msfTime,
	], msfToSector, msfToSector) }, Object.assign.apply(undefined, [1, 2].map(v => ({ ['arv' + v]: extractValues([
		'.+?\\[([\\da-fA-F]{8})\\].+\\(AR v' + v + '\\)',
		'(?:AccurateRip v' + v + ' signature)\\s*:\\s*([\\da-fA-F]{8})',
	], h2i, h2i) }))));
}

function getUniqueSessions(logFiles, detectVolumes = GM_getValue('detect_volumes', false)) {
	logFiles = Array.prototype.map.call(logFiles, function(logFile) {
		while (logFile.startsWith('\uFEFF')) logFile = logFile.slice(1);
		return logFile;
	});
	const rxRipperSignatures = '(?:(?:' + [
		'Exact Audio Copy V', 'X Lossless Decoder version ', 'CUERipper v',
		'EZ CD Audio Converter ', 'Log created by: whipper ', 'morituri version ', 'Rip ',
	].join('|') + ')\\d+)';
	const verifyOffsets = sessions => sessions.length > 0 ? Promise.all(sessions.map(function(session) {
		let usedDrive = [
			/^(?:Used drive|Benutztes Laufwerk|Unità predefinita|Usar unidad|Използвано устройство|使用驱动器|Použitá mechanika|Använd enhet|gebruikt loopwerk|Użyty napęd|Дисковод|Korišćen drajv|Äèñêîâîä|Drive used)\s*:\s*(.+)$/im,
			/^(?:Device: *\(?:\[ ?[A-Z]: ?\] *)? \s*:\s*(.+)$/m, // EZCD
		].reduce((matches, rx) => matches || rx.exec(session), null);
		let readOffset = /^(?:Read offset(?: correction)?|Коррекция смещения при чтении|读取偏移校正|Leseoffset Korrektur|Correzione offset di lettura|Corrección de Desplazamiento de Lectura|Lees-offset correctie|Läs-offset-korrigering|Офсет корекция при четене|Korekta położenia dla odczytu|Korekce vychýlení čtení|Offsetová korekcia pre čítanie|Korekcija offset-a kod čitanja|Êîððåêöèÿ ñìåùåíèÿ ïðè ÷òåíèè|Sample offset)\s*:\s*(\d+)\b/im.exec(session);
		if (usedDrive == null || readOffset == null) return Promise.resolve(undefined);
		usedDrive = usedDrive[1].replace(/\s+(?:(?:Adapter|ID):\s+\d+.*|\((?:not found|revision)\b.+\))$/, '').replace(/\s+/g, ' ').trim();
		readOffset = parseInt(readOffset[1]);
		return getAccuripOffsets().then(function(arOffsets) {
			const drives = Object.keys(arOffsets).filter(key => sameStringValues(key, usedDrive) || sameStringValues(key, [
				[/^(?:JLMS)\b/, 'Lite-ON'],
				[/^(?:HL-DT-ST|HL[ -]?DT[ -]?ST\b)/, 'LG Electronics'],
				[/^(?:Matshita)\b/i, 'Panasonic'],
			].reduce((driveStr, subst) => driveStr.replace(...subst), usedDrive)));
			if (drives.length <= 0) return undefined;
			console.info('Read offset(s) for %s found in AR database:', usedDrive, drives.map(drive =>
				`${arOffsets[drive][0] > 0 ? '+' + arOffsets[drive][0] : arOffsets[drive][0]} (submits: ${arOffsets[drive][1]}, agree rate: ${arOffsets[drive][2]})`));
			const matches = drives.map(function(drive) {
				if (arOffsets[drive][1] >= 5 || arOffsets[drive][2] >= 100) return readOffset == arOffsets[drive][0];
				console.info('Weak read offset for', drive, 'found in AR database - offset not verified');
			});
			if (matches.some(Boolean)) return true; else if (matches.some(match => match != false)) return undefined;
			console.warn('Mismatching read offset for', usedDrive, matches);
			return false;
		}, reason => { console.warn(reason) });
	})).then(results => !results.some(result => result == false)
		|| confirm('At least one disc was ripped with incorrect read offset, continue anyway?')
			? sessions : Promise.reject('Incorrect read offset')) : null;
	if (!detectVolumes) {
		const rxStackedLog = new RegExp('^[\\S\\s]*(?:\\r?\\n)+(?=' + rxRipperSignatures + ')');
		logFiles = logFiles.map(logFile => rxStackedLog.test(logFile) ? logFile.replace(rxStackedLog, '') : logFile)
			.filter(RegExp.prototype.test.bind(new RegExp('^(?:' + rxRipperSignatures + '|' + sessionHeader + ')')));
		return verifyOffsets(logFiles);
	}
	if ((logFiles = logFiles.map(function(logFile) {
		let rxSessionsIndexer = new RegExp('^' + rxRipperSignatures, 'gm'), indexes = [ ], match;
		while ((match = rxSessionsIndexer.exec(logFile)) != null) indexes.push(match.index);
		if (indexes.length <= 0) {
			rxSessionsIndexer = new RegExp('^' + sessionHeader, 'gm');
			while ((match = rxSessionsIndexer.exec(logFile)) != null) indexes.push(match.index);
		}
		return (indexes = indexes.map((index, ndx, arr) => logFile.slice(index, arr[ndx + 1])).filter(function(logFile) {
			const rr = rxRangeRip.exec(logFile);
			if (rr == null) return true;
			// Ditch HTOA logs
			const tocEntries = getTocEntries(logFile);
			return tocEntries == null || parseInt(rr[1]) != 0 || parseInt(rr[2]) + 1 != tocEntries[0].startSector;
		})).length > 0 ? indexes : null;
	}).filter(Boolean)).length <= 0) return null;
	const sessions = new Map;
	for (const logFile of logFiles) for (const session of logFile) {
		let uniqueKey = getTocEntries(session), title;
		if (uniqueKey != null) uniqueKey = [uniqueKey[0].startSector].concat(uniqueKey.map(tocEntry =>
			tocEntry.endSector + 1)).map(offset => offset.toString(32).padStart(4, '0')).join(''); else continue;
		if ((title = new RegExp('^' + sessionHeader + '(.+)$(?:\\r?\\n)+^(.+ [\\/\\-] .+)$', 'm').exec(session)) != null)
			title = title[2];
		else if ((title = /^ +Release: *$\r?\n^ +Artist: *(.+)$\r?\n^ +Title: *(.+)$/m.exec(session)) != null)
			title = title[1] + ' / ' + title[2]; // Whipper?
		else if ((title = /^Compact Disc Information\r?\n=+\r?\nName: *(.+)$/m.exec(session)) != null)
			title = title[1]; // Rip
		if (title != null) uniqueKey += '/' + title.replace(/\s+/g, '').toLowerCase();
		sessions.set(uniqueKey, session);
	}
	//console.info('Unique keys:', Array.from(sessions.keys()));
	return sessions.size > 0 ? verifyOffsets(Array.from(sessions.values())) : null;
}

function getSessions(torrentId) {
	if (!(torrentId > 0)) throw 'Invalid argument';
	if (sessionsCache.has(torrentId)) return sessionsCache.get(torrentId);
	if (!sessionsSessionCache && 'ripSessionsCache' in sessionStorage) try {
		sessionsSessionCache = JSON.parse(sessionStorage.getItem('ripSessionsCache'));
	} catch(e) {
		console.warn(e);
		sessionStorage.removeItem('ripSessionsCache');
		sessionsSessionCache = undefined;
	}
	if (!logScoresCache && (logScoresCache = sessionStorage.getItem('logScoresCache'))) try {
		logScoresCache = JSON.parse(logScoresCache);
	} catch(e) {
		console.warn(e);
		sessionStorage.removeItem('logScoresCache');
		logScoresCache = undefined;
	}
	if (sessionsSessionCache && torrentId in sessionsSessionCache)
		return Promise.resolve(sessionsSessionCache[torrentId]);
	// let request = queryAjaxAPICached('torrent', { id: torrentId });
	// request = request.then(({torrent}) => torrent.logCount > 0 ? Promise.all(torrent.ripLogIds.map(ripLogId =>
	// 	queryAjaxAPICached('riplog', { id: torrentId, logid: ripLogId }).then(response => response))) : Promise.reject('No logfiles attached'));
	let request = localXHR('/torrents.php?' + new URLSearchParams({ action: 'loglist', torrentid: torrentId }));
	request.then(function(document) {
		const spans = Array.from(document.body.querySelectorAll(':scope > blockquote > strong + span'), function(span) {
			const result = { score: parseInt(span.textContent) };
			console.assert(!isNaN(result.score), span.cloneNode());
			if ((span = span.parentNode.nextElementSibling) != null
					&& (span = span.querySelector(':scope > h3 + pre')) != null
				 	&& (span = span.textContent.split(/\r?\n/).map(deduction => deduction.trim()).filter(Boolean)).length > 0)
				result.deductions = span;
			return result;
		});
		if (spans.length <= 0) return;
		if (!logScoresCache) logScoresCache = { };
		logScoresCache[torrentId] = spans;
		sessionStorage.setItem('logScoresCache', JSON.stringify(logScoresCache));
	});
	request = request.then(document => Array.from(document.body.querySelectorAll(':scope > blockquote > pre:first-child'),
		pre => pre.textContent));
	sessionsCache.set(torrentId, (request = request.then(getUniqueSessions).then(function(sessions) {
		if (sessions == null) return Promise.reject('No valid logfiles attached'); else if (!quotaExceeded) try {
			if (!sessionsSessionCache) sessionsSessionCache = { };
			sessionsSessionCache[torrentId] = sessions;
			sessionStorage.setItem('ripSessionsCache', JSON.stringify(sessionsSessionCache));
		} catch(e) {
			quotaExceeded = true;
			console.warn(e);
		}
		return sessions;
	})));
	return request;
}

function getLayoutType(tocEntries) {
	for (let index = 0; index < tocEntries.length - 1; ++index) {
		const gap = tocEntries[index + 1].startSector - tocEntries[index].endSector - 1;
		if (gap != 0) return (gap == 11400 ? tocEntries.length - index : 0) - 1;
	}
	return 0;
}

function lookupByToc(torrentId, callback) {
	if (typeof callback != 'function') return Promise.reject('Invalid argument');
	return getSessions(torrentId).then(sessions => Promise.all(sessions.map(function(session, volumeNdx) {
		const isRangeRip = rxRangeRip.test(session), tocEntries = getTocEntries(session);
		if (tocEntries == null) throw `disc ${volumeNdx + 1} ToC not found`;
		let layoutType = getLayoutType(tocEntries);
		if (layoutType < 0) console.warn('Disc %d unknown layout type', volumeNdx + 1);
		else while (layoutType-- > 0) tocEntries.pop(); // ditch data tracks for CD with data track(s)
		return callback(tocEntries, volumeNdx, sessions.length);
	}).map(results => results.catch(function(reason) {
		console.log('Edition lookup failed for the reason', reason);
		return null;
	}))));
}

class DiscID {
	constructor() { this.id = '' }

	addValues(values, width = 0, length = 0) {
		if (!Array.isArray(values)) values = [values];
		values = values.map(value => value.toString(16).toUpperCase().padStart(width, '0')).join('');
		this.id += width > 0 && length > 0 ? values.padEnd(length * width, '0') : values;
		return this;
	}
	toDigest() {
		return CryptoJS.SHA1(this.id).toString(CryptoJS.enc.Base64)
			.replace(/\=/g, '-').replace(/\+/g, '.').replace(/\//g, '_');
	}
}

function mbComputeDiscID(mbTOC) {
	if (!Array.isArray(mbTOC) || mbTOC.length != mbTOC[1] - mbTOC[0] + 4 || mbTOC[1] - mbTOC[0] > 98)
		throw 'Invalid or too long MB TOC';
	return new DiscID().addValues(mbTOC.slice(0, 2), 2).addValues(mbTOC.slice(2), 8, 100).toDigest();
}

function tocEntriesToMbTOC(tocEntries) {
	if (!Array.isArray(tocEntries) || tocEntries.length <= 0) throw 'Invalid argument';
	const isHTOA = tocEntries[0].startSector > preGap, mbTOC = [tocEntries[0].trackNumber, tocEntries.length];
	mbTOC.push(preGap + tocEntries[tocEntries.length - 1].endSector + 1);
	return Array.prototype.concat.apply(mbTOC, tocEntries.map(tocEntry => preGap + tocEntry.startSector));
}

if (typeof unsafeWindow == 'object') {
	unsafeWindow.lookupByToc = lookupByToc;
	unsafeWindow.mbComputeDiscID = mbComputeDiscID;
	unsafeWindow.tocEntriesToMbTOC = tocEntriesToMbTOC;
}

function getCDDBiD(tocEntries) {
	if (!Array.isArray(tocEntries)) throw 'Invalid argument';
	const tt = Math.floor((tocEntries[tocEntries.length - 1].endSector + 1 - tocEntries[0].startSector) / msf);
	let discId = tocEntries.reduce(function(sum, tocEntry) {
		let n = Math.floor((parseInt(tocEntry.startSector) + preGap) / msf), s = 0;
		while (n > 0) { s += n % 10; n = Math.floor(n / 10) }
		return sum + s;
	}, 0) % 0xFF << 24 | tt << 8 | tocEntries.length;
	if (discId < 0) discId = 2**32 + discId;
	return discId.toString(16).toLowerCase().padStart(8, '0');
}

function getARiD(tocEntries) {
	if (!Array.isArray(tocEntries)) throw 'Invalid argument';
  const discIds = [0, 0];
  for (let index = 0; index < tocEntries.length; ++index) {
		discIds[0] += tocEntries[index].startSector;
		discIds[1] += Math.max(tocEntries[index].startSector, 1) * (index + 1);
	}
	discIds[0] += tocEntries[tocEntries.length - 1].endSector + 1;
	discIds[1] += (tocEntries[tocEntries.length - 1].endSector + 1) * (tocEntries.length + 1);
  return discIds.map(discId => discId.toString(16).toLowerCase().padStart(8, '0'))
		.concat(getCDDBiD(tocEntries)).join('-');
}

const noLabel = 'self-released';
const [rxNoLabel, rxBareLabel, rxNoCatno, rxCatNoRange] = [
	/^(?:(?:No(?:t\s+On)?\s+Label|None|iMD|Independ[ae]nt)\b|\[(?:no\s+label|none)\]$)|\b(?:Self[\-\s]?Released?)\b/i,
	[/^(?:The)\s+|(?:\s+\b(?:Record(?:ings|s)?|(?:Production|Publishing)s?|Corporation|Int(?:ernationa|\')?l|Discos)\b|,?\s+(?:Ltd|Inc|Co(?:rp)?|LLC|Intl)\.?)+$/ig, ''],
	/^(?:None|\[none\])$/i, /^(.*?)(\d+)~(\d+)$/,
];

function setTooltip(elem, tooltip, params) {
	if (!(elem instanceof HTMLElement)) throw 'Invalid argument';
	if (typeof jQuery.fn.tooltipster == '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(Object.assign(params || { }, { content: tooltip }));
	} else if (tooltip) elem.title = tooltip; else elem.removeAttribute('title');
}

function getTorrentId(tr) {
	if (!(tr instanceof HTMLElement)) throw 'Invalid argument';
	if ((tr = tr.querySelector('a.button_pl')) != null
			&& (tr = parseInt(new URLSearchParams(tr.search).get('torrentid'))) > 0) return tr;
}

function updateEdition(evt) {
	if (noEditPerms || /*!openTabHandler(evt) || */evt.currentTarget.disabled) return false; else if (!ajaxApiKey) {
		if (!(ajaxApiKey = prompt('Set your API key with torrent edit permission:\n\n'))) return false;
		GM_setValue('redacted_api_key', ajaxApiKey);
	}
	const target = evt.currentTarget, payload = { }, torrentDetails = target.closest('tr.torrentdetails');
	if (torrentDetails == null || !('editionGroup' in torrentDetails.dataset)) return false;
	if (target.dataset.remasterYear) payload.remaster_year = target.dataset.remasterYear; else return false;
	if (target.dataset.remasterRecordLabel) payload.remaster_record_label = target.dataset.remasterRecordLabel;
	if (target.dataset.remasterCatalogueNumber) payload.remaster_catalogue_number = target.dataset.remasterCatalogueNumber;
	if (target.dataset.remasterTitle) payload.remaster_title = target.dataset.remasterTitle;
	if (Object.keys(payload).length <= 0) return false; else if (Boolean(eval(target.dataset.confirm))) {
		const entries = [ ];
		if ('remaster_year' in payload) entries.push('Edition year: ' + payload.remaster_year);
		if ('remaster_record_label' in payload) entries.push('Record label: ' + payload.remaster_record_label);
		if ('remaster_catalogue_number' in payload) entries.push('Catalogue number: ' + payload.remaster_catalogue_number);
		if ('remaster_title' in payload) entries.push('Edition title: ' + payload.remaster_title);
		if (!confirm('Edition group is going to be updated\n\n' + entries.join('\n') +
				'\n\nAre you sure the information is correct?')) return false;
	}
	[target.disabled, target.style.color] = [true, 'orange'];
	Promise.all(Array.from(document.body.querySelectorAll('table#torrent_details > tbody > tr.torrent_row.edition_' + torrentDetails.dataset.editionGroup), function(tr) {
		const torrentId = getTorrentId(tr);
		if (!(torrentId > 0)) return null;
		const postData = new URLSearchParams(payload);
		if (target.dataset.releaseUrl && torrentId == parseInt(torrentDetails.dataset.torrentId)
				&& !(torrentDetails.dataset.torrentDescription || '').toLowerCase().includes(target.dataset.releaseUrl.toLowerCase()))
			postData.set('release_desc', ((torrentDetails.dataset.torrentDescription || '') + '\n\n').trimLeft() +
				'[url]' + target.dataset.releaseUrl + '[/url]');
		return queryAjaxAPI('torrentedit', { id: torrentId }, postData);
	})).then(function(responses) {
		target.style.color = '#0a0';
		console.log('Edition updated successfully:', responses);
		document.location.reload();
	}, function(reason) {
		target.style.color = 'red';
		alert(reason);
		target.disabled = false;
	});
	return false;
}

function applyOnClick(tr) {
	[tr.style.cursor, tr.dataset.confirm, tr.onclick] = ['pointer', true, updateEdition];
	setTooltip(tr, 'Use simple click to apply edition info from this release', { position: 'right' });
	tr.onmouseenter = tr.onmouseleave = evt => { evt.currentTarget.style.backgroundColor =
		evt.type == 'mouseenter' ? '#FFA50040' : evt.currentTarget.dataset.backgroundColor || null };
}

function applyOnCtrlClick(tr) {
	function updateStyle(state) {
		tr.style.cursor = state ? 'pointer' : 'auto';
		tr.style.backgroundColor = state ? '#FFA50040' : tr.dataset.backgroundColor || null;
	}

	tr.dataset.confirm = true;
	const eventHandler = evt => { updateStyle(evt.ctrlKey) }, events = ['keyup', 'keydown'];
	const listenerChanger = name => { for (let evt of events) document[name + 'EventListener'](evt, eventHandler) };
	tr.onclick = evt => evt.ctrlKey ? updateEdition(evt) : true;
	tr.onmouseenter = evt => { updateStyle(evt.ctrlKey); listenerChanger('add') };
	tr.onmouseleave = evt => { updateStyle(false); listenerChanger('remove') };
	setTooltip(tr, 'Use Ctrl to apply edition info from this release', { position: 'right' });
}

function addLookupResults(torrentId, ...elems) {
	if (!(torrentId > 0)) throw 'Invalid argument'; else if (elems.length <= 0) return;
	let elem = document.getElementById('torrent_' + torrentId);
	if (elem == null) throw '#torrent_' + torrentId + ' not found';
	let container = elem.querySelector('div.toc-lookup-tables');
	if (container == null) {
		if ((elem = elem.querySelector('div.linkbox')) == null) throw 'linkbox not found';
		elem.after(container = Object.assign(document.createElement('div'), {
			className: 'toc-lookup-tables',
			style: 'margin: 10pt 0; padding: 0; display: flex; flex-flow: column; row-gap: 10pt;',
		}));
	}
	(elem = document.createElement('div')).append(...elems);
	container.append(elem);
}

function decodeHTML(html) {
	const textArea = document.createElement('textarea');
	textArea.innerHTML = html;
	return textArea.value;
}

const editableHosts = GM_getValue('editable_hosts', ['redacted.ch']);
const incompleteEdition = /^(?:\d+ -|(?:Unconfirmed Release(?: \/.+)?|Unknown Release\(s\)) \/) CD$/;
const minifyHTML = html => html.replace(/\s*(?:\r?\n)+\s*/g, '');
const svgFail = (color = 'red', height = '0.9em') => minifyHTML(`
<svg height="${height}" viewBox="0 0 100 100" version="1.1" xmlns="http://www.w3.org/2000/svg">
	<circle fill="${color}" cx="50" cy="50" r="50"/>
	<polygon fill="white" clip-path="circle(35)" points="19.95,90.66 9.34,80.05 39.39,50 9.34,19.95 19.95,9.34 50,39.39 80.05,9.34 90.66,19.95 60.61,50 90.66,80.05 80.05,90.66 50,60.61"/>
</svg>`);
const svgCheckmark = (color = '#0c0', height = '0.9em') => minifyHTML(`
<svg height="${height}" viewBox="0 0 100 100" version="1.1" xmlns="http://www.w3.org/2000/svg">
	<circle fill="${color}" cx="50" cy="50" r="50"/>
	<path fill="white" d="M70.78 21.13c-2.05,0.44 -2.95,2.61 -3.98,4.19 -1.06,1.6 -2.28,3.22 -3.38,4.82 -6.68,9.75 -13.24,19.58 -19.9,29.34 -1.47,2.16 -1.1,1.99 -1.8,1.24 -1.95,-2.07 -4.14,-3.99 -6.18,-6.1 -1.36,-1.4 -2.72,-2.66 -4.06,-4.11 -1.44,-1.54 -3.14,-2.77 -5.29,-1.72 -1.18,0.57 -3.2,2.92 -4.35,3.98 -4.54,4.2 0.46,6.96 2.89,9.64 1.29,1.43 2.71,2.78 4.08,4.14 2.75,2.73 5.42,5.46 8.24,8.12 1.4,1.33 2.66,3.09 4.46,3.84 2.15,0.9 4.38,0.42 5.87,-1.39 1.03,-1.24 2.32,-3.43 3.31,-4.86 8.93,-12.94 17.53,-26.19 26.5,-39.06 1.1,-1.59 2.82,-3.29 2.81,-5.35 -0.02,-2.35 -2.03,-3.22 -3.69,-4.36 -1.69,-1.16 -3.25,-2.84 -5.53,-2.36z"/>
</svg>`);
const svgQuestionMark = (color = '#fc0', height = '0.9em') => minifyHTML(`
<svg height="${height}" viewBox="0 0 100 100" version="1.1" xmlns="http://www.w3.org/2000/svg">
	<circle fill="${color}" cx="50" cy="50" r="50"/>
	<path fill="white" fill-rule="nonzero" d="M74.57 31.75c0,7.23 -3.5,13.58 -10.5,19.08 -2.38,1.9 -4.05,3.55 -5.03,4.99 -0.99,1.43 -1.47,3.21 -1.47,5.33l0 1.7 -16.4 0 0 -4.3c0,-5.97 1.97,-10.63 5.92,-14.02 2.56,-2.18 4.21,-3.68 4.94,-4.5 0.74,-0.81 1.27,-1.6 1.57,-2.35 0.32,-0.75 0.47,-1.63 0.47,-2.63 0,-1.23 -0.55,-2.28 -1.63,-3.13 -1.11,-0.85 -2.51,-1.27 -4.24,-1.27 -5.25,0 -10.76,2.1 -16.53,6.3l0 -19.17c2.12,-1.2 4.98,-2.23 8.58,-3.11 3.6,-0.89 6.82,-1.32 9.68,-1.32 7.99,0 14.09,1.57 18.3,4.72 4.22,3.13 6.34,7.7 6.34,13.68zm-13 45c0,2.9 -1.05,5.27 -3.15,7.12 -2.1,1.85 -4.92,2.78 -8.47,2.78 -3.43,0 -6.21,-0.95 -8.36,-2.85 -2.15,-1.9 -3.22,-4.25 -3.22,-7.05 0,-2.85 1.05,-5.17 3.15,-6.95 2.1,-1.77 4.92,-2.65 8.43,-2.65 3.48,0 6.28,0.88 8.42,2.65 2.13,1.78 3.2,4.1 3.2,6.95z"/>
</svg>
`);
const svgAniSpinner = (color = 'orange', phases = 12, height = '0.9em') => minifyHTML(`
<svg fill="${color}" style="scale: 2.5;" height="${height}" viewBox="0 0 100 100" version="1.1" xmlns="http://www.w3.org/2000/svg">
${Array.from(Array(phases)).map((_, ndx) => `
<rect x="47.5" y="27" rx="2.5" ry="4.16" width="5" height="16" transform="rotate(${Math.round((phases - ndx) * 360 / phases)} 50 50)">
	<animate attributeName="opacity" values="1; 0;" keyTimes="0; 1;" dur="1s" begin="${Math.round(-1000 * ndx / phases)}ms" repeatCount="indefinite"></animate>
</rect>`).join('')}</svg>`);
const staticIconColor = 'cadetblue';
const beep = new Audio('data:audio/mp3;base64,SUQzAwAAAAABdlBSSVYAAAAOAABQZWFrVmFsdWUA/38AAFBSSVYAAAARAABBdmVyYWdlTGV2ZWwAeSkAAFRJVDIAAAAGAAAAUHVsc2VUQ09OAAAABQAAACgxMilUQUxCAAAADgAAAFNvbnkgRXJpY3Nzb25UUEUxAAAADgAAAFNvbnkgRXJpY3Nzb24AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/7cMAAAAyI7v+0l4ABxTNufzDWAjbdrtdtprEBnmvOoYuCBJGewFBIucigmhx74o8fv4+GBOIYyN5oHQyZY1er4ycHrLm5tisiZf3////pf0pKnzTR6IE3HGq4CsiZfv3lNXv6au8eUze9//////r536Upl48d/4fZ4YzYxZnZ3QhUaIRCQSKRWvBemkQEmUrty8GV5Nq0tXbJ6QQCg4SsuAszAPA4SXNx8HgS5maj3H9jpuqkXDSVhI036k/qZO2grU3qOvvvU31v6i5bt/60/9V+pm0/9Tf6yX1W+3/6H/+Z/1Gn////+UCol2MhBEiBSCSn18t2e5hk040M2YdyiSewijhmz//Up2oq690TET0AHhLG3///zoO5J/6//9Q/CNoo////OCJR/0v/9Y+kgl/p//9Y6v//+3LAJwAK3ZV5/ZaAEZSzLrmEnbLo//6ROJf/o9MqQiAgIiqqqs5RXghMCJsaWJEZdPWbbsAETF2NhAjWdubl3V1ekBJdyiRDJ7c/XJ6v//HvPdOgnANQ12MUm/+qKT+YD49FRGP//PfqPO3mN/+r9Co1T0M/+n7HP8yn/RvjxL/+JYUhADIgCVVak6YsRVSm2YBWpWDPPD8s5qifNhlQVwS3/SMQIjHa61faofyY/+sWlP/4rBJ//hK//0BP/8RP/xMT//FXZv4gGP9ODr/U7h3+dwn/5fE7ft+v1ysoZCREQFFVVHMMU/zCP0fQuoQWFJHgR3TMOaI6APBnCfHNPfs6KR0iqDv/qKI5pgXxNg2DZFFFZ2HiX/x1jl92OFIB44Nn/+hz/+hyowjkmT5xL/9W/shUSH/WGv/7cMBbgAtdpW3MzO3RjKavOPgdsvzsLcl+35DovKpRARcSRSSVItRKTxPhTvS+McGOx0lbli7CmAdCziJ9BNTMv2Xtet1NeuERQ7UFroJqRdk01X6v/6YIN26v3TQWk1TP37LQDCMi//9/sv+kxJhqb1f9L/67GCQ+hwM3////rc4a3EOykIgDKqrgbo3jIGOp08nWSPCYbSvCsHog2qLcBAIHkpFDZFB29u+yVtlqdE4MsOIxQNTJNjIshgQW0yZJFTrv69fWiZEsbLupdJd1oIjUG46KnX1/WskSVZ/1fd10lsF3NndfzzVt///6nSIpHZTmJdbQ////9SRp5MC5dmRBIhaqqmYdhaG5CLgrEMVDVAeMjyGdOm5gCawDZCcC0MeFv4XVu1Bq/f//Up6CCBiDbByGkwT/+3LAjoAM5b1n59GtwfC37jj5tboammnTL5fJMS8TM3oJvq+v1KaIQWJ+gh6m/ddmUyZIDkNEEKS1tWm6hkjzen2t/v/9SaaedN4/j0j8VDHZ/57ECKdmRCERFplqlJ8eompcVCkWCyte/FnzmJiRYjxZJQGaIMSoEfEoWiQZJ+vo1//r5kbEyapI6kkqJqk5MkVAvg4jE1R/9v1IqNlt/r/WjpF5IyAiZijU/etFjI4Ype3/st1N/U9kVhq03SM0VJf1E6h/+pv//6i+j//NwJYxEQABFVVWEVJOW8v43yxIY1ub+RWUhq8vqLhuTBOGYuclw2QDEIxOg7ySQQsZuXC+buo0ZN1Oh19SC01ILdNjMvuggmnTUYDgALYeBmbqToMtPTLjLTdBqdN0GWmk1TPUXDRlN1JrUf/7cMCvAA9BcXfHza2R6T1uOPnJuiYmg83QZb9BZmXzddP//+gXEEKbrTTUgaEmAbR9Bk01ILN3My+X3ISDdV9bpmC/rWyH/plFxB4AC3UxkRiRE1KJRdYkAMZdlpBnKqByodgpW4uUik069eqJwktPMsKjls96y7ySg4yIINRJdsu+rTQMLfNZmfgsG4LQXA+JK05TXHQgJDrHd800FCg2GptPqOkl/nOprCSb/U0dYJzZxymzaDw2G6P+/Qf/1Nyr+rHHmnIPM/+c80v7qP/7PqCFMhASERVqqp9VCNrTl7YDKlCgiCjf95j3/um0gPJ5LHUhcFRjWcauklum6avh481fMJrpq//6uQKF2A0P3PSsyfPgVRXEWLaf2Gw1PU98zyMTT0MujkAiCRovHLadpAWID3M9K0H/+3LAxoAURc9vx9Gt0hGxrrmGHbRWh556fRh9XMHhOnml/zE6E3/Qw9xunLVfq21MhEBIbr/+GWRXl1v63Ry5YI82CP3Sb7//9s0tBsAJ66nxqP6brmtvNHAjRH+d4iX3TO/v02QLF85+NLc4imnSYHYbMt+10UE2NzQJ6mh9Trq2TDlnqmvduyknE6ZK6v9TICyUurvfraOdS/dTIL61jiUgy/+p44El+3qalNf/9U7/QkCmQREAIBFWamuq3RtoLtPvSt2SqC2x60zz/66SVHOD1PBXA+pP9TokiMKSqkVJLHaJcSrK1OYj1LqKkVJUVJCzIVJJSTosplJLZSR0uhMxlEkPYlnRbv66KyIYqf+yVFjEep4yJ4niLK37omRdNaTooBy0kf1l1FG9lJJjDoPpLLprMUq2uv/7cMDFgA/5i3XMvU2SEjntuZe1uN1koU21o2mSKh64gf1eX9cugiIAYiKytOkzRuCfEraXfhhqBI9YSW5//TTQQWmYD0LhLnBAAjoN+tyYPQuIKQWmoJ7Q58gEQST2V0MUfgYBWQqgqA2DQ9/9lESTGGMf/954uG7p/+ee58Wf+swfnujMRiQTt9CAeHvq8xgvX9DGZfZXU8Sh03fETuBf6YsAhDAQAAABFVXTI4CWg+7pS2RGZGLDu43dq7XKqSLu1nY3CekZA6cTa6LnwUwRBDLCkFtG0+tT2SX7YqQQ9kITw+ANpNtl1u+SCIY6ep76mJcdD2ZuoqHVLm3VXE/E3aAereUDolzqupj6ody+138TDR3r1pJH6nvq/2T6T7lAsfz/f+ke99//Mm8VqG3yUWDpd3rGTnH/+3LA1QASvZdxzDWtkfkwbnmGqbIES7MyYGJIl31XOtHYbBj/urIH+qx2tSmTYamrODru7PbrdFa9isisJHgDrZBj1de+38ydRiYAZIOLGYbs9H9OutuEsWw2PV9bfvXtY4F3GC6nrpaudfV4Y67a91/zfU3JQQ9Jq1Muk7dRRP6lrw2UrofWjU/LJP/yyV3/v+bN/Isgh2RBISA6qr/6Bo7XW4rCvtH6aRy1lRhUC3bHCtWmr+kk+tEydACIAAaMRdB2/+r2sPkDAihYkVL9X627WcAugpJ8o6tf9FtGtRPB9Lqv2+o6m1JqJeDZC/lh9ST60m6nMtSSlqL4bRiqtdTpddInH9JNaROB6Eied2ZaST7sskRgn/RNv/+j/WYtmPLCQoi9Ki2smMw5x8M3knpiu8Bq6QOBRP/7cMDdABLdiW3MtW2SADzsebpBuNBOYLZJmUzvVWsipLGxKBqYB6J0vIqf9Jnr/UOUAtxspM7dHXatvs4AwgYqZqq11q19+vTJgLMyXY2ekvUt6R/XrOCEBmPPRXWp+y0SVb2cYoAgli6aqrKtXHclVpVAjxeXrqZJqWtSDfoJf/9T/yc4nXRjElZo3I42tdlq0o80Z4c4jRRJrIgaVknkXlIiK1utZkgtFFmSLJETYhwJcFaVTa7mz+g3oJrWozFkgPQh5q9IyLq0qCB15ii6dqDgB6ANVzWixjRbL6DInmUXKSbCXhJBg0GTNUTJ1VTjzhsk7O6ZwAK4BxGKlutTpPu6jIlnqdJSxYBLJNt/7tup0URijal6mX7zI1b84a/0+geSFS2XXXSEf71LqpmBIrQPepphE5H/+3LA44ARPedjzNGt0ic86fmotbycHJbs3zcmJOzBTjS61flL+2f3Vx1TUbk3VMiA4lEtUv6JT0RWFn5bS0spvSqXU2OE8zQ3FcmKALhgAaS7ZXqtmVzNXFmJmzWC9jNqGuMZqJ0bI/VyhNO3M0sWuLZe6/hPtQo1oydcFde+rZr/j1pb43mDXNYKtXSig6z///////8b/1CSgAAAAAD/+uV////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////7cMDsABOps0fsRa3ia6lltYw9sf/////////////////////////////////////////////////////////////////////////////////////////////////8////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////+3LA4YAdGAMYgAAAIAAAJcAAAAT//////////////////////////////////////////////////////////////////////////////////////////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/7cMD/gCSAAS4AAAAgAAAlwAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABUQUdQdWxzZQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABTb255IEVyaWNzc29uAAAAAAAAAAAAAAAAAAAAAABTb255IEVyaWNzc29u');
const uncalibratedReadOffset = logStatus => 'deductions' in logStatus
	&& logStatus.deductions.some(RegExp.prototype.test.bind(/\b(?:read offset)\b/i));
const autoOpenTab = GM_getValue('auto_open_tab', true);
const editionSearch = GM_getValue('edition_search', true);
let discogsBindingsCache, amBindingsCache;

for (let tr of Array.prototype.filter.call(document.body.querySelectorAll('table#torrent_details > tbody > tr.torrent_row'),
		tr => (tr = tr.querySelector('td > a')) != null && /\b(?:FLAC)\b.+\b(?:Lossless)\b.+\b(?:Log) \(\-?\d+\s*\%\)/.test(tr.textContent))) {
	function addLookup(caption, callback, tooltip) {
		const [span, a] = createElements('span', 'a');
		span.className = 'brackets';
		span.style = 'display: inline-flex; flex-flow: row; align-items: baseline; column-gap: 5px; justify-content: space-around; color: initial;';
		if (caption instanceof Element) a.append(caption); else a.textContent = caption;
		[a.className, a.href, a.onclick] = ['toc-lookup', '#', evt => { callback(evt); return false }];
		if (tooltip) setTooltip(a, tooltip);
		span.append(a);
		container.append(span);
	}
	function svgCaption(resourceName, style, fallbackText) {
		console.assert(resourceName || fallbackText);
		if (!resourceName) return fallbackText;
		let svg = new DOMParser().parseFromString(GM_getResourceText(resourceName), 'text/html');
		if ((svg = svg.body.getElementsByTagName('svg')).length > 0) svg = svg[0]; else return fallbackText;
		for (let attr of ['id', 'width', 'class', 'x', 'y', 'style']) svg.removeAttribute(attr);
		if (style) svg.style = style;
		svg.setAttribute('height', '0.9em');
		return svg;
	}
	function imgCaption(src, style) {
		const img = document.createElement('img');
		img.src = src;
		if (style) img.style = style;
		return img;
	}
	function addIcon(html, clickHandler, dropHandler, className, style, tooltip, tooltipster = false) {
		if (!html || ![clickHandler, dropHandler].some(cb => typeof cb == 'function')) throw 'Invalid argument';
		const span = document.createElement('span');
		span.innerHTML = html;
		if (className) span.className = className;
		span.style = 'transition: scale 100ms;' + (style ? ' ' + style : '');
		if (typeof clickHandler == 'function') {
			span.style.cursor = 'pointer';
			span.onclick = function(evt) {
				if (evt.currentTarget.disabled) return true; else evt.stopPropagation();
				clickHandler(evt);
				return false;
			};
		}
		span.onmouseenter = span.onmouseleave = evt =>
			{ evt.currentTarget.style.scale = evt.type == 'mouseenter' ? 1.5 : 'none' };
		if (typeof dropHandler == 'function') {
			span.ondragover = evt => Boolean(evt.currentTarget.disabled) || !evt.dataTransfer.types.includes('text/plain');
			span.ondrop = function(evt) {
				evt.currentTarget.style.scale = 'none';
				if (evt.currentTarget.disabled || !evt.dataTransfer || !(evt.dataTransfer.items.length > 0)) return true;
				evt.stopPropagation();
				dropHandler(evt);
				return false;
			}
			span.ondragenter = span[`ondrag${'ondragexit' in span ? 'exit' : 'leave'}`] = function(evt) {
				if (evt.currentTarget.disabled) return true;
				if (!evt.currentTarget.contains(evt.relatedTarget))
					evt.currentTarget.style.scale = evt.type == 'dragenter' ? 3 : 'none';
				return false;
			};
		}
		if (tooltip) if (tooltipster) setTooltip(span, tooltip); else span.title = tooltip;
		return span;
	}
	function getReleaseYear(date) {
		if (!date) return undefined;
		let year = new Date(date).getUTCFullYear();
		return (!isNaN(year) || (year = /\b(\d{4})\b/.exec(date)) != null
			&& (year = parseInt(year[1]))) && year >= 1900 ? year : NaN;
	}
	function svgSetTitle(elem, title) {
		if (!(elem instanceof Element)) return;
		for (let title of elem.getElementsByTagName('title')) title.remove();
		if (title) elem.insertAdjacentHTML('afterbegin', `<title>${title}</title>`);
	}
	function mbFindEditionInfoInAnnotation(elem, mbId) {
		if (!mbId || !(elem instanceof HTMLElement)) throw 'Invalid argument';
		return mbApiRequest('annotation', { query: `entity:${mbId} AND type:release` }).then(function(response) {
			if (response.count <= 0 || (response = response.annotations.filter(function(annotation) {
				console.assert(annotation.type == 'release' && annotation.entity == mbId, 'Unexpected annotation for MBID %s:', mbId, annotation);
				return /\b(?:Label|Catalog|Cat(?:alog(?:ue)?)?\s*(?:[#№]|Num(?:ber|\.?)|(?:No|Nr)\.?))\s*:/i.test(annotation.text);
			})).length <= 0) return Promise.reject('No edition info in annotation');
			const a = document.createElement('a');
			[a.href, a.target, a.textContent, a.style] = [[mbOrigin, 'release', mbId].join('/'),
				'_blank', 'by annotation', 'font-style: italic; ' + noLinkDecoration];
			a.title = response.map(annotation => annotation.text).join('\n');
			elem.append(a);
		});
	}
	function editionInfoMatchingStyle(elem) {
		if (!(elem instanceof Element)) throw 'Invalid argument';
		//elem.style.fontWeight = 'bold';
		elem.style.textShadow = '0 0 1pt #aaa';
		//elem.style.textShadow = '0 0 5pt #9acd32C0';
		elem.style.textDecoration = 'underline yellowgreen dotted 2pt';
		//elem.style.backgroundColor = '#9acd3230';
		elem.classList.add('matching');
	}
	function releaseEventMapper(countryCode, date, editionYear) {
		if (!countryCode && !date) return null;
		const components = [ ];
		if (countryCode) {
			const [span, img] = createElements('span', 'img');
			span.className = 'country';
			if (/^[A-Z]{2}$/i.test(countryCode)) {
				[img.height, img.referrerPolicy, img.title] = [9, 'same-origin', countryCode.toUpperCase()];
				img.setAttribute('onerror', 'this.replaceWith(this.title)');
				img.src = 'http://s3.cuetools.net/flags/' + countryCode.toLowerCase() + '.png';
				span.append(img);
			} else span.textContent = countryCode;
			components.push(span);
		}
		if (date) {
			const span = document.createElement('span');
			[span.className, span.textContent] = ['date', date];
			if (editionYear > 0 && editionYear == getReleaseYear(date)) editionInfoMatchingStyle(span);
			components.push(span);
		}
		return components;
	}
	function catNoMapper(catNo) {
		if (catNo) catNo = dashUnifier(catNo); else return [ ];
		const m = rxCatNoRange.exec(catNo);
		if (m == null) return [catNo]; else if (m[3].length > m[2].length) return [m[1] + m[2]];
		catNo = [ ];
		for (let n = m[2]; n <= m[2].slice(0, -m[3].length) + m[3]; ++n) catNo.push(m[1] + n);
		return catNo.length > 0 ? catNo : [m[1] + m[2]];
	}
	function editionInfoParser(torrent) {
		const [labels, catNos] = ['RecordLabel', 'CatalogueNumber'].map(prop => (value => value ? decodeHTML(value)
			.split(rxEditionSplitter).map(value => value.trim()).filter(Boolean) : [ ])(torrent['remaster' + prop]));
		return [
			labels.map(label => !rxNoLabel.test(label) ? labelMapper(label.replace(...rxBareLabel)) : noLabel),
			Array.prototype.concat.apply([ ], catNos.map(catNo => !rxNoCatno.test(catNo) ? catNoMapper(catNo) : [ ])),
		].map(values => values.filter((s1, n, a) => a.findIndex(s2 => s2.toLowerCase() == s1.toLowerCase()) == n));
	}
	function editionInfoMapper(labelName, catNo, recordLabels, catalogueNumbers, labelURL) {
		if (!labelName && !catNo) return null;
		const components = [ ];
		if (labelName) {
			const elem = document.createElement(labelURL ? 'a' : 'span');
			[elem.className, elem.textContent] = ['label', dashUnifier(labelName)];
			if (labelURL) [elem.href, elem.target, elem.style] = [labelURL, '_blank', noLinkDecoration];
			labelName = labelMapper(labelName.replace(...rxBareLabel));
			if (Array.isArray(recordLabels) && recordLabels.some(function(recordLabel) {
				const labels = [recordLabel, labelName].map(label => rxNoLabel.test(label) ? noLabel : label);
				const startsWith = (index1, index2) => labels[index1].toLowerCase().startsWith(labels[index2].toLowerCase())
					&& /^\W/.test(labels[index1].slice(labels[index2].length));
				return cmpNorm(labels[0]) == cmpNorm(labels[1]) || startsWith(0, 1) || startsWith(1, 0);
			})) editionInfoMatchingStyle(elem);
			components.push(elem);
		}
		if (catNo) {
			const span = document.createElement('span');
			[span.className, span.textContent, span.style] = ['catno', dashUnifier(catNo), 'white-space: nowrap;'];
			catNo = catNoMapper(catNo);
			if (Array.isArray(catalogueNumbers) && (catalogueNumbers.some(catalogueNumber =>
					catNoMapper(catalogueNumber).some(catalogueNumber =>
						catNo.some(catNo => sameStringValues(catalogueNumber, catNo)))
					|| catNo.some(catNo => sameStringValues(catNo, catalogueNumbers.join('/'))))))
				editionInfoMatchingStyle(span);
			components.push(span);
		}
		return components;
	}
	function fillListRows(container, listElements, maxRowsToShow, expandedIfMatch = false) {
		function addRows(root, range) {
			for (let row of range) {
				const div = document.createElement('div');
				row.forEach((elem, index) => { if (index > 0) div.append(' '); div.append(elem) });
				root.append(div);
			}
		}

		if (!(container instanceof HTMLElement)) throw 'Invalid argument';
		if (!Array.isArray(listElements) || (listElements = listElements.filter(listElement =>
				Array.isArray(listElement) && listElement.length > 0)).length <= 0) return;
		addRows(container, maxRowsToShow > 0 ? listElements.slice(0, maxRowsToShow) : listElements);
		if (!(maxRowsToShow > 0 && listElements.length > maxRowsToShow)) return;
		const hasMatching = commponents => commponents.some(component => component.classList.contains('matching'));
		if (expandedIfMatch && !listElements.slice(0, maxRowsToShow).some(hasMatching)
				&& listElements.slice(maxRowsToShow).some(hasMatching))
			return addRows(container, listElements.slice(maxRowsToShow));
		const divs = createElements('div', 'div');
		[divs[0].className, divs[0].style] = ['show-all', 'color: cadetblue; font-style: italic; cursor: pointer;'];
		[divs[0].onclick, divs[0].textContent, divs[0].title] = [function(evt) {
			evt.currentTarget.remove();
			divs[1].hidden = false;
		}, `+ ${listElements.length - maxRowsToShow} others…`, 'Show all'];
		divs[1].hidden = true;
		addRows(divs[1], listElements.slice(maxRowsToShow));
		container.append(...divs);
	}
	function discogsIdExtractor(expr, entity) {
		if (!expr || !entity) return null;
		let discogsId = parseInt(expr);
		if (discogsId > 0) return discogsId; else try { discogsId = new URL(expr) } catch(e) { return null }
		return discogsId.hostname.endsWith('discogs.com')
			&& (discogsId = new RegExp(`\\/${entity}s?\\/(\\d+)\\b`, 'i').exec(discogsId.pathname)) != null
			&& (discogsId = parseInt(discogsId[1])) > 0 ? discogsId : null;
	}
	function allMusicIdExtractor(expr, entity) {
		if (!expr) return null;
		let allMusicId = /^(m[a-z]\d{10})$/i.exec(expr);
		if (allMusicId != null) return allMusicId[1].toLowerCase();
		try { allMusicId = new URL(expr) } catch(e) { return null }
		return allMusicId.hostname.endsWith('allmusic.com') && (!entity
			|| allMusicId.pathname.startsWith('/' + entity.toLowerCase() + '/'))
			&& (allMusicId = /\b(m[a-z]\d{10})\b/i.exec(allMusicId.pathname)) != null ?
				allMusicId[1].toLowerCase() : null;
	}
	function isDiscogsCD(format) {
		const descriptions = getFormatDescriptions(format);
		return ['CD', 'CDr'].includes(format.name) && !descriptions.some(description =>
				['SVCD', 'VCD', 'CDi'].includes(description))
			|| format.name == 'Hybrid' && descriptions.includes('DualDisc')
			|| format.name == 'SACD' && descriptions.includes('Hybrid');
	}
	function findDiscogsRelatives(entity, discogsId) {
		if (!entity || !((discogsId = parseInt(discogsId)) > 0)) throw 'Invalid argument';
		const targetType = entity.replace(/-/g, '_');
		return mbApiRequest('url', {
			query: `url_descendent:*discogs.com/${discogsEntity(entity)}/${discogsId}`,
			targettype: targetType,
			limit: 100,
		}).then(results => results.count > 0 && (results = results.urls.filter(url =>
			discogsIdExtractor(url.resource, discogsEntity(entity)) == discogsId)).length > 0 ? Promise.all(results.map(url =>
				mbApiRequest('url/' + url.id, { inc: entity + '-rels' }).then(url => url.relations.filter(relation =>
					relation['target-type'] == targetType), console.warn))) : [ ])
			.then(relations => (relations = Array.prototype.concat.apply([ ], relations.filter(Boolean))).length > 0 ?
				relations.map(relation => relation[relation['target-type']]).filter((entry, index, array) =>
					array.findIndex(entry2 => entry2.id == entry.id) == index) : Promise.reject('No relations by URL'));
	}
	function findAllMusicRelatives(entity, allMusicId, mbEntity = entity) {
		if (!entity || !allMusicId || !mbEntity) throw 'Invalid argument';
		const targetType = mbEntity.replace(/-/g, '_');
		return mbApiRequest('url', {
			query: `url_descendent:*allmusic.com/${entity}/*${allMusicId}`,
			targettype: targetType,
			limit: 100,
		}).then(results => results.count > 0 && (results = results.urls.filter(url =>
			allMusicIdExtractor(url.resource, entity) == allMusicId)).length > 0 ? Promise.all(results.map(url =>
				mbApiRequest('url/' + url.id, { inc: mbEntity + '-rels' }).then(url => url.relations.filter(relation =>
					relation['target-type'] == targetType), console.warn))) : [ ])
			.then(relations => (relations = Array.prototype.concat.apply([ ], relations.filter(Boolean))).length > 0 ?
				relations.map(relation => relation[relation['target-type']]).filter((entry, index, array) =>
					array.findIndex(entry2 => entry2.id == entry.id) == index) : Promise.reject('No relations by URL'));
	}
	function appendDisambiguation(elem, disambiguation) {
		if (!(elem instanceof HTMLElement) || !disambiguation) return;
		const span = document.createElement('span');
		[span.className, span.style.opacity, span.textContent] =
			['disambiguation', 0.6, '(' + disambiguation + ')'];
		elem.append(' ', span);
	}
	function addThumbnail(element, src, url) {
		function setThumbNail(src) {
			if (src) [img.onload, img.onerror, img.src] = [function(evt) {
				function addHoverHandlers(elem) {
					elem.style.transition = 'scale 200ms ease-in-out';
					elem.style.boxSizing = 'border-box';
					elem.onmouseenter = elem.onmouseleave = function(evt) {
						evt.currentTarget.style.scale = evt.type == 'mouseenter' ? 8 : 'none';
						evt.currentTarget.style.border = evt.type == 'mouseenter' ? '1pt solid #aaaa' : null;
					};
					if (url) elem.onclick = evt => { GM_openInTab(url, false) }; else return;
					elem.style.cursor = 'pointer';
				}

				if (evt.currentTarget.src != defaultSrc) /*if (evt.currentTarget.naturalWidth == evt.currentTarget.naturalHeight) {
					const canvas = document.createElement('canvas'), context = canvas.getContext('2d');
					[canvas.className, canvas.style] = ['rectangle-cover-icon', 'height: 10px; margin-right: 3pt;'];
					[canvas.width, canvas.height] = [evt.currentTarget.naturalWidth, evt.currentTarget.naturalHeight];
					context.drawImage(evt.currentTarget, 0, 0);
					const shortest = Math.min(evt.currentTarget.naturalWidth, evt.currentTarget.naturalHeight);
					let scale = 0.6;
					const offsetX = Math.round((1 - scale) * (evt.currentTarget.naturalWidth - shortest / 8));
					const offsetY = Math.round(shortest * (1 - scale) / 8);
					const path = new Path2D('M25.62 16.68c8.5,10.59 17.2,20.68 26.26,30.72 3.45,-2.66 6.81,-5.38 10.02,-8.18 4.02,-3.37 9.55,-10.75 12.7,-15.08 2.37,-3.17 4.57,-6.32 6.75,-9.62 0.8,-1.34 1.56,-2.69 2.33,-4.06 0.38,-0.82 1.29,-3.35 2.15,-3.71 1.7,-0.25 2.2,3.33 2.29,4.38 0.21,1.99 0,3.73 -0.38,5.69 -0.78,4.46 -2.28,8.35 -4.08,12.48 -3.14,6.64 -6.76,13.06 -10.83,19.17 -2.9,3.79 -6.26,7.47 -9.92,11.01 6.3,6.8 12.67,13.5 19.16,20.21 3.53,3.4 7.1,6.72 10.67,10.08 1.17,0.93 7.09,4.96 7.26,6.36 -0.65,3.15 -9.17,-0.1 -10.58,-0.68 -5.02,-1.87 -9.44,-4.58 -13.89,-7.52 -8.6,-5.56 -16.46,-11.82 -24.08,-18.49 -6.18,4.9 -12.64,9.37 -18.74,13.28 -10.44,6.16 -22.82,16.29 -25.99,3.68 -4.12,-11.03 3.01,-10.99 16.3,-19.17 5.05,-3.19 10.17,-6.51 15.24,-9.98 -9.51,-9.27 -18.49,-18.77 -26.91,-29.16 -7.51,-9.88 -18.05,-19.86 -5.6,-23.85 10.84,-4.5 9.95,-0.13 19.87,12.44z');
					context.setTransform(scale *= shortest / 100, 0, 0, scale, offsetX, offsetY);
					context.fillStyle = '#f00a';
					context.fill(path);
					addHoverHandlers(canvas);
					evt.currentTarget.replaceWith(canvas);
				} else */addHoverHandlers(evt.currentTarget);
			}, evt => { evt.currentTarget.src = defaultSrc }, src];
		}

		if (!(element instanceof HTMLElement)) throw 'Invalid argument';
		if (typeof src == 'string' && src.endsWith('/images/spacer.gif')) return;
		const defaultSrc = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAACXBIWXMAAAsSAAALEgHS3X78AAAGjUlEQVR4nO1aXUgcVxS+6qghbvxBRROyIaLF1SSYEPGXNWmigVJMIJA2IIQ89KHQ0pc+BdK+5aVt+kNKoYRCoZiYtEINClkT1KioqIkW0gSrKBr/uiT1LzFZo27Pd8ldNpNdZ3Z3xtkxfjC4u565c853zj3n3DtXcrvd7G2GZLQCRmOTAKMVMBqbBBitgNHwENDb2/tRe3v753NzcxlUGUxPTERExGpcXNxUfn7+z3a7/WJUVJTLlxw3tKOj47P6+vofIiMjGQmur6Y6gZwYRc7cdfPmzQvz8/PWysrKT0CKXE4aHh5+t7W19TwMBwGrq6tsZWWFmb0/gD24YmJiWHd398e5ubl/ZmVlOeRyUltb27nFxcVUYXxKSgojQYSQEXprhkePHrHx8XHuVDj04cOHJ3wS8Pjx42xhLAg4fPgwy8vLW3eFtQaMv3z5Mo9kkPD06dMMX3LIAZ55ASKio6PXTUk9IUkSt0dMZcx/l8tlef78eXJiYuKoR05+o9nnvoDcDlSBkZGRomvXrv2RkZHRXl5efn7Hjh39AZW7Z8+escHBQeZ0OvkDUlNTeb6Ij4/XVHkdEUFRkED54P2JiYn8M2fOvKeagL6+Pnb79m02MzPjHVbceOSNoqIi3bTWCqR3JP6iMiwsLKRRibyoioCenh5WV1fHP8tzBKLixo0bjJhlhw4d0lpnzQDjt23b5ty6devM0tJSEuyYnJwsUiSAqgRzOBzc28imcuA3/K+pqYllZmaynTt36mJAqKBSGEO69RUUFFS3tLR8CgLwmyIB/f393MsIG38AAcQqu3v3btgSILBly5ZFr6/KPf/U1JRPz8uBrmt6eprnh1CbKGph2cDAACNvhTSOL5B+rymnSMDy8rLqwSEbKgGUnNjVq1cZlSxOREVFRdBjqYEiAZQ4VPUGkLFYLKqixR+E8WNjYyw2NpbRXOVkUs0OekwlKBKQnZ3N7t27pzgQ2mjIBgsYX11dzY0XlQbTqrm5mZOrVyQoEpCTk8ObHTRA/hLhy5cvmdVqZfv37w9KCRh/5cqV14wH4H2QgEgA9CBBkQD01CdPnmQ1NTVsdHSUiWUzIJbO27dvZ6dOnUKGDVgBX573hiABkQBoTYKqRogWD+zs2bOsq6uL3b9/nycnAPnBZrOx0tJSFhcXF/DD/XleDpAAR+gRCapbYXgXLa/dbsfSks9LGB3s6lHJ83LoNR0C3vuDEgkJCSE9VK3n5dCDhHXf/AzU83JonRPWlYD5+XlPnQ9l40XLnBAyAagE9fX1nmWxP8D4YMLeH7ynA/LRsWPHghonJAJgfENDA+vs7OSlEVdZWdkbct4dnpZbboKEO3fu8GcfPXo04DY8aAK8jUeDBC80NjZyBVApBLT2vBximY5VK56LFjoQBEWAt/HCKKEI9g5ABiJBL8/7QrAvdAImQMx5NEVyowQJt27d4jtE6BxxhfNOc0AECM/7Ml4AJOBCcgIZ4Ww8oJqAtTzvc2DJHO9XVWmpxvNmhSIBWO1tVOMBRQLwLkCUuo0IRQJQwkLZ5gp3KBKwUQ5M+IM5UrWO2CRASQB7/XjrE86vzVGmsTEbDBQJKCws5Nvd4ZwI4Rxs2QXTfCnesRGOy6yFNwgw++EoAbV2SCToiW2E0osXL3RTaj2BvIXcoAQpLS3t79nZ2V1iKYvNxuHhYdNHwuTkpOeEGIiIj4//B7/Lk7lUVlb25cTExN6FhQUrhIkM9uTJE0OU1hLiDRYMpuS4vGfPnlpfcpLVau2tqKg4V1tb+yuVPAk3boRFDwzHQg7XkSNHLuzevbvNlxxPggcOHKgmlpZaWlq+IO/n0E3of900DdyyQTEvTDE3yPurSUlJo8XFxd+VlJT86E/OUwX27dv3u81mayAC3nG5XHzph4h49W9ORGxs7KKPMcISFMUrycnJI+TY13QmYla8v0uymxbT09P/Et+vX7/+2/T0tI1uctO1TPni+5SUlAF9VdcGOPLvdDpzKAGiyiFq3fQ5emhoqEQ0dfwFy1qD4EDR+Ph4PraaMadqamqKKUesys/ZmAnQHXkOlcFisfy7JgE0d7558ODBcaoQ6V5tZqSZS6Q4P4w1TkFBwaU1CaBwHzx9+vSHdXV1lyg37PVumsyKV2VxrrS09Ce73f6t4lqAykdrVVXVBw6H4yuaP4W06oowcQREUGL8jwz/+uDBg7/gB1XLJyQ+IuGEvroZg80NEaMVMBqbBBitgNF46wn4H+FgCXJk4OBeAAAAAElFTkSuQmCC';
		const img = document.createElement('img');
		[img.height, img.style.marginRight, img.className] = [10, '3pt', 'cover-icon'];
		element.insertAdjacentElement('afterbegin', img);
		if (src instanceof Promise) {
			img.src = defaultSrc;
			src.then(setThumbNail);
		} else if (src) setThumbNail(src); else img.src = defaultSrc;
	}
	function setEditionInfo(elem, editionInfo) {
		if (!(elem instanceof HTMLElement)) throw 'Invalid argument';
		if (!Array.isArray(editionInfo) || editionInfo.length <= 0) return;
		const uniqueValues = (el1, ndx, arr, normFn = cmpNorm) =>
			el1 && arr.findIndex(el2 => el2 && normFn(el2) == normFn(el1)) == ndx;
		elem.dataset.remasterRecordLabel =
			editionInfo.map(labelInfo => labelMapper(labelInfo.label)).filter((label, index, labels) =>
				uniqueValues(label, index, labels, label => cmpNorm(label.replace(...rxBareLabel)))).join(' / ');
		elem.dataset.remasterCatalogueNumber =
			editionInfo.map(labelInfo => dashUnifier(labelInfo.catNo)).filter((catNo, index, catNos) =>
				uniqueValues(catNo, index, catNos, catNo => cmpNorm(catNo.replace(rxCatNoRange, '$1$2')))).join(' / ');
	}
	function setMusicBrainzArtist(release, artist, linkify = true) {
		if ('artist-credit' in release) release['artist-credit'].forEach(function(artistCredit, index, artists) {
			if (linkify && 'artist' in artistCredit && artistCredit.artist.id && ![mbidVA].includes(artistCredit.artist.id)) {
				const a = document.createElement('a');
				if (artistCredit.artist) a.href = [mbOrigin, 'artist', artistCredit.artist.id].join('/');
				[a.target, a.style, a.textContent, a.className] =
					['_blank', noLinkDecoration, artistCredit.name, 'musicbrainz-artist'];
				if (artistCredit.artist) a.title = artistCredit.artist.disambiguation || artistCredit.artist.id;
				artist.append(a);
			} else artist.append(artistCredit.name);
			if (artistCredit.joinphrase) artist.append(artistCredit.joinphrase);
			else if (index < artists.length - 1) artist.append(index < artists.length - 2 ? ', ' : ' & ');
		});
	}
	function setMusicBrainzTitle(release, title) {
		title.innerHTML = linkHTML([mbOrigin, 'release', release.id].join('/'), release.title, 'musicbrainz-release');
		if (release['cover-art-archive'] && release['cover-art-archive'].artwork) addThumbnail(title,
			globalXHR('https://coverartarchive.org/release/' + release.id, { responseType: 'json' }).then(function({response}) {
				const isFront = image => image.front || image.types && image.types.includes('Front');
				let thumbnail = response.images.find(image => isFront(image) && image.types.length == 1)
					|| response.images.find(isFront) || response.images[0];
				if (thumbnail) thumbnail = thumbnail.thumbnails && thumbnail.thumbnails.small || thumbnail.image;
				return thumbnail;
			}), [mbOrigin, 'release', release.id, 'cover-art'].join('/'));
		switch (release.quality) {
			case 'low': title.insertAdjacentHTML('afterbegin', svgBulletHTML('#ff6723')); break;
			case 'high': title.insertAdjacentHTML('afterbegin', svgBulletHTML('#00d26a')); break;
		}
		appendDisambiguation(title, release.disambiguation);
	}
	function setMusicBrainzReleaseEvents(release, releaseEvents, releaseYear) {
		if ('release-events' in release) {
			fillListRows(releaseEvents, Array.prototype.concat.apply([ ], release['release-events'].map(function(releaseEvent) {
				const countryEvents = releaseEvent.area && Array.isArray(releaseEvent.area['iso-3166-1-codes']) ?
					iso3166ToFlagCodes(releaseEvent.area['iso-3166-1-codes']).map(countryCode =>
						releaseEventMapper(countryCode, releaseEvent.date, releaseYear)).filter(Boolean) : [ ];
				return countryEvents.length > 0 ? countryEvents : releaseEvent.country || releaseEvent.date ?
					iso3166ToFlagCodes([releaseEvent.country]).map(countryCode =>
						releaseEventMapper(countryCode, releaseEvent.date, releaseYear)) : null;
			}).filter(Boolean)), 3);
		}
		if (releaseEvents.childElementCount <= 0) fillListRows(releaseEvents,
			iso3166ToFlagCodes([release.country]).map(countryCode =>
				releaseEventMapper(countryCode, release.date, releaseYear)));
	}
	function setMusicBrainzTooltip(release, elem) {
		function applyTooltip() {
			elem.title = lines.map(lines => lines.filter(Boolean).join('\n')).filter(Boolean).join('\n');
		}

		if (!release || !(elem instanceof HTMLElement)) throw 'Invalid argument';
		if (elem.title) elem = elem.querySelector('td.title > a.musicbrainz-release');
		const lines = [[ ], [ ], [ ]];
		if (release['release-group']) lines[0].push([release['release-group']['primary-type']]
		 .concat((release['release-group']['secondary-types'] || [ ])).filter(Boolean).join(' + '));
		if (release.media) lines[0].push(release.media.map(medium => medium.format || 'unknown medium').join(' + '));
		const getSeries = root => root && root.relations ? root.relations
				.filter(relation => relation['target-type'] == 'series').map(function(relation) {
			let line = relation.type + ': ' + relation.series.name;
			if (relation['attribute-values'] && relation['attribute-values'].number)
				line += ' (' + relation['attribute-values'].number + ')';
			return line;
		}) : [ ];
		if (release.relations) lines[1] = getSeries(release);
		lines[2].push([release.status, release.packaging].filter(Boolean).join(' / '));
		if (release.quality && release.quality != 'normal') lines[2].push(release.quality + ' data quality');
		lines[2].push(release.id);
		applyTooltip();
		if (release['release-group']) mbApiRequest('release-group/' + release['release-group'].id, {
			inc: 'releases+media+discids+series-rels',
		}).then(function(releaseGroup) {
			const series = getSeries(releaseGroup);
			if (series.length <= 0) return;
			Array.prototype.unshift.apply(lines[1], series);
			applyTooltip();
		});
	}
	function setMusicBrainzGroupSize(release, groupSize, releasesWithId, totalDiscs = 0) {
		if (release['release-group']) mbApiRequest('release-group/' + release['release-group'].id, {
			inc: 'releases+media+discids+series-rels',
		}).then(function(releaseGroup) {
			const releases = releaseGroup.releases.filter(release => !release.media || !totalDiscs
				|| sameMedia(release).length == totalDiscs);
			const a = document.createElement('a');
			a.href = [mbOrigin, 'release-group', release['release-group'].id/*, 'releases'*/].join('/');
			[a.target, a.style, a.textContent] = ['_blank', noLinkDecoration, releases.length];
			if (releases.length == 1) a.style.color = '#0a0';
			groupSize.append(a);
			groupSize.title = 'Same media count in release group';
			const counts = ['some', 'every'].map(fn => releases.filter(release => release.media
				&& (release = sameMedia(release)).length > 0
				&& release[fn](medium => medium.discs && medium.discs.length > 0)).length);
			releasesWithId.textContent = counts[0] > counts[1] ? counts[0] + '/' + counts[1] : counts[1];
			releasesWithId.title = 'Same media count with known TOC in release group';
		}, function(reason) {
			if (releasesWithId.parentNode != null) releasesWithId.remove();
			[groupSize.colSpan, groupSize.innerHTML, groupSize.title] = [2, svgFail(), reason];
		});
	}
	function setDiscogsArtist(artist, artists) {
		if (!(artist instanceof HTMLElement)) throw 'Invalid argument';
		if (artists) artists.forEach(function(artistCredit, index, artists) {
			const name = artistCredit.anv || stripDiscogsNameVersion(artistCredit.name);
			if (artistCredit.id > 0 && ![194].includes(artistCredit.id)) {
				const a = document.createElement('a');
				if (artistCredit.id) a.href = [dcOrigin, 'artist', artistCredit.id].join('/');
				[a.target, a.style, a.className, a.title] =
					['_blank', noLinkDecoration, 'discogs-artist', artistCredit.role || artistCredit.id];
				a.textContent = name;
				artist.append(a);
			} else artist.append(name);
			if (artistCredit.join) artist.append(fmtJoinPhrase(artistCredit.join));
			else if (index < artists.length - 1) artist.append(index < artists.length - 2 ? ', ' : ' & ');
		});
	}
	function discogsSeriesMapper(series) {
		if (!series || !series.name) return;
		let result = 'Series: ' + stripDiscogsNameVersion(series.name);
		if (series.catno) result += ' (' + series.catno + ')';
		return result;
	}
	function discogsIdentifierMapper(identifier) {
		let label = identifier.type;
		if (identifier.description) label += ' (' + identifier.description + ')';
		return label + ': ' + identifier.value;
	}
	function setDiscogsTooltip(release, elem) {
		if (!release || !(elem instanceof HTMLElement)) throw 'Invalid argument';
		if (elem.title) elem = elem.querySelector('td.title > a.discogs-release');
		const lines = [ ];
		const singleLineAdapter = (props, valueMapper = val => val) =>
			[props.map(prop => release[prop] && valueMapper(release[prop])).filter(Boolean).join(' / ')];
		if (release.formats) lines.push(release.formats.map(function(format) {
			const tags = getFormatDescriptions(format);
			if (format.name == 'All Media') return tags.join(', ');
			let description = format.qty + '×' + format.name;
			if (tags.length > 0) description += ' (' + tags.join(', ') + ')';
			return description;
		}));
		if (release.series) lines.push(release.series.map(discogsSeriesMapper));
		if (release.identifiers) lines.push(release.identifiers.map(discogsIdentifierMapper));
		if (release.notes) lines.push(release.notes.split(/(?:\r?\n)+/).map(line => line.trim()));
		if (release.extraartists) lines.push(release.extraartists.map(function(artistCredit) {
			let line = artistCredit.name + ': ' + artistCredit.role;
			if (artistCredit.tracks) line += ' (' + artistCredit.tracks + ')';
			return line;
		}));
		if (release.companies) {
			const r = { };
			for (let company of release.companies) {
				if (!(company.entity_type_name in r)) r[company.entity_type_name] = [ ];
				let name = company.name;
				if (company.catno) name += ' (' + company.catno + ')';
				r[company.entity_type_name].push(name);
			}
			lines.push(Object.keys(r).map(key => key + ': ' + r[key].join(', ')));
		}
		lines.push(singleLineAdapter(['genres', 'styles'], val => val.join(', ')));
		//lines.push(singleLineAdapter(['data_quality', 'status']));
		//lines.push(['ID ' + release.id]);
		elem.title = lines.map(function(lines) {
			if ((lines = lines.filter(Boolean)).length > 15)
				lines = lines.slice(0, 15).concat(`…and ${lines.length - 1} more`);
			return lines.length > 0 ? lines.join('\n') : undefined;
		}).filter(Boolean).join('\n\n');
	}
	function setDiscogsGroupSize(release, groupSize) {
		if (!release || !(groupSize instanceof HTMLElement)) throw 'Invalid argument';
		if (release.master_id) {
			const masterUrl = new URL('master/' + release.master_id, dcOrigin);
			for (let format of ['CD', 'CDr']) masterUrl.searchParams.append('format', format);
			masterUrl.hash = 'versions';
			const getGroupSize1 = () => dcApiRequest(['masters', release.master_id, 'versions'].join('/'))
				.then(({filters}) => (filters = filters && filters.available && filters.available.format) ?
					['CD', 'CDr'].reduce((s, f) => s + (filters[f] || 0), 0) : Promise.reject('Filter totals missing'));
			const getGroupSize2 = (page = 1) => dcApiRequest(['masters', release.master_id, 'versions'].join('/'), {
				page: page,
				per_page: 1000,
			}).then(function(versions) {
				const releases = versions.versions.filter(version => !Array.isArray(version.major_formats)
					|| version.major_formats.some(format => ['CD', 'CDr'].includes(format))
					|| version.major_formats.includes('Hybrid') && version.format.includes('DualDisc')
					|| version.major_formats.includes('SACD') && version.format.includes('Hybrid')).length;
				if (!(versions.pagination.pages > versions.pagination.page)) return releases;
				return getGroupSize2(page + 1).then(releasesNxt => releases + releasesNxt);
			});
			getGroupSize1().catch(reason => getGroupSize2()).then(function(_groupSize) {
				const a = document.createElement('a');
				[a.href, a.target, a.style, a.textContent] = [masterUrl, '_blank', noLinkDecoration, _groupSize];
				if (_groupSize == 1) a.style.color = '#0a0';
				groupSize.append(a);
				groupSize.title = 'Total of same media versions for master release';
			}, function(reason) {
				[groupSize.style.paddingTop, groupSize.innerHTML, groupSize.title] = ['5pt', svgFail(), reason];
			});
		} else [groupSize.textContent, groupSize.style.color, groupSize.title] =
			['–', '#0a0', 'Without master release'];
	}
	function getDiscogsReleaseDescriptors(release) {
		let descriptors = new Set;
		if (release.formats) for (let format of release.formats) {
			if (!['CD', 'CDr', 'SACD', 'Hybrid', 'All Media'].includes(format.name)) continue;
			const descriptions = getFormatDescriptions(format);
			if (!descriptions.some(description => ['SVCD', 'VCD', 'CDi'].includes(description))
					&& (format.name != 'Hybrid' || !descriptions.includes('DualDisc'))
					&& (format.name != 'SACD' || !descriptions.includes('Hybrid')))
				for (let description of descriptions) if (![
					'Album', 'Single', 'EP', 'LP', 'Compilation', 'Stereo',
				].includes(description)) descriptors.add(description);
		}
		return Array.from(descriptors);
	}
	function addResultsFilter(thead, tbody, minRows = 5) {
		function filterByClasses(...classes) {
			if (classes.length > 0) classes = classes.map(cls => ({
				catno: ['catno', 'barcode', 'identifier'],
			}[cls]) || [cls]); else return null;
			const rows = Array.prototype.filter.call(tbody.rows, (tr, index) => classes.every(cls =>
				cls.some(cls => tr.querySelector('.' + cls + '.matching') != null)
					|| classes.length > 1 && cls.includes('date') && tr.querySelector('.' + cls) == null));
			return rows.length > 0 ? rows : null;
		}

		if (!(thead instanceof HTMLElement) || !(tbody instanceof HTMLElement)) throw 'Invalid argument';
		if (minRows > 0 && tbody.rows.length < minRows) return;
		const filteredRows = filterByClasses('date', 'label', 'catno') || filterByClasses('label', 'catno')
			|| filterByClasses('catno') || filterByClasses('label') || filterByClasses('date');
		if (filteredRows == null || filteredRows.length >= tbody.rows.length) return;
		const [labels, cls] = [['closest editions only', 'show all'], 'filtered'];
		thead.append(Object.assign(document.createElement('span'), {
			style: 'float: right; color: cadetblue; cursor: pointer;',
			className: 'filter-switch',
			textContent: '[' + labels[0] + ']',
			onclick: function(evt) {
				const filtered = evt.currentTarget.classList.contains(cls);
				for (let tr of tbody.rows) tr.hidden = !filtered && !filteredRows.includes(tr);
				evt.currentTarget.textContent = '[' + labels[filtered ? 0 : 1] + ']';
				evt.currentTarget.classList[filtered ? 'remove' : 'add'](cls);
			},
		}));
	}
	function scriptFromLanguage(language) {
		if (language) language = language.toLowerCase(); else return;
		const scripts = {
			Latn: [
				'eng', 'deu', 'spa', 'fra', 'ita', 'swe', 'nor', 'fin', 'por', 'nld', 'pol', 'ces', 'slk', 'slv', 'hrv',
				'srp', 'hun', 'tur', 'dan', 'ltz', 'ron', 'est', 'lav', 'isl', 'lat', 'cat', 'gsw', 'fil', 'eus', 'afr',
				'lit', 'cym', 'glg', 'bre', 'oci', 'haw', 'gla', 'nob', 'mri', 'zul', 'ast', 'swa', 'som', 'gle',
			],
			Hant: ['zho'], Jpan: ['jpn'], Kore: ['kor'], Thai: ['tha'],
			Cyrl: ['rus', 'bul', 'mkd', 'ukr', 'bos', 'bel', 'mon'],
			Deva: ['hin', 'mar', 'san'],
			Arab: ['ara', 'ind', 'fas', 'urd', 'msa', 'kaz', 'tuk', 'uzb'],
			Hebr: ['heb', 'yid'], Grek: ['grk', 'gre', 'ell'],
			Armn: ['hye'], Guru: ['pan'], Taml: ['tam'], Hani: ['vie'], Telu: ['tel'], Mymr: ['mya'],
			Mlym: ['mal'], Beng: ['asm'], Geor: ['kat'],
		};
		for (let script in scripts) if (scripts[script].includes(language)) return script;
	}
	function frequencyAnalysis(literals, string) {
		if (!literals || typeof literals != 'object') throw 'Invalid argument';
		if (typeof string == 'string') for (let index = 0; index < string.length; ++index) {
			const charCode = string.charCodeAt(index);
			if (charCode < 0x20 || charCode == 0x7F) continue;
			if (charCode in literals) ++literals[charCode]; else literals[charCode] = 1;
		}
	}
	function detectAlphabet(literals, charSets) {
		if (!literals || typeof literals != 'object' || !charSets || typeof charSets != 'object')
			throw 'Invalid argument';
		const charCodes = Object.keys(literals).map(key => parseInt(key))
		if (charSets) for (let key in charSets) {
			const charSet = Array.prototype.concat.apply(range(0x20, 0x40).concat(range(0x50, 0x60),
				range(0x7B, 0x7E), range(0xA0, 0xBF), 0xD7, 0xF7), charSets[key]);
			if (charCodes.every(charCode => charSet.includes(charCode))) return key;
		} else throw 'Invalid argument';
	}
	function parseLanguages(siteName, ignoreLanguages = false) {
		let matches = /^(.+?)\s+\(([^\(\)]+)\)$/.exec(siteName);
		if (matches != null) {
			matches = matches.slice(1);
			if (ignoreLanguages) return matches;
			const scripts = matches.map(function(namePart, index) {
				const literals = { };
				frequencyAnalysis(literals, namePart);
				return detectScript(literals) || scriptFromLanguage(detectLanguage(literals));
			});
			if (scripts.every((script, index, scripts) => script && scripts.indexOf(script) == index)
					|| scripts.includes('Latn') && scripts.some(script => script != 'Latn')) return matches;
		}
		return siteName ? [siteName] : [ ];
	}
	function flashElement(elem) {
		if (elem instanceof Element) return elem.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});
	}

	const torrentId = getTorrentId(tr);
	if (!(torrentId > 0)) continue; // assertion failed
	let edition = /\b(?:edition_(\d+))\b/.exec(tr.className), editionInfo = tr, torrentDetails = tr;
	while (torrentDetails != null && !torrentDetails.classList.contains('torrentdetails'))
		torrentDetails = torrentDetails.nextElementSibling;
	if (torrentDetails == null) continue; // assertion failed
	const linkBox = torrentDetails.querySelector('div.linkbox');
	if (linkBox == null) continue;
	edition = edition != null ? parseInt(edition[1]) : undefined;
	while (editionInfo != null && !editionInfo.classList.contains('edition'))
		editionInfo = editionInfo.previousElementSibling;
	if (editionInfo != null) editionInfo = editionInfo.querySelector('td.edition_info > strong');
	const uniqueValues = ((val, ndx, arr) => val && arr.indexOf(val) == ndx);
	const getFormatDescriptions = format => format ? (format.descriptions || [ ])
		.concat((format.text || '').split(',').map(descriptor => descriptor.trim()).filter(Boolean)) : [ ];
	const theadStyle = 'padding: 4pt; background-color: #AAA4;'
	const noLinkDecoration = 'background: none !important; padding: 0 !important;';
	const linkHTML = (url, caption, cls) => `<a href="${url}" target="_blank" ${cls ? 'class="' + cls + '" ' : ''}style="${noLinkDecoration}">${caption}</a>`;
	const mbidVA = '89ad4ac3-39f7-470e-963a-56509c546377';
	const mbidUnknown = '125ec42a-7229-4250-afc5-e057484327fe';
	const stripDiscogsNameVersion = name => name && name.replace(/\s+\(\d+\)$/, '');
	const discogsEntity = entity => entity && ({
		'series': 'label',
		'release-group': 'master',
	}[entity.toLowerCase()] || entity);
	const amEntity = entity => entity && ({
		'release': 'album/release',
		'release-group': 'album',
		'label': 'artist',
	}[entity.toLowerCase()] || entity);
	const fmtJoinPhrase = (joinPhrase = '&') => [
		[/^\s+(?=[\,\;])/, ''],
		[/\b(\w+)\b/g, (...m) => m[1] == m[1].toUpperCase() ? m[1] : m[1].toLowerCase()],
		[/\s+(?:feat(?:uring)?|f(?:\/|\.\/?|t\.?\/?)|\/ft?\.?)\s+/ig, ' feat. '],
		[/\s+(?:w(?:\/|\.\/?)|\/w\.?)\s+/ig, ' with '],
	].reduce((phrase, subst) => phrase.replace(...subst), ' ' + joinPhrase.trim() + ' ');
	const rxEditionSplitter = /[\/\|\•\;\,]+/;
	const dashUnifier = str => str && str.replace(/[\‐\−\—\–]/g, '-');
	const isCD = medium => medium && /^(?:(?:SHM-|HD|HQ|DTS |Enhanced |Blu-spec |Copy Control |Minimax |Mixed Mode )?CD|CD-R|(?:8cm )?CD(?:\+G)?|Hybrid SACD(?: \(CD layer\))?|DualDisc(?: \(CD side\))?)$/.test(medium.format);
	const sameMedia = release => release.media.every(medium => !medium.format) ?
		release.media : release.media.filter(isCD);
	const sameBarcodes = (...barcodes) => barcodes.length > 0
		&& barcodes.every((barcode, ndx, arr) => parseInt(cmpNorm(barcode)) == parseInt(cmpNorm(arr[0])));
	const discogsCountryToIso3166Mapper = discogsCountry => ({
		'US': ['US'], 'UK': ['GB'], 'Germany': ['DE'], 'France': ['FR'], 'Japan': ['JP'], 'Italy': ['IT'],
		'Europe': ['XE'], 'Canada': ['CA'], 'Netherlands': ['NL'], 'Spain': ['ES'], 'Australia': ['AU'],
		'Russia': ['RU'], 'Sweden': ['SE'], 'Brazil': ['BR'], 'Belgium': ['BE'], 'Greece': ['GR'], 'USSR': ['SU'],
		'Poland': ['PL'], 'Mexico': ['MX'], 'Finland': ['FI'], 'Jamaica': ['JM'], 'Switzerland': ['CH'],
		'Denmark': ['DK'], 'Argentina': ['AR'], 'Portugal': ['PT'], 'Norway': ['NO'], 'Austria': ['AT'],
		'UK & Europe': ['GB', 'XE'], 'New Zealand': ['NZ'], 'Romania': ['RO'], 'Cyprus': ['CY'],
		'South Africa': ['ZA'], 'Yugoslavia': ['YU'], 'Hungary': ['HU'], 'Colombia': ['CO'], 'Malaysia': ['MY'],
		'USA & Canada': ['US', 'CA'], 'Ukraine': ['UA'], 'Turkey': ['TR'], 'India': ['IN'], 'Indonesia': ['ID'],
		'Czech Republic': ['CZ'], 'Czechoslovakia': ['XC'], 'Venezuela': ['VE'], 'Ireland': ['IE'],
		'Taiwan': ['TW'], 'Chile': ['CL'], 'Peru': ['PE'], 'South Korea': ['KR'], 'Worldwide': ['XW'],
		'Israel': ['IL'], 'Bulgaria': ['BG'], 'Thailand': ['TH'], 'Scandinavia': ['SE', 'NO', 'FI'],
		'German Democratic Republic (GDR)': ['XG'], 'China': ['CN'], 'Croatia': ['HR'], 'Hong Kong': ['HK'],
		'Philippines': ['PH'], 'Serbia': ['RS'], 'Ecuador': ['EC'], 'Lithuania': ['LT'], 'East Timor': ['TL'],
		'UK, Europe & US': ['GB', 'XE', 'US'], 'USA & Europe': ['US', 'XE'], 'Dutch East Indies': ['ID'],
		'Germany, Austria, & Switzerland': ['DE', 'AT', 'CH'], 'Singapore': ['SG'], 'Slovenia': ['SI'],
		'Slovakia': ['SK'], 'Uruguay': ['UY'], 'Australasia': ['AU'],  'Iceland': ['IS'], 'Bolivia': ['BO'],
		'UK & Ireland': ['GB', 'IE'], 'Nigeria': ['NG'], 'Estonia': ['EE'], 'Egypt': ['EG'], 'Cuba': ['CU'],
		'USA, Canada & Europe': ['US', 'CA', 'XE'], 'Benelux': ['BE', 'NL', 'LU'], 'Panama': ['PA'],
		'UK & US': ['GB', 'US'], 'Pakistan': ['PK'], 'Lebanon': ['LB'], 'Costa Rica': ['CR'], 'Latvia': ['LV'],
		'Puerto Rico': ['PR'], 'Kenya': ['KE'], 'Iran': ['IR'], 'Belarus': ['BY'], 'Morocco': ['MA'],
		'Guatemala': ['GT'], 'Saudi Arabia': ['SA'], 'Trinidad & Tobago': ['TT'], 'Barbados': ['BB'],
		'USA, Canada & UK': ['US', 'CA', 'GB'], 'Luxembourg': ['LU'], 'Czech Republic & Slovakia': ['CZ', 'SK'],
		'Bosnia & Herzegovina': ['BA'], 'Macedonia': ['MK'], 'Madagascar': ['MG'], 'Ghana': ['GH'], 'Iraq': ['IQ'],
		'Zimbabwe': ['ZW'], 'El Salvador': ['SV'], 'North America (inc Mexico)': ['US', 'CA', 'MX'],
		'Algeria': ['DZ'], 'Singapore, Malaysia & Hong Kong': ['SG', 'MY', 'HK'], 'Dominican Republic': ['DO'],
		'France & Benelux': ['FR', 'BE', 'NL', 'LU'], 'Ivory Coast': ['CI'], 'Tunisia': ['TN'], 'Kuwait': ['KW'],
		'Reunion': ['RE'], 'Angola': ['AO'], 'Serbia and Montenegro': ['RS', 'ME'], 'Georgia': ['GE'],
		'United Arab Emirates': ['AE'], 'Congo, Democratic Republic of the': ['CD'], 'Mauritius': ['MU'],
		'Germany & Switzerland': ['DE', 'CH'], 'Malta': ['MT'], 'Mozambique': ['MZ'], 'Guadeloupe': ['GP'],
		'Australia & New Zealand': ['AU', 'NZ'], 'Azerbaijan': ['AZ'], 'Zambia': ['ZM'], 'Kazakhstan': ['KZ'],
		'Nicaragua': ['NI'], 'Syria': ['SY'], 'Senegal': ['SN'], 'Paraguay': ['PY'], 'Wake Island': ['MH'],
		'UK & France': ['GB', 'FR'], 'Vietnam': ['VN'], 'UK, Europe & Japan': ['GB', 'XE', 'JP'],
		'Bahamas, The': ['BS'], 'Ethiopia': ['ET'], 'Suriname': ['SR'], 'Haiti': ['HT'], 'South America': ['ZA'],
		'Singapore & Malaysia': ['SG', 'MY'], 'Moldova, Republic of': ['MD'], 'Faroe Islands': ['FO'],
		'Cameroon': ['CM'], 'South Vietnam': ['VN'], 'Uzbekistan': ['UZ'], 'Albania': ['AL'], 'Honduras': ['HN'],
		'Martinique': ['MQ'], 'Benin': ['BJ'], 'Sri Lanka': ['LK'], 'Andorra': ['AD'], 'Liechtenstein': ['LI'],
		'Curaçao': ['CW'], 'Mali': ['ML'], 'Guinea': ['GN'], 'Congo, Republic of the': ['CG'], 'Sudan': ['SD'],
		'Mongolia': ['MN'], 'Nepal': ['NP'], 'French Polynesia': ['PF'], 'Greenland': ['GL'], 'Uganda': ['UG'],
		'Bohemia': ['CZ'], 'Bangladesh': ['BD'], 'Armenia': ['AM'], 'North Korea': ['KP'], 'Bermuda': ['BM'],
		'Seychelles': ['SC'], 'Cambodia': ['KH'], 'Guyana': ['GY'], 'Tanzania': ['TZ'], 'Bahrain': ['BH'],
		'Jordan': ['JO'], 'Libya': ['LY'], 'Montenegro': ['ME'], 'Gabon': ['GA'], 'Togo': ['TG'], 'Yemen': ['YE'],
		'Afghanistan': ['AF'], 'Cayman Islands': ['KY'], 'Monaco': ['MC'], 'Papua New Guinea': ['PG'],
		'Belize': ['BZ'], 'Fiji': ['FJ'], 'UK & Germany': ['UK', 'DE'], 'New Caledonia': ['NC'], 'Qatar': ['QA'],
		'Protectorate of Bohemia and Moravia': ['CZ' /*'XP'*/], 'Saint Helena' : ['SH'], 'Laos': ['LA'], 'Dahomey': ['BJ'],
		'UK, Europe & Israel': ['GB', 'XE', 'IL'], 'French Guiana': ['GF'], 'Aruba': ['AW'], 'Dominica': ['DM'],
		'San Marino': ['SM'], 'Kyrgyzstan': ['KG'], 'Upper Volta': ['BF'], 'Burkina Faso': ['BF'], 'Oman': ['OM'],
		'Turkmenistan': ['TM'], 'Namibia': ['NA'], 'Sierra Leone': ['SL'], 'Marshall Islands': ['MH'],
		'Guernsey': ['GG'], 'Jersey': ['JE'], 'Guam': ['GU'], 'Central African Republic': ['CF'], 'Tonga': ['TO'],
		'Eritrea': ['ER'], 'Saint Kitts and Nevis': ['KN'], 'Grenada': ['GD'], 'Somalia': ['SO'], 'Malawi': ['MW'],
		'Liberia': ['LR'], 'Sint Maarten': ['SX'], 'Saint Lucia': ['LC'], 'Lesotho': ['LS'], 'Maldives': ['MV'],
		'Saint Vincent and the Grenadines': ['VC'], 'Guinea-Bissau': ['GW'], 'Botswana': ['BW'], 'Palau': ['PW'],
		'Comoros': ['KM'], 'Gibraltar': ['GI'], 'Cook Islands': ['CK'], 'Kosovo': ['XK'], 'Bhutan': ['BT'],
		'Gulf Cooperation Council': ['BH', 'KW', 'OM', 'QA', 'SA', 'AE'], 'Niger': ['NE'], 'Mauritania': ['MR'],
		'Anguilla': ['AI'], 'Sao Tome and Principe': ['ST'], 'Djibouti': ['DJ'], 'Mayotte': ['YT'],
		'Montserrat': ['MS'], 'Vanuatu': ['VU'], 'Norfolk Island': ['NF'], 'Gaza Strip': ['PS'], 'Macau': ['MO'],
		'Solomon Islands': ['SB'], 'Turks and Caicos Islands': ['TC'], 'Northern Mariana Islands': ['MP'],
		'Equatorial Guinea': ['GQ'], 'American Samoa': ['AS'], 'Chad': ['TD'], 'Falkland Islands': ['FK'],
		'Antarctica': ['AQ'], 'Nauru': ['NR'], 'Niue': ['NU'], 'Saint Pierre and Miquelon': ['PM'],
		'Tokelau': ['TK'], 'Tuvalu': ['TV'], 'Wallis and Futuna': ['WF'], 'Korea': ['KR'], 'Abkhazia': ['GE'],
		'Antigua & Barbuda': ['AG'], 'Austria-Hungary': ['AT', 'HU'], 'British Virgin Islands': ['VG'],
		'Brunei': ['BN'], 'Burma': ['MM'], 'Cape Verde': ['CV'], 'Virgin Islands': ['VI'], 'Tibet' : ['CN'],
		'Vatican City': ['VA'], 'Swaziland': ['SZ'], 'Southern Sudan': ['SS'], 'Palestine': ['PS'],
		'Singapore, Malaysia, Hong Kong & Thailand': ['SG', 'MY', 'HK', 'TH'], 'Pitcairn Islands': ['PN'],
		'Micronesia, Federated States of': ['FM'], 'Man, Isle of': ['IM'], 'Zanzibar': ['TZ'], 'Burundi' : ['BI'],
		'Korea (pre-1945)': ['KR'], 'Hong Kong & Thailand': ['HK', 'TH'], 'Gambia, The': ['GM'], 'Zaire': ['ZR'],
		'South Georgia and the South Sandwich Islands' : ['GS'], 'Cocos (Keeling) Islands' : ['CC'],
		'Kiribati' : ['KI'], 'Christmas Island' : ['CX'], 'French Southern & Antarctic Lands' : ['TF'],
		'British Indian Ocean Territory' : ['IO'], 'Western Sahara': ['EH'],  'Rhodesia': ['ZW'], 'Samoa': ['WS'],
		'Southern Rhodesia': ['ZW'], 'West Bank': ['PS'], 'Belgian Congo': ['CD'], 'Ottoman Empire': ['TR'],
		'Netherlands Antilles': ['AW', 'BQ', 'CW', 'BQ', 'SX'],  'Tajikistan': ['TJ'], 'Rwanda': ['RW'],
		'Indochina': ['KH', 'MY', 'MM', 'TH', 'VN', '	LA'], 'South West Africa': ['NA'],
		'Russia & CIS': ['RU', 'AM', 'AZ', 'BY', 'KZ', 'KG', 'MD', 'TJ', 'UZ']/*.concat('TM', 'UA')*/,
		'Central America': ['BZ', 'CR', 'SV', 'GT', 'HN', 'NI', 'PA'],
		'South East Asia': ['BN', 'KH', 'TL', 'ID', 'LA', 'MY', 'MM', 'PH', 'SG', 'TH', 'VN'],
		'Middle East': ['BH', 'CY', 'EG', 'IR', 'IQ', 'IL', 'JO', 'KW', 'LB', 'OM', 'PS', 'QA', 'SA', 'SY', 'TR', 'AE', 'YE'],
		'Asia': ['AF', 'AM', 'AZ', 'BH', 'BD', 'BT', 'BN', 'KH', 'CN', 'CY', 'TL', 'EG', 'GE', 'IN', 'ID', 'IR', 'IQ', 'IL', 'JP', 'JO', 'KZ', 'KW', 'KG', 'LA', 'LB', 'MY', 'MV', 'MN', 'MM', 'NP', 'KP', 'OM', 'PK', 'PS', 'PH', 'QA', 'RU', 'SA', 'SG', 'KR', 'LK', 'SY', 'TW', 'TJ', 'TH', 'TR', 'TM', 'AE', 'UZ', 'VN', 'YE'],
		'Africa': ['DZ', 'EG', 'LY', 'MA', 'TN', 'EH', 'BI', 'KM', 'DJ', 'ER', 'ET', 'TF', 'KE', 'MG', 'MW', 'MU', 'YT', 'MZ', 'RE', 'RW', 'SC', 'SO', 'SS', 'SD', 'TZ', 'UG', 'ZM', 'ZW', 'AO', 'CM', 'CF', 'TD', 'CG', 'CD', 'GQ', 'GA', 'ST', 'BW', 'SZ', 'LS', 'NA', 'ZA', 'BJ', 'BF', 'CV', 'GM', 'GH', 'GN', 'GW', 'CI', 'LR', 'ML', 'MR', 'NE', 'NG', 'SH', 'SN', 'SL', 'TG'],
		'North & South America': ['AI', 'AG', 'AW', 'BS', 'BB', 'BZ', 'BM', 'BQ', 'VG', 'CA', 'KY', 'CR', 'CU', 'CW', 'DM', 'DO', 'SV', 'GL', 'GD', 'GP', 'GT', 'HT', 'HN', 'JM', 'MQ', 'MX', 'MS', 'NI', 'VE', 'PA', 'PR', 'BL', 'KN', 'LC', 'MF', 'PM', 'VC', 'SX', 'TT', 'TC', 'US', 'VI', 'AR', 'BO', 'BV', 'BR', 'CL', 'CO', 'EC', 'FK', 'GF', 'GY', 'PY', 'PE', 'GS', 'SR', 'UY'],
		'South Pacific': ['AU', 'CK', 'FJ', 'KI', 'MH', 'FM', 'NR', 'NZ', 'NU', 'PW', 'PG', 'WS', 'SB', 'TO', 'TV', 'VU'],
		'Unknown': [undefined],
	}[discogsCountry]) || [discogsCountry || undefined];
	const iso3166ToFlagCodes = langCodes => langCodes && Array.prototype.concat.apply([ ], langCodes.map(langCode =>
		langCode && ({ XC: ['cz', 'sk'], XP: 'cz' }[langCode.toUpperCase()] || langCode.toLowerCase())));
	const iso3166ToCountryShort = { XE: 'EU', GB: 'UK', XC: 'CS' };
	const range = (from, to) => Array.from(Array(to + 1 - from), (_, index) => from + index);
	const detectLanguage = literals => detectAlphabet(literals, {
		eng: [range(0x20, 0x5F), range(0x61, 0x7A), 0x7C, 0xA0, 0xA7, 0xA9, range(0x2010, 0x2011), range(0x2013, 0x2014), range(0x2018, 0x2019), range(0x201C, 0x201D), range(0x2020, 0x2021), 0x2026, 0x2030, range(0x2032, 0x2033), 0x20AC], fra: [range(0x20, 0x5F), range(0x61, 0x7A), 0x7C, 0xA0, 0xA7, 0xA9, 0xAB, range(0xB2, 0xB3), 0xBB, 0xC0, 0xC2, range(0xC6, 0xCB), range(0xCE, 0xCF), 0xD4, 0xD9, range(0xDB, 0xDC), 0xE0, 0xE2, range(0xE6, 0xEB), range(0xEE, 0xEF), 0xF4, 0xF9, range(0xFB, 0xFC), 0xFF, range(0x152, 0x153), 0x178, 0x2B3, 0x2E2, range(0x1D48, 0x1D49), range(0x2010, 0x2011), range(0x2013, 0x2014), 0x2019, range(0x201C, 0x201D), range(0x2020, 0x2021), 0x2026, range(0x202F, 0x2030), 0x20AC, 0x2212],
		jpn: [range(0x20, 0x40), range(0x5B, 0x5F), range(0x7B, 0x7D), 0xA0, 0xA7, 0xA9, 0xB6, range(0x2010, 0x2011), range(0x2014, 0x2016), range(0x2018, 0x2019), range(0x201C, 0x201D), range(0x2020, 0x2021), range(0x2025, 0x2026), 0x2030, range(0x2032, 0x2033), 0x203B, 0x203E, 0x20AC, range(0x3001, 0x3003), 0x3005, range(0x3008, 0x3011), range(0x3014, 0x3015), 0x301C, range(0x3041, 0x3093), range(0x309D, 0x309E), range(0x30A1, 0x30F6), range(0x30FB, 0x30FE), range(0x4E00, 0x4E01), 0x4E03, range(0x4E07, 0x4E0B), range(0x4E0D, 0x4E0E), 0x4E14, 0x4E16, range(0x4E18, 0x4E19), 0x4E21, 0x4E26, 0x4E2D, 0x4E32, range(0x4E38, 0x4E39), range(0x4E3B, 0x4E3C), 0x4E45, 0x4E4F, 0x4E57, 0x4E59, range(0x4E5D, 0x4E5E), 0x4E71, 0x4E73, 0x4E7E, 0x4E80, 0x4E86, range(0x4E88, 0x4E89), range(0x4E8B, 0x4E8C), 0x4E92, range(0x4E94, 0x4E95), 0x4E9C, 0x4EA1, 0x4EA4, range(0x4EAB, 0x4EAD), 0x4EBA, 0x4EC1, range(0x4ECA, 0x4ECB), 0x4ECF, range(0x4ED5, 0x4ED6), range(0x4ED8, 0x4ED9), range(0x4EE3, 0x4EE5), 0x4EEE, 0x4EF0, 0x4EF2, 0x4EF6, 0x4EFB, 0x4F01, range(0x4F0E, 0x4F11), 0x4F1A, 0x4F1D, 0x4F2F, 0x4F34, 0x4F38, 0x4F3A, 0x4F3C, 0x4F46, range(0x4F4D, 0x4F50), 0x4F53, 0x4F55, 0x4F59, 0x4F5C, 0x4F73, 0x4F75, 0x4F7F, 0x4F8B, 0x4F8D, 0x4F9B, 0x4F9D, 0x4FA1, range(0x4FAE, 0x4FAF), range(0x4FB5, 0x4FB6), 0x4FBF, range(0x4FC2, 0x4FC3), 0x4FCA, 0x4FD7, 0x4FDD, 0x4FE1, 0x4FEE, 0x4FF3, 0x4FF5, 0x4FF8, 0x4FFA, 0x5009, 0x500B, 0x500D, 0x5012, 0x5019, 0x501F, range(0x5023, 0x5024), 0x502B, 0x5039, 0x5049, 0x504F, 0x505C, 0x5065, range(0x5074, 0x5076), 0x507D, 0x508D, 0x5091, range(0x5098, 0x5099), 0x50AC, 0x50B2, 0x50B5, 0x50B7, 0x50BE, 0x50C5, 0x50CD, 0x50CF, 0x50D5, 0x50DA, 0x50E7, 0x5100, 0x5104, 0x5112, 0x511F, 0x512A, range(0x5143, 0x5146), range(0x5148, 0x5149), 0x514B, 0x514D, 0x5150, 0x515A, 0x5165, 0x5168, range(0x516B, 0x516D), 0x5171, 0x5175, range(0x5177, 0x5178), 0x517C, range(0x5185, 0x5186), 0x518A, 0x518D, 0x5192, 0x5197, 0x5199, 0x51A0, 0x51A5, 0x51AC, range(0x51B6, 0x51B7), 0x51C4, 0x51C6, 0x51CD, 0x51DD, 0x51E1, 0x51E6, 0x51F6, range(0x51F8, 0x51FA), 0x5200, 0x5203, range(0x5206, 0x5208), 0x520A, 0x5211, 0x5217, 0x521D, range(0x5224, 0x5225), 0x5229, 0x5230, range(0x5236, 0x523B), 0x5247, 0x524A, 0x524D, 0x5256, 0x525B, range(0x5263, 0x5265), range(0x526F, 0x5270), 0x5272, 0x5275, 0x5287, 0x529B, range(0x529F, 0x52A0), 0x52A3, range(0x52A9, 0x52AA), 0x52B1, 0x52B4, 0x52B9, 0x52BE, 0x52C3, 0x52C5, 0x52C7, 0x52C9, 0x52D5, range(0x52D8, 0x52D9), 0x52DD, 0x52DF, 0x52E2, 0x52E4, 0x52E7, 0x52F2, 0x52FE, 0x5302, 0x5305, range(0x5316, 0x5317), 0x5320, range(0x5339, 0x533B), 0x533F, 0x5341, 0x5343, range(0x5347, 0x5348), 0x534A, range(0x5351, 0x5354), range(0x5357, 0x5358), 0x535A, 0x5360, range(0x5370, 0x5371), range(0x5373, 0x5375), 0x5378, 0x5384, 0x5398, 0x539A, 0x539F, 0x53B3, 0x53BB, 0x53C2, 0x53C8, range(0x53CA, 0x53CE), 0x53D4, range(0x53D6, 0x53D7), 0x53D9, range(0x53E3, 0x53E5), range(0x53EB, 0x53EC), range(0x53EF, 0x53F3), range(0x53F7, 0x53F8), 0x5404, range(0x5408, 0x5409), range(0x540C, 0x5411), 0x541B, 0x541F, 0x5426, 0x542B, range(0x5438, 0x5439), 0x5442, range(0x5448, 0x544A), 0x5468, 0x546A, 0x5473, range(0x547C, 0x547D), 0x548C, 0x54B2, 0x54BD, range(0x54C0, 0x54C1), 0x54E1, 0x54F2, 0x54FA, 0x5504, range(0x5506, 0x5507), 0x5510, 0x552F, 0x5531, 0x553E, 0x5546, 0x554F, 0x5553, 0x5584, 0x5589, 0x559A, range(0x559C, 0x559D), range(0x55A9, 0x55AB), 0x55B6, 0x55C5, 0x55E3, 0x5606, range(0x5631, 0x5632), 0x5668, 0x5674, 0x5687, range(0x56DA, 0x56DB), 0x56DE, 0x56E0, 0x56E3, 0x56F0, range(0x56F2, 0x56F3), 0x56FA, 0x56FD, 0x570F, 0x5712, 0x571F, range(0x5727, 0x5728), 0x5730, 0x5742, 0x5747, 0x574A, 0x5751, 0x576A, 0x5782, 0x578B, 0x57A3, 0x57CB, 0x57CE, 0x57DF, 0x57F7, range(0x57F9, 0x57FA), 0x57FC, 0x5800, 0x5802, range(0x5805, 0x5806), 0x5815, 0x5824, 0x582A, 0x5831, 0x5834, range(0x5840, 0x5841), 0x584A, 0x5851, 0x5854, 0x5857, 0x585A, 0x585E, 0x5869, 0x586B, 0x587E, 0x5883, 0x5893, 0x5897, 0x589C, 0x58A8, 0x58B3, 0x58BE, 0x58C1, 0x58C7, 0x58CA, 0x58CC, 0x58EB, 0x58EE, range(0x58F0, 0x58F2), 0x5909, 0x590F, range(0x5915, 0x5916), 0x591A, 0x591C, 0x5922, 0x5927, range(0x5929, 0x592B), 0x592E, 0x5931, range(0x5947, 0x5949), 0x594F, 0x5951, 0x5954, 0x5965, 0x5968, 0x596A, 0x596E, range(0x5973, 0x5974), 0x597D, range(0x5982, 0x5984), 0x598A, 0x5996, 0x5999, 0x59A5, 0x59A8, 0x59AC, 0x59B9, 0x59BB, 0x59C9, 0x59CB, range(0x59D3, 0x59D4), 0x59EB, 0x59FB, 0x59FF, 0x5A01, 0x5A18, 0x5A20, 0x5A2F, 0x5A46, 0x5A5A, 0x5A66, 0x5A7F, 0x5A92, 0x5A9B, 0x5AC1, 0x5AC9, 0x5ACC, 0x5AE1, 0x5B22, 0x5B50, 0x5B54, range(0x5B57, 0x5B58), 0x5B5D, range(0x5B63, 0x5B64), 0x5B66, 0x5B6B, 0x5B85, range(0x5B87, 0x5B89), 0x5B8C, range(0x5B97, 0x5B9D), 0x5B9F, range(0x5BA2, 0x5BA4), 0x5BAE, 0x5BB0, range(0x5BB3, 0x5BB6), 0x5BB9, 0x5BBF, 0x5BC2, 0x5BC4, 0x5BC6, 0x5BCC, 0x5BD2, 0x5BDB, 0x5BDD, 0x5BDF, 0x5BE1, 0x5BE7, 0x5BE9, 0x5BEE, 0x5BF8, 0x5BFA, range(0x5BFE, 0x5BFF), range(0x5C01, 0x5C02), 0x5C04, 0x5C06, range(0x5C09, 0x5C0B), range(0x5C0E, 0x5C0F), 0x5C11, 0x5C1A, 0x5C31, range(0x5C3A, 0x5C40), 0x5C45, 0x5C48, range(0x5C4A, 0x5C4B), 0x5C55, 0x5C5E, range(0x5C64, 0x5C65), 0x5C6F, 0x5C71, 0x5C90, 0x5CA1, 0x5CA9, 0x5CAC, 0x5CB3, 0x5CB8, range(0x5CE0, 0x5CE1), 0x5CF0, 0x5CF6, 0x5D07, 0x5D0E, 0x5D16, 0x5D29, 0x5D50, range(0x5DDD, 0x5DDE), 0x5DE1, 0x5DE3, range(0x5DE5, 0x5DE8), 0x5DEE, 0x5DF1, 0x5DFB, 0x5DFE, range(0x5E02, 0x5E03), 0x5E06, 0x5E0C, 0x5E1D, 0x5E25, 0x5E2B, 0x5E2D, range(0x5E2F, 0x5E30), 0x5E33, 0x5E38, 0x5E3D, 0x5E45, 0x5E55, 0x5E63, range(0x5E72, 0x5E74), range(0x5E78, 0x5E79), range(0x5E7B, 0x5E7E), 0x5E81, 0x5E83, 0x5E8A, 0x5E8F, 0x5E95, 0x5E97, 0x5E9C, range(0x5EA6, 0x5EA7), 0x5EAB, 0x5EAD, range(0x5EB6, 0x5EB8), 0x5EC3, range(0x5EC9, 0x5ECA), range(0x5EF6, 0x5EF7), 0x5EFA, 0x5F01, 0x5F04, 0x5F0A, range(0x5F0F, 0x5F10), range(0x5F13, 0x5F15), 0x5F1F, range(0x5F25, 0x5F27), 0x5F31, 0x5F35, 0x5F37, 0x5F3E, 0x5F53, 0x5F59, 0x5F62, 0x5F69, 0x5F6B, range(0x5F70, 0x5F71), 0x5F79, 0x5F7C, range(0x5F80, 0x5F81), range(0x5F84, 0x5F85), range(0x5F8B, 0x5F8C), 0x5F90, range(0x5F92, 0x5F93), 0x5F97, 0x5FA1, range(0x5FA9, 0x5FAA), 0x5FAE, range(0x5FB3, 0x5FB4), 0x5FB9, 0x5FC3, 0x5FC5, range(0x5FCC, 0x5FCD), range(0x5FD7, 0x5FD9), 0x5FDC, 0x5FE0, 0x5FEB, 0x5FF5, 0x6012, 0x6016, 0x601D, 0x6020, 0x6025, range(0x6027, 0x6028), 0x602A, 0x604B, 0x6050, 0x6052, 0x6063, 0x6065, range(0x6068, 0x6069), 0x606D, 0x606F, 0x6075, 0x6094, range(0x609F, 0x60A0), 0x60A3, 0x60A6, range(0x60A9, 0x60AA), 0x60B2, 0x60BC, 0x60C5, 0x60D1, 0x60DC, range(0x60E7, 0x60E8), 0x60F0, 0x60F3, 0x6101, 0x6109, 0x610F, range(0x611A, 0x611B), 0x611F, 0x6144, 0x6148, range(0x614B, 0x614C), 0x614E, 0x6155, range(0x6162, 0x6163), 0x6168, 0x616E, 0x6170, 0x6176, 0x6182, 0x618E, 0x61A4, 0x61A7, 0x61A9, 0x61AC, 0x61B2, 0x61B6, 0x61BE, 0x61C7, 0x61D0, 0x61F2, 0x61F8, range(0x6210, 0x6212), 0x621A, 0x6226, 0x622F, 0x6234, 0x6238, 0x623B, range(0x623F, 0x6240), 0x6247, 0x6249, 0x624B, 0x624D, 0x6253, 0x6255, 0x6271, 0x6276, 0x6279, range(0x627F, 0x6280), 0x6284, 0x628A, 0x6291, 0x6295, range(0x6297, 0x6298), 0x629C, 0x629E, 0x62AB, 0x62B1, 0x62B5, 0x62B9, range(0x62BC, 0x62BD), 0x62C5, 0x62C9, 0x62CD, 0x62D0, range(0x62D2, 0x62D3), range(0x62D8, 0x62D9), 0x62DB, 0x62DD, range(0x62E0, 0x62E1), range(0x62EC, 0x62ED), 0x62F3, range(0x62F6, 0x62F7), 0x62FE, 0x6301, 0x6307, 0x6311, 0x6319, 0x631F, 0x6328, 0x632B, 0x632F, 0x633F, 0x6349, 0x6355, 0x6357, 0x635C, 0x6368, 0x636E, 0x637B, 0x6383, 0x6388, 0x638C, 0x6392, 0x6398, 0x639B, range(0x63A1, 0x63A2), 0x63A5, range(0x63A7, 0x63A8), 0x63AA, 0x63B2, range(0x63CF, 0x63D0), range(0x63DA, 0x63DB), 0x63E1, 0x63EE, 0x63F4, 0x63FA, 0x640D, range(0x642C, 0x642D), 0x643A, 0x643E, 0x6442, 0x6458, 0x6469, 0x646F, 0x6483, 0x64A4, 0x64AE, 0x64B2, 0x64C1, 0x64CD, 0x64E6, 0x64EC, 0x652F, 0x6539, 0x653B, range(0x653E, 0x653F), 0x6545, 0x654F, 0x6551, 0x6557, 0x6559, range(0x6562, 0x6563), 0x656C, 0x6570, range(0x6574, 0x6575), 0x6577, 0x6587, 0x6589, 0x658E, 0x6591, 0x6597, 0x6599, 0x659C, range(0x65A4, 0x65A5), range(0x65AC, 0x65AD), 0x65B0, 0x65B9, 0x65BD, 0x65C5, 0x65CB, 0x65CF, 0x65D7, 0x65E2, range(0x65E5, 0x65E9), 0x65EC, 0x65FA, range(0x6606, 0x6607), 0x660E, range(0x6613, 0x6614), range(0x661F, 0x6620), 0x6625, range(0x6627, 0x6628), 0x662D, 0x662F, 0x663C, 0x6642, 0x6669, range(0x666E, 0x666F), 0x6674, 0x6676, 0x6681, 0x6687, 0x6691, range(0x6696, 0x6697), 0x66A6, 0x66AB, 0x66AE, 0x66B4, 0x66C7, 0x66D6, 0x66DC, 0x66F2, 0x66F4, range(0x66F8, 0x66F9), 0x66FD, range(0x66FF, 0x6700), range(0x6708, 0x6709), 0x670D, 0x6715, 0x6717, 0x671B, 0x671D, 0x671F, 0x6728, range(0x672A, 0x672D), 0x6731, 0x6734, 0x673A, 0x673D, 0x6749, range(0x6750, 0x6751), 0x675F, 0x6761, 0x6765, 0x676F, 0x6771, range(0x677E, 0x677F), 0x6790, 0x6795, 0x6797, 0x679A, range(0x679C, 0x679D), 0x67A0, 0x67A2, 0x67AF, 0x67B6, 0x67C4, 0x67D0, range(0x67D3, 0x67D4), 0x67F1, 0x67F3, 0x67F5, 0x67FB, 0x67FF, range(0x6803, 0x6804), 0x6813, 0x6821, 0x682A, range(0x6838, 0x6839), range(0x683C, 0x683D), 0x6841, 0x6843, 0x6848, 0x6851, 0x685C, 0x685F, 0x6885, 0x6897, 0x68A8, 0x68B0, 0x68C4, 0x68CB, 0x68D2, 0x68DA, 0x68DF, 0x68EE, 0x68FA, 0x6905, range(0x690D, 0x690E), 0x691C, 0x696D, 0x6975, 0x6977, range(0x697C, 0x697D), 0x6982, 0x69CB, 0x69D8, 0x69FD, 0x6A19, 0x6A21, range(0x6A29, 0x6A2A), 0x6A39, 0x6A4B, 0x6A5F, 0x6B04, range(0x6B20, 0x6B21), 0x6B27, 0x6B32, 0x6B3A, 0x6B3E, 0x6B4C, 0x6B53, range(0x6B62, 0x6B63), 0x6B66, 0x6B69, 0x6B6F, range(0x6B73, 0x6B74), 0x6B7B, range(0x6B89, 0x6B8B), 0x6B96, range(0x6BB4, 0x6BB5), range(0x6BBA, 0x6BBB), range(0x6BBF, 0x6BC0), range(0x6BCD, 0x6BCE), 0x6BD2, 0x6BD4, 0x6BDB, 0x6C0F, 0x6C11, 0x6C17, 0x6C34, range(0x6C37, 0x6C38), 0x6C3E, range(0x6C41, 0x6C42), 0x6C4E, 0x6C57, 0x6C5A, range(0x6C5F, 0x6C60), 0x6C70, 0x6C7A, 0x6C7D, 0x6C83, 0x6C88, 0x6C96, 0x6C99, range(0x6CA1, 0x6CA2), 0x6CB3, range(0x6CB8, 0x6CB9), range(0x6CBB, 0x6CBC), 0x6CBF, 0x6CC1, range(0x6CC9, 0x6CCA), 0x6CCC, 0x6CD5, range(0x6CE1, 0x6CE3), 0x6CE5, 0x6CE8, 0x6CF0, 0x6CF3, 0x6D0B, 0x6D17, 0x6D1E, 0x6D25, 0x6D2A, 0x6D3B, 0x6D3E, 0x6D41, range(0x6D44, 0x6D45), 0x6D5C, 0x6D66, 0x6D6A, 0x6D6E, 0x6D74, range(0x6D77, 0x6D78), 0x6D88, 0x6D99, 0x6DAF, 0x6DB2, 0x6DBC, 0x6DD1, 0x6DE1, 0x6DEB, 0x6DF1, 0x6DF7, 0x6DFB, 0x6E05, range(0x6E07, 0x6E09), 0x6E0B, 0x6E13, 0x6E1B, 0x6E21, 0x6E26, 0x6E29, 0x6E2C, 0x6E2F, 0x6E56, 0x6E67, 0x6E6F, range(0x6E7E, 0x6E80), 0x6E90, 0x6E96, 0x6E9D, 0x6EB6, 0x6EBA, 0x6EC5, 0x6ECB, 0x6ED1, range(0x6EDD, 0x6EDE), 0x6EF4, range(0x6F01, 0x6F02), 0x6F06, 0x6F0F, 0x6F14, 0x6F20, 0x6F22, range(0x6F2B, 0x6F2C), 0x6F38, 0x6F54, 0x6F5C, 0x6F5F, 0x6F64, 0x6F6E, 0x6F70, 0x6F84, range(0x6FC0, 0x6FC1), 0x6FC3, 0x6FEB, 0x6FEF, 0x702C, 0x706B, range(0x706F, 0x7070), 0x707D, range(0x7089, 0x708A), 0x708E, 0x70AD, range(0x70B9, 0x70BA), 0x70C8, 0x7121, 0x7126, 0x7136, 0x713C, 0x714E, 0x7159, 0x7167, 0x7169, 0x716E, 0x718A, 0x719F, 0x71B1, 0x71C3, 0x71E5, 0x7206, 0x722A, range(0x7235, 0x7236), 0x723D, range(0x7247, 0x7248), 0x7259, 0x725B, 0x7267, 0x7269, 0x7272, 0x7279, 0x72A0, 0x72AC, 0x72AF, 0x72B6, 0x72C2, 0x72D9, 0x72E9, range(0x72EC, 0x72ED), 0x731B, 0x731F, 0x732B, 0x732E, 0x7336, 0x733F, 0x7344, 0x7363, 0x7372, 0x7384, 0x7387, 0x7389, 0x738B, 0x73A9, 0x73CD, 0x73E0, 0x73ED, 0x73FE, 0x7403, 0x7406, 0x7434, 0x7460, 0x7483, 0x74A7, 0x74B0, 0x74BD, 0x74E6, 0x74F6, 0x7518, 0x751A, 0x751F, 0x7523, 0x7528, range(0x7530, 0x7533), 0x7537, range(0x753A, 0x753B), 0x754C, 0x754F, 0x7551, 0x7554, 0x7559, range(0x755C, 0x755D), 0x7565, 0x756A, 0x7570, 0x7573, 0x757F, 0x758E, 0x7591, 0x75AB, 0x75B2, 0x75BE, 0x75C5, 0x75C7, 0x75D5, 0x75D8, 0x75DB, 0x75E2, 0x75E9, 0x75F4, 0x760D, 0x7642, 0x7652, 0x7656, range(0x767A, 0x767B), range(0x767D, 0x767E), 0x7684, range(0x7686, 0x7687), 0x76AE, 0x76BF, 0x76C6, 0x76CA, 0x76D7, 0x76DB, 0x76DF, range(0x76E3, 0x76E4), 0x76EE, 0x76F2, 0x76F4, 0x76F8, 0x76FE, 0x7701, 0x7709, range(0x770B, 0x770C), range(0x771F, 0x7720), 0x773A, 0x773C, 0x7740, 0x7761, 0x7763, 0x7766, range(0x77AC, 0x77AD), 0x77B3, 0x77DB, 0x77E2, 0x77E5, 0x77ED, 0x77EF, 0x77F3, 0x7802, range(0x7814, 0x7815), 0x7832, 0x7834, 0x785D, range(0x786B, 0x786C), 0x7881, 0x7891, 0x78BA, 0x78C1, 0x78E8, 0x7901, 0x790E, 0x793A, 0x793C, 0x793E, range(0x7948, 0x7949), 0x7956, range(0x795D, 0x795E), 0x7965, 0x7968, 0x796D, 0x7981, 0x7985, 0x798D, 0x798F, range(0x79C0, 0x79C1), 0x79CB, range(0x79D1, 0x79D2), 0x79D8, 0x79DF, 0x79E9, 0x79F0, 0x79FB, 0x7A0B, 0x7A0E, 0x7A1A, 0x7A2E, 0x7A32, range(0x7A3C, 0x7A3D), range(0x7A3F, 0x7A40), 0x7A42, 0x7A4D, 0x7A4F, 0x7A6B, 0x7A74, 0x7A76, 0x7A7A, 0x7A81, 0x7A83, range(0x7A92, 0x7A93), 0x7A9F, range(0x7AAE, 0x7AAF), 0x7ACB, 0x7ADC, 0x7AE0, 0x7AE5, 0x7AEF, 0x7AF6, 0x7AF9, 0x7B11, 0x7B1B, 0x7B26, 0x7B2C, 0x7B46, 0x7B49, 0x7B4B, 0x7B52, 0x7B54, 0x7B56, 0x7B87, 0x7B8B, 0x7B97, 0x7BA1, 0x7BB1, 0x7BB8, 0x7BC0, 0x7BC4, 0x7BC9, 0x7BE4, 0x7C21, 0x7C3F, 0x7C4D, 0x7C60, 0x7C73, 0x7C89, 0x7C8B, 0x7C92, range(0x7C97, 0x7C98), 0x7C9B, 0x7CA7, 0x7CBE, 0x7CD6, 0x7CE7, 0x7CF8, 0x7CFB, 0x7CFE, 0x7D00, range(0x7D04, 0x7D05), 0x7D0B, 0x7D0D, 0x7D14, range(0x7D19, 0x7D1B), range(0x7D20, 0x7D22), 0x7D2B, range(0x7D2F, 0x7D30), 0x7D33, range(0x7D39, 0x7D3A), 0x7D42, 0x7D44, 0x7D4C, 0x7D50, 0x7D5E, 0x7D61, 0x7D66, 0x7D71, range(0x7D75, 0x7D76), 0x7D79, range(0x7D99, 0x7D9A), 0x7DAD, range(0x7DB1, 0x7DB2), 0x7DBB, 0x7DBF, 0x7DCA, 0x7DCF, range(0x7DD1, 0x7DD2), 0x7DDA, 0x7DE0, range(0x7DE8, 0x7DE9), 0x7DEF, 0x7DF4, 0x7DFB, 0x7E01, 0x7E04, 0x7E1B, 0x7E26, 0x7E2B, 0x7E2E, 0x7E3E, 0x7E41, 0x7E4A, range(0x7E54, 0x7E55), 0x7E6D, 0x7E70, 0x7F36, 0x7F6A, 0x7F6E, 0x7F70, 0x7F72, 0x7F75, 0x7F77, 0x7F85, 0x7F8A, 0x7F8E, 0x7F9E, 0x7FA4, range(0x7FA8, 0x7FA9), 0x7FBD, 0x7FC1, 0x7FCC, 0x7FD2, range(0x7FFB, 0x7FFC), 0x8001, 0x8003, 0x8005, 0x8010, 0x8015, 0x8017, 0x8033, 0x8056, 0x805E, 0x8074, 0x8077, 0x8089, 0x808C, 0x8096, 0x8098, 0x809D, range(0x80A1, 0x80A2), 0x80A5, range(0x80A9, 0x80AA), 0x80AF, 0x80B2, 0x80BA, 0x80C3, 0x80C6, 0x80CC, 0x80CE, 0x80DE, 0x80F4, 0x80F8, 0x80FD, 0x8102, 0x8105, range(0x8107, 0x8108), 0x810A, 0x811A, 0x8131, 0x8133, 0x814E, 0x8150, 0x8155, 0x816B, 0x8170, range(0x8178, 0x817A), 0x819A, range(0x819C, 0x819D), 0x81A8, 0x81B3, 0x81C6, 0x81D3, 0x81E3, 0x81E8, 0x81EA, 0x81ED, range(0x81F3, 0x81F4), 0x81FC, 0x8208, 0x820C, 0x820E, 0x8217, range(0x821E, 0x821F), 0x822A, 0x822C, range(0x8236, 0x8237), 0x8239, 0x8247, 0x8266, 0x826F, 0x8272, 0x8276, 0x828B, 0x829D, 0x82AF, 0x82B1, 0x82B3, 0x82B8, 0x82BD, 0x82D7, 0x82DB, range(0x82E5, 0x82E6), 0x82F1, 0x8302, 0x830E, 0x8328, 0x8336, 0x8349, 0x8352, 0x8358, 0x8377, 0x83CA, 0x83CC, 0x83D3, 0x83DC, 0x83EF, 0x840E, 0x843D, 0x8449, 0x8457, 0x845B, 0x846C, 0x84B8, 0x84C4, 0x84CB, 0x8511, 0x8535, 0x853D, 0x8584, 0x85A6, range(0x85AA, 0x85AC), 0x85CD, 0x85E4, 0x85E9, 0x85FB, 0x864E, 0x8650, 0x865A, 0x865C, 0x865E, 0x866B, 0x8679, 0x868A, 0x8695, 0x86C7, 0x86CD, 0x86EE, 0x8702, 0x871C, 0x878D, 0x8840, 0x8846, 0x884C, 0x8853, 0x8857, 0x885B, 0x885D, 0x8861, 0x8863, 0x8868, 0x8870, 0x8877, 0x888B, 0x8896, 0x88AB, range(0x88C1, 0x88C2), 0x88C5, 0x88CF, 0x88D5, 0x88DC, 0x88F8, range(0x88FD, 0x88FE), 0x8907, 0x8910, 0x8912, 0x895F, 0x8972, 0x897F, 0x8981, range(0x8986, 0x8987), 0x898B, 0x898F, 0x8996, 0x899A, 0x89A7, 0x89AA, 0x89B3, 0x89D2, 0x89E3, 0x89E6, 0x8A00, range(0x8A02, 0x8A03), 0x8A08, 0x8A0E, 0x8A13, range(0x8A17, 0x8A18), 0x8A1F, 0x8A2A, 0x8A2D, 0x8A31, range(0x8A33, 0x8A34), 0x8A3A, 0x8A3C, 0x8A50, range(0x8A54, 0x8A55), 0x8A5E, 0x8A60, 0x8A63, 0x8A66, 0x8A69, 0x8A6E, range(0x8A70, 0x8A73), 0x8A87, 0x8A89, range(0x8A8C, 0x8A8D), 0x8A93, 0x8A95, 0x8A98, 0x8A9E, 0x8AA0, 0x8AA4, range(0x8AAC, 0x8AAD), 0x8AB0, 0x8AB2, 0x8ABF, 0x8AC7, 0x8ACB, 0x8AD6, range(0x8AE6, 0x8AE7), range(0x8AED, 0x8AEE), 0x8AF8, 0x8AFE, range(0x8B00, 0x8B01), 0x8B04, 0x8B0E, 0x8B19, 0x8B1B, 0x8B1D, 0x8B21, 0x8B39, 0x8B58, 0x8B5C, 0x8B66, 0x8B70, 0x8B72, 0x8B77, 0x8C37, 0x8C46, 0x8C4A, 0x8C5A, 0x8C61, 0x8C6A, 0x8C8C, range(0x8C9D, 0x8C9E), range(0x8CA0, 0x8CA2), range(0x8CA7, 0x8CAC), 0x8CAF, 0x8CB4, range(0x8CB7, 0x8CB8), range(0x8CBB, 0x8CBC), range(0x8CBF, 0x8CC0), range(0x8CC2, 0x8CC4), 0x8CC7, 0x8CCA, 0x8CD3, range(0x8CDB, 0x8CDC), 0x8CDE, 0x8CE0, 0x8CE2, 0x8CE6, 0x8CEA, 0x8CED, 0x8CFC, 0x8D08, 0x8D64, 0x8D66, 0x8D70, 0x8D74, 0x8D77, 0x8D85, 0x8D8A, 0x8DA3, 0x8DB3, 0x8DDD, 0x8DE1, 0x8DEF, 0x8DF3, 0x8DF5, 0x8E0A, 0x8E0F, 0x8E2A, 0x8E74, 0x8E8D, 0x8EAB, 0x8ECA, range(0x8ECC, 0x8ECD), 0x8ED2, 0x8EDF, 0x8EE2, 0x8EF8, 0x8EFD, 0x8F03, 0x8F09, 0x8F1D, range(0x8F29, 0x8F2A), 0x8F38, 0x8F44, 0x8F9B, 0x8F9E, 0x8FA3, range(0x8FB1, 0x8FB2), 0x8FBA, 0x8FBC, 0x8FC5, 0x8FCE, 0x8FD1, 0x8FD4, 0x8FEB, 0x8FED, 0x8FF0, 0x8FF7, 0x8FFD, range(0x9000, 0x9001), 0x9003, 0x9006, range(0x900F, 0x9010), range(0x9013, 0x9014), 0x901A, 0x901D, range(0x901F, 0x9020), 0x9023, 0x902E, range(0x9031, 0x9032), 0x9038, 0x9042, 0x9045, 0x9047, range(0x904A, 0x904B), range(0x904D, 0x904E), range(0x9053, 0x9055), 0x905C, range(0x9060, 0x9061), 0x9063, 0x9069, range(0x906D, 0x906E), 0x9075, range(0x9077, 0x9078), 0x907A, 0x907F, 0x9084, 0x90A3, 0x90A6, 0x90AA, 0x90B8, 0x90CA, 0x90CE, 0x90E1, 0x90E8, 0x90ED, 0x90F5, 0x90F7, 0x90FD, range(0x914C, 0x914E), 0x9152, 0x9154, 0x9162, 0x916A, 0x916C, 0x9175, range(0x9177, 0x9178), 0x9192, 0x919C, 0x91B8, range(0x91C7, 0x91C8), range(0x91CC, 0x91CF), 0x91D1, range(0x91DC, 0x91DD), 0x91E3, 0x920D, 0x9234, 0x9244, 0x925B, 0x9262, 0x9271, 0x9280, 0x9283, 0x9285, 0x9298, 0x92AD, 0x92ED, 0x92F3, 0x92FC, 0x9320, 0x9326, 0x932C, range(0x932E, 0x932F), 0x9332, 0x934B, 0x935B, 0x9375, 0x938C, 0x9396, 0x93AE, 0x93E1, 0x9418, 0x9451, 0x9577, 0x9580, 0x9589, 0x958B, 0x9591, 0x9593, range(0x95A2, 0x95A3), 0x95A5, 0x95B2, 0x95C7, 0x95D8, 0x961C, 0x962A, 0x9632, 0x963B, 0x9644, 0x964D, 0x9650, 0x965B, range(0x9662, 0x9665), 0x966A, 0x9670, 0x9673, range(0x9675, 0x9676), 0x9678, 0x967A, 0x967D, range(0x9685, 0x9686), 0x968A, range(0x968E, 0x968F), 0x9694, 0x9699, range(0x969B, 0x969C), 0x96A0, 0x96A3, 0x96B7, 0x96BB, range(0x96C4, 0x96C7), 0x96CC, 0x96D1, range(0x96E2, 0x96E3), 0x96E8, 0x96EA, 0x96F0, 0x96F2, range(0x96F6, 0x96F7), 0x96FB, 0x9700, 0x9707, 0x970A, 0x971C, 0x9727, 0x9732, 0x9752, 0x9759, 0x975E, 0x9762, 0x9769, 0x9774, 0x97D3, 0x97F3, 0x97FB, 0x97FF, range(0x9802, 0x9803), range(0x9805, 0x9806), 0x9808, range(0x9810, 0x9813), 0x9818, range(0x982C, 0x982D), range(0x983B, 0x983C), range(0x984C, 0x984E), range(0x9854, 0x9855), 0x9858, 0x985E, 0x9867, 0x98A8, 0x98DB, 0x98DF, 0x98E2, 0x98EF, 0x98F2, range(0x98FC, 0x98FE), 0x9905, 0x990A, 0x990C, 0x9913, 0x9928, 0x9996, 0x9999, 0x99AC, range(0x99C4, 0x99C6), 0x99D0, 0x99D2, 0x9A0E, range(0x9A12, 0x9A13), 0x9A30, 0x9A5A, 0x9AA8, 0x9AB8, 0x9AC4, 0x9AD8, 0x9AEA, 0x9B31, 0x9B3C, 0x9B42, 0x9B45, 0x9B54, 0x9B5A, 0x9BAE, 0x9BE8, 0x9CE5, 0x9CF4, 0x9D8F, 0x9DB4, 0x9E7F, 0x9E93, 0x9E97, 0x9EA6, range(0x9EBA, 0x9EBB), 0x9EC4, 0x9ED2, 0x9ED9, 0x9F13, 0x9F3B, 0x9F62, range(0xFF01, 0xFF03), range(0xFF05, 0xFF0A), range(0xFF0C, 0xFF0F), range(0xFF1A, 0xFF1B), range(0xFF1F, 0xFF20), range(0xFF3B, 0xFF3D), 0xFF3F, 0xFF5B, 0xFF5D, range(0xFF61, 0xFF65)],
		spa: [range(0x20, 0x5F), range(0x61, 0x7A), 0x7C, range(0xA0, 0xA1), 0xA7, 0xA9, 0xAB, 0xBB, 0xBF, 0xC1, 0xC9, 0xCD, 0xD1, 0xD3, 0xDA, 0xDC, 0xE1, 0xE9, 0xED, 0xF1, 0xF3, 0xFA, 0xFC, range(0x2010, 0x2011), range(0x2013, 0x2014), range(0x2018, 0x2019), range(0x201C, 0x201D), range(0x2020, 0x2021), 0x2026, 0x2030, range(0x2032, 0x2033), 0x20AC],
		deu: [range(0x20, 0x5F), range(0x61, 0x7D), 0xA0, 0xA7, 0xA9, 0xAB, 0xBB, 0xC4, 0xD6, 0xDC, 0xDF, 0xE4, 0xF6, 0xFC, range(0x2010, 0x2011), range(0x2013, 0x2014), 0x2018, 0x201A, 0x201C, 0x201E, 0x2026, 0x2030, 0x20AC],
		fra: [range(0x20, 0x5F), range(0x61, 0x7A), 0x7C, 0xA0, 0xA7, 0xA9, 0xAB, range(0xB2, 0xB3), 0xBB, 0xC0, 0xC2, range(0xC6, 0xCB), range(0xCE, 0xCF), 0xD4, 0xD9, range(0xDB, 0xDC), 0xE0, 0xE2, range(0xE6, 0xEB), range(0xEE, 0xEF), 0xF4, 0xF9, range(0xFB, 0xFC), 0xFF, range(0x152, 0x153), 0x178, 0x2B3, 0x2E2, range(0x1D48, 0x1D49), range(0x2010, 0x2011), range(0x2013, 0x2014), 0x2019, range(0x201C, 0x201D), range(0x2020, 0x2021), 0x2026, range(0x202F, 0x2030), 0x20AC, 0x2212],
		ita: [range(0x20, 0x22), range(0x24, 0x25), range(0x27, 0x29), range(0x2B, 0x5F), range(0x61, 0x7D), 0xA0, 0xA9, 0xAB, 0xBB, 0xC0, range(0xC8, 0xC9), 0xCC, range(0xD2, 0xD3), 0xD9, 0xE0, range(0xE8, 0xE9), 0xEC, range(0xF2, 0xF3), 0xF9, 0x2011, 0x2014, 0x2019, range(0x201C, 0x201D), 0x2026, 0x2030, 0x20AC],
		por: [range(0x20, 0x5F), range(0x61, 0x7A), 0x7C, 0xA0, 0xA7, 0xA9, range(0xC0, 0xC3), 0xC7, range(0xC9, 0xCA), 0xCD, range(0xD2, 0xD5), 0xDA, range(0xE0, 0xE3), 0xE7, range(0xE9, 0xEA), 0xED, range(0xF2, 0xF5), 0xFA, range(0x2010, 0x2011), range(0x2013, 0x2014), range(0x2018, 0x2019), range(0x201C, 0x201D), range(0x2020, 0x2021), 0x2026, 0x2030, range(0x2032, 0x2033), 0x20AC],
		rus: [range(0x20, 0x40), range(0x5B, 0x5F), range(0x7B, 0x7D), 0xA0, 0xA7, 0xA9, 0xAB, 0xBB, 0x401, range(0x410, 0x44F), 0x451, range(0x2010, 0x2011), range(0x2013, 0x2014), 0x2018, 0x201A, 0x201C, 0x201E, 0x2026, 0x2030, 0x20AC],
		fin: [range(0x20, 0x21), range(0x23, 0x5F), range(0x61, 0x7A), 0x7C, 0xA0, 0xA7, 0xA9, 0xBB, range(0xC4, 0xC5), 0xD6, range(0xE4, 0xE5), 0xF6, range(0x160, 0x161), range(0x17D, 0x17E), range(0x2010, 0x2011), 0x2013, 0x2019, 0x201D, 0x2026, range(0x202F, 0x2030), 0x20AC, 0x2212],
		nld: [range(0x20, 0x5F), range(0x61, 0x7A), 0x7C, 0xA0, 0xA7, 0xA9, 0xC1, 0xC4, 0xC9, 0xCB, 0xCD, 0xCF, 0xD3, 0xD6, 0xDA, 0xDC, 0xE1, 0xE4, 0xE9, 0xEB, 0xED, 0xEF, 0xF3, 0xF6, 0xFA, 0xFC, 0x133, 0x301, range(0x2010, 0x2011), range(0x2013, 0x2014), range(0x2018, 0x2019), range(0x201C, 0x201D), range(0x2020, 0x2021), 0x2026, 0x2030, range(0x2032, 0x2033), 0x20AC],
		zho: [range(0x20, 0x5F), range(0x7B, 0x7D), 0xA0, 0xA7, 0xA9, 0xB7, range(0x2010, 0x2011), range(0x2013, 0x2016), range(0x2018, 0x2019), range(0x201C, 0x201D), range(0x2025, 0x2026), 0x2030, range(0x2032, 0x2033), 0x2035, 0x203B, 0x20AC, range(0x3001, 0x3003), range(0x3007, 0x3011), range(0x3014, 0x3017), range(0x301D, 0x301E), range(0x4E00, 0x4E01), 0x4E03, range(0x4E07, 0x4E0E), 0x4E11, range(0x4E13, 0x4E14), 0x4E16, range(0x4E18, 0x4E1A), range(0x4E1C, 0x4E1D), 0x4E22, range(0x4E24, 0x4E25), 0x4E27, 0x4E2A, 0x4E2D, 0x4E30, 0x4E32, 0x4E34, range(0x4E38, 0x4E3B), range(0x4E3D, 0x4E3E), 0x4E43, 0x4E45, range(0x4E48, 0x4E49), range(0x4E4B, 0x4E50), 0x4E54, 0x4E56, range(0x4E58, 0x4E59), 0x4E5D, range(0x4E5F, 0x4E61), 0x4E66, range(0x4E70, 0x4E71), 0x4E7E, 0x4E86, range(0x4E88, 0x4E89), range(0x4E8B, 0x4E8C), range(0x4E8E, 0x4E8F), range(0x4E91, 0x4E92), range(0x4E94, 0x4E95), range(0x4E9A, 0x4E9B), 0x4EA1, range(0x4EA4, 0x4EA8), range(0x4EAB, 0x4EAC), 0x4EAE, 0x4EB2, 0x4EBA, range(0x4EBF, 0x4EC1), 0x4EC5, 0x4EC7, range(0x4ECA, 0x4ECB), range(0x4ECD, 0x4ECE), 0x4ED4, 0x4ED6, range(0x4ED8, 0x4ED9), range(0x4EE3, 0x4EE5), 0x4EEA, 0x4EEC, 0x4EF0, 0x4EF2, range(0x4EF6, 0x4EF7), 0x4EFB, 0x4EFD, 0x4EFF, 0x4F01, 0x4F0A, 0x4F0D, range(0x4F0F, 0x4F11), range(0x4F17, 0x4F1A), range(0x4F1F, 0x4F20), 0x4F24, 0x4F26, range(0x4F2F, 0x4F30), 0x4F34, 0x4F38, range(0x4F3C, 0x4F3D), 0x4F46, range(0x4F4D, 0x4F51), 0x4F53, 0x4F55, 0x4F59, range(0x4F5B, 0x4F5C), 0x4F60, 0x4F64, 0x4F69, 0x4F73, 0x4F7F, 0x4F8B, 0x4F9B, 0x4F9D, 0x4FA0, range(0x4FA6, 0x4FA8), 0x4FAC, 0x4FAF, 0x4FB5, 0x4FBF, range(0x4FC3, 0x4FC4), 0x4FCA, 0x4FD7, 0x4FDD, 0x4FE1, 0x4FE9, 0x4FEE, 0x4FF1, 0x4FFE, 0x500D, 0x5012, range(0x5019, 0x501A), 0x501F, 0x5026, 0x503C, 0x503E, 0x5047, 0x504C, 0x504F, 0x505A, 0x505C, 0x5065, range(0x5076, 0x5077), 0x50A8, 0x50AC, 0x50B2, 0x50BB, 0x50CF, 0x50E7, 0x5112, 0x513F, 0x5141, range(0x5143, 0x5146), range(0x5148, 0x5149), 0x514B, 0x514D, 0x5151, 0x5154, 0x515A, 0x5165, 0x5168, range(0x516B, 0x516E), range(0x5170, 0x5171), range(0x5173, 0x5179), range(0x517B, 0x517D), 0x5185, 0x5188, range(0x518C, 0x518D), 0x5192, 0x5199, range(0x519B, 0x519C), 0x51A0, 0x51AC, 0x51B0, range(0x51B2, 0x51B3), 0x51B5, 0x51B7, 0x51C6, 0x51CC, 0x51CF, 0x51DD, range(0x51E0, 0x51E1), 0x51E4, 0x51ED, range(0x51EF, 0x51F0), range(0x51FA, 0x51FB), 0x51FD, 0x5200, range(0x5206, 0x5207), 0x520A, range(0x5211, 0x5212), range(0x5217, 0x521B), 0x521D, 0x5224, 0x5229, 0x522B, 0x5230, range(0x5236, 0x5238), range(0x523A, 0x523B), 0x5242, 0x524D, 0x5251, 0x5267, range(0x5269, 0x526A), 0x526F, 0x5272, 0x529B, range(0x529D, 0x52A1), 0x52A3, range(0x52A8, 0x52AB), range(0x52B1, 0x52B3), 0x52BF, 0x52C7, 0x52C9, 0x52CB, 0x52D2, 0x52E4, range(0x52FE, 0x52FF), range(0x5305, 0x5306), 0x5308, range(0x5316, 0x5317), 0x5319, range(0x5339, 0x533B), 0x5341, 0x5343, range(0x5347, 0x5348), 0x534A, range(0x534E, 0x534F), range(0x5352, 0x5353), range(0x5355, 0x5357), 0x535A, range(0x5360, 0x5362), 0x536B, range(0x536F, 0x5371), range(0x5373, 0x5374), 0x5377, 0x5382, range(0x5384, 0x5386), 0x5389, range(0x538B, 0x538D), 0x539A, 0x539F, 0x53BB, 0x53BF, 0x53C2, range(0x53C8, 0x53CD), 0x53D1, 0x53D4, range(0x53D6, 0x53D9), range(0x53E3, 0x53E6), range(0x53EA, 0x53ED), range(0x53EF, 0x53F0), range(0x53F2, 0x53F3), range(0x53F6, 0x53F9), range(0x5403, 0x5404), range(0x5408, 0x540A), range(0x540C, 0x540E), range(0x5410, 0x5411), 0x5413, 0x5417, 0x541B, 0x541D, 0x541F, range(0x5426, 0x5427), range(0x542B, 0x542C), 0x542F, 0x5435, range(0x5438, 0x5439), 0x543B, 0x543E, 0x5440, 0x5446, 0x5448, 0x544A, 0x5450, 0x5458, 0x545C, 0x5462, 0x5466, 0x5468, 0x5473, 0x5475, range(0x547C, 0x547D), 0x548C, 0x5496, range(0x54A6, 0x54A8), 0x54AA, 0x54AC, 0x54AF, 0x54B1, range(0x54C0, 0x54C1), range(0x54C7, 0x54C9), range(0x54CD, 0x54CE), 0x54DF, range(0x54E5, 0x54E6), range(0x54E9, 0x54EA), 0x54ED, 0x54F2, 0x5509, 0x5510, 0x5524, 0x552C, range(0x552E, 0x552F), 0x5531, 0x5537, 0x5546, 0x554A, 0x5561, range(0x5565, 0x5566), 0x556A, 0x5580, 0x5582, 0x5584, 0x5587, 0x558A, 0x558F, 0x5594, range(0x559C, 0x559D), 0x55B5, 0x55B7, 0x55BB, 0x55D2, 0x55E8, 0x55EF, 0x5609, 0x561B, 0x5634, 0x563B, 0x563F, 0x5668, 0x56DB, 0x56DE, 0x56E0, 0x56E2, 0x56ED, 0x56F0, 0x56F4, 0x56FA, range(0x56FD, 0x56FE), 0x5706, 0x5708, 0x571F, 0x5723, 0x5728, 0x572D, 0x5730, 0x5733, 0x573A, 0x573E, 0x5740, 0x5747, 0x574E, range(0x5750, 0x5751), 0x5757, range(0x575A, 0x575C), 0x5761, 0x5764, 0x5766, 0x576A, range(0x5782, 0x5783), 0x578B, 0x5792, 0x57C3, 0x57CB, 0x57CE, 0x57D4, 0x57DF, range(0x57F9, 0x57FA), 0x5802, 0x5806, 0x5815, 0x5821, 0x582A, 0x5851, 0x5854, 0x585E, 0x586B, 0x5883, 0x589E, 0x58A8, 0x58C1, 0x58E4, range(0x58EB, 0x58EC), 0x58EE, 0x58F0, 0x5904, 0x5907, 0x590D, 0x590F, range(0x5915, 0x5916), 0x591A, 0x591C, 0x591F, 0x5925, 0x5927, range(0x5929, 0x592B), 0x592E, 0x5931, 0x5934, range(0x5937, 0x593A), range(0x5947, 0x5949), 0x594B, 0x594F, 0x5951, 0x5954, range(0x5956, 0x5957), 0x5965, range(0x5973, 0x5974), 0x5976, 0x5979, 0x597D, 0x5982, range(0x5987, 0x5988), 0x5996, 0x5999, 0x59A5, 0x59A8, 0x59AE, 0x59B9, 0x59BB, 0x59C6, range(0x59CA, 0x59CB), range(0x59D0, 0x59D1), range(0x59D3, 0x59D4), 0x59FF, 0x5A01, range(0x5A03, 0x5A04), 0x5A18, 0x5A1C, 0x5A1F, 0x5A31, 0x5A46, 0x5A5A, 0x5A92, 0x5AC1, 0x5ACC, 0x5AE9, 0x5B50, range(0x5B54, 0x5B55), range(0x5B57, 0x5B59), range(0x5B5C, 0x5B5D), 0x5B5F, range(0x5B63, 0x5B64), 0x5B66, 0x5B69, 0x5B81, 0x5B83, range(0x5B87, 0x5B89), range(0x5B8B, 0x5B8C), 0x5B8F, range(0x5B97, 0x5B9E), range(0x5BA1, 0x5BA4), 0x5BAA, range(0x5BB3, 0x5BB4), 0x5BB6, 0x5BB9, range(0x5BBD, 0x5BBF), 0x5BC2, range(0x5BC4, 0x5BC7), 0x5BCC, 0x5BD2, range(0x5BDD, 0x5BDF), 0x5BE1, 0x5BE8, range(0x5BF8, 0x5BF9), range(0x5BFB, 0x5BFC), 0x5BFF, 0x5C01, 0x5C04, 0x5C06, 0x5C0A, 0x5C0F, 0x5C11, 0x5C14, 0x5C16, 0x5C18, 0x5C1A, 0x5C1D, 0x5C24, 0x5C31, 0x5C3A, range(0x5C3C, 0x5C3E), range(0x5C40, 0x5C42), 0x5C45, 0x5C4B, 0x5C4F, 0x5C55, 0x5C5E, 0x5C60, 0x5C71, range(0x5C81, 0x5C82), range(0x5C97, 0x5C98), range(0x5C9A, 0x5C9B), 0x5CB3, 0x5CB8, 0x5CE1, 0x5CF0, 0x5D07, 0x5D29, 0x5D34, range(0x5DDD, 0x5DDE), 0x5DE1, range(0x5DE5, 0x5DE8), 0x5DEB, 0x5DEE, range(0x5DF1, 0x5DF4), 0x5DF7, range(0x5E01, 0x5E03), 0x5E05, 0x5E08, 0x5E0C, 0x5E10, range(0x5E15, 0x5E16), 0x5E1D, 0x5E26, range(0x5E2D, 0x5E2E), 0x5E38, 0x5E3D, 0x5E45, 0x5E55, range(0x5E72, 0x5E74), 0x5E76, 0x5E78, range(0x5E7B, 0x5E7D), 0x5E7F, 0x5E86, 0x5E8A, 0x5E8F, range(0x5E93, 0x5E95), 0x5E97, range(0x5E99, 0x5E9A), 0x5E9C, range(0x5E9E, 0x5E9F), range(0x5EA6, 0x5EA7), 0x5EAD, range(0x5EB7, 0x5EB8), 0x5EC9, 0x5ED6, range(0x5EF6, 0x5EF7), 0x5EFA, 0x5F00, range(0x5F02, 0x5F04), 0x5F0A, 0x5F0F, 0x5F15, range(0x5F17, 0x5F18), range(0x5F1F, 0x5F20), range(0x5F25, 0x5F26), 0x5F2F, 0x5F31, range(0x5F39, 0x5F3A), range(0x5F52, 0x5F53), 0x5F55, 0x5F5D, 0x5F62, 0x5F69, range(0x5F6C, 0x5F6D), range(0x5F70, 0x5F71), 0x5F77, 0x5F79, range(0x5F7B, 0x5F7C), range(0x5F80, 0x5F81), range(0x5F84, 0x5F85), 0x5F88, range(0x5F8B, 0x5F8C), 0x5F90, 0x5F92, 0x5F97, 0x5FAA, 0x5FAE, 0x5FB5, 0x5FB7, 0x5FC3, range(0x5FC5, 0x5FC6), range(0x5FCC, 0x5FCD), range(0x5FD7, 0x5FD9), 0x5FE0, 0x5FE7, 0x5FEB, 0x5FF5, 0x5FFD, range(0x6000, 0x6001), 0x600E, 0x6012, range(0x6015, 0x6016), 0x601D, 0x6021, 0x6025, range(0x6027, 0x6028), 0x602A, 0x603B, 0x604B, 0x6050, 0x6062, range(0x6068, 0x6069), 0x606D, range(0x606F, 0x6070), 0x6076, 0x607C, 0x6084, 0x6089, 0x6094, range(0x609F, 0x60A0), 0x60A3, 0x60A8, 0x60B2, 0x60C5, 0x60D1, 0x60DC, 0x60E0, range(0x60E7, 0x60E8), 0x60EF, 0x60F3, 0x60F9, 0x6101, range(0x6108, 0x6109), 0x610F, 0x611A, 0x611F, 0x6127, 0x6148, 0x614E, 0x6155, 0x6162, 0x6167, 0x6170, 0x61BE, 0x61C2, 0x61D2, 0x6208, 0x620A, 0x620C, range(0x620F, 0x6212), 0x6216, 0x6218, 0x622A, 0x6234, 0x6237, range(0x623F, 0x6241), 0x6247, 0x624B, range(0x624D, 0x624E), 0x6251, 0x6253, 0x6258, 0x6263, 0x6267, 0x6269, range(0x626B, 0x626F), 0x6279, range(0x627E, 0x6280), 0x6284, 0x628A, 0x6291, 0x6293, 0x6295, range(0x6297, 0x6298), 0x62A2, range(0x62A4, 0x62A5), range(0x62AB, 0x62AC), 0x62B1, 0x62B5, 0x62B9, 0x62BD, range(0x62C5, 0x62C6), 0x62C9, 0x62CD, 0x62D2, 0x62D4, 0x62D6, 0x62D8, range(0x62DB, 0x62DC), 0x62DF, range(0x62E5, 0x62E6), range(0x62E8, 0x62E9), 0x62EC, 0x62F3, 0x62F7, 0x62FC, range(0x62FE, 0x62FF), 0x6301, 0x6307, 0x6309, 0x6311, 0x6316, 0x631D, 0x6321, range(0x6324, 0x6325), 0x632A, 0x632F, 0x633A, 0x6349, 0x6350, 0x6355, 0x635F, range(0x6361, 0x6362), 0x636E, 0x6377, range(0x6388, 0x6389), 0x638C, 0x6392, 0x63A2, 0x63A5, range(0x63A7, 0x63AA), 0x63B8, range(0x63CF, 0x63D0), 0x63D2, 0x63E1, 0x63F4, 0x641C, 0x641E, range(0x642C, 0x642D), 0x6444, 0x6446, 0x644A, 0x6454, 0x6458, 0x6469, 0x6478, 0x6492, 0x649E, 0x64AD, range(0x64CD, 0x64CE), 0x64E6, 0x652F, 0x6536, 0x6539, 0x653B, range(0x653E, 0x653F), 0x6545, 0x6548, 0x654C, 0x654F, 0x6551, 0x6559, 0x655D, range(0x6562, 0x6563), 0x6566, 0x656C, 0x6570, 0x6572, 0x6574, 0x6587, 0x658B, 0x6590, 0x6597, 0x6599, 0x659C, 0x65A5, 0x65AD, range(0x65AF, 0x65B0), 0x65B9, range(0x65BC, 0x65BD), 0x65C1, 0x65C5, 0x65CB, 0x65CF, 0x65D7, 0x65E0, 0x65E2, range(0x65E5, 0x65E9), 0x65ED, 0x65F6, 0x65FA, 0x6602, 0x6606, 0x660C, range(0x660E, 0x660F), 0x6613, range(0x661F, 0x6620), 0x6625, 0x6628, 0x662D, 0x662F, 0x663E, 0x6643, 0x664B, range(0x6652, 0x6653), 0x665A, 0x6668, range(0x666E, 0x666F), 0x6674, 0x6676, 0x667A, 0x6682, 0x6691, range(0x6696, 0x6697), 0x66AE, 0x66B4, 0x66F0, 0x66F2, 0x66F4, 0x66F9, 0x66FC, range(0x66FE, 0x6700), range(0x6708, 0x6709), 0x670B, 0x670D, 0x6717, 0x671B, 0x671D, 0x671F, 0x6728, range(0x672A, 0x672D), 0x672F, 0x6731, 0x6735, 0x673A, 0x6740, range(0x6742, 0x6743), 0x6749, 0x674E, range(0x6750, 0x6751), 0x675C, 0x675F, 0x6761, 0x6765, 0x6768, range(0x676F, 0x6770), range(0x677E, 0x677F), 0x6781, 0x6784, 0x6790, 0x6797, range(0x679C, 0x679D), 0x67A2, range(0x67AA, 0x67AB), 0x67B6, range(0x67CF, 0x67D0), range(0x67D3, 0x67D4), 0x67E5, 0x67EC, 0x67EF, range(0x67F3, 0x67F4), 0x6807, 0x680B, 0x680F, 0x6811, 0x6821, range(0x6837, 0x6839), 0x683C, 0x6843, 0x6846, 0x6848, 0x684C, 0x6851, 0x6863, 0x6865, 0x6881, 0x6885, 0x68A6, range(0x68AF, 0x68B0), 0x68B5, 0x68C0, 0x68C9, 0x68CB, 0x68D2, 0x68DA, 0x68EE, 0x6905, 0x690D, 0x6930, 0x695A, 0x697C, 0x6982, 0x699C, 0x6A21, 0x6A31, 0x6A80, range(0x6B20, 0x6B23), 0x6B27, 0x6B32, 0x6B3A, 0x6B3E, 0x6B49, 0x6B4C, range(0x6B62, 0x6B66), 0x6B6A, 0x6B7B, range(0x6B8A, 0x6B8B), 0x6BB5, 0x6BC5, 0x6BCD, 0x6BCF, 0x6BD2, range(0x6BD4, 0x6BD5), 0x6BDB, 0x6BEB, 0x6C0F, 0x6C11, 0x6C14, 0x6C1B, 0x6C34, 0x6C38, 0x6C42, 0x6C47, 0x6C49, 0x6C57, 0x6C5D, range(0x6C5F, 0x6C61), 0x6C64, 0x6C6A, 0x6C76, 0x6C7D, 0x6C83, range(0x6C88, 0x6C89), 0x6C99, 0x6C9F, 0x6CA1, 0x6CA7, 0x6CB3, 0x6CB9, 0x6CBB, 0x6CBF, range(0x6CC9, 0x6CCA), 0x6CD5, 0x6CDB, range(0x6CE1, 0x6CE3), 0x6CE5, 0x6CE8, 0x6CF0, 0x6CF3, 0x6CFD, 0x6D0B, 0x6D17, 0x6D1B, 0x6D1E, 0x6D25, 0x6D2A, 0x6D32, 0x6D3B, range(0x6D3D, 0x6D3E), 0x6D41, 0x6D45, 0x6D4B, range(0x6D4E, 0x6D4F), 0x6D51, 0x6D53, 0x6D59, 0x6D66, range(0x6D69, 0x6D6A), 0x6D6E, 0x6D74, 0x6D77, 0x6D85, range(0x6D88, 0x6D89), 0x6D9B, 0x6DA8, 0x6DAF, 0x6DB2, 0x6DB5, 0x6DCB, 0x6DD1, 0x6DD8, 0x6DE1, 0x6DF1, 0x6DF7, 0x6DFB, 0x6E05, 0x6E10, 0x6E21, 0x6E23, 0x6E29, 0x6E2F, 0x6E34, 0x6E38, 0x6E56, 0x6E7E, 0x6E90, 0x6E9C, 0x6EAA, 0x6ECB, 0x6ED1, 0x6EE1, 0x6EE5, 0x6EE8, 0x6EF4, 0x6F02, 0x6F0F, 0x6F14, 0x6F20, 0x6F2B, 0x6F58, 0x6F5C, 0x6F6E, 0x6F8E, 0x6FB3, 0x6FC0, 0x704C, 0x706B, 0x706D, range(0x706F, 0x7070), 0x7075, 0x707F, 0x7089, 0x708E, 0x70AE, range(0x70B8, 0x70B9), 0x70C2, 0x70C8, 0x70E4, range(0x70E6, 0x70E7), 0x70ED, 0x7126, 0x7136, 0x714C, 0x715E, 0x7167, 0x716E, 0x718A, 0x719F, 0x71C3, 0x71D5, 0x7206, 0x722A, 0x722C, 0x7231, range(0x7235, 0x7238), 0x723D, range(0x7247, 0x7248), 0x724C, 0x7259, 0x725B, range(0x7261, 0x7262), 0x7267, 0x7269, 0x7272, 0x7275, range(0x7279, 0x727A), 0x72AF, 0x72B6, 0x72B9, 0x72C2, 0x72D0, 0x72D7, 0x72E0, 0x72EC, 0x72EE, 0x72F1, 0x72FC, range(0x731B, 0x731C), 0x732A, 0x732E, 0x7334, 0x7384, 0x7387, 0x7389, 0x738B, 0x739B, 0x73A9, 0x73AB, range(0x73AF, 0x73B0), 0x73B2, 0x73BB, 0x73C0, 0x73CA, 0x73CD, 0x73E0, 0x73ED, 0x7403, 0x7406, 0x740A, 0x742A, range(0x7433, 0x7434), 0x743C, 0x7459, 0x745C, range(0x745E, 0x745F), 0x7470, 0x7476, 0x7483, 0x74DC, 0x74E6, 0x74F6, 0x7518, 0x751A, 0x751C, 0x751F, 0x7528, range(0x7530, 0x7533), 0x7535, range(0x7537, 0x7538), 0x753B, 0x7545, 0x754C, 0x7559, 0x7565, 0x756A, 0x7586, 0x758F, 0x7591, 0x7597, 0x75AF, 0x75B2, 0x75BC, 0x75BE, 0x75C5, 0x75D5, 0x75DB, 0x75F4, 0x7678, 0x767B, range(0x767D, 0x767E), 0x7684, range(0x7686, 0x7687), 0x76AE, 0x76C8, 0x76CA, range(0x76D1, 0x76D2), 0x76D6, 0x76D8, 0x76DB, 0x76DF, 0x76EE, 0x76F4, 0x76F8, 0x76FC, 0x76FE, 0x7701, 0x7709, 0x770B, range(0x771F, 0x7720), 0x773C, 0x7740, 0x775B, 0x7761, 0x7763, 0x77A7, 0x77DB, 0x77E3, 0x77E5, 0x77ED, 0x77F3, 0x77F6, range(0x7801, 0x7802), 0x780D, 0x7814, 0x7834, 0x7840, 0x7855, 0x786C, 0x786E, range(0x788D, 0x788E), 0x7897, 0x789F, 0x78A7, 0x78B0, 0x78C1, 0x78C5, 0x78E8, 0x793A, 0x793C, 0x793E, 0x7956, 0x795A, range(0x795D, 0x795E), 0x7965, 0x7968, 0x796F, 0x7978, 0x7981, 0x7985, 0x798F, 0x79BB, range(0x79C0, 0x79C1), 0x79CB, 0x79CD, range(0x79D1, 0x79D2), 0x79D8, 0x79DF, 0x79E4, 0x79E6, 0x79E9, range(0x79EF, 0x79F0), 0x79FB, 0x7A00, 0x7A0B, range(0x7A0D, 0x7A0E), 0x7A23, 0x7A33, 0x7A3F, 0x7A46, range(0x7A76, 0x7A77), range(0x7A79, 0x7A7A), 0x7A7F, 0x7A81, 0x7A97, 0x7A9D, 0x7ACB, 0x7AD9, range(0x7ADE, 0x7AE0), 0x7AE5, 0x7AEF, 0x7AF9, 0x7B11, 0x7B14, 0x7B1B, 0x7B26, 0x7B28, 0x7B2C, 0x7B49, 0x7B4B, 0x7B51, 0x7B54, 0x7B56, 0x7B79, 0x7B7E, 0x7B80, 0x7B97, 0x7BA1, 0x7BAD, 0x7BB1, 0x7BC7, 0x7BEE, 0x7C3F, 0x7C4D, 0x7C73, 0x7C7B, 0x7C89, 0x7C92, 0x7C97, 0x7CA4, 0x7CB9, 0x7CBE, 0x7CCA, range(0x7CD5, 0x7CD6), 0x7CDF, 0x7CFB, 0x7D20, 0x7D22, 0x7D27, 0x7D2B, 0x7D2F, 0x7E41, 0x7EA2, range(0x7EA6, 0x7EA7), 0x7EAA, 0x7EAF, range(0x7EB2, 0x7EB3), 0x7EB5, range(0x7EB7, 0x7EB8), 0x7EBD, 0x7EBF, range(0x7EC3, 0x7EC4), range(0x7EC6, 0x7EC8), 0x7ECD, 0x7ECF, 0x7ED3, 0x7ED5, range(0x7ED8, 0x7ED9), range(0x7EDC, 0x7EDD), 0x7EDF, 0x7EE7, range(0x7EE9, 0x7EEA), 0x7EED, range(0x7EF4, 0x7EF5), 0x7EFC, 0x7EFF, 0x7F05, 0x7F13, 0x7F16, 0x7F18, 0x7F20, 0x7F29, 0x7F34, 0x7F36, 0x7F38, 0x7F3A, range(0x7F50, 0x7F51), 0x7F55, 0x7F57, 0x7F5A, 0x7F62, 0x7F6A, 0x7F6E, 0x7F72, 0x7F8A, 0x7F8E, 0x7F9E, 0x7FA4, 0x7FAF, 0x7FBD, 0x7FC1, 0x7FC5, 0x7FD4, 0x7FD8, 0x7FE0, 0x7FF0, range(0x7FFB, 0x7FFC), range(0x8000, 0x8001), 0x8003, 0x8005, range(0x800C, 0x800D), 0x8010, 0x8017, 0x8033, 0x8036, 0x804A, 0x804C, 0x8054, 0x8058, 0x805A, 0x806A, 0x8089, 0x8096, 0x809A, 0x80A1, range(0x80A4, 0x80A5), 0x80A9, 0x80AF, 0x80B2, 0x80C1, 0x80C6, 0x80CC, 0x80CE, 0x80D6, 0x80DC, 0x80DE, 0x80E1, 0x80F6, 0x80F8, 0x80FD, 0x8106, 0x8111, 0x8131, 0x8138, 0x814A, 0x8150, 0x8153, 0x8170, 0x8179, range(0x817E, 0x817F), 0x81C2, 0x81E3, 0x81EA, 0x81ED, range(0x81F3, 0x81F4), range(0x820C, 0x820D), 0x8212, range(0x821E, 0x821F), 0x822A, 0x822C, 0x8230, 0x8239, 0x826F, 0x8272, 0x827A, 0x827E, 0x8282, 0x8292, 0x829D, 0x82A6, range(0x82AC, 0x82AD), 0x82B1, 0x82B3, 0x82CD, 0x82CF, 0x82D7, range(0x82E5, 0x82E6), 0x82F1, range(0x8302, 0x8303), 0x8328, 0x832B, 0x8336, 0x8349, 0x8350, 0x8352, 0x8363, 0x836F, 0x8377, 0x8389, 0x838E, range(0x83AA, 0x83AB), range(0x83B1, 0x83B2), 0x83B7, 0x83DC, 0x83E9, 0x83F2, 0x8404, 0x840D, range(0x8424, 0x8425), range(0x8427, 0x8428), 0x843D, 0x8457, 0x845B, 0x8461, 0x8482, 0x848B, 0x8499, 0x84C9, 0x84DD, 0x84EC, 0x8511, 0x8521, 0x8584, 0x85AA, 0x85C9, 0x85CF, 0x85E4, 0x864E, 0x8651, 0x866B, 0x8679, range(0x867D, 0x867E), 0x8681, 0x86C7, 0x86CB, 0x86D9, 0x86EE, 0x8702, 0x871C, 0x8776, 0x878D, 0x87F9, 0x8822, 0x8840, 0x884C, 0x8857, 0x8861, 0x8863, 0x8865, 0x8868, 0x888B, 0x88AB, 0x88AD, range(0x88C1, 0x88C2), 0x88C5, 0x88D5, 0x88E4, 0x897F, 0x8981, 0x8986, range(0x89C1, 0x89C2), 0x89C4, 0x89C6, range(0x89C8, 0x89C9), 0x89D2, 0x89E3, 0x8A00, 0x8A89, 0x8A93, 0x8B66, range(0x8BA1, 0x8BA2), 0x8BA4, range(0x8BA8, 0x8BA9), range(0x8BAD, 0x8BB0), 0x8BB2, range(0x8BB7, 0x8BB8), 0x8BBA, range(0x8BBE, 0x8BBF), 0x8BC1, 0x8BC4, 0x8BC6, 0x8BC9, 0x8BCD, 0x8BD1, 0x8BD5, 0x8BD7, 0x8BDA, range(0x8BDD, 0x8BDE), 0x8BE2, range(0x8BE5, 0x8BE6), 0x8BED, 0x8BEF, 0x8BF4, range(0x8BF7, 0x8BF8), range(0x8BFA, 0x8BFB), 0x8BFE, 0x8C01, 0x8C03, 0x8C05, 0x8C08, range(0x8C0A, 0x8C0B), 0x8C13, 0x8C1C, 0x8C22, 0x8C28, 0x8C31, 0x8C37, 0x8C46, 0x8C61, 0x8C6A, 0x8C8C, range(0x8D1D, 0x8D1F), range(0x8D21, 0x8D25), range(0x8D27, 0x8D2A), 0x8D2D, 0x8D2F, 0x8D31, range(0x8D34, 0x8D35), range(0x8D38, 0x8D3A), 0x8D3C, 0x8D3E, 0x8D44, range(0x8D4B, 0x8D4C), range(0x8D4F, 0x8D50), 0x8D54, 0x8D56, range(0x8D5A, 0x8D5B), 0x8D5E, 0x8D60, 0x8D62, 0x8D64, 0x8D6B, 0x8D70, 0x8D75, 0x8D77, 0x8D81, 0x8D85, range(0x8D8A, 0x8D8B), 0x8DA3, 0x8DB3, 0x8DC3, 0x8DCC, 0x8DD1, 0x8DDD, 0x8DDF, 0x8DEF, 0x8DF3, 0x8E0F, 0x8E22, 0x8E29, 0x8EAB, 0x8EB2, 0x8F66, range(0x8F68, 0x8F69), 0x8F6C, range(0x8F6E, 0x8F70), 0x8F7B, 0x8F7D, 0x8F83, range(0x8F85, 0x8F86), range(0x8F88, 0x8F89), 0x8F91, 0x8F93, 0x8F9B, 0x8F9E, range(0x8FA8, 0x8FA9), range(0x8FB0, 0x8FB1), 0x8FB9, 0x8FBE, 0x8FC1, 0x8FC5, range(0x8FC7, 0x8FC8), 0x8FCE, range(0x8FD0, 0x8FD1), 0x8FD4, range(0x8FD8, 0x8FD9), range(0x8FDB, 0x8FDF), 0x8FE6, range(0x8FEA, 0x8FEB), 0x8FF0, 0x8FF7, 0x8FFD, range(0x9000, 0x9003), 0x9006, range(0x9009, 0x900A), range(0x900F, 0x9010), 0x9012, 0x9014, range(0x901A, 0x901B), 0x901D, range(0x901F, 0x9020), 0x9022, 0x9038, range(0x903B, 0x903C), 0x9047, 0x904D, 0x9053, 0x9057, range(0x906D, 0x906E), 0x9075, range(0x907F, 0x9080), 0x9093, 0x90A3, 0x90A6, 0x90AA, 0x90AE, 0x90B1, 0x90BB, 0x90CE, 0x90D1, 0x90E8, 0x90ED, 0x90FD, 0x9102, 0x9149, 0x914B, 0x914D, 0x9152, range(0x9177, 0x9178), 0x9189, 0x9192, 0x91C7, 0x91CA, range(0x91CC, 0x91CF), 0x91D1, 0x9488, 0x9493, 0x949F, 0x94A2, 0x94A6, 0x94B1, 0x94BB, 0x94C1, 0x94C3, 0x94DC, 0x94E2, 0x94ED, 0x94F6, 0x94FA, 0x94FE, range(0x9500, 0x9501), 0x9505, 0x950B, 0x9519, 0x9521, 0x9526, 0x952E, 0x953A, 0x9547, 0x955C, 0x956D, 0x957F, 0x95E8, 0x95EA, range(0x95ED, 0x95EE), 0x95F0, 0x95F2, 0x95F4, 0x95F7, 0x95F9, 0x95FB, 0x9601, 0x9605, 0x9610, 0x9614, 0x961F, 0x962E, range(0x9632, 0x9636), 0x963B, range(0x963F, 0x9640), range(0x9644, 0x9646), 0x9648, 0x964D, 0x9650, 0x9662, 0x9664, range(0x9669, 0x966A), range(0x9675, 0x9677), 0x9686, range(0x968F, 0x9690), 0x9694, 0x969C, 0x96BE, range(0x96C4, 0x96C6), 0x96C9, 0x96E8, 0x96EA, 0x96EF, 0x96F3, range(0x96F6, 0x96F7), 0x96FE, 0x9700, 0x9707, 0x970D, 0x9716, 0x9732, range(0x9738, 0x9739), 0x9752, 0x9756, 0x9759, 0x975E, 0x9760, 0x9762, 0x9769, 0x977C, 0x978B, 0x9791, 0x97E6, 0x97E9, 0x97F3, range(0x9875, 0x9876), range(0x9879, 0x987B), range(0x987D, 0x987F), 0x9884, range(0x9886, 0x9887), 0x9891, range(0x9897, 0x9898), 0x989D, 0x98CE, range(0x98D8, 0x98D9), range(0x98DE, 0x98DF), 0x9910, range(0x996D, 0x996E), range(0x9970, 0x9971), 0x997C, 0x9986, 0x9996, 0x9999, 0x99A8, 0x9A6C, 0x9A71, 0x9A76, 0x9A7B, 0x9A7E, 0x9A8C, 0x9A91, 0x9A97, 0x9A9A, 0x9AA4, 0x9AA8, 0x9AD8, 0x9B3C, 0x9B42, 0x9B45, 0x9B54, 0x9C7C, 0x9C81, 0x9C9C, 0x9E1F, 0x9E21, 0x9E23, 0x9E2D, 0x9E3F, 0x9E45, 0x9E64, 0x9E70, 0x9E7F, 0x9EA6, 0x9EBB, 0x9EC4, 0x9ECE, 0x9ED1, 0x9ED8, 0x9F13, 0x9F20, 0x9F3B, 0x9F50, 0x9F7F, 0x9F84, 0x9F99, 0x9F9F, range(0xFE30, 0xFE31), range(0xFE33, 0xFE44), range(0xFE49, 0xFE52), range(0xFE54, 0xFE57), range(0xFE59, 0xFE61), 0xFE63, 0xFE68, range(0xFE6A, 0xFE6B), range(0xFF01, 0xFF03), range(0xFF05, 0xFF0A), range(0xFF0C, 0xFF0F), range(0xFF1A, 0xFF1B), range(0xFF1F, 0xFF20), range(0xFF3B, 0xFF3D), 0xFF3F, 0xFF5B, 0xFF5D],
		swe: [range(0x20, 0x5F), range(0x61, 0x7A), 0x7C, 0xA0, 0xA7, 0xA9, 0xC0, range(0xC4, 0xC5), 0xC9, 0xD6, 0xE0, range(0xE4, 0xE5), 0xE9, 0xF6, range(0x2010, 0x2011), range(0x2013, 0x2014), range(0x2018, 0x2019), range(0x201C, 0x201D), range(0x2020, 0x2021), 0x2026, 0x2030, range(0x2032, 0x2033), 0x20AC, 0x2212],
		pol: [range(0x20, 0x5F), range(0x61, 0x70), range(0x72, 0x75), 0x77, range(0x79, 0x7E), 0xA0, 0xA7, 0xA9, 0xAB, 0xB0, 0xBB, 0xD3, 0xF3, range(0x104, 0x107), range(0x118, 0x119), range(0x141, 0x144), range(0x15A, 0x15B), range(0x179, 0x17C), range(0x2010, 0x2011), range(0x2013, 0x2014), range(0x201D, 0x201E), range(0x2020, 0x2021), 0x2026, 0x2030, range(0x2032, 0x2033), 0x20AC],
		kor: [range(0x20, 0x40), range(0x5B, 0x5F), range(0x7B, 0x7D), range(0xA0, 0xA1), 0xA7, 0xA9, range(0xB6, 0xB7), 0xBF, range(0x2010, 0x2011), range(0x2014, 0x2015), range(0x2018, 0x2019), range(0x201C, 0x201D), range(0x2020, 0x2021), range(0x2025, 0x2026), 0x2030, range(0x2032, 0x2033), 0x203B, 0x203E, 0x20AC, range(0x3001, 0x3003), range(0x3008, 0x3011), range(0x3014, 0x3015), 0x301C, 0x30FB, 0x3131, 0x3134, 0x3137, 0x3139, range(0x3141, 0x3142), 0x3145, range(0x3147, 0x3148), range(0x314A, 0x314E), range(0xAC00, 0xD7A3), range(0xFF01, 0xFF03), range(0xFF05, 0xFF0A), range(0xFF0C, 0xFF0F), range(0xFF1A, 0xFF1B), range(0xFF1F, 0xFF20), range(0xFF3B, 0xFF3D), 0xFF3F, 0xFF5B, 0xFF5D],
		tur: [range(0x20, 0x5F), range(0x61, 0x70), range(0x72, 0x76), range(0x79, 0x7A), 0x7C, 0xA0, 0xA7, 0xA9, 0xC7, 0xD6, 0xDC, 0xE7, 0xF6, 0xFC, range(0x11E, 0x11F), range(0x130, 0x131), range(0x15E, 0x15F), range(0x2010, 0x2011), range(0x2013, 0x2014), range(0x2018, 0x2019), range(0x201C, 0x201D), range(0x2020, 0x2021), 0x2026, 0x2030, range(0x2032, 0x2033), 0x20AC],
		hin: [range(0x20, 0x25), range(0x27, 0x40), range(0x5B, 0x5F), 0x7C, 0xA0, 0xA7, 0xA9, range(0x901, 0x903), range(0x905, 0x90D), range(0x90F, 0x911), range(0x913, 0x928), range(0x92A, 0x930), range(0x932, 0x933), range(0x935, 0x939), range(0x93C, 0x943), 0x945, range(0x947, 0x949), range(0x94B, 0x94D), 0x950, range(0x964, 0x970), range(0x2010, 0x2011), range(0x2013, 0x2014), range(0x2018, 0x2019), range(0x201C, 0x201D), range(0x2020, 0x2021), 0x2026, 0x2030, range(0x2032, 0x2033), 0x20AC],
		ell: [range(0x20, 0x22), range(0x24, 0x3E), 0x40, range(0x5B, 0x5F), 0x7C, 0xA0, 0xA7, 0xA9, 0xAB, 0xBB, 0x301, 0x308, 0x386, range(0x388, 0x38A), 0x38C, range(0x38E, 0x3A1), range(0x3A3, 0x3CE), range(0x2010, 0x2011), range(0x2013, 0x2014), 0x2026, 0x2030, 0x20AC],
		nor: [range(0x20, 0x21), range(0x23, 0x25), range(0x27, 0x5F), range(0x61, 0x7D), 0xA0, 0xA7, 0xA9, 0xAB, 0xBB, range(0xBF, 0xC0), range(0xC5, 0xC6), 0xC9, range(0xD2, 0xD4), 0xD8, 0xE0, range(0xE5, 0xE6), 0xE9, range(0xF2, 0xF4), 0xF8, 0x2011, 0x2013, range(0x2018, 0x2019), range(0x201C, 0x201D), range(0x2020, 0x2021), 0x2026, 0x2030, range(0x2032, 0x2033), 0x20AC, 0x2212],
		hun: [range(0x20, 0x5F), range(0x61, 0x70), range(0x72, 0x76), range(0x79, 0x7E), 0xA0, 0xA7, 0xA9, 0xAB, 0xBB, 0xC1, 0xC9, 0xCD, 0xD3, 0xD6, 0xDA, 0xDC, 0xE1, 0xE9, 0xED, 0xF3, 0xF6, 0xFA, 0xFC, range(0x150, 0x151), range(0x170, 0x171), 0x1F1, 0x1F3, 0x2011, 0x2013, 0x2019, range(0x201D, 0x201E), 0x2026, 0x2030, 0x2052, 0x20AC, range(0x27E8, 0x27E9)],
		ces: [range(0x20, 0x21), range(0x24, 0x5F), range(0x61, 0x7A), 0x7C, 0xA0, 0xA7, 0xA9, 0xC1, 0xC9, 0xCD, 0xD3, 0xDA, 0xDD, 0xE1, 0xE9, 0xED, 0xF3, 0xFA, 0xFD, range(0x10C, 0x10F), range(0x11A, 0x11B), range(0x147, 0x148), range(0x158, 0x159), range(0x160, 0x161), range(0x164, 0x165), range(0x16E, 0x16F), range(0x17D, 0x17E), range(0x2010, 0x2011), 0x2013, 0x2018, 0x201A, 0x201C, 0x201E, 0x2026, 0x2030, 0x20AC],
		dan: [range(0x20, 0x5F), range(0x61, 0x7A), 0x7C, 0xA0, 0xA7, 0xA9, range(0xC5, 0xC6), 0xD8, range(0xE5, 0xE6), 0xF8, range(0x2010, 0x2011), 0x2013, range(0x2018, 0x2019), range(0x201C, 0x201D), 0x2020, 0x2026, 0x2030, range(0x2032, 0x2033), 0x20AC],
		ind: [range(0x20, 0x5F), range(0x61, 0x7A), 0x7C, 0xA0, 0xA7, 0xA9, range(0x2010, 0x2011), range(0x2013, 0x2014), range(0x2018, 0x2019), range(0x201C, 0x201D), range(0x2020, 0x2021), 0x2026, 0x2030, range(0x2032, 0x2033), 0x20AC],
		est: [range(0x20, 0x21), range(0x24, 0x25), range(0x27, 0x29), range(0x2B, 0x5F), range(0x61, 0x7D), 0xA0, 0xA9, 0xC4, range(0xD5, 0xD6), 0xDC, 0xE4, range(0xF5, 0xF6), 0xFC, range(0x160, 0x161), range(0x17D, 0x17E), 0x2011, 0x2013, 0x201C, 0x201E, 0x2030, 0x20AC, 0x2212],
		ara: [range(0x20, 0x22), range(0x24, 0x25), range(0x27, 0x29), range(0x2B, 0x3A), range(0x3C, 0x3E), range(0x5B, 0x5F), 0x7C, 0xA0, 0xA9, 0xAB, 0xBB, 0x609, 0x60C, range(0x61B, 0x61C), 0x61F, range(0x621, 0x63A), range(0x641, 0x652), range(0x660, 0x66C), 0x670, 0x200E, range(0x2010, 0x2011), range(0x2013, 0x2014), 0x2026, 0x2030, 0x20AC],
		ron: [range(0x20, 0x22), range(0x24, 0x25), range(0x27, 0x5F), range(0x61, 0x7A), 0x7C, 0xA0, 0xA9, 0xAB, 0xBB, 0xC2, 0xCE, 0xE2, 0xEE, range(0x102, 0x103), range(0x218, 0x21B), range(0x2010, 0x2011), range(0x2013, 0x2014), 0x2018, range(0x201C, 0x201E), 0x2026, 0x2030, 0x20AC],
		lat: [range(0x20, 0x5F), range(0x61, 0x7A), 0x7C, 0xA0, 0xA7, 0xA9, range(0x2010, 0x2011), range(0x2013, 0x2014), range(0x2018, 0x2019), range(0x201C, 0x201D), range(0x2020, 0x2021), 0x2026, 0x2030, range(0x2032, 0x2033), 0x20AC],
		srp: [range(0x20, 0x21), range(0x23, 0x25), range(0x27, 0x3F), range(0x5B, 0x5F), range(0x7B, 0x7D), 0xA0, 0xA9, 0x402, range(0x408, 0x40B), range(0x40F, 0x418), range(0x41A, 0x428), range(0x430, 0x438), range(0x43A, 0x448), 0x452, range(0x458, 0x45B), 0x45F, range(0x2010, 0x2011), 0x2013, 0x2018, 0x201A, 0x201C, 0x201E, 0x2026, 0x2030, 0x20AC],
		pan: [range(0x20, 0x22), range(0x24, 0x29), range(0x2B, 0x3F), range(0x5B, 0x5F), 0x7C, 0xA0, 0xA9, range(0xA05, 0xA0A), range(0xA0F, 0xA10), range(0xA13, 0xA28), range(0xA2A, 0xA30), 0xA32, range(0xA35, 0xA36), range(0xA38, 0xA39), 0xA3C, range(0xA3E, 0xA42), range(0xA47, 0xA48), range(0xA4B, 0xA4D), range(0xA59, 0xA5C), 0xA5E, range(0xA66, 0xA74), range(0x2010, 0x2011), range(0x2013, 0x2014), range(0x2018, 0x2019), range(0x201C, 0x201D), 0x2030, range(0x2032, 0x2033), 0x20AC],
		heb: [range(0x20, 0x22), range(0x24, 0x25), range(0x27, 0x29), range(0x2B, 0x3F), range(0x5B, 0x5F), 0x7C, 0xA0, 0xA9, 0x5BE, range(0x5D0, 0x5EA), range(0x5F3, 0x5F4), 0x200E, range(0x2010, 0x2011), range(0x2013, 0x2014), 0x2030, 0x20AC],
		slv: [range(0x20, 0x22), range(0x24, 0x25), range(0x27, 0x5F), range(0x61, 0x70), range(0x72, 0x76), range(0x7A, 0x7D), 0xA0, 0xA9, 0xAB, 0xBB, 0x106, range(0x10C, 0x10D), 0x110, range(0x160, 0x161), range(0x17D, 0x17E), 0x2011, 0x2013, range(0x201E, 0x201F), 0x2026, 0x2030, 0x20AC, 0x2212],
		cat: [range(0x20, 0x5F), range(0x61, 0x7A), 0x7C, range(0xA0, 0xA1), 0xA7, 0xA9, 0xAB, 0xB7, 0xBB, range(0xBF, 0xC0), range(0xC7, 0xC9), 0xCD, 0xCF, range(0xD2, 0xD3), 0xDA, 0xDC, 0xE0, range(0xE7, 0xE9), 0xED, 0xEF, range(0xF2, 0xF3), 0xFA, 0xFC, range(0x2010, 0x2011), range(0x2013, 0x2014), range(0x2018, 0x2019), range(0x201C, 0x201D), range(0x2020, 0x2021), 0x2026, 0x2030, range(0x2032, 0x2033), 0x20AC],
		tam: [range(0x20, 0x40), range(0x5B, 0x5F), 0x7C, 0xA0, 0xA7, 0xA9, 0xB83, range(0xB85, 0xB8A), range(0xB8E, 0xB90), range(0xB92, 0xB95), range(0xB99, 0xB9A), 0xB9C, range(0xB9E, 0xB9F), range(0xBA3, 0xBA4), range(0xBA8, 0xBAA), range(0xBAE, 0xBB5), range(0xBB7, 0xBB9), range(0xBBE, 0xBC2), range(0xBC6, 0xBC8), range(0xBCA, 0xBCD), range(0xBE6, 0xBEF), range(0x2010, 0x2011), range(0x2013, 0x2014), range(0x2018, 0x2019), range(0x201C, 0x201D), range(0x2020, 0x2021), 0x2026, 0x2030, range(0x2032, 0x2033), 0x20AC],
		lav: [range(0x20, 0x5F), range(0x61, 0x70), range(0x72, 0x76), 0x7A, 0x7C, 0xA0, 0xA7, 0xA9, range(0x100, 0x101), range(0x10C, 0x10D), range(0x112, 0x113), range(0x122, 0x123), range(0x12A, 0x12B), range(0x136, 0x137), range(0x13B, 0x13C), range(0x145, 0x146), range(0x160, 0x161), range(0x16A, 0x16B), range(0x17D, 0x17E), range(0x2010, 0x2011), range(0x2013, 0x2014), range(0x2018, 0x201A), range(0x201C, 0x201E), range(0x2020, 0x2021), 0x2026, 0x2030, range(0x2032, 0x2033), 0x20AC],
		hrv: [range(0x20, 0x22), range(0x24, 0x25), range(0x27, 0x5F), range(0x61, 0x70), range(0x72, 0x76), 0x7A, 0x7C, 0xA0, 0xA9, range(0x106, 0x107), range(0x10C, 0x10D), range(0x110, 0x111), range(0x160, 0x161), range(0x17D, 0x17E), 0x1C4, range(0x1C6, 0x1C7), range(0x1C9, 0x1CA), 0x1CC, range(0x2010, 0x2011), range(0x2013, 0x2014), range(0x2018, 0x201A), range(0x201C, 0x201E), 0x2026, 0x2030, range(0x2032, 0x2033), 0x20AC],
		gsw: [range(0x20, 0x21), range(0x24, 0x25), range(0x27, 0x29), range(0x2B, 0x3F), range(0x41, 0x5F), range(0x61, 0x7D), 0xA0, 0xA9, 0xC4, 0xD6, 0xDC, 0xE4, 0xF6, 0xFC, 0x2011, 0x2019, 0x2030, 0x20AC, 0x2212],
		fil: [range(0x20, 0x3F), range(0x41, 0x5F), range(0x61, 0x7A), 0x7C, 0xA0, 0xA7, 0xA9, 0xD1, 0xF1, range(0x2010, 0x2011), range(0x2013, 0x2014), range(0x2018, 0x2019), range(0x201C, 0x201D), 0x2026, 0x2030, range(0x2032, 0x2033), 0x20AC],
		slk: [range(0x20, 0x21), range(0x24, 0x5F), range(0x61, 0x7A), 0x7C, 0xA0, 0xA7, 0xA9, 0xC1, 0xC4, 0xC9, 0xCD, range(0xD3, 0xD4), 0xDA, 0xDD, 0xE1, 0xE4, 0xE9, 0xED, range(0xF3, 0xF4), 0xFA, 0xFD, range(0x10C, 0x10F), range(0x139, 0x13A), range(0x13D, 0x13E), range(0x147, 0x148), range(0x154, 0x155), range(0x160, 0x161), range(0x164, 0x165), range(0x17D, 0x17E), 0x1C6, 0x1F3, range(0x2010, 0x2011), 0x2013, 0x2018, 0x201A, 0x201C, 0x201E, 0x2026, 0x2030, 0x20AC],
		ukr: [range(0x20, 0x22), range(0x24, 0x25), range(0x27, 0x40), range(0x5B, 0x5F), range(0x7B, 0x7D), 0xA0, 0xA7, 0xA9, 0xAB, 0xBB, 0x2BC, 0x404, range(0x406, 0x407), range(0x410, 0x429), 0x42C, range(0x42E, 0x449), 0x44C, range(0x44E, 0x44F), 0x454, range(0x456, 0x457), range(0x490, 0x491), 0x2011, 0x2013, 0x2019, 0x201C, 0x201E, 0x2030, 0x20AC, 0x2116],
		fas: [range(0x20, 0x21), range(0x24, 0x25), range(0x27, 0x3A), range(0x3C, 0x3E), range(0x5B, 0x5F), 0x7C, 0xA0, 0xA9, 0xAB, 0xBB, 0x609, 0x60C, 0x61B, 0x61F, range(0x621, 0x624), range(0x626, 0x63A), range(0x641, 0x642), range(0x644, 0x648), range(0x64B, 0x64D), 0x651, 0x654, range(0x66A, 0x66C), 0x67E, 0x686, 0x698, 0x6A9, 0x6AF, 0x6CC, range(0x6F0, 0x6F9), 0x200E, range(0x2010, 0x2011), 0x2026, 0x2030, range(0x2039, 0x203A), 0x20AC, 0x2212],
		eus: [range(0x20, 0x5F), range(0x61, 0x7A), 0x7C, 0xA0, 0xA7, 0xA9, 0xC7, 0xD1, 0xE7, 0xF1, range(0x2010, 0x2011), range(0x2013, 0x2014), range(0x2018, 0x2019), range(0x201C, 0x201D), range(0x2020, 0x2021), 0x2026, 0x2030, range(0x2032, 0x2033), 0x20AC, 0x2212],
		isl: [range(0x20, 0x5F), range(0x61, 0x62), range(0x64, 0x70), range(0x72, 0x76), range(0x78, 0x79), 0x7C, 0xA0, 0xA7, 0xA9, 0xC1, 0xC6, 0xC9, 0xCD, 0xD0, 0xD3, 0xD6, 0xDA, range(0xDD, 0xDE), 0xE1, 0xE6, 0xE9, 0xED, 0xF0, 0xF3, 0xF6, 0xFA, range(0xFD, 0xFE), range(0x2010, 0x2011), range(0x2013, 0x2014), 0x2018, 0x201A, 0x201C, 0x201E, range(0x2020, 0x2021), 0x2026, 0x2030, range(0x2032, 0x2033), 0x20AC],
		tha: [range(0x20, 0x25), range(0x27, 0x3A), range(0x3C, 0x3E), 0x40, range(0x5B, 0x5F), 0x7C, 0xA0, 0xA9, range(0xE01, 0xE3A), range(0xE40, 0xE4E), range(0x2010, 0x2011), range(0x2013, 0x2014), range(0x2018, 0x2019), range(0x201C, 0x201D), 0x2026, 0x2030, range(0x2032, 0x2033), 0x20AC],
		vie: [range(0x20, 0x5F), range(0x61, 0x65), range(0x67, 0x69), range(0x6B, 0x76), range(0x78, 0x79), 0x7C, 0xA0, 0xA7, 0xA9, range(0xC0, 0xC3), range(0xC8, 0xCA), range(0xCC, 0xCD), range(0xD2, 0xD5), range(0xD9, 0xDA), 0xDD, range(0xE0, 0xE3), range(0xE8, 0xEA), range(0xEC, 0xED), range(0xF2, 0xF5), range(0xF9, 0xFA), 0xFD, range(0x102, 0x103), range(0x110, 0x111), range(0x128, 0x129), range(0x168, 0x169), range(0x1A0, 0x1A1), range(0x1AF, 0x1B0), range(0x1EA0, 0x1EF9), range(0x2010, 0x2011), range(0x2013, 0x2014), range(0x2018, 0x2019), range(0x201C, 0x201D), range(0x2020, 0x2021), 0x2026, 0x2030, range(0x2032, 0x2033), 0x20AC],
		afr: [range(0x20, 0x5F), range(0x61, 0x7A), 0x7C, 0xA0, 0xA7, 0xA9, range(0xC1, 0xC2), range(0xC8, 0xCB), range(0xCE, 0xCF), 0xD4, 0xD6, 0xDB, range(0xE1, 0xE2), range(0xE8, 0xEB), range(0xEE, 0xEF), 0xF4, 0xF6, 0xFB, range(0x2010, 0x2011), range(0x2013, 0x2014), range(0x2018, 0x2019), range(0x201C, 0x201D), range(0x2020, 0x2021), 0x2026, 0x2030, range(0x2032, 0x2033), 0x20AC],
		lit: [range(0x20, 0x21), range(0x24, 0x25), range(0x27, 0x29), range(0x2B, 0x3F), range(0x41, 0x50), range(0x52, 0x56), range(0x59, 0x5F), range(0x61, 0x70), range(0x72, 0x76), range(0x79, 0x7D), 0xA0, 0xA9, range(0x104, 0x105), range(0x10C, 0x10D), range(0x116, 0x119), range(0x12E, 0x12F), range(0x160, 0x161), range(0x16A, 0x16B), range(0x172, 0x173), range(0x17D, 0x17E), range(0x2010, 0x2011), range(0x2013, 0x2014), 0x201C, 0x201E, 0x2026, 0x2030, 0x20AC, 0x2212],
		cym: [range(0x20, 0x5F), range(0x61, 0x6A), range(0x6C, 0x70), range(0x72, 0x75), 0x77, 0x79, 0x7C, 0xA0, 0xA7, 0xA9, range(0xC0, 0xC2), 0xC4, range(0xC8, 0xCF), range(0xD2, 0xD4), 0xD6, range(0xD9, 0xDD), range(0xE0, 0xE2), 0xE4, range(0xE8, 0xEF), range(0xF2, 0xF4), 0xF6, range(0xF9, 0xFD), 0xFF, range(0x174, 0x178), range(0x1E80, 0x1E85), range(0x1EF2, 0x1EF3), range(0x2010, 0x2011), range(0x2013, 0x2014), range(0x2018, 0x2019), range(0x201C, 0x201D), range(0x2020, 0x2021), 0x2026, 0x2030, range(0x2032, 0x2033), 0x20AC],
		bul: [range(0x20, 0x22), range(0x24, 0x25), range(0x27, 0x40), range(0x5B, 0x5F), 0x7C, 0xA0, 0xA7, 0xA9, range(0x410, 0x42A), 0x42C, range(0x42E, 0x44A), 0x44C, range(0x44E, 0x44F), range(0x2010, 0x2011), range(0x2013, 0x2014), 0x2018, 0x201A, 0x201C, 0x201E, 0x2026, 0x2030, 0x2033, 0x20AC, 0x2116],
		tel: [range(0x20, 0x22), range(0x24, 0x25), range(0x27, 0x29), range(0x2B, 0x3F), range(0x5B, 0x5F), range(0x7B, 0x7D), 0xA0, 0xA9, range(0xC01, 0xC03), range(0xC05, 0xC0C), range(0xC0E, 0xC10), range(0xC12, 0xC28), range(0xC2A, 0xC33), range(0xC35, 0xC39), range(0xC3E, 0xC44), range(0xC46, 0xC48), range(0xC4A, 0xC4D), range(0xC55, 0xC56), range(0xC60, 0xC61), range(0xC66, 0xC6F), 0x2011, range(0x2018, 0x2019), range(0x201C, 0x201D), 0x2030, 0x20AC],
		glg: [range(0x20, 0x5F), range(0x61, 0x7A), 0x7C, range(0xA0, 0xA1), 0xA7, 0xA9, 0xAB, 0xBB, 0xBF, 0xC1, 0xC9, 0xCD, 0xCF, 0xD1, 0xD3, 0xDA, 0xDC, 0xE1, 0xE9, 0xED, 0xEF, 0xF1, 0xF3, 0xFA, 0xFC, range(0x2010, 0x2011), range(0x2013, 0x2014), range(0x2018, 0x2019), range(0x201C, 0x201D), range(0x2020, 0x2021), 0x2026, 0x2030, range(0x2032, 0x2033), 0x20AC],
		bre: [range(0x20, 0x21), range(0x24, 0x25), range(0x27, 0x29), range(0x2B, 0x3F), range(0x41, 0x50), range(0x52, 0x5F), range(0x61, 0x70), range(0x72, 0x7D), 0xA0, 0xA9, 0xCA, 0xD1, 0xD9, 0xEA, 0xF1, 0xF9, 0x2BC, 0x2011, 0x2030, 0x20AC],
		mya: [0x20, range(0x23, 0x25), range(0x27, 0x39), range(0x3C, 0x3E), 0x40, range(0x5B, 0x5F), range(0x7B, 0x7D), 0xA0, 0xA9, range(0x1000, 0x1021), range(0x1023, 0x1027), range(0x1029, 0x1032), range(0x1036, 0x104B), 0x104F, range(0x2010, 0x2011), range(0x2013, 0x2014), range(0x2018, 0x2019), range(0x201C, 0x201D), 0x2026, 0x2030, 0x20AC],
		urd: [0x20, range(0x24, 0x25), range(0x27, 0x29), range(0x2B, 0x3A), range(0x3C, 0x3E), range(0x5B, 0x5F), 0x7C, 0xA0, 0xA9, range(0x60C, 0x60D), 0x61B, 0x61F, 0x621, range(0x627, 0x628), range(0x62A, 0x63A), range(0x641, 0x642), range(0x644, 0x646), 0x648, range(0x66B, 0x66C), 0x679, 0x67E, 0x686, 0x688, 0x691, 0x698, 0x6A9, 0x6AF, 0x6BE, 0x6C1, 0x6CC, 0x6D2, 0x6D4, range(0x6F0, 0x6F9), 0x200E, 0x2011, 0x2030, 0x20AC],
		bos: [range(0x20, 0x22), range(0x24, 0x25), range(0x27, 0x5F), range(0x61, 0x70), range(0x72, 0x76), 0x7A, 0x7C, 0xA0, 0xA9, range(0x106, 0x107), range(0x10C, 0x10D), range(0x110, 0x111), range(0x160, 0x161), range(0x17D, 0x17E), 0x1C4, range(0x1C6, 0x1C7), range(0x1C9, 0x1CA), 0x1CC, range(0x2010, 0x2011), range(0x2013, 0x2014), range(0x2018, 0x2019), range(0x201C, 0x201D), 0x2026, 0x2030, range(0x2032, 0x2033), 0x20AC],
		oci: [range(0x20, 0x5F), range(0x61, 0x7A), 0x7C, 0xA0, 0xA7, 0xA9, 0xAB, 0xBB, range(0xC0, 0xC1), range(0xC7, 0xC9), 0xCD, 0xCF, range(0xD2, 0xD3), 0xDA, 0xDC, range(0xE0, 0xE1), range(0xE7, 0xE9), 0xED, 0xEF, range(0xF2, 0xF3), 0xFA, 0xFC, range(0x2010, 0x2011), range(0x2013, 0x2014), 0x2019, range(0x201C, 0x201D), range(0x2020, 0x2021), 0x2026, 0x2030, 0x20AC, 0x22C5],
		msa: [range(0x20, 0x5F), range(0x61, 0x7A), 0x7C, 0xA0, 0xA7, 0xA9, range(0x2010, 0x2011), range(0x2013, 0x2014), range(0x2018, 0x2019), range(0x201C, 0x201D), range(0x2020, 0x2021), 0x2026, 0x2030, range(0x2032, 0x2033), 0x20AC],
		mal: [range(0x20, 0x40), range(0x5B, 0x5F), 0x7C, 0xA0, 0xA7, 0xA9, range(0xD02, 0xD03), range(0xD05, 0xD0C), range(0xD0E, 0xD10), range(0xD12, 0xD28), range(0xD2A, 0xD39), range(0xD3E, 0xD43), range(0xD46, 0xD48), range(0xD4A, 0xD4D), 0xD57, range(0xD60, 0xD61), range(0xD66, 0xD6F), range(0xD7A, 0xD7F), range(0x200C, 0x200D), range(0x2010, 0x2011), range(0x2013, 0x2014), range(0x2018, 0x2019), range(0x201C, 0x201D), range(0x2020, 0x2021), 0x2026, 0x2030, range(0x2032, 0x2033), 0x20AC],
		bel: [range(0x20, 0x21), range(0x24, 0x25), range(0x27, 0x29), range(0x2B, 0x3F), range(0x5B, 0x5F), range(0x7B, 0x7D), 0xA0, 0xA9, 0xAB, 0xBB, 0x401, 0x406, 0x40E, range(0x410, 0x417), range(0x419, 0x428), range(0x42B, 0x437), range(0x439, 0x448), range(0x44B, 0x44F), 0x451, 0x456, 0x45E, 0x2011, 0x2030, 0x20AC],
		haw: [range(0x20, 0x21), range(0x24, 0x25), range(0x27, 0x29), range(0x2B, 0x3F), range(0x41, 0x5F), 0x61, 0x65, range(0x68, 0x69), range(0x6B, 0x70), 0x75, 0x77, range(0x7B, 0x7D), 0xA0, 0xA9, range(0x100, 0x101), range(0x112, 0x113), range(0x12A, 0x12B), range(0x14C, 0x14D), range(0x16A, 0x16B), 0x2BB, 0x2011, 0x2030, 0x20AC],
		yid: [range(0x20, 0x22), range(0x24, 0x25), range(0x27, 0x29), range(0x2B, 0x3F), range(0x5B, 0x5F), 0x7C, 0xA0, 0xA9, 0x5BC, range(0x5BE, 0x5BF), 0x5C2, range(0x5D0, 0x5EA), range(0x5F3, 0x5F4), range(0x2010, 0x2011), range(0x2013, 0x2014), 0x2030, 0x20AC, 0xFB1D, 0xFB1F, 0xFB2B, range(0xFB2E, 0xFB2F), 0xFB35, 0xFB3B, 0xFB44, 0xFB4A, 0xFB4C, 0xFB4E],
		asm: [range(0x20, 0x40), range(0x5B, 0x5F), 0x7C, 0xA0, 0xA7, 0xA9, 0x964, range(0x981, 0x983), range(0x985, 0x98B), range(0x98F, 0x990), range(0x993, 0x9A8), range(0x9AA, 0x9AF), 0x9B2, range(0x9B6, 0x9B9), 0x9BC, range(0x9BE, 0x9C3), range(0x9C7, 0x9C8), range(0x9CB, 0x9CE), range(0x9DC, 0x9DD), 0x9DF, range(0x9E6, 0x9F1), range(0x2010, 0x2011), range(0x2013, 0x2014), range(0x2018, 0x2019), range(0x201C, 0x201D), range(0x2020, 0x2021), 0x2026, 0x2030, range(0x2032, 0x2033), 0x20AC],
		mar: [range(0x20, 0x40), range(0x5B, 0x5F), 0x7C, 0xA0, 0xA9, range(0x901, 0x903), range(0x905, 0x90D), range(0x90F, 0x911), range(0x913, 0x928), range(0x92A, 0x933), range(0x935, 0x939), range(0x93C, 0x943), 0x945, range(0x947, 0x949), range(0x94B, 0x94D), 0x950, range(0x966, 0x96F), 0x200D, range(0x2010, 0x2011), range(0x2013, 0x2014), range(0x2018, 0x2019), range(0x201C, 0x201D), 0x2026, 0x2030, range(0x2032, 0x2033), 0x20AC],
		gle: [range(0x20, 0x5F), range(0x61, 0x69), range(0x6C, 0x70), range(0x72, 0x75), 0x7C, 0xA0, 0xA7, 0xA9, 0xC1, 0xC9, 0xCD, 0xD3, 0xDA, 0xE1, 0xE9, 0xED, 0xF3, 0xFA, range(0x2010, 0x2011), range(0x2013, 0x2014), range(0x2018, 0x2019), range(0x201C, 0x201D), range(0x2020, 0x2021), 0x2026, 0x2030, range(0x2032, 0x2033), 0x20AC],
		gla: [range(0x20, 0x49), range(0x4C, 0x50), range(0x52, 0x55), range(0x5B, 0x5F), range(0x61, 0x69), range(0x6C, 0x70), range(0x72, 0x75), range(0x7B, 0x7D), range(0xA0, 0xA1), 0xA7, 0xA9, 0xAE, 0xB0, range(0xB6, 0xB7), 0xC0, 0xC8, 0xCC, 0xD2, 0xD9, 0xE0, 0xE8, 0xEC, 0xF2, 0xF9, range(0x2010, 0x2011), range(0x2013, 0x2014), range(0x2018, 0x2019), range(0x201C, 0x201D), range(0x2020, 0x2021), range(0x2026, 0x2027), 0x2030, 0x204A, 0x20AC, 0x2122],
		mkd: [range(0x20, 0x21), range(0x24, 0x25), range(0x27, 0x29), range(0x2B, 0x3F), range(0x5B, 0x5F), range(0x7B, 0x7D), 0xA0, 0xA9, 0x403, 0x405, range(0x408, 0x40A), 0x40C, range(0x40F, 0x418), range(0x41A, 0x428), range(0x430, 0x438), range(0x43A, 0x448), 0x453, 0x455, range(0x458, 0x45A), 0x45C, 0x45F, range(0x2010, 0x2011), range(0x2013, 0x2014), 0x2018, 0x201A, 0x201C, 0x201E, 0x2026, 0x2030, 0x20AC],
		nob: [range(0x20, 0x21), range(0x23, 0x25), range(0x27, 0x5F), range(0x61, 0x7D), 0xA0, 0xA7, 0xA9, 0xAB, 0xBB, range(0xBF, 0xC0), range(0xC5, 0xC6), 0xC9, range(0xD2, 0xD4), 0xD8, 0xE0, range(0xE5, 0xE6), 0xE9, range(0xF2, 0xF4), 0xF8, 0x2011, 0x2013, range(0x2018, 0x2019), range(0x201C, 0x201D), range(0x2020, 0x2021), 0x2026, 0x2030, range(0x2032, 0x2033), 0x20AC, 0x2212],
		mri: [range(0x20, 0x21), range(0x24, 0x25), range(0x27, 0x29), range(0x2B, 0x3F), 0x41, 0x45, range(0x48, 0x49), 0x4B, range(0x4D, 0x50), 0x52, range(0x54, 0x55), 0x57, range(0x5B, 0x5F), 0x61, 0x65, range(0x67, 0x69), 0x6B, range(0x6D, 0x70), 0x72, range(0x74, 0x75), 0x77, range(0x7B, 0x7D), 0xA0, 0xA9, range(0x100, 0x101), range(0x112, 0x113), range(0x12A, 0x12B), range(0x14C, 0x14D), range(0x16A, 0x16B), 0x2011, 0x2030, 0x20AC],
		san: [range(0x20, 0x40), range(0x5B, 0x60), range(0x7B, 0x7E), 0xA0, 0xA7, 0xA9, range(0x901, 0x903), range(0x905, 0x90C), range(0x90F, 0x910), range(0x913, 0x928), range(0x92A, 0x930), range(0x932, 0x933), range(0x935, 0x939), range(0x93C, 0x944), range(0x947, 0x948), range(0x94B, 0x94D), range(0x950, 0x952), range(0x960, 0x963), range(0x966, 0x96F), 0x2011, range(0x2013, 0x2014), range(0x2018, 0x2019), range(0x201C, 0x201D), 0x2026, 0x2030, range(0x2032, 0x2033), 0x20AC],
		zul: [range(0x20, 0x21), range(0x24, 0x25), range(0x27, 0x29), range(0x2B, 0x3F), range(0x41, 0x5F), range(0x61, 0x7D), 0xA0, 0xA9, 0x2011, 0x2030, 0x20AC],
		ast: [range(0x20, 0x49), range(0x4C, 0x56), range(0x58, 0x5F), range(0x61, 0x69), range(0x6C, 0x76), range(0x78, 0x7A), 0x7C, range(0xA0, 0xA1), 0xA7, 0xA9, 0xAB, 0xBB, 0xBF, 0xC1, 0xC9, 0xCD, 0xD1, 0xD3, 0xDA, 0xDC, 0xE1, 0xE9, 0xED, 0xF1, 0xF3, 0xFA, 0xFC, range(0x1E24, 0x1E25), range(0x1E36, 0x1E37), range(0x2010, 0x2011), range(0x2013, 0x2014), range(0x2018, 0x2019), range(0x201C, 0x201D), range(0x2020, 0x2021), 0x2026, 0x2030, range(0x2032, 0x2033), 0x20AC],
		swa: [range(0x20, 0x22), range(0x24, 0x25), range(0x27, 0x29), range(0x2B, 0x3F), range(0x41, 0x50), range(0x52, 0x57), range(0x59, 0x5F), range(0x61, 0x70), range(0x72, 0x77), range(0x79, 0x7D), 0xA0, 0xA9, 0x2011, 0x2030, 0x20AC],
		kat: [range(0x20, 0x21), range(0x23, 0x40), range(0x5B, 0x5F), range(0x7B, 0x7D), 0xA0, 0xA7, 0xA9, 0xAB, 0xBB, range(0x10D0, 0x10F0), 0x10FB, range(0x1C90, 0x1CB0), range(0x2010, 0x2011), range(0x2013, 0x2014), 0x2018, 0x201A, 0x201C, 0x201E, range(0x2020, 0x2021), 0x2026, 0x2030, range(0x2032, 0x2033), 0x20AC, 0x2116],
		hye: [0x20, range(0x24, 0x25), 0x27, range(0x2B, 0x3A), range(0x3C, 0x3E), 0x5C, range(0x5E, 0x5F), 0x7C, 0xA0, 0xA9, 0xAB, 0xBB, range(0x531, 0x556), range(0x55A, 0x55F), range(0x561, 0x586), 0x58A, 0x2011, 0x2030, 0x20AC],
		kaz: [range(0x20, 0x40), range(0x5B, 0x5F), range(0x7B, 0x7D), 0xA0, 0xA7, 0xA9, 0xAB, 0xBB, 0x401, 0x406, range(0x410, 0x44F), 0x451, 0x456, range(0x492, 0x493), range(0x49A, 0x49B), range(0x4A2, 0x4A3), range(0x4AE, 0x4B1), range(0x4BA, 0x4BB), range(0x4D8, 0x4D9), range(0x4E8, 0x4E9), range(0x2010, 0x2011), range(0x2013, 0x2014), range(0x2018, 0x2019), range(0x201C, 0x201D), 0x2026, 0x2030, 0x20AC],
		tuk: [range(0x20, 0x25), range(0x27, 0x42), range(0x44, 0x50), range(0x52, 0x55), 0x57, range(0x59, 0x5F), range(0x61, 0x62), range(0x64, 0x70), range(0x72, 0x75), 0x77, range(0x79, 0x7D), 0xA0, 0xA7, 0xA9, 0xC4, 0xC7, 0xD6, range(0xDC, 0xDD), 0xE4, 0xE7, 0xF6, range(0xFC, 0xFD), range(0x147, 0x148), range(0x15E, 0x15F), range(0x17D, 0x17E), 0x2011, range(0x2013, 0x2014), range(0x201C, 0x201D), 0x2026, 0x2030, 0x20AC],
		uzb: [range(0x20, 0x56), range(0x58, 0x5F), range(0x61, 0x76), range(0x78, 0x7A), 0x7C, 0xA0, 0xA7, 0xA9, range(0x2BB, 0x2BC), range(0x2010, 0x2011), range(0x2013, 0x2014), range(0x2018, 0x2019), range(0x201C, 0x201D), range(0x2020, 0x2021), 0x2026, 0x2030, range(0x2032, 0x2033), 0x20AC],
		ltz: [range(0x20, 0x5F), range(0x61, 0x7D), 0xA0, 0xA7, 0xA9, 0xAB, 0xBB, 0xC4, 0xC9, 0xCB, 0xE4, 0xE9, 0xEB, range(0x2010, 0x2011), range(0x2013, 0x2014), 0x2018, 0x201A, 0x201C, 0x201E, 0x2026, 0x2030, 0x20AC],
		mon: [range(0x20, 0x40), range(0x5B, 0x5F), 0x7C, 0xA0, 0xA7, 0xA9, 0x401, range(0x410, 0x44F), 0x451, range(0x4AE, 0x4AF), range(0x4E8, 0x4E9), range(0x2010, 0x2011), range(0x2013, 0x2014), range(0x2018, 0x2019), range(0x201C, 0x201D), range(0x2020, 0x2021), 0x2026, 0x2030, range(0x2032, 0x2033), 0x20AC],
		som: [range(0x20, 0x40), range(0x42, 0x44), range(0x46, 0x48), range(0x4A, 0x4E), range(0x51, 0x54), range(0x57, 0x59), range(0x5B, 0x5F), range(0x62, 0x64), range(0x66, 0x68), range(0x6A, 0x6E), range(0x71, 0x74), range(0x77, 0x79), 0x7C, 0xA0, 0xA7, 0xA9, range(0x2010, 0x2011), range(0x2013, 0x2014), range(0x2018, 0x2019), range(0x201C, 0x201D), range(0x2020, 0x2021), 0x2026, 0x2030, range(0x2032, 0x2033), 0x20AC],
	});
	const detectScript = literals => detectAlphabet(literals, {
		Latn: [range(0x20, 0x7E), range(0xA0, 0xFF), range(0x100, 0x17F), range(0x180, 0x24F), range(0x250, 0x2AF), range(0x2B0, 0x2FF), range(0x300, 0x36F), range(0x1E00, 0x1EFF), range(0x2C60, 0x2C7F), range(0xA720, 0xA7FF), range(0xAB30, 0xAB6F), range(0x10780, 0x107BF), range(0x1DF00, 0x1DFFF)],
		Cyrl: [range(0x400, 0x4FF), range(0x500, 0x52F), range(0x2DE0, 0x2DFF), range(0xA640, 0xA69F), range(0x1C80, 0x1C8F), range(0x1E030, 0x1E08F)],
	});
	const languageIdentifier = text => globalXHR('https://api.translatedlabs.com/language-identifier/identify', {
		responseType: 'json',
	}, { text: text, etnologue: true, uiLanguage: 'en' }).then(function({response}) {
		console.log('Online language identifier success:', response);
		let iso6393 = /^(\w{2})-(\w{2})$/.exec(response.code);
		return iso6393 != null && (iso6393 = {
			aa: 'aar', ab: 'abk', ae: 'ave', af: 'afr', ak: 'aka', am: 'amh', an: 'arg', ar: 'ara', as: 'asm',
			av: 'ava', ay: 'aym', az: 'aze', ba: 'bak', be: 'bel', bg: 'bul', bi: 'bis', bm: 'bam', bn: 'ben',
			bo: 'bod', br: 'bre', bs: 'bos', ca: 'cat', ce: 'che', ch: 'cha', co: 'cos', cr: 'cre', cs: 'ces',
			cu: 'chu', cv: 'chv', cy: 'cym', da: 'dan', de: 'deu', dv: 'div', dz: 'dzo', ee: 'ewe', el: 'ell',
			en: 'eng', eo: 'epo', es: 'spa', et: 'est', eu: 'eus', fa: 'fas', ff: 'ful', fi: 'fin', fj: 'fij',
			fo: 'fao', fr: 'fra', fy: 'fry', ga: 'gle', gd: 'gla', gl: 'glg', gn: 'grn', gu: 'guj', gv: 'glv',
			ha: 'hau', he: 'heb', hi: 'hin', ho: 'hmo', hr: 'hrv', ht: 'hat', hu: 'hun', hy: 'hye', hz: 'her',
			ia: 'ina', id: 'ind', ie: 'ile', ig: 'ibo', ii: 'iii', ik: 'ipk', io: 'ido', is: 'isl', it: 'ita',
			iu: 'iku', ja: 'jpn', jv: 'jav', ka: 'kat', kg: 'kon', ki: 'kik', kj: 'kua', kk: 'kaz', kl: 'kal',
			km: 'khm', kn: 'kan', ko: 'kor', kr: 'kau', ks: 'kas', ku: 'kur', kv: 'kom', kw: 'cor', ky: 'kir',
			la: 'lat', lb: 'ltz', lg: 'lug', li: 'lim', ln: 'lin', lo: 'lao', lt: 'lit', lu: 'lub', lv: 'lav',
			mg: 'mlg', mh: 'mah', mi: 'mri', mk: 'mkd', ml: 'mal', mn: 'mon', mr: 'mar', ms: 'msa', mt: 'mlt',
			my: 'mya', na: 'nau', nb: 'nob', nd: 'nde', ne: 'nep', ng: 'ndo', nl: 'nld', nn: 'nno', no: 'nor',
			nr: 'nbl', nv: 'nav', ny: 'nya', oc: 'oci', oj: 'oji', om: 'orm', or: 'ori', os: 'oss', pa: 'pan',
			pi: 'pli', pl: 'pol', ps: 'pus', pt: 'por', qu: 'que', rm: 'roh', rn: 'run', ro: 'ron', ru: 'rus',
			rw: 'kin', sa: 'san', sc: 'srd', sd: 'snd', se: 'sme', sg: 'sag', si: 'sin', sk: 'slk', sl: 'slv',
			sm: 'smo', sn: 'sna', so: 'som', sq: 'sqi', sr: 'srp', ss: 'ssw', st: 'sot', su: 'sun', sv: 'swe',
			sw: 'swa', ta: 'tam', te: 'tel', tg: 'tgk', th: 'tha', ti: 'tir', tk: 'tuk', tl: 'tgl', tn: 'tsn',
			to: 'ton', tr: 'tur', ts: 'tso', tt: 'tat', tw: 'twi', ty: 'tah', ug: 'uig', uk: 'ukr', ur: 'urd',
			uz: 'uzb', ve: 'ven', vi: 'vie', vo: 'vol', wa: 'wln', wo: 'wol', xh: 'xho', yi: 'yid', yo: 'yor',
			za: 'zha', zh: 'zho', zu: 'zul',
		}[iso6393[1].toLowerCase()]) ? Object.assign(response, { iso6393: iso6393 })
			: Promise.reject('Language not resolved');
	});
	const labelMapper = label => dashUnifier(label);
	if (editionInfo != null) {
		if (incompleteEdition.test(editionInfo.lastChild.textContent.trim()))
			editionInfo.parentNode.style.backgroundColor = '#f001';
		if (editionSearch && editionInfo.parentNode.querySelector('span[class$="-edition-search"]') == null) {
			function addSearch(className, resourceName, callBack, title) {
				const span = document.createElement('span'), defaultOpacity = 0.5;
				span.style = 'float: right; margin-left: 3pt;';
				[span.className, span.innerHTML] = [className, GM_getResourceText(resourceName)];
				span.firstElementChild.setAttribute('height', '1em');
				span.firstElementChild.removeAttribute('width');
				span.firstElementChild.style.cursor = 'pointer';
				span.firstElementChild.style.opacity = defaultOpacity;
				span.firstElementChild.style.transition = 'opacity 100ms, scale 100ms';
				span.firstElementChild.dataset.torrentId = torrentId;
				span.firstElementChild.onclick = function(evt) {
					const target = evt.currentTarget, editionTR = evt.currentTarget.closest('tr.edition');
					console.assert(editionTR != null, evt.currentTarget);
					queryAjaxAPICached('torrent', { id: evt.currentTarget.dataset.torrentId }).then(function(torrent) {
						console.assert(torrent.torrent.remasterYear > 0);
						const [labels, catNos] = editionInfoParser(torrent.torrent);
						const roleMapper = importance => torrent.group.musicInfo && (importance = torrent.group.musicInfo[importance])
							&& importance.length > 0 ? importance.map(artist => artist.name) : undefined;
						const artists = torrent.group.releaseType == 7 ? 'Various Artists'
							: roleMapper('dj') || roleMapper('artists');
						const nonemptyArray = array => array.length > 0 ? array : undefined;
						const searchParams = {
							artists: artists,
							releaseTitle: torrent.group.name ? releaseTitleNorm(torrent.group.name) : undefined,
							year: torrent.torrent.remasterYear > 0 ? torrent.torrent.remasterYear : undefined,
							labels: nonemptyArray(labels),
							searchLabels: nonemptyArray(labels.filter(label => !rxNoLabel.test(label))),
							catNos: nonemptyArray(catNos),
							searchCatNos: nonemptyArray(decodeHTML(torrent.torrent.remasterCatalogueNumber).split(rxEditionSplitter)
								.map(value => value.trim()).map(catno => !rxNoCatno.test(catno) ? catno.replace(rxCatNoRange, '$1$2') : undefined)
								.filter((s1, n, a) => s1 && a.findIndex(s2 => s2.toLowerCase() == s1.toLowerCase()) == n)),
							barcodes: nonemptyArray(catNos.map(catNo => (catNo = catNo.replace(/\D+/g, '')).length >= 10
								&& catNo.length <= 13 ? catNo : null).filter(Boolean)),
							editionTitle: torrent.torrent.remasterTitle || undefined,
							releaseType: torrent.group.releaseType,
						};
						if (searchParams.searchCatNos && (searchParams.searchLabels || searchParams.year)
								|| searchParams.barcodes
								|| searchParams.releaseTitle && (searchParams.artists || searchParams.year))
							callBack(searchParams, editionTR);
						else target.parentNode.remove();
					}, alert);
				};
				span.firstElementChild.onmouseenter = span.firstElementChild.onmouseleave = function(evt) {
					if (evt.type == 'mouseenter') {
						evt.currentTarget.style.opacity = 1;
						evt.currentTarget.style.scale = 1.25;
					} else {
						if (!evt.currentTarget.dataset.haveResults) evt.currentTarget.style.opacity = defaultOpacity;
						evt.currentTarget.style.scale = 'none';
					}
				};
				svgSetTitle(span.firstElementChild, title);
				editionInfo.after(span);
			}

			addSearch('discogs-edition-search', 'dc_icon', function(params, editionTR) {
				function openInNewWindow(background = false) {
					const url = new URL('search', dcOrigin);
					url.searchParams.set('type', 'release');
					if (params.searchLabels) url.searchParams.set('label', params.searchLabels.join(' '));
					if (params.searchCatNos) url.searchParams.set('catno', params.searchCatNos.join(' '));
					if (!params.searchCatNos || !params.searchLabels) {
						if (params.year) url.searchParams.set('year', params.year);
						if (params.releaseTitle) url.searchParams.set('title', params.releaseTitle);
						if (!params.releaseTitle || !params.year)
							if (Array.isArray(params.artists)) url.searchParams.set('artist', params.artists[0]);
							else if (params.artists == 'Various Artists') url.searchParams.set('format', 'Compilation');
					}
					//url.searchParams.set('format', 'CD');
					GM_openInTab(url.href, background);
					if (!params.barcodes) return;
					url.searchParams.delete('label'); url.searchParams.delete('catno');
					for (let barcode of params.barcodes) {
						url.searchParams.set('barcode', barcode);
						GM_openInTab(url.href, background);
					}
				}

				if (!(editionTR instanceof HTMLElement) || !dcAuth && !params.searchLabels && !params.searchCatNos) return;
				const icon = editionTR.querySelector('span.discogs-edition-search > svg');
				const haveResults = icon != null && Boolean(eval(icon.dataset.haveResults)), searchMethods = { };
				if (autoOpenTab || haveResults || !dcAuth) openInNewWindow(false);
				if (haveResults || !dcAuth) return; else if (icon != null) if (icon.disabled) return; {
					[icon.disabled, icon.dataset.haveResults] = [true, true];
					var animation = flashElement(icon);
				}
				if (params.searchCatNos || params.barcodes) {
					const searches = [ ], search = { };
					if (params.searchLabels) search.label = params.searchLabels.join(' ');
					if (params.searchCatNos) search.catno = params.searchCatNos.join(' ');
					if ((!params.searchLabels || !params.searchCatNos) && params.year) search.year = params.year;
					if (Object.keys(search).length > 0) searches.push(search);
					if (params.barcodes)
						Array.prototype.push.apply(searches, params.barcodes.map(barcode => ({ barcode: barcode })));
					if (searches.length > 0) searchMethods['label / cat# / barcode'] = searches;
				}
				if (params.searchLabels && params.searchCatNos
						&& Math.min(...['Labels', 'CatNos'].map(p => params['search' + p].length)) > 2
						&& params.searchLabels.length * params.searchCatNos.length <= 15)
					searchMethods['single label / cat#'] = Array.prototype.concat.apply([ ], params.searchLabels.map(label =>
						params.searchCatNos.map(catNo => ({ label: label, catno: catNo }))));
				if (params.releaseTitle) {
					const releaseTitles = parseLanguages(params.releaseTitle);
					if (Array.isArray(params.artists)) searchMethods['artist / album'] = Array.prototype.concat.apply([ ],
						params.artists.map(artist => Array.prototype.concat.apply([ ], parseLanguages(artist, true).map(artist =>
							releaseTitles.map(releaseTitle => ({ release_title: releaseTitle, artist: artist }))))));
					if (params.year) searchMethods['album / year'] =
						releaseTitles.map(releaseTitle => ({ release_title: releaseTitle, year: params.year }));
				}
				if (Object.keys(searchMethods) <= 0) {
					if (icon != null) icon.remove();
					return;
				}
				(function searchMethod(index = 0) {
					return index < Object.keys(searchMethods).length ? (searches =>
							Promise.all(searches.map(search => (function searchPage(page = 1) {
						return dcApiRequest('database/search', Object.assign({
							type: 'release', sort: 'score',
							page: page, per_page: 100,
						}, search)).then(function(response) {
							return parseInt(response.pagination.pages) > parseInt(response.pagination.page) ?
								searchPage(page + 1).then(Array.prototype.concat.bind(response.results)) : response.results;
						}, function(reason) {
							console.warn(reason);
							return [ ];
						});
					})())).then(results => Array.prototype.concat.apply([ ], results).filter((result, index, array) =>
						array.findIndex(result2 => result2.id == result.id) == index)).then(function(results) {
						const resultsFilter = (matchYear = true) => (matchYear = results.filter(function(release) {
							if (matchYear && release.year && parseInt(release.year) != params.year) return false;
							return !release.formats || release.formats.some(isDiscogsCD);
						})).length > 0 ? matchYear : null;
						return resultsFilter(true) || resultsFilter(false) || Promise.reject('Nothing found by any method');
					}))(searchMethods[Object.keys(searchMethods)[index]]).then(results => [results, index],
						reason => searchMethod(index + 1)) : Promise.reject('Not found');
				})().then(function([results, methodIndex]) {
					const [tr, td, table, thead, tbody] = createElements('tr', 'td', 'table', 'div', 'tbody');
					[tr.className, td.colSpan, thead.style] = ['edition-search-results', 6, theadStyle];
					thead.innerHTML = `<b>Matched by ${Object.keys(searchMethods)[methodIndex]}</b> (${results.length})`;
					const rowWorkers = [ ];
					results.forEach(function(release, index) {
						const [tr, artist, title, mediaFormats, releaseEvents, companies, catNos, barcode, groupSize] =
							createElements('tr', 'td', 'td', 'td', 'td', 'td', 'td', 'td', 'td');
						tr.className = 'discogs-release';
						[barcode.style, groupSize.style.textAlign] = ['white-space: break-spaces; max-width: 30%;', 'right'];
						title.innerHTML = linkHTML(dcOrigin + release.uri, release.title, 'discogs-release');
						let descriptors = getDiscogsReleaseDescriptors(release);
						if (descriptors.length > 0) appendDisambiguation(title, descriptors.join(', '));
						if (release.thumb || release.cover_image) addThumbnail(title, release.thumb || release.cover_image,
							[dcOrigin, 'release', release.id, 'images'].join('/'));
						if (release.formats) mediaFormats.innerHTML = release.formats.filter(format =>
								parseInt(format.qty) > 0).map(function(format) {
							const medium = format.qty + '×' + format.name, descriptors = getFormatDescriptions(format);
							return /*descriptors.length > 0 ? medium + ' (' + descriptors.join(', ') + ')' : */medium;
						}).join('<br>');
						if (release.country || release.year) fillListRows(releaseEvents,
							iso3166ToFlagCodes(discogsCountryToIso3166Mapper(release.country)).map(countryCode =>
								releaseEventMapper(countryCode, release.year, params.year)), 3);
						if (Array.isArray(release.label)) fillListRows(companies, release.label.map(stripDiscogsNameVersion)
							.filter(uniqueValues).map(label => editionInfoMapper(label, undefined, params.labels)), 3, true);
						if (release.catno) fillListRows(catNos, [editionInfoMapper(undefined, release.catno, null, params.catNos)]);
						const barcodes = (release.barcode || [ ]).map(function(barcode) {
							const div = Object.assign(document.createElement('div'), {
								textContent: barcode,
								className: 'identifier',
							});
							if (params.catNos && params.catNos.some(catNo => sameBarcodes(catNo, barcode)))
								editionInfoMatchingStyle(div);
							return div;
						});
						if (barcodes.length > 0); barcode.append(...barcodes);
						rowWorkers.push(dcApiRequest('releases/' + release.id).catch(reason =>
								globalXHR([dcOrigin, 'release', release.id].join('/'), { method: 'HEAD' }).then(function({finalUrl}) {
							const releaseId = discogsIdExtractor(finalUrl, 'release');
							return releaseId > 0 && releaseId != release.id ?
								dcApiRequest('releases/' + releaseId) : Promise.reject(reason);
						}, () => Promise.reject(reason))).then(function(release) {
							const releaseLink = title.querySelector('a.discogs-release');
							if (releaseLink != null) [releaseLink.href/*, releaseLink.textContent*/] = [release.uri, release.title];
							// setDiscogsArtist(artist, release.artists);
							if (release.labels.length <= 0) return;
							while (catNos.lastChild != null) catNos.removeChild(catNos.lastChild);
							fillListRows(catNos, release.labels.map(label => label.catno).filter(uniqueValues)
								.map(catNo => editionInfoMapper(undefined, catNo, null, params.catNos)));
							setDiscogsTooltip(release, tr);
						}, function(reason) {
							tr.style = 'background-color: #f001; opacity: 0.5;';
							setTooltip(tr, reason);
						}));
						setDiscogsGroupSize(release, groupSize);
						tr.append(title, mediaFormats, releaseEvents, companies, catNos, barcode, groupSize);
						for (let cell of tr.cells) cell.style.backgroundColor = 'inherit';
						['title', 'media-formats', 'release-events', 'labels-and-companies', 'cat-nos', 'barcode', 'editions-total']
							.forEach((className, index) => { tr.cells[index].className = className });
						tbody.append(tr);
					});
					table.append(thead, tbody); td.append(thead, table); tr.append(td);
					Promise.all(rowWorkers).then(() => { addResultsFilter(thead, tbody, 5) }, console.warn);
					editionTR.after(tr);
				}, function(reason) {
					if (icon == null) return;
					icon.style.fill = 'red';
					icon.insertAdjacentHTML('afterbegin', '<title>' + reason + '</title>');
				}).then(function() {
					if (animation) animation.cancel();
					if (icon != null) [icon.style.opacity, icon.disabled] = [1, false];
				});
			}, 'Search edition on Discogs\n(Discogs API authorization required for embedded results)');
			addSearch('musicbrainz-edition-search', 'mb_logo', function(params, editionTR) {
				function openInNewWindow(background = false) {
					const url = new URL('search', mbOrigin);
					url.searchParams.set('method', 'advanced');
					url.searchParams.set('type', 'release');
					url.searchParams.set('query', '(' + queries.map(query => query.join(' AND '))
						.map(query => '(' + query + ')').join(' OR ') + ') AND ' + formats);
					GM_openInTab(url.href, background);
				}

				let queries = [ ], yearField = 'date:' + params.year;
				if (params.searchLabels && params.searchCatNos
						|| params.year > 0 && (/*params.searchLabels || */params.searchCatNos)) {
					const fields = [ ];
					if (params.searchLabels)
						fields.push(`(${params.searchLabels.map(label => 'label:"' + label + '"').join(' OR ')})`);
					if (params.searchCatNos || params.barcodes) fields.push(`(${(params.searchCatNos || [ ])
						.map(catNo => 'catno:"' + catNo + '"').concat((params.barcodes || [ ])
							.map(catNo => 'barcode:"' + catNo + '"')).join(' OR ')})`);
					fields.push(params.searchLabels && params.searchCatNos ? `(${yearField} OR (NOT date:*))` : yearField);
					queries.push(fields);
				}
				if (params.releaseTitle) {
					const release = `(${parseLanguages(params.releaseTitle).map(release => '(' + [
						`release:"${release}"`,
						`alias:"${release}"`,
					].join(' OR ') + ')').join(' OR ')})`;
					const artistMapper = artist =>
						`(${parseLanguages(artist, true).map(artist => `artistname:"${artist}" OR creditname:"${artist}"`).join(' OR ')})`;
					if (params.year > 0) queries.push(Array.isArray(params.artists) && ![7, 19].includes(params.releaseType) ? [
						release,
						'(' + params.artists.map(artistMapper).join(' OR ') + ')',
						`(${yearField} OR (NOT date:*))`,
					] : [release, yearField]);
					if (params.releaseType == 7) queries.push([release, 'secondarytype:compilation']);
					else if (params.releaseType == 19) queries.push([release, 'secondarytype:DJ-mix']);
					else if (Array.isArray(params.artists) && params.artists.length > 0)
						queries.push([release, params.artists.map(artistMapper).join(' AND ')]);
				}
				const formats = '(' + [
					'CD', 'CD-R', 'Enhanced CD', 'SHM-CD', 'Blu-spec CD', 'Copy Control CD',
					'HDCD', '8cm CD', 'Hybrid SACD (CD layer)', 'DualDisc (CD side)', 'DualDisc',
					'Hybrid SACD', 'DTS CD', 'HQCD', 'CD+G', '8cm CD+G', 'Mixed Mode CD', 'Minimax CD',
				].map(format => 'format:"' + format + '"').join(' OR ') + ' OR (NOT format:*))';
				if (queries.length <= 0) queries = null;
				if (!(editionTR instanceof HTMLElement) && queries != null) return openInNewWindow();
				const icon = editionTR.querySelector('span.musicbrainz-edition-search > svg');
				if (queries == null) {
					if (icon != null) icon.remove();
					return;
				}
				const haveResults = icon != null && Boolean(eval(icon.dataset.haveResults));
				if (autoOpenTab || haveResults) openInNewWindow(!haveResults);
				if (haveResults) return; else if (icon != null) if (icon.disabled) return; else {
					[icon.disabled, icon.dataset.haveResults] = [true, true];
					var animation = flashElement(icon);
				}
				(function doQuery(index = 0) {
					return index < queries.length ? mbApiRequest('release', {
						query: queries[index].concat(formats).join(' AND '),
						limit: 100,
					}).then(({releases}) => releases.length > 0 ? releases : doQuery(index + 1))
						: Promise.reject('Nothing found by any method');
				})().then(function(releases) {
					const [tr, td, table, thead, tbody] = createElements('tr', 'td', 'table', 'div', 'tbody');
					[tr.className, td.colSpan, thead.style, thead.style.minHeight] =
						['edition-search-results', 6, theadStyle, '1em'];
					releases.forEach(function(release, index) {
						const [tr, artist, title, releaseEvents, labels, catNos, barcode, groupSize, releasesWithId] =
							createElements('tr', 'td', 'td', 'td', 'td', 'td', 'td', 'td', 'td');
						tr.className = 'musicbrainz-release';
						if (release.quality == 'low') tr.style.opacity = 0.75;
						[releaseEvents, barcode].forEach(elem => { elem.style.whiteSpace = 'nowrap' });
						[groupSize, releasesWithId].forEach(elem => { elem.style.textAlign = 'right' });
						setMusicBrainzArtist(release, artist, false);
						setMusicBrainzTitle(release, title);
						setMusicBrainzReleaseEvents(release, releaseEvents, params.year);
						if (Array.isArray(release['label-info'])) {
							fillListRows(labels, release['label-info'].map(labelInfo =>
								labelInfo.label && labelInfo.label.name).filter(uniqueValues).map(label =>
									editionInfoMapper(label, undefined, params.labels)));
							fillListRows(catNos, release['label-info'].map(labelInfo => labelInfo['catalog-number'])
								.filter(uniqueValues).map(catNo => editionInfoMapper(undefined, catNo, undefined, params.catNos)));
						}
						if (release.barcode) {
							barcode.textContent = release.barcode;
							if (params.barcodes && params.barcodes.some(catalogueNumber =>
									sameBarcodes(catalogueNumber, release.barcode))) editionInfoMatchingStyle(barcode);
						}
						setMusicBrainzGroupSize(release, groupSize, releasesWithId);
						setMusicBrainzTooltip(release, tr);
						tr.append(artist, title, releaseEvents, labels, catNos, barcode, groupSize, releasesWithId);
						for (let cell of tr.cells) cell.style.fontSize = cell.style.backgroundColor = 'inherit';
						['artist', 'title', 'release-events', 'labels', 'cat-nos', 'barcode', 'editions-total', 'discids-total']
							.forEach((className, index) => { tr.cells[index].className = className });
						tbody.append(tr);
					});
					table.append(thead, tbody); td.append(thead, table); tr.append(td);
					addResultsFilter(thead, tbody, 5);
					editionTR.after(tr);
				}, function(reason) {
					if (icon == null) return;
					icon.style.fill = 'red';
					for (let path of icon.getElementsByTagName('path')) path.removeAttribute('fill');
					icon.insertAdjacentHTML('afterbegin', '<title>' + reason + '</title>');
				}).then(function() {
					if (animation) animation.cancel();
					if (icon != null) [icon.style.opacity, icon.style.cursor, icon.disabled] = [1, 'pointer', false];
				});
			}, 'Search edition on MusicBrainz');
		}
	}
	torrentDetails.dataset.torrentId = torrentId;
	const useCountryInTitle = GM_getValue('use_country_in_title', true);
	if (edition > 0) torrentDetails.dataset.editionGroup = edition;
	const container = document.createElement('span');
	container.style = 'display: inline-flex; flex-flow: row nowrap; column-gap: 2pt; justify-content: space-around;';
	linkBox.append(' ', container);
	const svgBulletHTML = color => `<svg height="0.8em" style="margin-right: 3pt;" viewBox="0 0 10 10"><circle fill="${color || ''}" cx="5" cy="5" r="5"></circle></svg>`;
	const releaseTitleNorm = title => title && [
		/\s+(?:EP|E\.\s?P\.|-\s+(?:EP|E\.\s?P\.|Single|Live))$/i,
		/\s+\((?:EP|E\.\s?P\.|Single|Live)\)$/i, /\s+\[(?:EP|E\.\s?P\.|Single|Live)\]$/i,
	].reduce((title, rx) => title.replace(rx, ''), title.trim());

	addLookup(svgCaption('mb_text', 'filter: saturate(30%) brightness(130%);', 'MusicBrainz'), function(evt) {
		function logScoreTest(testFn) {
			if (typeof testFn != 'function') throw 'Invalid argument';
			return logScoresCache && Array.isArray(logScoresCache[torrentId]) && logScoresCache[torrentId].some(testFn);
		}

		const target = evt.currentTarget;
		console.assert(target instanceof HTMLElement);
		const torrentId = parseInt(torrentDetails.dataset.torrentId);
		console.assert(torrentId > 0);
		if (evt.altKey) { // alternate lookup by CDDB ID
			if (target.disabled) return; else target.disabled = true;
			lookupByToc(torrentId, (tocEntries, discNdx, totalDiscs) => Promise.resolve(getCDDBiD(tocEntries))).then(function(discIds) {
				for (let discId of Array.from(discIds).reverse()) if (discId != null)
					GM_openInTab([mbOrigin, 'otherlookup', 'freedbid?other-lookup.freedbid=' + discId].join('/'), false);
			}).catch(reason => { [target.textContent, target.style.color] = [reason, 'red'] }).then(() => { target.disabled = false });
		} else if (Boolean(target.dataset.haveResponse)) {
			if ('releaseIds' in target.dataset) for (let id of JSON.parse(target.dataset.releaseIds).reverse())
				GM_openInTab([mbOrigin, 'release', id].join('/'), false);
			// GM_openInTab([mbOrigin, 'cdtoc', evt.shiftKey ? 'attach?toc=' + JSON.parse(target.dataset.toc).join(' ')
			// 	: target.dataset.discId].join('/'), false);
		} else {
			function getGID({document}) {
				for (let gid of document.body.querySelectorAll('script[type="application/json"]')) try {
					if ((gid = JSON.parse(gid.text).entity) && (gid = mbIdExtractor(gid.gid))) return gid;
				} catch(e) { console.warn(e, gid) }
				return Promise.reject('Incorrect response structure');
			}
			function notify(html, color = 'orange', length = 6) {
				if (!html) return;
				let div = document.body.querySelector('div.mb-notification'), animation;
				if (div == null) {
					div = document.createElement('div');
					div.className = 'mb-notification';
					div.style = `
position: sticky; margin: 0 auto; padding: 5pt; bottom: 0; left: 0; right: 0; text-align: center;
white-space: nowrap; overflow-x: clip; text-overflow: ellipsis;
font: normal 9pt "Noto Sans", sans-serif; color: white; background-color: #000b; box-shadow: 0 0 7pt 2pt #000b;
cursor: default; z-index: 10000001; opacity: 0;`;
					animation = [{ opacity: 0, color: 'white', transform: 'scaleX(0.5)' }];
					document.body.append(div);
				} else {
					animation = [{ color: 'white' }];
					if ('timer' in div.dataset) clearTimeout(parseInt(div.dataset.timer));
				}
				div.innerHTML = html;
				div.animate(animation.concat(
					{ offset: 0.03, opacity: 1, color: color, transform: 'scaleX(1)' },
					{ offset: 0.80, opacity: 1, color: color },
					{ offset: 1.00, opacity: 0 },
				), length * 1000);
				div.dataset.timer = setTimeout(elem => { elem.remove() }, length * 1000, div);
			}
			function getEntityFromCache(cacheName, entity, id, param) {
				if (!cacheName || !entity || !id) throw 'Invalid argument';
				const result = eval(`
					if (!${cacheName} && '${cacheName}' in sessionStorage) try {
						${cacheName} = JSON.parse(sessionStorage.getItem('${cacheName}'));
					} catch(e) {
						sessionStorage.removeItem('${cacheName}');
						console.warn(e);
					}
					if (!${cacheName}) ${cacheName} = { };
					if (!(entity in ${cacheName})) ${cacheName}[entity] = { };
					if (param) {
						if (!(param in ${cacheName}[entity])) ${cacheName}[entity][param] = { };
						${cacheName}[entity][param][id];
					} else ${cacheName}[entity][id];
				`);
				if (result) return Promise.resolve(result);
			}
			function mbLookupByDiscID(mbTOC, allowTOCLookup = true, anyMedia = false) {
				if (!Array.isArray(mbTOC) || mbTOC.length != mbTOC[1] - mbTOC[0] + 4)
					return Promise.reject('mbLookupByDiscID(…): missing or invalid TOC');
				const mbDiscID = mbComputeDiscID(mbTOC);
				const params = { inc: 'release-groups+artist-credits+labels+url-rels+series-rels+release-rels' };
				if (!mbDiscID || allowTOCLookup) params.toc = mbTOC.join(' ');
				if (anyMedia) params['media-format'] = 'all';
				return mbApiRequest('discid/' + (mbDiscID || '-'), params).then(function(result) {
					if (!Array.isArray(result.releases) || result.releases.length <= 0)
						return Promise.reject('MusicBrainz: no matches');
					console.log('MusicBrainz lookup by discId/TOC successfull:', mbDiscID, '/', params, 'releases:', result.releases);
					console.assert(!result.id || result.id == mbDiscID, 'mbLookupByDiscID ids mismatch', result.id, mbDiscID);
					return { mbDiscID: mbDiscID, mbTOC: mbTOC, attached: Boolean(result.id), releases: result.releases };
				});
			}
			function mbLookupById(entityType, browsingEntityType, mbid, inc = ['aliases']) {
				if (!entityType || !browsingEntityType || !mbid) throw 'Invalid argument';
				[entityType, browsingEntityType] = [entityType.toLowerCase(), browsingEntityType.toLowerCase()];
				inc = ['url-rels'].concat(Array.isArray(inc) ? inc : [ ]).join('+');
				const loadPage = (offset = 0) => mbApiRequest(entityType, {
					[browsingEntityType]: mbid,
					inc: inc,
					offset: offset,
					limit: 5000,
				}).then(function(response) {
					if (debugLogging && response[entityType + '-offset'] >= 5000 && response[entityType + '-offset'] % 1000 == 0)
						console.info('mbLookupById offset for %s %s: %d/%d', browsingEntityType, mbid,
							response[entityType + '-offset'], response[entityType + '-count']);
					let results = response[(entityType + 's').replace(/s(?=s$)/, '')];
					if (Array.isArray(results)) offset = response[entityType + '-offset'] + results.length; else return [ ];
					results = results.filter(result => !result.video);
					return offset < response[entityType + '-count'] ? loadPage(offset)
						.then(Array.prototype.concat.bind(results)) : results;
				});
				return loadPage();
			}
			function sameTitleMapper(entry, title, cmpFn = sameStringValues, normFn = str => str.trim()) {
				const compareTo = root => (root.title || root.name) && cmpFn(normFn(root.title || root.name), normFn(title));
				return entry && title && (compareTo(entry) || entry.aliases && entry.aliases.some(compareTo));
			}
			function trackTitleNorm(title, formData) {
				if (!title) return title;
				const rxBonus = /\b(?:bonus(?:\s+tracks?)?|extra tracks?)\b/.source;
				title = [
					[new RegExp('\\s+\\(' + rxBonus + '\\)$', 'i'), ''],
					[new RegExp('\\s+\\[' + rxBonus + '\\]$', 'i'), ''],
				].reduce((title, subst) => title.replace(...subst), title);
				const rxLive = /\b(?:live|(?:en|ao) (?:vivo|directo?))\b/.source;
				// if (formData && formData.getAll('type').includes('Live') && ![
				// 	'\\s+\\(' + rxLive + '[^\\(\\)]*\\)$',
				// 	'\\s+\\[' + rxLive + '[^\\[\\]]*\\]$',
				// 	'\\s+-\\s+' + rxLive + '$',
				// ].some(rx => new RegExp(rx, 'i').test(title))) title += ' (live)';
				return title;
			}
			function instrumentResolver(creditType) {
				if (creditType) creditType = {
					'Keyboards': 'Keyboard',
				}[creditType] || creditType; else return Promise.reject('Credit type is missing');
				if (['Programming'].includes(creditType)) return Promise.reject('Explicitly not an instrument');
				return mbApiRequest('instrument', {
					query: `instrument:"${creditType}" OR alias:"${creditType}"`,
				}).then(results => results.instruments).then(function(instruments) {
					if (debugLogging) console.debug('Instruments found for "%s":', creditType, instruments);
					if (instruments.length > 1) instruments = instruments.filter(instrument =>
						sameTitleMapper(instrument, creditType, similarStringValues));
					return instruments.length > 0 ? [{ id: instruments[0].id }]
						: Promise.reject('Unresolved credit type (' + creditType + ')');
				});
			}
			function guessTextRepresentation(formData, literals) {
				if (!formData || typeof formData != 'object' || !literals || typeof literals != 'object')
					throw 'Invalid argument';
				const language = detectLanguage(literals);
				if (language) {
					formData.set('language', language);
					console.log('Guessed language from charset analysis:', language);
				}
				//if (!formData.has('language')) formData.set('language', 'mul');
				const script = detectScript(literals);
				if (script) {
					formData.set('script', script);
					console.log('Guessed script from charset analysis:', script);
				}
				//if (!formData.has('script')) formData.set('script', 'Qaaa');
			}
			function getMediaFingerprint(session) {
				const tocEntries = getTocEntries(session), digests = getTrackDetails(session);
				let fingerprint = ` Track# │  Start │    End │    CRC32 │     ARv1 │     ARv2 │ Peak
──────────────────────────────────────────────────────────────────────`;
				for (let trackIndex = 0; trackIndex < tocEntries.length; ++trackIndex) {
					const getTOCDetail = (key, width = 6) => tocEntries[trackIndex][key].toString().padStart(width);
					const getTrackDetail = (key, callback, width = 8) => Array.isArray(digests[key])
						&& digests[key].length == tocEntries.length && digests[key][trackIndex] != null ?
							callback(digests[key][trackIndex]) : width > 0 ? ' '.repeat(width) : '';
					const getTrackDigest = (key, width = 8) => !logScoreTest(logStatus => 'deductions' in logStatus
							&& logStatus.deductions.some(RegExp.prototype.test.bind(/\b(?:CRC calculations)\b/i))) ?
						getTrackDetail(key, value => value.toString(16).toUpperCase().padStart(width, '0'), 8) : '<invalid setup>';
					fingerprint += '\n' + [
						getTOCDetail('trackNumber'), getTOCDetail('startSector'), getTOCDetail('endSector'),
						getTrackDigest('crc32'), getTrackDigest('arv1'), getTrackDigest('arv2'),
						getTrackDetail('peak', value => (value[0] / 1000).toFixed(value[1])),
						//getTrackDetail('preGap', value => value.toString().padStart(6)),
					].map(column => ' ' + column + ' ').join('│').trimRight();
				}
				return fingerprint;
			}
			function seedFromTorrent(formData, torrent) {
				function addArtist(artist, index, artists) {
					formData.set(`artist_credit.names.${++artistIndex}.name`, artist.name);
					formData.set(`artist_credit.names.${artistIndex}.artist.name`, artist.name);
					if (index < artists.length - 1) formData.set(`artist_credit.names.${artistIndex}.join_phrase`,
						index < artists.length - 2 ? ', ' : ' & ');
				}

				if (!formData || typeof formData != 'object') throw 'Invalid argument';
				if (torrent.group.name) formData.set('name', decodeHTML(torrent.group.name));
				if (torrent.torrent.remasterTitle)
					formData.set('comment', decodeHTML(torrent.torrent.remasterTitle)/*.toLowerCase()*/);
				if (torrent.group.releaseType != 21) {
					formData.set('type', { 5: 'EP', 9: 'Single' }[torrent.group.releaseType] || 'Album');
					switch (torrent.group.releaseType) {
						case 3: formData.append('type', 'Soundtrack'); break;
						case 6: case 7: formData.append('type', 'Compilation'); break;
						case 11: /*case 14: */case 18: formData.append('type', 'Live'); break;
						case 13: formData.append('type', 'Remix'); break;
						case 15: formData.append('type', 'Interview'); break;
						case 16: formData.append('type', 'Mixtape/Street'); break;
						case 17: formData.append('type', 'Demo'); break;
						case 19: formData.append('type', 'DJ-mix'); /*formData.append('type', 'Compilation');*/ break;
					}
				}
				let artistIndex = -1;
				if (torrent.group.releaseType == 19 && torrent.group.musicInfo
						&& torrent.group.musicInfo.dj && torrent.group.musicInfo.dj.length > 0)
					torrent.group.musicInfo.dj.forEach(addArtist);
				else if ([7, 19].includes(torrent.group.releaseType)) formData.set('artist_credit.names.0.mbid', mbidVA);
				else if (torrent.group.musicInfo)
					for (let role of ['dj', 'artists']) if (artistIndex < 0)
						torrent.group.musicInfo[role].forEach(addArtist);
				formData.set('status', [14, 18].includes(torrent.group.releaseType) ? 'bootleg' : 'official');
				if (torrent.torrent.remasterYear) formData.set('events.0.date.year', torrent.torrent.remasterYear);
				if (torrent.torrent.remasterRecordLabel) {
					const label = decodeHTML(torrent.torrent.remasterRecordLabel);
					if (rxNoLabel.test(label)) formData.set('labels.0.mbid', '157afde4-4bf5-4039-8ad2-5a15acc85176');
					else formData.set('labels.0.name', label);
				}
				if (torrent.torrent.remasterCatalogueNumber) {
					const catNo = decodeHTML(torrent.torrent.remasterCatalogueNumber);
					formData.set('labels.0.catalog_number', rxNoCatno.test(catNo) ? '[none]' : catNo);
					let barcode = catNo.split(' / ').map(catNo => catNo.replace(/\W+/g, ''));
					if (barcode = barcode.find(RegExp.prototype.test.bind(/^\d{10,13}$/))) formData.set('barcode', barcode);
				}
				if (GM_getValue('insert_torrent_reference', false)) formData.set('edit_note', ((formData.get('edit_note') || '') + `
Seeded from torrent ${document.location.origin}/torrents.php?torrentid=${torrent.torrent.id} edition info`).trimLeft());
			}
			function seedFromTOCs(formData, mbTOCs) {
				if (!formData || typeof formData != 'object') throw 'Invalid argument';
				for (let discIndex = 0; discIndex < mbTOCs.length; ++discIndex) {
					formData.set(`mediums.${discIndex}.format`, 'CD');
					formData.set(`mediums.${discIndex}.toc`, mbTOCs[discIndex].join(' '));
				}
				let editNote = (formData.get('edit_note') || '') + '\nSeeded from EAC/XLD ripping ' +
					(mbTOCs.length > 1 ? 'logs' : 'log').trimLeft();
				return getSessions(torrentId).catch(console.error).then(function(sessions) {
					if (GM_getValue('mb_seed_with_fingerprints', false) && Array.isArray(sessions) && sessions.length > 0)
						editNote += '\n\n' + (sessions.length > 1 ? 'Media fingerprints' : 'Medium fingerprint') + ' :\n' +
							sessions.map(getMediaFingerprint).join('\n') + '\n';
					formData.set('edit_note', editNote);
					return formData;
				});
			}
			function seedFromDiscogs(formData, discogsId, params, cdLengths) {
				if (!formData || typeof formData != 'object' || !(discogsId > 0)) throw 'Invalid argument';
				params = Object.assign({
					tracklist: true,
					mbidLookup: true, recordingsLookup: true, lookupArtistsByRecording: true, rgLookup: true,
					searchSize: GM_getValue('mbid_search_size', 30),
					languageIdentifier: true,
					openInconsistent: GM_getValue('open_inconsistent', true),
					assignUncertain: GM_getValue('assign_uncertain', false),
					createEntries: GM_getValue('mb_create_entries', 2),
					createUncertainArtists: GM_getValue('create_uncertain_artists', false),
					extendedMetadata: false, rgRelations: false, releaseRelations: false,
					recordingRelations: false, workRelations: false,
				}, params);
				return dcApiRequest('releases/' + discogsId).then(function(release) {
					function addLookupEntry(entity, entry, context) {
						console.assert(entity && entry && context);
						if (!entity || !entry || !context) throw 'Invalid argument';
						console.assert(entry.id > 0 && entry.name, entry);
						if (!(entry.id > 0) || !entry.name) return;
						if (!(entity in lookupIndexes)) lookupIndexes[entity] = { };
						if (!(entry.id in lookupIndexes[entity])) {
							lookupIndexes[entity][entry.id] = { name: entry.name, contexts: [context] };
							if (entry.anv) lookupIndexes[entity][entry.id].anv = entry.anv;
						} else if (!lookupIndexes[entity][entry.id].contexts.includes(context))
							lookupIndexes[entity][entry.id].contexts.push(context);
					}
					function sameArtists(...artists) {
						const props = ['id', 'role'];
						return artists.length > 0 && artists.every(artist1 =>
							artists.every(artist2 => props.every(prop => artist2[prop] == artist1[prop])));
					}
					function resolveArtists(...roots) {
						if (!roots.includes(release)) roots.unshift(release);
						const artists = roots.map(root => root.artists).reverse()
							.find(artists => Array.isArray(artists) && artists.length > 0);
						if (!artists) return null; else for (let index = artists.length; index > 0; --index)
							if (index > 1 && artists[index - 2].id == artists[index - 1].id)
								artists[index - 2].join = artists[index - 1].join;
						const extraArtists = roots.map(root => root.extraartists).reduce(function(extraArtists, rootExtraArtists) {
							if (rootExtraArtists) rootExtraArtists = rootExtraArtists.filter(function(extraArtist) {
								if (!extraArtist.tracks) return true;
								const tracks = extraArtist.tracks.split(',').map(track => track.trim());
								return roots.some(position => (position = position.position) && tracks.some(function(track) {
									let range = /^(\S+)(?:\s*to\s*|\s+(?:-)\s+)(\S+)$/i.exec(track);
									if (range == null) return trackPosMapper(position) == trackPosMapper(track);
									range = [position].concat(range.slice(1)).map(trackPosMapper);
									return range[0].localeCompare(range[1]) >= 0 && range[0].localeCompare(range[2]) <= 0;
								}));
							}); else return extraArtists;
							extraArtists = extraArtists.filter(extraArtist =>
								!rootExtraArtists.map(extraArtist => extraArtist.role).includes(extraArtist.role));
							return extraArtists.concat(rootExtraArtists);
						}, [ ]);
						const unique = arr => arr.filter((item1, index, array) =>
							array.findIndex(item2 => item2.id == item1.id && item2.role == item1.role) == index);
						return {
							artists: unique(artists),
							extraartists: extraArtists.length > 0 ? unique(extraArtists) : undefined,
						};
					}
					function sameTrackArtists(...roots) {
						if (roots.length < 2 && !roots.includes(release)) roots.unshift(release);
						if (!roots.some(Boolean)) return true; else if (!roots.every(Boolean)) return false;
						const props = ['artists', 'extraartists'];
						return props.every(prop => !roots.some(root1 => root1[prop] && !root1[prop].every(artist1 =>
							roots.every(root2 => root2[prop] && root2[prop].some(artist2 => sameArtists(artist1, artist2))))));
					}
					function trackMainArtists(track) {
						let artists = track.trackArtists;
						if (artists && (artists = Array.prototype.concat.apply([ ], artists.map(artists => artists.artists))).length > 0)
							return artists.filter((artist1, index, artists) =>
								artists.findIndex(artist2 => sameArtists(artist2, artist1)) == index);
						return (artists = resolveArtists(track)) != null ? artists.artists : null;
					}
					function seedArtists(root, prefix, offset = 0) {
						function seedArtist(artist, index, array, prefix, offset = 0) {
							prefix = `${prefix || ''}artist_credit.names.${offset + index}`;
							formData.set(`${prefix}.artist.name`, capitalizeName(stripDiscogsNameVersion(artist.name)));
							if (artist.anv) formData.set(`${prefix}.name`, capitalizeName(artist.anv));
							else formData.delete(`${prefix}.name`);
							if (artist.join) formData.set(`${prefix}.join_phrase`, fmtJoinPhrase(artist.join));
							else if (index < array.length - 1)
								formData.set(`${prefix}.join_phrase`, index < array.length - 2 ? ', ' : ' & ');
							//else formData.delete(`${prefix}.join_phrase`);
							addLookupEntry('artist', artist, prefix);
						}
						function processExtraArtists(extraArtists, join) {
							if (!extraArtists || extraArtists.length <= 0 || !join) return;
							if (offset > 0) formData.set(`${prefix || ''}artist_credit.names.${offset - 1}.join_phrase`,
								fmtJoinPhrase(join));
							extraArtists.forEach((extraArtist, index, array) =>
								{ seedArtist(extraArtist, index, array, prefix, offset) });
							offset += extraArtists.length;
						}

						if (!root) throw 'Invalid argument';
						if (!Array.isArray(root.artists) || root.artists.length <= 0) return offset;
						root.artists.forEach((artist, index, array) => { seedArtist(artist, index, array, prefix, offset) });
						offset += root.artists.length;
						if (!root.extraartists) return offset;
						const byRole = role => root.extraartists.filter(extraArtist =>
							getRoles(extraArtist).some(_role => _role.toLowerCase() == role.toLowerCase()))
								.filter((artist1, index, array) => array.findIndex(artist2 => artist2.id == artist1.id) == index);
						let extraArtists = byRole('Featuring').filter(extraArtist =>
							!root.artists.some(artist => artist.id == extraArtist.id));
						processExtraArtists(extraArtists, 'feat.');
						//processExtraArtists(extraArtists = byRole('Remix'), 'remixed by');
						return offset;
					}
					function addUrlRef(url, linkType) {
						formData.set(`urls.${++urlRelIndex}.url`, url);
						if (linkType != undefined) formData.set(`urls.${urlRelIndex}.link_type`, linkType);
					}
					function calcTime(tracks) {
						const duration = tracks.reduce(function(length, track) {
							const duration = /\b(\d+):(\d+)\b/.exec(track.duration);
							return duration != null ? length + parseInt(duration[1]) * 60 + parseInt(duration[2]) : undefined;
						}, 0);
						return duration > 0 ? Math.floor(duration / 60).toString() + ':' +
							(duration % 60).toString().padStart(2, '0') : undefined;
					}
					function romanToArabic(input) {
						const romans = { I: 1, V: 5, X: 10, L: 50, C: 100, D: 500, M: 1000 };
						return [...input.trim().toUpperCase()].reduce((previous, current, index, array) =>
							romans[array[index + 1]] > romans[current] ? previous - romans[current] : previous + romans[current], 0);
					}
					function layoutMatch(media) {
						if (!media) return -Infinity; else if (!Array.isArray(cdLengths) || cdLengths.length <= 0) return 0;
						if ((media = media.filter(isCD)).length != cdLengths.length) return -2;
						if (media.every((medium, mediumIndex) => medium.tracks.length == cdLengths[mediumIndex])) return 3;
						if (media.every((medium, mediumIndex) => medium.tracks.length == cdLengths[mediumIndex]
								|| medium.format == 'Enhanced CD' && medium.tracks.length > cdLengths[mediumIndex])) return 2;
						if (cdLengths.length > 1) {
							const index = { };
							for (let key of cdLengths) if (!(key in index))
								index[key] = media.filter(medium => medium.tracks.length == key).length;
							if (Object.keys(index).every(key1 => index[key1] == cdLengths.filter(key2 =>
									key2 == parseInt(key1)).length)) {
								notify('Tracks layout matched to reordered logs', 'orangered');
								return 1;
							}
						}
						return -1;
					}
					function parseTracklist(trackParser = rxParsingMethods[0], collapseSubtracks = false) {
						if (!(trackParser instanceof RegExp)) throw 'Invalid argument';
						if (!release.tracklist) return null;
						const media = [ ], romanNumbers = release.tracklist.every(track => romanToArabic(track.position) > 0);
						let lastMediumId, heading;
						(function addTracks(root, titles) {
							if (Array.isArray(root)) root.forEach(function(track) {
								if (track.type_ == 'index' && !collapseSubtracks)
									return addTracks(track.sub_tracks, (titles || [ ]).concat(track.title));
								if (track.type_ == 'heading') return heading = track.title != '-' && [
									[/:+$/, ''],
									[/^(?:Bonus(?:\s+Tracks?)?)(?:(?:\s*-|:)\s+)?/, ''],
								].reduce((heading, subst) => heading.replace(...subst), track.title) || undefined;
								const parsedTrack = trackParser.exec(track.position.trim());
								let [mediumFormat, mediumId, number] = parsedTrack != null ?
									parsedTrack.slice(1) : [undefined, undefined, track.position.trim()];
								if (!mediumFormat && !romanNumbers && /^[A-Z]\d*$/.test(number)) mediumFormat = 'LP';
								mediumId = (mediumFormat || '') + (mediumId || '');
								if (lastMediumId == undefined || !titles && mediumId !== lastMediumId) {
									let title;
									for (let subst of [
										[/^(?:B(?:R?D|R))$/, 'Blu-ray'],
										[/^(?:LP)$/, 'Vinyl'],
										[/^(FLAC|MP[234]|AAC|M4[AB]|OGG|Vorbis|Opus)$/i, 'Digital Media'],
									]) if (subst[0].test(mediumFormat)) {
										mediumFormat = subst[1];
										if (RegExp.$1) title = RegExp.$1;
									}
									if (mediumFormat == 'CD') mediumFormat = defaultFormat;
									media.push({ format: mediumFormat || defaultFormat, name: title, tracks: [ ] });
									lastMediumId = mediumId;
								}
								let name = track.title, duration = track.duration, trackArtists;
								if (track.type_ == 'index' && track.sub_tracks && track.sub_tracks.length > 0) {
									name += `: ${track.sub_tracks.map(track => track.title.trim()).filter(Boolean).join(' / ')}`;
									trackArtists = (trackArtists = track.sub_tracks.map(function(subTrack) {
										const trackArtists = resolveArtists(track, subTrack);
										return !sameTrackArtists(trackArtists) ? trackArtists : null;
									}).filter(Boolean)).length > 0 ? trackArtists.filter((artists1, index, array) =>
										array.findIndex(artists2 => sameTrackArtists(artists2, artists1)) == index) : undefined;
									if (!duration) duration = calcTime(track.sub_tracks);
								}
								media[media.length - 1].tracks.push({
									number: number, position: track.position,
									heading: heading, name: name, titles: titles, length: duration,
									artists: track.artists, extraartists: track.extraartists, trackArtists: trackArtists,
								});
							});
						})(release.tracklist);
						return media;
					}
					function groupTracks(media, rxExtractor) {
						if (layoutMatch(media = media.map(function tracksConsolidation(medium, index) {
							const tracks = { };
							for (let track of medium.tracks) {
								if (rxExtractor instanceof RegExp) {
									var trackNum = rxExtractor.exec(track.number);
									console.assert(trackNum != null, track.number);
									trackNum = trackNum != null ? trackNum[0] : track.number;
								} else trackNum = isCD(medium) ? index + 1 : track.number;
								if (!(trackNum in tracks)) tracks[trackNum] = [ ];
								tracks[trackNum].push(track);
							}
							medium.tracks = Object.keys(tracks).sort(function(...positions) {
								positions = positions.map(trackPosMapper);
								return positions[0].localeCompare(positions[1]);
							}).map(function(trackNo) {
								const trackArtists = tracks[trackNo].map(function(track) {
									const trackArtists = resolveArtists(track);
									return !sameTrackArtists(trackArtists) ? trackArtists : null;
								}).filter(Boolean);
								// if (debugLogging && tracks[trackNo].length > 1) alert(`Medium tracks merged (${trackNo}/${trackArtists.length})`);
								return {
									number: trackNo,
									name: tracks[trackNo].map(track => track.name.trim()).filter(Boolean).join(' / '),
									heading: tracks[trackNo][0].heading, titles: tracks[trackNo][0].titles,
									trackArtists: trackArtists.length > 0 ? trackArtists.filter((artists1, index, array) =>
										array.findIndex(artists2 => sameTrackArtists(artists2, artists1)) == index) : undefined,
									length: calcTime(tracks[trackNo]),
								};
							});
							return medium;
						})) > 0) return media;
					}
					function processFormats(mappers, applyFn) {
						if (!mappers || typeof mappers != 'object') throw 'Invalid argument';
						const regExp = key => new RegExp('^(?:' + key + ')$', 'i');
						if (typeof applyFn == 'function') for (let key in mappers) if (mappers[key] != undefined
								&& descriptors.some(RegExp.prototype.test.bind(regExp(key)))) applyFn(mappers[key]);
						descriptors = descriptors.filter(descriptor =>
							!Object.keys(mappers).some(key => regExp(key).test(descriptor)));
					}
					function openInconsistent(entity, discogsId, mbids, subpage) {
						Array.from(mbids).reverse().forEach(mbid =>
							{ GM_openInTab([mbOrigin, entity, mbid, subpage].filter(Boolean).join('/'), true) });
						GM_openInTab([dcOrigin, discogsEntity(entity), discogsId].join('/') + '#discography_wrapper', true);
					}
					function saveToCache(entity, discogsId, mbid) {
						discogsBindingsCache[entity][discogsId] = mbid;
						GM_setValue('discogs_to_mb_bindings', discogsBindingsCache);
					}
					function getCachedMBID(entity, discogsId, mbEntity = entity) {
						if (!discogsBindingsCache) {
							const timeStamp = Date.now();
							if (!(discogsBindingsCache = GM_getValue('discogs_to_mb_bindings'))) discogsBindingsCache = {
								artist: {
									194: mbidVA,
									598667: '9be7f096-97ec-4615-8957-8d40b5dcbc41', // trad.
								}, // VA
								label: {
									750: '49b58bdb-3d74-40c6-956a-4c4b46115c9c', // Virgin
									895: '1ca5ed29-e00b-4ea5-b817-0bcca0e04946', // RCA
									1003: null, // BMG ambiguous
									1818: '157afde4-4bf5-4039-8ad2-5a15acc85176', // no label
									1866: '011d1192-6f65-45bd-85c4-0400dd45693e', // Columbia
									2345: '3730c0ea-3dc2-45c3-ac5c-9d482921ea51', // Warner
									5320: 'f18f3b31-8263-4de3-966a-fda317492d3d', // Decca
									5870: null, // Metronome ambiguous
									26126: 'c029628b-6633-439e-bcee-ed02e8a338f7', // EMI
									51167: null, // Rough Trade ambiguous
									108701: '7c439400-a83c-48bc-9042-2041711c9599', // Virgin JP
								},
								series: {
									77074: '713c4a95-6616-442b-9cf6-14e1ddfd5946', // Blue Note Records => Blue Note
								},
							}; else console.info('Discogs to MB bindings cache loaded:', Object.keys(discogsBindingsCache)
								.map(key => `${Object.keys(discogsBindingsCache[key]).length} ${(key + 's').replace(/s(?=s$)/, '')}`).join(', '));
							GM_addValueChangeListener('discogs_to_mb_bindings',
								(name, oldVal, newVal, remote) => { if (remote) discogsBindingsCache = newVal });
						}
						if (!(entity in discogsBindingsCache)) discogsBindingsCache[entity] = { };
						if (!(discogsId in discogsBindingsCache[entity])) return Promise.reject('Not cached');
						if (!rxMBID.test(discogsBindingsCache[entity][discogsId])) return Promise.resolve(null);
						return globalXHR(`${mbOrigin}/${entity}/${discogsBindingsCache[entity][discogsId]}`, {
							method: 'HEAD', redirect: 'follow', anonymous: true,
						}).then(function(response) {
							if (response.status < 200 || response.status >= 400) return Promise.reject(response.statusText);
							response = mbIdExtractor(response.finalUrl, mbEntity);
							if (!response) return Promise.reject('Cached check failed');
							console.log('Entity binding for', entity, discogsId, 'got from cache');
							discogsName(entity, discogsId).then(name =>
								{ notify(`MBID for ${entity} ${name} got from cache`, 'sandybrown') });
							if (response != discogsBindingsCache[entity][discogsId]) saveToCache(entity, discogsId, response); //return Promise.reject('Location changed');
							return response;
						});
					}
					function searchQueryBuilder(entity, discogsId) {
						function addField(field, expr) {
							if (!(field in query)) query[field] = [ ];
							query[field].push(expr);
						}

						if (!(entity in lookupIndexes) || !(discogsId in lookupIndexes[entity])) return;
						const query = { }, name = stripDiscogsNameVersion(lookupIndexes[entity][discogsId].name);
						for (let field of [entity, 'alias', 'sortname']) addField(field, '"' + name + '"');
						switch (entity) {
							case 'label': {
								const bareName = labelMapper(name.replace(...rxBareLabel));
								for (let field of [entity, 'alias']) addField(field, '"' + bareName + '"');
								break;
							}
						}
						if (lookupIndexes[entity][discogsId].anv && lookupIndexes[entity][discogsId].anv != name)
							addField('alias', '"' + lookupIndexes[entity][discogsId].anv + '"');
						return Object.keys(query).map(field => query[field].map(expr => `${field}:(${expr})`).join(' OR ')).join(' OR ');
					}
					function findMBIDByCommonTitles(entity, discogsId, mbids) {
						function getReleases(mbid, params) {
							const safeErrorHandler = reason => (console.warn(reason), null);
							const workers = [mbApiRequest(entity + '/' + mbid, { inc: 'aliases+release-rels+release-group-rels' }).then(function({relations}) {
								if (!relations) throw `Assertion failed: relations missing for ${entity} ${mbid}`;
								return relations.filter(relation => ['release_group', 'release'].includes(relation['target-type']))
									.map(relation => Object.assign({
										type: relation['target-type'],
										relationType: relation.type,
									}, relation[relation['target-type']])).filter((r1, n, a) =>
										a.findIndex(r2 => ['type', 'id'].every(prop => r2[prop] == r1[prop])) == n);
							}).catch(safeErrorHandler)];
							if (params) Array.prototype.unshift.apply(workers, params.map(param =>
								mbLookupById('release', param, mbid, ['aliases', 'release-groups', 'url-rels', 'release-group-level-rels'])
									.then(releases => releases.map(release => Object.assign({ type: 'release', relationType: param }, release)), safeErrorHandler)));
							return Promise.all(workers).then(results => (results = Array.prototype.concat.apply([ ],
								results.filter(Boolean))).length > 0 ? results : null);
						}

						if (!entity || !(discogsId > 0) || !Array.isArray(mbids)) return Promise.reject('Invalid argument');
						if (mbids.length <= 0) return Promise.reject('No MusicBrainz entries');
						const getDiscogsArtistReleases = (page = 1) => dcApiRequest(`${discogsEntity(entity)}s/${discogsId}/releases`, {
							page: page,
							per_page: 500,
						}).then(function(response) {
							if (debugLogging && response.pagination.page > 1 && response.pagination.page % 50 == 0)
								console.info('getDiscogsArtistReleases page %d/%d', response.pagination.page, response.pagination.pages);
							return response.pagination.page < response.pagination.pages ?
								getDiscogsArtistReleases(response.pagination.page + 1)
									.then(releases => response.releases.concat(releases)) : response.releases;
						});
						const dcReleasesWorker = getDiscogsArtistReleases();
						const lookupMethods = [{
							worker: {
								artist: mbid => getReleases(mbid, ['artist', 'track_artist']),
								label: mbid => getReleases(mbid, [entity]),
								series: mbid => getReleases(mbid),
							}[entity],
							resolver: function(dcReleases, results) {
								function openUncertain() {
									GM_openInTab([mbOrigin, entity, mbids[hiIndex], 'releases'].join('/'), true);
									GM_openInTab([dcOrigin, discogsEntity(entity), discogsId].join('/'), true);
								}

								const mutualScores = results.map(results => results ? results.reduce(function(score, result) {
									let releases = Array.prototype.concat.apply([ ], getDiscogsRels(result).map(discogsId =>
										dcReleases.filter(dcRelease => dcRelease.type == 'release' && dcRelease.id == discogsId)));
									if (result['release-group']) Array.prototype.push.apply(releases,
										getDiscogsRels(result['release-group'], 'master').map(discogsId =>
											dcReleases.filter(dcRelease => dcRelease.type == 'master' && dcRelease.id == discogsId)));
									if (releases.length <= 0 && (releases = dcReleases.filter(function(dcRelease) {
										if (!(dcRelease.year > 0)) return false;
										const releaseYear = result.type == 'release' ? getReleaseYear(result.date) : NaN;
										const rgYear = result.type == 'release' && result['release-group']
												&& getReleaseYear(result['release-group']['first-release-date'])
											|| result.type == 'release_group' && getReleaseYear(result['first-release-date']) || NaN;
										const strongYearMatch = dcRelease.type == 'release' && dcRelease.year == releaseYear
											|| dcRelease.type == 'master' && dcRelease.year == rgYear;
										const weakYearMatch = dcRelease.type == 'release' && dcRelease.year >= rgYear
											|| dcRelease.type == 'master' && dcRelease.year <= rgYear;
										return strongYearMatch && sameTitleMapper(result, dcRelease.title, sameStringValues, releaseTitleNorm)
											|| weakYearMatch && sameTitleMapper(result, dcRelease.title, sameStringValues);
									})).length <= 0) return score;
									if (debugLogging) console.debug('Found matching releases:', result, releases);
									return score + (result.relationType == 'track_artist'
										|| releases.every(release => release.role == 'TrackAppearance') ? 1/2 : 1);
								}, 0) : 0);
								const hiScore = Math.max(...mutualScores);
								if (debugLogging) console.debug('Common titles lookup method #1: Entity:', entity,
									'Discogs ID:', discogsId, 'MBIDs:', mbids, 'Scores:', mutualScores, 'HiScore:', hiScore);
								if (!(hiScore > 0)) return Promise.reject('No matches by common releases');
								const hiIndex = mutualScores.indexOf(hiScore);
								if (!(hiScore >= 1)) if (params.assignUncertain) openUncertain();
									else return Promise.reject('Not found by common releases');
								const dataSize = Math.min(dcReleases.length, results[hiIndex].length);
								if (!(hiScore * 50 >= dataSize)) if (!params.assignUncertain)
									return Promise.reject('matched by common releases with too low match rate');
								else if (hiScore >= 1) openUncertain();
								console.log('Entity binding found by having score %f:\n%s\n%s',
									hiScore, [dcOrigin, discogsEntity(entity), discogsId].join('/') + '#' + discogsEntity(entity),
									[mbOrigin, entity, mbids[hiIndex], 'releases'].join('/'));
								if (mutualScores.filter(score => score > 0).length > 1) {
									console.log('Matches by more entities:', mutualScores.map((score, index) =>
										score > 0 && [mbOrigin, entity, mbids[index], 'releases'].join('/') + ' (' + score + ')').filter(Boolean));
									if (mutualScores.reduce((sum, score) => sum + score, 0) >= hiScore * 1.5)
										return Promise.reject('Ambiguity (releases)');
									beep.play();
									if (params.openInconsistent) openInconsistent(entity, discogsId,
										mutualScores.map((score, index) => score > 0 && mbids[index]).filter(Boolean), 'releases');
								}
								discogsName(entity, discogsId).then(name =>
									{ notify(`MBID for ${entity} ${name} found by score <b>${hiScore.toFixed(1)}</b> out of ${dataSize} release(s)`, 'gold') });
								saveToCache(entity, discogsId, mbids[hiIndex]);
								return mbids[hiIndex];
							},
						}, {
							worker: { artist: getArtistRecordings }[entity],
							resolver: function(dcReleases, recordings) {
								function openUncertain() {
									GM_openInTab([mbOrigin, entity, mbids[hiIndex], 'recordings'].join('/'), true);
									GM_openInTab([dcOrigin, discogsEntity(entity), discogsId].join('/'), true);
								}

								dcReleases = dcReleases.filter(dcRelease => dcRelease.trackinfo);
								const mutualScores = recordings.map(recordings => recordings ? recordings.reduce(function(score, recording) {
									if (!recording) return score;
									let releases = dcReleases.filter(dcRelease =>
										sameTitleMapper(recording, dcRelease.trackinfo, sameStringValues, title => title && [
											/\s+\((?:(?:feat(?:\b|\.|uring)|ft\.).+|.+\b(?:re)?mix|live|(?:en|ao) (vivo|directo?))\)$/i,
										].reduce((str, rx) => str.replace(rx, ''), title.trim())));
									if (releases.length <= 0) return score;
									if (debugLogging) console.debug('Found matching releases:', recording, releases);
									return score + 0.5 + (releases.length - 1) * 0.25;
								}, 0) : 0);
								const hiScore = Math.max(...mutualScores);
								if (debugLogging) console.debug('Common titles lookup method #2: Entity:', entity,
									'Discogs ID:', discogsId, 'MBIDs:', mbids, 'Scores:', mutualScores, 'HiScore:', hiScore);
								if (!(hiScore > 0)) return Promise.reject('No matches by common recordings');
								const hiIndex = mutualScores.indexOf(hiScore);
								if (!(hiScore >= 1)) if (params.assignUncertain) openUncertain();
									else return Promise.reject('Not found by common recordings');
								const dataSize = Math.min(dcReleases.length, recordings[hiIndex].length);
								if (!(hiScore * 50 >= dataSize)) if (!params.assignUncertain)
									return Promise.reject('Matched by common recordings with too low score');
								else if (hiScore >= 1) openUncertain();
								console.log('Entity binding found by having score %f:\n%s\n%s',
									hiScore, [dcOrigin, discogsEntity(entity), discogsId].join('/') + '#' + discogsEntity(entity),
									[mbOrigin, entity, mbids[hiIndex], 'recordings'].join('/'));
								if (mutualScores.filter(score => score > 0).length > 1) {
									console.log('Matches by more entities:', mutualScores.map((score, index) =>
										score > 0 && [mbOrigin, entity, mbids[index], 'recordings'].join('/') + ' (' + score + ')').filter(Boolean));
									if (mutualScores.reduce((sum, score) => sum + score, 0) >= hiScore * 1.5)
										return Promise.reject('Ambiguity (recordings)');
									beep.play();
									if (params.openInconsistent) openInconsistent(entity, discogsId,
										mutualScores.map((score, index) => score > 0 && mbids[index]).filter(Boolean), 'recordings');
								}
								discogsName(entity, discogsId).then(name =>
									{ notify(`MBID for ${entity} ${name} found by score <b>${hiScore.toFixed(1)}</b> out of ${dataSize} recording(s)`, 'gold') });
								saveToCache(entity, discogsId, mbids[hiIndex]);
								return mbids[hiIndex];
							},
						}];
						const discogsEntry = dcApiRequest(discogsEntity(entity) + 's/' + discogsId).catch(reason => null);
						return Promise.all(mbids.map(mbid => entity in discogsBindingsCache
								&& Object.values(discogsBindingsCache[entity]).includes(mbid) ? Promise.resolve(null) : Promise.all([
							mbApiRequest(entity + '/' + mbid, { inc: `aliases+url-rels+${entity}-rels` }),
							discogsEntry,
						]).then(function([entry, discogsEntry]) {
							function hasSameRelatives(prop, relationType, forward) {
								if (!prop || !relationType || !discogsEntry[prop]) return false;
								const mbRelatives = entry.relations.filter(relation => relation['target-type'] == entity
										&& relation.type == relationType
										&& (forward === undefined || relation.direction == (forward ? 'forward' : 'backward')))
									.map(relation => relation[entity]);
								return mbRelatives.length > 0 && discogsEntry[prop].some(discogsRelative =>
									mbRelatives.some(mbRelative => entity in discogsBindingsCache
											&& discogsBindingsCache[entity][discogsRelative.id] == mbRelative.id
										|| matchNameVariant(discogsRelative, mbRelative.name)));
							}

							const discogsIds = getDiscogsRels(entry, discogsEntity(entity));
							if (discogsIds.includes(parseInt(discogsId))) return discogsIds.length < 2 ? entry.id : true;
							if (discogsIds.length > 0) return null;
							if (discogsEntry.urls && discogsEntry.urls.some(function(url) {
								try { url = new URL(url) } catch(e) { return false }
								return entry.relations.filter(relation => relation['target-type'] == 'url').some(function(relation) {
									try { relation = new URL(relation.url.resource) } catch(e) { return false }
									return relation.hostname + relation.pathname + relation.search
										== url.hostname + url.pathname + url.search;
								});
							})) {
								if (debugLogging) console.debug('Same %ss found by having same url(s):',
									entity, discogsEntry, entry);
								return entry.id;
							}
							if (entity == 'artist' && (hasSameRelatives('members', 'member of band', false)
									|| hasSameRelatives('groups', 'member of band', true) || hasSameRelatives('aliases', 'is person'))
									|| entity == 'label' && hasSameRelatives('sublabels', 'label ownership', true)) {
								if (debugLogging) console.debug('Same %ss found by having same relative(s):',
									entity, discogsEntry, entry);
								return entry.id;
							}
							if (entry.disambiguation && discogsEntry.profile) {
								const length = Math.min(entry.disambiguation.length, discogsEntry.profile.length);
								if (length >= 10 && jaroWinklerSimilarity(entry.disambiguation.toLowerCase(),
										discogsEntry.profile.toLowerCase()) >= 0.98 - (length - 10) / 2000) {
									if (debugLogging) console.debug('Same %ss found by having same profile info:',
										entity, discogsEntry, entry);
									return entry.id;
								}
							}
							return true;
						}).catch(function(reason) {
							console.warn(reason);
							return true;
						}))).then(function(statuses) {
							let lookupMethod = statuses.filter(mbIdExtractor);
							if (lookupMethod.length == 1) {
								discogsName(entity, discogsId).then(name =>
									{ notify(`MBID for ${entity} ${name} found by sharing same relation`, 'cyan') });
								saveToCache(entity, discogsId, lookupMethod[0]);
								return lookupMethod[0];
							} else lookupMethod = (methodIndex = 0) => methodIndex < lookupMethods.length ?
								(['worker', 'resolver'].every(fn => typeof lookupMethods[methodIndex][fn] == 'function') ?
									Promise.all([dcReleasesWorker].concat(statuses.map((status, index) => status
										&& lookupMethods[methodIndex].worker(mbids[index]).catch(console.warn))))
								 			.then(resolved => lookupMethods[methodIndex].resolver(resolved.shift(), resolved))
									: Promise.reject('Method not implemented')).catch(reason => lookupMethod(methodIndex + 1))
								: Promise.reject('Not found by common titles');
							return lookupMethod();
						});
					}
					function findMBID(entity, discogsId) {
						let promise = getCachedMBID(entity, discogsId).catch(reason => findDiscogsRelatives(entity, discogsId).then(function(entries) {
							console.assert(entries.length > 0);
							console.assert(entries.length == 1, 'Ambiguous %s linkage for Discogs id', entity, discogsId, entries);
							if (entries.length > 1) return params.searchSize > 0 ?
								findMBIDByCommonTitles(entity, discogsId, entries.map(entry => entry.id)) : Promise.reject('Ambiguity');
							discogsName(entity, discogsId).then(name =>
								{ notify(`MBID for ${entity} ${name} found by having Discogs relative set`, 'salmon') });
							saveToCache(entity, discogsId, entries[0].id);
							return entries[0].id;
						}));
						if (params.searchSize > 0) promise = promise.catch(function(reason) {
							if (/^(?:Ambiguity)\b/.test(reason)) return Promise.reject(reason);
							if (!(entity in lookupIndexes) || !(discogsId in lookupIndexes[entity]))
								return Promise.reject(`Assertion failed: ${entity}/${discogsId} not in lookup indexes`);
							return mbApiRequest(entity, {
								query: searchQueryBuilder(entity, discogsId),
								limit: params.searchSize,
							}).then(results => findMBIDByCommonTitles(entity, discogsId, results[(entity + 's').replace(/s(?=s$)/, '')].filter(function(result) {
								if (result.score > 90) return true;
								const equal = (name, normFn = str => str) => {
									const cmp = root => similarStringValues(normFn(root.name), normFn(name));
									return cmp(result) || result.aliases && result.aliases.some(cmp);
								};
								return equal(stripDiscogsNameVersion(lookupIndexes[entity][discogsId].name))
									|| lookupIndexes[entity][discogsId].anv && equal(lookupIndexes[entity][discogsId].anv)
									|| entity == 'label' && equal(stripDiscogsNameVersion(lookupIndexes[entity][discogsId].name),
										label => label && label.replace(...rxBareLabel));
							}).map(result => result.id)));
						});
						return promise;
					}
					function translateDiscogsMarkup(source, richFormat = true) {
						if (!source || !(source = source.trim())) return Promise.resolve(source);
						const entryTypes = { a: 'artist', r: 'release', m: 'master', l: 'label', u: 'user' };
						const mbEntity = key => ({ m: 'release-group' })[key] || entryTypes[key];
						const nameNormalizer = name => name
							&& stripDiscogsNameVersion(name.replace(/[\x00-\x1f]+/g, '').trim().replace(/\s+/g, ' '));
						const link = (url, caption) => url && richFormat ? caption ?
							`[${encodeURI(url)}|${caption.replace(/[\[\]\|]/g, m => `&#${m.charCodeAt(0)};`)}]`
							: '[' + encodeURI(url) + ']' : caption;
						return (function(body, replacer) {
							if (typeof body != 'string' || typeof replacer != 'function') throw 'Invalid argument';
							if (richFormat) {
								body = body.replace(/\[(?![armlutg]=?\d+\]|url=|\/?url\]|img=)([^\[\]]*)\]/ig, '&#91;$1&#93;');
								body = ['u', 'i', 'b'].reduce((str, tag, index) =>
									str.replace(new RegExp(`\\[(${tag})\\]([\\S\\s]*?)\\[\\/\\1\\]`, 'ig'), (...m) =>
										'"'.repeat(index) + m[2] + '"'.repeat(index)), body);
							} else body = ['u', 'i', 'b'].reduce((str, tag, index) =>
								str.replace(new RegExp(`\\[(${tag})\\]([\\S\\s]*?)\\[\\/\\1\\]`, 'ig'), '$2'), body);
							body = body.replace(/\[([armlu])=([^\[\]\r\n]+)\]/ig,
								(match, key, id) => !/^\d+$/.test(id) ? nameNormalizer(id) : match);
							let lookupWorkers = [ ], match;
							const entryExtractor = /\[([armlu])=?(\d+)\]/ig;
							while ((match = entryExtractor.exec(body)) != null) {
								const en1 = { key: match[1].toLowerCase(), id: parseInt(match[2]) };
								if (!lookupWorkers.some(en2 => en2.key == en1.key && en2.id == en1.id)) lookupWorkers.push(en1);
							}
							return (lookupWorkers = lookupWorkers.map(function(entry) {
								function addMethod(method) {
									if (typeof method != 'function') return;
									promise = promise instanceof Promise ? promise.catch(method) : method();
								}

								const discogsEntry = dcApiRequest(`${entryTypes[entry.key]}s/${entry.id}`).then(result => ({
									key: entry.key, id: entry.id,
									resolvedId: result.id,
									caption: result.name ? nameNormalizer(result.name) : result.title.trim(),
								}), function(reason) {
									console.warn(`Discogs lookup for ${entry.key}${entry.id} failed:`, reason);
									return null;
								});
								if (richFormat) {
									const mbEntry = mbEntry => mbEntry ? {
										key: entry.key, id: entry.id,
										resolvedId: mbEntry.id,
										caption: mbEntry.name || mbEntry.title,
									} : null;
									const getFromMBID = mbid => rxMBID.test(mbid) ?
										mbApiRequest(mbEntity(entry.key) + '/' + mbid, { inc: 'url-rels' }).then(mbEntry)
											: Promise.reject('Invalid MBID');
									const findFromMBIDs = mbids =>
										findMBIDByCommonTitles(mbEntity(entry.key), entry.id, mbids).then(getFromMBID);
									if (['a', 'l'].includes(entry.key)) var promise = getCachedMBID(entryTypes[entry.key], entry.id)
										.then(mbid => getFromMBID(mbid).catch(reason => discogsEntry));
									if (['a', 'r', 'm', 'l'].includes(entry.key)) addMethod(() =>
											findDiscogsRelatives(mbEntity(entry.key), entry.id).then(function(entries) {
										if (entries.length > 1) return ['a', 'l'].includes(entry.key) && params.searchSize > 0 ?
											findFromMBIDs(entries.map(entry => entry.id)) : Promise.reject('Ambiguity');
										discogsEntry.then(discogsEntry => '<b>' + discogsEntry.caption + '</b>', reason => entry.id).then(name =>
											{ notify(`MBID for ${entryTypes[entry.key]} ${name} found by having Discogs relative set`, 'salmon') });
										if (['a', 'l'].includes(entry.key)) saveToCache(entryTypes[entry.key], entry.id, entries[0].id);
										return mbEntry(entries[0]);
									}));
									if (['a', 'l'].includes(entry.key) && params.searchSize > 0) addMethod(reason => reason != 'Ambiguity (releases)' ?
											discogsEntry.then(discogsEntry => mbApiRequest(mbEntity(entry.key), {
										query: discogsEntry.caption,
										limit: params.searchSize,
									})).then(results => findFromMBIDs(results[mbEntity(entry.key) + 's'].map(result => result.id))) : Promise.reject(reason));
									addMethod(() => discogsEntry);
								} else promise = discogsEntry;
								return promise.catch(function(reason) {
									console.warn('Discogs entry lookup failed by all methods (', entry, ')');
									return null;
								});
							})).length > 0 ? Promise.all(lookupWorkers).then(entries => (entries = entries.filter(Boolean)).length > 0 ?
									Object.assign.apply({ }, Object.keys(entryTypes).map(key => ({ [key]: (function() {
								const items = entries.filter(entry => entry.key == key).map(entry =>
									({ [entry.id]: { caption: entry.caption, id: entry.resolvedId }}));
								return items.length > 0 ? Object.assign.apply({ }, items) : { };
							})() }))) : Promise.reject('No entries were resolved')).then(lookupTable =>
									body.replace(entryExtractor, function(match, key, id) {
								const entry = lookupTable[key = key.toLowerCase()][id = parseInt(id)];
								if (!entry) console.warn('Discogs item not resolved:', match);
								return entry ? replacer(key, entry.id, entry.caption) : replacer(key, id);
							})) : Promise.resolve(body);
						})(source, richFormat ? function replacer(key, id, caption) {
							if (!key || !id) throw 'Invalid argument';
							return link((mbEntity(key) && rxMBID.test(id) ? [mbOrigin, mbEntity(key), id]
								: [dcOrigin, entryTypes[key], id]).join('/'), caption);
						} : (key, id, caption) => caption || entryTypes[key] + id).catch(function(reason) {
							console.warn(reason);
							return source;
						}).then(source => [
							[/\[url=([^\[\]\r\n]+)\]([^\[\]\r\n]*)\[\/url\]/ig, function(m, url, caption) {
								if (richFormat) try {
									url = new URL(url.trim(), dcOrigin);
									return link(url.href, caption);
								} catch(e) { console.warn('Invalid Discogs link:', url) }
								return caption || url.trim();
							}], [/\[url\]([^\[\]\r\n]+)\[\/url\]/ig, function(m, url) {
								if (richFormat) try {
									url = new URL(url.trim(), dcOrigin);
									return link(url.href);
								} catch(e) { console.warn('Invalid Discogs link:', url) }
								return url.trim();
							}], [/\[img=([^\[\]\r\n]+)\]/ig, richFormat ? (m, url) => link(url.trim()) : '$1'],
							[/\[t=?(\d+)\]/ig, richFormat ? `[${dcOrigin}/help/forums/topic?topic_id=$1]` : `${dcOrigin}/help/forums/topic?topic_id=$1`],
							[/\[g=?([^\[\]\r\n]+)\]/ig, richFormat ? `[${dcOrigin}/help/guidelines/$1]` : `${dcOrigin}/help/guidelines/$1`],
							[/[ \t]+$/gm, ''], [/(?:\r?\n){2,}/g, '\n\n'],
						].reduce((str, substitution) => str.replace(...substitution), source));
					}
					function purgeArtists(fromIndex = 0) {
						const artistSuffixes = ['mbid', 'name', 'artist.name', 'join_phrase'];
						const key = (ndx, sfx) => `artist_credit.names.${ndx}.${sfx}`;
						for (let ndx = 0; artistSuffixes.some(sfx => formData.has(key(ndx, sfx))); ++ndx)
							artistSuffixes.forEach(sfx => { formData.delete(key(ndx, sfx)) });
					}

					if (!params.mbidLookup) params.searchSize = 0;
					if (params.recordingRelations || params.workRelations) params.tracklist = true;
					const rxMLang = /^(.+?)\s*=\s*(.+)$/, literals = { }, lookupIndexes = { artist: { }, label: { } };
					const uncapitalize = [/\b(\p{Lu}\p{Ll}+)\b/gu, (...m) => m[1].toLowerCase()];
					const discogsName = (entity, discogsId) => (entity in lookupIndexes && discogsId in lookupIndexes[entity] ?
						Promise.resolve(lookupIndexes[entity][discogsId].name)
							: dcApiRequest(`${discogsEntity(entity)}s/${discogsId}`).then(discogsEntry =>
								discogsEntry.name || discogsEntry.title, reason => entity + '#' + discogsId)).then(name => '<b>' + name + '</b>');
					const matchNameVariant = (artist, nameVariant) => artist && nameVariant
						&& (artist.name && sameStringValues(stripDiscogsNameVersion(artist.name), nameVariant)
						|| artist.namevariations && artist.namevariations.some(anv => sameStringValues(anv, nameVariant)));
					const hasType = (...types) => types.some(type => formData.getAll('type').includes(type));
					const trackPosMapper = trackPos => (trackPos || '').toString()
						.replace(/\d+/g, m => m.padStart(3, '0')).replace(/\W+/g, '').toUpperCase();
					formData.set('name', normTitle(release.title));
					//frequencyAnalysis(literals, release.title);
					const released = dateParser(release.released), artistCredits = { };
					discogsCountryToIso3166Mapper(release.country).forEach(function(countryCode, countryIndex) {
						if (countryCode) formData.set(`events.${countryIndex}.country`, countryCode);
						if (released != null) {
							function setDate(index, part) {
								const key = `events.${countryIndex}.date.${part}`;
								if ((index = released[index]) > 0) formData.set(key, index);
								else formData.delete(key);
							}
							setDate(0, 'year'); setDate(1, 'month'); setDate(2, 'day');
						}
					});
					let defaultFormat, descriptors = new Set, media, masterWorker;
					const cdFormats = {
						'HD-?CD': 'HDCD',
						'Enhanced': 'Enhanced CD',
						'Copy Protected': 'Copy Control CD',
						'CD\\+G': 'CD+G',
						'DualDisc': 'DualDisc',
						'SHM[ \\-]?CD': 'SHM-CD',
						'(?:BS|Blu-?Spec)[ \\-]?CD2?': 'Blu-spec CD',
						'HQ-?CD': 'HQCD',
						'DTS[ \\-]?CD': 'DTS CD',
						'Minimax CD': 'Minimax CD', // ?
						'Mixed Mode CD': 'Mixed Mode CD', // ?
						//'Hybrid': undefined,
					};
					if (release.formats) {
						for (let format of release.formats) for (let description of getFormatDescriptions(format))
							descriptors.add(description);
						const hasFormat = (fmt, ...specifiers) => release.formats.some(format => format.name == fmt
							&& (specifiers.length <= 0 || specifiers.every(specifier => getFormatDescriptions(format)
								.some(RegExp.prototype.test.bind(new RegExp('^(?:' + specifier + ')$', 'i'))))));
						if (hasFormat('Hybrid', 'DualDisc')) defaultFormat = 'DualDisc';
						if (hasFormat('SACD', 'Hybrid')) defaultFormat = 'Hybrid SACD';
						if (hasFormat('CDr')) defaultFormat = 'CD-R';
						if (hasFormat('CD')) defaultFormat = 'CD';
						for (let cdFormat in cdFormats) if (hasFormat('CD', cdFormat)) defaultFormat = cdFormats[cdFormat];
					}
					if (!defaultFormat) defaultFormat = 'CD';
					descriptors = Array.from(descriptors);
					processFormats({ // remove bogus tags
						Stereo: undefined,
						//Multichannel: undefined,
						NTSC: undefined, PAL: undefined,
					});
					processFormats({
						Album: 'Album',
						EP: 'EP', 'Mini-Album': 'EP',
						Single: 'Single', //'Maxi-Single': 'Single',
						Compilation: 'Compilation', Sampler: 'Compilation',
						Mixtape: 'Mixtape/Street',
						Live: 'Live',
					}, type => { formData.append('type', type) });
					if (/ +\([^\(\)]*\b(?:live|(?:en|ao) (?:vivo|directo?))\b[^\(\)]*\)$/i.test(release.title))
						formData.append('type', 'Live');
					if (/ +\([^\(\)]*\b(?:soundtrack|score)\b[^\(\)]*\)$/i.test(release.title)
							|| release.style && release.style.includes('Soundtrack'))
						formData.append('type', 'Soundtrack');
					const getRoles = artist => artist && artist.role ?
						artist.role.split(',').map(role => role.trim()).filter(Boolean) : [ ];
					if (release.extraartists) for (let extraArtist of release.extraartists) for (let role of getRoles(extraArtist)) {
						if (!(role in artistCredits)) artistCredits[role] = [ ];
						if (!artistCredits[role].some(ea => ea.id == extraArtist.id)) artistCredits[role].push(extraArtist);
					}
					if (release.extraartists && release.extraartists.some(extraArtist => !extraArtist.tracks
							&& getRoles(extraArtist).includes('DJ Mix'))) formData.append('type', 'DJ-mix');
					else if (!hasType('DJ-mix') && !descriptors.includes('Mixed'))
						if ('DJ Mix' in artistCredits) descriptors.push('Mixed');
					processFormats(cdFormats);
					processFormats(Object.assign.apply({ },
						['FLAC', 'MP[234]', 'OGG|Vorbis', 'AAC', 'M4[AB]', 'Opus', 'DSD\\d*']
							.map(key => ({ [key]: undefined }))));
					processFormats({
						'Mini': '8cm',
						'7"': undefined /*'Single'*/, '10"': undefined /*'Single'*/, '12"': undefined /*'EP'*/,
						'LP': undefined,
					}, size => { if (!defaultFormat.startsWith(size)) defaultFormat = size + ' ' + defaultFormat });
					if (/^8cm (?!CD(?:\+G)?$)/.test(defaultFormat)) defaultFormat = defaultFormat.slice(4);
					if (release.labels) release.labels.forEach(function(label, index) {
						const prefix = 'labels.' + index;
						if (label.name) {
							const name = stripDiscogsNameVersion(label.name);
							if (rxNoLabel.test(name)) formData.set(prefix + '.mbid', '157afde4-4bf5-4039-8ad2-5a15acc85176');
							else {
								formData.set(prefix + '.name', capitalizeName(name));
								addLookupEntry('label', label, prefix);
							}
						}
						if (label.catno) formData.set(prefix + '.catalog_number',
							rxNoCatno.test(label.catno) ? '[none]' : label.catno);
					});
					if (release.series) release.series.forEach((series, index) =>
						{ addLookupEntry('series', series, 'series.' + index) });
					const artistRelsIndex = {
						'work': {
							'Songwriter': 167, 'Written-By': 167, 'Written By': 167, 'Composed By': 168, 'Music By': 168,
							'Lyrics By': 165, 'Libretto By': 169, 'Translated By': 872, //'Arranged By': 293,
						},
						'recording': {
							'Performer': 156, 'Guest': 156,
							'Instruments': 148, 'Performer [Instruments]': 148, 'Musician': 148, 'Soloist': 148,
							'Voice': 149, 'Vocals': 149, 'Alto Vocals': 149, 'Backing Vocals': 149, 'Baritone Vocals': 149,
							'Bass Vocals': 149, 'Bass-Baritone Vocals': 149, 'Contralto Vocals': 149, 'Countertenor Vocals': 149,
							'Harmony Vocals': 149, 'Lead Vocals': 149, 'Mezzo-soprano Vocals': 149, 'Solo Vocal': 149,
							'Soprano Vocals': 149, 'Tenor Vocals': 149, 'Treble Vocals': 149, 'Whistling': 149, 'Choir': 149,
							'Chorus': 149, 'Coro': 149, 'Caller': 149, 'Eefing': 149, 'Human Beatbox': 149, 'Humming': 149,
							'Kakegoe': 149, 'MC': 149, 'Overtone Voice': 149, 'Rap': 149, 'Satsuma': 149, 'Scat': 149,
							'Speech': 149, 'Narrator': 149, 'Toasting': 149, 'Vocal Percussion': 149, 'Vocalese': 149,
							'Yodeling': 149,
							'Orchestra': 150, 'Ensemble': 150, 'Band': 150, 'Backing Band': 150, 'Brass Band': 150, 'Concert Band': 150,
							'Conductor': 151, 'Chorus Master': 152, 'Orchestrated By': 300, 'Concertmaster': 760, 'Concertmistress': 760,
							'DJ Mix': 155, 'Remix': 153, 'Mixed By': 143,
							'Recorded By': 128, 'Adapted By': 297, 'Arranged By': 297, 'Art Direction': 137, 'Music Director': 1186,
							//'Producer': 141, 'Editor': 144, 'A&R': 135,
						},
						'release': {
							'Compiled By': 48, 'DJ Mix': 43, 'Mixed By': 26,
							'Producer': 30, 'Co-producer': 30, 'Compilation Producer': 30, 'Executive Producer': 30,
							'Executive-Producer': 30, 'Mastered By': 42, 'Remastered By': 42, 'Engineer': 28, 'Recorded By': 36,
							'Programmed By': 37, 'Music Director': 1187, 'Editor': 38, 'Edited By': 38, 'Field Recording': 1012,
							'Artwork': 993, 'Artwork By': 993, 'Cover': 993, 'Calligraphy': 993, 'Design Concept': 993,
							'Graphic Design': 993, 'Graphics': 993, 'Layout': 993,  'Image Editor': 993, 'Lettering': 993,
							'Lithography': 993, 'Logo': 993, 'Model': 993, 'Painting': 993, 'Sleeve': 993, 'Typography': 993,
							'Photography': 20, 'Photography By': 20, 'Design': 928, 'Film Director': 1185, 'Illustration': 927,
							'Transferred By': 1179, 'Graphic Design': 27, 'Design': 928, 'Liner Notes': 24, //'Art Direction': 18,
						},
						'release-group': { 'A&R': 62, 'Creative Director': 63 },
					}, relationResolvers = { };
					const findEntities = role => Object.keys(artistRelsIndex)
						.filter(entity => role in artistRelsIndex[entity]);
					const relateToEntity = sourceEntity => sourceEntity && ({
						'work': params.workRelations,
						'recording': params.recordingRelations,
						'release': params.releaseRelations,
						'release-group': params.rgRelations,
					}[sourceEntity]);
					if (!Array.isArray(cdLengths) || cdLengths.length <= 0) cdLengths = false;
					const rxParsingMethods = [/^()()(\S+)$/];
					const totalMedia = release.formats ? release.formats.reduce((total, format) =>
						total + (parseInt(format.qty) || 0), 0) : undefined;
					if (totalMedia != 1) {
						// most universal parser
						rxParsingMethods.unshift(/^(?:([A-Z]{2,}|MP[234]|M4A)[\ \-\.]?)?(?:(\d+)[\ \-\.])?([A-Z]?\d+(?:\.(?:[a-z]|\d+))?)$/i);
						// old parsers, just for sure
						rxParsingMethods.push(/^([A-Z]{2,}|MP[234]|M4A)?(\d+)?[\ \-\.]?\b(\S+)$/i,
							/^([A-Z]{2,}|MP[234]|M4A)(?:[\-\ ](\d+))?[\ \-\.]?\b(\S+)$/i);
					}
					if (params.tracklist && ([
						media => layoutMatch(media) >= 0 ? media : undefined,
						media => groupTracks(media, /^\S*?\d+/), media => groupTracks(media, /^\S*\d+/),
						media => groupTracks(media),
						function alignByTOCs(media) {
							const cdMedia = media.filter(isCD);
							if (cdMedia.length <= 0) return false;
							const cdTracks = Array.prototype.concat.apply([ ], cdMedia.map(medium => medium.tracks));
							if (cdTracks.length <= 0) return;
							if (cdTracks.length != cdLengths.reduce((sum, totalTracks) => sum + totalTracks, 0)) return;
							if (layoutMatch(media = cdLengths.map(function(discNumTracks, discIndex) {
								const trackOffset = cdLengths.slice(0, discIndex).reduce((sum, totalTracks) => sum + totalTracks, 0);
								const mediaIndex = Math.min(discIndex, cdMedia.length - 1);
								return {
									format: cdMedia[mediaIndex].format,
									name: cdMedia[mediaIndex].name,
									tracks: cdTracks.slice(trackOffset, trackOffset + discNumTracks)
										.map((track, index) => Object.assign(track, { number: index + 1 })),
								};
							}).concat(media.filter(medium => !isCD(medium)))) > 2) return media;
						}, function alignByTOCsIgnoreMedia(media) {
							const cdTracks = Array.prototype.concat.apply([ ], media.map(medium => medium.tracks));
							if (cdTracks.length <= 0) return;
							if (cdTracks.length != cdLengths.reduce((sum, totalTracks) => sum + totalTracks, 0)) return;
							if (layoutMatch(media = cdLengths.map(function(discNumTracks, discIndex) {
								const trackOffset = cdLengths.slice(0, discIndex).reduce((sum, totalTracks) => sum + totalTracks, 0);
								const mediaIndex = Math.min(discIndex, media.length - 1);
								return {
									format: defaultFormat,
									name: media[mediaIndex].name,
									tracks: cdTracks.slice(trackOffset, trackOffset + discNumTracks)
										.map((track, index) => Object.assign(track, { number: index + 1 })),
								};
							})) > 2) return media;
						},
					].some(function(mediaSplitter, splitterIndex) {
						for (let collapseSubtracks of [false, true]) for (let rxParsingMethod of rxParsingMethods) {
							media = parseTracklist(rxParsingMethod, collapseSubtracks);
							if (!media || media.length <= 0) continue;
							if (media = mediaSplitter(media)) return true;
						}
						return false;
					}) || confirm('Could not find appropriatte tracks mapping to media (' +
							(media = parseTracklist()).map(medium => medium.tracks.length).join('+') +
							' ≠ ' + cdLengths.join('+') + '), attach tracks with this layout anyway?'))) {
						for (let medium of media) if (medium.tracks.every((track, ndx, tracks) => track.heading == tracks[0].heading)) {
							medium.name = medium.tracks[0].heading;
							for (let track of medium.tracks) track.heading = undefined;
						}
						(media = media.filter(isCD).concat(media.filter(medium => !isCD(medium)))).forEach(function(medium, mediumIndex) {
							if (medium.format) formData.set(`mediums.${mediumIndex}.format`, medium.format);
							if (medium.name) formData.set(`mediums.${mediumIndex}.name`, normTitle(medium.name));
							if (!medium.tracks) return;
							const multilingual = medium.tracks.every(track => rxMLang.test(track.name));
							medium.tracks.forEach(function(track, trackIndex) {
								function addArtistCredits(trackArtist) {
									if (!trackArtist.extraartists) return;
									for (let extraArtist of trackArtist.extraartists) for (let role of getRoles(extraArtist)) {
										const entities = findEntities(role);
										if ((!params.recordingRelations || entities.length > 0 && !entities.includes('recording'))
												&& (!params.workRelations || !entities.includes('work'))) continue;
										if (!(role in artistCredits)) artistCredits[role] = [ ];
										if (!artistCredits[role].some(artist => artist.id == extraArtist.id))
											artistCredits[role].push(extraArtist);
									}
								}

								const prefix = `mediums.${mediumIndex}.track.${trackIndex}.`;
								if (track.number) formData.set(prefix + 'number', track.number);
								if (track.name) {
									const _prefix = str => str ? str + ': ' : '';
									const title = trackTitleNorm(multilingual ? track.name.replace(rxMLang, '$1') : track.name, formData);
									formData.set(prefix + 'name', normTitle(_prefix(track.heading) +
										_prefix((track.titles || [ ]).join(' / ')) + title));
									frequencyAnalysis(literals, track.name /*fullTitle*/);
								}
								if (track.trackArtists && track.trackArtists.length > 0) {
									track.trackArtists.reduce(function(offset, trackArtist) {
										if (offset > 0) formData.set(`${prefix || ''}artist_credit.names.${offset - 1}.join_phrase`, ' / ');
										return offset + seedArtists(trackArtist, prefix, offset);
									}, 0);
									track.trackArtists.forEach(addArtistCredits);
								} else {
									const trackArtist = resolveArtists(track);
									if (!sameTrackArtists(trackArtist)) seedArtists(trackArtist, prefix);
									addArtistCredits(trackArtist);
								}
								if (track.length) formData.set(prefix + 'length', track.length);
							});
						});
					}
					for (let role in artistCredits) if (!['Featuring'].includes(role)) {
						const entities = findEntities(role);
						if (entities.length <= 0 && !(role in relationResolvers)) {
							const resolvers = [ ], roleParser = /^(.+)\s+\[([^\[\]]+)\]$/.exec(role);
							if (roleParser != null) {
								if (['Musician', 'Instruments'].includes(roleParser[1])) resolvers.push(() =>
									instrumentResolver(roleParser[2]).then(attributes => instrumentMapper(attributes, roleParser[2])));
								resolvers.push(function() {
									const entities = findEntities(roleParser[1]);
									if (entities.length <= 0) return params.recordingRelations ?
										instrumentResolver(roleParser[1]).then(attributes => instrumentMapper(attributes, roleParser[1]))
											: Promise.reject(role + ' not resolved');
									if (/\b(?:Band|Orchestra|Ensemble)$/.test(roleParser[1])) return Promise.reject(role + ' not resolved');
									return instrumentResolver(roleParser[2]).catch(reason => null).then(instrument => entities.map(function(entity) {
										function testForAttribute(expr, appliesTo, attributeId) {
											if (!attributeId) throw 'Invalid argument';
											if (!Array.isArray(appliesTo)) appliesTo = null;
											if ((!expr || (expr instanceof RegExp ? expr.test(roleParser[2]) : roleParser[2] == expr))
													&& (!appliesTo || appliesTo.includes(relation.linkTypeId))) return [{ id: attributeId }];
										}

										const relation = {
											linkTypeId: artistRelsIndex[entity][roleParser[1]],
											creditType: roleParser[1],
										}, instrumentRelIds = [
											26, 28, 29, 30, 31, 36, 37, 38, 41, 44, 46, 49, 103, 105, 128, 132, 133, 138, 140, 141,
											143, 144, 148, 151, 154, 158, 282, 726, 727, 798, 799, 800, 847, 923, 924, 986, 987,
										];
										console.assert(relation.linkTypeId > 0, entity, role);
										if (!(relation.linkTypeId > 0)) throw 'Assertion failed: invalid linkTypeId (' + role + ')';
										relation.attributes = testForAttribute('Additional', [18, 19, 20, 22, 24, 26, 27, 28, 29, 30, 31, 36, 37, 38, 40, 41, 42, 44, 45, 46, 47, 49, 51, 53, 54, 55, 56, 57, 60, 63, 102, 103, 123, 125, 128, 130, 132, 133, 136, 137, 138, 140, 141, 143, 144, 146, 148, 149, 150, 151, 152, 153, 154, 156, 158, 164, 165, 167, 168, 169, 282, 293, 294, 295, 296, 297, 298, 300, 726, 727, 751, 871, 872, 927, 928, 993, 1179], '0a5341f8-3b1d-4f99-a0c6-26b7f4e42c7f')
											|| testForAttribute(/^(?:Assist(?:ed|ant))$/, [18, 26, 28, 29, 30, 31, 36, 37, 38, 42, 46, 47, 53, 128, 132, 133, 136, 138, 140, 141, 143, 144, 151, 152, 153, 305, 726, 727, 856, 928, 962, 1165, 1179, 1185, 1186, 1187], '8c4196b1-7053-4b16-921a-f22b2898ed44')
											|| testForAttribute('Associate', [26, 28, 29, 30, 31, 36, 37, 38, 41, 42, 128, 132, 133, 136, 138, 140, 141, 143, 144, 158, 282, 293, 294, 295, 296, 297, 298, 726, 727, 1179], '8d23d2dd-13df-43ea-85a0-d7eb38dc32ec')
											|| testForAttribute(/^(?:Guest|Featuring)$/, [44, 51, 60, 148, 149, 156, 305, 759, 760], 'b3045913-62ac-433e-9211-ac683cdf6b5c')
											|| testForAttribute('Solo', [44, 51, 60, 148, 149, 156], '63daa0d3-9b63-4434-acff-4977c07808ca')
											|| testForAttribute('Executive', [28, 30, 138, 141], 'e0039285-6667-4f94-80d6-aa6520c6d359')
											|| instrumentRelIds.includes(relation.linkTypeId) && instrument
											|| testForAttribute('Instrument', instrumentRelIds, '0abd7f04-5e28-425b-956f-94789d9bcbe2')
											|| testForAttribute('Vocal', [26, 28, 29, 30, 31, 36, 38, 46, 49, 60, 103, 107, 128, 133, 138, 140, 141, 143, 144, 149, 151, 154, 294, 296, 298, 726, 727, 798, 799, 800], 'd92884b7-ee0c-46d5-96f3-918196ba8c5b')
											|| testForAttribute(/^(?:Sub-?)$/, [32, 161], '4521ce8e-3d24-4b64-9805-59df6f3a4740')
											|| testForAttribute(/^(?:Co-?)$/, [26, 28, 29, 30, 31, 36, 38, 41, 42, 128, 133, 136, 138, 140, 141, 143, 144, 158, 282, 293, 294, 295, 296, 297, 298, 726, 727, 1179], 'ac6f6b4c-a4ec-4483-a04e-9f425a914573')
											|| testForAttribute(/^(?:Pre-?)$/, [42], '288b973a-26ea-4880-8eca-45af4b8e8665')
											|| testForAttribute(/^(?:Translat(?:or|ed))$/, [24], '25dfb08e-9b99-44db-b30c-1d6ec6747af8')
											|| [20, 25, 30, 62, 129, 138, 141, 143, 162, 993, 998, 999].includes(relation.linkTypeId) && [{
												id: '39867b3b-0f1e-40d5-b602-4f3936b7f486',
												value: roleParser[2].replace(...uncapitalize),
											}] || null;
										return relation;
									}));
								});
							} else if (params.recordingRelations) resolvers.push(() => instrumentResolver(role).then(instrumentMapper));
							for (let resolver of resolvers) if (!relationResolvers[role]) relationResolvers[role] = resolver();
								else relationResolvers[role] = relationResolvers[role].catch(resolver);
						}
						if (entities.length > 0 ? entities.some(relateToEntity) : role in relationResolvers)
							for (let extraArtist of artistCredits[role]) addLookupEntry('artist', extraArtist, role);
					}
					if (!media && release.tracklist) release.tracklist.forEach(track =>
						{ if (track.type_ == 'track') frequencyAnalysis(literals, track.title) });
					if (Object.keys(literals).length > 0) guessTextRepresentation(formData, literals);
					const languageDetector = params.languageIdentifier && release.tracklist
						&& languageIdentifier(release.tracklist.map(track =>
							track.title.replace(...bracketStripper) + '.').join(' ')).then(function(result) {
								/*if (!formData.has('language')) */formData.set('language', result.iso6393);
								if (params.extendedMetadata) formData.set('language_en', result.language);
								notify(`<b>${result.language}</b> identified as release language`, 'deeppink');
							}, reason => { console.warn('Remote language identification failed') });
					const packagingMappers = {
						[/^Book$/.source]: 'book',
						[/^Box$|Box[ \-]?Set/.source]: 'box',
						[/(?:Card(?:board)?|Paper) ?Sleeve/.source]: 'cardboard/paper sleeve',
						[/Cassette(?: Case)?/.source]: undefined, //'cassette case',
						[/Clamshell(?: Case)?/.source]: 'clamshell case',
						[/Digi ?book/.source]: 'digibook',
						[/Digi(?:pac?k|sleeve)(?: (?:Case|Cover))?/.source]: 'digipak',
						[/Disc ?box(?: ?Slider)?/.source]: 'discbox slider',
						[/Fat ?box(?: Case)?|^Fat(?:Box)?\b/.source]: 'fatbox',
						[/Gatefold(?: Cover)?/.source]: 'gatefold cover',
						[/Jewel(?: ?Case)?/.source]: 'jewel case',
						[/^Keep$|Keep Case/.source]: 'keep case',
						[/Long ?box(?: Case)?|Lbx/.source]: 'longbox',
						[/Metal ?Tin(?: Case)?/.source]: 'metal tin',
						[/Plastic Sleeve/.source]: 'plastic sleeve',
						[/Slide ?pac?k/.source]: 'slidepack',
						[/Slim(?:-?line)? Jewel(?: Case)?/.source]: 'slim jewel case',
						[/^Snap$|Snap ?Case/.source]: 'snap case',
						[/Snap ?Pack/.source]: 'snappack',
						[/Super ?Jewel(?: ?(?:Box|Case))?/.source]: 'super jewel box',
					};
					for (let key in packagingMappers) if (new RegExp('\\b(?:' + key + ')\\b', 'i').test(release.notes)
							&& packagingMappers[key]) formData.set('packaging', packagingMappers[key]);
					processFormats(packagingMappers, packaging => { formData.set('packaging', packaging) });
					processFormats({
						[/Unofficial(?: Release)?/.source]: 'bootleg',
						[/Promo(?:tion(?:al)?)?/.source]: 'promotion',
						[/Pseudo[ \-]Release/.source]: 'pseudo-release',
						[/Withdrawn(?: Release)?/.source]: 'withdrawn',
						[/Cancelled(?: Release)?/.source]: 'cancelled',
					}, status => { formData.set('status', status) });
					if (!formData.has('status')) formData.set('status', 'official');
					if (formData.get('status') == 'official') {
						if (/\b(?:cancelled)\b/i.test(release.notes)) formData.set('status', 'cancelled');
						if (/\b(?:withdrawn)\b/i.test(release.notes)) formData.set('status', 'withdrawn');
					}
					descriptors = descriptors.map(function(descriptor) {
						switch (descriptor) {
							case 'Mixed': if (hasType('DJ-mix')) return false; else break;
							case 'Remastered': if (hasType('Compilation')) return false; else break;
							case 'Reissue': case 'CD-TEXT': return false;
						}
						return descriptor.replace(...uncapitalize).trim();
					}).filter(Boolean);
					//else formData.delete('comment');
					let annotation = [
						!params.releaseRelations && !params.rgRelations && release.series
							&& release.series.map(discogsSeriesMapper).join('\n'),
						release.notes && [
							[/(?:\r?\n)*^\s*(?:(?:(?:Total|Running|Playing)\s+)+(?:Time|Length|Duration)|TT):? +(?:(?:\d+:)+\d+|(?:\d+['"] ?){2})$/gmi, ''],
							[/(?:\r?\n){2,}/g, '\n\n'],
						].reduce((str, subst) => str.replace(...subst), release.notes).trim(),
						release.identifiers && release.identifiers
							.filter(identifier => !['Barcode', 'ASIN'].includes(identifier.type))
							.map(discogsIdentifierMapper).join('\n'),
					].filter(Boolean);
					annotation = annotation.length > 0 ? translateDiscogsMarkup(annotation.join('\n\n'))
						.then(annotation => { formData.set('annotation', annotation) }) : undefined;
					let urlRelIndex = -1, haveValidBarcode = false;
					addUrlRef([dcOrigin, 'release', release.id].join('/'), 76);
					if (params.rgRelations && release.master_id > 0)
						addUrlRef([dcOrigin, 'master', release.master_id].join('/'), 90);
					if (release.identifiers) for (let identifier of release.identifiers) switch (identifier.type) {
						case 'Barcode': {
							function checkDigit(digits, effectiveLength = digits.length) {
								const sum = digits.reduce((sum, digit, n) =>
									sum + (n < effectiveLength ? digit * ((n & 1) == 0 ? 3 : 1) : 0), 0);
								return (10 - sum % 10) % 10;
							}

							if (haveValidBarcode) break;
							let barcode = identifier.value.replace(/\D+/g, '');
							if (barcode.length < 9) break;
							if (barcode.length > 13) console.warn('Nonstandard barcode:', barcode);
							const digits = Array.from(barcode, char => parseInt(char)).filter(n => !isNaN(n));
							switch (digits.length) {
								case 11:
									console.info('UPC check digit is', checkDigit(digits, 11),
										'(', barcode + checkDigit(digits, 11), ')');
									//barcode += checkDigit(digits, 11);
									break;
								case 12: case 13:
									if (checkDigit(digits, digits.length - 1) == digits.pop()) {
										haveValidBarcode = true;
										console.info('Valid %s:', ['UPC', 'EAN'][digits.length - 11], barcode);
									} else console.warn('Invalid %s:', ['UPC', 'EAN'][digits.length - 11], barcode);
									break;
							}
							formData.set('barcode', barcode);
							break;
						}
						case 'ASIN':
							addUrlRef('https://www.amazon.com/gp/product/' + identifier.value, 77);
							break;
					}
					purgeArtists();
					seedArtists(release);
					if (descriptors.length > 0) formData.set('comment', descriptors.join(', '));
					formData.set('edit_note', ((formData.get('edit_note') || '') +
						`\nSeeded from Discogs release id ${release.id}`).trimLeft());
					const rgLookupWorkers = [ ];
					if (params.rgLookup && params.mbidLookup && !formData.has('release_group') && release.master_id > 0)
						rgLookupWorkers.push(findDiscogsRelatives('release-group', release.master_id).then(function(releaseGroups) {
							console.assert(releaseGroups.length > 0);
							console.assert(releaseGroups.length == 1, 'Ambiguous master %d release referencing:', release.master_id, releaseGroups);
							return releaseGroups.length == 1 ? releaseGroups[0] : Promise.reject('Ambiguity');
						}).catch(reason => null));
					if (params.extendedMetadata) {
						let tags = (release.genres || [ ]).concat(release.styles || [ ]).filter(uniqueValues), tagIndex = -1;
						for (let tag of (tags = tags.filter(uniqueValues))) formData.set(`tags.${++tagIndex}`, tag);
						if (release.master_id) masterWorker = dcApiRequest('masters/' + release.master_id).then(function(master) {
							for (let tag of (master.genres || [ ]).concat(master.styles || [ ]).filter(uniqueValues))
								if (!tags.includes(tag)) formData.set(`tags.${++tagIndex}`, tag);
						}, console.error);
					}
					return Promise.all([
						getSessions(torrentId).catch(reason => null).then(function(sessions) {
							function recordingsLookup(medium, track, mbidLookupFn) {
								if (!track) throw 'Invalid argument'; else if (!media) return Promise.reject('Missing media');
								if (!track.name) return Promise.reject('Missing track name');
								const mediumIndex = media.indexOf(medium);
								const trackIndex = medium && medium.tracks ? medium.tracks.indexOf(track) : -1;
								console.assert(mediumIndex >= 0 && trackIndex >= 0);
								if (layoutMatch(media) > 2) var trackLength = (function getLengthFromTOC() {
									if (!sessions || !isCD(medium) || !(mediumIndex >= 0) || !(trackIndex >= 0)) return;
									const tocEntries = getTocEntries(sessions[mediumIndex]);
									if (tocEntries[trackIndex]) return (tocEntries[trackIndex].endSector + 1 -
										tocEntries[trackIndex].startSector) * 1000 / 75;
								})();
								if (!(trackLength > 0) && track.length) {
									if (/^(\d+)$/.test(track.length)) trackLength = parseInt(track.length);
									else if ((trackLength = /^(\d+):(\d+)$/.exec(track.length)) != null)
										trackLength = trackLength.slice(1).reverse()
											.reduce((s, t, n) => s + parseInt(t) * 60**n, 0) * 1000;
									console.assert(trackLength > 0, track.length);
								}
								if (typeof mbidLookupFn != 'function') mbidLookupFn = undefined;
								if (!(trackLength > 0) && !mbidLookupFn) return Promise.reject('Missing track length');
								const maxLengthDifference = 5000;
								const artists = trackMainArtists(track);
								console.assert(artists != null, track);
								return artists != null ? mbApiRequest('recording', {
									query: (function queryBuilder() {
										const OR = (values, mapFn) => Array.isArray(values)
											&& (values = values.filter((recording, index, arr) => arr.indexOf(recording) == index)).length > 0
											&& typeof mapFn == 'function' ? '(' + values.map(mapFn).join(' OR ') + ')' : '';
										const fields = artists.map(function(artist) {
											const arid = typeof mbidLookupFn == 'function' && mbidLookupFn('artist', artist.id);
											return arid ? 'arid:' + arid : '(artistname:"' + stripDiscogsNameVersion(artist.name) +
												'" OR creditname:"' + (artist.anv || stripDiscogsNameVersion(artist.name)) + '")';
										});
										fields.push(OR([track.name, track.name.replace(...bracketStripper)],
											title => 'recording:"' + title + '"'));
										if (trackLength > 0) fields.push(`(${[
											`dur:[${Math.max(Math.round(trackLength) - 5000, 0)} TO ${Math.round(trackLength) + 5000}]`,
											'(NOT dur:[* TO *])',
										].join(' OR ')})`);
										if (!canContainVideo(medium)) fields.push('video:false');
										return fields.join(' AND ');
									})(),
									limit: 100,
								}).then(function(recordings) {
									function recordingValidator(recording, lengthRequired = false, dateRequired = false) {
										if (recording.score < 90 || !canContainVideo(medium) && recording.video) return false;
										if (!sameStringValues(recording.title, track.name) && ['(?:re-?)?mix(?:ed)?|RMX'].some(function(rx) {
											rx = new RegExp('\\s+\\(.*\\b(?:' + rx + ')\\b.*\\)$', 'i');
											let recordingFlag = rx.test(recording.title), trackFlag = rx.test(track.name);
											return recordingFlag != trackFlag;
										})) return false;
										if (recording.releases && ['Live', 'Interview', 'Demo'].some(function(secondaryType) {
											const hasSecondaryType = release => 'release-group' in release
												&& 'secondary-types' in release['release-group']
												&& release['release-group']['secondary-types'].includes(secondaryType);
											return hasType(secondaryType) ? !recording.releases.every(hasSecondaryType)
												: recording.releases.some(hasSecondaryType);
										})) return false;
										if (!Array.isArray(recording['artist-credit']) || !artists.every(function(artist) {
											const arid = typeof mbidLookupFn == 'function' && mbidLookupFn('artist', artist.id);
											return recording['artist-credit'].some(arid ? artistCredit =>
													artistCredit.artist && artistCredit.artist.id == arid
												: artistCredit => artistCredit.artist && matchNameVariant(artist, artistCredit.artist.name)
													|| artistCredit.name && matchNameVariant(artist, artistCredit.name));
										})) return false;
										if (!recordingDate(recording) && dateRequired) return false;
										if (recording.length > 0 ? trackLength > 0 && deltaMapper(recording) > maxLengthDifference
												: lengthRequired || !mbidLookupFn) return false;
										return sameTitleMapper(recording, track.name, recordingDate(recording)
											&& deltaMapper(recording) < 1000 ? weakMatchMapper : deltaMapper(recording) < 3000 ?
												similarStringValues : sameStringValues);
									}

									const deltaMapper = recording => recording.length > 0 && trackLength > 0 ?
										Math.abs(recording.length - trackLength) : NaN;
									const weakMatchMapper = (...strings) => sameStringValues(...strings)
										|| strings.some(str1 => strings.every(str2 => str2.toLowerCase().startsWith(str1.toLowerCase())))
										|| strings.every(str => sameStringValues(...[str, strings[0]].map(str => str.replace(...bracketStripper))))
										|| similarStringValues(strings[0], strings[1]);
									if (recordings.count <= 0 || (recordings = recordings.recordings.filter(recording =>
											recordingValidator(recording, false, false))).length <= 0) return Promise.reject('No matches');
									return recordings.sort(function(...recordings) {
										const hasLength = recording => recording.length > 0;
										const cmpVal = fn => fn(recordings[0]) && !fn(recordings[1]) ? -1
											: fn(recordings[1]) && !fn(recordings[0]) ? +1 : 0;
										return [
											function() {
												if (!recordings.every(hasLength)) return;
												const deltas = recordings.map(deltaMapper);
												return deltas[0] < 1000 && deltas[1] >= 1000 || deltas[1] < 1000 && deltas[0] >= 1000
													|| Math.abs(deltas[0] - deltas[1]) >= 1000 ? Math.sign(deltas[0] - deltas[1]) : 0;
											}, () => recordings.every(recordingDate) ?
												recordingDate(recordings[0]).localeCompare(recordingDate(recordings[1])) : 0,
											() => cmpVal(recording => sameTitleMapper(recording, track.name)), function() {
												if (!recordings.every(hasLength)) return;
												const deltas = recordings.map(deltaMapper);
												return Math.sign(deltas[0] - deltas[1]);
											}, function() {
												if (!recordings.every(recording => Array.isArray(recording.releases))) return;
												const releases = recordings.map(recording => recording.releases.length);
												return Math.sign(releases[1] - releases[0]);
											}, () => cmpVal(recordingDate), () => cmpVal(hasLength),
										].reduce((result, cmpFn) => result || cmpFn(...recordings), undefined) || 0;
									});
								}) : Promise.reject('No artists for track');
							}

							const recordingDate = recording => recording['first-release-date'] || recording.date;
							const canContainVideo = medium => medium && medium.format
								&& ['BLU-RAY', 'DVD'].includes(medium.format.toUpperCase());
							const artistLookupWorkers = { };
							if (media && params.lookupArtistsByRecording && params.searchSize > 0) for (let medium of media)
								if ('tracks' in medium) for (let track of medium.tracks) (function addArtistLookups(artists) {
									if (artists != null) for (let artist of artists) {
										if (!(artist.id in artistLookupWorkers)) artistLookupWorkers[artist.id] = [ ];
										artistLookupWorkers[artist.id].push(recordingsLookup(medium, track).then(function(recordings) {
											const mbids = [ ];
											for (let recording of recordings) if (recording.score >= 90 && 'artist-credit' in recording)
												for (let artistCredit of recording['artist-credit']) if (artistCredit.artist
														&& (matchNameVariant(artist, artistCredit.artist.name)
														|| artistCredit.name && matchNameVariant(artist, artistCredit.name)))
													mbids.push(artistCredit.artist.id);
											return mbids.length > 0 ? mbids : null;
										}).catch(reason => null));
									}
								})(trackMainArtists(track));
							if (params.searchSize > 0) for (let discogsId in artistLookupWorkers)
								artistLookupWorkers[discogsId] = Promise.all(artistLookupWorkers[discogsId]).then(function(mbids) {
									const scores = { };
									for (let _mbids of mbids.filter(Boolean)) for (let mbid of _mbids)
										if (!(mbid in scores)) scores[mbid] = 1; else ++scores[mbid];
									return Object.keys(scores).length > 0 ? scores : Promise.reject('No matches');
								});
							return Promise.all(Object.keys(lookupIndexes).map(entity =>
									Promise.all(Object.keys(lookupIndexes[entity]).map(function(discogsId) {
								function checkMBID(mbid) {
									console.assert(rxMBID.test(mbid), mbid);
									if (!rxMBID.test(mbid)) return Promise.reject('Invalid MBID');
									if (entity == 'artist' && discogsId in artistLookupWorkers) artistLookupWorkers[discogsId].then(function(mbids) {
										if (Object.keys(mbids).length > 1)
											console.warn('MBID for artist', [dcOrigin, 'artist', discogsId].join('/'),
												'can resolve to multiple entities:', printArtistMBIDs(mbids));
										if (!Object.keys(mbids).includes(mbid))
											console.warn('MBID for artist', [dcOrigin, 'artist', discogsId].join('/'),
												'matching different entities:', printArtistMBIDs(mbids));
										if (Object.keys(mbids).length > 1 || !Object.keys(mbids).includes(mbid)) {
											beep.play();
											if (params.openInconsistent)
												openInconsistent(entity, discogsId, Object.keys(mbids), 'recordings');
										}
									});
									return mbid;
								}
								function createEntity(entity, discogsId, recursive = true) {
									function relationResolver(entity, relation) {
										if (!relation) throw 'Invalid argument';
										if (!relation.id) return Promise.reject('Invalid entry');
										let worker = findMBID(entity, relation.id);
										if (recursive) worker = worker.catch(reason => createEntity(entity, relation.id, false));
										return worker;
									}
									function typeIdFromUrl(url, linkTypes, socialNetwork, otherDatabase, purchaseForDownload) {
										if (!url) throw 'Invalid argument';
										try { url = new URL(url).hostname } catch(e) { return -Infinity }
										const urlMatch = domain => domain == url || url.endsWith('.' + domain);
										const domain = linkTypes ? Object.keys(linkTypes).find(urlMatch) : undefined;
										if (domain) return linkTypes[domain];
										if (socialNetwork > 0 && [
											'facebook.com', 'twitter.com', 'instagram.com', 'linkedin.com', ,'vk.com',
											'tumblr.com', 'snapchat.com', 't.me', 'mixcloud.com',
										].some(urlMatch)) return socialNetwork;
										if (otherDatabase > 0 && [
											'45cat.com', '45worlds.com', 'adp.library.ucsb.edu', 'anidb.net', 'animenewsnetwork.com',
											'anison.info', 'baike.baidu.com', 'bibliotekapiosenki.pl', 'brahms.ircam.fr', 'cancioneros.si',
											'cancioneros.si', 'castalbums.org', 'catalogue.bnf.fr', 'cbfiddle.com', 'ccmixter.org',
											'ci.nii.ac.jp', 'classicalarchives.com', 'd-nb.info', 'dhhu.dk', 'discografia.dds.it',
											'discosdobrasil.com.br', 'dr.loudness-war.info', 'dramonline.org', 'encyclopedisque.fr',
											'ester.ee', 'finna.fi', 'finnmusic.net', 'folkwiki.se', 'fono.fi', 'generasia.com',
											'goodreads.com', 'ibdb.com', 'id.loc.gov', 'idref.fr', 'imvdb.com', 'irishtune.info',
											'isrc.ncl.edu.tw', 'iss.ndl.go.jp', 'japanesemetal.gooside.com', 'jaxsta.com',
											'jazzmusicarchives.com', 'kbr.be', 'librarything.com', 'livefans.jp', 'lortel.org',
											'mainlynorfolk.info', 'maniadb.com', 'metal-archives.com', 'mobygames.com', 'musicmoz.org',
											'musik-sammler.de', 'muziekweb.eu', 'mvdbase.com', 'ocremix.org', 'offiziellecharts.de',
											'openlibrary.org', 'operabase.com', 'operadis-opera-discography.org.uk', 'overture.doremus.org',
											'pomus.net', 'progarchives.com', 'psydb.net', 'qim.com', 'rateyourmusic.com',
											'residentadvisor.net', 'rock.com.ar', 'rockensdanmarkskort.dk', 'rockinchina.com',
											'rockipedia.no', 'rolldabeats.com', 'smdb.kb.se', 'snaccooperative.org',
											'soundtrackcollector.com', 'spirit-of-metal.com', 'spirit-of-rock.com', 'stage48.net',
											'tedcrane.com', 'theatricalia.com', 'thedancegypsy.com', 'themoviedb.org', 'thesession.org',
											'touhoudb.com', 'triplejunearthed.com', 'trove.nla.gov.au', 'tunearch.org', 'utaitedb.net',
											'vkdb.jp', 'vndb.org', 'vocadb.net', 'whosampled.com', 'worldcat.org', 'www22.big.or.jp',
											'www5.atwiki.jp', 'ra.co',
										].some(urlMatch)) return otherDatabase;
										if (purchaseForDownload > 0 && [
											'beatport.com', 'qobuz.com', 'deezer.com', 'spotify.com', 'music.163.com',
										].some(urlMatch)) return purchaseForDownload;
									}
									function normProfile(profile) {
										profile = profile.trim().replace(/\s+/g, ' ');
										if (profile.endsWith('.') && Array.prototype.filter.call(profile, ch => ch == '.').length < 2)
											profile = profile.slice(0, -1);
										return profile;
									}
									function extractIdentifiers(entry) {
										if (!entry || !entry.profile) return;
										const isni = /\b(?:ISNI)(?::\s*|\s+)([\dX ]+)\b/g,
													ipi = /\b(?:IPI)(?::\s*|\s+)([A-Z]-\d{9}-\d)\b/g;
										let id, index = -1;
										while ((id = isni.exec(entry.profile)) != null) {
											if ((id = id[1].replace(/\s/g, '').slice(0, 16)).length == 16) {
												let check = 11 - Array.prototype.reduce.call(id.slice(0, 15),
													(sum, ch, index) => sum + parseInt(ch) * (16 - index), 0) % 11;
												if (id[15] == (check > 9 ? 'X' : check.toString()))
													postData.set(`edit-${entity}.isni_codes.${++index}`, id);
											}
										}
										index = -1;
										while ((id = ipi.exec(entry.profile)) != null)
											postData.set(`edit-${entity}.ipi_codes.${++index}`, id[1]);
									}
									function addAlias(entity, mbid, name, typeId) {
										if (!entity || !mbid || !name) return Promise.reject('Invalid argument');
										const postData = new URLSearchParams({ 'edit-alias.name': capitalizeName(name) });
										if (typeId == 1) postData.set('edit-alias.sort_name', capitalizeName(name));
										if (typeId) postData.set('edit-alias.type_id', typeId);
										postData.set('edit-alias.edit_note', 'Auto-imported from Discogs by ' + scriptSignature);
										if (params.createEntries < 2) postData.set('edit-alias.make_votable', 1);
										return globalXHR([mbOrigin, entity, mbid, 'add-alias'].join('/'), { responseType: null }, postData);
									}

									const postData = new URLSearchParams, workers = [ ];
									const resolverAdapter = (resolver, postData) =>
										resolver.then(mbid => Object.assign({ 'target': mbid }, postData));
									let typeId, urlIndex = -1, disambiguation, sortName, m;
									const create = entry => Promise.all(workers.map(worker =>
											worker.catch(reason => null)).concat(disambiguation)).then(function(relationships) {
										if (entry.name) postData.set(`edit-${entity}.name`, capitalizeName(stripDiscogsNameVersion(entry.name)));
										if (sortName) postData.set(`edit-${entity}.sort_name`, sortName);
										if (typeId > 0) postData.set(`edit-${entity}.type_id`, typeId);
										if (disambiguation = relationships.pop()) {
											while (disambiguation.length > 255) disambiguation = disambiguation.replace(/\s+\S+$/, '');
											postData.set(`edit-${entity}.comment`, disambiguation);
										} else postData.set(`edit-${entity}.comment`, '');
										relationships.filter(Boolean).forEach(function(relationship, index) {
											const requiredFields = ['link_type_id', 'target'].every(key => relationship[key]);
											console.assert(requiredFields, relationship);
											if (requiredFields) for (let key in relationship) if (relationship[key] != undefined)
												postData.set(`edit-${entity}.rel.${index}.${key}`, relationship[key]);
										});
										postData.set(`edit-${entity}.edit_note`, 'Auto-imported from Discogs by ' + scriptSignature);
										if (params.createEntries < 2) postData.set(`edit-${entity}.make_votable`, 1);
										//if (debugLogging) console.debug('createEntity(%s):', Array.from(postData));
										return globalXHR([mbOrigin, entity, 'create'].join('/'), undefined, postData).then(getGID).then(function(gid) {
											if (postData.has(`edit-${entity}.name`)) {
												const name = postData.get(`edit-${entity}.name`);
												const ent = entity[0].toUpperCase() + entity.slice(1).toLowerCase();
												notify(`${ent} <b>${name}</b> successfully created`, 'lime');
												console.info(ent, name, 'successfully created (', gid, ')');
											}
											saveToCache(entity, discogsId, gid);
											return gid;
										});
									});
									const extractYear = (entry, words, distance = 30) => entry && entry.profile && words
										&& (entry = new RegExp(`\\b(?:${words})\\b.{1,${distance}}\\b((?:19|20)\\d\\d)\\b`, 'i')
											.exec(entry.profile)) != null ? parseInt(entry[1]) : undefined;
									const createHandler = {
										artist: artistId => dcApiRequest(discogsEntity(entity) + 's/' + artistId).then(function(artist) {
											function addUrl(url) {
												let typeId = typeIdFromUrl(url, {
													'musicbrainz.org': -1, 'myspace.com': -1, 'wikipedia.org': 179, 'wikidata.org': 352,
													'discogs.com': 180, 'allmusic.com': 283, 'music.apple.com': 1131, 'bandcamp.com': 718,
													'soundcloud.com': 291, 'music.youtube.com': 1080, 'last.fm': 840, 'vgmdb.net': 191,
													'viaf.org': 310, 'setlist.fm': 816,  'imslp.org': 754, 'imdb.com': 178, 'cpdl.org': 981,
													'cdbaby.com': 919, 'secondhandsongs.com': 307, 'purevolume.com': 174,
													'youtube.com': 193, 'tiktok.com': 303, 'twitch.tv': 303, 'rumble.tv': 303,
												}, 192, 188, 176);
												if (!typeId && /\b(?:blog)\b/i.test(url)) typeId = 199;
												if (!typeId && cmpNorm(url).includes(cmpNorm(name))) typeId = 183;
												if (typeId < 0) return; else postData.set(`edit-${entity}.url.${++urlIndex}.text`, url);
												if (typeId != undefined) postData.set(`edit-${entity}.url.${urlIndex}.link_type_id`, typeId);
													else console.warn('Undetermined link type for %s. New %s probably won\'t be accepted.', url, entity);
											}

											const name = capitalizeName(stripDiscogsNameVersion(artist.name.trim()));
											const rxs = [
												/^(?:DJ|MC)\b|\b(?:Project)\b/,
												/\b(?:group|band|ensemble|duo|trio|(?:quartet|quintet|sextet|septet|octet|nonet|tentet)(?:te?)?|all[ \-]?stars?|collective|gang|conjurito)\b|\s+(?:[\&\+]|and|vs\.?)\s+/i,
												/\b(?:character)\b/i,
												/\b(?:or(?:ch|k)est)/i,
												/\b(?:choir|[ck]oro|chor(?:us)?|singers)\b/i,
												/^(The|Da|Le|Der|Die|Das|El|La|DJ|MC)\s+/,
												/\b(?:Project)\b/i,
											];
											if (rxs[3].test(name)/* || rxs[1].test(artist.profile)*/) typeId = 5;
											else if (rxs[4].test(name)/* || rxs[0].test(artist.profile)*/) typeId = 6;
											else if (artist.members && artist.members.length > 0 || rxs[1].test(name)
													|| artist.realname && rxs[1].test(artist.realname)) typeId = 2;
											else if (artist.realname || artist.groups && artist.groups.length > 0
													|| rxs[0].test(name)) typeId = 1;
											//else if (!typeId && rxs[2].test(artist.profile)) typeId = 4;
											if ((m = rxs[5].exec(name)) != null && !/\s+(?:[\&\+]|and)\s+/i.test(name)) {
												sortName = m.input.slice(m[0].length) + ', ' + m[1];
												if (!typeId) typeId = -1;
											} else if (typeId == 1) {
												sortName = (m = name.split(/\s+/)).pop();
												if (m.length > 0) sortName += ', ' + m.join(' ');
												//postData.set(`edit-${entity}.gender_id`, 0); // 1=M. 2=F, 3=🤷, 4=not applicable, 5=other
											} else sortName = name;
											if (!typeId) {
												const words = stripDiscogsNameVersion(artist.name.trim()).split(/\s+/), rxs = [
													/^["„]?[']?\p{L}(?:[\-']?\p{L})*[']?\.?["“]?$/u,
													/^[']?\p{L}(?:[\-']?\p{L})+[']?$/u,
												];
												if (words.length < 2 || words.length > 3 || words.every(word => word.toLowerCase() == word)
														//|| words.some(word => /^[\p{L}]{2,}$/u.test(word) && word.toUpperCase() == word)
													 	|| !words.every((word, n, a) => rxs[n < a.length - 1 ? 0 : 1].test(word)))
													typeId = -1;
												else if (rxs[6].test(name)) typeId = -1;
												else if (params.createUncertainArtists) typeId = -2;
											}
											if (typeId != 1 && artist.profile && /^.{0,15}\b(?:unidentified|unknown)\b/i.test(artist.profile))
												return mbidUnknown;
											if (!typeId) return Promise.reject('Undeterminable sort name');
											addUrl([dcOrigin, discogsEntity(entity), artist.id].join('/'));
											if (artist.urls) artist.urls.forEach(addUrl);
											disambiguation = artist.profile ? [normProfile(artist.profile.trim())] : [ ];
											if (artist.realname && (![2, 5, 6].includes(typeId) || disambiguation.length <= 0)
													&& artist.realname.toLowerCase() != stripDiscogsNameVersion(artist.name).toLowerCase())
												disambiguation.unshift(artist.realname);
											if (disambiguation = disambiguation.join('; ')) {
												disambiguation = disambiguation.trim().replace(/\r?\n[\S\s]*$/, '').trimRight();
												disambiguation = translateDiscogsMarkup(disambiguation, false);
											} else if (artist.members && artist.members.length > 0) {
												const members = artist.members.filter(member => member.active)
													.map(member => stripDiscogsNameVersion(member.name));
												if (members.length > 0) {
													disambiguation = members.pop();
													if (members.length > 0) disambiguation = members.join(', ') + ' & ' + disambiguation;
												}
											}
											if (!disambiguation && artist.aliases && artist.aliases.length > 0)
												disambiguation = 'aka. ' + artist.aliases.map(alias => stripDiscogsNameVersion(alias.name)).join(', ');
											if (!disambiguation && artist.groups && artist.groups.length > 0) {
												const groups = artist.groups.filter(group => group.active)
													.map(group => stripDiscogsNameVersion(group.name));
												if (groups.length > 0) disambiguation = 'member of ' + groups.join(', ');
											}
											if (artist.profile) {
												if (!(typeId > 4)) {
													if (!(typeId > 1) && (m = extractYear(artist, 'Born')) > 0
															|| typeId != 1 && (m = extractYear(artist, 'Established')) > 0)
														postData.set(`edit-${entity}.period.begin_date.year`, m);
													if (!(typeId > 1) && (m = extractYear(artist, 'Died|Deceased')) > 0
															|| typeId != 1 && (m = extractYear(artist, 'Dissolved')) > 0) {
														postData.set(`edit-${entity}.period.end_date.year`, m);
														postData.set(`edit-${entity}.period.ended`, 1);
													}
												}
												extractIdentifiers(artist);
											}
											const periodEnded = relative => 'active' in relative ? relative.active ? 0 : 1 : undefined;
											const nameNorm = name => name && toASCII(stripDiscogsNameVersion(name));
											const namedBy = (group, member) => new RegExp('\\b' + nameNorm(member)
												.replace(/[^\w\s]/g, '\\$&') + '\\b', 'i').test(nameNorm(group));
											if (artist.aliases) artist.aliases.forEach(function(alias) {
												workers.push(resolverAdapter(relationResolver(entity, alias), {
													'link_type_id': 108,
													'backward': sameStringValues(stripDiscogsNameVersion(alias.name), artist.realname) ? 1 : 0,
													'period.ended': periodEnded(alias),
												}));
											});
											if (artist.members) artist.members.forEach(function(member) {
												const mbidResolver = relationResolver(entity, member);
												workers.push(resolverAdapter(mbidResolver, {
													'link_type_id': 103,
													'backward': 1,
													'period.ended': periodEnded(member),
												}));
												if (namedBy(artist.name, member.name))
													workers.push(resolverAdapter(mbidResolver, { 'link_type_id': 973, 'backward': 0 }),
														resolverAdapter(mbidResolver, { 'link_type_id': 895, 'backward': 1 }));
											});
											if (artist.groups) artist.groups.forEach(function(group) {
												const mbidResolver = relationResolver(entity, group);
												workers.push(resolverAdapter(mbidResolver, {
													'link_type_id': 103,
													'backward': 0,
													'period.ended': periodEnded(group),
												}));
												if (namedBy(group.name, artist.name))
													workers.push(resolverAdapter(mbidResolver, { 'link_type_id': 973, 'backward': 1 }),
														resolverAdapter(mbidResolver, { 'link_type_id': 895, 'backward': 0 }));
											});
											return create(artist).then(function(mbid) {
												const aliasWorkers = artist.namevariations ? artist.namevariations
													.filter(anv => anv.toLowerCase() != name.toLowerCase())
													.map(anv => addAlias(entity, mbid, anv, 1)) : [ ];
												if (typeId == 1 && artist.realname && artist.realname.toLowerCase() != name.toLowerCase())
													aliasWorkers.push(addAlias(entity, mbid, artist.realname, 2));
												if (aliasWorkers.length > 0) Promise.all(aliasWorkers).then(status =>
													{ console.info(status.length, 'alias(es) successfully created for artist id', mbid) },
													reason => { console.warn('Some aliases could not be created (%s)', reason) });
												if (typeId == -2) GM_openInTab([mbOrigin, 'artist', mbid].join('/'), true);
												return mbid;
											});
										}),
										label: labelId => dcApiRequest(discogsEntity(entity) + 's/' + labelId).then(function(label) {
											function addUrl(url) {
												let typeId = typeIdFromUrl(url, {
													'musicbrainz.org': -1, 'myspace.com': -1, 'wikipedia.org': 216, 'wikidata.org': 354,
													'discogs.com': 217, 'apple.com': 1130, 'bandcamp.com': 719, 'soundcloud.com': 290,
													'last.fm': 838, 'vgmdb.net': 210, 'imdb.com': 313, 'viaf.org': 311,
													'secondhandsongs.com': 977,
													'youtube.com': 225, 'tiktok.com': 304, 'twitch.tv': 304, 'rumble.com': 304,
												}, 218, 222, 959);
												if (!typeId && cmpNorm(url).includes(cmpNorm(stripDiscogsNameVersion(label.name.trim()))))
													typeId = 219;
												if (typeId < 0) return; else postData.set(`edit-${entity}.url.${++urlIndex}.text`, url);
												if (typeId != undefined) postData.set(`edit-${entity}.url.${urlIndex}.link_type_id`, typeId);
													else console.warn('Undetermined link type for %s. New %s probably won\'t be accepted.', url, entity);
											}

											addUrl([dcOrigin, discogsEntity(entity), label.id].join('/'));
											if (label.urls) label.urls.forEach(addUrl);
											if (label.profile) {
												disambiguation = normProfile(label.profile.trim().replace(/\r?\n[\S\s]*$/, '').trimRight());
												if (disambiguation) disambiguation = translateDiscogsMarkup(disambiguation, false);
												const extractDate = words =>
													new RegExp(`\\b(?:${words})\\b.{1,30}\\b((?:19|20)\\d\\d)\\b`, 'i')
														.exec(label.profile);
												if ((m = extractYear(label, 'Established')) > 0)
													postData.set(`edit-${entity}.period.begin_date.year`, m);
												if ((m = extractYear(label, 'Defunct')) > 0) {
													postData.set(`edit-${entity}.period.end_date.year`, m);
													postData.set(`edit-${entity}.period.ended`, 1);
												}
												let id = /\b(?:LC):?\s*(\d+)\b/i.exec(label.profile);
												if (id != null) postData.set(`edit-${entity}.label_code`, id[1]);
												extractIdentifiers(label);
											}
											const labelAdapter = (relative, backward = false) =>
												resolverAdapter(relationResolver(entity, relative),
													{ 'link_type_id': 200, 'backward': backward ? 1 : 0 });
											if (label.sublabels) Array.prototype.push.apply(workers,
												label.sublabels.map(subLabel => labelAdapter(subLabel, false)));
											if (label.parent_label) workers.push(labelAdapter(label.parent_label, true));
											return create(label);
										}),
										series: seriesId => dcApiRequest(discogsEntity(entity) + 's/' + seriesId).then(function(series) {
											function addUrl(url) {
												let typeId = typeIdFromUrl(url, {
													'musicbrainz.org': -1, 'myspace.com': -1, 'wikipedia.org': 744, 'wikidata.org': 354,
													'discogs.com': 747, 'viaf.org': 1001, 'setlist.fm': 938, 'soundcloud.com': 870,
													'youtube.com': 792, 'tiktok.com': 805, 'twitch.tv': 805, 'rumble.com': 805,
												}, 784, 746);
												if (!typeId && cmpNorm(url).includes(cmpNorm(stripDiscogsNameVersion(series.name.trim()))))
													typeId = 745;
												if (typeId < 0) return; else postData.set(`edit-${entity}.url.${++urlIndex}.text`, url);
												if (typeId != undefined) postData.set(`edit-${entity}.url.${urlIndex}.link_type_id`, typeId);
													else console.warn('Undetermined link type for %s. New %s probably won\'t be accepted.', url, entity);
											}

											if (series.parent_label) typeId = 2;
											if (!typeId && series.profile
													&& /\b(?:editions?\b|remasters?|re-?issues?|anniversary\b)/i.test(series.profile))
												typeId = 2;
											if (!((typeId || (typeId = GM_getValue('default_series_type'))) > 0))
												return Promise.reject('Series type not determinable for ' + series.name);
											postData.set(`edit-${entity}.ordering_type_id`, 1);
											addUrl([dcOrigin, discogsEntity(entity), series.id].join('/'));
											if (series.urls) series.urls.forEach(addUrl);
											if (series.profile) {
												disambiguation = normProfile(series.profile.trim().replace(/\r?\n[\S\s]*$/, '').trimRight());
												if (disambiguation) disambiguation = translateDiscogsMarkup(disambiguation, false);
												extractIdentifiers(series);
											}
											if (series.parent_label)
												workers.push(resolverAdapter(relationResolver('label', series.parent_label),
													{ 'link_type_id': 933, 'backward': 0 }));
											return create(series);
										}),
									}[entity];
									console.assert(typeof createHandler == 'function', entity);
									if (typeof createHandler != 'function') return Promise.reject('Create not implemented for ' + entity);
									console.assert(discogsId in lookupIndexes[entity], lookupIndexes, entity, discogsId);
									return mbApiRequest(entity, { query: searchQueryBuilder(entity, discogsId) }).then(results =>
											Promise.all(results[entity.replace(/(?<!s)$/i, 's')].filter(function(result) {
										const equal = (name, normFn = name => name && toASCII(name).toLowerCase()) => {
											const cmp = root => normFn(root.name) == normFn(name);
											return cmp(result) || result.aliases && result.aliases.some(cmp);
										};
										return equal(stripDiscogsNameVersion(lookupIndexes[entity][discogsId].name))
											//|| lookupIndexes[entity][discogsId].anv && equal(lookupIndexes[entity][discogsId].anv)
											|| entity == 'label' && equal(stripDiscogsNameVersion(lookupIndexes[entity][discogsId].name),
												label => label && toASCII(label.replace(...rxBareLabel)).toLowerCase());
									}).map(result => mbApiRequest(entity + '/' + result.id, { inc: `aliases+url-rels+${entity}-rels` }).then(function(entry) {
										const discogsIds = getDiscogsRels(entry, discogsEntity(entity));
										if (discogsIds.includes(parseInt(discogsId))) return discogsIds.length < 2 ? entry.id : true;
										return discogsIds.length <= 0;
									}).catch(reason => true))), reason => null).then(function(results) {
										if (results != null) {
											const discogsIds = results.filter(mbIdExtractor);
											if (discogsIds.length == 1) return discogsIds[0];
											if (results.filter(Boolean) > 0) return Promise.reject('Name collision');
										}
										return createHandler(discogsId);
									});
								}

								const printArtistMBIDs = mbids => Object.keys(mbids).map(mbid =>
									[mbOrigin, 'artist', mbid, 'recordings'].join('/') + ' => ' + mbids[mbid]);
								let promise = findMBID(entity, discogsId);
								if (entity == 'artist' && discogsId in artistLookupWorkers) promise = promise.catch(() =>
										artistLookupWorkers[discogsId].then(function(mbids) {
									const hiValue = Math.max(...Object.values(mbids));
									if (Object.values(mbids).reduce((sum, count) => sum + count, 0) >= hiValue * 1.5) {
										console.warn('MBID for artist', [dcOrigin, 'artist', discogsId].join('/'),
											'resolved to multiple entities:', printArtistMBIDs(mbids), '(rejected)');
										return Promise.reject('Ambiguity (recordings)');
									} else if (Object.keys(mbids).length > 1) {
										console.warn('MBID for artist', [dcOrigin, 'artist', discogsId].join('/'),
											'resolved to multiple entities:', printArtistMBIDs(mbids), '(accepted)');
										beep.play();
										if (params.openInconsistent)
											openInconsistent(entity, discogsId, Object.keys(mbids), 'recordings');
									}
									const mbid = Object.keys(mbids).find(key => mbids[key] == hiValue);
									if (!mbid) return Promise.reject('Assertion failed: MBID indexed lookup failed');
									if (debugLogging) console.debug('Entity binding found by matching existing recordings:',
										[dcOrigin, discogsEntity(entity), discogsId].join('/') + '#' + discogsEntity(entity),
										[mbOrigin, entity, mbid, 'releases'].join('/'));
									notify(`MBID for ${entity} <b>${lookupIndexes[entity][discogsId].name}</b> found by match with <b>${hiValue}</b> existing recordings`, 'hotpink');
									saveToCache(entity, discogsId, mbid);
									return mbid;
								}));
								if (params.createEntries > 0 && params.searchSize > 0
										&& entity != 'artist' || (['rg', 'release', 'recording', 'work'].some(entity => params[entity + 'Relations'])
										|| !lookupIndexes[entity][discogsId].contexts.every(context => context in artistCredits)))
									promise = promise.catch(reason => getCachedMBID(entity, discogsId)
										.catch(reason => createEntity(entity, discogsId, false)));
								return promise.then(checkMBID).catch(reason => null);
							})))).then(function(lookupResults) {
								function getMBID(entity, discogsId) {
									console.assert(entity in lookupIndexes);
									if (!(entity in lookupIndexes)) return undefined;
									let index = Object.keys(lookupIndexes[entity]).findIndex(key => parseInt(key) == discogsId);
									return index >= 0 ? lookupResults[Object.keys(lookupIndexes).indexOf(entity)][index] : undefined;
								}

								let relIndex = -1, relationWorkers = [ ];
								Object.keys(lookupIndexes).forEach(function(entity, ndx1) {
									Object.keys(lookupIndexes[entity]).forEach(function(discogsId, ndx2) {
										function addRelation(linkTypeId, attributes, extraData) {
											const prefix = 'rels.' + ++relIndex;
											formData.set(prefix + '.entity', entity);
											formData.set(prefix + '.link_type_id', linkTypeId);
											formData.set(prefix + '.target', mbid);
											formData.set(prefix + '.name', stripDiscogsNameVersion(lookupIndexes[entity][discogsId].name));
											if (lookupIndexes[entity][discogsId].anv)
												formData.set(prefix + '.credit', capitalizeName(lookupIndexes[entity][discogsId].anv));
											if (attributes && (attributes = attributes.filter(attribute => attribute.id)).length >= 0)
												formData.set(prefix + '.attributes', JSON.stringify(attributes));
											if (extraData) for (let key in extraData) formData.set(prefix + '.' + key, extraData[key]);
										}

										const mbid = lookupResults[ndx1][ndx2];
										if (mbid != null) for (let context of lookupIndexes[entity][discogsId].contexts) switch(entity) {
											case 'series':
												if (params.releaseRelations || params.rgRelations) relationWorkers.push((relationResolvers[context]
														|| (relationResolvers[context] = mbApiRequest('series/' + mbid).then(function(mbSeries) {
													const linkTypeId = {
														'52b90f1e-ff62-3bd0-b254-5d91ced5d757': 741,
														'4c1c4949-7b6c-3a2d-9d54-a50a27e4fa77': 742,
													}[mbSeries['type-id']] || {
														'Release series': 741,
														'Release group series': 742,
													}[mbSeries.type];
													return linkTypeId > 0 ? linkTypeId : Promise.reject('Unhandled series type');
												}, console.error))).then(function(linkTypeId) {
													if (!{
														'release': params.releaseRelations,
														'release-group': params.rgRelations,
													}[findSourceEntity(linkTypeId)]) return Promise.reject(lookupIndexes[entity][discogsId].name + 'not to be resolved');
													const series = release.series.find(series => series.id == parseInt(discogsId));
													console.assert(series, discogsId, release.series);
													if (series && series.catno) {
														var number = series.catno.trim().replace(/^(?:Vol(?:ume|\.)?|#|N(?:umber|o\.?)|№|N°)\s*(?=\d+$)|\.$/gi, '');
														number = /^(?:\d+)$/.test(number) ? parseInt(number) : romanToArabic(number);
													}
													addRelation(linkTypeId, number > 0 ? [{
														id: 'a59c5830-5ec7-38fe-9a21-c7ea54f6650a',
														value: number,
													}] : null);
												}).catch(console.log)); else GM_openInTab([mbOrigin, entity, mbid, 'edit'].join('/'), true);
												console.info('MBID for series %s:', lookupIndexes[entity][discogsId].name, mbid);
												break;
											case 'artist':
												if (context in artistCredits) {
													function isCredited(track) {
														const hasEA = trackArtist => trackArtist != null && trackArtist.extraartists
															&& trackArtist.extraartists.some(extraArtist =>
																extraArtist.id == parseInt(discogsId) && getRoles(extraArtist).includes(context));
														return track.trackArtists && track.trackArtists.length > 0 ?
															track.trackArtists.some(hasEA) : hasEA(resolveArtists(track));
													}

													const sourceEntities = findEntities(context);
													const resolver = sourceEntities.length > 0 ? Promise.resolve(sourceEntities.map(entity =>
														({ linkTypeId: artistRelsIndex[entity][context] }))) : relationResolvers[context];
													relationWorkers.push(resolver.then(function(relations) {
														console.assert(relations.length > 0);
														for (let { linkTypeId, attributes, creditType = context } of relations) {
															console.assert(linkTypeId > 0, relations);
															if (!(linkTypeId > 0)) continue; else if (!attributes) attributes = [ ];
															const sourceEntity = findSourceEntity(linkTypeId);
															if (relateToEntity(sourceEntity)) switch (creditType) {
																case 'Alto Vocals': attributes.push({ id: '9f63c4ba-b76f-40d5-9e99-2fb08bd4c286' }); break;
																case 'Backing Vocals': attributes.push({ id: '75052401-7340-4e5b-a71d-ea024a128849' }); break;
																case 'Baritone Vocals': attributes.push({ id: 'a40b43ed-2722-4b4a-98a5-478283cdf8df' }); break;
																case 'Bass Vocals': attributes.push({ id: '1bfdb77e-f339-4e8e-9627-331ca9d9e920' }); break;
																case 'Bass-Baritone Vocals': attributes.push({ id: '629763ee-3dc7-4225-b209-0ebb6d49bfab' }); break;
																case 'Choir': case 'Chorus': case 'Coro': attributes.push({ id: '43427f08-837b-46b8-bc77-483453af6a7b' }); break;
																case 'Co-producer': attributes.push({ id: 'ac6f6b4c-a4ec-4483-a04e-9f425a914573' }); break;
																case 'Contralto Vocals': attributes.push({ id: '80d94f2e-e38f-4561-add2-c866f083d276' }); break;
																case 'Countertenor Vocals': attributes.push({ id: '435a19f5-55dc-4a08-8c59-4257680b4217' }); break;
																case 'Executive-Producer': case 'Executive Producer': attributes.push({ id: 'e0039285-6667-4f94-80d6-aa6520c6d359' }); break;
																case 'Guest': attributes.push({ id: 'b3045913-62ac-433e-9211-ac683cdf6b5c' }); break;
																case 'Lead Vocals': attributes.push({ id: '8e2a3255-87c2-4809-a174-98cb3704f1a5' }); break;
																case 'Mezzo-soprano Vocals': attributes.push({ id: 'f81325d7-593c-4197-b776-4f8a62c67a8e' }); break;
																case 'Remastered By': attributes.push({ id: '9b72452f-550e-4ace-93ed-fb8789cdc245' }); break;
																case 'Solo Vocal': case 'Soloist': attributes.push({ id: '63daa0d3-9b63-4434-acff-4977c07808ca' }); break;
																case 'Soprano Vocals': attributes.push({ id: 'e88f0be8-a07e-4c0d-bd06-e938eea4d5f6' }); break;
																case 'Speech': case 'Narrator': attributes.push({ id: 'd3a36e62-a7c4-4eb9-839f-adfebe87ac12' }); break;
																case 'Tenor Vocals': attributes.push({ id: '122c11da-651f-46cc-9118-c523a14afa1d' }); break;
																case 'Treble Vocals': attributes.push({ id: '433631a2-68b7-49e6-90b4-5af19e26fc75' }); break;
																case 'Whistling': attributes.push({ id: 'ed220196-6250-456d-ab7b-465bee605b16' }); break;
																case 'Vocals': attributes.push({ id: 'd92884b7-ee0c-46d5-96f3-918196ba8c5b' }); break;
																case 'Caller': case 'Eefing': case 'Harmony Vocals': case 'Human Beatbox': case 'Humming':
																case 'MC': case 'Overtone Voice': case 'Rap': case 'Satsuma': case 'Scat': case 'Toasting':
																case 'Kakegoe': case 'Vocal Percussion': case 'Vocalese': case 'Yodeling': // other vocals
																	attributes.push({ id: 'c359be96-620a-435c-bd25-2eb0ce81a22e' });
																	break;
																case 'Cover': case 'Calligraphy': case 'Design Concept': case 'Graphics': case 'Layout':
																case 'Image Editor': case 'Lettering': case 'Lithography': case 'Logo': case 'Model':
																case 'Painting': case 'Sleeve': case 'Typography':
																	attributes.push({ id: '39867b3b-0f1e-40d5-b602-4f3936b7f486', value: creditType.toLowerCase() });
																	break;
															} else continue;
															if (['recording', 'work'].includes(sourceEntity) && media && media.some(medium =>
																	medium.tracks.some(isCredited))) media.forEach(function(medium, mediumIndex) {
																medium.tracks.forEach(function(track, trackIndex) {
																	if (isCredited(track)) addRelation(linkTypeId, attributes,
																		{ medium: mediumIndex, track: trackIndex });
																});
															}); else addRelation(linkTypeId, attributes);
														}
													}).catch(console.log));
													console.info('MBID for %s %s:', context, lookupIndexes[entity][discogsId].name, mbid);
													break;
												}
											default: formData.set(context + '.mbid', mbid);
										}
									});
								});
								if (params.rgLookup && !formData.has('release_group') && Array.isArray(release.artists)) {
									function rgResolver(releaseGroups) {
										if (!releaseGroups) return null;
										const rgFilter = (releaseGroups, strictType = false, strictName = true) => releaseGroups.filter(function(releaseGroup) {
											if (formData.has('type') && releaseGroup['primary-type']) {
												const types = formData.getAll('type');
												const cmpNocase = (...str) => str.every((s, n, a) => s.toLowerCase() == a[0].toLowerCase());
												if (!types.some(type => cmpNocase(type, releaseGroup['primary-type']))) return false;
												if (strictType && releaseGroup['secondary-types']) {
													if (!releaseGroup['secondary-types'].every(secondaryType =>
															types.some(type => cmpNocase(type, secondaryType)))) return false;
													if (!types.every(type => cmpNocase(type, releaseGroup['primary-type'])
															|| releaseGroup['secondary-types'].some(secondaryType =>
																cmpNocase(secondaryType, type)))) return false;
												}
											}
											return sameTitleMapper(releaseGroup, release.title, strictName ?
													sameStringValues : similarStringValues, releaseTitleNorm)
												|| releaseGroup.releases && releaseGroup.releases.some(release2 =>
													sameTitleMapper(release2, release.title, strictName ?
														sameStringValues : similarStringValues, releaseTitleNorm));
										});
										let filtered = rgFilter(releaseGroups, false, true);
										if (filtered.length > 1) filtered = rgFilter(releaseGroups, true, true);
										else if (filtered.length < 1) filtered = rgFilter(releaseGroups, false, false);
										if (filtered.length != 1) filtered = rgFilter(releaseGroups, true, false);
										return filtered.length == 1 ? filtered[0] : null;
									}

									Array.prototype.push.apply(rgLookupWorkers, release.artists.map(function(artist) {
										if (artist.id == 194) return;
										const mbid = getMBID('artist', artist.id);
										if (mbid) return mbLookupById('release-group', 'artist', mbid).then(rgResolver, console.error);
									}).filter(Boolean));
									if (release.artists.length > 0) {
										const normTitle = releaseTitleNorm(release.title);
										rgLookupWorkers.push(mbApiRequest('release-group', { query: ['(' + [
											`releasegroup:"${release.title}"`,
											`releasegroup:"${normTitle}"`,
											`releasegroup:"${normTitle.replace(/(?:\s+(?:\([^\(\)]+\)|\[[^\[\]]+\]))+$/g, '')}"`,
											`alias:"${release.title}"`,
											`alias:"${normTitle}"`,
											`release:"${release.title}"`,
											`release:"${normTitle}"`,
										].join(' OR ') + ')'].concat(release.artists.map(function(artist) {
											const arid = getMBID('artist', artist.id);
											return arid ? 'arid:' + arid : '(artistname:"' + stripDiscogsNameVersion(artist.name) +
												'" OR creditname:"' + (artist.anv || stripDiscogsNameVersion(artist.name)) + '")';
										})).join(' AND '), limit: 100 }).then(results => rgResolver(results['release-groups']), console.error));
									}
								}
								return Promise.all(rgLookupWorkers).then(function releaseGroupResolver(releaseGroups) {
									const releaseGroup = releaseGroups.find(Boolean);
									if (releaseGroup) formData.set('release_group', releaseGroup.id); else return false;
									let notification = `MBID for release group <b>${releaseGroup.name || releaseGroup.title}</b>`;
									if (releaseGroup['first-release-date'])
										notification += ` (<b>${getReleaseYear(releaseGroup['first-release-date'])}</b>)`;
									notification += ` found by ${'relations' in releaseGroup ? 'unique name match' : 'known URL relation'}`;
									notify(notification, 'goldenrod');
									return true;
								}).then(function findExistingRecordings() {
									if (!(params.recordingsLookup > 0) || !media || !(params.recordingsLookup > 1)
											&& !hasType('Single') && (formData.has('release_group') || hasType('DJ-mix', 'Remix', 'Live')
											|| descriptors.some(RegExp.prototype.test.bind(/^(?:mix(?:ed|tape)|remix|RMX)\b/i))))
										return false;
									return Promise.all(media.map(function(medium, mediumIndex) {
										if (!medium || !Array.isArray(medium.tracks) || canContainVideo(medium))
											return false;
										return Promise.all(medium.tracks.map(function(track, trackIndex) {
											if (params.recordingsLookup > 1 || !['DJ Mix', 'Remix', 'Mixed By'].some(function(role) {
												const hasRole = trackArtist => trackArtist != null && trackArtist.extraartists
													&& trackArtist.extraartists.some(extraArtist => getRoles(extraArtist).includes(role));
												return track.trackArtists && track.trackArtists.length > 0 ?
													track.trackArtists.some(hasRole) : hasRole(resolveArtists(track));
											})) return recordingsLookup(medium, track, getMBID).then(function(recordings) {
												if ((recordings = recordings.filter(recording => !/\b(?:(?:re)?mix(?:ed)?|RMX|rework(?:ed)?|live)\b/i
														.test(recording.disambiguation))).length <= 0) return Promise.reject('No matches');
												formData.set(`mediums.${mediumIndex}.track.${trackIndex}.recording`, recordings[0].id);
												let notifyText = `MBID for recording <b>${track.name}</b> found`, firstRelease = [ ];
												if (recordingDate(recordings[0])) firstRelease.push('<b>' +
													getReleaseYear(recordingDate(recordings[0])) + '</b>');
												if (recordings[0].releases && recordings[0].releases.length > 0) {
													const release = recordings[0].releases.length > 1 ? recordings[0].releases.find(release =>
														release.date == recordingDate(recordings[0])) : recordings[0].releases[0];
													if (release) {
														let releaseType = release['release-group'] && release['release-group']['primary-type'];
														if (releaseType && releaseType.toUpperCase() != releaseType) releaseType = releaseType.toLowerCase();
														if (releaseType && release['release-group']['secondary-types']
																&& release['release-group']['secondary-types'].includes('Live'))
															releaseType = 'live ' + releaseType;
														firstRelease.push('on <b>' + (releaseType ? releaseType + ' ' + release.title : release.title) + '</b>');
													}
												}
												if (firstRelease.length > 0) notifyText += ` (first released ${firstRelease.join(' ')})`;
												notify(notifyText, 'orange');
												if (debugLogging) console.debug('Closest recordings for track %o:', track, recordings);
											}).catch(reason => { /*console.info('No recording for track %o found (%s)', track, reason)*/ });
										}));
									}));
								}).then(() => Promise.all(relationWorkers));
							});
						}),
						annotation,
						masterWorker,
						languageDetector,
					]).then(() => formData);
				});
			}
			function seedFromAllMusic(formData, allMusicId, params, cdLengths) {
				function getReleaseMeta(allMusicId) {
					if (!allMusicId) throw 'Invalid argument';
					const origin = 'https://www.allmusic.com';
					const idExtractor = (url, entity) => url && entity
						&& (entity = new RegExp(`\\bm${entity}\\d{10}\\b`, 'i').exec(url)) != null ? entity[0] : undefined;
					return globalXHR(origin + '/album/release/' + allMusicId).then(function(response) {
						function coverResolver(element) {
							if (element instanceof HTMLImageElement) try {
								if (!(element = new URL(element.src)).pathname.includes('/images/no_image/')) {
									element.searchParams.set('f', 0);
									return element.href;
								}
							} catch(e) { console.warn(e) }
						}
						function trackListingAdapter(body) {
							if (!(body instanceof HTMLBodyElement)) return null;
							const media = Array.from(body.querySelectorAll('div#trackListing div.disc'), function(disc) {
								const medium = {
									title: textResolver(disc.querySelector(':scope > h3')),
									tracks: Array.from(disc.querySelectorAll(':scope > div.track'), track => ({
										trackNum: textResolver(track.querySelector('div.trackNum')),
										title: textResolver(track.querySelector('div.title > a:first-of-type')),
										artists: Array.from(track.querySelectorAll('div.performer > a'), artistMapper),
										featArtists: Array.from(track.querySelectorAll('span.featuring > a'), artistMapper),
										composers: Array.from(track.querySelectorAll('div.composer > a'), artistMapper),
										duration: textResolver(track.querySelector('div.duration')),
									})),
								};
								if (medium.title && !(medium.title = medium.title.replace(/^Disc\s+\d+(?:\s*[:\-])?\s*/, '')))
									medium.title = undefined;
								return medium.tracks.length > 0 ? medium : null;
							});
							return media.filter(Boolean).length > 0 ? media : null;
						}
						function creditsAdapter(body) {
							if (!(body instanceof HTMLBodyElement)) return null;
							const credits = { artists: [ ], featArtists: [ ], extraArtists: { } };
							body.querySelectorAll('table.creditsTable > tbody > tr').forEach(function(tr) {
								const artists = Array.from(tr.querySelectorAll('td.singleCredit > span > a'), artistMapper);
								if (artists.length <= 0) return;
								let artistCredits = textResolver(tr.querySelector('span.artistCredits')) || 'Primary Artist';
								artistCredits = artistCredits.split(',').map(artist => artist.trim().toLowerCase());
								for (let artistCredit of artistCredits) switch (artistCredit) {
									case 'primary artist': Array.prototype.push.apply(credits.artists, artists); break;
									case 'featured artist': Array.prototype.push.apply(credits.featArtists, artists); break;
									default:
										if (!(artistCredit in credits.extraArtists)) credits.extraArtists[artistCredit] = [ ];
										Array.prototype.push.apply(credits.extraArtists[artistCredit], artists);
								}
							});
							return credits;
						}
						function urlResolver(elem) {
							if (elem instanceof HTMLElement) try { return new URL(elem.getAttribute('href'), origin).href }
								catch(e) { console.warn(e) }
						}

						const ajaxAdapter = url => globalXHR(url, { headers: { Referer: response.finalUrl } })
								.then(({document}) => document.body).catch(function(reason) {
							console.warn(reason);
							return null;
						});
						const reviewAdapter = body => body instanceof HTMLBodyElement ?
							body.querySelector('div#review') : null;
						const textResolver = elem => elem instanceof Element ? elem.textContent.trim() : undefined;
						const artistMapper = elem => elem instanceof Element ? {
							name: textResolver(elem),
							id: idExtractor(elem.href, 'n'),
						} : undefined;
						const body = response.document.body, release = {
							id: idExtractor(response.finalUrl, 'r'),
							url: response.finalUrl,
							title: textResolver(body.querySelector('h1#releaseTitle')),
							artists: Array.from(body.querySelectorAll('div#releaseHeadline > h2 > a'), artistMapper),
							date: textResolver(body.querySelector('div#basicInfoMeta > div.releaseDate > span')),
							format: textResolver(body.querySelector('div#basicInfoMeta > div.format > span')),
							labels: Array.from(body.querySelectorAll('div#basicInfoMeta > div.label a'), artistMapper),
							catalogNumber: textResolver(body.querySelector('div#basicInfoMeta > div.catalogNumber > span')),
							genres: Array.from(body.querySelectorAll('div#basicInfoMeta > div.genre a'), textResolver),
							styles: Array.from(body.querySelectorAll('div#basicInfoMeta > div.styles a'), textResolver),
							releaseTypes: textResolver(body.querySelector('div#basicInfoMeta > div.releaseInfo > div')),
							recordingDate: textResolver(body.querySelector('div.recording-date > div')),
							recordingLocations: Array.from(body.querySelectorAll('div#basicInfoMeta > div.recordingLocation > div:not([id])'), textResolver),
							cover: coverResolver(body.querySelector('div#releaseCover img')),
						};
						let mainAlbum = body.querySelector('div#mainAlbumMeta a');
						if (mainAlbum != null) mainAlbum = urlResolver(mainAlbum);
						mainAlbum = mainAlbum ? Promise.all([
							globalXHR(mainAlbum).then(response => ({
								id: idExtractor(response.finalUrl, 'w'),
								url: response.finalUrl,
								title: textResolver(response.document.body.querySelector('h1#albumTitle')),
								artists: Array.from(response.document.body.querySelectorAll('h2#albumArtists > a'), artistMapper),
								date: textResolver(response.document.body.querySelector('div#basicInfoMeta > div.release-date > span')),
								genres: Array.from(response.document.body.querySelectorAll('div#basicInfoMeta > div.genre a'), textResolver),
								styles: Array.from(response.document.body.querySelectorAll('div#basicInfoMeta > div.styles a'), textResolver),
								recordingDate: textResolver(response.document.body.querySelector('div.recording-date > div')),
								recordingLocations: Array.from(response.document.body.querySelectorAll('div#basicInfoMeta > div.recording-location > div:not([id])'), textResolver),
								cover: coverResolver(response.document.body.querySelector('div#albumCover img')),
							})).catch(function(reason) {
								console.warn(reason);
								return null;
							}),
							ajaxAdapter(mainAlbum + '/trackListingAjax').then(trackListingAdapter),
							ajaxAdapter(mainAlbum + '/creditsAjax').then(creditsAdapter),
							ajaxAdapter(mainAlbum + '/reviewAjax').then(reviewAdapter),
						]).then(([mainAlbum, media, artistCredits, review]) => mainAlbum && Object.assign(mainAlbum, {
							media: media,
							artistCredits: artistCredits,
							review: review,
						})) : Promise.resolve(null);
						let [trackListingAjax, creditsAjax, reviewAjax] = ['trackListingAjax', 'creditsAjax', 'reviewAjax']
							.map(ajax => ajaxAdapter(response.finalUrl + '/' + ajax));
						trackListingAjax = trackListingAjax.then(trackListingAdapter);
						creditsAjax = creditsAjax.then(creditsAdapter);
						reviewAjax = reviewAjax.then(reviewAdapter);
						if (release.releaseTypes && (release.releaseTypes = release.releaseTypes.split(/\r?\n/)
								.map(releaseType => releaseType.trim()).filter(Boolean)).length <= 0)
							release.releaseTypes = undefined;
						return Promise.all([release, mainAlbum, trackListingAjax, creditsAjax, reviewAjax]);
					}).then(([release, mainAlbum, media, artistCredits, review]) => Object.assign(release, {
						mainAlbum: mainAlbum,
						media: media,
						artistCredits: artistCredits,
						review: review,
					}));
				}

				if (!formData || typeof formData != 'object' || !allMusicId) throw 'Invalid argument';
				params = Object.assign({
					tracklist: true,
					mbidLookup: true, recordingsLookup: true, lookupArtistsByRecording: true, rgLookup: true,
					searchSize: GM_getValue('mbid_search_size', 30),
					languageIdentifier: true,
					openInconsistent: GM_getValue('open_inconsistent', true),
					assignUncertain: GM_getValue('assign_uncertain', false),
					extendedMetadata: false, releaseRelations: false, recordingRelations: false, workRelations: false,
				}, params);
				const amOrigin = 'https://www.allmusic.com';
				return getReleaseMeta(allMusicId).then(function(release) {
					function addLookupEntry(entity, entry, context) {
						console.assert(entity && entry && context);
						if (!entity || !entry || !context) throw 'Invalid argument'; else if (!entry.id) return;
						if (!(entity in lookupIndexes)) lookupIndexes[entity] = { };
						if (!(entry.id in lookupIndexes[entity])) lookupIndexes[entity][entry.id] = {
							name: entry.name,
							contexts: [context],
						}; else if (!lookupIndexes[entity][entry.id].contexts.includes(context))
							lookupIndexes[entity][entry.id].contexts.push(context);
					}
					function sameArtists(...roots) {
						if (roots.length < 2 && !roots.includes(release)) roots.unshift(release);
						const sameArtists = (...artists) => artists.length > 0 && (prop =>
							artists.every(artist1 => artists.every(artist2 => artist2[prop] == artist1[prop])))
								(artists.every(artist => artist.id) ? 'id' : 'name');
						return roots.length > 0 && ['artists', 'featArtists'].every(propName =>
							roots.every(entry1 => roots.every(entry2 => (entry2[propName] || [ ]).every(artist2 =>
								(entry1[propName] || [ ]).some(artist1 => sameArtists(artist1, artist2))))));
					}
					function seedArtists(root, prefix, offset = 0) {
						function seedArtist(artist, index, array, prefix, offset = 0) {
							prefix = `${prefix || ''}artist_credit.names.${offset + index}`;
							formData.set(`${prefix}.artist.name`, capitalizeName(artist.name));
							if (index < array.length - 1)
								formData.set(`${prefix}.join_phrase`, index < array.length - 2 ? ', ' : ' & ');
							else formData.delete(`${prefix}.join_phrase`);
							addLookupEntry('artist', artist, prefix);
						}

						if (!root) throw 'Invalid argument';
						if (root.artists.length <= 0) return offset;
						root.artists.forEach((artist, index, array) => { seedArtist(artist, index, array, prefix, offset) });
						offset += root.artists.length;
						if (!root.featArtists || root.featArtists.length <= 0) return offset;
						formData.set(`${prefix || ''}artist_credit.names.${offset - 1}.join_phrase`, fmtJoinPhrase('feat.'));
						root.featArtists.forEach((extraArtist, index, array) =>
							{ seedArtist(extraArtist, index, array, prefix, offset) });
						return offset += root.featArtists.length;
					}
					function addUrlRef(url, linkType) {
						formData.set(`urls.${++urlRelIndex}.url`, url);
						if (linkType != undefined) formData.set(`urls.${urlRelIndex}.link_type`, linkType);
					}
					function openInconsistent(entity, allMusicId, mbids, subpage) {
						Array.from(mbids).reverse().forEach(mbid =>
							{ GM_openInTab([mbOrigin, entity, mbid, subpage].filter(Boolean).join('/'), true) });
						GM_openInTab([amOrigin, amEntity(entity), allMusicId].join('/'), true);
					}
					function saveToCache(entity, allMusicId, mbid) {
						amBindingsCache[entity][allMusicId] = mbid;
						GM_setValue('allmusic_to_mb_bindings', amBindingsCache);
					}
					function getCachedMBID(entity, allMusicId, mbEntity = entity) {
						if (!amBindingsCache) {
							const timeStamp = Date.now();
							if (!(amBindingsCache = GM_getValue('allmusic_to_mb_bindings')))
								amBindingsCache = { artist: { }, label: { } };
							else console.info('AllMusic to MB bindings cache loaded:', Object.keys(amBindingsCache).map(key =>
								`${Object.keys(amBindingsCache[key]).length} ${(key + 's').replace(/s(?=s$)/, '')}`).join(', '));
							GM_addValueChangeListener('allmusic_to_mb_bindings',
								(name, oldVal, newVal, remote) => { if (remote) amBindingsCache = newVal });
						}
						if (!(entity in amBindingsCache)) amBindingsCache[entity] = { };
						if (!(allMusicId in amBindingsCache[entity])) return Promise.reject('Not cached');
						if (!rxMBID.test(amBindingsCache[entity][allMusicId])) return Promise.resolve(null);
						return globalXHR(`${mbOrigin}/${entity}/${amBindingsCache[entity][allMusicId]}`, {
							method: 'HEAD', redirect: 'follow', anonymous: true,
						}).then(function(response) {
							if (response.status < 200 || response.status >= 400) return Promise.reject(response.statusText);
							response = mbIdExtractor(response.finalUrl, mbEntity);
							if (!response) return Promise.reject('Cached check failed');
							console.log('Entity binding for', entity, allMusicId, 'got from cache');
							allMusicName(entity, allMusicId).then(name =>
								{ notify(`MBID for ${entity} ${name} got from cache`, 'sandybrown') });
							if (response != amBindingsCache[entity][allMusicId]) saveToCache(entity, allMusicId, response); //return Promise.reject('Location changed');
							return response;
						});
					}
					function searchQueryBuilder(entity, allMusicId) {
						function addField(field, expr) {
							if (!(field in query)) query[field] = [ ];
							query[field].push(expr);
						}

						if (!(entity in lookupIndexes) || !(allMusicId in lookupIndexes[entity])) return;
						const query = { }, name = lookupIndexes[entity][allMusicId].name;
						for (let field of [entity, 'alias', 'sortname']) addField(field, '"' + name + '"');
						switch (entity) {
							case 'label': {
								const bareName = labelMapper(name.replace(...rxBareLabel));
								for (let field of [entity, 'alias']) addField(field, '"' + bareName + '"');
								break;
							}
						}
						return Object.keys(query).map(field => query[field].map(expr => `${field}:(${expr})`).join(' OR ')).join(' OR ');
					}
					function findMBIDByCommonTitles(entity, allMusicId, mbids) {
						function resolveUrl(elem) {
							if (elem instanceof HTMLElement) try {
								return new URL(elem.getAttribute('href'), amOrigin).href;
							} catch(e) { console.warn(e) } else throw 'Invalid argument';
						}
						function getAllMusicArtistReleases(releaseType = 1, ajax) {
							const url = [amOrigin, amEntity(entity), allMusicId, 'discographyAjax'];
							if (ajax) url.push(ajax);
							return globalXHR(url.join('/'), {
								headers: { Referer: [amOrigin, amEntity(entity), allMusicId].join('/') },
							}).then(({document}) => Array.from(document.body.querySelectorAll('table.discographyTable > tbody > tr'), function(album) {
								const [year, title] = ['td.year', 'td.meta > span.title > a:first-of-type']
									.map(selector => album.querySelector(selector));
								if (title != null) return {
									id: allMusicIdExtractor(resolveUrl(title), 'album') || allMusicIdExtractor(resolveUrl(title), 'album/release'),
									year: year != null ? parseInt(year.textContent) : undefined,
									title: title.textContent.trim(),
									releaseType: releaseType,
								};
							}).filter(Boolean), console.error);
						}

						if (!entity || !allMusicId || !Array.isArray(mbids)) return Promise.reject('Invalid argument');
						if (mbids.length <= 0) return Promise.reject('No MusicBrainz entries');
						const amReleasesWorker = Promise.all([
							getAllMusicArtistReleases(1, 'main'),
							getAllMusicArtistReleases(2, 'compilations'),
							getAllMusicArtistReleases(3, 'singles'),
							getAllMusicArtistReleases(4, 'others'),
						]).then(discography => Array.prototype.concat.apply([ ], discography.filter(Boolean)));
						const amSongsWorker = (function getAllMusicArtistSongs(page = 1) {
							const url = [amOrigin, amEntity(entity), allMusicId, 'songsAjax', 'all', page];
							return globalXHR(url.join('/'), {
								headers: { Referer: [amOrigin, amEntity(entity), allMusicId].join('/') },
							}).then(function({document}) {
								const songs = Array.from(document.body.querySelectorAll('div.singleSongResult'), function(song) {
									const title = song.querySelector('span.songTitle > a');
									if (title != null) return {
										id: allMusicIdExtractor(resolveUrl(title), 'song'),
										title: title.title || title.textContent.trim(),
									};
								}).filter(Boolean);
								return document.querySelector('div#paginationContainer > button.paginationNext:not(.disabled)') != null ?
									getAllMusicArtistSongs(page + 1).then(Array.prototype.concat.bind(songs)) : songs;
							}, console.error);
						})();
						const rgLookupAdapter = (mbid, params) => Promise.all(params.map(param =>
							mbLookupById('release', param, mbid, ['aliases', 'release-groups', 'url-rels', 'release-group-level-rels'])
								.then(releases => releases.map(release => release['release-group'])
									.filter((rg1, ndx, rgs) => rgs.findIndex(rg2 => rg2.id == rg1.id) == ndx), reason => null)));
						const lookupMethods = [{
							worker: {
								artist: mbid => rgLookupAdapter(mbid, ['artist', 'track_artist']),
								label: mbid => rgLookupAdapter(mbid, [entity]),
							}[entity],
							resolver: mbReleaseGroups => amReleasesWorker.then(function(amAlbums) {
								function openUncertain() {
									GM_openInTab([mbOrigin, entity, mbids[hiIndex], 'releases'].join('/'), true);
									GM_openInTab([amOrigin, amEntity(entity), allMusicId].join('/'), true);
								}

								const testFn = RegExp.prototype.test.bind(rxMBID);
								if (mbReleaseGroups.filter(testFn).length == 1) {
									const mbid = mbReleaseGroups.find(testFn);
									allMusicName(entity, allMusicId).then(name =>
										{ notify(`MBID for ${entity} ${name} found by having AllMusic relative set`, 'salmon') });
									return mbid;
								}
								mbReleaseGroups = mbReleaseGroups.map(mbResults => Array.isArray(mbResults) ? Array.prototype.concat.apply([ ],
									mbResults.map((lookupResults, lookupIndex) => (lookupResults || [ ]).map(lookupResult =>
										Object.assign({ lookupIndex: lookupIndex }, lookupResult)))).filter((lookupResult1, index, arr) =>
											arr.findIndex(lookupResult2 => lookupResult2.id == lookupResult1.id) == index) : [ ]);
								const mutualScores = mbReleaseGroups.map(mbReleaseGroups => mbReleaseGroups.reduce(function(score, mbReleaseGroup) {
									if (!mbReleaseGroup) return score;
									let albums = Array.prototype.concat.apply([ ], getAllMusicRels(mbReleaseGroup, 'album')
										.map(allMusicId => amAlbums.filter(albums => albums.id == allMusicId)));
									if (albums.length <= 0 && (!mbReleaseGroup['first-release-date']
											|| !mbReleaseGroup.title || (albums = amAlbums.filter(function(amAlbum) {
										if (!(amAlbum.year > 0) || amAlbum.year != getReleaseYear(mbReleaseGroup['first-release-date']))
											return false;
										return sameTitleMapper(mbReleaseGroup, amAlbum.title, sameStringValues, releaseTitleNorm);
									})).length <= 0)) return score;
									if (debugLogging) console.debug('Found matching releases:', mbReleaseGroup, albums);
									return score + (mbReleaseGroups.lookupIndex == 1
										|| albums.every(album => album.releaseType == 2) ? 1/2 : 1);
								}, 0));
								const hiScore = Math.max(...mutualScores);
								if (debugLogging) console.debug('Common titles lookup method #1: Entity:', entity,
									'AllMusic ID:', allMusicId, 'MBIDs:', mbids, 'Scores:', mutualScores, 'HiScore:', hiScore);
								if (!(hiScore > 0)) return Promise.reject('No matches by common releases');
								const hiIndex = mutualScores.indexOf(hiScore);
								if (!(hiScore >= 1)) if (params.assignUncertain) openUncertain();
									else return Promise.reject('Not found by common releases');
								const dataSize = Math.min(amAlbums.length, mbReleaseGroups[hiIndex].length);
								if (!(hiScore * 50 >= dataSize)) if (!params.assignUncertain)
									return Promise.reject('matched by common releases with too low match rate');
								else if (hiScore >= 1) openUncertain();
								console.log('Entity binding found by having score %f:\n%s\n%s',
									hiScore, [amOrigin, amEntity(entity), allMusicId].join('/'),
									[mbOrigin, entity, mbids[hiIndex], 'releases'].join('/'));
								if (mutualScores.filter(score => score > 0).length > 1) {
									console.log('Matches by more entities:', mutualScores.map((score, index) =>
										score > 0 && [mbOrigin, entity, mbids[index], 'releases'].join('/') + ' (' + score + ')').filter(Boolean));
									if (mutualScores.reduce((sum, score) => sum + score, 0) >= hiScore * 1.5)
										return Promise.reject('Ambiguity (releases)');
									beep.play();
									if (params.openInconsistent) openInconsistent(entity, allMusicId,
										mutualScores.map((score, index) => score > 0 && mbids[index]).filter(Boolean), 'releases');
								}
								allMusicName(entity, allMusicId).then(name =>
									{ notify(`MBID for ${entity} ${name} found by score <b>${hiScore.toFixed(1)}</b> out of ${dataSize} release(s)`, 'gold') });
								saveToCache(entity, allMusicId, mbids[hiIndex]);
								return mbids[hiIndex];
							}),
						}, {
							worker: { artist: getArtistRecordings }[entity],
							resolver: mbRecordings => amSongsWorker.then(function(amSongs) {
								function openUncertain() {
									GM_openInTab([mbOrigin, entity, mbids[hiIndex], 'recordings'].join('/'), true);
									GM_openInTab([amOrigin, amEntity(entity), allMusicId].join('/'), true);
								}

								const mutualScores = mbRecordings.map(recordings =>
										[amSongs, recordings].every(Array.isArray) ? recordings.reduce(function(score, recording) {
									if (!recording) return score;
									let songs = amSongs.filter(amSong =>
										sameTitleMapper(recording, amSong.title, sameStringValues, title => title && [
											/\s+\((?:(?:feat(?:\b|\.|uring)|ft\.).+|.+\b(?:re)?mix|live|(?:en|ao) (?:vivo|directo?))\)$/i,
										].reduce((str, rx) => str.replace(rx, ''), title.trim())));
									if (songs.length <= 0) return score;
									if (debugLogging) console.debug('Found matching releases:', recording, releases);
									return score + 0.5 + (songs.length - 1) * 0.25;
								}, 0) : 0);
								const hiScore = Math.max(...mutualScores);
								if (debugLogging) console.debug('Common titles lookup method #2: Entity:', entity,
									'AllMusic ID:', allMusicId, 'MBIDs:', mbids, 'Scores:', mutualScores, 'HiScore:', hiScore);
								if (!(hiScore > 0)) return Promise.reject('No matches by common recordings');
								const hiIndex = mutualScores.indexOf(hiScore);
								if (!(hiScore >= 1)) if (params.assignUncertain) openUncertain();
									else return Promise.reject('Not found by common recordings');
								const dataSize = Math.min(amSongs.length, mbRecordings[hiIndex].length);
								if (!(hiScore * 50 >= dataSize)) if (!params.assignUncertain)
									return Promise.reject('Matched by common recordings with too low score');
								else if (hiScore >= 1) openUncertain();
								console.log('Entity binding found by having score %f:\n%s\n%s',
									hiScore, [amOrigin, amEntity(entity), allMusicId].join('/'),
									[mbOrigin, entity, mbids[hiIndex], 'recordings'].join('/'));
								if (mutualScores.filter(score => score > 0).length > 1) {
									console.log('Matches by more entities:', mutualScores.map((score, index) =>
										score > 0 && [mbOrigin, entity, mbids[index], 'recordings'].join('/') + ' (' + score + ')').filter(Boolean));
									if (mutualScores.reduce((sum, score) => sum + score, 0) >= hiScore * 1.5)
										return Promise.reject('Ambiguity (recordings)');
									beep.play();
									if (params.openInconsistent) openInconsistent(entity, allMusicId,
										mutualScores.map((score, index) => score > 0 && mbids[index]).filter(Boolean), 'recordings');
								}
								allMusicName(entity, allMusicId).then(name =>
									{ notify(`MBID for ${entity} ${name} found by score <b>${hiScore.toFixed(1)}</b> out of ${mbRecordings.length} recording(s)`, 'gold') });
								saveToCache(entity, allMusicId, mbids[hiIndex]);
								return mbids[hiIndex];
							}),
						}];
						return Promise.all(mbids.map(mbid => entity in amBindingsCache
								&& Object.values(amBindingsCache[entity]).includes(mbid) ? Promise.resolve(null)
									: mbApiRequest(entity + '/' + mbid, { inc: `aliases+url-rels+${entity}-rels` }).then(function(mbEntry) {
							const allMusicIds = getAllMusicRels(mbEntry, amEntity(entity));
							if (allMusicIds.includes(allMusicId)) return allMusicIds.length < 2 ? mbEntry.id : true;
							if (allMusicIds.length > 0) return null;
							return true;
						}).catch(function(reason) {
							console.warn(reason);
							return true;
						}))).then(function(statuses) {
							let lookupMethod = statuses.filter(mbIdExtractor);
							if (lookupMethod.length == 1) {
								allMusicName(entity, allMusicId).then(name =>
									{ notify(`MBID for ${entity} ${name} found by sharing same relation`, 'cyan') });
								saveToCache(entity, allMusicId, lookupMethod[0]);
								return lookupMethod[0];
							} else lookupMethod = (methodIndex = 0) => methodIndex < lookupMethods.length ?
								(['worker', 'resolver'].every(fn => typeof lookupMethods[methodIndex][fn] == 'function') ?
									Promise.all(statuses.map((status, mbidIndex) => status
										&& lookupMethods[methodIndex].worker(mbids[mbidIndex]).catch(reason => null)))
								 			.then(resolved => lookupMethods[methodIndex].resolver(resolved))
									: Promise.reject('Method not implemented')).catch(reason => lookupMethod(methodIndex + 1))
								: Promise.reject('Not found by common titles');
							return lookupMethod();
						});
					}
					function findMBID(entity, allMusicId) {
						let promise = getCachedMBID(entity, allMusicId).catch(reason => findAllMusicRelatives(entity, allMusicId).then(function(entries) {
							console.assert(entries.length > 0);
							console.assert(entries.length == 1, 'Ambiguous %s linkage for AllMusic id', entity, allMusicId, entries);
							if (entries.length > 1) return params.searchSize > 0 ?
								findMBIDByCommonTitles(entity, allMusicId, entries.map(entry => entry.id)) : Promise.reject('Ambiguity');
							allMusicName(entity, allMusicId).then(name =>
								{ notify(`MBID for ${entity} ${name} found by having AllMusic relative set`, 'salmon') });
							saveToCache(entity, allMusicId, entries[0].id);
							return entries[0].id;
						}));
						if (params.searchSize > 0) promise = promise.catch(function(reason) {
							if (/^(?:Ambiguity)\b/.test(reason)) return Promise.reject(reason);
							if (!(entity in lookupIndexes) || !(allMusicId in lookupIndexes[entity]))
								return Promise.reject(`Assertion failed: ${entity}/${allMusicId} not in lookup indexes`);
							return mbApiRequest(entity, {
								query: searchQueryBuilder(entity, allMusicId),
								limit: params.searchSize,
							}).then(results => findMBIDByCommonTitles(entity, allMusicId,
								results[(entity + 's').replace(/s(?=s$)/, '')].map(result => result.id)));
						});
						return promise;
					}
					function purgeArtists(fromIndex = 0) {
						const artistSuffixes = ['mbid', 'name', 'artist.name', 'join_phrase'];
						const key = (ndx, sfx) => `artist_credit.names.${ndx}.${sfx}`;
						for (let ndx = 0; artistSuffixes.some(sfx => formData.has(key(ndx, sfx))); ++ndx)
							artistSuffixes.forEach(sfx => { formData.delete(key(ndx, sfx)) });
					}
					function layoutMatch(media) {
						if (!media) return -Infinity; else if (!Array.isArray(cdLengths) || cdLengths.length <= 0) return 0;
						if ((media = media.filter(isCD)).length != cdLengths.length) return -2;
						if (media.every((medium, mediumIndex) => medium.tracks.length == cdLengths[mediumIndex])) return 3;
						if (media.every((medium, mediumIndex) => medium.tracks.length == cdLengths[mediumIndex]
								|| medium.format == 'Enhanced CD' && medium.tracks.length > cdLengths[mediumIndex])) return 2;
						if (cdLengths.length > 1) {
							const index = { };
							for (let key of cdLengths) if (!(key in index))
								index[key] = media.filter(medium => medium.tracks.length == key).length;
							if (Object.keys(index).every(key1 => index[key1] == cdLengths.filter(key2 =>
									key2 == parseInt(key1)).length)) {
								notify('Tracks layout matched to reordered logs', 'blue');
								return 1;
							}
						}
						return -1;
					}

					if (debugLogging) console.debug('AllMusic release metadata for %s:', allMusicId, release);
					if (!params.mbidLookup) params.searchSize = 0;
					if (params.recordingRelations || params.workRelations) params.tracklist = true;
					const literals = { }, lookupIndexes = { artist: { }, label: { } };
					const trackMainArtists = track => (track.artists.length > 0 ? track : release).artists;
					const allMusicName = (entity, allMusicId) =>
						(entity in lookupIndexes && allMusicId in lookupIndexes[entity] ?
							Promise.resolve(lookupIndexes[entity][allMusicId].name)
						 		: globalXHR([amOrigin, amEntity(entity), allMusicId].join('/')).then(({document}) =>
									(document = document.body.querySelector('body div[id$="Header"] > div[id$="Headline"] > h1')) != null ?
										document.textContent.trim() : Promise.reject('Entity title not found'))
											.catch(reason => entity + '#' + allMusicId)).then(name => '<b>' + name + '</b>');
					const hasType = (...types) => types.some(type => formData.getAll('type').includes(type));
					formData.set('name', normTitle(release.title));
					if (/ +\([^\(\)]*\b(?:live|(?:en|ao) (?:vivo|directo?))\b[^\(\)]*\)$/i.test(release.title))
						formData.append('type', 'Live');
					if (/ +\([^\(\)]*\b(?:soundtrack|score)\b[^\(\)]*\)$/i.test(release.title)
							|| release.style && release.style.includes('Soundtrack'))
						formData.append('type', 'Soundtrack');
					if (release.labels) release.labels.forEach(function(label, index) {
						const prefix = 'labels.' + index;
						if (label.name) {
							if (rxNoLabel.test(label.name))
								formData.set(prefix + '.mbid', '157afde4-4bf5-4039-8ad2-5a15acc85176');
							else {
								formData.set(prefix + '.name', capitalizeName(label.name));
								if (label.id) addLookupEntry('label', label, prefix);
							}
						}
						if (release.catalogNumber) formData.set(prefix + '.catalog_number',
							rxNoCatno.test(release.catalogNumber) ? '[none]' : release.catalogNumber);
					});
					if (!Array.isArray(cdLengths) || cdLengths.length <= 0) cdLengths = false;
					const media = params.tracklist ? release.media || release.mainAlbum && release.mainAlbum.media : null;
					const artistCredits = release.artistCredits/* || release.mainAlbum && release.mainAlbum.artistCredits*/;
					if (params.tracklist) if (media != null) media.forEach(function(medium, mediumIndex) {
						if (medium.format || release.format)
							formData.set(`mediums.${mediumIndex}.format`, medium.format || release.format);
						if (medium.title) formData.set(`mediums.${mediumIndex}.name`, normTitle(medium.title));
						medium.tracks.forEach(function(track, trackIndex) {
							const prefix = `mediums.${mediumIndex}.track.${trackIndex}.`;
							if (track.trackNum) formData.set(prefix + 'number', track.trackNum);
							if (track.title) {
								formData.set(prefix + 'name', normTitle(track.title));
								frequencyAnalysis(literals, track.title);
							}
							if (!sameArtists(release, track)) seedArtists(track, prefix);
							if (track.duration) formData.set(prefix + 'length', track.duration);
						});
					}); else console.log('AllMusic release', release.id, 'has no tracklist, track names won\'t be seeded');
					if (Object.keys(literals).length > 0) guessTextRepresentation(formData, literals);
					const languageDetector = params.languageIdentifier && media
						&& languageIdentifier(media.map(medium => medium.tracks.map(track =>
							track.title.replace(...bracketStripper) + '.').join(' ')).join(' ')).then(function(result) {
								/*if (!formData.has('language')) */formData.set('language', result.iso6393);
								if (params.extendedMetadata) formData.set('language_en', result.language);
								notify(`<b>${result.language}</b> identified as release language`, 'deeppink');
							}, reason => { console.warn('Remote language identification failed') });
					let urlRelIndex = -1, relationResolvers = { }, artistRelsIndex = {
						'work': { 'composer': 55 },
						'recording': { },
						'release': { 'producer': 30, 'mastering': 42 },
						'release-group': { },
					};
					const findEntities = role => Object.keys(artistRelsIndex)
						.filter(entity => role in artistRelsIndex[entity]);
					addUrlRef([amOrigin, 'album/release', release.id].join('/'), 755);
					if (params.rgRelations && release.mainAlbum != null)
						addUrlRef([amOrigin, 'album', release.mainAlbum.id].join('/'), 284);
					const relateToEntity = sourceEntity => ({
						'work': params.workRelations,
						'recording': params.recordingRelations,
						'release': params.releaseRelations,
						'release-group': params.rgRelations,
					}[sourceEntity]);
					if (artistCredits != null) {
						for (let role in artistCredits.extraArtists) {
							const entities = findEntities(role);
							if (entities.length <= 0 && !(role in relationResolvers)) {
								const resolvers = [ ];
								if (params.recordingRelations) resolvers.push(() => instrumentResolver(role).then(instrumentMapper));
								for (let resolver of resolvers) if (!relationResolvers[role]) relationResolvers[role] = resolver();
									else relationResolvers[role] = relationResolvers[role].catch(resolver);
							}
							if (entities.length > 0 ? entities.some(relateToEntity) : role in relationResolvers)
								for (let extraArtist of artistCredits[role]) addLookupEntry('artist', extraArtist, role);
						}
						if ('dj mix' in artistCredits.extraArtists) formData.append('type', 'DJ-mix');
					}
					purgeArtists();
					if (release.releaseTypes) for (let releaseType of release.releaseTypes) switch (releaseType) {
					}
					if (hasType('DJ-mix') || hasType('Compilation') && formData.get('artist_credit.names.0.mbid') == mbidVA)
						formData.set('artist_credit.names.0.mbid', mbidVA);
					else seedArtists(release);
					formData.set('edit_note', ((formData.get('edit_note') || '') +
						`\nSeeded from AllMusic release id ${release.id}`).trimLeft());
					const rgLookupWorkers = [ ];
					if (params.rgLookup && params.mbidLookup && !formData.has('release_group') && release.mainAlbum)
						rgLookupWorkers.push(findAllMusicRelatives('release-group', release.mainAlbum.id).then(function(releaseGroups) {
							console.assert(releaseGroups.length > 0);
							console.assert(releaseGroups.length == 1, 'Ambiguous master %d release referencing:', release.master_id, releaseGroups);
							return releaseGroups.length == 1 ? releaseGroups[0] : Promise.reject('Ambiguity');
						}).catch(reason => null));
					if (params.extendedMetadata) {
						let tags = release.genres.concat(release.styles), tagIndex = -1;
						if (release.mainAlbum != null) {
							Array.prototype.push.apply(tags, release.mainAlbum.genres);
							Array.prototype.push.apply(tags, release.mainAlbum.styles);
						}
						for (let tag of (tags = tags.filter(uniqueValues))) formData.set(`tags.${++tagIndex}`, tag);
					}
					return Promise.all([
						getSessions(torrentId).catch(reason => null).then(function(sessions) {
							function recordingsLookup(medium, track, mbidLookupFn) {
								if (!track) throw 'Invalid argument';
								if (media == null) return Promise.reject('Missing media');
								if (!track.title) return Promise.reject('Missing track name');
								const mediumIndex = media.indexOf(medium);
								const trackIndex = medium && Array.isArray(medium.tracks) ? medium.tracks.indexOf(track) : -1;
								console.assert(mediumIndex >= 0 && trackIndex >= 0);
								if (layoutMatch > 2) var trackLength = (function getLengthFromTOC() {
									if (!sessions || !(mediumIndex >= 0) || !(trackIndex >= 0)) return;
									const tocEntries = getTocEntries(sessions[mediumIndex]);
									if (tocEntries[trackIndex]) return (tocEntries[trackIndex].endSector + 1 -
										tocEntries[trackIndex].startSector) * 1000 / 75;
								})();
								if (!(trackLength > 0) && track.duration) {
									if ((trackLength = /^(\d+):(\d+)$/.exec(track.duration)) != null)
										trackLength = trackLength.slice(1).reverse()
											.reduce((s, t, n) => s + parseInt(t) * 60**n, 0) * 1000;
									console.assert(trackLength > 0, track.length);
								}
								if (typeof mbidLookupFn != 'function') mbidLookupFn = undefined;
								if (!(trackLength > 0) && !mbidLookupFn) return Promise.reject('Missing track length');
								const maxLengthDifference = 5000;
								const artists = trackMainArtists(track);
								return artists != null ? mbApiRequest('recording', {
									query: (function queryBuilder() {
										const OR = (values, mapFn) => Array.isArray(values)
											&& (values = values.filter((recording, index, arr) => arr.indexOf(recording) == index)).length > 0
											&& typeof mapFn == 'function' ? '(' + values.map(mapFn).join(' OR ') + ')' : '';
										const fields = artists.map(function(artist) {
											const arid = typeof mbidLookupFn == 'function' && artist.id && mbidLookupFn('artist', artist.id);
											return arid ? 'arid:' + arid
												: '(artistname:"' + artist.name + '" OR creditname:"' + artist.name + '")';
										});
										fields.push(OR([track.title, track.title.replace(...bracketStripper)],
											title => 'recording:"' + title + '"'));
										if (trackLength > 0) fields.push(`(${[
											`dur:[${Math.max(Math.round(trackLength) - 5000, 0)} TO ${Math.round(trackLength) + 5000}]`,
											'(NOT dur:[* TO *])',
										].join(' OR ')})`);
										fields.push('video:false');
										return fields.join(' AND ');
									})(),
									limit: 100,
								}).then(function(recordings) {
									function recordingValidator(recording, lengthRequired = false, dateRequired = false) {
										if (recording.score < 90 || recording.video) return false;
										if (!sameStringValues(recording.title, track.title) && ['(?:re-?)?mix(?:ed)?|RMX'].some(function(rx) {
											rx = new RegExp('\\s+\\(.*\\b(?:' + rx + ')\\b.*\\)$', 'i');
											let recordingFlag = rx.test(recording.title), trackFlag = rx.test(track.title);
											return recordingFlag != trackFlag;
										})) return false;
										if (recording.releases && ['Live', 'Interview', 'Demo'].some(function(secondaryType) {
											const hasSecondaryType = release => 'release-group' in release
												&& 'secondary-types' in release['release-group']
												&& release['release-group']['secondary-types'].includes(secondaryType);
											return hasType(secondaryType) ? !recording.releases.every(hasSecondaryType)
												: recording.releases.some(hasSecondaryType);
										})) return false;
										if (!Array.isArray(recording['artist-credit']) || !artists.every(function(artist) {
											const arid = typeof mbidLookupFn == 'function' && mbidLookupFn('artist', artist.id);
											return recording['artist-credit'].some(arid ? artistCredit =>
													artistCredit.artist && artistCredit.artist.id == arid
												: artistCredit => artistCredit.artist && sameStringValues(artist.name, artistCredit.artist.name)
													|| artistCredit.name && sameStringValues(artist.name, artistCredit.name));
										})) return false;
										if (!recordingDate(recording) && dateRequired) return false;
										if (recording.length > 0 ? trackLength > 0 && deltaMapper(recording) > maxLengthDifference
												: lengthRequired || !mbidLookupFn) return false;
										return sameTitleMapper(recording, track.title, recordingDate(recording)
											&& deltaMapper(recording) < 1000 ? weakMatchMapper : deltaMapper(recording) < 3000 ?
												similarStringValues : sameStringValues);
									}

									const deltaMapper = recording => recording.length > 0 && trackLength > 0 ?
										Math.abs(recording.length - trackLength) : NaN;
									const weakMatchMapper = (...strings) => sameStringValues(...strings)
										|| strings.some(str1 => strings.every(str2 => str2.toLowerCase().startsWith(str1.toLowerCase())))
										|| strings.every(str => sameStringValues(...[str, strings[0]].map(str => str.replace(...bracketStripper))))
										|| similarStringValues(strings[0], strings[1]);
									if (recordings.count <= 0 || (recordings = recordings.recordings.filter(recording =>
											recordingValidator(recording, false, false))).length <= 0) return Promise.reject('No matches');
									return recordings.sort(function(...recordings) {
										const hasLength = recording => recording.length > 0;
										const cmpVal = fn => fn(recordings[0]) && !fn(recordings[1]) ? -1
											: fn(recordings[1]) && !fn(recordings[0]) ? +1 : 0;
										return [
											function() {
												if (!recordings.every(hasLength)) return;
												const deltas = recordings.map(deltaMapper);
												return deltas[0] < 1000 && deltas[1] >= 1000 || deltas[1] < 1000 && deltas[0] >= 1000
													|| Math.abs(deltas[0] - deltas[1]) >= 1000 ? Math.sign(deltas[0] - deltas[1]) : 0;
											}, () => recordings.every(recordingDate) ?
												recordingDate(recordings[0]).localeCompare(recordingDate(recordings[1])) : 0,
											() => cmpVal(recording => sameTitleMapper(recording, track.title)), function() {
												if (!recordings.every(hasLength)) return;
												const deltas = recordings.map(deltaMapper);
												return Math.sign(deltas[0] - deltas[1]);
											}, function() {
												if (!recordings.every(recording => Array.isArray(recording.releases))) return;
												const releases = recordings.map(recording => recording.releases.length);
												return Math.sign(releases[1] - releases[0]);
											}, () => cmpVal(recordingDate), () => cmpVal(hasLength),
										].reduce((result, cmpFn) => result || cmpFn(...recordings), undefined) || 0;
									});
								}) : Promise.reject('No artists for track');
							}

							const recordingDate = recording => recording['first-release-date'] || recording.date;
							const artistLookupWorkers = { };
							if (media != null && params.lookupArtistsByRecording && params.searchSize > 0)
								for (let medium of media) for (let track of medium.tracks) (function addArtistLookups(artists) {
									if (artists != null) for (let artist of artists) if (artist.id) {
										if (!(artist.id in artistLookupWorkers)) artistLookupWorkers[artist.id] = [ ];
										artistLookupWorkers[artist.id].push(recordingsLookup(medium, track).then(function(recordings) {
											const mbids = [ ];
											for (let recording of recordings) if (recording.score >= 90 && 'artist-credit' in recording)
												for (let artistCredit of recording['artist-credit'])
													if (artistCredit.artist && (sameStringValues(artist.name, artistCredit.artist.name)
															|| artistCredit.name && sameStringValues(artist.name, artistCredit.name)))
														mbids.push(artistCredit.artist.id);
											return mbids.length > 0 ? mbids : null;
										}).catch(reason => null));
									}
								})(trackMainArtists(track));
							if (params.searchSize > 0) for (let allMusicId in artistLookupWorkers)
								artistLookupWorkers[allMusicId] = Promise.all(artistLookupWorkers[allMusicId]).then(function(mbids) {
									const scores = { };
									for (let _mbids of mbids.filter(Boolean)) for (let mbid of _mbids)
										if (!(mbid in scores)) scores[mbid] = 1; else ++scores[mbid];
									return Object.keys(scores).length > 0 ? scores : Promise.reject('No matches');
								});
							return Promise.all(Object.keys(lookupIndexes).map(entity =>
									Promise.all(Object.keys(lookupIndexes[entity]).map(function(allMusicId) {
								function checkMBID(mbid) {
									console.assert(rxMBID.test(mbid), mbid);
									if (!rxMBID.test(mbid)) return Promise.reject('Invalid MBID');
									if (entity == 'artist' && allMusicId in artistLookupWorkers) artistLookupWorkers[allMusicId].then(function(mbids) {
										if (Object.keys(mbids).length > 1)
											console.warn('MBID for artist', [amOrigin, 'artist', allMusicId].join('/'),
												'can resolve to multiple entities:', printArtistMBIDs(mbids));
										if (!Object.keys(mbids).includes(mbid))
											console.warn('MBID for artist', [allMusicId, 'artist', allMusicId].join('/'),
												'matching different entities:', printArtistMBIDs(mbids));
										if (Object.keys(mbids).length > 1 || !Object.keys(mbids).includes(mbid)) {
											beep.play();
											if (params.openInconsistent)
												openInconsistent(entity, allMusicId, Object.keys(mbids), 'recordings');
										}
									});
									return mbid;
								}

								const printArtistMBIDs = mbids => Object.keys(mbids).map(mbid =>
									[mbOrigin, 'artist', mbid, 'recordings'].join('/') + ' => ' + mbids[mbid]);
								let promise = findMBID(entity, allMusicId);
								if (entity == 'artist' && allMusicId in artistLookupWorkers) promise = promise.catch(() =>
										artistLookupWorkers[allMusicId].then(function(mbids) {
									const hiValue = Math.max(...Object.values(mbids));
									if (Object.values(mbids).reduce((sum, count) => sum + count, 0) >= hiValue * 1.5) {
										console.warn('MBID for artist', [amOrigin, 'artist', allMusicId].join('/'),
											'resolved to multiple entities:', printArtistMBIDs(mbids), '(rejected)');
										return Promise.reject('Ambiguity (recordings)');
									} else if (Object.keys(mbids).length > 1) {
										console.warn('MBID for artist', [amOrigin, 'artist', allMusicId].join('/'),
											'resolved to multiple entities:', printArtistMBIDs(mbids), '(accepted)');
										beep.play();
										if (params.openInconsistent)
											openInconsistent(entity, allMusicId, Object.keys(mbids), 'recordings');
									}
									const mbid = Object.keys(mbids).find(key => mbids[key] == hiValue);
									if (!mbid) return Promise.reject('Assertion failed: MBID indexed lookup failed');
									if (debugLogging) console.debug('Entity binding found by matching existing recordings:',
										[amOrigin, amEntity(entity), allMusicId].join('/') + '#' + entity,
										[mbOrigin, entity, mbid, 'releases'].join('/'));
									notify(`MBID for ${entity} <b>${lookupIndexes[entity][allMusicId].name}</b> found by match with <b>${hiValue}</b> existing recordings`, 'hotpink');
									saveToCache(entity, allMusicId, mbid);
									return mbid;
								}));
								return promise.then(checkMBID).catch(reason => null);
							})))).then(function(lookupResults) {
								function getMBID(entity, allMusicId) {
									console.assert(entity in lookupIndexes);
									if (!(entity in lookupIndexes)) return undefined;
									let index = Object.keys(lookupIndexes[entity]).findIndex(key => parseInt(key) == allMusicId);
									return index >= 0 ? lookupResults[Object.keys(lookupIndexes).indexOf(entity)][index] : undefined;
								}

								let relIndex = -1, relationWorkers = [ ];
								Object.keys(lookupIndexes).forEach(function(entity, ndx1) {
									Object.keys(lookupIndexes[entity]).forEach(function(allMusicId, ndx2) {
										const mbid = lookupResults[ndx1][ndx2];
										if (mbid != null) for (let context of lookupIndexes[entity][allMusicId].contexts) switch(entity) {
											case 'artist':
												if (context in artistCredits) {
													const sourceEntities = findEntities(context);
													const resolver = sourceEntities.length > 0 ? Promise.resolve(sourceEntities.map(entity =>
														({ linkTypeId: artistRelsIndex[entity][context] }))) : relationResolvers[context];
													relationWorkers.push(resolver.then(function(relations) {
														console.assert(relations.length > 0);
														for (let { linkTypeId, attributes, creditType = context } of relations) {
															if (!(linkTypeId > 0)) continue; // not to be related
															const sourceEntity = findSourceEntity(linkTypeId), attrIds = [ ];
															if (relateToEntity(sourceEntity)) switch (creditType) {
															} else continue;
															if (attrIds.length > 0) {
																if (!attributes) attributes = [ ];
																Array.prototype.push.apply(attributes, attrIds.map(id => ({ id: id })));
															}
															const prefix = 'rels.' + ++relIndex;
															formData.set(`${prefix}.entity`, entity);
															formData.set(`${prefix}.link_type_id`, linkTypeId);
															formData.set(`${prefix}.target`, mbid);
															formData.set(`${prefix}.name`, stripDiscogsNameVersion(lookupIndexes[entity][discogsId].name));
															if ((attributes = attributes.filter(attribute => attribute.id)).length > 0)
																formData.set(`${prefix}.attributes`, JSON.stringify(attributes));
														}
													}).catch(console.log));
													console.info('MBID for %s %s:', context, lookupIndexes[entity][allMusicId].name, mbid);
													break;
												}
											default:
												formData.set(context + '.mbid', mbid);
										}
									});
								});
								if (params.rgLookup && !formData.has('release_group') && Array.isArray(release.artists)) {
									function rgResolver(releaseGroups) {
										if (!releaseGroups) return null;
										const rgFilter = (releaseGroups, strictType = false, strictName = true) => releaseGroups.filter(function(releaseGroup) {
											if (formData.has('type') && releaseGroup['primary-type']) {
												const types = formData.getAll('type');
												const cmpNocase = (...str) => str.every((s, n, a) => s.toLowerCase() == a[0].toLowerCase());
												if (!types.some(type => cmpNocase(type, releaseGroup['primary-type']))) return false;
												if (strictType && releaseGroup['secondary-types']) {
													if (!releaseGroup['secondary-types'].every(secondaryType =>
															types.some(type => cmpNocase(type, secondaryType)))) return false;
													if (!types.every(type => cmpNocase(type, releaseGroup['primary-type'])
															|| releaseGroup['secondary-types'].some(secondaryType =>
																cmpNocase(secondaryType, type)))) return false;
												}
											}
											return sameTitleMapper(releaseGroup, release.title, strictName ?
													sameStringValues : similarStringValues, releaseTitleNorm)
												|| releaseGroup.releases && releaseGroup.releases.some(release2 =>
													sameTitleMapper(release2, release.title, strictName ?
														sameStringValues : similarStringValues, releaseTitleNorm));
										});
										let filtered = rgFilter(releaseGroups, false, true);
										if (filtered.length > 1) filtered = rgFilter(releaseGroups, true, true);
										else if (filtered.length < 1) filtered = rgFilter(releaseGroups, false, false);
										if (filtered.length != 1) filtered = rgFilter(releaseGroups, true, false);
										return filtered.length == 1 ? filtered[0] : null;
									}

									Array.prototype.push.apply(rgLookupWorkers, release.artists.map(function(artist) {
										if (artist.id == 194) return;
										const mbid = getMBID('artist', artist.id);
										if (mbid) return mbLookupById('release-group', 'artist', mbid).then(rgResolver, console.error);
									}).filter(Boolean));
									if (release.artists.length > 0) {
										const normTitle = releaseTitleNorm(release.title);
										rgLookupWorkers.push(mbApiRequest('release-group', { query: ['(' + [
											`releasegroup:"${release.title}"`,
											`releasegroup:"${normTitle}"`,
											`releasegroup:"${normTitle.replace(/(?:\s+(?:\([^\(\)]+\)|\[[^\[\]]+\]))+$/g, '')}"`,
											`alias:"${release.title}"`,
											`alias:"${normTitle}"`,
											`release:"${release.title}"`,
											`release:"${normTitle}"`,
										].join(' OR ') + ')'].concat(release.artists.map(function(artist) {
											const arid = getMBID('artist', artist.id);
											return arid ? 'arid:' + arid
												: '(artistname:"' + artist.name + '" OR creditname:"' + artist.name + '")';
										})).join(' AND '), limit: 100 }).then(results => rgResolver(results['release-groups']), console.error));
									}
								}
								return Promise.all(rgLookupWorkers).then(function releaseGroupResolver(releaseGroups) {
									const releaseGroup = releaseGroups.find(Boolean);
									if (releaseGroup) formData.set('release_group', releaseGroup.id); else return false;
									let notification = `MBID for release group <b>${releaseGroup.name || releaseGroup.title}</b>`;
									if (releaseGroup['first-release-date'])
										notification += ` (<b>${getReleaseYear(releaseGroup['first-release-date'])}</b>)`;
									notification += ` found by ${'relations' in releaseGroup ? 'unique name match' : 'known URL relation'}`;
									notify(notification, 'goldenrod');
									return true;
								}).then(function findExistingRecordings() {
									if (media == null) return false;
									const isOriginalRecording = hasType('DJ-mix', 'Remix', 'Live');
									if (params.recordingsLookup <= 0) return false; else if (!(params.recordingsLookup > 1))
										if (isOriginalRecording || formData.has('release_group')) return false;
									return Promise.all(media.map(function(medium, mediumIndex) {
										if (!medium || !Array.isArray(medium.tracks)) return false;
										return Promise.all(medium.tracks.map((track, trackIndex) =>
												recordingsLookup(medium, track, getMBID).then(function(recordings) {
											if ((recordings = recordings.filter(recording =>
													!/\b(?:(?:re)?mix|rework|live\b)/i.test(recording.disambiguation))).length <= 0)
												return Promise.reject('No matches');
											formData.set(`mediums.${mediumIndex}.track.${trackIndex}.recording`, recordings[0].id);
											let notifyText = `MBID for recording <b>${track.title}</b> found`, firstRelease = [ ];
											if (recordingDate(recordings[0])) firstRelease.push('<b>' +
												getReleaseYear(recordingDate(recordings[0])) + '</b>');
											if (recordings[0].releases && recordings[0].releases.length > 0) {
												const release = recordings[0].releases.length > 1 ? recordings[0].releases.find(release =>
													release.date == recordingDate(recordings[0])) : recordings[0].releases[0];
												if (release) {
													let releaseType = release['release-group'] && release['release-group']['primary-type'];
													if (releaseType && releaseType.toUpperCase() != releaseType) releaseType = releaseType.toLowerCase();
													if (releaseType && release['release-group']['secondary-types']
															&& release['release-group']['secondary-types'].includes('Live'))
														releaseType = 'live ' + releaseType;
													firstRelease.push('on <b>' + (releaseType ? releaseType + ' ' + release.title : release.title) + '</b>');
												}
											}
											if (firstRelease.length > 0) notifyText += ` (first released ${firstRelease.join(' ')})`;
											notify(notifyText, 'orange');
											if (debugLogging) console.debug('Closest recordings for track %o:', track, recordings);
										}).catch(reason => { /*console.info('No recording for track %o found (%s)', track, reason)*/ })));
									}));
								}).then(() => Promise.all(relationWorkers));
							});
						}),
						languageDetector,
					]).then(() => formData);
				});
			}
			function finalizeSeed(formData) {
				if (!formData || typeof formData != 'object') throw 'Invalid argument';
				// if (!formData.has('language')) formData.set('language', 'eng');
				const releaseTypes = formData.getAll('type');
				if (formData.get('artist_credit.names.0.mbid') == mbidVA && !releaseTypes.includes('Compilation')
						&& !['Soundtrack', 'Live'].some(secondaryType => releaseTypes.includes(secondaryType)))
					formData.append('type', 'Compilation');
				if (!formData.has('script') && formData.has('language')) {
					const script = scriptFromLanguage(formData.get('language'));
					if (script) formData.set('script', script);
				}
				return getSessions(torrentId).catch(reason => null).then(function(sessions) {
					if (sessions != null) sessions.forEach(function(session, discIndex) {
						const key = `mediums.${discIndex}.format`, format = formData.get(key);
						if (!format || format == 'CD') switch (getLayoutType(getTocEntries(session))) {
							case 0: formData.set(key, 'CD'); break;
							case 1: formData.set(key, 'Enhanced CD'); break;
							case 2: formData.set(key, 'Copy Control CD'); break;
							default: console.warn(`Disc ${discIndex + 1} unknown TOC type`, getTocEntries(session));
						}
					});
				}).then(function() {
					formData.set('edit_note', ((formData.get('edit_note') || '') + '\nSeeded by ' + scriptSignature).trimLeft());
					if (mbSeedNew < 2) formData.set('make_votable', 1);
					return formData;
				});
			}
			function seedNewRelease(formData) {
				if (!formData || typeof formData != 'object') throw 'Invalid argument';
				const form = Object.assign(document.createElement('form'), {
					method: 'POST',
					action: mbOrigin + '/release/add',
					target: '_blank',
					hidden: true,
				});
				form.append(...Array.from(formData, entry => Object.assign(document.createElement(entry[1].includes('\n') ?
					'textarea' : 'input'), { name: entry[0], value: entry[1] })));
				document.body.appendChild(form).submit();
				document.body.removeChild(form);
			}
			function editNoteFromSession(session) {
				let editNote = GM_getValue('insert_torrent_reference', false) ?
					`Release identification from torrent ${document.location.origin}/torrents.php?torrentid=${torrentId} edition info\n` : '';
				editNote += 'TOC derived from EAC/XLD ripping log';
				if (session) editNote += '\n\n' + (mbSubmitLog ? session
					: 'Medium fingerprint:\n' + getMediaFingerprint(session)) + '\n';
				return editNote + '\nSubmitted by ' + scriptSignature;
			}
			const attachToMB = (mbId, attended = false, skipPoll = false) => getMbTOCs().then(function(mbTOCs) {
				function attachByHand() {
					for (let discNumber = mbTOCs.length; discNumber > 0; --discNumber) {
						url.searchParams.setTOC(discNumber - 1);
						GM_openInTab(url.href, discNumber > 1);
					}
					return false;
				}

				const url = new URL('/cdtoc/attach', mbOrigin);
				url.searchParams.setTOC = function(index = 0) { this.set('toc', mbTOCs[index].join(' ')) };
				const warnings = [ ];
				return (mbId ? rxMBID.test(mbId) ? mbApiRequest('release/' + mbId, { inc: 'media+discids' }).then(function(release) {
					if (release.media && sameMedia(release).length < mbTOCs.length)
						return Promise.reject('not enough attachable media in this release');
					url.searchParams.set('filter-release.query', mbId);
					warnings[0] = sameMedia(release).map(medium => medium.discs ? medium.discs.length : 0);
					return mbId;
				}) : Promise.reject('invalid format') : Promise.reject(false)).catch(function(reason) {
					if (reason) alert(`Not linking to release id ${mbId} for the reason ` + reason);
				}).then(mbId => mbId && !attended && mbAttachMode > 1 ? Promise.all(mbTOCs.map(function(mbTOC, tocNdx) {
					url.searchParams.setTOC(tocNdx);
					return globalXHR(url).then(({document}) =>
						Array.from(document.body.querySelectorAll('table > tbody > tr input[type="radio"][name="medium"][value]'), input => ({
							id: input.value,
							title: input.nextSibling && input.nextSibling.textContent.trim().replace(/(?:\r?\n|[\t ])+/g, ' '),
					})));
				})).then(function(mediums) {
					mediums = mediums.every(medium => medium.length == 1) ? mediums.map(medium => medium[0]) : mediums[0];
					if (mediums.length != mbTOCs.length)
						return Promise.reject('Unable to reliably bind volumes or not logged in');
					if (Array.isArray(warnings[0])) if (warnings[0].every(mediumIds => mediumIds > 0))
						warnings[0] = `all CD media already have assigned at least ${Math.min(...warnings[0])} disc id(s)`;
					else if (warnings[0].some(mediumIds => mediumIds > 0))
						warnings[0] = `some CD media already have assigned at least ${Math.min(...warnings[0].filter(mediaIds => mediaIds > 0))} disc id(s)`;
					else warnings[0] = false; else warnings[0] = false;
					if (logScoreTest(uncalibratedReadOffset))
						warnings.push('at least one session ripped with wrong read offset');
					if (logScoreTest(logStatus => !(logStatus.score > 0)))
						warnings.push('at least one logfile seems to have very bad score');
					if (!confirm([
						[
							`${mbTOCs.length != 1 ? mbTOCs.length.toString() + ' TOCs are' : 'TOC is'} going to be attached to release id ${mbId}`,
						], warnings.filter(Boolean).map(warning => 'WARNING: ' + warning), [
							mediums.length > 1 && 'Media titles:\n' + mediums.map(medium => '\t' + medium.title).join('\n'),
							'Submit mode: ' + (!skipPoll && mbAttachMode < 3 ? 'apply after poll close (one week or sooner)' : 'auto-edit (without poll)'),
							'Edit note: ' + (mbSubmitLog ? 'entire .LOG file per volume' : 'medium fingerprint only'),
						], [
							'Before you confirm make sure -',
							'- uploaded CD and MB release are identical edition',
							'- attached log(s) have no score deductions for uncalibrated read offset',
						],
					].map(lines => lines.filter(Boolean).join('\n')).filter(Boolean).join('\n\n'))) return false;
					const postData = new FormData;
					if (!skipPoll && mbAttachMode < 3) postData.set('confirm.make_votable', 1);
					return getSessions(torrentId).then(sessions => Promise.all(mbTOCs.map(function(mbTOC, index) {
						url.searchParams.setTOC(index);
						url.searchParams.set('medium', mediums[index].id);
						postData.set('confirm.edit_note', editNoteFromSession(sessions[index]));
						return globalXHR(url, { responseType: null }, postData);
					}))).then(responses => (GM_openInTab(`${mbOrigin}/release/${mbId}/discids`, false), true));
				}).catch(reason => (alert(reason + '\n\nAttach by hand'), attachByHand())) : attachByHand());
			}, reason => (alert(reason), false));
			function droppedUrls(evt) {
				if (!(evt instanceof Event)) throw 'Invalid argument';
				let urls = evt.dataTransfer.getData('text/plain');
				if(urls && (urls = urls.split(/(?:\r?\n)+/).map(url => url.trim())).length > 0) return urls;
			}
			function attachToMBIcon(mbId, style, tooltip, tooltipster) {
// <svg height="0.9em" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
// 	<circle fill="${staticIconColor}" cx="50" cy="50" r="50"/>
// 	<path fill="white" d="M68.13 32.87c2.18,-0.13 5.27,-0.31 7.1,0.94 2.03,1.37 2.1,3.8 1.97,6.01 -0.55,9.72 -0.13,19.52 -0.21,29.25 -0.05,5.73 -2.35,10.96 -6.57,14.84 -4.85,4.46 -11.39,6.42 -17.86,6.78 -0.04,0 -0.08,0.01 -0.11,0.01l-4.5 0c-0.03,0 -0.05,0 -0.07,-0.01 -0.18,0 -0.36,-0.01 -0.54,-0.02 -6.89,-0.43 -14,-2.82 -19,-7.74 -2.53,-2.48 -4.26,-5.23 -5.01,-8.71 -0.62,-2.89 -0.49,-5.88 -0.47,-8.81 0.1,-12.96 -0.24,-25.93 -0.02,-38.89 0.05,-3.01 0.42,-5.94 1.97,-8.57 1.81,-3.07 4.83,-5.05 7.99,-6.53 2.69,-1.26 5.73,-1.91 8.69,-2.11 0.05,0 0.09,-0.01 0.13,-0.01l3.59 0c0.05,0 0.08,0.01 0.13,0.01 3.61,0.23 7.41,1.15 10.55,2.99 3.09,1.8 5.79,4.11 7.13,7.52 1.09,2.79 1.07,5.79 1.01,8.74 -0.14,6.49 -0.06,12.98 -0.02,19.47 0.02,4.95 0.98,15.66 -1.95,19.61 -2.26,3.06 -6.11,4.59 -9.79,5 -3.82,0.43 -8.01,-0.26 -11.32,-2.27 -3.28,-1.98 -4.54,-5.39 -4.89,-9.04 -0.16,-1.6 -0.14,-3.22 -0.07,-4.83 0.05,-1.32 0.15,-2.64 0.16,-3.96 0.05,-6.1 0.06,-12.21 0.21,-18.31 0.03,-1.09 0.92,-1.95 2,-1.95l6.37 0c1.1,0 2,0.9 2,2 0,2.02 -0.09,4.06 -0.14,6.08 -0.08,3.09 -0.14,6.18 -0.15,9.27 0,2.99 0.02,6.03 0.23,9.01 0.06,0.87 0.29,3.78 0.7,4.63 1.08,0.91 3.95,0.88 5.06,0.1 1.09,-0.76 0.71,-3.87 0.68,-4.99 -0.14,-5.16 -0.01,-10.32 -0.01,-15.48 0,-5.21 -0.07,-10.42 0.03,-15.63 0.08,-4.8 -0.58,-7.19 -5.63,-8.72 -2.35,-0.71 -4.97,-0.78 -7.36,-0.21 -1.96,0.47 -4.04,1.46 -5.29,3.08 -1.77,2.29 -1.09,10.23 -1.08,13.15 0.02,10.39 0.1,20.78 0.01,31.16 -0.04,4.6 -0.76,8.12 2.93,11.61 6.55,6.2 19.73,6.26 26.32,0.08 3.76,-3.53 3.06,-6.86 3.02,-11.54 -0.09,-10.33 0.01,-20.67 0.01,-31 0,-0.77 0.4,-1.42 1.08,-1.77 0.32,-0.17 0.66,-0.25 0.99,-0.24z"/>
				return addIcon(minifyHTML(`
<svg height="0.9em" viewBox="0 0 56 100" xmlns="http://www.w3.org/2000/svg">
	<path fill="${staticIconColor}" fill-rule="nonzero" d="M43.56 30.51c0,0 0.77,-4.33 -4.16,-4.33 -4.93,0 -4.14,4.24 -4.14,4.24l0 37.66c0,0 1.69,10.1 -7.26,10.02 -8.94,-0.1 -7.26,-10.02 -7.26,-10.02l0 -49.37c0,0 -0.77,-12.11 13.49,-12.12 14.25,0.01 13.47,12.15 13.47,12.15l0 55.48c0,0 1.81,19.13 -19.82,19.13 -21.64,0 -19.56,-19.13 -19.56,-19.13l-0.01 -42.13c0,0 0.51,-4.33 -4.15,-4.33 -4.66,0 -4.14,4.33 -4.14,4.33l0 48.33c0,0 1.16,19.13 27.98,19.13 26.83,0 28,-19.13 28,-19.13l-0.01 -66.44c0,0 -0.9,-13.98 -21.76,-13.98 -20.87,0 -21.77,13.98 -21.77,13.98l-0.01 57.16c0,0 -1.8,13.27 15.69,13.27 17.49,0 15.41,-13.27 15.41,-13.27l0.01 -40.63z"/>
</svg>`), function clickHandler(evt) {
					// let mbid = !style && evt.ctrlKey ? prompt('Enter MusicBrainz release ID or URL:\n\n') : undefined;
					// if (mbid === null) return;
					// if (mbid != undefined && !(mbid = mbIdExtractor(mbid, 'release'))) return alert('Invalid input');
					evt.currentTarget.disabled = true;
					const target = evt.currentTarget, animation = flashElement(target);
					attachToMB(mbId, evt.altKey, evt.ctrlKey).then(function(attached) {
						animation.cancel();
						target.disabled = false;
					});
				}, !mbId && function dropHandler(evt) {
					const urls = droppedUrls(evt);
					if (!urls) return;
					const mbReleaseId = urls.map(url => mbIdExtractor(url, 'release')).find(Boolean);
					if (!mbReleaseId) return;
					evt.currentTarget.disabled = true;
					const target = evt.currentTarget, animation = flashElement(target);
					attachToMB(mbReleaseId, evt.altKey, evt.ctrlKey).then(function(attached) {
						animation.cancel();
						target.disabled = false;
					});
				}, 'attach-toc', style, tooltip, tooltipster);
			}
			function seedToMB(target, torrent, params) {
				function seedToMb() {
					if (logScoreTest(uncalibratedReadOffset)
								&& !confirm('At least one session ripped with wrong read offset, continue anyway?')
							|| logScoreTest(logStatus => !(logStatus.score > 0))
								&& !confirm('At least one logfile seems to have very bad score, continue anyway?')) return;
					return getMbTOCs().then(function(mbTOCs) {
						const formData = new URLSearchParams;
						if (rxMBID.test(params.releaseGroupId)) formData.set('release_group', params.releaseGroupId);
						seedFromTorrent(formData, torrent);
						return seedFromTOCs(formData, mbTOCs)
							.then(formData => params.allMusicId ?
								seedFromAllMusic(formData, params.allMusicId, params, mbTOCs.map(mbTOC => mbTOC[1])) : formData)
							.then(formData => params.discogsId > 0 ?
								seedFromDiscogs(formData, params.discogsId, params, mbTOCs.map(mbTOC => mbTOC[1])) : formData);
					}).then(finalizeSeed).then(seedNewRelease).catch(alert);
				}
				function relationsHandler(releases) {
					console.assert(releases.length > 0);
					if (releases.length > 1) return confirm(`This release already exists by ambiguous binding from

${releases.map(release => `\t${mbOrigin}/release/${release.id}`).join('\n')}

Create new release anyway?`) ?
						Promise.reject('New release enforced') : Promise.resolve('Cancelled');
					return confirm(`This release already exists as ${mbOrigin}/release/${releases[0].id}.
Attach the TOC(s) instead?`) ? attachToMB(releases[0].id, false, false) : Promise.reject('New release enforced');
				}

				if (!(target instanceof HTMLElement) || !torrent) throw 'Invalid argument';
				if (!params) params = { };
				(params.discogsId > 0 ? findDiscogsRelatives('release', params.discogsId).then(relationsHandler) : Promise.reject('No Discogs relations'))
					.catch(() => params.allMusicId ? findAllMusicRelatives('album/release', params.allMusicId, 'release').then(relationsHandler) : Promise.reject('No AllMusic relations'))
					.catch(seedToMb).then(target.epilogue);
			}
			function updateFromExternalDb(mbid, metaCollector, sourceRef, overwrite = false) {
				if (!mbid || typeof metaCollector != 'function') throw 'Invalid argument';
				return Promise.all([
					globalXHR([mbOrigin, 'release', mbid, 'edit'].join('/')).then(function({document}) {
						const objects = {
							sourceEntity: Array.from(document.head.querySelectorAll(':scope > script:not([src])'), function(script) {
								let obj = /^Object\.defineProperty\(window,\s*"__MB__",\s*(.+)\)$/.exec(script.text.trim());
								if (obj != null) try {
									if (obj = eval(obj[1])['$c'].stash.source_entity) return obj;
								} catch(e) { console.warn(e) }
							}).find(Boolean),
						};
						document.body.querySelectorAll('div#release-editor select[id]').forEach(function(select) {
							objects[select.id.toLowerCase() + 'Ids'] = Object.assign.apply({ },
								Array.from(select.options, option => ({ [option.text]: parseInt(option.value) }))
									.filter(elem => Object.values(elem).every(value => !isNaN(value))));
						});
						return objects;
					}, console.warn),
					mbApiRequest('release/' + mbid, { inc: [
						'release-groups', 'artist-credits', 'media', 'labels', 'annotation', 'recordings', 'tags',
						'artist-rels', 'series-rels', 'url-rels', 'work-rels',
						'release-group-level-rels', 'recording-level-rels', 'work-level-rels',
					].join('+') }),
					getMbTOCs(),
				]).then(([mbEditObjects, mbRelease, mbTOCs]) => metaCollector({
					labguageIdentifier: overwrite || !mbRelease['text-representation'].language,
				}, mbTOCs.map(mbTOC => mbTOC[1])).then(finalizeSeed).then(function(formData) {
					function findEditId(indexName, field) {
						if (!indexName || !field || !mbEditObjects || !mbEditObjects[indexName = indexName + 'Ids'])
							return null;
						const index = Object.keys(mbEditObjects[indexName]).find(key => sameStringValues(key, field));
						return index ? mbEditObjects[indexName][index] : null;
					}
					function objectsEqual(a, b) {
						function cmp(a, b) {
							for (let prop in a) if (a.hasOwnProperty(prop) && (!b.hasOwnProperty(prop)
									|| (typeof a[prop] == 'object' ? !objectsEqual(a[prop], b[prop]) : a[prop] !== b[prop])))
								return false;
							return true;
						}

						return [a, b].every(o => o == null) || [a, b].every(Boolean) && cmp(a, b) && cmp(b, a);
					}

					if (debugLogging) console.debug('Edit objects:', mbEditObjects, mbRelease);
					if (mbRelease.barcode && formData.get('barcode')
							&& !sameBarcodes(mbRelease.barcode, formData.get('barcode'))
							&& !confirm(`Releases don't match by barcode: ${mbRelease.barcode} ≠ ${formData.get('barcode')},\nApply edits anyway?`))
						return;
					const edits = [ ], batchEdits = { 32: { } };
					let events = [ ], updateEvents = overwrite, tags = [ ];
					if (overwrite || !mbRelease.status) {
						let statusId = formData.get('status');
						if (statusId = findEditId('status', statusId) || statusId)
							batchEdits[32].status_id = statusId;
					}
					if (overwrite || !mbRelease.packaging) {
						let packagingId = formData.get('packaging');
						if (packagingId = findEditId('packaging', packagingId) || packagingId)
							batchEdits[32].packaging_id = packagingId;
					}
					if (overwrite || !mbRelease['text-representation'].language) {
						let languageId = formData.get('language_en');
						if (languageId && (languageId = findEditId('language', languageId))
								|| (languageId = formData.get('language')) && (languageId = findEditId('language', {
							eng: 'English', deu: 'German', spa: 'Spanish', fra: 'French', ita: 'Italian',
							zho: 'Chinese', rus: 'Russian', jpn: 'Japanese', kor: 'Korean', tha: 'Thai',
							swe: 'Swedish', nor: 'Norwegian', fin: 'Finnish', por: 'Portugese', nld: 'Dutch',
							pol: 'Polish', ces: 'Czech', slk: 'Slovak', slv: 'Slovenian', hrv: 'Croatian',
							srp: 'Serbian', hun: 'Hungarian', tur: 'Turkish', dan: 'Danish', ltz: 'Lithuanian',
							ron: 'Romanian', est: 'Estonian', lav: 'Latvian', isl: 'Islandian', lat: 'latin',
							cat: 'Catalanian', hin: 'Hindi', ell: 'Greek', vie: 'Vietnamese', heb: 'Hebrew',
							bul: 'Bulgarian', mak: 'Macedonian', ara: 'Arabic', bos: 'Bosnian', afr: 'Afrikaans',
							// 'gsw', 'fil', 'eus', 'lit', 'cym', 'glg', 'bre', 'oci', 'haw', 'gla',
							// 'zul', 'ast', 'swa', 'som', 'gle', 'ukr', 'bel', 'mon', 'mal', 'asm', 'kat',
							// 'mar', 'san', 'ind', 'fas', 'urd', 'msa', 'kaz', 'tuk', 'uzb', 'nob', 'mri',
							// 'yid', 'grk', 'gre', 'hye', 'pan', 'tam', 'tel', 'mya',
						}[languageId]))) batchEdits[32].language_id = languageId;
					}
					if (overwrite || !mbRelease['text-representation'].script) {
						let scriptId = formData.get('script');
						if (scriptId = findEditId('script', {
							Latn: 'Latin', Cyrl: 'Cyrillic', Jpan: 'Japanese', Kore: 'Korean', Thai: 'Thai', Arab: 'Arabic',
							Grek: 'Greek', Hebr: 'Hebrew', Hant: '', Deva: '', Armn: '', Guru: '', Taml: 'Tamil',
							Hani: '', Telu: '', Mymr: '', Mlym: '', Beng: '', Geor: '',
						}[scriptId]) || scriptId) batchEdits[32].script_id = scriptId;
					}
					if (overwrite || !mbRelease.barcode && formData.get('barcode'))
						batchEdits[32].barcode = formData.get('barcode');
					const isAreaCode = (area, countryCode) => area && countryCode
						&& area['iso-3166-1-codes'].some(isoCode => isoCode.toUpperCase() == countryCode.toUpperCase());
					const countryIdFromEvent = event => event.area ? mbEditObjects && mbEditObjects.sourceEntity
						&& mbEditObjects.sourceEntity.events[mbRelease['release-events'].indexOf(event)].country.id
							|| event.area['iso-3166-1-codes'] : null;
					for (let index = 0; ['country', 'date.year'].some(suffix => formData.has(`events.${index}.${suffix}`)); ++index) {
						let country = formData.get(`events.${index}.country`);
						let date = ['year', 'month', 'day'].map(unit => (unit = formData.get(`events.${index}.date.${unit}`))
							&& !isNaN(unit = parseInt(unit)) ? unit : undefined);
						const event = mbRelease['release-events'] && (mbRelease['release-events'].find(event => isAreaCode(event.area, country))
							|| mbRelease['release-events'].find(event => (!country || !event.area)
								&& (date[0] > 0 && (event = dateParser(event.date)) != null && event[0] == date[0])));
						if (country) country = [country];
						if (event) {
							if (event.area) country = countryIdFromEvent(event); else if (country) updateEvents = true;
							const eventDate = dateParser(event.date);
							if (eventDate != null) {
								if (eventDate.some((unit, index) => unit > 0 && !(date[index] > 0))
									 || overwrite && eventDate.some((unit, index) => unit != date[index])) date = eventDate;
								if (date.some((unit, index) => unit > 0 && !(eventDate[index] > 0))) updateEvents = true;
							} else if (date[0] > 0) updateEvents = true;
						} else updateEvents = true;
						events.push({
							country_id: country || null,
							date: { year: date[0] || null, month: date[1] || null, day: date[2] || null },
						});
					}
					if (updateEvents) {
						if (!overwrite && mbRelease['release-events']) Array.prototype.unshift.apply(events,
								mbRelease['release-events'].map(function(releaseEvent, eventIndex) {
							const date = dateParser(releaseEvent.date);
							const event = events.some(event => (date == null || event.date != null && event.date.year == date[0])
								&& (!releaseEvent.area || event.country_id && (Array.isArray(event.country_id) ?
									event.country_id.some(countryId => isAreaCode(releaseEvent.area, countryId))
										: event.country_id == mbEditObjects.sourceEntity.events[eventIndex].country.id)));
							return !event && {
								country_id: countryIdFromEvent(releaseEvent),
								date: date && { year: date[0] || null, month: date[1] || null, day: date[2] || null },
							};
						}).filter(Boolean));
						events = events.map(event => Array.isArray(event.country_id) ? Promise.all(event.country_id.map(country =>
							mbApiRequest('area', { query: 'iso1:' + country }).then(function(results) {
								let countryId = results.areas.filter(area =>
									(area.type == 'Country' || !area.type) && isAreaCode(area, country));
								if (countryId.length == 1) countryId = countryId[0];
									else return Promise.reject('Country/reegion id for ' + country + ' not found');
								return findEditId('country', countryId.name)/* || countryId.id*/;
							}).catch(console.warn))).then(results => results.find(Boolean))
								.then(countryId => Object.assign(event, { country_id: countryId || null })) : event);
					}
					if (!mbRelease.disambiguation && formData.get('comment'))
						batchEdits[32].comment = formData.get('comment');
					if (Object.keys(batchEdits[32]).length > 0 || updateEvents)
						edits.push(Promise.all(events).then(function(events) {
							if (updateEvents && (events = events.filter(Boolean)).length > 0) batchEdits[32].events = events;
							return Object.keys(batchEdits[32]).length > 0 ? Object.assign({
								edit_type: 32,
								to_edit: mbRelease.id,
							}, batchEdits[32]) : null;
						}));
					const matchedLabels = new Set;
					for (let index = 0; ['name', 'catalog_number'].some(suffix => formData.has(`labels.${index}.${suffix}`)); ++index) {
						const mbid = formData.get(`labels.${index}.mbid`);
						const name = formData.get(`labels.${index}.name`);
						if (name && !mbid) continue;
						const catNo = formData.get(`labels.${index}.catalog_number`);
						const labelInfo = mbRelease['label-info'] || [ ];
						const labelMatch = labelInfo => mbid && labelInfo.label && labelInfo.label.id == mbid;
						const catNoMatch = labelInfo => catNo && sameStringValues(labelInfo['catalog-number'], catNo);
						if (labelInfo.some(labelInfo => labelMatch(labelInfo) && catNoMatch(labelInfo))) continue;
						const label = labelInfo.find((labelInfo, index) => (!labelInfo.label || !mbid || labelMatch(labelInfo))
							&& (!labelInfo['catalog-number'] || !catNo || catNoMatch(labelInfo)) && !matchedLabels.has(index));
						edits.push(Object.assign(!label || !mbEditObjects || !mbEditObjects.sourceEntity ? {
							edit_type: 34,
							release: mbRelease.id,
						} : {
							edit_type: 37,
							release_label: mbEditObjects.sourceEntity.labels[labelInfo.indexOf(label)].id,
						}, { label: mbid || null, catalog_number: catNo || null }));
						if (label) matchedLabels.add(labelInfo.indexOf(label));
					}
					if (!mbRelease.annotation && formData.get('annotation')) edits.push({
						edit_type: 35,
						entity: mbRelease.id,
						text: formData.get('annotation'),
					});
					const createEntries = GM_getValue('mb_create_entries', 2);
					const workWorkers = new Map, assignedWorks = new Set;
					for (let index = 0; formData.has(`rels.${index}.entity`); ++index) {
						function createEdit(entity, backward = false) {
							const entities = [{ entityType: targetEntity, gid: target/*, name: name*/ }];
							entities[backward ? 'unshift' : 'push'](entity);
							const edit = { edit_type: 90, linkTypeID: linkTypeId, entities: entities };
							edit.attributes = attributes && attributes.length > 0 ? attributes.map(function(attribute) {
								const a = { type: { gid: attribute.id } };
								if (attribute.creditedAs) a.credited_as = attribute.creditedAs;
								if (attribute.value) a.text_value = attribute.value;
								return a;
							}) : null;
							if (creditedAs && creditedAs != capitalizeName(name))
								edit[`entity${backward ? 1 : 0}_credit`] = creditedAs;
							return edit
						}

						const targetEntity = formData.get(`rels.${index}.entity`);
						const target = formData.get(`rels.${index}.target`);
						const linkTypeId = parseInt(formData.get(`rels.${index}.link_type_id`));
						console.assert(targetEntity && target && linkTypeId > 0);
						if (!targetEntity || !target || !(linkTypeId > 0)) continue;
						const entityType = findSourceEntity(linkTypeId);
						console.assert(entityType, linkTypeId);
						if (!entityType) continue; else console.assert(mbRelationsIndex[entityType][linkTypeId], entityType, linkTypeId);
						const name = formData.get(`rels.${index}.name`), creditedAs = formData.get(`rels.${index}.credit`);
						let attributes = formData.get(`rels.${index}.attributes`);
						if (attributes) try { attributes = JSON.parse(attributes) } catch(e) { console.warn(e) }
						const hasRelation = root => root && root.relations && root.relations.some(function(relation) {
							if (relation['target-type'] != targetEntity) return false;
							if (relation.type != mbRelationsIndex[entityType][linkTypeId]) return false;
							if (relation[targetEntity].id.toLowerCase() != target.toLowerCase()) return false;
							return !attributes || attributes.every(attribute => Object.values(relation['attribute-ids'])
								.some(id => id.toLowerCase() == attribute.id.toLowerCase()));
						});
						if (['recording', 'work'].includes(entityType)) if (!mbRelease.media) continue;
						else mbRelease.media.forEach(function(medium, mediumIndex) {
							medium.tracks.forEach(function(track, trackIndex) {
								const mediumNdx = parseInt(formData.get(`rels.${index}.medium`));
								const trackNdx = parseInt(formData.get(`rels.${index}.track`));
								if (track.recording && (isNaN(mediumNdx) || mediumIndex == mediumNdx)
										&& (isNaN(trackNdx) || trackIndex == trackNdx)) switch (entityType) {
									case 'recording':
										if (!hasRelation(track.recording))
											edits.push(createEdit({ entityType: entityType, gid: track.recording.id }));
										break;
									case 'work': {
										const works = track.recording.relations.filter(relation => relation['target-type'] == 'work'
											&& relation.type == 'performance').map(relation => relation.work);
										if (works.length > 0) for (let work of works) {
											if (!hasRelation(work)) edits.push(createEdit({ entityType: entityType, gid: work.id }));
										} else {
											const hasWorks = mbRelease?.media.some(medium => medium?.tracks.some(track =>
												track?.recording?.relations.some(relation =>
													relation['target-type'] == 'work' && relation.type == 'performance')));
											const findWorkType = (secondaryType, typeId) => typeId > 0
												&& mbRelease?.['release-group']?.['secondary-types'].includes(secondaryType) ? typeId : undefined;
											let workTypeId = findWorkType('Soundtrack', 22) || findWorkType('Audio drama', 25);
											if (workTypeId > 0 && hasWorks) workTypeId = -1;
											const work = (function workResolver(recording) {
												function workResolver(id, typeId, name, params) {
													if (workWorkers.has(id)) return workWorkers.get(id);
													if (!(typeId > 0) || !name) throw 'Invalid argument';
													if (!(createEntries > 0)) return Promise.reject('Creating new entries is forbidden');
													const postData = new URLSearchParams;
													params = Object.assign({ type_id: typeId, name: name }, params,
														{ edit_note: 'Auto-created by ' + scriptSignature });
													for (let key in params) postData.set('edit-work.' + key, params[key]);
													if (createEntries < 2) postData.set('edit-work.make_votable', 1);
													const promise = globalXHR([mbOrigin, 'work', 'create'].join('/'), undefined, postData).then(getGID).then(function(gid) {
														notify(`Work <b>${name}</b> successfully created`, 'lime');
														console.info('Work', name, 'successfully created (', gid, ')');
														return gid;
													});
													workWorkers.set(id, promise);
													return promise;
												}

												if (workTypeId > 0) {
													let rx = ['live', '(?:en|ao) (?:vivo|directo?)', 'soundtrack', 'score'];
													rx = '(?:\\s+(?:' + [['(', ')'], ['[', ']']].map(br => `\\${br[0]}[^\\${br[0]}\\${br[1]}]*\\b(?:` +
														rx.join('|') + `)\\b[^\\${br[0]}\\${br[1]}]*\\${br[1]}`).join('|') + '))+$';
													return workResolver(mbRelease.id, workTypeId, mbRelease.title.replace(new RegExp(rx, 'gi'), ''));
												}
												if (workTypeId < 0) return Promise.reject('Some recordings have works assigned');
												if (!recording) return Promise.reject('Assertion failed: track without recording');
												return workResolver(recording.id, 17, recording.title);
											})(track.recording);
											edits.push(work.then(workId => createEdit({ entityType: entityType, gid: workId }), console.log));
											if (assignedWorks.has(track.recording.id)) break; else assignedWorks.add(track.recording.id);
											const attributes = [ ];
											if (mbRelease?.['release-group']?.['secondary-types'].includes('Live'))
												attributes.push({ type: { gid: '70007db6-a8bc-46d7-a770-80e6a0bb551a' } });
											// /* instrumental */ attributes.push({ type: { gid: 'c031ed4f-c9bb-4394-8cf5-e8ce4db512ae' } });
											// /* karaoke */ attributes.push({ type: { gid: '3d984f6e-bbe2-4620-9425-5f32e945b60d' } });
											// /* medley performance */ attributes.push({ type: { gid: '37da3398-5d1b-4acb-be25-df95e33e423c' } });
											// /* partial */ attributes.push({ type: { gid: 'd2b63be6-91ec-426a-987a-30b47f8aae2d' } });
											edits.push(work.then(workId => ({
												edit_type: 90,
												entities: [
													{ entityType: 'recording', gid: track.recording.id },
													{ entityType: 'work', gid: workId },
												],
												linkTypeID: 278,
												attributes: attributes.length > 0 ? attributes : null,
											}), console.log));
										}
										break;
									}
								}
							});
						}); else if (!hasRelation({
							'release': mbRelease, 'release-group': mbRelease['release-group'],
						}[entityType])) edits.push(createEdit({
							'release': { entityType: 'release', gid: mbRelease.id },
							'release-group': { entityType: 'release_group', gid: mbRelease['release-group'].id },
						}[entityType], ['series'].includes(targetEntity)));
					}
					for (let index = 0; formData.has(`urls.${index}.url`); ++index) {
						const url = formData.get(`urls.${index}.url`);
						const linkType = parseInt(formData.get(`urls.${index}.link_type`));
						if (!url || !(linkType > 0)) continue;
						const entityType = [90, 284].includes(linkType) ? 'release-group' : 'release';
						const entity = { 'release': mbRelease, 'release-group': mbRelease['release-group'] }[entityType];
						if (!entity.relations || !entity.relations.some(function(relation) {
							if (relation['target-type'] == 'url') try {
								const urls = [url, relation.url.resource].map(url => new URL(url));
								return ['hostname', 'pathname'].every(prop => urls[0][prop] == urls[1][prop]);
							} catch(e) { console.warn(e) }
							return false;
						})) edits.push({
							edit_type: 90,
							linkTypeID: linkType,
							entities: [
								{ entityType: entityType.replace(/-/g, '_'), gid: entity.id },
								{ entityType: 'url', name: url },
							],
						});
					}
					for (let index = 0; formData.has(`tags.${index}`); ++index) tags.push(formData.get(`tags.${index}`));
					tags = tags.filter(Boolean).map(tag => tag.replace(/,\s*(&|and\b)/gi, ' $1').replace(/\s+/g, ' '))
						.filter(uniqueValues).filter(tag => !mbRelease.tags.some(rTag =>
							sameStringValues(rTag.name, tag)/* && rTag.count > 0*/));
					if (tags.length > 0 && GM_getValue('mb_add_tags', true)) {
						const url = new URL(['release', mbRelease.id, 'tags', 'upvote'].join('/'), mbOrigin);
						url.searchParams.set('tags', tags.join(', '));
						globalXHR(url, { responseType: null }).then(function(responseCode) {
							notify('Tags added with response code ' + responseCode, 'aquamarine');
							console.log('Tags for release %s added with response code', mbRelease.id, responseCode);
						}, console.error);
					}
					return edits.length > 0 ? Promise.all(edits).then(function(edits) {
						if ((edits = edits.filter(Boolean)).length <= 0) return null;
						if (debugLogging) console.debug('Resolved edits:', edits);
						edits = edits.filter((edit1, index, edits) =>
							edits.findIndex(edit2 => objectsEqual(edit1, edit2)) == index);
						const editNote = ['Release updated'];
						if (sourceRef) editNote.push('from ' + sourceRef);
						return edits.length > 0 ? globalXHR(mbOrigin + '/ws/js/edit/create', { responseType: 'json' }, {
							edits: edits,
							editNote: editNote.concat('by ' + scriptSignature).join(' '),
							makeVotable: true,
						}).then(function({response}) {
							console.log('Release', mbRelease.id, (response.edits.every(edit => edit.response == 1) ?
								'edits successfull' : 'edits successfull (some rejected)') + ':', response.edits);
							notify(response.edits.every(edit => edit.response == 1) ? 'Release edits successfull'
								: 'Release edits edits successfull (some rejected)', 'mediumturquoise');
							return response.edits;
						}) : null;
					}) : null;
				}));
			}
			function seedToMBIcon(callback, style, tooltip, tooltipster) {
				const staticIcon = minifyHTML(`
<svg height="0.9em" fill="${staticIconColor}" viewBox="0 0 100 100" version="1.1" xmlns="http://www.w3.org/2000/svg">
	<circle cx="50" cy="50" r="50"/>
	<polygon fill="white" clip-path="circle(30)" points="41.34,0 58.66,0 58.66,41.34 100,41.34 100,58.66 58.66,58.66 58.66,100 41.34,100 41.34,58.66 0,58.66 0,41.34 41.34,41.34"/>
</svg>`);
				const span = addIcon(staticIcon, function clickHandler(evt) {
					const input = evt.ctrlKey ? prompt(`Enter Discogs/AllMusic release ID or URL:
(note the data preparation process may take some time due to MB API rate limits, especially for compilations)

`) : undefined;
					let param, id;
					if (input === null) return; else if (input != undefined) {
						if ((id = discogsIdExtractor(input, 'release')) > 0) param = 'discogsId';
						else if (id = allMusicIdExtractor(input, 'album/release')) param = 'allMusicId';
						else return alert('Invalid input');
					}
					evt.currentTarget.prologue(id && param && !evt.shiftKey);
					callback(evt.currentTarget, id && param ? {
						[param]: id,
						mbidLookup: !evt.shiftKey,
						recordingsLookup: evt.altKey ? 0 : GM_getValue('recordings_lookup', 1),
					} : undefined);
				}, function dropHandler(evt) {
					const urls = droppedUrls(evt), target = evt.currentTarget;
					if (urls) evt.stopPropagation(); else return;
					let param, id;
					if (id = urls.map(url => discogsIdExtractor(url, 'release')).find(Boolean)) param = 'discogsId';
					else if (id = urls.map(url => allMusicIdExtractor(url, 'album/release')).find(Boolean)) param = 'allMusicId';
					if (param) {
						target.prologue(!evt.shiftKey);
						callback(target, {
							[param]: id,
							mbidLookup: !evt.shiftKey,
							recordingsLookup: evt.ctrlKey && !evt.altKey ? 2 : evt.altKey && !evt.ctrlKey ?
								0 : GM_getValue('recordings_lookup', 1),
							rgLookup: !evt.ctrlKey || !evt.altKey,
						});
					} else (function(urls) {
						if (id = urls.map(url => mbIdExtractor(url, 'release-group')).find(Boolean))
							return Promise.resolve(id);
						if (id = urls.map(url => mbIdExtractor(url, 'release')).filter(Boolean))
							return mbApiRequest('release/' + id, { inc: 'release-groups' })
								.then(release => release['release-group'].id);
						return Promise.reject('Dropped target doesnot contain valid link');
					})(urls).then(releaseGroupId => { callback(target, { releaseGroupId: releaseGroupId }) });
				}, 'seed-mb-release', style, tooltip, tooltipster);
				span.prologue = function(waitingStatus) {
					if (this.disabled) return false; else this.disabled = true;
					if (waitingStatus) {
						this.classList.add('in-progress');
						this.style.cursor = 'auto';
						this.animation = flashElement(this); //this.innerHTML = svgAniSpinner();
					}
					return true;
				}.bind(span);
				span.epilogue = function() {
					if (this.classList.contains('in-progress')) {
						//this.innerHTML = staticIcon;
						if (this.animation) {
							this.animation.cancel();
							delete this.animation;
						}
						this.style.cursor = 'pointer';
						this.classList.remove('in-progress');
					}
					this.disabled = false;
				}.bind(span);
				return span;
			}

			if (target.disabled) return; else target.disabled = true;
			[target.textContent, target.style.color] = ['Looking up...', null];
			const animation = flashElement(target);
			const getMbTOCs = () => lookupByToc(torrentId, tocEntries => Promise.resolve(tocEntriesToMbTOC(tocEntries)));
			const scriptSignature = 'Edition lookup by CD TOC browser script (https://greasyfork.org/scripts/459083)';
			const mbAttachMode = Number(GM_getValue('mb_attach_toc', 2));
			const mbSubmitLog = GM_getValue('mb_submit_log', false);
			const mbSeedNew = Number(GM_getValue('mb_seed_release', 1));
			const mbUpdateRelease = Number(GM_getValue('mb_update_release', 1));
			const getDiscogsRels = (entry, entity = 'release') => entry && Array.isArray(entry.relations) ?
				entry.relations.filter(relation => relation['target-type'] == 'url' && relation.type == 'discogs')
					.map(relation => discogsIdExtractor(relation.url.resource, entity))
						.filter((discogsId, index, discogsIds) => discogsId > 0 && discogsIds.indexOf(discogsId) == index) : [ ];
			const getAllMusicRels = (entry, entity = 'album/release') => entry && Array.isArray(entry.relations) ?
				entry.relations.filter(relation => relation['target-type'] == 'url' && relation.type == 'allmusic')
					.map(relation => allMusicIdExtractor(relation.url.resource, entity))
						.filter((allMusicId, index, allMusicIds) => allMusicId && allMusicIds.indexOf(allMusicId) == index) : [ ];
			const getArtistRecordings = mbid => Promise.all([
				mbLookupById('recording', 'artist', mbid),
				mbApiRequest('artist/' + mbid, { inc: 'aliases+recording-rels' }).then(function({relations}) {
					if (!relations) throw `Assertion failed: no relations for artist ${mbid}`;
					return relations.filter(relation => ['recording'].includes(relation['target-type']))
						.map(relation => Object.assign({ relationType: relation.type }, relation[relation['target-type']]))
						.filter((recording1, index, recordings) => recordings.findIndex(recording2 => recording2.id == recording1.id) == index);
				}).catch(function(reason) {
					console.warn(reason);
					return null;
				}),
			]).then(recordings => (recordings = Array.prototype.concat.apply([ ],
				recordings.filter(Boolean))).length > 0 ? recordings : null);
			const normTitle = title => title && [ ].reduce((str, subst) => str.replace(...subst), title);
			const capitalizeName = name => name && [
				[/\s+/g, ' '],
				[/\s+(And|Of|In|On|The|Da|De[lnr]?|Du|Van|Von|Y|Et|Vs\.?|Feat(?:uring|\.)?|Ft\.?)(?=\s)/g, (...m) => ' ' + m[1].toLowerCase()],
			].reduce((str, subst) => str.replace(...subst), name);
			const bracketStripper = [/(?:\s+(?:\(.+\)|\[.+\]))+$/, ''];
			const dateParser = date => date && (date = /^((?:19|20)\d{2})(?:-(\d{2})(?:-(\d{2}))?)?$/.exec(date)) != null ?
				date.slice(1).map(n => (n = parseInt(n)) > 0 ? n : undefined) : null;
			const mbRelationsIndex = {
				'release': {
					// artist
					18: 'art direction', 19: 'design/illustration', 20: 'photography', 22: 'legal representation',
					23: 'booking', 24: 'liner notes', 25: 'misc', 26: 'mix', 27: 'graphic design', 28: 'engineer', 29: 'sound',
					30: 'producer', 31: 'audio', 32: 'publishing', 36: 'recording', 37: 'programming', 38: 'editor',
					40: 'orchestrator', 41: 'instrument arranger', 42: 'mastering', 43: 'mix-DJ', 44: 'instrument',
					45: 'performing orchestra', 46: 'conductor', 47: 'remixer', 48: 'compiler', 49: 'samples from artist',
					51: 'performer', 53: 'chorus master', 54: 'writer', 55: 'composer', 56: 'lyricist', 57: 'librettist',
					60: 'vocal', 295: 'arranger', 296: 'vocal arranger', 709: 'copyright', 710: 'phonographic copyright',
					727: 'balance', 759: 'concertmaster', 871: 'translator', 927: 'illustration', 928: 'design',
					929: 'booklet editor', 969: 'lacquer cut', 987: 'instrument technician', 993: 'artwork', 1010: 'licensor',
					1012: 'field recordist', 1179: 'transfer', 1185: 'video director', 1187: 'audio director',
					// series
					741: 'part of',
				},
				'release-group': {
					// artist
					62: 'artists and repertoire', 63: 'creative direction', 65: 'tribute', 868: 'dedicated to', 974: 'named after',
					// series
					742: 'part of',
				},
				'recording': {
					// artist
					123: 'photography', 125: 'graphic design', 127: 'publishing', 128: 'recording', 129: 'misc',
					130: 'design/illustration', 132: 'programming', 133: 'sound', 134: 'booking', 135: 'artists and repertoire',
					136: 'mastering', 137: 'art direction', 138: 'engineer', 140: 'audio', 141: 'producer',
					142: 'legal representation', 143: 'mix', 144: 'editor', 146: 'creative direction', 147: 'compiler',
					148: 'instrument', 149: 'vocal', 150: 'performing orchestra', 151: 'conductor', 152: 'chorus master',
					153: 'remixer', 154: 'samples from artist', 155: 'mix-DJ', 156: 'performer', 158: 'instrument arranger',
					297: 'arranger', 298: 'vocal arranger', 300: 'orchestrator', 726: 'balance', 760: 'concertmaster',
					858: 'video appearance', 869: 'phonographic copyright', 962: 'video director', 986: 'instrument technician',
					1011: 'field recordist', 1186: 'audio director',
				},
				'work': {
					// artist
					167: 'writer', 168: 'composer', 165: 'lyricist', 169: 'librettist', 844: 'revised by', 872: 'translator',
					917: 'reconstructed by', 293: 'arranger', 282: 'instrument arranger', 164: 'orchestrator',
					294: 'vocal arranger', 834: 'previous attribution', 162: 'misc', 889: 'commissioned', 846: 'dedication',
					956: 'premiere', 161: 'publishing', 972: 'named after',
				},
			};
			const instrumentMapper = (attributes, creditType) => [/*44, */148].map(linkTypeId => ({
				linkTypeId: linkTypeId,
				attributes: attributes,
				creditType: creditType || undefined,
			}));
			const findSourceEntity = linkTypeId => Object.keys(mbRelationsIndex).find(key => linkTypeId in mbRelationsIndex[key]);
			lookupByToc(torrentId, (tocEntries, discNdx, totalDiscs) =>
					mbLookupByDiscID(tocEntriesToMbTOC(tocEntries), !evt.ctrlKey)).then(function(results) {
				if (animation) animation.cancel();
				if (logScoresCache && 'torrentId' in logScoresCache && logScoresCache[torrentId].every(uncalibratedReadOffset))
					return Promise.reject('Incorrect read offset');
				if (mbSeedNew > 0) target.after(seedToMBIcon(function clickHandler(target, params) {
					queryAjaxAPICached('torrent', { id: torrentId })
						.then(torrent => { seedToMB(target, torrent, params) }, alert);
				}, undefined, `Seed new MusicBrainz release from this CD TOC
Use Ctrl or drop Discogs/AllMusic release link to import external metadata (+ Shift skips MBID lookup - faster, use when adding to exising release group)
Drop exising MusicBrainz release group link to seed to this group
MusicBrainz account required`, true));
				if (mbAttachMode > 0) target.after(attachToMBIcon(undefined, undefined,
						'Attach this CD TOC by hand to release not shown in lookup results\nMusicBrainz account required', true));
				let score = results.every(medium => medium == null) ? 8 : results[0] == null ?
					results.every(medium => medium == null || !medium.attached) ? 7 : 6 : 5;
				if (score < 6 || !evt.ctrlKey) target.dataset.haveResponse = true;
				if (score > 7) return Promise.reject('No matches'); else if (score > 5) {
					target.textContent = 'Unlikely matches';
					target.style.color = score > 6 ? '#f40' : '#f80';
					setTooltip(target, Boolean(target.dataset.haveResponse) ?
						`Matched media found only for some volumes (${score > 6 ? 'fuzzy' : 'exact'})` : null);
					return;
				}
				let releases = results[0].releases.filter(release => !release.media
					|| sameMedia(release).length == results.length, results);
				if (releases.length > 0) score = results.every(result => result != null) ?
					results.every(result => result.attached) ? 0 : results.some(result => result.attached) ? 1 : 2
						: results.some(result => result != null && result.attached) ? 3 : 4;
				if (releases.length <= 0) releases = results[0].releases;
				target.dataset.releaseIds = JSON.stringify(releases.map(release => release.id));
				target.dataset.discIds = JSON.stringify(results.filter(Boolean).map(result => result.mbDiscID));
				target.dataset.tocs = JSON.stringify(results.filter(Boolean).map(result => result.mbTOC));
				setTooltip(target, 'Open results in new window');
				(function(type, color) {
					type = `${releases.length} ${type} match`;
					target.textContent = releases.length != 1 ? type + 'es' : type;
					target.style.color = color;
				})(...[
					['exact', '#0a0'], ['hybrid', '#3a0'], ['fuzzy', '#6a0'],
					['partial', '#9a0'], ['partial', '#ca0'], ['irrelevant', '#f80'],
				][score]);
				if (autoOpenTab && score < 2) GM_openInTab(mbOrigin + '/cdtoc/' +
					(evt.shiftKey ? 'attach?toc=' + results[0].mbTOC.join(' ') : results[0].mbDiscID), true);
				if (score < 5) return queryAjaxAPICached('torrent', { id: torrentId }).then(function(torrent) {
					function buildEditionTitle(release) {
						const editionTitle = (release.disambiguation || '')
							.split(/[\,\;]/).map(str => str.trim()).filter(Boolean);
						if (release.status) switch (release.status) {
							case 'Official': break;
							case 'Bootleg': editionTitle.push('Unofficial'); break;
							default: editionTitle.push(release.status);
						}
						// if (release.packaging && !['Jewel Case'].includes(release.packaging))
						// 	editionTitle.push(release.packaging);
						Array.prototype.push.apply(editionTitle, release.media.filter(isCD)
							.map(medium => medium.format).filter(format => format != 'CD').filter(uniqueValues));
						if (useCountryInTitle && release.country && !['XW'].includes(release.country.toUpperCase()))
							editionTitle.push((iso3166ToCountryShort[release.country.toUpperCase()] || release.country).toUpperCase());
						return editionTitle.length > 0 ? editionTitle.join(' / ') : undefined;
					}

					const isCompleteInfo = torrent.torrent.remasterYear > 0
						&& Boolean(torrent.torrent.remasterRecordLabel)
						&& Boolean(torrent.torrent.remasterCatalogueNumber);
					const is = what => !torrent.torrent.remasterYear && {
						unknown: torrent.torrent.remastered,
						unconfirmed: !torrent.torrent.remastered,
					}[what];
					if (torrent.torrent.description)
						torrentDetails.dataset.torrentDescription = torrent.torrent.description.trim();
					// add inpage search results
					const [thead, table, tbody] = createElements('div', 'table', 'tbody');
					[thead.style, thead.innerHTML] = [theadStyle, `<b>Applicable MusicBrainz matches</b> (${[
						'exact',
						`${results.filter(result => result != null && result.attached).length} exact out of ${results.length} matches`,
						'fuzzy',
						`${results.filter(result => result != null && result.attached).length} exact / ${results.filter(result => result != null).length} matches out of ${results.length}`,
						`${results.filter(result => result != null).length} matches out of ${results.length}`,
					][score]})`];
					table.style = 'margin: 0; max-height: 10rem; overflow-y: auto; empty-cells: hide;';
					table.className = 'mb-lookup-results mb-lookup-' + torrent.torrent.id;
					const [recordLabels, catalogueNumbers] = editionInfoParser(torrent.torrent);
					const labelInfoMapper = release => Array.isArray(release['label-info']) ?
						release['label-info'].map(labelInfo => ({
							label: labelInfo.label ? rxNoLabel.test(labelInfo.label.name) ? noLabel : labelInfo.label.name : undefined,
							catNo: rxNoCatno.test(labelInfo['catalog-number']) ? undefined : labelInfo['catalog-number'],
						})).filter(labelInfo => labelInfo.label || labelInfo.catNo) : [ ];
					const rowWorkers = [ ];
					releases.forEach(function(release, index) {
						function updateFromXtrnDb(param, xtrnDbId, params, target) {
							if (param && xtrnDbId) params = Object.assign({
								overwrite: false,
								releaseRels: true,
								recordingRels: false, workRels: false,
								mbidLookup: true,
							}, params); else return;
							if (target instanceof HTMLElement) target.disabled = true; else target = null;
							const animation = target && flashElement(target);
							const updateFromXtrnDb = (seeder, dbName) =>
								updateFromExternalDb(release.id, (params2, cdLengths) => seeder(new URLSearchParams, xtrnDbId, {
									extendedMetadata: true,
									mbidLookup: params.mbidLookup,
									recordingRelations: params.recordingRels, workRelations: params.workRels,
									releaseRelations: params.releaseRels, rgRelations: params.releaseRels,
									languageIdentifier: params2.languageIdentifier,
									tracklist: false, recordingsLookup: 0, rgLookup: false, lookupArtistsByRecording: false,
								}, cdLengths), dbName + ' release id ' + xtrnDbId, params.overwrite).then(function(results) {
									if (results === null) alert('Nothing to be updated');
									else if (results) GM_openInTab([mbOrigin, 'release', release.id,
										mbUpdateRelease > 1 ? 'edit' : 'edits'].join('/'), false);
								}, alert).then(function() {
									if (animation != null) animation.cancel();
									if (target != null) target.disabled = false;
								});
							switch (param) {
								case 'discogsId': updateFromXtrnDb(seedFromDiscogs, 'Discogs'); break;
								case 'allMusicId': updateFromXtrnDb(seedFromAllMusic, 'AllMusic'); break;
								default:
									alert('Not implemented');
									if (animation != null) animation.cancel();
									if (target != null) target.disabled = false;
							}
						}

						const [tr, artist, title, releaseEvents, editionInfo, barcode, groupSize, releasesWithId] =
							createElements('tr', 'td', 'td', 'td', 'td', 'td', 'td', 'td');
						[tr.className, tr.style] = ['musicbrainz-release', 'transition: color 200ms ease-in-out;'];
						if (release.quality == 'low') tr.style.opacity = 0.75;
						[releaseEvents, barcode].forEach(elem => { elem.style.whiteSpace = 'nowrap' });
						[groupSize, releasesWithId].forEach(elem => { elem.style.textAlign = 'right' });
						setMusicBrainzArtist(release, artist);
						setMusicBrainzTitle(release, title);
						// attach CD TOC
						if (mbAttachMode > 0 && (score > 0 || results.some(medium => !medium.releases.some(_release =>
								_release.id == release.id)))) title.prepend(attachToMBIcon(release.id,
							'float: right; margin: 0 0 0 4pt;',
							`Attach CD TOC to release (verify CD rip and MB release are identical edition)
Submission mode: ${mbAttachMode > 1 ? 'unattended (Alt+click enforces attended mode, Ctrl+click disables poll)' : 'attended'}
MusicBrainz account required`));
						// Seed new edition
						if (mbSeedNew > 0) title.prepend(seedToMBIcon(function(target, params) {
							seedToMB(target, torrent, Object.assign((params || { }), { releaseGroupId: release['release-group'].id }));
						}, 'float: right; margin: 0 0 0 4pt;', `Seed new MusicBrainz edition from this CD TOC in same release group
Use Ctrl or drop Discogs/AllMusic release link to import external metadata (+ Shift skips MBIDs lookup – faster)
MusicBrainz account required`));
						// update from external DB
						if (mbUpdateRelease > 0) title.prepend(addIcon(minifyHTML(`
<svg height="0.9em" fill="${staticIconColor}" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
  <path d="M50 22.73l0 13.63 18.18 -18.18 -18.18 -18.18 0 13.64c-20.09,0 -36.36,16.27 -36.36,36.36 0,7.14 2.09,13.75 5.63,19.36l6.64 -6.63c-2.02,-3.8 -3.18,-8.12 -3.18,-12.73 0,-15.07 12.2,-27.27 27.27,-27.27l0 0zm30.73 7.91l-6.64 6.63c2.02,3.8 3.18,8.12 3.18,12.73 0,15.07 -12.2,27.27 -27.27,27.27l0 -13.63 -18.18 18.18 18.18 18.18 0 -13.64c20.09,0 36.36,-16.27 36.36,-36.36 0,-7.14 -2.09,-13.75 -5.63,-19.36l0 0z"/>
</svg>`), function clickHandler(evt) {
							const input = prompt('Enter Discogs/AllMusic release ID or URL:\n\n');
							if (!input) return;
							let param, xtrnDbId, releaseType = document.body.querySelector('div#content div.header > h2');
							releaseType = releaseType && / \[([^\[\]]+)\]$/.exec(releaseType.textContent.trim());
							releaseType = releaseType && releaseType[1];
							if (xtrnDbId = discogsIdExtractor(input, 'release')) param = 'discogsId';
							else if (xtrnDbId = allMusicIdExtractor(input, 'album/release')) param = 'allMusicId';
							if (param) updateFromXtrnDb(param, xtrnDbId, {
								releaseRels: !evt.shiftKey,
								recordingRels: evt.altKey, workRels: evt.altKey,
								overwrite: evt.ctrlKey,
							}, evt.currentTarget);
						}, function dropHandler(evt) {
							const urls = droppedUrls(evt);
							if (urls) evt.stopPropagation(); else return;
							let param, xtrnDbId, releaseType = document.body.querySelector('div#content div.header > h2');
							releaseType = releaseType && / \[([^\[\]]+)\]$/.exec(releaseType.textContent.trim());
							releaseType = releaseType && releaseType[1];
							if (xtrnDbId = urls.map(url => discogsIdExtractor(url, 'release')).find(Boolean)) param = 'discogsId';
							else if (xtrnDbId = urls.map(url => allMusicIdExtractor(url, 'album/release')).find(Boolean)) param = 'allMusicId';
							if (param) updateFromXtrnDb(param, xtrnDbId, {
								releaseRels: !evt.shiftKey,
								recordingRels: evt.altKey, workRels: evt.altKey,
								overwrite: evt.ctrlKey,
							}, evt.currentTarget);
						}, 'update-from-xtrn-source', 'float: right; margin: 0 0 0 2pt;', 'Update release info from external source and create relations\n(drop Discogs/AllMusic release link here)\n--------------------\nKeyboard modifiers:\nAlt - relate artist credits also on recordings and works level\nCtrl - force overwrite (use only if existing release data is somehow wrong)\nShift - don\'t lookup any MBIDs (fast but some information will be missing)\nMusicBrainz account required'));
						setMusicBrainzReleaseEvents(release, releaseEvents, torrent.torrent.remasterYear);
						if (Array.isArray(release['label-info'])) fillListRows(editionInfo, release['label-info']
							.map(labelInfo => editionInfoMapper(labelInfo.label && labelInfo.label.name,
								labelInfo['catalog-number'], recordLabels, catalogueNumbers,
									labelInfo.label && labelInfo.label.id && labelInfo.label.id != '157afde4-4bf5-4039-8ad2-5a15acc85176' ?
										[mbOrigin, 'label', labelInfo.label.id].join('/') : undefined)));
						if (editionInfo.childElementCount <= 0) mbFindEditionInfoInAnnotation(editionInfo, release.id);
						if (release.barcode) {
							barcode.textContent = release.barcode;
							if (catalogueNumbers.some(catalogueNumber => sameBarcodes(catalogueNumber, release.barcode)))
								editionInfoMatchingStyle(barcode);
						}
						setMusicBrainzGroupSize(release, groupSize, releasesWithId, results.length);
						tr.dataset.releaseUrl = [mbOrigin, 'release', release.id].join('/');
						const releaseYear = getReleaseYear(release.date), _editionInfo = labelInfoMapper(release);
						if (!_editionInfo.some(labelInfo => labelInfo.catNo) && release.barcode)
							_editionInfo.push({ catNo: release.barcode });
						if (releaseYear > 0 && _editionInfo.length > 0) {
							tr.dataset.remasterYear = releaseYear;
							setEditionInfo(tr, _editionInfo);
							let editionTitle = buildEditionTitle(release);
							if (editionTitle) tr.dataset.remasterTitle = editionTitle;
							try {
								if (isCompleteInfo || !('editionGroup' in torrentDetails.dataset) || score > (is('unknown') ? 0 : 3)
										|| noEditPerms || !editableHosts.includes(document.domain) && !ajaxApiKey)
									throw 'Not applicable';
								if (!(releaseYear > 0)) throw 'Edition year missing';
								if (_editionInfo.length <= 0 && torrent.torrent.remasterYear > 0
										&& (torrent.torrent.remasterTitle || !editionTitle)) throw 'No additional edition information';
								applyOnClick(tr);
							} catch(e) { applyOnCtrlClick(tr) }
						}
						setMusicBrainzTooltip(release, tr);
						tr.append(artist, title, releaseEvents, editionInfo, barcode, groupSize, releasesWithId);
						for (let cell of tr.cells) cell.style.backgroundColor = 'inherit';
						['artist', 'title', 'release-events', 'edition-info', 'barcode', 'editions-total', 'discids-total']
							.forEach((className, index) => { tr.cells[index].className = className });
						tbody.append(tr);
						for (let discogsId of getDiscogsRels(release)) {
							if (title.querySelector('span.have-discogs-relatives') == null) {
								const span = document.createElement('span');
								[span.className, span.innerHTML] = ['have-discogs-relatives', GM_getResourceText('dc_icon')];
								span.firstElementChild.setAttribute('height', 6);
								span.firstElementChild.removeAttribute('width');
								span.firstElementChild.style.verticalAlign = 'top';
								svgSetTitle(span.firstElementChild, 'Has defined Discogs relative(s)');
								title.append(' ', span);
							}
							rowWorkers.push(dcApiRequest('releases/' + discogsId).then(function(release) {
								const [trDC, icon, artist, title, releaseEvents, editionInfo, barcode, groupSize] =
									createElements('tr', 'td', 'td', 'td', 'td', 'td', 'td', 'td');
								[trDC.className, icon.style.textAlign] = ['discogs-release', 'right'];
								trDC.style = 'transition: color 200ms ease-in-out;';
								trDC.style.backgroundColor = trDC.dataset.backgroundColor ='#8881';
								[barcode.style.whiteSpace, groupSize.style.textAlign] = ['nowrap', 'right'];
								setDiscogsArtist(artist, release.artists);
								title.innerHTML = linkHTML(release.uri, release.title, 'discogs-release');
								let descriptors = getDiscogsReleaseDescriptors(release);
								if (descriptors.length > 0) appendDisambiguation(title, descriptors.join(', '));
								if (release.images && release.images.length > 0) {
									let thumbnail = release.images.find(image => image.type == 'primary') || release.images[0];
									addThumbnail(title, thumbnail && (thumbnail.uri150 || thumbnail.uri),
										[dcOrigin, 'release', release.id, 'images'].join('/'));
								}
								if (release.country || release.released)
									fillListRows(releaseEvents, iso3166ToFlagCodes(discogsCountryToIso3166Mapper(release.country)).map(countryCode =>
										releaseEventMapper(countryCode, release.released && release.released.replace(/(?:-00)+$/, ''),
											torrent.torrent.remasterYear)), 3);
								if (Array.isArray(release.labels)) fillListRows(editionInfo, release.labels.map(label =>
									editionInfoMapper(stripDiscogsNameVersion(label.name), label.catno, recordLabels, catalogueNumbers,
										label.id && !label.name.startsWith('Not On Label') ? [dcOrigin, 'label', label.id].join('/') : undefined)));
								let barCode = release.identifiers && release.identifiers.find(id => id.type == 'Barcode');
								if (barCode && (barCode = barCode.value.replace(/\D+/g, ''))) {
									barcode.textContent = barCode;
									if (catalogueNumbers.some(catalogueNumber => sameBarcodes(catalogueNumber, barCode)))
										editionInfoMatchingStyle(barcode);
								}
								setDiscogsGroupSize(release, groupSize);
								icon.innerHTML = GM_getResourceText('dc_icon');
								icon.firstElementChild.style = '';
								icon.firstElementChild.removeAttribute('width');
								icon.firstElementChild.setAttribute('height', '0.9em');
								svgSetTitle(icon.firstElementChild, release.id);
								trDC.dataset.releaseUrl = [dcOrigin, 'release', release.id].join('/');
								const releaseYear = getReleaseYear(release.released);
								const _editionInfo = Array.isArray(release.labels) ? release.labels.map(label => ({
									label: rxNoLabel.test(label.name) ? noLabel : stripDiscogsNameVersion(label.name),
									catNo: rxNoCatno.test(label.catno) ? undefined : label.catno,
								})).filter(label => label.label || label.catNo) : [ ];
								if (!_editionInfo.some(label => label.catNo) && barCode) _editionInfo.push({ catNo: barCode });
								if (releaseYear > 0 && _editionInfo.length > 0) {
									trDC.dataset.remasterYear = releaseYear;
									setEditionInfo(trDC, _editionInfo);
									descriptors = Array.from(descriptors).filter(description => ![
										'Mini-Album', 'Digipak', 'Digipack', 'Sampler', 'Mixtape', 'CD-TEXT', //'Maxi-Single',
									].includes(description));
									if (release.formats) {
										if (release.formats.some(format => format.name == 'CDr')) descriptors.push('CD-R');
										if (release.formats.some(format => format.name == 'SACD') && descriptors.includes('Hybrid'))
											descriptors.push('Hybrid SACD');
										if (release.formats.some(format => format.name == 'Hybrid') && descriptors.includes('DualDisc'))
											descriptors.push('DualDisc');
									}
									const countriesIso3166 = discogsCountryToIso3166Mapper(release.country)
										.filter(countryIso3166 => countryIso3166 && !['XW'].includes(countryIso3166));
									if (useCountryInTitle && countriesIso3166.length > 0 && countriesIso3166.length < 3)
										descriptors.push(countriesIso3166.map(countryIso3166 =>
											iso3166ToCountryShort[countryIso3166] || countryIso3166).join(' / '));
									if (descriptors.length > 0) trDC.dataset.remasterTitle = descriptors.join(' / ');
									try {
										if (isCompleteInfo || !('editionGroup' in torrentDetails.dataset) || score > (is('unknown') ? 0 : 3)
												|| noEditPerms || !editableHosts.includes(document.domain) && !ajaxApiKey)
											throw 'No additional edition information';
										if (!(releaseYear > 0)) throw 'Edition year missing';
										if (_editionInfo.length <= 0 && torrent.torrent.remasterYear > 0
												&& (torrent.torrent.remasterTitle || !descriptors))
											throw 'No additional edition information';
										applyOnClick(trDC);
									} catch(e) { applyOnCtrlClick(trDC) }
								}
								setDiscogsTooltip(release, trDC);
								trDC.append(artist, title, releaseEvents, editionInfo, barcode, groupSize, icon);
								for (let cell of trDC.cells) cell.style.backgroundColor = 'inherit';
								['artist', 'title', 'release-events', 'edition-info', 'barcode', 'editions-total', 'discogs-icon']
									.forEach((className, index) => { trDC.cells[index].className = className });
								tr.after(trDC); //tbody.append(trDC);
							}, reason => { svgSetTitle(title.querySelector('span.have-discogs-relatives').firstElementChild, reason) }));
						}
					});
					table.append(tbody);
					Promise.all(rowWorkers).then(() => { addResultsFilter(thead, tbody, 5) }, console.warn);
					addLookupResults(torrentId, thead, table);
					// Group set
					if (isCompleteInfo || !('editionGroup' in torrentDetails.dataset) || score > (is('unknown') ? 0 : 3)
							|| noEditPerms || !editableHosts.includes(document.domain) && !ajaxApiKey
							|| torrent.torrent.remasterYear > 0 && !(releases = releases.filter(release =>
								!release.date || getReleaseYear(release.date) == torrent.torrent.remasterYear))
									.some(release => release['label-info'] && release['label-info'].length > 0 || release.barcode)
							|| releases.length > (is('unknown') ? 1 : 2)) return;
					const releaseYear = releases.reduce((year, release) =>
						year > 0 ? year : getReleaseYear(release.date), undefined);
					if (!(releaseYear > 0) || releases.some(release1 => releases.some(release2 =>
								getReleaseYear(release2.date) != getReleaseYear(release1.date)))
							|| !releases.every((release, ndx, arr) =>
								release['release-group'].id == arr[0]['release-group'].id)) return;
					const a = document.createElement('a');
					[a.className, a.href, a.textContent, a.style.fontWeight] =
						['update-edition', '#', '(set)', score <= 0 && releases.length < 2 ? 'bold' : 300];
					let editionInfo = Array.prototype.concat.apply([ ], releases.map(labelInfoMapper)), editionTitle;
					const barcodes = releases.map(release => release.barcode).filter(Boolean);
					if (!editionInfo.some(labelInfo => labelInfo.catNo) && barcodes.length > 0)
						Array.prototype.push.apply(editionInfo, barcodes.map(barcode => ({ catNo: barcode })));
					if (releases.length < 2) editionTitle = buildEditionTitle(releases[0]);
					if (editionInfo.length <= 0 && torrent.torrent.remasterYear > 0
							&& (torrent.torrent.remasterTitle || !editionTitle)) return;
					a.dataset.remasterYear = releaseYear;
					setEditionInfo(a, editionInfo);
					if (editionTitle) a.dataset.remasterTitle = editionTitle;
					if (releases.length < 2) a.dataset.releaseUrl = [mbOrigin, 'release', releases[0].id].join('/');
					setTooltip(a, 'Update edition info from matched release(s)\n\n' + releases.map(release =>
						release['label-info'].map(labelInfo => [getReleaseYear(release.date), [
							labelInfo.label && labelInfo.label.name,
							labelInfo['catalog-number'] || release.barcode,
						].filter(Boolean).join(' / ')].filter(Boolean).join(' – ')).filter(Boolean).join('\n')).join('\n'));
					a.onclick = updateEdition;
					if (is('unknown') || releases.length > 1) a.dataset.confirm = true;
					target.after(a);
				}, alert);
			}).catch(function(reason) {
				if (animation) animation.cancel();
				target.textContent = reason;
				target.style.color = 'red';
				if (Boolean(target.dataset.haveResponse)) setTooltip(target);
			}).then(() => { target.disabled = false });
		}
	}, 'Lookup edition on MusicBrainz by Disc ID/TOC (Ctrl enforces strict TOC matching)\nUse Alt to lookup by CDDB ID');
	addLookup('GnuDb', function(evt) {
		const target = evt.currentTarget;
		console.assert(target instanceof HTMLElement);
		const entryUrl = entry => `https://gnudb.org/cd/${entry[1].slice(0, 2)}${entry[2]}`;
		if (Boolean(target.dataset.haveResponse)) {
			if (!('entries' in target.dataset)) return;
			for (let entry of JSON.parse(target.dataset.entries).reverse()) GM_openInTab(entryUrl(entry), false);
			return;
		} else if (target.disabled) return; else target.disabled = true;
		target.textContent = 'Looking up...';
		target.style.color = null;
		lookupByToc(parseInt(torrentDetails.dataset.torrentId), function(tocEntries) {
			console.info('Local CDDB ID:', getCDDBiD(tocEntries));
			console.info('Local AR ID:', getARiD(tocEntries));
			const reqUrl = new URL('https://gnudb.gnudb.org/~cddb/cddb.cgi');
			let tocDef = [tocEntries.length].concat(tocEntries.map(tocEntry => preGap + tocEntry.startSector));
			const tt = preGap + tocEntries[tocEntries.length - 1].endSector + 1 - tocEntries[0].startSector;
			tocDef = tocDef.concat(Math.floor(tt / msf)).join(' ');
			reqUrl.searchParams.set('cmd', `discid ${tocDef}`);
			reqUrl.searchParams.set('hello', `name ${document.domain} userscript.js 1.0`);
			reqUrl.searchParams.set('proto', 6);
			return globalXHR(reqUrl, { responseType: 'text' }).then(function({responseText}) {
				console.log('GnuDb CDDB discid:', responseText);
				const response = /^(\d+) Disc ID is ([\da-f]{8})$/i.exec(responseText.trim());
				if (response == null) return Promise.reject(`Unexpected response format (${responseText})`);
				console.assert((response[1] = parseInt(response[1])) == 200);
				reqUrl.searchParams.set('cmd', `cddb query ${response[2]} ${tocDef}`);
				return globalXHR(reqUrl, { responseType: 'text', context: response });
			}).then(function({responseText}) {
				console.log('GnuDb CDDB query:', responseText);
				let entries = /^(\d+)\s+(.+)/.exec((responseText = responseText.trim().split(/\r?\n/))[0]);
				if (entries == null) return Promise.reject('Unexpected response format');
				const statusCode = parseInt(entries[1]);
				if (statusCode < 200 || statusCode >= 400) return Promise.reject(`Server response error (${statusCode})`);
				if (statusCode == 202) return Promise.reject('No matches');
				entries = (statusCode >= 210 ? responseText.slice(1) : [entries[2]])
					.map(RegExp.prototype.exec.bind(/^(\w+)\s+([\da-f]{8})\s+(.*)$/i)).filter(Boolean);
				return entries.length <= 0 ? Promise.reject('No matches')
					: { status: statusCode, discId: arguments[0].context[2], entries: entries };
			});
		}).then(function(results) {
			if (logScoresCache && 'torrentId' in logScoresCache && logScoresCache[torrentId].every(uncalibratedReadOffset))
				return Promise.reject('Incorrect read offset');
			if (results.length <= 0 || results[0] == null) return Promise.reject('No matches');
			let caption = `${results[0].entries.length} ${['exact', 'fuzzy'][results[0].status % 10]} match`;
			if (results[0].entries.length > 1) caption += 'es';
			target.textContent = caption;
			target.style.color = '#0a0';
			if (results[0].entries.length <= 5) for (let entry of Array.from(results[0].entries).reverse())
				GM_openInTab(entryUrl(entry), true);
			target.dataset.entries = JSON.stringify(results[0].entries);
			target.dataset.haveResponse = true;
		}).catch(function(reason) {
			target.textContent = reason;
			target.style.color = 'red';
		}).then(() => { target.disabled = false });
	}, 'Lookup edition on GnuDb (CDDB disc id)');
	addLookup('CTDB', function(evt) {
		const target = evt.currentTarget;
		console.assert(target instanceof HTMLElement);
		if (target.disabled) return; else target.disabled = true;
		const torrentId = parseInt(torrentDetails.dataset.torrentId);
		if (!(torrentId > 0)) throw 'Assertion failed: invalid torrentId';
		lookupByToc(torrentId, function(tocEntries) {
			if (tocEntries.length > 100) throw 'TOC size exceeds limit';
			tocEntries = tocEntries.map(tocEntry => tocEntry.endSector + 1 - tocEntries[0].startSector);
			return Promise.resolve(new DiscID().addValues(tocEntries, 8, 100).toDigest());
		}).then(function(tocIds) {
			if (!Boolean(target.dataset.haveQuery) && !autoOpenTab) return;
			for (let tocId of Array.from(tocIds).reverse()) if (tocId != null)
				GM_openInTab('https://db.cue.tools/?tocid=' + tocId, !Boolean(target.dataset.haveQuery));
		}, function(reason) {
			target.textContent = reason;
			target.style.color = 'red';
		}).then(() => { target.disabled = false });
		if (Boolean(target.dataset.haveQuery)) return;
		const ctdbLookup = params => lookupByToc(torrentId, function(tocEntries, volumeNdx) {
			const url = new URL('https://db.cue.tools/lookup2.php');
			url.searchParams.set('version', 3);
			url.searchParams.set('ctdb', 1);
			if (params) for (let param in params) url.searchParams.set(param, params[param]);
			url.searchParams.set('toc', tocEntries.map(tocEntry => tocEntry.startSector)
				.concat(tocEntries.pop().endSector + 1).join(':'));
			const saefInt = (base, property) =>
				isNaN(property = parseInt(base.getAttribute(property))) ? undefined : property;
			return globalXHR(url).then(({responseXML}) => ({
				metadata: Array.from(responseXML.getElementsByTagName('metadata'), metadata => ({
					source: metadata.getAttribute('source') || undefined,
					id: metadata.getAttribute('id') || undefined,
					artist: metadata.getAttribute('artist') || undefined,
					album: metadata.getAttribute('album') || undefined,
					year: saefInt(metadata, 'year'),
					discNumber: saefInt(metadata, 'discnumber'),
					discCount: saefInt(metadata, 'disccount'),
					release: Array.from(metadata.getElementsByTagName('release'), release => ({
						date: release.getAttribute('date') || undefined,
						country: release.getAttribute('country') || undefined,
					})),
					labelInfo: Array.from(metadata.getElementsByTagName('label'), label => ({
						name: label.getAttribute('name') || undefined,
						catno: label.getAttribute('catno') || undefined,
					})),
					barcode: metadata.getAttribute('barcode') || undefined,
					infourl: metadata.getAttribute('infourl') || undefined,
					extra: Array.from(metadata.getElementsByTagName('extra'), extra => extra.textContent.trim()),
					relevance: saefInt(metadata, 'relevance'),
				})),
				entries: Array.from(responseXML.getElementsByTagName('entry'), entry => ({
					confidence: saefInt(entry, 'confidence'),
					crc32: saefInt(entry, 'crc32'),
					hasparity: entry.getAttribute('hasparity') || undefined,
					id: saefInt(entry, 'id'),
					npar: saefInt(entry, 'npar'),
					stride: saefInt(entry, 'stride'),
					syndrome: entry.getAttribute('syndrome') || undefined,
					toc: entry.hasAttribute('toc') ?
						entry.getAttribute('toc').split(':').map(offset => parseInt(offset)) : undefined,
					trackcrcs: entry.hasAttribute('trackcrcs') ?
						entry.getAttribute('trackcrcs').split(' ').map(crc => parseInt(crc, 16)) : undefined,
				})),
			}));
		}).then(function(results) {
			console.log('CTDB lookup (%s, %d) results:', params.metadata, params.fuzzy, results);
			return results.length > 0 && results[0] != null && (results = Object.assign(results[0].metadata.filter(function(metadata) {
				if (!['musicbrainz', 'discogs'].includes(metadata.source)) return false;
				if (metadata.discCount > 0 && metadata.discCount != results.length) return false;
				return true;
			}), { confidence: (entries => getSessions(torrentId).then(sessions => sessions.length == entries.length ? sessions.map(function(session, volumeNdx) {
				if (rxRangeRip.test(session)) return null;
				const rx = [
					/^\s+(?:(?:Copy|复制|Kopie|Copia|Kopiera|Copiar|Копиран) CRC|CRC (?:копии|êîïèè|kopii|kopije|kopie|kópie)|コピーCRC)\s+([\da-fA-F]{8})$/gm,
					/^\s+(?:CRC32 hash|CRC)\s*:\s*([\da-fA-F]{8})$/gm, // XLD / EZ CD
				];
				return (session = session.match(rx[0]) || session.match(rx[1])) && session.map(match =>
					parseInt(rx.reduce((m, rx) => m || (rx.lastIndex = 0, rx.exec(match)), null)[1], 16));
			}).map(function getScores(checksums, volumeNdx) {
				if (checksums == null || entries[volumeNdx] == null || checksums.length < 3
						|| !entries[volumeNdx].some(entry => entry.trackcrcs && entry.trackcrcs.length == checksums.length))
					return null; // tracklist too short
				const getMatches = matchFn => entries[volumeNdx].reduce((sum, entry, ndx) =>
					matchFn(entry.trackcrcs && entry.trackcrcs.length == checksums.length ?
						entry.trackcrcs.slice(1, -1).filter((crc32, ndx) =>
							crc32 == checksums[ndx + 1]).length / (entry.trackcrcs.length - 2) : -Infinity) ?
								sum + entry.confidence : sum, 0);
				return [entries[volumeNdx].reduce((sum, entry) => sum + entry.confidence, 0),
					getMatches(score => score >= 1), getMatches(score => score >= 0.5), getMatches(score => score > 0)];
			}) : Promise.reject('assertion failed: LOGfiles miscount')).then(function getTotal(scores) {
				if ((scores = scores.filter(Boolean)).length <= 0)
					return Promise.reject('all media having too short tracklist,\nmismatching tracklist length, range rip or failed to extract checksums');
				const sum = array => array.reduce((sum, val) => sum + val, 0);
				const getTotal = index => Math.min(...(index = scores.map(score => score[index]))) > 0 ? sum(index) : 0;
				return {
					matched: getTotal(1),
					partiallyMatched: getTotal(2),
					anyMatched: getTotal(3),
					total: sum(scores.map(score => score[0])),
				};
			}))(results.map(result => result && result.entries)) })).length > 0 ? results : Promise.reject('No matches');
		});
		const methods = [
			{ metadata: 'fast', fuzzy: 0 }, { metadata: 'default', fuzzy: 0 }, { metadata: 'extensive', fuzzy: 0 },
			//{ metadata: 'fast', fuzzy: 1 }, { metadata: 'default', fuzzy: 1 }, { metadata: 'extensive', fuzzy: 1 },
		];
		[target.textContent, target.style.color] = ['Looking up...', null];
		(function execMethod(index = 0, reason = 'index out of range') {
			return index < methods.length ? ctdbLookup(methods[index]).then(results =>
					Object.assign(results, { method: methods[index] }),
				reason => execMethod(index + 1, reason)) : Promise.reject(reason);
		})().then(function(results) {
			if (logScoresCache && 'torrentId' in logScoresCache && logScoresCache[torrentId].every(uncalibratedReadOffset))
				return Promise.reject('Incorrect read offset');
			target.textContent = `${results.length}${Boolean(results.method.fuzzy) ? ' fuzzy' : ''} ${results.method.metadata} ${results.length == 1 ? 'match' : 'matches'}`;
			target.style.color = '#' + (['fast', 'default', 'extensive'].indexOf(results.method.metadata) +
				results.method.fuzzy * 3 << 1).toString(16) + 'a0';
			setTooltip(target, 'Open results in new window');
			return queryAjaxAPICached('torrent', { id: torrentId }).then(function(torrent) {
				function buildEditionTitle(metadata) {
					const editionTitle = [ ];
					const countries = metadata.release.filter(release => release.country
						&& !['XW'].includes(release.country.toUpperCase())).map(release =>
							iso3166ToCountryShort[release.country] || release.country);
					if (useCountryInTitle && countries.length > 0 && countries.length < 3)
						Array.prototype.push.apply(editionTitle, countries);
					return editionTitle.length > 0 ? editionTitle.join(' / ') : undefined;
				}

				const isCompleteInfo = torrent.torrent.remasterYear > 0
					&& Boolean(torrent.torrent.remasterRecordLabel)
					&& Boolean(torrent.torrent.remasterCatalogueNumber);
				const is = what => !torrent.torrent.remasterYear && {
					unknown: torrent.torrent.remastered,
					unconfirmed: !torrent.torrent.remastered,
				}[what];
				let [method, confidence] = [results.method, results.confidence];
				const confidenceBox = document.createElement('span');
				confidence.then(function(confidence) {
					if (confidence.anyMatched <= 0) return Promise.reject('mismatch');
					let color = confidence.matched || confidence.partiallyMatched || confidence.anyMatched;
					color = Math.round(color * 0x55 / confidence.total);
					color = 0x55 * (3 - Number(confidence.partiallyMatched > 0) - Number(confidence.matched > 0)) - color;
					confidenceBox.innerHTML = svgCheckmark('#' + (color << 16 | 0xCC00).toString(16).padStart(6, '0'));
					confidenceBox.className = confidence.matched > 0 ? 'ctdb-verified' : 'ctdb-partially-verified';
					setTooltip(confidenceBox, `Checksums${confidence.matched > 0 ? '' : ' partially'} matched (confidence ${confidence.matched || confidence.partiallyMatched || confidence.anyMatched}/${confidence.total})`);
				}).catch(function(reason) {
					confidenceBox.innerHTML = reason == 'mismatch' ? svgFail() : svgQuestionMark();
					confidenceBox.className = 'ctdb-not-verified';
					setTooltip(confidenceBox, `Could not verify checksums (${reason})`);
				}).then(() => { target.parentNode.append(confidenceBox) });
				confidence = confidence.then(confidence =>
						is('unknown') && confidence.anyMatched <= 0 ? Promise.reject('mismatch') : confidence,
					reason => ({ matched: undefined, partiallyMatched: undefined, anyMatched: undefined }));
				if (torrent.torrent.description)
					torrentDetails.dataset.torrentDescription = torrent.torrent.description.trim();
				// In-page results table
				const [thead, table, tbody] = createElements('div', 'table', 'tbody');
				thead.style = theadStyle;
				thead.innerHTML = `<b>Applicable CTDB matches</b> (method: ${Boolean(method.fuzzy) ? 'fuzzy, ' : ''}${method.metadata})`;
				table.style = 'margin: 0; max-height: 10rem; overflow-y: auto; empty-cells: hide;';
				table.className = 'ctdb-lookup-results ctdb-lookup-' + torrentId;
				const [recordLabels, catalogueNumbers] = editionInfoParser(torrent.torrent);
				const labelInfoMapper = metadata => metadata.labelInfo.map(labelInfo => ({
					label: labelInfo.name ? rxNoLabel.test(labelInfo.name) ? noLabel :
						metadata.source == 'discogs' ? stripDiscogsNameVersion(labelInfo.name) : labelInfo.name : undefined,
					catNo: rxNoCatno.test(labelInfo.catno) ? undefined : labelInfo.catno,
				})).filter(labelInfo => labelInfo.label || labelInfo.catNo);
				const _getReleaseYear = metadata => (metadata = metadata.release.map(release => getReleaseYear(release.date)))
					.every((year, ndx, arr) => year > 0 && year == arr[0]) ? metadata[0] : NaN;
				results.forEach(function(metadata) {
					function applyTooltip() {
						if (metadata.extra.length > 0 || metadata.infourl)
							(tr.title ? title.querySelector('a.' + metadata.source + '-release') : tr).title =
								metadata.extra.concat(metadata.infourl).filter(Boolean).join('\n\n');
					}

					const [tr, source, artist, title, release, editionInfo, barcode, relevance] =
						createElements('tr', 'td', 'td', 'td', 'td', 'td', 'td', 'td', 'td');
					[tr.className, tr.style] = ['ctdb-metadata', 'transition: color 200ms ease-in-out;'];
					tr.dataset.releaseUrl = [{
						musicbrainz: mbOrigin,
						discogs: dcOrigin,
					}[metadata.source], 'release', metadata.id].join('/');
					[release, barcode, relevance].forEach(elem => { elem.style.whiteSpace = 'nowrap' });
					[relevance].forEach(elem => { elem.style.textAlign = 'right' });
					if (source.innerHTML = GM_getResourceText({ musicbrainz: 'mb_logo', discogs: 'dc_icon' }[metadata.source])) {
						source.firstElementChild.removeAttribute('width');
						source.firstElementChild.setAttribute('height', '1em');
						svgSetTitle(source.firstElementChild, metadata.source);
					} else source.innerHTML = `<img src="http://s3.cuetools.net/icons/${metadata.source}.png" height="12" title="${metadata.source}" />`;
					artist.textContent = metadata.source == 'discogs' ? stripDiscogsNameVersion(metadata.artist) : metadata.artist;
					source.style.alignTop = '1pt';
					title.innerHTML = linkHTML(tr.dataset.releaseUrl, metadata.album, metadata.source + '-release');
					if (metadata.source == 'discogs') findDiscogsRelatives('release', metadata.id).then(function(releases) {
						title.style = 'display: inline-flex; flex-flow: row wrap; column-gap: 3pt;';
						const span = Object.assign(document.createElement('span'), { className: 'musicbrainz-relations' });
						span.style = 'display: flex; flex-flow: column wrap; max-height: 1em;';
						span.append.apply(span, releases.map((release, index) => Object.assign(document.createElement('a'), {
							href: [mbOrigin, 'release', release.id].join('/'), target: '_blank',
							style: noLinkDecoration + ' vertical-align: top;',
							innerHTML: '<img src="https://musicbrainz.org/static/images/entity/release.svg" height="6" />',
							title: release.id,
						})));
						title.append(span);
					});
					if (Array.isArray(metadata.release)) fillListRows(release, Array.prototype.concat.apply([ ],
						metadata.release.map(release => iso3166ToFlagCodes([release.country]).map(countryCode =>
							releaseEventMapper(countryCode, release.date, torrent.torrent.remasterYear)))), 3);
					if (Array.isArray(metadata.labelInfo)) fillListRows(editionInfo, metadata.labelInfo.map(labelInfo =>
						editionInfoMapper(stripDiscogsNameVersion(labelInfo.name), labelInfo.catno, recordLabels, catalogueNumbers)));
					if (editionInfo.childElementCount <= 0 && metadata.source == 'musicbrainz')
						mbFindEditionInfoInAnnotation(editionInfo, metadata.id);
					if (metadata.barcode) {
						barcode.textContent = metadata.barcode;
						if (catalogueNumbers.some(catalogueNumber => sameBarcodes(catalogueNumber, metadata.barcode)))
							editionInfoMatchingStyle(barcode);
					}
					if (metadata.relevance >= 0) [relevance.textContent, relevance.title] =
						[metadata.relevance + '%', 'Relevance'];
					const releaseYear = _getReleaseYear(metadata);
					const _editionInfo = labelInfoMapper(metadata);
					if (!_editionInfo.some(labelInfo => labelInfo.catNo) && metadata.barcode)
						_editionInfo.push({ catNo: metadata.barcode });
					if (releaseYear > 0 && _editionInfo.length > 0) {
						tr.dataset.remasterYear = releaseYear;
						setEditionInfo(tr, _editionInfo);
						const editionTitle = buildEditionTitle(metadata);
						if (editionTitle) tr.dataset.remasterTitle = editionTitle;
						(!isCompleteInfo && 'editionGroup' in torrentDetails.dataset && !Boolean(method.fuzzy)
								&& !noEditPerms && (editableHosts.includes(document.domain) || ajaxApiKey)
								&& (!is('unknown') || method.metadata != 'extensive' || !(metadata.relevance < 100)) ?
									confidence : Promise.reject('Not applicable')).then(function(confidence) {
							if (!(releaseYear > 0)) throw 'Unknown or inconsistent release year';
							if (_editionInfo.length <= 0 && torrent.torrent.remasterYear > 0)
								throw 'No additional edition information';
							applyOnClick(tr);
						}).catch(reason => { applyOnCtrlClick(tr) }).then(applyTooltip);
					} else applyTooltip();
					tr.append(source, artist, title, release, editionInfo, barcode, relevance);
					for (let cell of tr.cells) cell.style.backgroundColor = 'inherit';
					['source', 'artist', 'title', 'release-events', 'edition-info', 'barcode', 'relevance'].forEach(function(className, index) {
						tr.cells[index].style.backgroundColor = 'inherit';
						tr.cells[index].className = className;
					});
					tbody.append(tr);
				});
				table.append(tbody);
				addResultsFilter(thead, tbody, 5);
				addLookupResults(torrentId, thead, table);
				// Group set
				if (isCompleteInfo || !('editionGroup' in torrentDetails.dataset) || Boolean(method.fuzzy)
						|| noEditPerms || !editableHosts.includes(document.domain) && !ajaxApiKey
						|| torrent.torrent.remasterYear > 0 && !(results = results.filter(metadata =>
							isNaN(metadata = _getReleaseYear(metadata)) || metadata == torrent.torrent.remasterYear))
								.some(metadata => metadata.labelInfo && metadata.labelInfo.length > 0 || metadata.barcode)
						|| results.length > (is('unknown') ? 1 : 2)
						|| is('unknown') && method.metadata == 'extensive' && results.some(metadata => metadata.relevance < 100))
					return;
				confidence.then(function(confidence) {
					const releaseYear = results.reduce((year, metadata) => isNaN(year) ? NaN :
						(metadata = _getReleaseYear(metadata)) > 0 && (year <= 0 || metadata == year) ? metadata : NaN, -Infinity);
					if (!(releaseYear > 0) || !results.every(m1 => m1.release.every(r1 => results.every(m2 =>
							m2.release.every(r2 => getReleaseYear(r2.date) == getReleaseYear(r1.date))))))
						throw 'No additional edition information';
					const a = document.createElement('a');
					[a.className, a.href, a.textContent] = ['update-edition', '#', '(set)'];
					if (results.length > 1 || results.some(result => result.relevance < 100)
							|| !(confidence.partiallyMatched > 0)) {
						a.style.fontWeight = 300;
						a.dataset.confirm = true;
					} else a.style.fontWeight = 'bold';
					let editionInfo = Array.prototype.concat.apply([ ], results.map(labelInfoMapper));
					const barcodes = results.map(metadata => metadata.barcode).filter(Boolean);
					if (!editionInfo.some(labelInfo => labelInfo.catNo) && barcodes.length > 0)
						Array.prototype.push.apply(editionInfo, barcodes.map(barcode => ({ catNo: barcode })));
					if (editionInfo.length <= 0 && torrent.torrent.remasterYear > 0)
						throw 'No additional edition information';
					a.dataset.remasterYear = releaseYear;
					setEditionInfo(a, editionInfo);
					if (results.length < 2) {
						a.dataset.releaseUrl = {
							musicbrainz: [mbOrigin, 'release', results[0].id],
							discogs: [dcOrigin, 'release', results[0].id],
						}[results[0].source].join('/');
						const editionTitle = buildEditionTitle(results[0]);
						if (editionTitle) tr.dataset.remasterTitle = editionTitle;
					}
					setTooltip(a, 'Update edition info from matched release(s)\n\n' + results.map(metadata =>
						metadata.labelInfo.map(labelInfo => ({ discogs: 'Discogs', musicbrainz: 'MusicBrainz' }[metadata.source]) + ' ' + [
							_getReleaseYear(metadata),
							[stripDiscogsNameVersion(labelInfo.name), labelInfo.catno || metadata.barcode].filter(Boolean).join(' / '),
						].filter(Boolean).join(' – ') + (metadata.relevance >= 0 ? ` (${metadata.relevance}%)` : ''))
							.filter(Boolean).join('\n')).join('\n'));
					a.onclick = updateEdition;
					target.parentNode.append(a);
				});
			}, alert);
		}, function(reason) {
			target.textContent = reason;
			target.style.color = 'red';
			setTooltip(target);
		}).then(() => { target.dataset.haveQuery = true });
	}, 'Lookup edition in CUETools DB (TOCID)');
}

const elem = document.body.querySelector('div#discog_table > div.box.center > a:last-of-type');
if (elem != null) {
	const a = document.createElement('a'), captions = ['Incomplete editions only', 'All editions'];
	[a.textContent, a.href, a.className, a.style.marginLeft] = [captions[0], '#', 'brackets', '2rem'];
	a.onclick = function(evt) {
		if (captions.indexOf(evt.currentTarget.textContent) == 0) {
			for (let strong of document.body.querySelectorAll('div#discog_table > table.torrent_table.grouped > tbody > tr.edition.discog > td.edition_info > strong')) (function(tr, show = true) {
				if (show) (function(tr) {
					show = false;
					while ((tr = tr.nextElementSibling) != null && tr.classList.contains('torrent_row')) {
						const a = tr.querySelector('td > a:last-of-type');
						if (a == null || !/\bFLAC\s*\/\s*Lossless\s*\/\s*Log\s*\(\-?\d+%\)/.test(a.textContent)) continue;
						show = true;
						break;
					}
				})(tr);
				if (show) (function(tr) {
					while (tr != null && !tr.classList.contains('group')) tr = tr.previousElementSibling;
					if (tr != null && (tr = tr.querySelector('div > a.show_torrents_link')) != null
							&& tr.parentNode.classList.contains('show_torrents')) tr.click();
				})(tr); else (function(tr) {
					do tr.hidden = true;
					while ((tr = tr.nextElementSibling) != null && tr.classList.contains('torrent_row'));
				})(tr);
			})(strong.parentNode.parentNode, incompleteEdition.test(strong.lastChild.textContent.trim()));
			for (let tr of document.body.querySelectorAll('div#discog_table > table.torrent_table.grouped > tbody > tr.group.discog')) (function(tr) {
				if (!(function(tr) {
					while ((tr = tr.nextElementSibling) != null && !tr.classList.contains('group'))
						if (tr.classList.contains('edition') && !tr.hidden) return true;
					return false;
				})(tr)) tr.hidden = true;
			})(tr);
		} else for (let tr of document.body.querySelectorAll('div#discog_table > table.torrent_table.grouped > tbody > tr.discog'))
			tr.hidden = false;
		evt.currentTarget.textContent = captions[1 - captions.indexOf(evt.currentTarget.textContent)];
	};
	elem.after(a);
}

}