Greasy Fork

Greasy Fork is available in English.

[GMT] Edition lookup by CD TOC

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

当前为 2023-05-29 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         [GMT] Edition lookup by CD TOC
// @namespace    http://greasyfork.icu/users/321857-anakunda
// @version      1.15.14
// @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;

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) {
					try {
						if (!dcApiResponses) dcApiResponses = { };
						dcApiResponses[cacheKey] = response.response;
						sessionStorage.setItem('discogsApiResponseCache', JSON.stringify(dcApiResponses));
					} catch(e) { 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);
	return 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`;
		console.assert(msfToSector(tocEntry[2]) == parseInt(tocEntry[12]));
		console.assert(msfToSector(tocEntry[7]) == parseInt(tocEntry[13]) + 1 - parseInt(tocEntry[12]));
		return {
			trackNumber: parseInt(tocEntry[1]),
			startSector: parseInt(tocEntry[12]),
			endSector: parseInt(tocEntry[13]),
		};
	})).length > 0 ? tocEntries : null;
}

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?|International)\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';
		container = document.createElement('div');
		container.className = 'toc-lookup-tables';
		container.style = 'margin: 10pt 0; padding: 0; display: flex; flex-flow: column; row-gap: 10pt;';
		elem.after(container);
	}
	(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] = [mbOrigin + '/release/' + mbId, '_blank'];
			[a.textContent, a.style] = ['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 =>
					sameCatnos(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');
				div.className = 'release-event';
				row.forEach((elem, index) => { if (index > 0) root.append(' '); div.append(elem) });
				container.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].className, divs[1].hidden] = ['more-events', true];
		addRows(divs[1], listElements.slice(maxRowsToShow));
		container.append(...divs);
	}

	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 ? decodeHTML(infoStr).split(/[\/\|\•]+/).map(expr => expr.trim()).filter(Boolean) : [ ];
	const cmpNorm = expr => expr && expr.toLowerCase().replace(/[^\w\u0080-\uFFFF]/g, '');
	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 sameCatnos = (...catnos) => catnos.length > 0
		&& catnos.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])));

	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, false);
			}).catch(function(reason) {
				target.textContent = reason;
				target.style.color = '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, false);
			// GM_openInTab(`${mbOrigin}/cdtoc/${evt.shiftKey ? 'attach?toc=' + JSON.parse(target.dataset.toc).join(' ')
			// 	: target.dataset.discId}`, 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 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;
			}
			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 findDiscogsToMbBinding(entity, discogsId) {
						function notifyEntityBinding(method, color = 'orange', length = 6) {
							let div = document.body.querySelector('div.entity-binding-notify'), animation;
							if (div == null) {
								div = document.createElement('div');
								div.className = 'entity-binding-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 ${entity} <b>${lookupIndexes[entity][discogsId].name}</b> ${method || 'found'}`;
							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 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
							},
						};
						if (discogsId in bindingsCache[entity]) {
							console.log('Entity binding for', entity, discogsId, 'got from cache');
							notifyEntityBinding('got from cache', 'sandybrown');
							return Promise.resolve(bindingsCache[entity][discogsId]);
						}
						const dcCounterparts = (dcReleases, mbReleases) => [mbReleases, dcReleases].every(Array.isArray) ?
							mbReleases.filter(mbRelease => mbRelease != null && mbRelease.date && mbRelease.title
								&& dcReleases.some(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');
							}
							notifyEntityBinding(`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));
							notifyEntityBinding('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) {
							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.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'),
						release.companies && (function() {
							const companies = { };
							for (let company of release.companies) if (company.entity_type_name) {
								if (!(company.entity_type_name in companies)) companies[company.entity_type_name] = [ ];
								companies[company.entity_type_name].push(company);
							}
							return Object.keys(companies).map(type => type + ' – ' + companies[type].map(company =>
								company.catno ? company.name + ' – ' + company.catno : company.name).join(', '));
						})().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) {
						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) {
							let mbid = Object.keys(lookupIndexes.artist).findIndex(key => parseInt(key) == release.artists[0].id);
							mbid = release.artists[0].id >= 0 && lookupResults[Object.keys(lookupIndexes).indexOf('artist')][mbid];
							if (mbid) return 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);
							}, console.error).then(() => formData);
						}
						return formData;
					}) : 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 ? mbApiRequest('url', {
					query: `url_descendent:*discogs.com/release/${Math.abs(discogsId)}`,
					limit: 100,
				}).then(results => results.count > 0 && (results = results.urls.filter(url =>
					discogsIdExtractor(url.resource, 'release') == Math.abs(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'))))
						: Promise.reject('No relations for this URL')).then(function(relations) {
					if ((relations = Array.prototype.concat.apply([ ], relations)).length <= 0)
						return Promise.reject('No relations for this URL');
					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 (http://greasyfork.icu/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'], 'Unknown': ['??'], 'Spain': ['ES'],
				'Australia': ['AU'], 'Russia': ['RU'], 'Sweden': ['SE'], 'Brazil': ['BR'], 'Belgium': ['BE'],
				'Greece': ['GR'], 'Poland': ['PL'], 'Mexico': ['MX'], 'Finland': ['FI'], 'Jamaica': ['JM'],
				'Switzerland': ['CH'], 'USSR': ['RU'], 'Denmark': ['DK'], 'Argentina': ['AR'], 'Portugal': ['PT'],
				'Norway': ['NO'], 'Austria': ['AT'], 'UK & Europe': ['GB', 'XE'], 'New Zealand': ['NZ'],
				'South Africa': ['ZA'], 'Yugoslavia': ['YU'], 'Hungary': ['HU'], 'Colombia': ['CO'],
				'USA & Canada': ['US', 'CA'], 'Ukraine': ['UA'], 'Turkey': ['TR'], 'India': ['IN'],
				'Czech Republic': ['CZ'], 'Czechoslovakia': ['CS'], 'Venezuela': ['VE'], 'Ireland': ['IE'],
				'Romania': ['RO'], 'Indonesia': ['ID'], 'Taiwan': ['TW'], 'Chile': ['CL'], 'Peru': ['PE'],
				'South Korea': ['KR'], 'Worldwide': ['XW'], 'Israel': ['IL'], 'Bulgaria': ['BG'],
				'Thailand': ['TH'], 'Malaysia': ['MY'], 'Scandinavia': ['SE', 'NO', 'FI'],
				'German Democratic Republic (GDR)': ['DE'], 'China': ['CN'], 'Croatia': ['HR'],
				'Hong Kong': ['HK'], 'Philippines': ['PH'], 'Serbia': ['RS'], 'Ecuador': ['EC'],
				'Lithuania': ['LT'], 'UK, Europe & US': ['GB', 'XE', 'US'], 'East Timor': ['TL'],
				'Germany, Austria, & Switzerland': ['DE', 'AT', 'CH'], 'USA & Europe': ['US', 'XE'],
				'Singapore': ['SG'], 'Slovenia': ['SI'], 'Slovakia': ['SK'], 'Uruguay': ['UY'],
				'Australasia': ['AU'], 'Australia & New Zealand': ['AU', 'NZ'], 'Iceland': ['IS'],
				'Bolivia': ['BO'], 'UK & Ireland': ['GB', 'IE'], 'Nigeria': ['NG'], 'Estonia': ['EE'],
				'USA, Canada & Europe': ['US', 'CA', 'XE'], 'Benelux': ['BE', 'NL', 'LU'], 'Panama': ['PA'],
				'UK & US': ['GB', 'US'], 'Pakistan': ['PK'], 'Lebanon': ['LB'], 'Egypt': ['EG'], 'Cuba': ['CU'],
				'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'], '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'],
				'Reunion': ['RE'], 'Angola': ['AO'], 'Serbia and Montenegro': ['RS', 'ME'], 'Georgia': ['GE'],
				'United Arab Emirates': ['AE'], 'Congo, Democratic Republic of the': ['CD'],
				'Germany & Switzerland': ['DE', 'CH'], 'Malta': ['MT'], 'Mozambique': ['MZ'], 'Cyprus': ['CY'],
				'Mauritius': ['MU'], 'Azerbaijan': ['AZ'], 'Zambia': ['ZM'], 'Kazakhstan': ['KZ'],
				'Nicaragua': ['NI'], 'Syria': ['SY'], 'Senegal': ['SN'], 'Paraguay': ['PY'], 'Guadeloupe': ['GP'],
				'UK & France': ['GB', 'FR'], 'Vietnam': ['VN'], 'UK, Europe & Japan': ['GB', 'XE', 'JP'],
				'Bahamas, The': ['BS'], 'Ethiopia': ['ET'], 'Suriname': ['SR'], 'Haiti': ['HT'],
				'Singapore & Malaysia': ['SG', 'MY'], 'Moldova, Republic of': ['MD'], 'Faroe Islands': ['FO'],
				'Cameroon': ['CM'], 'South Vietnam': ['VN'], 'Uzbekistan': ['UZ'], 'South America': ['ZA'],
				'Albania': ['AL'], 'Honduras': ['HN'], 'Martinique': ['MQ'], 'Benin': ['BJ'], 'Kuwait': ['KW'],
				'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'],
				'Bangladesh': ['BD'], 'Armenia': ['AM'], 'North Korea': ['KP'], 'Bermuda': ['BM'], 'Iraq': ['IQ'],
				'Seychelles': ['SC'], 'Cambodia': ['KH'], 'Guyana': ['GY'], 'Tanzania': ['TZ'], 'Bahrain': ['BH'],
				'Jordan': ['JO'], 'Libya': ['LY'], 'Montenegro': ['ME'], 'Gabon': ['GA'], 'Togo': ['TG'],
				'Afghanistan': ['AF'], 'Yemen': ['YE'], 'Cayman Islands': ['KY'], 'Monaco': ['MC'],
				'Papua New Guinea': ['PG'], 'Belize': ['BZ'], 'Fiji': ['FJ'], 'UK & Germany': ['UK', 'DE'],
				'New Caledonia': ['NC'], 'Protectorate of Bohemia and Moravia': ['CS'], 'Saint Helena' : ['SH'],
				'UK, Europe & Israel': ['GB', 'XE', 'IL'], 'French Guiana': ['GF'], 'Laos': ['LA'],
				'Aruba': ['AW'], 'Dominica': ['DM'], 'San Marino': ['SM'], 'Kyrgyzstan': ['KG'],
				'Burkina Faso': ['BF'], 'Turkmenistan': ['TM'], 'Namibia': ['NA'], 'Sierra Leone': ['SL'],
				'Marshall Islands': ['MH'], 'Botswana': ['BW'], 'Eritrea': ['ER'], 'Saint Kitts and Nevis': ['KN'],
				'Guernsey': ['GG'], 'Jersey': ['JE'], 'Guam': ['GU'], 'Central African Republic': ['CF'],
				'Grenada': ['GD'], 'Qatar': ['QA'], 'Somalia': ['SO'], 'Liberia': ['LR'], 'Sint Maarten': ['SX'],
				'Saint Lucia': ['LC'], 'Lesotho': ['LS'], 'Maldives': ['MV'], 'Bhutan': ['BT'], 'Niger': ['NE'],
				'Saint Vincent and the Grenadines': ['VC'], 'Malawi': ['MW'], 'Guinea-Bissau': ['GW'],
				'Palau': ['PW'], 'Comoros': ['KM'], 'Gibraltar': ['GI'], 'Cook Islands': ['CK'],
				'Mauritania': ['MR'], 'Tajikistan': ['TJ'], 'Rwanda': ['RW'], 'Samoa': ['WS'], 'Oman': ['OM'],
				'Anguilla': ['AI'], 'Sao Tome and Principe': ['ST'], 'Djibouti': ['DJ'], 'Mayotte': ['YT'],
				'Montserrat': ['MS'], 'Tonga': ['TO'], 'Vanuatu': ['VU'], 'Norfolk Island': ['NF'],
				'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'],
				'Antigua & Barbuda': ['AG'], 'Austria-Hungary': ['AT', 'HU'], 'British Virgin Islands': ['VG'],
				'Brunei': ['BN'], 'Burma': ['MM'], 'Cape Verde': ['CV'], 'Virgin Islands': ['VI'],
				'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'], 'Macau': ['MO'],
				'Korea (pre-1945)': ['KR'], 'Hong Kong & Thailand': ['HK', 'TH'], 'Gambia, The': ['GM'],
				'South Georgia and the South Sandwich Islands' : ['GS'], 'Cocos (Keeling) Islands' : ['CC'],
				'Kiribati' : ['KI'], 'Christmas Island' : ['CX'], 'French Southern & Antarctic Lands' : ['AQ'],
				'British Indian Ocean Territory' : ['IO'], 'Western Sahara': ['EH'], 'Burundi' : ['BI'],
				// 'Central America': ['??'], 'North & South America': ['??'],
				// 'Africa': ['??'], 'South West Africa': ['??'], 'Rhodesia': ['??'],
				// 'Southern Rhodesia': ['??'], 'Upper Volta': ['??'], 'West Bank': ['??'], 'Zaire': ['??'],
				// 'Zanzibar': ['??'], 'Belgian Congo': ['??'], 'Netherlands Antilles': ['??'],
				// 'Russia & CIS': ['??'], 'South Pacific': ['??'],
				// 'Asia': ['??'], 'South East Asia': ['??'], 'Tibet' : ['??'], 'Indochina': ['??'],
				// 'Middle East': ['??'], 'Gulf Cooperation Council': ['??'], 'Gaza Strip': ['??'],
				// 'Abkhazia': ['??'], 'Ottoman Empire': ['??'], 'Bohemia': ['??'], 'Kosovo': ['??'],
				// 'Dutch East Indies': ['??'], 'Dahomey': ['??'], 'Wake Island': ['??'],
			}[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 = 'disambiguation';
						span.style.opacity = 0.6;
						span.textContent = '(' + 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, _release, 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;
						[_release, 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) {
							let releaseEvents = [ ];
							for (let releaseEvent of release['release-events']) {
								const events = 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) : [ ];
								if (events.length > 0) Array.prototype.push.apply(releaseEvents, events);
								else releaseEvents.push(releaseEventMapper(undefined, releaseEvent.date, torrent.torrent.remasterYear));
							}
							fillListRows(_release, releaseEvents, 3);
						}
						if (_release.childElementCount <= 0) fillListRows(_release,
							[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, _release, 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.innerHTML = 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)');
								span.className = 'have-discogs-relatives';
								title.append(' ', span);
							}
							dcApiRequest('releases/' + discogsId).then(function(release) {
								const [trDc, icon, artist, title, _release, 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(_release, 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 = masterUrl; a.target = '_blank';
										a.style = noLinkDecoration;
										a.textContent = _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 = '5pt';
										groupSize.innerHTML = svgFail();
										groupSize.title = reason;
									});
								} else {
									groupSize.textContent = '–';
									groupSize.style.color = '#0a0';
									groupSize.title = '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, _release, 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 = 'update-edition';
					a.href = '#';
					a.textContent = '(set)';
					a.style.fontWeight = score <= 0 && releases.length < 2 ? 'bold' : 300;
					a.dataset.releaseYear = 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 + '/release/' + metadata.id,
						discogs: dcOrigin + '/release/' + metadata.id,
					}[metadata.source];
					[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 (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 = metadata.relevance + '%';
						relevance.title = '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 = 'update-edition';
					a.href = '#';
					a.textContent = '(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)');
}

let 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 = captions[0];
	a.href = '#';
	a.className = 'brackets';
	a.style.marginLeft = '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);
}

}