[GMT] Edition lookup by CD TOC

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

目前為 2023-04-29 提交的版本,檢視 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         [GMT] Edition lookup by CD TOC
// @namespace    https://greasyfork.org/users/321857-anakunda
// @version      1.15.12
// @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     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;
let noEditPerms = document.getElementById('nav_userclass');
noEditPerms = noEditPerms != null && ['User', 'Member', 'Power User'].includes(noEditPerms.textContent.trim());

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({ 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, ''), 'https://musicbrainz.org');
	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;
})();

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);
	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) 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+)';
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 rxSessionHeader = '^(?:' + [
	'(?: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: ',
].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)) {
	const rxRipperSignatures = '(?:(?:' + [
		'Exact Audio Copy V', 'X Lossless Decoder version ',
		'CUERipper v', 'EZ CD Audio Converter ', 'Log created by: whipper ',
	].join('|') + ')\\d+)';
	if (!detectVolumes) {
		const rxStackedLog = new RegExp('^[\\S\\s]*(?:\\r?\\n)+(?=' + rxRipperSignatures + ')');
		return (logFiles = Array.prototype.map.call(logFiles, (logFile =>
			rxStackedLog.test(logFile) ? logFile.replace(rxStackedLog, '') : logFile))
				.filter(RegExp.prototype.test.bind(new RegExp('^(?:' + rxRipperSignatures + '|EAC\\b)')))).length > 0 ?
			logFiles : null;
	}
	if ((logFiles = Array.prototype.map.call(logFiles, function(logFile) {
		const rxSessionsIndexer = new RegExp('^' + rxRipperSignatures, 'gm');
		let indexes = [ ], match;
		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(rxSessionHeader + '(?:\\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 && index == tocEntries.length - 2 ? 1 : -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`;
		const layoutType = getlayoutType(tocEntries);
		if (layoutType == 1) tocEntries.pop(); // ditch data track for CD Extra
		else if (layoutType != 0) console.warn('Disc %d unknown layout type', volumeNdx + 1);
		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 bareId = str => str ? str.trim().toLowerCase()
	.replace(/^(?:Not On Label|No label|\[no label\]|None|\[none\]|Self[\s\-]?Released)(?:\s*\(.+\))?$|(?:\s+\b(?:Record(?:ing)?s)\b|,?\s+(?:Ltd|Inc|Co)\.?)+$/ig, '')
	.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 (!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 => /^(?:Not On Label|No label|\[no label\]|None|\[none\])(?:\s*\(.+\))?$|\b(?:Self[\s\-]?Released)\b/i.test(label) ? 'self-released' : label).filter(Boolean).join(' / ');
		payload.remaster_catalogue_number = editionInfo.map(label => label.catNo).filter(uniqueValues)
			.map(catNo => !/^(?:\[none\]|None)$/i.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);
}

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 = (height = '1em', color = '#f00') => minifyHTML(`
<svg height="${height}" version="1.1" viewBox="0 0 256 256">
	<circle fill="${color}" cx="128" cy="128" r="128" />
	<path fill="white" d="M197.7 83.38l-1.78 1.78 -1.79 1.79 -1.79 1.79 -1.78 1.78 -1.79 1.79 -1.79 1.78 -1.78 1.79 -1.79 1.79 -1.79 1.78 -1.78 1.79 -1.79 1.79 -1.78 1.78 -1.79 1.79 -1.79 1.79 -1.78 1.78 -1.79 1.79 -1.79 1.78 -1.78 1.79 -1.79 1.79 -1.78 1.78 -1.79 1.79 -1.79 1.79 -1.78 1.78 -1.79 1.79 -1.75 1.75 1.75 1.75 1.79 1.79 1.78 1.78 1.79 1.79 1.79 1.79 1.78 1.78 1.79 1.79 1.78 1.79 1.79 1.78 1.79 1.79 1.78 1.79 1.79 1.78 1.79 1.79 1.78 1.78 1.79 1.79 1.79 1.79 1.78 1.78 1.79 1.79 1.78 1.79 1.79 1.78 1.79 1.79 1.78 1.79 1.79 1.78 1.79 1.79 1.78 1.78c6.58,6.58 -18.5,31.66 -25.08,25.08l-44.62 -44.62 -1.75 1.75 -1.79 1.79 -1.78 1.78 -1.79 1.79 -1.79 1.79 -1.78 1.78 -1.79 1.79 -1.79 1.78 -1.78 1.79 -1.79 1.79 -1.79 1.78 -1.78 1.79 -1.79 1.79 -1.78 1.78 -1.79 1.79 -1.79 1.79 -1.78 1.78 -1.79 1.79 -1.79 1.78 -1.78 1.79 -1.79 1.79 -1.79 1.78 -1.78 1.79 -1.79 1.79 -1.78 1.78c-6.58,6.58 -31.66,-18.5 -25.08,-25.08l44.62 -44.62 -44.62 -44.62c-6.58,-6.58 18.5,-31.66 25.08,-25.08l1.78 1.78 1.79 1.79 1.79 1.79 1.78 1.78 1.79 1.79 1.78 1.79 1.79 1.78 1.79 1.79 1.78 1.79 1.79 1.78 1.79 1.79 1.78 1.78 1.79 1.79 1.79 1.79 1.78 1.78 1.79 1.79 1.78 1.79 1.79 1.78 1.79 1.79 1.78 1.78 1.79 1.79 1.79 1.79 1.78 1.78 1.79 1.79 1.75 1.75 44.62 -44.62c6.58,-6.58 31.66,18.5 25.08,25.08z" />
</svg>`);
const svgCheckmark = (height = '1em', color = '#0c0') => minifyHTML(`
<svg height="${height}" version="1.1" viewBox="0 0 4120.39 4120.39">
	<circle fill="${color}" cx="2060.2" cy="2060.2" r="2060.2" />
	<path fill="white" d="M1849.38 3060.71c-131.17,0 -356.12,-267.24 -440.32,-351.45 -408.56,-408.55 -468.78,-397.75 -282.81,-581.74 151.52,-149.91 136.02,-195.31 420.15,88.91 66.71,66.73 168.48,183.48 238.34,230.26 60.59,-40.58 648.52,-923.59 736.78,-1056.81 262.36,-396.02 237.77,-402.28 515.29,-195.27 150.69,112.4 -16.43,237.96 -237.31,570.2l-749.75 1108.47c-44.39,66.71 -104.04,187.43 -200.37,187.43z" />
</svg>`);
const svgQuestionmark = (height = '1em', color = '#fc0') => minifyHTML(`
<svg height="${height}" version="1.1" viewBox="0 0 256 256">
	<circle fill="${color}" cx="128" cy="128" r="128" />
	<path fill="white" d="M103.92 165.09c-0.84,-2.13 -1.46,-4.52 -1.92,-7.15 -0.46,-2.68 -0.67,-5.19 -0.67,-7.54 0,-3.76 0.37,-7.19 1.09,-10.29 0.75,-3.14 1.84,-6.06 3.3,-8.78 1.51,-2.72 3.35,-5.36 5.52,-7.83 2.22,-2.51 4.81,-4.98 7.74,-7.45 3.1,-2.59 5.82,-5.02 8.16,-7.28 2.3,-2.25 4.31,-4.47 5.94,-6.73 1.63,-2.26 2.85,-4.6 3.68,-6.99 0.8,-2.42 1.22,-5.1 1.22,-8.03 0,-2.55 -0.46,-4.9 -1.34,-7.07 -0.92,-2.14 -2.18,-4.02 -3.89,-5.57 -1.67,-1.54 -3.68,-2.76 -6.11,-3.68 -2.43,-0.88 -5.1,-1.34 -8.03,-1.34 -6.36,0 -12.97,1.34 -19.88,3.98 -6.86,2.68 -13.34,6.69 -19.45,12.09l0 -36.9c6.27,-3.77 13.14,-6.57 20.58,-8.45 7.45,-1.89 15.11,-2.85 23.06,-2.85 7.57,0 14.64,0.84 21.17,2.55 6.57,1.68 12.26,4.31 17.11,7.91 4.85,3.56 8.66,8.16 11.42,13.77 2.72,5.6 4.1,12.34 4.1,20.16 0,4.98 -0.58,9.5 -1.71,13.56 -1.18,4.01 -2.85,7.86 -5.03,11.46 -2.21,3.6 -4.97,7.03 -8.24,10.34 -3.26,3.3 -7.03,6.73 -11.25,10.25 -2.89,2.38 -5.4,4.56 -7.53,6.61 -2.18,2.05 -3.98,4.05 -5.4,6.06 -1.42,2.01 -2.51,4.14 -3.26,6.36 -0.71,2.26 -1.09,4.81 -1.09,7.7 0,1.93 0.25,3.93 0.79,5.98 0.51,2.05 1.26,3.77 2.14,5.15l-32.22 0zm17.87 53.68c-6.53,0 -11.97,-1.93 -16.28,-5.86 -4.35,-4.1 -6.53,-8.91 -6.53,-14.47 0,-5.74 2.18,-10.51 6.53,-14.36 4.31,-3.84 9.75,-5.73 16.28,-5.73 6.48,0 11.8,1.89 16.06,5.73 4.27,3.77 6.36,8.54 6.36,14.36 0,5.89 -2.05,10.75 -6.23,14.6 -4.27,3.8 -9.67,5.73 -16.19,5.73z" />
</svg>`);

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 = document.createElement('SPAN'), a = document.createElement('A');
		span.className = 'brackets';
		span.dataset.torrentId = torrentId;
		span.style = 'display: inline-flex; flex-flow: row; column-gap: 5px; color: initial;';
		if (edition != null) span.dataset.edition = edition;
		if (isUnknownRelease) span.dataset.isUnknownRelease = true;
		else if (isUnconfirmedRelease) span.dataset.isUnconfirmedRelease = true;
		if (incompleteEdition.test(editionInfo)) span.dataset.editionInfoMissing = true;
		a.textContent = caption;
		a.className = 'toc-lookup';
		a.href = '#';
		a.onclick = callback;
		if (tooltip) setTooltip(a, tooltip);
		span.append(a);
		container.append(span);
	}
	function addClickableIcon(svg, className, clickHandler, style, tooltip, tooltipster = false) {
		if (!svg || typeof clickHandler != 'function') throw 'Invalid argument';
		const span = document.createElement('SPAN');
		span.innerHTML = svg;
		if (className) span.className = className;
		span.style = 'float: right; cursor: pointer;' + (style ? ' ' + style : '');
		span.prologue = function(waitingStatus = false) {
			if (this.disabled) return false; else this.disabled = true;
			if (waitingStatus) {
				if (this.firstElementChild != null) {
					this.dataset.fill = this.firstElementChild.getAttribute('fill');
					this.firstElementChild.setAttribute('fill', 'orange');
				}
				this.style.transition = 'opacity 100ms';
				this.dataset.timer =
					setInterval(elem => { elem.style.opacity = parseInt(elem.style.opacity) < 1 ? 1 : 0.25 }, 500, this);
			}
			return true;
		}.bind(span);
		span.epilogue = function() {
			if ('timer' in this.dataset) {
				clearInterval(parseInt(this.dataset.timer));
				delete this.dataset.timer;
			}
			if ('fill' in this.dataset) {
				this.firstElementChild.setAttribute('fill', this.dataset.fill);
				delete this.dataset.fill;
			}
			[this.style.opacity, this.disabled] = [null, false];
		}.bind(span);
		span.onclick = clickHandler;
		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, releaseId) {
		if (!releaseId || !(elem instanceof HTMLElement)) throw 'Invalid argument';
		return mbApiRequest('annotation', { query: `entity:${releaseId} AND type:release` }).then(function(response) {
			if (response.count <= 0 || (response = response.annotations.filter(annotation =>
					/\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');
			elem.innerHTML = `<a href="https://musicbrainz.org/release/${releaseId}" target="_blank" style="font-style: italic; background: none !important; padding: 0 !important;" title="${response.map(annotation => annotation.text).join('\n')}">by annotation</a>`;
		});
	}

	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() : '';
	const [isUnknownRelease, isUnconfirmedRelease] = ['Unknown Release(s)', 'Unconfirmed Release']
		.map(substr => editionInfo.startsWith(substr));
	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;';
	linkBox.append(' ', container);
	const releaseToHtml = (release, country = 'country', date = 'date') => release ? [
		release[country] && `<span class="country"><img src="http://s3.cuetools.net/flags/${release[country].toLowerCase()}.png" height="9" title="${release.country.toUpperCase()}" onerror="this.replaceWith(this.title)" /></span>`,
		release[date] && `<span class="date">${release[date]}</span>`,
	].filter(Boolean).join(' ') : '';
	const stripSuffix = name => name && name.replace(/\s*\(\d+\)$/, '');
	addLookup('MusicBrainz', function(evt) {
		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);
			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) for (let artist of torrent.group.musicInfo[role]) {
					formData.set(`artist_credit.names.${++artistIndex}.name`, artist.name);
					formData.set(`artist_credit.names.${artistIndex}.artist.name`, artist.name);
					formData.set(`artist_credit.names.${artistIndex}.join_phrase`, '&');
				}
				if (artistIndex >= 0) formData.delete(`artist_credit.names.${artistIndex}.join_phrase`);
			}
			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)
				formData.set('labels.0.name', torrent.torrent.remasterRecordLabel);
			if (torrent.torrent.remasterCatalogueNumber) {
				formData.set('labels.0.catalog_number', 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);
			}
			// formData.set('edit_note', ((formData.get('edit_note') || '') +
			// 	`\nSeeded from 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(' '));
			}
			formData.set('edit_note', ((formData.get('edit_note') || '') +
				`\nSeeded from EAC/XLD ripping ${mbTOCs.length > 1 ? 'logs' : 'log'}`).trimLeft());
		}
		function seedFromDiscogs(formData, cdLengths, idsLookupLimit = GM_getValue('mbid_search_size', 25)) {
			if (!formData || typeof formData != 'object') throw 'Invalid argument';
			let discogsId = prompt('Enter Discogs release ID or URL:\n(note the data preparation process may take some time due to MB API rate limits, especially for compilations)\n\n');
			return discogsId != null ? (discogsId = /(?:\/releases?\/)?(\d+)\b/i.exec(discogsId)) != null
					&& (discogsId = parseInt(discogsId[1])) > 0 ? dcApiRequest('releases/' + discogsId).then(function(release) {
				function seedArtists(root, prefix) {
					if (root && Array.isArray(root)) root.forEach(function(artist, index) {
						const creditPrefix = `${prefix || ''}artist_credit.names.${index}`;
						const name = stripSuffix(artist.name);
						formData.set(`${creditPrefix}.artist.name`, name);
						if (artist.anv) formData.set(`${creditPrefix}.name`, artist.anv);
							else formData.delete(`${creditPrefix}.name`);
						if (artist.join) formData.set(`${creditPrefix}.join_phrase`, [
							[/^\s*(?:Feat(?:uring)?|Ft)\.?\s*$/i, ' feat. '], [/^\s*([\,\;])\s*$/, '$1 '],
							[/^\s*([\&\+\/\x\×]|vs\.|w\/|\w+)\s*$/i, (m, join) => ' ' + join.toLowerCase() + ' '],
							[/^\s*(?:,\s*(?:and|&|with))\s*$/i, ', $1 '],
						].reduce((phrase, subst) => phrase.replace(...subst), artist.join));
							else formData.delete(`${creditPrefix}.join_phrase`);
						if (!(artist.id in lookupIndexes.artist)) lookupIndexes.artist[artist.id] = {
							name: 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 freqAnalysis(string) {
					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 getMediaLayout(trackParser) {
					if (!(trackParser instanceof RegExp)) throw 'Invalid argument';
					let lastMediumId, heading, media = [ ];
					(function addTracks(root, titles) {
						if (Array.isArray(root)) for (let track of root) switch (track.type_) {
							case 'track': {
								const parsedTrack = trackParser.exec(track.position.trim());
								let [mediumFormat, mediumId, trackPosition] = parsedTrack != null ?
									parsedTrack.slice(1) : [undefined, undefined, track.position.trim()];
								if ((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;
								}
								media[media.length - 1].tracks.push({
									number: trackPosition,
									heading: heading,
									titles: titles,
									name: track.title,
									length: track.duration,
									artists: track.artists,
									extraartists: track.extraartists,
								});
								break;
							}
							case 'index':
								addTracks(track.sub_tracks, (titles || [ ]).concat(track.title));
								break;
							case 'heading':
								heading = track.title != '-' && track.title || undefined;
								break;
						}
					})(release.tracklist);
					for (let medium of media) if (medium.tracks.every((track, ndx, tracks) => track.heading == tracks[0].heading)) {
						medium.name = medium.tracks[0].heading;
						medium.tracks.forEach(track => { track.heading = undefined });
					}
					return media;
				}
				function layoutMatch(media) {
					if (!cdLengths) return undefined;
					if ((media = media.filter(medium => [
						'CD', 'Copy Control CD', 'Data CD', 'DTS CD', 'Enhanced CD', 'HDCD',
						'CD-R', '8cm CD', 'Blu-spec CD', 'SHM-CD', 'HQCD', 'CD+G', '8cm CD+G',
					].includes(medium.format))).length != cdLengths.length) return false;
					return (media.every((medium, mediumIndex) => medium.tracks.length == cdLengths[mediumIndex]));
				}

				const stripSuffix = name => name && name.replace(/ \(\d+\)$/, '');
				const literals = { }, lookupIndexes = { artist: { }, label: { } };
				formData.set('name', release.title);
				freqAnalysis(release.title);
				let released = new Date(release.released), media;
				if (isNaN(released)) released = release.year;
				(release.country ? {
					'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'],
					'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'],
					// 'Africa': ['??'], 'South West Africa': ['??'],
					// 'Central America': ['??'], 'North & South America': ['??'],
					// 'Asia': ['??'], 'South East Asia': ['??'], Middle East': ['??'], 'Gulf Cooperation Council': ['??'],
					// 'South Pacific': ['??'],
					// 'Dutch East Indies': ['??'], 'Gaza Strip': ['??'], 'Dahomey': ['??'], 'Indochina': ['??'],
					// 'Abkhazia': ['??'], 'Belgian Congo': ['??'], 'Bohemia': ['??'], 'Kosovo': ['??'],
					// 'Netherlands Antilles': ['??'], 'Ottoman Empire': ['??'], 'Rhodesia': ['??'],
					// 'Russia & CIS': ['??'], 'Southern Rhodesia': ['??'], 'Upper Volta': ['??'], 'West Bank': ['??'],
					// 'Zaire': ['??'], 'Zanzibar': ['??'],
				}[release.country] || [release.country] : [undefined]).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';
					else if (descriptors.has('HDCD')) defaultFormat = 'HDCD';
					else if (descriptors.has('CD+G')) defaultFormat = 'CD+G';
				}
				if (release.labels) release.labels.forEach(function(label, index) {
					if (label.name) {
						const prefix = 'labels.' + index;
						formData.set(prefix + '.name', stripSuffix(label.name));
						if (!(label.id in lookupIndexes.label)) lookupIndexes.label[label.id] = {
							name: label.name.replace(/(?:\s+\b(?:Record(?:ing)?s)\b|,?\s+(?:Ltd|Inc|Co)\.?)+$/i, ''),
							prefixes: [prefix],
						}; else lookupIndexes.label[label.id].prefixes.push(prefix);
					}
					if (label.catno) formData.set(`labels.${index}.catalog_number`,
						label.catno.toLowerCase() == 'none' ? '[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;
				if (!cdLengths || !layoutMatch(media = getMediaLayout(/^()?()?(\S+)$/)))
					media = getMediaLayout(/^([A-Z]{2,})? ?(\d+)?[\-\.](\S+)$/);
				if (!cdLengths || layoutMatch(media) || confirm('Tracks seem not mapped correctly to media (' +
						media.map(medium => medium.tracks.length).join('+') + '), attach tracks with this layout anyway?'))
					media.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);
								freqAnalysis(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');
				if (descriptors.has('Promo') && formData.get('status') != 'bootleg') formData.set('status', 'promotion');
				if ((descriptors = dcFmtFilters.reduce((arr, filter) => arr.filter(filter),
					Array.from(descriptors.values()))).length > 0) formData.set('comment', descriptors.join(', '));
				if (release.notes) formData.set('annotation', release.notes);
				let urlRelIndex = -1;
				addUrlRef('https://www.discogs.com/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 => mbApiRequest(entity, {
					query: '"' + lookupIndexes[entity][discogsId].name + '"',
					limit: idsLookupLimit,
				}).then(function(results) {
					const idXtractor = new RegExp(`\\/${entity}s?\\/(\\d+)\\b`, 'i');
					return Promise.all(results[entity + 's'].map(result => mbApiRequest(entity + '/' + result.id, { inc: 'url-rels' })
							.then(release => release.relations.some(relation => relation.type == 'discogs'
								&& (relation = idXtractor.exec(relation.url.resource)) != null
									&& parseInt(relation[1]) == parseInt(discogsId)) ? release.id : null, reason => null)))
						.then(mbids => mbids.find(Boolean) || null);
				}).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]);
						});
					});
					return formData;
				}) : formData;
			}) : Promise.reject('Invalid input') : Promise.reject('Cancelled');
		}
		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', 'https://musicbrainz.org/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);
		}
		const editNoteFromSession = session => ((session ? GM_getValue('mb_submit_log', false) ? session : (function() {
			const tocEntries = getTocEntries(session), digests = getTrackDetails(session);
			let editNote = `TOC derived from EAC/XLD ripping log

Media 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);
				editNote += `\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))}
			}
			return editNote;
		})() : header || '') + '\n\nSubmitted by ' + scriptSignature).trimLeft();

		const target = evt.currentTarget;
		console.assert(target instanceof HTMLElement);
		if (target.disabled) return false;
		const torrentId = parseInt(target.parentNode.dataset.torrentId);
		console.assert(torrentId > 0);
		const getMbTOCs = () => lookupByToc(torrentId, tocEntries => Promise.resolve(tocEntriesToMbTOC(tocEntries)));
		const attachToReleaseIcon = (clickHandler, style, tooltip, tooltipster) =>
			addClickableIcon('<svg version="1.1" height="0.9em" viewBox="0 0 53.61 53.61"><path style="fill:none; stroke:#14A085; stroke-width:4; stroke-linecap:round; stroke-miterlimit:10;" d="M31.406,18.422L14.037,35.792c-1.82,1.82-1.82,4.797,0,6.617l0,0c1.82,1.82,4.797,1.82,6.617,0l25.64-25.64c3.412-3.412,2.998-8.581-0.414-11.993l0,0c-3.412-3.412-8.581-3.825-11.993-0.414L6.593,31.656c-4.549,4.549-4.549,11.993,0,16.542l0,0c4.549,4.549,11.993,4.549,16.542,0l27.295-27.295"/></svg>',
				'attach-toc', clickHandler, style, tooltip, tooltipster);
		const addNewReleaseIcon = (clickHandler, style, tooltip, tooltipster) =>
			addClickableIcon('<svg version="1.1" height="0.9em" fill="#0a0" viewBox="0 0 33.43 33.43"><path d="M0 21.01l0 -8.59c0,-0.37 0.3,-0.67 0.67,-0.67l11.08 0 0 -11.08c0,-0.37 0.3,-0.67 0.67,-0.67l8.59 0c0.37,0 0.67,0.3 0.67,0.67l0 11.08 11.08 0c0.37,0 0.67,0.3 0.67,0.67l0 8.59c0,0.37 -0.3,0.67 -0.67,0.67l-11.08 0 0 11.08c0,0.37 -0.3,0.67 -0.67,0.67l-8.59 0c-0.37,0 -0.67,-0.3 -0.67,-0.67l0 -11.08 -11.08 0c-0.37,0 -0.67,-0.3 -0.67,-0.67z"/></svg>',
				'new-mb-release', clickHandler, style, tooltip, tooltipster);
		const baseUrl = 'https://musicbrainz.org/cdtoc/', 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', 'Sampler'/*, 'Maxi-Single'*/].includes(description),
		];
		const scriptSignature = 'Edition lookup by CD TOC browser script (https://greasyfork.org/scripts/459083)';
		if (evt.altKey) { // alternate lookup by CDDB ID
			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('https://musicbrainz.org/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 ('ids' in target.dataset) for (let id of JSON.parse(target.dataset.ids).reverse())
				GM_openInTab('https://musicbrainz.org/release/' + id, false);
			// GM_openInTab(baseUrl + (evt.shiftKey ? 'attach?toc=' + JSON.parse(target.dataset.toc).join(' ')
			// 	: target.dataset.discId), false);
		} else {
			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'].join('+') };
				if (!mbDiscID || allowTOCLookup) params.toc = mbTOC.join('+');
				if (anyMedia) params['media-format'] = 'all';
				return mbApiRequest('discid/' + (mbDiscID || '-'), params).then(function(result) {
					if (!('releases' in result) && !/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i.test(result.id))
						return Promise.reject('MusicBrainz: no matches');
					const releases = result.releases || (['id', 'title'].every(key => key in result) ? [result] : null);
					if (!Array.isArray(releases) || releases.length <= 0) return Promise.reject('MusicBrainz: no matches');
					console.log('MusicBrainz lookup by discId/TOC successfull:', mbDiscID, '/', params, 'releases:', releases);
					if (result.id) console.assert(result.id == mbDiscID);
					return { mbDiscID: mbDiscID, mbTOC: mbTOC, attached: Boolean(result.id), releases: releases };
				});
			}

			[target.disabled, target.textContent, target.style.color] = [true, 'Looking up...', null];
			lookupByToc(torrentId, (tocEntries, discNdx, totalDiscs) =>
					mbLookupByDiscID(tocEntriesToMbTOC(tocEntries), !evt.ctrlKey)).then(function(results) {
				function attachByHand(mbTOCs) {
					if (!Array.isArray(mbTOCs)) throw 'Invalid argument';
					const url = new URL(baseUrl + 'attach');
					for (let discNumber = mbTOCs.length; discNumber > 0; --discNumber) {
						url.searchParams.set('toc', mbTOCs[discNumber - 1].join(' '));
						GM_openInTab(url.href, discNumber > 1);
					}
				}

				const mbAttachMode = Number(GM_getValue('mb_attach_toc', 2));
				if (mbAttachMode > 0) target.parentNode.append(attachToReleaseIcon(function(evt) {
					getMbTOCs().then(attachByHand, alert);
				}, undefined, 'Attach this CD TOC by hand to release not shown in lookup results (user account required)', true));
				const mbCreateNew = Number(GM_getValue('mb_create_release', true));
				if (mbCreateNew) target.parentNode.append(addNewReleaseIcon(function(evt) {
					if (!evt.currentTarget.prologue(true)) return;
					const target = evt.currentTarget, formData = new URLSearchParams;
					getMbTOCs().then(mbTOCs => queryAjaxAPICached('torrent', { id: torrentId })
							.then(torrent => seedFromTorrent(formData, torrent), alert).then(function() {
						seedFromTOCs(formData, mbTOCs);
						return (evt.ctrlKey ? seedFromDiscogs(formData, mbTOCs.map(mbTOC => mbTOC[1]))
							: Promise.resolve(formData)).then(seedNewRelease);
					})).catch(reason => { if (reason != 'Cancelled') alert(reason) }).then(target.epilogue);
				}, undefined, 'Create new MusicBrainz release based on this CD TOC (user account required)\nUse Ctrl to seed from existing Discogs release', true));
				if (results.length <= 0 || results[0] == null) {
					if (!evt.ctrlKey) target.dataset.haveResponse = true;
					return Promise.reject('No matches');
				}
				const attached = results[0].attached;
				let caption = `${results[0].releases.length} ${attached ? 'exact' : ' fuzzy'} match`;
				if (results[0].releases.length > 1) caption += 'es';
				target.textContent = caption;
				target.style.color = '#0a0';
				if (Boolean(target.dataset.haveResponse) || GM_getValue('auto_open_tab', true)) {
					if (attached && results[0].releases.length > 0)
						GM_openInTab(baseUrl + (evt.shiftKey ? 'attach?toc=' + results[0].mbTOC.join(' ') : results[0].mbDiscID), true);
					// else if (results[0].releases.length <= 1) for (let id of results[0].releases.map(release => release.id).reverse())
					// 	GM_openInTab('https://musicbrainz.org/release/' + id, true);
				}
				target.dataset.ids = JSON.stringify(results[0].releases.map(release => release.id));
				target.dataset.discId = results[0].mbDiscID;
				target.dataset.toc = JSON.stringify(results[0].mbTOC);
				target.dataset.haveResponse = true;
				if (!('edition' in target.parentNode.dataset) || Boolean(target.parentNode.dataset.haveQuery)) return;
				const totalDiscs = results.length;
				const mediaCD = media => !media.format || /\b(?:H[DQ])?CD\b/.test(media.format);
				const releaseFilter = release => !release.media || release.media.filter(mediaCD).length == totalDiscs;
				if ((results = results[0].releases.filter(releaseFilter)).length <= 0) return;
				target.parentNode.dataset.haveQuery = true;
				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 [isUnknownRelease, isUnconfirmedRelease] = ['isUnknownRelease', 'isUnconfirmedRelease']
						.map(prop => Boolean(target.parentNode.dataset[prop]));
					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) : [ ];
					if ((editableHosts.includes(document.domain) || ajaxApiKey) && !isCompleteInfo && attached) {
						const filteredResults = torrent.torrent.remasterYear > 0 ? results.filter(release => !release.date
							|| getReleaseYear(release.date) == torrent.torrent.remasterYear) : results;
						const releaseYear = filteredResults.reduce((year, release) =>
							year > 0 ? year : getReleaseYear(release.date), undefined);
						if (releaseYear > 0 && filteredResults.length > 0 && filteredResults.length < (isUnknownRelease ? 2 : 4)
								&& !filteredResults.some(release1 => filteredResults.some(release2 =>
									getReleaseYear(release2.date) != getReleaseYear(release1.date)))
							 	&& filteredResults.every((release, ndx, arr) =>
									release['release-group'].id == arr[0]['release-group'].id)) {
							const a = document.createElement('A');
							a.className = 'update-edition';
							a.href = '#';
							a.textContent = '(set)';
							a.style.fontWeight = filteredResults.length < 2 ? 'bold' : 300;
							a.dataset.releaseYear = releaseYear;
							const editionInfo = Array.prototype.concat.apply([ ], filteredResults.map(labelInfoMapper));
							if (editionInfo.length > 0) a.dataset.editionInfo = JSON.stringify(editionInfo);
							const barcodes = filteredResults.map(release => release.barcode).filter(Boolean);
							if (barcodes.length > 0) a.dataset.barcodes = JSON.stringify(barcodes);
							if (filteredResults.length < 2 && filteredResults[0].disambiguation)
								a.dataset.editionTitle = filteredResults[0].disambiguation;
							if (filteredResults.length < 2 && !torrent.torrent.description.includes(filteredResults[0].id)) {
								a.dataset.url = 'https://musicbrainz.org/release/' + filteredResults[0].id;
								a.dataset.description = torrent.torrent.description.trim();
							}
							setTooltip(a, 'Update edition info from matched release(s)\n\n' + filteredResults.map(function(release) {
								let title = getReleaseYear(release.date);
								title = (title > 0 ? title.toString() : '') + (' ' + release['label-info'].map(labelInfo => [
									labelInfo.label && labelInfo.label.name,
									labelInfo['catalog-number'],
								].filter(Boolean).join(' - ')).concat(release.barcode).filter(Boolean).join(' / ')).trimRight();
								return title;
							}).join('\n'));
							a.onclick = updateEdition;
							if (isUnknownRelease || filteredResults.length > 1) a.dataset.confirm = true;
							target.parentNode.append(a);
						}
					}
					// add inpage search results
					const [thead, table, tbody] = ['DIV', 'TABLE', 'TBODY'].map(Document.prototype.createElement.bind(document));
					thead.style = 'margin-bottom: 5pt;';
					thead.innerHTML = `<b>Applicable MusicBrainz matches</b> (${attached ? 'exact' : 'fuzzy'})`;
					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;
					results.forEach(function(release, index) {
						const [tr, artist, title, _release, editionInfo, barcode, groupSize, releasesWithId] =
							['TR', 'TD', 'TD', 'TD', 'TD', 'TD', 'TD', 'TD'].map(Document.prototype.createElement.bind(document));
						tr.className = 'musicbrainz-release';
						tr.style = 'word-wrap: break-word; transition: color 200ms ease-in-out;';
						tr.dataset.url = 'https://musicbrainz.org/release/' + release.id;
						[_release, barcode].forEach(elem => { elem.style.whiteSpace = 'nowrap' });
						[groupSize, releasesWithId].forEach(elem => { elem.style.textAlign = 'right' });
						artist.textContent = release['artist-credit'].map(artist => artist.name).join(' & ');
						title.innerHTML = `<a href="${tr.dataset.url}" target="_blank" style="background: none !important; padding: 0 !important;">${release.title}</a>`;
						// attach CD TOC
						if (mbAttachMode > 0 && !attached) {
							function attachToMB(unattended = false, skipPoll = false) {
								getMbTOCs().then(function(mbTOCs) {
									const url = new URL(baseUrl + 'attach');
									url.searchParams.set('filter-release.query', release.id);
									url.searchParams.setTOC = function(index = 0) { this.set('toc', mbTOCs[index].join(' ')) };
									if (!unattended && mbAttachMode > 1) return 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} TOCs are going to be attached to release id ${release.id}
${mediums.length > 1 ? '\nMedia titles:\n' + mediums.map(medium => '\t' + medium.title).join('\n') : ''}
Voting condition: ${!skipPoll && mbAttachMode < 3 ? 'enable one week poll' : 'without poll'}
${GM_getValue('mb_submit_log', false) ? 'Full .LOG file(s) will be posted as edit note for TOC lineage\n' : ''}
Make certain uploaded CD and MB release are identical edition
Make certain attached log(s) have read offset properly set
MusicBrainz account required`)) return false;
										const postData = new FormData;
										if (!skipPoll && mbAttachMode < 3) postData.set('confirm.make_votable', 1);
										return getSessions(torrent.torrent.id).then(sessions => Promise.all(mbTOCs.map(function(mbTOC, index) {
											url.searchParams.setTOC(index);
											url.searchParams.set('medium', mediums[index].id);
											postData.set('confirm.edit_note', editNoteFromSession(sessions[index]));
											return globalXHR(url, { responseType: null }, postData);
										}))).then(responses => (GM_openInTab(tr.dataset.url + '/discids', false), true));
									}).catch(reason => { alert(reason + '\n\nAttach by hand'); attachByHand(mbTOCs) });
									else attachByHand(mbTOCs);
								}, alert);
							}
							title.prepend(attachToReleaseIcon(function(evt) {
								evt.stopPropagation();
								attachToMB(evt.altKey, evt.ctrlKey);
							}, 'margin-left: 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`));
						}
						// create new edition
						if (mbCreateNew) title.prepend(addNewReleaseIcon(function(evt) {
							if (evt.currentTarget.prologue(true)) evt.stopPropagation(); else return;
							const target = evt.currentTarget;
							getMbTOCs().then(function(mbTOCs) {
								const formData = new URLSearchParams;
								formData.set('release_group', release['release-group'].id);
								seedFromTorrent(formData, torrent);
								seedFromTOCs(formData, mbTOCs);
								return (evt.ctrlKey ? seedFromDiscogs(formData, mbTOCs.map(mbTOC => mbTOC[1]))
									: Promise.resolve(formData)).then(seedNewRelease);
							}).catch(reason => { if (reason != 'Cancelled') alert(reason) }).then(target.epilogue);
						}, 'margin-left: 4pt;', 'Create new edition based on this CD TOC in same release group\nUse Ctrl to seed from existing Discogs release\nMusicBrainz account required'));
						appendDisambiguation(title, release.disambiguation);
						_release.innerHTML = releaseToHtml(release);
						if ('label-info' in release) editionInfo.innerHTML = release['label-info'].map(labelInfo => [
							labelInfo.label && labelInfo.label.name && `<span class="label">${labelInfo.label.name}</span>`,
							labelInfo['catalog-number'] && `<span class="catno" style="white-space: nowrap;">${labelInfo['catalog-number']}</span>`,
						].filter(Boolean).join(' ')).filter(Boolean).join('<br>');
						if (editionInfo.childElementCount <= 0) mbFindEditionInfoInAnnotation(editionInfo, release.id);
						if (release.barcode) barcode.textContent = release.barcode;
						mbApiRequest('release-group/' + release['release-group'].id, { inc: ['releases', 'media', 'discids'].join('+') }).then(function(releaseGroup) {
							const releases = releaseGroup.releases.filter(releaseFilter);
							if ('release-group' in release) {
								const a = document.createElement('A');
								a.href = 'https://musicbrainz.org/release-group/' + release['release-group'].id;
								a.target = '_blank';
								a.style = 'padding: 0 !important; background: none !important;';
								a.textContent = releases.length;
								if (releases.length == 1) a.style.color = '#0a0';
								groupSize.append(a);
							} else {
								groupSize.textContent = releases.length;
								if (releases.length == 1) groupSize.style.color = '#0a0';
							}
							groupSize.title = 'Same media count in release group';
							const haveDiscId = releases.filter(release => (release = release.media.filter(media =>
								mediaCD(media))).length > 0 && release[0].discs && release[0].discs.length > 0);
							releasesWithId.textContent = haveDiscId.length;
							releasesWithId.title = 'Same media count with known TOC in release group';
						}, function(reason) {
							if (releasesWithId.parentNode != null) releasesWithId.remove();
							groupSize.colSpan = 2;
							groupSize.innerHTML = svgFail('1em');
							groupSize.title = reason;
						});
						if (release['release-group'])
							tr.dataset.groupUrl = 'https://musicbrainz.org/release-group/' + release['release-group'].id;
						const releaseYear = getReleaseYear(release.date);
						if ((editableHosts.includes(document.domain) || ajaxApiKey) && !isCompleteInfo && releaseYear > 0
								&& (!isUnknownRelease || attached)) {
							tr.dataset.releaseYear = releaseYear;
							const editionInfo = labelInfoMapper(release);
							if (editionInfo.length > 0) tr.dataset.editionInfo = JSON.stringify(editionInfo);
							if (release.barcode) tr.dataset.barcodes = JSON.stringify([ release.barcode ]);
							if (release.disambiguation) tr.dataset.editionTitle = release.disambiguation;
							if (!torrent.torrent.description.includes(release.id))
								tr.dataset.description = torrent.torrent.description.trim();
							applyOnClick(tr);
						} else openOnClick(tr);
						tr.append(artist, title, _release, editionInfo, barcode, groupSize, releasesWithId);
						['artist', 'title', 'release-event', '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] =
									['TR', 'TD', 'TD', 'TD', 'TD', 'TD', 'TD', 'TD'].map(Document.prototype.createElement.bind(document));
								trDc.className = 'discogs-release';
								trDc.style = 'background-color: #8882; word-wrap: break-word; transition: color 200ms ease-in-out;';
								trDc.dataset.url = 'https://www.discogs.com/release/' + release.id;
								[_release, barcode].forEach(elem => { elem.style.whiteSpace = 'nowrap' });
								[groupSize, icon].forEach(elem => { elem.style.textAlign = 'right' });
								artist.textContent = release.artists.map(artist =>
									(artist.anv || stripSuffix(artist.name)) + ' ' + artist.join + ' ').join('')
										.trimRight().replace(/\s+([,])/g, '$1').replace(/\s+/g, ' ');
								title.innerHTML = `<a href="${trDc.dataset.url}" target="_blank" style="background: none !important; padding: 0 !important;">${release.title}</a>`;
								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]))) {
									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(', '));
								_release.innerHTML = [
									release.country && `<span class="country">${release.country}</span>`, // `<span class="country"><img src="http://s3.cuetools.net/flags/${release.country.toLowerCase()}.png" height="9" title="${release.country}" onerror="this.replaceWith(this.title)" /></span>`,
									release.released && `<span class="date">${release.released}</span>`,
								].filter(Boolean).join(' ');
								if (Array.isArray(release.labels)) editionInfo.innerHTML = release.labels.map(label => [
									label.name && `<span class="label">${stripSuffix(label.name)}</span>`,
									label.catno && `<span class="catno" style="white-space: nowrap;">${label.catno}</span>`,
								].filter(Boolean).join(' ')).filter(Boolean).join('<br>');
								let barCode = release.identifiers && release.identifiers.find(id => id.type == 'Barcode');
								if (barCode) barCode = barCode.value.replace(/\D+/g, '');
								if (barCode) barcode.textContent = barCode;
								icon.innerHTML = GM_getResourceText('dc_icon');
								icon.firstElementChild.style = '';
								icon.firstElementChild.removeAttribute('width');
								icon.firstElementChild.setAttribute('height', '1em');
								svgSetTitle(icon.firstElementChild, release.id);
								if (release.master_id) {
									const masterUrl = new URL('https://www.discogs.com/master/' + release.master_id);
									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 = 'padding: 0 !important; background: none !important;';
										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('1em');
										groupSize.title = reason;
									});
								} else {
									groupSize.textContent = '-';
									groupSize.style.color = '#0a0';
									groupSize.title = 'Without master release';
								}
								const releaseYear = getReleaseYear(release.released);
								if ((editableHosts.includes(document.domain) || ajaxApiKey) && !isCompleteInfo && releaseYear > 0
										&& (!isUnknownRelease || attached)) {
									trDc.dataset.releaseYear = releaseYear;
									const editionInfo = Array.isArray(release.labels) ? release.labels.map(label => ({
										label: stripSuffix(label.name),
										catNo: label.catno,
									})).filter(label => label.label || label.catNo) : [ ];
									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);
								} else openOnClick(trDc);
								trDc.append(artist, title, _release, editionInfo, barcode, groupSize, icon);
								['artist', 'title', 'release-event', '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);
				}, alert);
			}).catch(function(reason) {
				target.textContent = reason;
				target.style.color = 'red';
			}).then(() => { target.disabled = false });
		}
		return 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 false;
			for (let entry of JSON.parse(target.dataset.entries).reverse()) GM_openInTab(entryUrl(entry), false);
		} else if (!target.disabled) {
			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 });
		}
		return false;
	}, 'Lookup edition on GnuDb (CDDB)');
	addLookup('CTDB', function(evt) {
		const target = evt.currentTarget;
		console.assert(target instanceof HTMLElement);
		if (target.disabled) return false; 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 false;
		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, mismatching 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 = 'Looking up...';
		target.style.color = 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 = '#0a0';
			queryAjaxAPICached('torrent', { id: torrentId }).then(function(response) {
				const isCompleteInfo = response.torrent.remasterYear > 0
					&& Boolean(response.torrent.remasterRecordLabel)
					&& Boolean(response.torrent.remasterCatalogueNumber);
				const [isUnknownRelease, isUnconfirmedRelease] = ['isUnknownRelease', 'isUnconfirmedRelease']
					.map(prop => Boolean(target.parentNode.dataset[prop]));
				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('1em', '#' + (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('1em') : svgQuestionmark('1em');
					confidenceBox.className = 'ctdb-not-verified';
					setTooltip(confidenceBox, `Could not verify checksums (${reason})`);
				}).then(() => { target.parentNode.append(confidenceBox) });
				confidence = confidence.then(confidence =>
						isUnknownRelease && 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: stripSuffix(labelInfo.name), catNo: labelInfo.catno }))
						.filter(labelInfo => labelInfo.label || labelInfo.catNo);
				if ((editableHosts.includes(document.domain) || ajaxApiKey) && !isCompleteInfo && !Boolean(method.fuzzy)) {
					const filteredResults = response.torrent.remasterYear > 0 ? results.filter(metadata =>
						isNaN(metadata = _getReleaseYear(metadata)) || metadata == response.torrent.remasterYear) : results;
					const releaseYear = filteredResults.reduce((year, metadata) => isNaN(year) ? NaN :
						(metadata = _getReleaseYear(metadata)) > 0 && (year <= 0 || metadata == year) ? metadata : NaN, -Infinity);
					(releaseYear > 0 && filteredResults.length > 0 && filteredResults.length < (isUnknownRelease ? 2 : 4)
							&& (!isUnknownRelease || method.metadata != 'extensive' || filteredResults.every(metadata => !(metadata.relevance < 100)))
							&& filteredResults.every(m1 => m1.release.every(r1 => filteredResults.every(m2 =>
								m2.release.every(r2 => getReleaseYear(r2.date) == getReleaseYear(r1.date))))) ?
					 		confidence : Promise.reject('Not applicable')).then(function(confidence) {
						const a = document.createElement('A');
						a.className = 'update-edition';
						a.href = '#';
						a.textContent = '(set)';
						if (filteredResults.length > 1 || filteredResults.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([ ], filteredResults.map(labelInfoMapper));
						if (editionInfo.length > 0) a.dataset.editionInfo = JSON.stringify(editionInfo);
						const barcodes = filteredResults.map(metadata => metadata.barcode).filter(Boolean);
						if (barcodes.length > 0) a.dataset.barcodes = JSON.stringify(barcodes);
						if (filteredResults.length < 2 && !response.torrent.description.includes(filteredResults[0].id)) {
							a.dataset.description = response.torrent.description.trim();
							a.dataset.url = {
								musicbrainz: 'https://musicbrainz.org/release/' + results[0].id,
								discogs: 'https://www.discogs.com/release/' + results[0].id,
							}[filteredResults[0].source];
						}
						setTooltip(a, 'Update edition info from matched release(s)\n\n' + filteredResults.map(function(metadata) {
							let title = { discogs: 'Discogs', musicbrainz: 'MusicBrainz' }[metadata.source];
							const releaseYear = _getReleaseYear(metadata);
							if (releaseYear > 0) title += ' ' + releaseYear.toString();
							title += (' ' + metadata.labelInfo.map(labelInfo => [stripSuffix(labelInfo.name), labelInfo.catno]
								.filter(Boolean).join(' - ')).concat(metadata.barcode).filter(Boolean).join(' / ')).trimRight();
							if (metadata.relevance >= 0) title += ` (${metadata.relevance}%)`;
							return title.trim();
						}).join('\n'));
						a.onclick = updateEdition;
						target.parentNode.append(a);
					});
				}
				const [thead, table, tbody] = ['DIV', 'TABLE', 'TBODY'].map(Document.prototype.createElement.bind(document));
				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;
				results.forEach(function(metadata) {
					const [tr, source, artist, title, release, editionInfo, barcode, relevance] =
						['TR', 'TD', 'TD', 'TD', 'TD', 'TD', 'TD', 'TD', 'TD'].map(Document.prototype.createElement.bind(document));
					tr.className = 'ctdb-metadata';
					tr.style = 'word-wrap: break-word; transition: color 200ms ease-in-out;';
					tr.dataset.url = {
						musicbrainz: 'https://musicbrainz.org/release/' + metadata.id,
						discogs: 'https://www.discogs.com/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.artist;
					title.innerHTML = `<a href="${tr.dataset.url}" target="_blank" style="background: none !important; padding: 0 !important;">${metadata.album}</a>`;
					release.innerHTML = metadata.release.map(release => releaseToHtml(release)).filter(Boolean).join('<br>');
					editionInfo.innerHTML = metadata.labelInfo.map(labelInfo => [
						labelInfo.name && `<span class="label">${stripSuffix(labelInfo.name)}</span>`,
						labelInfo.catno && `<span class="catno" style="white-space: nowrap;">${labelInfo.catno}</span>`,
					].filter(Boolean).join(' ')).filter(Boolean).join('<br>');
					if (editionInfo.childElementCount <= 0 && metadata.source == 'musicbrainz')
						mbFindEditionInfoInAnnotation(editionInfo, metadata.id);
					if (metadata.barcode) barcode.textContent = metadata.barcode;
					if (metadata.relevance >= 0) {
						relevance.textContent = metadata.relevance + '%';
						relevance.title = 'Relevance';
					}
					const releaseYear = _getReleaseYear(metadata);
					((editableHosts.includes(document.domain) || ajaxApiKey)
					 	&& !isCompleteInfo && !Boolean(method.fuzzy) && releaseYear > 0
						&& (!isUnknownRelease || method.metadata != 'extensive' || !(metadata.relevance < 100)) ?
							confidence : Promise.reject('Not applicable')).then(function(confidence) {
						tr.dataset.releaseYear = releaseYear;
						const editionInfo = labelInfoMapper(metadata);
						if (editionInfo.length > 0) tr.dataset.editionInfo = JSON.stringify(editionInfo);
						if (metadata.barcode) tr.dataset.barcodes = JSON.stringify([ metadata.barcode ]);
						if (!response.torrent.description.includes(metadata.id))
							tr.dataset.description = response.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);
			}, console.warn);
		}, function(reason) {
			target.textContent = reason;
			target.style.color = 'red';
		}).then(() => { target.parentNode.dataset.haveQuery = true });
		return false;
	}, '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);
}

}