Greasy Fork

[GMT] Edition lookup by CD TOC

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

目前为 2023-03-22 提交的版本。查看 最新版本

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

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

function getTocEntries(logFile) {
	if (!logFile) return null;
	const 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 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',
	];
	let tocEntries = tocParsers.reduce((m, rx) => m || logFile.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 getLogs(torrentId) {
	function logFileValidator(logFile) {
		if (!/^(?:Exact Audio Copy|EAC|X Lossless Decoder|CUERipper|EZ CD Audio Converter)\b/.test(logFile)) return false;
		const rr = rxRR.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;
	}

	if (!(torrentId > 0)) throw 'Invalid argument';
	if (requestsCache.has(torrentId)) return requestsCache.get(torrentId);
	const stackedLogRx = /^[\S\s]*(?:\r?\n)+(?=(?:Exact Audio Copy V|X Lossless Decoder version\s+|CUERipper v|EZ CD Audio Converter\s+)\d+\b)/;
	const stackedLogReducer = logFile => stackedLogRx.test(logFile) ? logFile.replace(stackedLogRx, '') : logFile;
	// 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 => stackedLogReducer(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 =>
			stackedLogReducer(pre.textContent.trimLeft())));
	request = request.then(logfiles => (logfiles = logfiles.filter(logFileValidator)).length > 0 ?
		logfiles : Promise.reject('No valid logfiles attached'));
	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 => Promise.all(logfiles.map(function(logfile, volumeNdx) {
		const isRangeRip = rxRR.test(logfile), tocEntries = getTocEntries(logfile);
		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, logfiles.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);
		linkBox.append(' ', 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;
	}

	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 = editionInfo.startsWith('Unknown Release(s)');
	const isUnconfirmedRelease = editionInfo.startsWith('Unconfirmed Release');
	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 releaseToHtml = (release, country = 'country', date = 'date') => release ? [
		release[country] && `<img src="http://s3.cuetools.net/flags/${release[country].toLowerCase()}.png" height="9" class="country" title="${release.country.toUpperCase()}" onerror="this.replaceWith('${release[country].toUpperCase()}')" />`,
		release[date] && `<span class="date">${release[date]}</span>`,
	].filter(Boolean).join(' ') : '';
	const stripSuffix = name => name && name.replace(/\s*\(\d+\)$/, '');
	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 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),
							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);
					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), tocEntries =>
					mbLookupByDiscID(tocEntriesToMbTOC(tocEntries), !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 = '#0a0';
				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) || 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: parseInt(target.parentNode.dataset.torrentId) }).then(function(response) {
					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 = response.torrent.remasterYear > 0
						&& Boolean(response.torrent.remasterRecordLabel)
						&& Boolean(response.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 && exactMatch) {
						const filteredResults = response.torrent.remasterYear > 0 ? results.filter(release => !release.date
							|| getReleaseYear(release.date) == response.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 && !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 = 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);
						}
					}
					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.className = 'musicbrainz-release';
						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;
						appendDisambiguation(album, release.disambiguation);
						_release.innerHTML = releaseToHtml(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 (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);
							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;
						});
						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 = getReleaseYear(release.date);
						if ((editableHosts.includes(document.domain) || ajaxApiKey) && !isCompleteInfo && releaseYear > 0
								&& (!isUnknownRelease || exactMatch)) {
							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);
						['artist', 'album', 'release-event', 'edition-info', 'barcode', 'releases-count', 'tocs-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 (album.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';
								span.className = 'have-discogs-relatives';
								span.title = 'Has defined Discogs relative(s)';
								album.append(' ', span);
							}
							dcApiRequest('releases/' + discogsId).then(function(release) {
								const [trDc, icon, artist, album, _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;';
								[_release, barcode].forEach(elem => { elem.style.whiteSpace = 'nowrap' });
								icon.innerHTML = GM_getResourceText('dc_icon');
								icon.firstElementChild.style = '';
								icon.firstElementChild.removeAttribute('width');
								icon.firstElementChild.setAttribute('height', '1em');
								icon.title = release.id;
								artist.textContent = release.artists.map(artist =>
									(artist.anv || stripSuffix(artist.name)) + ' ' + artist.join + ' ').join('')
										.trimRight().replace(/\s+([,])/g, '$1').replace(/\s+/g, ' ');
								album.textContent = release.title;
								const fmtCDFilter = RegExp.prototype.test.bind(/^CD[ir]$|CD(?:V)?|\b(?:H[DQ]|AV|XR)?CD\b/);
								const fmtDescFilter = RegExp.prototype.test.bind(/^(?:Promo|Reissue|Repress|Enhanced|Mono|Multichannel|Quadrophonic|Maxi-Single|Numbered|(?:Partially |Un\-?)?Mixed|Mini(?:-Album)?|Advance|Misprint|Copy Protected|Hybrid|(?:Partially )?Unofficial|Etched|Shape|Reel-To-Reel|4-Track Stereo|Lathe Cut)$|\b(?:Edition|Version|Release|Label|Pressing|Sided)$/i);
								let descriptions = [ ];
								if ('formats' in release) for (let format of release.formats) if (fmtCDFilter(format.name))
									Array.prototype.push.apply(descriptions, format.descriptions.filter(fmtDescFilter));
								appendDisambiguation(album, descriptions.filter((d1, n, a) =>
									a.findIndex(d2 => d2.toLowerCase() == d1.toLowerCase()) == n).join(', '));
								//`<img src="http://s3.cuetools.net/flags/${release.country.toLowerCase()}.png" height="9" title="${release.country}" onerror="this.replaceWith('${release.country}')" />`;
								_release.innerHTML = [
									release.country && `<span class="country">${release.country}</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;
								trDc.dataset.url = 'https://www.discogs.com/release/' + release.id;
								if (release.master_id) {
									const masterUrl = new URL('https://www.discogs.com/master/' + release.master_id);
									for (let format of ['CD', 'CDr', 'HDCD', 'CD+G', 'CDi', 'CDV', 'CD-Record', 'AVCD', 'XRCD'])
										masterUrl.searchParams.append('format', format);
									trDc.dataset.groupUrl = masterUrl.href;
									dcApiRequest('masters/' + release.master_id + '/versions', { per_page: 500 }).then(function(versions) {
										const masterTotal = versions.versions.filter(version => 'major_formats' in version
											&& version.major_formats.some(fmtCDFilter)).length;
										groupSize.textContent = masterTotal;
										if (versions.pages > versions.page) groupSize.textContent += '+';
										if (masterTotal == 1) groupSize.style.color = '#0a0';
										groupSize.title = 'Total of same media versions for master release';
									}, function(reason) {
										groupSize.style.paddingTop = '5pt';
										groupSize.innerHTML = svgFail('0.75em');
										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 || exactMatch)) {
									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 (!response.torrent.description.includes(trDc.dataset.url))
										trDc.dataset.description = response.torrent.description.trim();
									applyOnClick(trDc);
								} else openOnClick(trDc);
								trDc.append(artist, album, _release, editionInfo, barcode, groupSize, icon);
								['artist', 'album', 'release-event', 'edition-info', 'barcode', 'releases-count', 'discogs-icon']
									.forEach((className, index) => trDc.cells[index].className = className);
								tr.after(trDc); //tbody.append(trDc);
							}, reason => { album.querySelector('span.have-discogs-relatives').title = 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\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 = '#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[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 {
					matched: getTotal(1),
					partiallyMatched: getTotal(2),
					anyMatched: getTotal(3),
					total: sum(scores.map(score => score[0])),
				};
			}))(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 },
		];
		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 - (confidence.matched > 0) - (confidence.partiallyMatched > 0)) - color;
					color = '#' + (color << 16 | 0xCC00).toString(16).padStart(6, '0');
					confidenceBox.innerHTML = svgCheckmark('1em', color);
					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, album, 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;';
					[release, barcode, relevance].forEach(elem => { elem.style.whiteSpace = 'nowrap' });
					if (source.innerHTML = GM_getResourceText({ musicbrainz: 'mb_logo', discogs: 'dc_icon' }[metadata.source])) {
						source.firstElementChild.removeAttribute('width');
						source.firstElementChild.setAttribute('height', '1em');
					} 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 => 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 (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);
					((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, album, release, editionInfo, barcode, relevance);
					['source', 'artist', 'album', '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);
}

}