Greasy Fork

[GMT] Edition lookup by CD TOC

Lookup edition by CD TOC on MusicBrainz, GnuDb and in CUETools DB

目前为 2023-06-05 提交的版本。查看 最新版本

// ==UserScript==
// @name         [GMT] Edition lookup by CD TOC
// @namespace    https://greasyfork.org/users/321857-anakunda
// @version      1.15.15
// @description  Lookup edition by CD TOC on MusicBrainz, GnuDb and in CUETools DB
// @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
// @connect      musicbrainz.org
// @connect      api.discogs.com
// @connect      www.discogs.com
// @connect      db.cuetools.net
// @connect      db.cue.tools
// @connect      gnudb.org
// @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
// ==/UserScript==

{

'use strict';

const requestsCache = new Map, mbRequestsCache = new Map;
let mbLastRequest = null, noEditPerms = document.getElementById('nav_userclass');
noEditPerms = noEditPerms != null && ['User', 'Member', 'Power User'].includes(noEditPerms.textContent.trim());
const [mbOrigin, dcOrigin] = ['https://musicbrainz.org', 'https://www.discogs.com'];

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 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, 100, reqCounter);
		const now = Date.now();
		if (now <= mbLastRequest + 1000) return setTimeout(request, mbLastRequest + 1000 - now, reqCounter);
		mbLastRequest = Infinity;
		globalXHR(url, { responseType: 'json' }).then(function({response}) {
			mbLastRequest = Date.now();
			resolve(response);
		}, function(reason) {
			mbLastRequest = Date.now();
			if (/^HTTP error (?:429|430)\b/.test(reason)) return setTimeout(request, 1000, reqCounter + 1);
			reject(reason);
		});
	})() });
	mbRequestsCache.set(cacheKey, request);
	return request;
}

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 reqHeaders = { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' };
	if (dcAuth) reqHeaders.Authorization = 'Discogs ' + dcAuth;
	let requestsMax = reqHeaders.Authorization ? 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: reqHeaders,
			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;
}

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 ', '(?:EAC|XLD) Auslese-Logdatei vom ',
	'File di log (?:EAC|XLD) per l\'estrazione del ', 'Archivo Log de extracciones desde ',
	'(?:EAC|XLD) extraheringsloggfil från ', '(?:EAC|XLD) uitlezen log bestand van ',
	'(?:EAC|XLD) 抓取日志文件从',
	'Отчёт (?:EAC|XLD) об извлечении, выполненном ', 'Отчет на (?:EAC|XLD) за извличане, извършено на ',
	'Protokol extrakce (?:EAC|XLD) z ', '(?:EAC|XLD) log súbor extrakcie z ',
	'Sprawozdanie ze zgrywania programem (?:EAC|XLD) z ', '(?:EAC|XLD)-ov fajl dnevnika ekstrakcije iz ',
	'Log created by: whipper .+\r?\n+Log creation date: ', 'morituri extraction logfile from ',
].join('|') + ')';
const rxTrackExtractor = /^(?:(?:Track|Трек|Òðåê|音轨|Traccia|Spår|Pista|Трак|Utwór|Stopa)\s+\d+[^\S\r\n]*$(?:\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(''),
	];
	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)\\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 ',
	].join('|') + ')\\d+)';
	if (!detectVolumes) {
		const rxStackedLog = new RegExp('^[\\S\\s]*(?:\\r?\\n)+(?=' + rxRipperSignatures + ')');
		return (logFiles = logFiles.map(logFile => rxStackedLog.test(logFile) ? logFile.replace(rxStackedLog, '') : logFile)
				.filter(RegExp.prototype.test.bind(new RegExp('^(?:' + rxRipperSignatures + '|' + sessionHeader + ')')))).length > 0 ?
			logFiles : null;
	}
	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, rxTitleExtractor = new RegExp('^' + sessionHeader + '(.+)$(?:\\r?\\n)+^(.+\\/.+)$', 'm');
	for (const logFile of logFiles) for (const session of logFile) {
		let [uniqueKey, title] = [getTocEntries(session), rxTitleExtractor.exec(session)];
		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 != null) title = title[2];
		else if ((title = /^ +Release: *$\r?\n^ +Artist: *(.+)$\r?\n^ +Title: *(.+)$/m.exec(session)) != null)
			title = title[1] + '/' + title[2];
		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 ? Array.from(sessions.values()) : null;
}

