Greasy Fork

Greasy Fork is available in English.

[GMT] Edition lookup by CD TOC

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

当前为 2023-02-13 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         [GMT] Edition lookup by CD TOC
// @namespace    http://greasyfork.icu/users/321857-anakunda
// @version      1.15.4
// @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
// @grant        GM_xmlhttpRequest
// @grant        GM_openInTab
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_getResourceText
// @grant        GM_getResourceURL
// @connect      musicbrainz.org
// @connect      db.cuetools.net
// @connect      db.cue.tools
// @connect      gnudb.org
// @author       Anakunda
// @license      GPL-3.0-or-later
// @resource     mb_icon https://upload.wikimedia.org/wikipedia/commons/9/9e/MusicBrainz_Logo_%282016%29.svg
// @resource     mb_icon_simplified 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
// @resource     invalid_icon https://no.domain.org/images/favicon.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;
const msf = 75, preGap = 2 * msf;
let mbLastRequest = null;

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

const rxRR = /^(?: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 msfTime = '(?:(\\d+):)?(\\d+):(\\d+)[\\.\\:](\\d+)';
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+'),
];

function tocEntriesMapper(tocEntry, trackNdx) {
	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;
	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]),
	};
}

function getLogs(torrentId) {
	if (!(torrentId > 0)) throw 'Invalid argument';
	if (requestsCache.has(torrentId)) return requestsCache.get(torrentId);
	const request = localXHR('/torrents.php?' + new URLSearchParams({ action: 'loglist', torrentid: torrentId })).then(document =>
		Array.from(document.body.querySelectorAll(':scope > blockquote > pre:first-child'), function(pre) {
			const rx = /^[\S\s]+(?:\r?\n){2,}(?=(?:Exact Audio Copy V|X Lossless Decoder version |EZ CD Audio Converter )\d+\b)/;
			return rx.test(pre = pre.textContent.trimLeft()) ? pre.replace(rx, '') : pre;
		}).filter(function(logFile) {
			if (!['Exact Audio Copy', 'EAC', 'X Lossless Decoder', 'EZ CD Audio Converter']
					.some(prefix => logFile.startsWith(prefix))) return false;
			const rr = rxRR.exec(logFile);
			if (rr == null) return true;
			// Ditch HTOA logs
			let tocEntries = tocParsers.reduce((m, rx) => m || logFile.match(new RegExp(rx, 'gm')), null);
			if (tocEntries != null) tocEntries = tocEntries.map(tocEntriesMapper); else return true;
			return parseInt(rr[1]) != 0 || parseInt(rr[2]) + 1 != tocEntries[0].startSector;
		}));
	requestsCache.set(torrentId, request);
	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 getLogs(torrentId).then(logfiles => logfiles.length > 0 ? Promise.all(logfiles.map(function(logfile, volumeNdx) {
		const isRangeRip = rxRR.test(logfile);
		let tocEntries = tocParsers.reduce((m, rx) => m || logfile.match(new RegExp(rx, 'gm')), null);
		if (tocEntries != null && tocEntries.length > 0) tocEntries = tocEntries.map(tocEntriesMapper);
			else throw `disc ${volumeNdx + 1} ToC not found`;
		if (getlayoutType(tocEntries) == 1) tocEntries.pop();
		return callback(tocEntries, volumeNdx);
	}).map(results => results.catch(function(reason) {
		console.log('Edition lookup failed for the reason', reason);
		return null;
	}))) : Promise.reject('No valid log files found'));
}

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 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)$|(?:\s+\b(?:Record(?:ing)?s)\b|,?\s+(?:Ltd|Inc|Co)\.?)+$|[\s\-]+/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 => label.replace(/^(?:Not On Label|\[no label\]|No label|None)$/ig, '')).filter(Boolean).join(' / ');
		payload.remaster_catalogue_number = editionInfo.map(label => label.catNo).filter(uniqueValues)
			.map(catNo => catNo.replace(/^(?:None|\[none\])$/ig, '')).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 = 'green';
		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;
	setTooltip(tr, 'Apply edition info from this release');
	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);
	};
}

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 nowrap; row-gap: 10pt;';
		elem.insertAdjacentElement('afterend', container);
	}
	(elem = document.createElement('DIV')).append(...elems);
	container.append(elem);
}

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;
		if (edition != null) span.dataset.edition = edition;
		if (isUnknownRelease) span.dataset.isUnknownRelease = true;
		if (isUnconfirmedRelease) span.dataset.isUnconfirmedRelease = true;
		a.textContent = caption;
		a.className = 'toc-lookup';
		a.href = '#';
		a.onclick = callback;
		if (tooltip) setTooltip(a, tooltip);
		span.append(a);
		linkBox.append(' ', span);
	}

	const torrentId = getTorrentId(tr);
	if (!(torrentId > 0)) continue;
	let edition = /\b(?:edition_(\d+))\b/.exec(tr.className);
	if (edition != null) edition = parseInt(edition[1]);
	for (var isUnconfirmedRelease = tr; isUnconfirmedRelease != null; isUnconfirmedRelease = isUnconfirmedRelease.previousElementSibling)
		if (isUnconfirmedRelease.classList.contains('edition')) break;
	if (isUnconfirmedRelease != null) isUnconfirmedRelease = isUnconfirmedRelease.querySelector('td.edition_info > strong');
	const isUnknownRelease = isUnconfirmedRelease != null
		&& isUnconfirmedRelease.textContent.startsWith('− Unknown Release(s)');
	isUnconfirmedRelease = isUnconfirmedRelease != null
		&& isUnconfirmedRelease.textContent.startsWith('− Unconfirmed Release');
	if ((tr = tr.nextElementSibling) == null || !tr.classList.contains('torrentdetails')) continue;
	const linkBox = tr.querySelector('div.linkbox');
	if (linkBox == null) continue;
	addLookup('MusicBrainz', function(evt) {
		const target = evt.currentTarget;
		console.assert(target instanceof HTMLElement);
		const baseUrl = 'https://musicbrainz.org/cdtoc/';
		if (evt.altKey) {
			target.disabled = true;
			lookupByToc(parseInt(target.parentNode.dataset.torrentId), tocEntries => 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 (!target.disabled) 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 mbQueryAPI(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;
			}
			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 mbLookupByDiscID(mbTOC, allowTOCLookup = true) {
				if (!Array.isArray(mbTOC) || mbTOC.length != mbTOC[1] - mbTOC[0] + 4)
					return Promise.reject('mbLookupByDiscID(…): missing or invalid TOC');
				const mbDiscID = mbComputeDiscID(mbTOC),
							params = { inc: ['artist-credits', 'labels', 'release-groups'].join('+') };
				if (!mbDiscID || allowTOCLookup) params.toc = mbTOC.join('+');
				//params['media-format'] = 'all';
				return mbQueryAPI('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);
					return { mbDiscID: result.id, mbTOC: mbTOC, releases: releases };
				});
			}

			target.disabled = true;
			target.textContent = 'Looking up...';
			target.style.color = null;
			lookupByToc(parseInt(target.parentNode.dataset.torrentId), function(tocEntries) {
				const isHTOA = tocEntries[0].startSector > preGap, mbTOC = [tocEntries[0].trackNumber, tocEntries.length];
				mbTOC.push(preGap + tocEntries[tocEntries.length - 1].endSector + 1);
				Array.prototype.push.apply(mbTOC, tocEntries.map(tocEntry => preGap + tocEntry.startSector));
				return mbLookupByDiscID(mbTOC, !evt.ctrlKey);
			}).then(function(results) {
				if (results.length <= 0 || results[0] == null) {
					if (!evt.ctrlKey) target.dataset.haveResponse = true;
					return Promise.reject('No matches');
				}
				const exactMatch = Boolean(results[0].mbDiscID);
				let caption = `${results[0].releases.length} ${exactMatch ? 'exact' : ' fuzzy'} match`;
				if (results[0].releases.length > 1) caption += 'es';
				target.textContent = caption;
				target.style.color = 'green';
				if (Boolean(target.dataset.haveResponse) || GM_getValue('auto_open_tab', true)) {
					if (results[0].mbDiscID && 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) || !['redacted.ch'].includes(document.domain)
						|| Boolean(target.parentNode.dataset.haveQuery)) return;
				const totalDiscs = results.length;
				const mediaCD = media => !media.format || /\b(?:HD|HQ)?CD\b/.test(media.format);
				const releaseFilter = release => !release.media || release.media.reduce((totalDiscs, media) =>
					totalDiscs + Number(mediaCD(media)), 0) == totalDiscs;
				if ((results = results[0].releases.filter(releaseFilter)).length <= 0) return;
				target.parentNode.dataset.haveQuery = true;
				queryAjaxAPICached('torrent', { id: parseInt(target.parentNode.dataset.torrentId) }).then(function(response) {
					const isCompleteInfo = response.torrent.remasterYear > 0 && response.torrent.remasterRecordLabel
						&& response.torrent.remasterCatalogueNumber;
					const isUnknownRelease = Boolean(target.parentNode.dataset.isUnknownRelease);
					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 (!isCompleteInfo && exactMatch) {
						const filteredResults = response.torrent.remasterYear > 0 ? results.filter(release => !release.date
							|| new Date(release.date).getUTCFullYear() == response.torrent.remasterYear) : results;
						const releaseYear = filteredResults.reduce((year, release) =>
							year || new Date(release.date).getUTCFullYear(), undefined);
						if (releaseYear > 0 && filteredResults.length > 0 && filteredResults.length < (isUnknownRelease ? 2 : 4)
								&& !filteredResults.some(release1 => filteredResults.some(release2 =>
									new Date(release2.date).getUTCFullYear() != new Date(release1.date).getUTCFullYear()))
							 	&& 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 && !response.torrent.description.includes(filteredResults[0].id)) {
								a.dataset.url = 'https://musicbrainz.org/release/' + filteredResults[0].id;
								a.dataset.description = response.torrent.description.trim();
							}
							setTooltip(a, 'Update edition info from matched release(s)\n\n' + filteredResults.map(function(release) {
								let title = new Date(release.date).getUTCFullYear();
								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);
						}
					}
					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> (${exactMatch ? 'exact' : 'fuzzy'})`;
					table.style = 'margin: 0; max-height: 10rem; overflow-y: auto; empty-cells: hide;';
					table.className = 'mb-lookup-results mb-lookup-' + torrentId;
					tbody.dataset.torrentId = torrentId; tbody.dataset.edition = target.parentNode.dataset.edition;
					results.forEach(function(release, index) {
						const [tr, artist, album, _release, editionInfo, barcode, groupSize, releasesWithId] =
							['TR', 'TD', 'TD', 'TD', 'TD', 'TD', 'TD', 'TD'].map(Document.prototype.createElement.bind(document));
						tr.style = 'word-wrap: break-word; transition: color 200ms ease-in-out;';
						[_release, barcode].forEach(elem => { elem.style.whiteSpace = 'nowrap' });
						artist.textContent = release['artist-credit'].map(artist => artist.name).join(' & ');
						album.textContent = release.title;
						if (release.disambiguation) {
							const disambiguation = document.createElement('SPAN');
							disambiguation.style.opacity = 0.6;
							disambiguation.textContent = '(' + release.disambiguation + ')';
							album.append(' ', disambiguation);
						}
						_release.innerHTML = [
							release.country ? `<img src="http://s3.cuetools.net/flags/${release.country.toLowerCase()}.png" height="9" title="${release.country.toUpperCase()}" />` : null,
							release.date,
						].filter(Boolean).join(' ');
						editionInfo.innerHTML = release['label-info'].map(labelInfo => [labelInfo.label && labelInfo.label.name,
							labelInfo['catalog-number']].filter(Boolean).join(' ')).filter(Boolean).join('<br>');
						if (release.barcode) barcode.textContent = release.barcode;
						mbQueryAPI('release-group/' + release['release-group'].id, { inc: ['releases', 'media', 'discids'].join('+') }).then(function(releaseGroup) {
							const releases = releaseGroup.releases.filter(releaseFilter);
							groupSize.textContent = releases.length;
							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';
						});
						tr.dataset.url = 'https://musicbrainz.org/release/' + release.id;
						if (release['release-group'])
							tr.dataset.groupUrl = 'https://musicbrainz.org/release-group/' + release['release-group'].id;
						const releaseYear = new Date(release.date).getUTCFullYear();
						if (!isCompleteInfo && releaseYear && (!isUnknownRelease || exactMatch)) {
							if (releaseYear > 0) 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 (!response.torrent.description.includes(release.id))
								tr.dataset.description = response.torrent.description.trim();
							applyOnClick(tr);
						} else openOnClick(tr);
						tr.append(artist, album, _release, editionInfo, barcode, groupSize, releasesWithId);
						tbody.append(tr);
					});
					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\n(Ctrl + click enforces strict TOC matching)\nUse Alt + click 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 = 'green';
				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));
		}).catch(function(reason) {
			target.textContent = reason;
			target.style.color = 'red';
		}).then(() => { target.disabled = false });
		if (!target.parentNode.dataset.edition || !['redacted.ch'].includes(document.domain)
				|| 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[tocEntries.length - 1].endSector + 1).join(':'));
			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: parseInt(metadata.getAttribute('year')) || undefined,
					discNumber: parseInt(metadata.getAttribute('discnumber')) || undefined,
					discCount: parseInt(metadata.getAttribute('disccount')) || undefined,
					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: (relevance => isNaN(relevance) ? undefined : relevance)
						(parseInt(metadata.getAttribute('relevance'))),
				})),
				entries: Array.from(responseXML.getElementsByTagName('entry'), entry => ({
					confidence: parseInt(entry.getAttribute('confidence')),
					crc32: parseInt(entry.getAttribute('crc32')),
					hasparity: entry.getAttribute('hasparity') || undefined,
					id: parseInt(entry.getAttribute('id')),
					npar: parseInt(entry.getAttribute('npar')),
					stride: parseInt(entry.getAttribute('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 => getLogs(torrentId).then(logfiles => logfiles.length == entries.length ? logfiles.map(function(logfile, volumeNdx) {
				if (rxRR.test(logfile)) 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 (logfile = logfile.match(rx[0]) || logfile.match(rx[1])) && logfile.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 || 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 (scores = {
					matched: getTotal(1),
					partiallyMatched: getTotal(2),
					anyMatched: getTotal(3),
					total: sum(scores.map(score => score[0])),
				}).anyMatched > 0 ? scores : Promise.reject('mismatch');
			}))(results.map(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 },
		];
		(function execMethod(index = 0) {
			return index < methods.length ? ctdbLookup(methods[index]).then(results =>
					Object.assign(results, { method: methods[index] }),
				reason => execMethod(index + 1)) : Promise.reject('No matches');
		})().then(results => queryAjaxAPICached('torrent', { id: torrentId }).then(function(response) {
			const isCompleteInfo = response.torrent.remasterYear > 0 && response.torrent.remasterRecordLabel
				&& response.torrent.remasterCatalogueNumber;
			const isUnknownRelease = Boolean(target.parentNode.dataset.isUnknownRelease);
			const [method, confidence] = [results.method, results.confidence];
			const confidenceBox = document.createElement('SPAN');
			confidence.then(function(confidence) {
				let fill = confidence.matched || confidence.partiallyMatched || confidence.anyMatched;
				fill = Math.round(fill * 0x55 / confidence.total);
				fill = 0x55 * (3 - (confidence.matched > 0) - (confidence.partiallyMatched > 0)) - fill;
				fill = '#' + (fill << 16 | 0xCC00).toString(16).padStart(6, '0');
				confidenceBox.innerHTML = `
<svg height="10px" version="1.1" viewBox="0 0 4120.39 4120.39">
	<circle fill="${fill}" 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>`;
				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' ? `
<svg height="10px" version="1.1" viewBox="0 0 256 256">
	<circle fill="#f00" 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>
` : `
<svg height="10px" version="1.1" viewBox="0 0 256 256">
	<circle fill="#fc0" 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>
`;
				confidenceBox.className = 'ctdb-not-verified';
				setTooltip(confidenceBox, `Could not verify checksums (${reason})`);
			}).then(() => { target.parentNode.append(' ', confidenceBox) });
			const stripSuffix = name => name && name.replace(/\s*\(\d+\)$/, '');
			const getReleaseYear = metadata => (metadata = metadata.release.map(release => new Date(release.date).getUTCFullYear()))
				.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 (!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);
				if (releaseYear > 0 && filteredResults.length > 0 && filteredResults.length < (isUnknownRelease ? 2 : 4)
						&& (method.metadata == 'fast' || filteredResults.every(metadata => !(metadata.relevance < 100)))
						&& filteredResults.every(m1 => m1.release.every(r1 => filteredResults.every(m2 =>
						m2.release.every(r2 => new Date(r2.date).getUTCFullYear() == new Date(r1.date).getUTCFullYear()))))) {
					const a = document.createElement('A');
					a.className = 'update-edition';
					a.href = '#';
					a.textContent = '(set)';
					confidence.then(function(confidence) {
						if (filteredResults.length > 1 || filteredResults[0].relevance < 100 || confidence.matches <= 0)
							return Promise.reject('Weak');
						a.style.fontWeight = 'bold';
					}).catch(function(reason) {
						a.style.fontWeight = 300;
						a.dataset.confirm = true;
					});
					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 => [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;
					(isUnknownRelease ? confidence : Promise.resolve({ partiallyMatched: Infinity }))
						.then(confidence => { if (confidence.partiallyMatched > 0) 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;
			for (let metadata of results) {
				const [tr, source, artist, album, release, editionInfo, barcode, relevance] =
					['TR', 'TD', 'TD', 'TD', 'TD', 'TD', 'TD', 'TD', 'TD'].map(Document.prototype.createElement.bind(document));
				tr.style = 'word-wrap: break-word; transition: color 200ms ease-in-out;';
				[release, barcode, relevance].forEach(elem => { elem.style.whiteSpace = 'nowrap' });
				if (source.innerHTML = GM_getResourceText('invalid_icon')) {
					console.debug('invalid_icon:', source.innerHTML);
				} else {
					console.debug('invalid_icon: empty string');
				}
				if (source.innerHTML = GM_getResourceText({ musicbrainz: 'mb_icon', discogs: 'dc_icon' }[metadata.source])) {
					source.children[0].removeAttribute('width');
					source.children[0].setAttribute('height', 12);
				} else {
					const img = document.createElement('IMG');
					img.src = `http://s3.cuetools.net/icons/${metadata.source}.png`;
					img.height = 12;
					source.append(img);
				}
				artist.textContent = metadata.artist;
				album.textContent = metadata.album;
				release.innerHTML = metadata.release.map(release => [
					release.country ? `<img src="http://s3.cuetools.net/flags/${release.country.toLowerCase()}.png" height="9" title="${release.country.toUpperCase()}" />` : null,
					release.date,
				].filter(Boolean).join(' ')).join('<br>');
				editionInfo.innerHTML = metadata.labelInfo.map(labelInfo =>
					[stripSuffix(labelInfo.name), labelInfo.catno].filter(Boolean).join(' ')).filter(Boolean).join('<br>');
				if (metadata.barcode) barcode.textContent = metadata.barcode;
				if (metadata.relevance >= 0) {
					relevance.textContent = metadata.relevance + '%';
					relevance.title = 'Relevance';
				}
				tr.dataset.url = {
					musicbrainz: 'https://musicbrainz.org/release/' + metadata.id,
					discogs: 'https://www.discogs.com/release/' + metadata.id,
				}[metadata.source];
				const releaseYear = getReleaseYear(metadata);
				if (!isCompleteInfo && releaseYear > 0 && !Boolean(method.fuzzy)
						&& (method.metadata == 'fast' || !isUnknownRelease || !(metadata.relevance < 100))) {
					if (releaseYear > 0) 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);
				} else openOnClick(tr);
				tr.append(source, artist, album, release, editionInfo, barcode, relevance);
				tbody.append(tr);
			}
			table.append(tbody);
			addLookupResults(torrentId, thead, table);
		})).catch(console.warn).then(() => { target.parentNode.dataset.haveQuery = true });
		return false;
	}, 'Lookup edition in CUETools DB (TOCID)');
}

}