function getSessions(torrentId) {
	if (!(torrentId > 0)) throw 'Invalid argument';
	if (requestsCache.has(torrentId)) return requestsCache.get(torrentId);
	// let request = queryAjaxAPICached('torrent', { id: torrentId }).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 }))
		.then(document => Array.from(document.body.querySelectorAll(':scope > blockquote > pre:first-child'),
			pre => pre.textContent));
	requestsCache.set(torrentId, request = request.then(getUniqueSessions).then(sessions =>
		sessions || Promise.reject('No valid logfiles attached')));
	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 [rxNoLabel, rxBareLabel, rxNoCatno] = [
	/^(?:Not On Label|No label|\[no label\]|None|\[none\]|iMD|Independ[ae]nt|Self[- ]?Released)\b/i,
	/(?:\s+\b(?:Record(?:ing)?s|Productions?|Int(?:ernationa|\')l)\b|,?\s+(?:Ltd|Inc|Co|LLC|Intl)\.?)+$/ig,
	/^(?:None|\[none\])$/i,
];
const bareId = str => str ? str.trim().toLowerCase().replace(rxBareLabel, '').replace(rxNoLabel, '').replace(/\W/g, '') : '';
const uniqueValues = ((el1, ndx, arr) => el1 && arr.findIndex(el2 => bareId(el2) == bareId(el1)) == ndx);

function openTabHandler(evt) {
	if (!evt.ctrlKey) return true;
	if (evt.shiftKey && evt.currentTarget.dataset.groupUrl)
		return (GM_openInTab(evt.currentTarget.dataset.groupUrl, false), false);
	if (evt.currentTarget.dataset.url)
		return (GM_openInTab(evt.currentTarget.dataset.url, false), false);
	return true;
}

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 = { };
	if (target.dataset.releaseYear) payload.remaster_year = target.dataset.releaseYear; else return false;
	if (target.dataset.editionInfo) try {
		const editionInfo = JSON.parse(target.dataset.editionInfo);
		payload.remaster_record_label = editionInfo.map(label => label.label).filter(uniqueValues)
			.map(label => rxNoLabel.test(label) ? 'self-released' : label).filter(Boolean).join(' / ');
		payload.remaster_catalogue_number = editionInfo.map(label => label.catNo).filter(uniqueValues)
			.map(catNo => !rxNoCatno.test(catNo) && catNo).filter(Boolean).join(' / ');
	} catch (e) { console.warn(e) }
	if (!payload.remaster_catalogue_number && target.dataset.barcodes) try {
		payload.remaster_catalogue_number = JSON.parse(target.dataset.barcodes)
			.filter((barcode, ndx, arr) => barcode && arr.indexOf(barcode) == ndx).join(' / ');
	} catch (e) { console.warn(e) }
	if (target.dataset.editionTitle) payload.remaster_title = target.dataset.editionTitle;
	const entries = [ ];
	if ('remaster_year' in payload) entries.push('Edition year: ' + payload.remaster_year);
	if ('remaster_title' in payload) entries.push('Edition title: ' + payload.remaster_title);
	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 (entries.length <= 0 || Boolean(target.dataset.confirm) && !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 = true;
	target.style.color = 'orange';
	let selector = target.parentNode.dataset.edition;
	if (!selector) return (alert('Assertion failed: edition group not found'), false);
	selector = 'table#torrent_details > tbody > tr.torrent_row.edition_' + selector;
	Promise.all(Array.from(document.body.querySelectorAll(selector), function(tr) {
		const torrentId = getTorrentId(tr);
		if (!(torrentId > 0)) return null;
		const postData = new URLSearchParams(payload);
		if (parseInt(target.parentNode.dataset.torrentId) == torrentId && 'description' in target.dataset
				&& target.dataset.url) postData.set('release_desc', (target.dataset.description + '\n\n').trimLeft() +
			'[url]' + target.dataset.url + '[/url]');
		return queryAjaxAPI('torrentedit', { id: torrentId }, postData);
		return `torrentId: ${torrentId}, postData: ${postData.toString()}`;
	})).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 = 'pointer';
	tr.dataset.confirm = true;
	tr.onclick = updateEdition;
	let tooltip = 'Apply edition info from this release\n(Ctrl + click opens release page';
	if (tr.dataset.groupUrl) tooltip += ' / Ctrl + Shift + click opens release group page';
	setTooltip(tr, (tooltip += ')'));
	tr.onmouseenter = tr.onmouseleave = evt =>
		{ evt.currentTarget.style.color = evt.type == 'mouseenter' ? 'orange' : null };
}

function openOnClick(tr) {
	tr.onclick = openTabHandler;
	const updateCursor = evt => { tr.style.cursor = evt.ctrlKey ? 'pointer' : 'auto' };
	tr.onmouseenter = function(evt) {
		updateCursor(evt);
		document.addEventListener('keyup', updateCursor);
		document.addEventListener('keydown', updateCursor);
	};
	tr.onmouseleave = function(evt) {
		document.removeEventListener('keyup', updateCursor);
		document.removeEventListener('keydown', updateCursor);
	};
	let tooltip = 'Ctrl + click opens release page';
	if (tr.dataset.groupUrl) tooltip += '\nCtrl + Shift + click opens release group page';
	setTooltip(tr, tooltip);
}

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

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, span.dataset.torrentId] = ['brackets', torrentId];
		span.style = 'display: inline-flex; flex-flow: row; align-items: baseline; column-gap: 5px; justify-content: space-around; color: initial;';
		if (edition != null) span.dataset.edition = edition;
		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 addClickableIcon(html, clickHandler, dropHandler, className, style, tooltip, tooltipster = false) {
		if (!html || typeof clickHandler != 'function') throw 'Invalid argument';
		const span = document.createElement('span');
		span.innerHTML = html;
		if (className) span.className = className;
		span.style = 'cursor: pointer; transition: transform 100ms;' + (style ? ' ' + style : '');
		span.onclick = clickHandler;
		if (typeof dropHandler == 'function') {
			span.ondragover = evt => Boolean(evt.currentTarget.disabled) || !evt.dataTransfer.types.includes('text/plain');
			span.ondrop = function(evt) {
				evt.currentTarget.style.transform = null;
				if (evt.currentTarget.disabled || !evt.dataTransfer || !(evt.dataTransfer.items.length > 0)) return true;
				dropHandler(evt);
				return false;
			}
			span.ondragenter = function(evt) {
				if (evt.currentTarget.disabled) return true;
				for (let tgt = evt.relatedTarget; tgt != null; tgt = tgt.parentNode)
					if (tgt == evt.currentTarget) return false;
				evt.currentTarget.style.transform = 'scale(3)';
				return false;
			};
			span[`ondrag${'ondragexit' in span ? 'exit' : 'leave'}`] = function(evt) {
				if (evt.currentTarget.disabled) return true;
				for (let tgt = evt.relatedTarget; tgt != null; tgt = tgt.parentNode)
					if (tgt == evt.currentTarget) return false;
				evt.currentTarget.style.transform = null;
				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, '_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.textDecoration = 'underline yellowgreen dotted 2pt';
		//elem.style.textShadow = '0 0 2pt yellowgreen';
	}
	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 editionInfoMapper(labelName, catNo, recordLabels, catalogueNumbers) {
		if (!labelName && !catNo) return null;
		const components = [ ];
		if (labelName) {
			const span = document.createElement('span');
			[span.className, span.textContent] = ['label', labelName];
			if (Array.isArray(recordLabels) && recordLabels.some(recordLabel =>
					sameLabels(recordLabel, labelName))) editionInfoMatchingStyle(span);
			components.push(span);
		}
		if (catNo) {
			const span = document.createElement('span');
			[span.className, span.textContent, span.style] = ['catno', catNo, 'white-space: nowrap;'];
			if (Array.isArray(catalogueNumbers) && catalogueNumbers.some(catalogueNumber =>
					sameStringValues(catalogueNumber, catNo))) editionInfoMatchingStyle(span);
			components.push(span);
		}
		return components;
	}
	function fillListRows(container, listElements, maxRowsToShow) {
		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 divs = createElements('div', 'div');
		[divs[0].className, divs[0].style] = ['show-all', '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 || !(expr = expr.trim()) || !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;
	}

	const torrentId = getTorrentId(tr);
	if (!(torrentId > 0)) continue; // assertion failed
	let edition = /\b(?:edition_(\d+))\b/.exec(tr.className);
	if (edition != null) edition = parseInt(edition[1]);
	const editionRow = (function(tr) {
		while (tr != null) { if (tr.classList.contains('edition')) return tr; tr = tr.previousElementSibling }
		return null;
	})(tr);
	let editionInfo = editionRow && editionRow.querySelector('td.edition_info > strong');
	editionInfo = editionInfo != null ? editionInfo.lastChild.textContent.trim() : '';
	if (incompleteEdition.test(editionInfo)) editionRow.cells[0].style.backgroundColor = '#f001';
	if ((tr = tr.nextElementSibling) == null || !tr.classList.contains('torrentdetails')) continue;
	const linkBox = tr.querySelector('div.linkbox');
	if (linkBox == null) continue;
	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 stripNameSuffix = name => name && name.replace(/\s+\(\d+\)$/, '');
	const noLinkDecoration = 'background: none !important; padding: 0 !important;';
	const linkHTML = (url, caption, cls) => `<a href="${url}" target="_blank" class="${cls || ''}" style="${noLinkDecoration}">${caption}</a>`;
	const svgBulletHTML = color => `<svg style="margin-right: 2pt;" viewBox="0 0 10 10" height="0.8em"><circle fill="${color || ''}" cx="5" cy="5" r="5"></circle></svg>`;
	const createElements = (...tagNames) => tagNames.map(Document.prototype.createElement.bind(document));
	const editionInfoSplitter = infoStr =>
		infoStr ? (infoStr = decodeHTML(infoStr)).split(/[\/\|\•]+/).concat(infoStr.split(/\s+[\/\|\•]+\s+/))
			.map(expr => expr.trim()).filter((s, n, a) => s && a.indexOf(s) == n) : [ ];
	const cmpNorm = expr => expr && expr.replace(/[\s\x00-\x2F\x3A-\x40\x5B-\x5E\x60\x7B-\x7F]+/g, '').toLowerCase();
	const sameLabels = (...labels) => labels.length > 0 && labels.every((label, ndx, arr) =>
		cmpNorm(label.replace(rxBareLabel, '')) == cmpNorm(arr[0].replace(rxBareLabel, ''))
		|| label.toLowerCase().startsWith(arr[0].toLowerCase()) && /^\W/.test(label.slice(arr[0].length))
		|| arr[0].toLowerCase().startsWith(label.toLowerCase()) && /^\W/.test(arr[0].slice(label.length)));
	const sameStringValues = (...strVals) => strVals.length > 0
		&& strVals.every((catno, ndx, arr) => cmpNorm(catno) == cmpNorm(arr[0]));
	const sameBarcodes = (...barcodes) => barcodes.length > 0
		&& barcodes.every((barcode, ndx, arr) => parseInt(cmpNorm(barcode)) == parseInt(cmpNorm(arr[0])));
	const findMusicBrainzRelations = discogsId => (discogsId = parseInt(discogsId)) > 0 ? mbApiRequest('url', {
		query: `url_descendent:*discogs.com/release/${discogsId}`,
		limit: 100,
	}).then(results => results.count > 0 && (results = results.urls.filter(url =>
		discogsIdExtractor(url.resource, 'release') == discogsId)).length > 0 ?
			Promise.all(results.map(url => mbApiRequest('url/' + url.id, { inc: 'release-rels' })
				.then(url => url.relations.filter(relation => relation['target-type'] == 'release'))))
			.then(relations => (relations = Array.prototype.concat.apply([ ], relations)).length > 0 ?
					relations : Promise.reject('No relations to this ID'))
			: Promise.reject('No relations to this ID')) : Promise.reject('Invalid argument');

	addLookup(svgCaption('mb_text', 'filter: saturate(30%) brightness(130%);', 'MusicBrainz'), function(evt) {
		const target = evt.currentTarget;
		console.assert(target instanceof HTMLElement);
		const torrentId = parseInt(target.parentNode.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 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: 'artist-credits labels release-groups url-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 frequencyAnalysis(literals, string) {
				if (!literals || typeof literals != 'object') throw 'Invalid argument';
				if (typeof string == 'string') for (let index = 0; index.length < 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 mbIdExtractor(expr, entity) {
				if (!expr || !(expr = expr.trim()) || !entity) return null;
				let mbId = rxMBID.exec(expr);
				if (mbId != null) return mbId[1]; else 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] : null;
			}
			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) => getTrackDetail(key, value =>
						value.toString(16).toUpperCase().padStart(width, '0'), 8);
					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) {
				if (!formData || typeof formData != 'object') throw 'Invalid argument';
				formData.set('name', torrent.group.name);
				if (torrent.torrent.remasterTitle) formData.set('comment', 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'); break;
					}
				}
				if (torrent.group.releaseType == 7)
					formData.set('artist_credit.names.0.mbid', '89ad4ac3-39f7-470e-963a-56509c546377');
				else if (torrent.group.musicInfo) {
					let artistIndex = -1;
					for (let role of ['dj', 'artists']) if (artistIndex < 0) torrent.group.musicInfo[role].forEach(function(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 ? ', ' : ' & ');
					});
				}
				formData.set('status', torrent.group.releaseType == 14 ? 'bootleg' : 'official');
				if (torrent.torrent.remasterYear) formData.set('events.0.date.year', torrent.torrent.remasterYear);
				if (torrent.torrent.remasterRecordLabel) if (rxNoLabel.test(torrent.torrent.remasterRecordLabel))
					formData.set('labels.0.mbid', '157afde4-4bf5-4039-8ad2-5a15acc85176');
				else formData.set('labels.0.name', decodeHTML(torrent.torrent.remasterRecordLabel));
				if (torrent.torrent.remasterCatalogueNumber) {
					formData.set('labels.0.catalog_number', rxNoCatno.test(torrent.torrent.remasterCatalogueNumber) ?
						'[none]' : decodeHTML(torrent.torrent.remasterCatalogueNumber));
					let barcode = torrent.torrent.remasterCatalogueNumber.split(' / ').map(catNo => catNo.replace(/\W+/g, ''));
					if (barcode = barcode.find(RegExp.prototype.test.bind(/^\d{9,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) {
					sessions.forEach(function(session, discIndex) {
						switch (getLayoutType(getTocEntries(session))) {
							case 0: break;
							case 1: formData.set(`mediums.${discIndex}.format`, 'Enhanced CD'); break;
							case 2: formData.set(`mediums.${discIndex}.format`, 'Copy Control CD'); break;
							default: console.warn(`Disc ${discIndex + 1} unknown TOC type`);
						}
					});
					if (GM_getValue('mb_seed_with_fingerprints', false) && Array.isArray(sessions) && sessions.length > 0)
						editNote += '\n\nMedia fingerprint' + (sessions.length > 1 ? 's' : '') + ' :\n' +
							sessions.map(getMediaFingerprint).join('\n') + '\n';
					formData.set('edit_note', editNote);
					return formData;
				});
			}
			function seedFromDiscogs(formData, discogsId, cdLengths, idsLookupLimit = GM_getValue('mbid_search_size', 30)) {
				if (!formData || typeof formData != 'object' || !discogsId) throw 'Invalid argument';
				if (discogsId < 0) [idsLookupLimit, discogsId] = [0, -discogsId];
				return discogsId > 0 ? dcApiRequest('releases/' + discogsId).then(function(release) {
					function seedArtists(root, prefix) {
						if (root && Array.isArray(root)) root.forEach(function(artist, index, arr) {
							const creditPrefix = `${prefix || ''}artist_credit.names.${index}`;
							const name = stripNameSuffix(artist.name);
							formData.set(`${creditPrefix}.artist.name`, name);
							if (artist.anv) formData.set(`${creditPrefix}.name`, artist.anv);
								else formData.delete(`${creditPrefix}.name`);
							if (index < arr.length - 1) formData.set(`${creditPrefix}.join_phrase`, fmtJoinPhrase(artist.join));
							else formData.delete(`${creditPrefix}.join_phrase`);
							if (!(artist.id in lookupIndexes.artist))
								lookupIndexes.artist[artist.id] = { name: artist.name, searchName: name, prefixes: [creditPrefix] };
							else lookupIndexes.artist[artist.id].prefixes.push(creditPrefix);
						});
					}
					function addUrlRef(url, linkType) {
						formData.set(`urls.${++urlRelIndex}.url`, url);
						if (linkType != undefined) formData.set(`urls.${urlRelIndex}.link_type`, linkType);
					}
					function mbLookupById(entity, param, mbid) {
						if (!entity || !param || !mbid) throw 'Invalid argument';
						[entity, param] = [entity.toLowerCase(), param.toLowerCase()];
						const loadPage = (offset = 0) => mbApiRequest(entity, { [param]: mbid, offset: offset, limit: 5000 }).then(function(response) {
							let results = response[entity + 's'];
							if (Array.isArray(results)) offset = response[entity + '-offset'] + results.length; else return [ ];
							results = results.filter(result => !result.video);
							return offset < response[entity + '-count'] ? loadPage(offset)
								.then(Array.prototype.concat.bind(results)) : results;
						});
						return loadPage();
					}
					function sameTitles(...titles) {
						if (titles.length <= 0) return false;
						const titleNorm = title => title && cmpNorm([
							/\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()));
						return titles.every(title => title && titleNorm(title) == titleNorm(titles[0]));
					}
					function notifyFoundMBID(html, color = 'orange', length = 6) {
						if (!html) return;
						let div = document.body.querySelector('div.mbid-lookup-notify'), animation;
						if (div == null) {
							div = document.createElement('div');
							div.className = 'mbid-lookup-notify';
							div.style = `
position: fixed; margin: 0 auto; padding: 5pt; bottom: 0; left: 0; right: 0; text-align: center;
font: normal 9pt "Noto Sans", sans-serif; color: white; background-color: #000b; box-shadow: 0 0 7pt 2pt #000b;
cursor: default; z-index: 999; 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 = 'MBID for ' + 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 findDiscogsToMbBinding(entity, discogsId) {
						function saveToCache(mbid) {
							if (!['artist', 'label', 'release'].includes(entity = entity.toLowerCase())) return;
							if (!(entity in bindingsCache)) bindingsCache[entity] = { };
							bindingsCache[entity][discogsId] = mbid;
							GM_setValue('discogs_to_mb_bindings', bindingsCache);
						}

						if (!bindingsCache) if (bindingsCache = GM_getValue('discogs_to_mb_bindings'))
							console.info('Discogs to MB bindings cache loaded:',
								Object.keys(bindingsCache.artist || { }).length, 'artists,',
								Object.keys(bindingsCache.label || { }).length, 'labels');
						else bindingsCache = {
							artist: { 194: '89ad4ac3-39f7-470e-963a-56509c546377' }, // VA
							label: {
								750: '49b58bdb-3d74-40c6-956a-4c4b46115c9c', // Virgin
								1003: '29d7c88f-5200-4418-a683-5c94ea032e38', // BMG
								1866: '011d1192-6f65-45bd-85c4-0400dd45693e', // Columbia
								26126: 'c029628b-6633-439e-bcee-ed02e8a338f7', // EMI
								108701: '7c439400-a83c-48bc-9042-2041711c9599', // Virgin JP
							},
						};
						if (discogsId in bindingsCache[entity]) {
							console.log('Entity binding for', entity, discogsId, 'got from cache');
							notifyFoundMBID(`${entity} <b>${lookupIndexes[entity][discogsId].name}</b> got from cache`, 'sandybrown');
							return Promise.resolve(bindingsCache[entity][discogsId]);
						}
						const dcCounterparts = (dcReleases, mbReleases) => [mbReleases, dcReleases].every(Array.isArray) ?
							mbReleases.filter(mbRelease => mbRelease && mbRelease.date && mbRelease.title
								&& dcReleases.some(dcRelease => dcRelease && dcRelease.year == getReleaseYear(mbRelease.date)
									&& sameTitles(dcRelease.title, mbRelease.title))).length : 0;
						const getDiscogsArtistReleases = (page = 1) =>
							dcApiRequest(`${entity}s/${discogsId}/releases`, { page: page, per_page: 500 })
								.then(response => response.pagination.page < response.pagination.pages ?
									getDiscogsArtistReleases(response.pagination.page + 1)
										.then(releases => response.releases.concat(releases)) : response.releases);
						const findByCommonReleases = mbids => Array.isArray(mbids) ? Promise.all([
							getDiscogsArtistReleases(),
							Promise.all(mbids.map(mbid => Promise.all(({ artist: ['artist', 'track_artist'] }[entity] || [entity]).map(param =>
								mbLookupById('release', param, mbid).catch(reason => null))).then(results =>
									Array.prototype.concat.apply([ ], results.filter(Boolean)).filter((rls1, ndx, arr) =>
										arr.findIndex(rls2 => rls2.id == rls1.id) == ndx)))),
						]).then(function([dcReleases, mbReleases]) {
							const matchScores = mbReleases.map(releases => dcCounterparts(dcReleases, releases));
							const hiScore = Math.max(...matchScores);
							if (!(hiScore > 0)) return Promise.reject('Not found by common releases');
							const hiIndex = matchScores.indexOf(hiScore);
							const dataSize = Math.min(dcReleases.length, mbReleases[hiIndex].length);
							if (hiScore * 50 < dataSize) return Promise.reject('Found by common releases with too low match rate');
							console.log('Entity binding found by having %d common releases:\n%s\n%s',
								hiScore, [dcOrigin, entity, discogsId].join('/') + '#' + entity,
								[mbOrigin, entity, mbids[hiIndex], 'releases'].join('/'));
							if (matchScores.filter(score => score > 0).length > 1) {
								console.log('Matches by more entities:', matchScores.map((score, index) =>
									score > 0 && [mbOrigin, entity, mbids[index], 'releases'].join('/') + ' (' + score + ')').filter(Boolean));
								if (matchScores.reduce((sum, score) => sum + score, 0) >= hiScore * 2) return Promise.reject('Ambiguity');
							}
							notifyFoundMBID(`${entity} <b>${lookupIndexes[entity][discogsId].name}</b> found by having <b>${hiScore}</b> common release${hiScore != 1 ? 's' : ''} out of ${dataSize}`, 'gold');
							saveToCache(mbids[hiIndex]);
							return mbids[hiIndex];
						}) : Promise.reject('Invalid argument');
						const findByUrlRel = urlTerm => mbApiRequest('url', { query: 'url_descendent:' + urlTerm, limit: 100 })
							.then(results => results.count > 0 && (results = results.urls.filter(url =>
								discogsIdExtractor(url.resource, entity) == parseInt(discogsId))).length > 0 ?
									Promise.all(results.map(url => mbApiRequest('url/' + url.id, { inc: entity + '-rels' })
										.then(url => url.relations.filter(relation => relation['target-type'] == entity))))
									: Promise.reject('Not found by URL')).then(relations => Array.prototype.concat.apply([ ], relations)).then(function(relations) {
							console.assert(relations.length == 1, 'Ambiguous %s linkage for Discogs id',
								entity, discogsId, relations);
							if (relations.length > 1) return findByCommonReleases(relations.map(relation => relation[entity].id));
							notifyFoundMBID(`${entity} <b>${lookupIndexes[entity][discogsId].name}</b> found by having Discogs relative set`, 'salmon');
							saveToCache(relations[0][entity].id)
							return relations[0][entity].id;
						});
						return findByUrlRel(`*discogs.com/${entity}/${discogsId}`).catch(reason => reason != 'Ambiguity' ? mbApiRequest(entity, {
							query: '"' + (lookupIndexes[entity][discogsId].searchName || lookupIndexes[entity][discogsId].name) + '"',
							limit: idsLookupLimit,
						}).then(results => findByCommonReleases(results[entity + 's'].map(result => result.id))) : Promise.reject(reason));
					}
					function parseTracks(trackParser, collapseSubtracks = false) {
						if (!(trackParser instanceof RegExp)) throw 'Invalid argument';
						const media = [ ];
						let lastMediumId, heading;
						(function addTracks(root, titles) {
							function addTrack(track) {
								const parsedTrack = trackParser.exec(track.position.trim());
								let [mediumFormat, mediumId, trackPosition] = parsedTrack != null ?
									parsedTrack.slice(1) : [undefined, undefined, track.position.trim()];
								if (!mediumFormat && /^[A-Z]\d*$/.test(trackPosition)) mediumFormat = 'LP';
								if (!titles && (mediumId = (mediumFormat || '') + (mediumId || '')) !== lastMediumId) {
									for (let subst of [[/^(?:B(?:R?D|R))$/, 'Blu-ray'], [/^(?:LP)$/, 'Vinyl']])
										if (subst[0].test(mediumFormat)) mediumFormat = subst[1];
									media.push({ format: mediumFormat || defaultFormat, name: undefined, tracks: [ ] });
									lastMediumId = mediumId;
								}
								let name = track.title;
								if (track.type_ == 'index' && 'sub_tracks' in track && track.sub_tracks.length > 0)
									name += ` (${track.sub_tracks.map(track => track.title).join(' / ')})`;
								media[media.length - 1].tracks.push({
									number: trackPosition, heading: heading, titles: titles, name: name,
									length: track.duration, artists: track.artists, extraartists: track.extraartists,
								});
							}

							if (Array.isArray(root)) for (let track of root) switch (track.type_) {
								case 'track': addTrack(track); break;
								case 'heading': heading = track.title != '-' && track.title || undefined; break;
								case 'index':
									if (collapseSubtracks) addTrack(track);
									else addTracks(track.sub_tracks, (titles || [ ]).concat(track.title));
									break;
							}
						})(release.tracklist);
						return media;
					}

					const literals = { }, lookupIndexes = { artist: { }, label: { } };
					formData.set('name', release.title);
					frequencyAnalysis(literals, release.title);
					let released, media, bindingsCache;
					if ((released = /^\d{4}$/.exec(release.released)) != null) released = parseInt(released[0]);
					else if (isNaN(released = new Date(release.released))) released = release.year;
					discogsCountryToIso3166Mapper(release.country).forEach(function(countryCode, countryIndex) {
						if (countryCode) formData.set(`events.${countryIndex}.country`, countryCode);
						if (released instanceof Date && !isNaN(released)) {
							formData.set(`events.${countryIndex}.date.year`, released.getUTCFullYear());
							formData.set(`events.${countryIndex}.date.month`, released.getUTCMonth() + 1);
							formData.set(`events.${countryIndex}.date.day`, released.getUTCDate());
						} else if (released > 0) formData.set(`events.${countryIndex}.date.year`, released);
					});
					let defaultFormat = 'CD', descriptors = new Set;
					if ('formats' in release) {
						for (let format of release.formats) {
							if (format.text) descriptors.add(format.text);
							if (Array.isArray(format.descriptions)) for (let description of format.descriptions)
								descriptors.add(description);
						}
						if (!release.formats.some(format => format.name == 'CD')
								&& release.formats.some(format => format.name == 'CDr')) defaultFormat = 'CD-R';
						if (descriptors.has('HDCD')) defaultFormat = 'HDCD';
						if (descriptors.has('CD+G')) defaultFormat = 'CD+G';
						if (descriptors.has('SHM-CD')) defaultFormat = 'SHM-CD';
						if (descriptors.has('Enhanced')) defaultFormat = 'Enhanced CD';
					}
					if (release.labels) release.labels.forEach(function(label, index) {
						if (label.name) {
							const prefix = 'labels.' + index, name = stripNameSuffix(label.name);
							if (rxNoLabel.test(name)) formData.set(prefix + '.mbid', '157afde4-4bf5-4039-8ad2-5a15acc85176');
							else {
								formData.set(prefix + '.name', name);
								if (label.id in lookupIndexes.label) lookupIndexes.label[label.id].prefixes.push(prefix);
								else lookupIndexes.label[label.id] = {
									name: label.name,
									searchName: stripNameSuffix(label.name).replace(rxBareLabel, ''),
									prefixes: [prefix],
								};
							}
						}
						if (label.catno) formData.set(`labels.${index}.catalog_number`,
							rxNoCatno.test(label.catno) ? '[none]' : label.catno);
					});
					if (release.identifiers) (barcode =>
						{ if (barcode) formData.set('barcode', barcode.value.replace(/\D+/g, '')) })
							(release.identifiers.find(identifier => identifier.type == 'Barcode'));
					seedArtists(release.artists); //seedArtists(release.extraartists);
					if (!Array.isArray(cdLengths) || cdLengths.length <= 0) cdLengths = false;
					const rxParsingMethods = [
						/^()?()?(\S+)$/,
						/^([A-Z]{2,})(?:[\-\ ](\d+))?[\ \-\.]?\b(\S+)$/i,
						/^([A-Z]{2,})?(\d+)?[\ \-\.]?\b(\S+)$/i,
					];
					const layoutMatch = media => Array.isArray(cdLengths) && cdLengths.length > 0 ?
						(media = media.filter(isCD)).length == cdLengths.length
							&& media.every((medium, mediumIndex) => medium.tracks.length == cdLengths[mediumIndex]) : undefined;
					if (rxParsingMethods.some(rx => layoutMatch(media = parseTracks(rx, true))) || !cdLengths
							|| rxParsingMethods.some(rx => layoutMatch(media = parseTracks(rx, false)))
							|| cdLengths.length > 1 && media.filter(isCD).length < 2 && rxParsingMethods.reverse().some(function(rx) {
						if (Array.isArray(cdLengths) && cdLengths.length > 0) for (let collapseSubtracks of [true, false]) {
							let rearrangedMedia = parseTracks(rx, collapseSubtracks);
							const cdMedia = rearrangedMedia.filter(isCD);
							if (cdMedia.length <= 0) continue;
							const cdTracks = Array.prototype.concat.apply([ ], cdMedia.map(medium => medium.tracks));
							if (cdTracks.length > 0 && cdTracks.length == cdLengths.reduce((sum, totalTracks) => sum + totalTracks, 0)
									&& layoutMatch(rearrangedMedia = 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(rearrangedMedia.filter(medium => !isCD(medium))))) media = rearrangedMedia;
							if (media == rearrangedMedia) return true;
						}
						return false;
					}) || confirm('Could not find appropriatte tracks mapping to media (' +
							media.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) {
							formData.set(`mediums.${mediumIndex}.format`, medium.format);
							if (medium.name) formData.set(`mediums.${mediumIndex}.name`, medium.name);
							if (medium.tracks) medium.tracks.forEach(function(track, trackIndex) {
								if (track.number) formData.set(`mediums.${mediumIndex}.track.${trackIndex}.number`, track.number);
								if (track.name) {
									const prefix = str => str ? str + ': ' : '';
									const fullTitle = prefix(track.heading) + prefix((track.titles || [ ]).join(' / ')) + track.name;
									formData.set(`mediums.${mediumIndex}.track.${trackIndex}.name`, fullTitle);
									frequencyAnalysis(literals, fullTitle);
								}
								if (track.length) formData.set(`mediums.${mediumIndex}.track.${trackIndex}.length`, track.length);
								if (track.artists) seedArtists(track.artists, `mediums.${mediumIndex}.track.${trackIndex}.`);
								//if (track.extraartists) seedArtists(track.extraartists, `mediums.${mediumIndex}.track.${trackIndex}.`);
							});
						});
					}
					const charCodes = Object.keys(literals).map(key => parseInt(key));
					if (charCodes.every(charCode => charCode < 0x100)) formData.set('script', 'Latn');
					const packagings = {
						'book': 'Book', 'box': 'Box', 'cardboard': 'Cardboard/Paper Sleeve',
						'card sleeve': 'Cardboard/Paper Sleeve', 'cardboard sleeve': 'Cardboard/Paper Sleeve',
						'paper sleeve': 'Cardboard/Paper Sleeve', 'cassette': 'Cassette Case', 'cassette case': 'Cassette Case',
						'clamshell': 'Clamshell Case', 'clamshell case': 'Clamshell Case', 'digibook': 'Digibook',
						'digisleeve': 'Digipak', 'digipak': 'Digipak', 'digipack': 'Digipak',
						'discbox slider': 'Discbox Slider', 'fatbox': 'Fatbox',
						'gatefold': 'Gatefold Cover', 'gatefold cover': 'Gatefold Cover', 'jewel': 'Jewel case',
						'jewel case': 'Jewel case', 'keep': 'Keep Case', 'keep case': 'Keep Case', 'longbox': 'Longbox',
						'metal tin': 'Metal Tin', 'plastic sleeve': 'Plastic sleeve', 'slidepack': 'Slidepack',
						'slim jewel': 'Slim Jewel Case', 'slim jewel case': 'Slim Jewel Case', 'snap': 'Snap Case',
						'snap case': 'Snap Case', 'snappack': 'SnapPack', 'super jewel': 'Super Jewel Box',
						'super jewel box': 'Super Jewel Box',
					};
					const setPackaging = (...packagings) =>
						{ packagings.forEach(packaging => { formData.set('packaging', packaging) }) };
					if (/\b(?:jewel)\b/i.test(release.notes)) setPackaging('Jewel case');
					if (/\b(?:slim\s+jewel)\b/i.test(release.notes)) setPackaging('Slim Jewel Case');
					if (/\b(?:(?:card(?:board)?|paper)\s?sleeve\b)/i.test(release.notes)) setPackaging('Cardboard/Paper Sleeve');
					if (/\b(?:plastic\s?sleeve\b)/i.test(release.notes)) setPackaging('Plastic sleeve');
					if (/\b(?:digisleeve)\b/i.test(release.notes)) setPackaging('Digipak');
					if (/\b(?:digipac?k)\b/i.test(release.notes)) setPackaging('Digipak');
					if (/\b(?:gatefold)\b/i.test(release.notes)) setPackaging('Gatefold Cover');
					setPackaging(...Array.from(descriptors, d => packagings[d.toLowerCase()]).filter(Boolean).reverse());
					if (descriptors.has('Promo') && formData.get('status') != 'bootleg') formData.set('status', 'promotion');
					if ((descriptors = dcFmtFilters.reduce((arr, filter) => arr.filter(filter), Array.from(descriptors))
							.filter(desc => !(desc.toLowerCase() in packagings)
								&& !['promo', 'enhanced'].includes(desc.toLowerCase()))).length > 0)
						formData.set('comment', descriptors.map(descriptor => // disambiguation
							descriptor.replace(/\b(\w+[a-z])\b/g, (...m) => m[1].toLowerCase())).join(', '));
					const annotation = [
						release.notes && release.notes.trim(),
						release.identifiers && release.identifiers
							.filter(identifier => !['Barcode', 'ASIN'].includes(identifier.type))
							.map(identifier => identifier.type + ': ' + identifier.value).join('\n'),
					].filter(Boolean);
					if (annotation.length > 0) formData.set('annotation', annotation.join('\n\n'));
					let urlRelIndex = -1;
					addUrlRef(dcOrigin + '/release/' + release.id, 76);
					if (release.identifiers) for (let identifier of release.identifiers) switch (identifier.type) {
						case 'ASIN': addUrlRef('https://www.amazon.com/dp/' + identifier.value, 77); break;
					}
					formData.set('edit_note', ((formData.get('edit_note') || '') +
						`\nSeeded from Discogs release id ${release.id}`).trimLeft());
					return idsLookupLimit > 0 ? Promise.all(Object.keys(lookupIndexes).map(entity =>
							Promise.all(Object.keys(lookupIndexes[entity]).map(discogsId =>
								findDiscogsToMbBinding(entity, discogsId).catch(reason => null))))).then(function(lookupResults) {
						function findMBID(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;
						}
						function findExistingRecordings() {
							if (!formData.has('release_group')) return getSessions(torrentId).catch(reason => null).then(sessions =>
									Promise.all(media.map((medium, mediumIndex) => Array.isArray(medium.tracks) ?
										Promise.all(medium.tracks.map(function(track, trackIndex) {
								function getArtistCredits(artists) {
									if (Array.isArray(artists)) for (let artist of artists) {
										const mbid = findMBID('artist', artist.id);
										if (mbid) arids.push(mbid); else artistnames.push(artist.anv || stripNameSuffix(artist.name));
									}
								}
								function getLengthFromTOC() {
									if (!sessions || !isCD(medium)) return;
									const tocEntries = getTocEntries(sessions[mediumIndex]);
									if (tocEntries[trackIndex]) return (tocEntries[trackIndex].endSector + 1 -
										tocEntries[trackIndex].startSector) * 1000 / 75;
								}

								if (!track.name) return;
								const arids = [ ], artistnames = [ ];
								getArtistCredits(track.artists);
								if (arids.length <= 0 && artistnames.length <= 0) getArtistCredits(release.artists);
								if (arids.length > 0 || artistnames.length > 0) return mbApiRequest('recording', {
									query: arids.map(arid => 'arid:' + arid).concat(artistnames.map(artistname =>
										'artistname:"' + artistname + '"'), 'recording:"' + track.name + '"').join(' AND '),
									limit: idsLookupLimit,
								}).then(function(recordings) {
									let trackLength;
									if (layoutMatch(media)) trackLength = getLengthFromTOC();
									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 (!(trackLength > 0) && !layoutMatch(media)) trackLength = getLengthFromTOC();
									const recordingDate = recording => recording['first-release-date'] || recording.date;
									if (recordings.count <= 0 || (recordings = recordings.recordings.filter(function(recording) {
										if (recording.score < 90
												|| !recordingDate(recording) && (!(trackLength > 0) || !(recording.length > 0))
												|| trackLength > 0 && recording.length > 0 && Math.abs(trackLength - recording.length) > 5000)
											return false;
										if (Array.isArray(recording['artist-credit'])) {
											if (!arids.every(arid => recording['artist-credit'].some(artistCredit =>
													artistCredit.artist && artistCredit.artist.id == arid))) return false;
											if (!artistnames.every(artistname => recording['artist-credit'].some(artistCredit =>
													artistCredit.artist && sameStringValues(artistCredit.artist.name, artistname))))
												return false;
										}
										if (!recording.title || !sameStringValues(track.name, recording.title)
												&& (!recordingDate(recording) || !(trackLength > 0) || !(recording.length > 0)
													|| !recording.title.toLowerCase().includes(track.name.toLowerCase())
														&& !track.name.toLowerCase().includes(recording.title.toLowerCase()))) return false;
										return true;
									})).length <= 0) return;
									if (recordings.length > 1) recordings = recordings.sort(function(a, b) {
										if ([a, b].every(r => r.length > 0)) {
											const deltas = [a, b].map(r => Math.abs(trackLength - r.length));
											if (deltas[0] < 1000 && deltas[1] >= 1000 ||deltas[1] < 1000 && deltas[0] >= 1000
													|| Math.abs(deltas[0] - deltas[1]) >= 1000 || ![a, b].every(recordingDate))
												return Math.sign(deltas[0] - deltas[1]);
										}
										return [a, b].every(recordingDate) ? recordingDate(a).localeCompare(recordingDate(b)) : 0;
									});
									formData.set(`mediums.${mediumIndex}.track.${trackIndex}.recording`, recordings[0].id);
									let notifyText = `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(' ')})`;
									notifyFoundMBID(notifyText, 'orange');
								}, console.warn);
							})) : Promise.resolve(null))));
						}

						Object.keys(lookupIndexes).forEach(function(entity, ndx1) {
							Object.keys(lookupIndexes[entity]).forEach(function(discogsId, ndx2) {
								if (lookupResults[ndx1][ndx2] != null) for (let prefix of lookupIndexes[entity][discogsId].prefixes)
									formData.set(prefix + '.mbid', lookupResults[ndx1][ndx2]);
							});
						});
						if (!formData.has('release-group') && Array.isArray(release.artists)
								&& release.artists.length > 0 && release.artists[0].id != 194) {
							const mbid = findMBID('artist', release.artists[0].id);
							if (mbid) var rgWorker = mbLookupById('release-group', 'artist', mbid).then(function(releaseGroups) {
								if ((releaseGroups = releaseGroups.filter(rG => sameTitles(rG.title, release.title))).length == 1) {
									formData.set('release_group', releaseGroups[0].id);
									let notify = `release group <b>${releaseGroups[0].title}</b>`;
									if (releaseGroups[0]['first-release-date'])
										notify += ` (<b>${getReleaseYear(releaseGroups[0]['first-release-date'])}</b>)`;
									notifyFoundMBID(notify + ' found by unique name match', 'goldenrod');
								}
							}, console.error);
						}
						return rgWorker instanceof Promise ? rgWorker.then(findExistingRecordings) : findExistingRecordings();
					}).then(() => formData) : Promise.resolve(formData);
				}) : Promise.reject('Invalid Discogs ID');
			}
			function seedNewRelease(formData) {
				if (!formData || typeof formData != 'object') throw 'Invalid argument';
				// if (!formData.has('language')) formData.set('language', 'eng');
				if (formData.has('language')) formData.set('script', {
					eng: 'Latn', deu: 'Latn', spa: 'Latn', fra: 'Latn', heb: 'Hebr', ara: 'Arab',
					gre: 'Grek', ell: 'Grek', rus: 'Cyrl', jpn: 'Jpan', zho: 'Hant', kor: 'Kore', tha: 'Thai',
				}[(formData.get('language') || '').toLowerCase()] || 'Latn');
				formData.set('edit_note', ((formData.get('edit_note') || '') + '\nSeeded by ' + scriptSignature).trimLeft());
				formData.set('make_votable', 1);
				const form = document.createElement('form');
				[form.method, form.action, form.target, form.hidden] = ['POST', mbOrigin + '/release/add', '_blank', 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
					: 'Media 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);
					}
				}

				const url = new URL('/cdtoc/attach', mbOrigin);
				url.searchParams.setTOC = function(index = 0) { this.set('toc', mbTOCs[index].join(' ')) };
				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);
					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('Not logged in or unable to reliably bind volumes');
					if (!confirm(`${mbTOCs.length != 1 ? mbTOCs.length.toString() + ' TOCs are' : 'TOC is'} going to be attached to release id ${mbId}
${mediums.length > 1 ? '\nMedia 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' : 'media 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`)) 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(function(responses) {
						GM_openInTab(`${mbOrigin}/release/${mbId}/discids`, false);
						return true;
					});
				}).catch(reason => { alert(reason + '\n\nAttach by hand'); attachByHand() }) : attachByHand());
			}, alert);
			function attachToMBIcon(callback, 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 addClickableIcon(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(evt) {
					if (evt.currentTarget.disabled) return; else evt.stopPropagation();
					// 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');
					callback(evt.altKey, evt.ctrlKey);
				}, !style ? function(evt) {
					let mbId = evt.dataTransfer.getData('text/plain');
					if (mbId && (mbId = mbId.split(/(?:\r?\n)+/)).length > 0 && (mbId = mbIdExtractor(mbId[0], 'release')))
						attachToMB(mbId, evt.altKey, evt.ctrlKey);
					return false;
				} : undefined, 'attach-toc', style, tooltip, tooltipster);
			}
			function seedToMB(target, torrent, discogsId, releaseGroupId) {
				if (!(target instanceof HTMLElement) || !torrent) throw 'Invalid argument';
				const seedToMb = () => getMbTOCs().then(function(mbTOCs) {
					const formData = new URLSearchParams;
					if (rxMBID.test(releaseGroupId)) formData.set('release_group', releaseGroupId);
					seedFromTorrent(formData, torrent);
					return seedFromTOCs(formData, mbTOCs).then(formData => discogsId > 0 || discogsId < 0 ?
						seedFromDiscogs(formData, discogsId, mbTOCs.map(mbTOC => mbTOC[1])) : formData);
				}).then(seedNewRelease).catch(alert);
				(discogsId > 0 || discogsId < 0 ? findMusicBrainzRelations(Math.abs(discogsId)).then(function(relations) {
					if (relations.length > 1) return confirm(`This release already exists by ambiguous binding from

${relations.map(relation => `\t${mbOrigin}/release/${relation.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/${relations[0].release.id}.
Attach the TOC(s) instead?`) ? attachToMB(relations[0].release.id, false, false) : Promise.reject('New release enforced');
				}).catch(seedToMb) : seedToMb()).then(target.epilogue);
			}
			function seedToMBIcon(callback, style, tooltip, tooltipster) {
				const staticIcon = minifyHTML(`
<svg height="0.9em" viewBox="0 0 100 100" version="1.1" xmlns="http://www.w3.org/2000/svg">
	<circle fill="${staticIconColor}" 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 = addClickableIcon(staticIcon, function(evt) {
					if (evt.currentTarget.disabled) return; else evt.stopPropagation();
					let discogsId = evt.ctrlKey ? prompt(`Enter Discogs release ID or URL:
(note the data preparation process may take some time due to MB API rate limits, especially for compilations)

`) : undefined;
					if (discogsId === null) return;
					if (discogsId != undefined && !((discogsId = discogsIdExtractor(discogsId, 'release')) > 0))
						return alert('Invalid input');
					evt.currentTarget.prologue(discogsId > 0 && !evt.shiftKey);
					callback(evt.currentTarget, evt.shiftKey ? -discogsId : discogsId);
				}, function(evt) {
					let data = evt.dataTransfer.getData('text/plain'), id, target = evt.currentTarget;
					if (data && (data = data.split(/(?:\r?\n)+/)).length > 0) {
						if ((id = discogsIdExtractor(data[0], 'release')) > 0) {
							target.prologue(!evt.shiftKey);
							callback(target, evt.shiftKey ? -id : id);
						} else if (id = mbIdExtractor(data[0], 'release-group')) callback(target, id);
						else if (id = mbIdExtractor(data[0], 'release')) mbApiRequest('release/' + id, { inc: 'release-groups' })
							.then(release => { callback(target, release['release-group'].id) });
					}
					return false;
				}, 'seed-mb-release', style, tooltip, tooltipster);
				span.prologue = function(waitingStatus = true) {
					if (this.disabled) return false; else this.disabled = true;
					if (waitingStatus) {
						this.classList.add('in-progress');
						this.innerHTML = svgAniSpinner();
					}
					return true;
				}.bind(span);
				span.epilogue = function() {
					if (this.classList.contains('in-progress')) {
						this.innerHTML = staticIcon;
						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 getMbTOCs = () => lookupByToc(torrentId, tocEntries => Promise.resolve(tocEntriesToMbTOC(tocEntries)));
			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 isCD = medium => /\b(?:(?:H[DQ])?CD|CDr|DualDisc)\b/.test(medium.format);
			const sameMedia = release =>
				release.media.every(medium => !medium.format) ? release.media : release.media.filter(isCD);
			const dcFmtFilters = [
				fmt => fmt && !['CD', 'Album', 'Single', 'EP', 'LP', 'Compilation', 'Stereo'].includes(fmt),
				fmt => !fmt || !['CDV', 'CD-ROM', 'SVCD', 'VCD'].includes(fmt),
				description => description && !['Mini-Album', 'Digipak', 'Digipack', 'Sampler'/*, 'Maxi-Single'*/].includes(description),
			];
			const scriptSignature = 'Edition lookup by CD TOC browser script (https://greasyfork.org/scripts/459083)';
			const fmtJoinPhrase = (joinPhrase = ' & ') => [
				[/^\s*(?:Feat(?:uring)?|Ft)\.?\s*$/i, ' feat. '], [/^\s*([\,\;])\s*$/, '$1 '],
				[/^\s*([\&\+\/\x\×\=]|vs\.|w\/|\/w|\w+)\s*$/i, (...m) => ` ${m[1].toLowerCase()} `],
				[/^\s*(?:,\s*(and|&|with))\s*$/i, (...m) => `, ${m[1].toLowerCase()} `],
			].reduce((phrase, subst) => phrase.replace(...subst), joinPhrase);
			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', true));
			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': ['RU'],
				'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': ['CZ', 'SK'], '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)': ['DE'], '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': ['CS'], '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'],
			}[discogsCountry]) || [discogsCountry || undefined];
			lookupByToc(torrentId, (tocEntries, discNdx, totalDiscs) =>
					mbLookupByDiscID(tocEntriesToMbTOC(tocEntries), !evt.ctrlKey)).then(function(results) {
				if (mbSeedNew) target.after(seedToMBIcon(function(target, id) {
					queryAjaxAPICached('torrent', { id: torrentId })
						.then(torrent => { seedToMB(target, torrent, id, id) }, alert);
				}, undefined, `Seed new MusicBrainz release from this CD TOC
Use Ctrl or drop Discogs release link to import Discogs 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(function(attended, skipPoll) {
					attachToMB(undefined, attended, skipPoll);
				}, 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';
					if (Boolean(target.dataset.haveResponse))
						setTooltip(target, `Matched media found only for some volumes (${score > 6 ? 'fuzzy' : 'exact'})`);
					return;
				}
				const isSameRemaster = release => !release.media || sameMedia(release).length == results.length;
				let releases = results[0].releases.filter(isSameRemaster);
				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.discId, target.dataset.toc] = [results[0].mbDiscID, JSON.stringify(results[0].mbTOC)];
				(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 (GM_getValue('auto_open_tab', true) && 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 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);
					}

					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];
					const labelInfoMapper = release => Array.isArray(release['label-info']) ?
						release['label-info'].map(labelInfo => ({
							label: labelInfo.label && labelInfo.label.name,
							catNo: labelInfo['catalog-number'],
						})).filter(labelInfo => labelInfo.label || labelInfo.catNo) : [ ];
					// add inpage search results
					const [thead, table, tbody] = createElements('div', 'table', 'tbody');
					thead.style = 'margin-bottom: 5pt;';
					thead.innerHTML = `<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;
					tbody.dataset.torrentId = torrent.torrent.id;
					tbody.dataset.edition = target.parentNode.dataset.edition;
					const [recordLabels, catalogueNumbers] = ['remasterRecordLabel', 'remasterCatalogueNumber']
						.map(prop => torrent.torrent[prop]).map(editionInfoSplitter);
					releases.forEach(function(release, index) {
						const [tr, artist, title, releaseEvents, editionInfo, barcode, groupSize, releasesWithId] =
							createElements('tr', 'td', 'td', 'td', 'td', 'td', 'td', 'td');
						tr.className = 'musicbrainz-release';
						tr.style = 'word-wrap: break-word; transition: color 200ms ease-in-out;';
						if (release.quality == 'low') tr.style.opacity = 0.75;
						tr.dataset.url = mbOrigin + '/release/' + release.id;
						[releaseEvents, barcode].forEach(elem => { elem.style.whiteSpace = 'nowrap' });
						[groupSize, releasesWithId].forEach(elem => { elem.style.textAlign = 'right' });
						if ('artist-credit' in release) release['artist-credit'].forEach(function(artistCredit, index, artists) {
							if ('artist' in artistCredit && artistCredit.artist.id && ![
								'89ad4ac3-39f7-470e-963a-56509c546377',
							].includes(artistCredit.artist.id)) {
								const a = document.createElement('a');
								if (artistCredit.artist) a.href = mbOrigin + '/artist/' + artistCredit.artist.id;
								[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 (index < artists.length - 1) artist.append(artistCredit.joinphrase || ' & ');
						});
						title.innerHTML = linkHTML(tr.dataset.url, release.title, 'musicbrainz-release');
						switch (release.quality) {
							case 'low': title.insertAdjacentHTML('afterbegin', svgBulletHTML('#ff6723')); break;
							case 'high': title.insertAdjacentHTML('afterbegin', svgBulletHTML('#00d26a')); break;
						}
						appendDisambiguation(title, release.disambiguation);
						// attach CD TOC
						if (mbAttachMode > 0 && (score > 0 || results.some(medium => !medium.releases.some(_release =>
								_release.id == release.id)))) title.prepend(attachToMBIcon(function(attended, skipPoll) {
							attachToMB(release.id, attended, skipPoll);
						}, '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) title.prepend(seedToMBIcon(function(target, discogsId) {
							seedToMB(target, torrent, discogsId, 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 release link to import Discogs metadata (+ Shift skips MBIDs lookup – faster)
MusicBrainz account required`));
						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']) ?
									releaseEvent.area['iso-3166-1-codes'].map(countryCode =>
										releaseEventMapper(countryCode, releaseEvent.date, torrent.torrent.remasterYear))
											.filter(Boolean) : [ ];
								return countryEvents.length > 0 ? countryEvents : releaseEvent.country || releaseEvent.date ?
									[releaseEventMapper(releaseEvent.country, releaseEvent.date, torrent.torrent.remasterYear)] : null;
							}).filter(Boolean)), 3);
						}
						if (releaseEvents.childElementCount <= 0) fillListRows(releaseEvents,
							[releaseEventMapper(release.country, release.date, 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)));
						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);
						}
						if (release['release-group']) {
							tr.dataset.groupUrl = mbOrigin + '/release-group/' + release['release-group'].id;
							mbApiRequest('release-group/' + release['release-group'].id, {
								inc: 'releases media discids',
							}).then(releaseGroup => releaseGroup.releases.filter(isSameRemaster)).then(function(releases) {
								const a = document.createElement('a');
								a.href = mbOrigin + '/release-group/' + release['release-group'].id;
								[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];
							});
						}
						try {
							if (isCompleteInfo || !('edition' in target.parentNode.dataset) || score > (is('unknown') ? 0 : 3)
									|| noEditPerms || !editableHosts.includes(document.domain) && !ajaxApiKey) throw 'Not applicable';
							const releaseYear = getReleaseYear(release.date), editionInfo = labelInfoMapper(release);
							if (!(releaseYear > 0) || editionInfo.length <= 0 && !release.barcode
									&& torrent.torrent.remasterYear > 0) throw 'Nothinng to update';
							tr.dataset.releaseYear = releaseYear;
							if (editionInfo.length > 0) tr.dataset.editionInfo = JSON.stringify(editionInfo);
							if (release.barcode) tr.dataset.barcodes = JSON.stringify([ release.barcode ]);
							const editionTitle = (release.disambiguation || '').split(/\s*[\,\;]+\s*/).filter(Boolean);
							if (release.packaging && !['Jewel case', 'Slim Jewel Case'].includes(release.packaging))
								editionTitle.push(release.packaging);
							if (release.status && !['Official', 'Bootleg'].includes(release.status))
								editionTitle.push(release.status);
							if (editionTitle.length > 0) tr.dataset.editionTitle = editionTitle.join(' / ');
							if (!torrent.torrent.description.includes(release.id))
								tr.dataset.description = torrent.torrent.description.trim();
							applyOnClick(tr);
						} catch(e) { openOnClick(tr) }
						(tr.title ? title.querySelector('a.musicbrainz-release') : tr).title = [
							release.quality && release.quality != 'normal' && release.quality + ' quality',
							release.media && release.media.map(medium => medium.format).join(' + '),
							[release.status != 'Official' && release.status, release.packaging].filter(Boolean).join(' / '),
							[release.id, release['cover-art-archive'] && release['cover-art-archive'].artwork && 'artwork'].filter(Boolean).join(' + '),
						].filter(Boolean).join('\n');
						tr.append(artist, title, releaseEvents, editionInfo, barcode, groupSize, releasesWithId);
						['artist', 'title', 'release-events', 'edition-info', 'barcode', 'releases-count', 'discids-count']
							.forEach((className, index) => tr.cells[index].className = className);
						tbody.append(tr);
						if (release.relations) for (let relation of release.relations) {
							if (relation.type != 'discogs' || !relation.url) continue;
							let discogsId = /\/releases?\/(\d+)\b/i.exec(relation.url.resource);
							if (discogsId != null) discogsId = parseInt(discogsId[1]); else continue;
							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);
							}
							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 = 'discogs-release';
								trDc.style = 'background-color: #8882; word-wrap: break-word; transition: color 200ms ease-in-out;';
								trDc.dataset.url = dcOrigin + '/release/' + release.id;
								[barcode].forEach(elem => { elem.style.whiteSpace = 'nowrap' });
								[groupSize, icon].forEach(elem => { elem.style.textAlign = 'right' });
								if (release.artists) release.artists.forEach(function(artistCredit, index, artists) {
									if (artistCredit.id > 0 && ![194].includes(artistCredit.id)) {
										const a = document.createElement('a');
										if (artistCredit.id) a.href = dcOrigin + '/artist/' + artistCredit.id;
										[a.target, a.style, a.className, a.title] =
											['_blank', noLinkDecoration, 'discogs-artist', artistCredit.role || artistCredit.id];
										a.textContent = artistCredit.anv || stripNameSuffix(artistCredit.name);
										artist.append(a);
									} else artist.append(artistCredit.anv || stripNameSuffix(artistCredit.name));
									if (index < artists.length - 1) artist.append(fmtJoinPhrase(artistCredit.join));
								});
								title.innerHTML = linkHTML(trDc.dataset.url, release.title, 'discogs-release');
								const fmtCDFilter = fmt => ['CD', 'CDr', 'All Media'].includes(fmt);
								let descriptors = [ ];
								if ('formats' in release) for (let format of release.formats) if (fmtCDFilter(format.name)
										&& dcFmtFilters[1](format.text)
										&& (!Array.isArray(format.descriptions) || format.descriptions.every(dcFmtFilters[1]))
										|| format.name == 'Hybrid' && (format.text == 'DualDisc'
											|| Array.isArray(format.descriptions) && format.descriptions.includes('DualDisc'))) {
									if (dcFmtFilters[0](format.text)) descriptors.push(format.text);
									if (Array.isArray(format.descriptions))
										Array.prototype.push.apply(descriptors, format.descriptions.filter(dcFmtFilters[0]));
								}
								descriptors = descriptors.filter((d1, n, a) => a.findIndex(d2 => d2.toLowerCase() == d1.toLowerCase()) == n);
								if (descriptors.length > 0) appendDisambiguation(title, descriptors.join(', '));
								if (release.country || release.released)
									fillListRows(releaseEvents, discogsCountryToIso3166Mapper(release.country).map(countryCode =>
										releaseEventMapper(countryCode, release.released, torrent.torrent.remasterYear)), 3);
								if (Array.isArray(release.labels)) fillListRows(editionInfo, release.labels.map(label =>
									editionInfoMapper(stripNameSuffix(label.name), label.catno, recordLabels, catalogueNumbers)));
								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);
								}
								icon.innerHTML = GM_getResourceText('dc_icon');
								icon.firstElementChild.style = '';
								icon.firstElementChild.removeAttribute('width');
								icon.firstElementChild.setAttribute('height', '0.9em');
								svgSetTitle(icon.firstElementChild, release.id);
								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';
									trDc.dataset.groupUrl = masterUrl;
									const getGroupSize1 = () => dcApiRequest(`masters/${release.master_id}/versions`)
										.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`, {
										page: page,
										per_page: 1000,
									}).then(function(versions) {
										const releases = versions.versions.filter(version => !Array.isArray(version.major_formats)
											|| version.major_formats.some(fmtCDFilter)).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'];
								try {
									if (isCompleteInfo || !('edition' in target.parentNode.dataset) || score > (is('unknown') ? 0 : 3)
											|| noEditPerms || !editableHosts.includes(document.domain) && !ajaxApiKey) throw 'Not applicable';
									const releaseYear = getReleaseYear(release.released);
									if (!(releaseYear > 0)) throw 'Year unknown';
									const editionInfo = Array.isArray(release.labels) ? release.labels.map(label => ({
										label: stripNameSuffix(label.name),
										catNo: label.catno,
									})).filter(label => label.label || label.catNo) : [ ];
									if (editionInfo.length <= 0 && !barCode && torrent.torrent.remasterYear > 0)
										throw 'Nothing to update';
									trDc.dataset.releaseYear = releaseYear;
									if (editionInfo.length > 0) trDc.dataset.editionInfo = JSON.stringify(editionInfo);
									if (barCode) trDc.dataset.barcodes = JSON.stringify([ barCode ]);
									if ((descriptors = descriptors.filter(dcFmtFilters[2])).length > 0)
										trDc.dataset.editionTitle = descriptors.join(' / ');
									if (!torrent.torrent.description.includes(trDc.dataset.url))
										trDc.dataset.description = torrent.torrent.description.trim();
									applyOnClick(trDc);
								} catch(e) { openOnClick(trDc) }
								(trDc.title ? title.querySelector('a.discogs-release') : trDc).title = release.formats.map(function(format) {
									const tags = [format.text].concat(format.descriptions || [ ]).filter(Boolean);
									if (format.name == 'All Media') return tags.length > 0 && tags.join(', ');
									let description = format.qty + '×' + format.name;
									if (tags.length > 0) description += ' (' + tags.join(', ') + ')';
									return description;
								}).concat((release.series || [ ]).map(series => 'Series: ' +
										[stripNameSuffix(series.name), series.catno].filter(Boolean).join(' ')))
									.concat((release.identifiers || [ ]).filter(identifier => identifier.type != 'Barcode')
										.map(identifier => identifier.type + ': ' + identifier.value))
									.concat([
										[release.data_quality, release.status].filter(Boolean).join(' / '),
										release.id,
									]).filter(Boolean).join('\n');
								trDc.append(artist, title, releaseEvents, editionInfo, barcode, groupSize, icon);
								['artist', 'title', 'release-events', 'edition-info', 'barcode', 'releases-count', '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);
					addLookupResults(torrentId, thead, table);
					if (isCompleteInfo || !('edition' in target.parentNode.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 : 3)) 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, a.dataset.releaseYear] =
						['update-edition', '#', '(set)', score <= 0 && releases.length < 2 ? 'bold' : 300, releaseYear];
					const editionInfo = Array.prototype.concat.apply([ ], releases.map(labelInfoMapper));
					if (editionInfo.length > 0) a.dataset.editionInfo = JSON.stringify(editionInfo);
					const barcodes = releases.map(release => release.barcode).filter(Boolean);
					if (barcodes.length > 0) a.dataset.barcodes = JSON.stringify(barcodes);
					if (releases.length < 2 && releases[0].disambiguation)
						a.dataset.editionTitle = releases[0].disambiguation;
					if (releases.length < 2 && !torrent.torrent.description.includes(releases[0].id)) {
						a.dataset.url = mbOrigin + '/release/' + releases[0].id;
						a.dataset.description = torrent.torrent.description.trim();
					}
					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) {
				target.textContent = reason;
				target.style.color = 'red';
			}).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(target.parentNode.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 (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(target.parentNode.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.parentNode.dataset.haveQuery) && !GM_getValue('auto_open_tab', true)) return;
			for (let tocId of Array.from(tocIds).reverse()) if (tocId != null)
				GM_openInTab('https://db.cue.tools/?tocid=' + tocId, !Boolean(target.parentNode.dataset.haveQuery));
		}, function(reason) {
			target.textContent = reason;
			target.style.color = 'red';
		}).then(() => { target.disabled = false });
		if (!target.parentNode.dataset.edition || Boolean(target.parentNode.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,
					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.length == checksums.length)) return null; // tracklist too short
				const getMatches = matchFn => entries[volumeNdx].reduce((sum, entry, ndx) =>
					matchFn(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) {
			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';
			return queryAjaxAPICached('torrent', { id: torrentId }).then(function(torrent) {
				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 }));
				const _getReleaseYear = metadata => (metadata = metadata.release.map(release => getReleaseYear(release.date)))
					.every((year, ndx, arr) => year > 0 && year == arr[0]) ? metadata[0] : NaN;
				const labelInfoMapper = metadata => metadata.labelInfo.map(labelInfo => ({
					label: metadata.source == 'discogs' ? stripNameSuffix(labelInfo.name) : labelInfo.name,
					catNo: labelInfo.catno,
				})).filter(labelInfo => labelInfo.label || labelInfo.catNo);
				// In-page results table
				const [thead, table, tbody] = createElements('div', 'table', 'tbody');
				thead.style = 'margin-bottom: 5pt;';
				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;
				tbody.dataset.torrentId = torrentId; tbody.dataset.edition = target.parentNode.dataset.edition;
				const [recordLabels, catalogueNumbers] = ['remasterRecordLabel', 'remasterCatalogueNumber']
					.map(prop => torrent.torrent[prop]).map(editionInfoSplitter);
				results.forEach(function(metadata) {
					const [tr, source, artist, title, release, editionInfo, barcode, relevance] =
						createElements('tr', 'td', 'td', 'td', 'td', 'td', 'td', 'td', 'td');
					tr.className = 'ctdb-metadata';
					tr.style = 'word-wrap: break-word; transition: color 200ms ease-in-out;';
					tr.dataset.url = [{ 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' ? stripNameSuffix(metadata.artist) : metadata.artist;
					source.style.alignTop = '1pt';
					title.innerHTML = linkHTML(tr.dataset.url, metadata.album, metadata.source + '-release');
					if (metadata.source == 'discogs') findMusicBrainzRelations(metadata.id).then(function(relations) {
						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, relations.map((relation, index) => Object.assign(document.createElement('a'), {
							href: [mbOrigin, 'release', relation.release.id].join('/'), target: '_blank',
							style: noLinkDecoration + ' vertical-align: top;',
							innerHTML: '<img src="https://musicbrainz.org/static/images/entity/release.svg" height="6" />',
							title: relation.release.id,
						})));
						title.append(span);
					});
					if (Array.isArray(metadata.release)) fillListRows(release, metadata.release.map(release =>
						releaseEventMapper(release.country, release.date, torrent.torrent.remasterYear)), 3);
					if (Array.isArray(metadata.labelInfo)) fillListRows(editionInfo, metadata.labelInfo.map(labelInfo =>
						editionInfoMapper(stripNameSuffix(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'];
					(!isCompleteInfo && 'edition' in target.parentNode.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) {
						const releaseYear = _getReleaseYear(metadata);
						if (!(releaseYear > 0)) return Promise.reject('Unknown or inconsistent release year');
						const editionInfo = labelInfoMapper(metadata);
						if (editionInfo.length <= 0 && !metadata.barcode && torrent.torrent.remasterYear > 0)
							return Promise.reject('No additional edition information');
						tr.dataset.releaseYear = releaseYear;
						if (editionInfo.length > 0) tr.dataset.editionInfo = JSON.stringify(editionInfo);
						if (metadata.barcode) tr.dataset.barcodes = JSON.stringify([ metadata.barcode ]);
						if (!torrent.torrent.description.includes(metadata.id))
							tr.dataset.description = torrent.torrent.description.trim();
						applyOnClick(tr);
					}).catch(reason => { openOnClick(tr) });
					tr.append(source, artist, title, release, editionInfo, barcode, relevance);
					['source', 'artist', 'title', 'release-events', 'edition-info', 'barcode', 'relevance']
						.forEach((className, index) => tr.cells[index].className = className);
					tbody.append(tr);
				});
				table.append(tbody);
				addLookupResults(torrentId, thead, table);
				// Group set
				if (isCompleteInfo || !('edition' in target.parentNode.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 : 3)
						|| 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)))))) return;
					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';
					a.dataset.releaseYear = releaseYear;
					const editionInfo = Array.prototype.concat.apply([ ], results.map(labelInfoMapper));
					if (editionInfo.length > 0) a.dataset.editionInfo = JSON.stringify(editionInfo);
					const barcodes = results.map(metadata => metadata.barcode).filter(Boolean);
					if (barcodes.length > 0) a.dataset.barcodes = JSON.stringify(barcodes);
					if (results.length < 2 && !torrent.torrent.description.includes(results[0].id)) {
						a.dataset.description = torrent.torrent.description.trim();
						a.dataset.url = {
							musicbrainz: mbOrigin + '/release/' + results[0].id,
							discogs: dcOrigin + '/release/' + results[0].id,
						}[results[0].source];
					}
					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),
							[stripNameSuffix(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';
		}).then(() => { target.parentNode.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);
}

}