Greasy Fork

[GMT] Edition lookup by CD TOC

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

目前为 2023-02-07 提交的版本。查看 最新版本

// ==UserScript==
// @name         [GMT] Edition lookup by CD TOC
// @namespace    https://greasyfork.org/users/321857-anakunda
// @version      1.15.0
// @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
// @connect      musicbrainz.org
// @connect      db.cuetools.net
// @connect      db.cue.tools
// @connect      gnudb.org
// @author       Anakunda
// @license      GPL-3.0-or-later
// @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, dataTrackGap = 11400;
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+)';
// 1211 + 1287
const tocParser = '^\\s*' + ['\\d+', msfTime, msfTime, '\\d+', '\\d+']
	.map(pattern => '(' + pattern + ')').join('\\s+\\|\\s+') + '\\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 = new RegExp(tocParser).exec(tocEntry)) == 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 )\d+\b)/;
			return rx.test(pre = pre.textContent.trimLeft()) ? pre.replace(rx, '') : pre;
		}).filter(function(logFile) {
			if (!['Exact Audio Copy', 'EAC', 'X Lossless Decoder'].some(prefix => logFile.startsWith(prefix))) return false;
			const rr = rxRR.exec(logFile);
			if (rr == null) return true;
			// Ditch HTOA logs
			let tocEntries = logFile.match(new RegExp(tocParser, 'gm'));
			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 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 = logfile.match(new RegExp(tocParser, 'gm'));
		if (tocEntries != null && tocEntries.length > 0) tocEntries = tocEntries.map(tocEntriesMapper);
			else throw `disc ${volumeNdx + 1} ToC not found`;
		let layoutType = 0;
		for (let index = 0; index < tocEntries.length - 1; ++index) {
			const gap = tocEntries[index + 1].startSector - tocEntries[index].endSector - 1;
			if (gap == 0) continue; else layoutType = gap == dataTrackGap && index == tocEntries.length - 2 ? 1 : -1;
			break;
		}
		if (layoutType == 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, '') : '';

function updateEdition(evt) {
	if (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;
	if (target.dataset.editionInfo) {
		const editionInfo = JSON.parse(target.dataset.editionInfo);
		const uniqueValues = ((el1, ndx, arr) => el1 && arr.findIndex(el2 => bareId(el2) == bareId(el1)) == ndx);
		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)$/ig, '')).filter(Boolean).join(' / ');
	}
	if (Boolean(target.dataset.confirm) && !confirm(`Edition group is going to be updated

Edition year: ${payload.remaster_year || ''}
Record label: ${payload.remaster_record_label || ''}
Catalogue number: ${payload.remaster_catalogue_number || ''}

Are you sure the information is correct?`)) return false;
	target.disabled = true;
	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 && target.dataset.description)
			postData.set('release_desc', target.dataset.description);
		return queryAjaxAPI('torrentedit', { id: torrentId }, postData);
		return `torrentId: ${torrentId}, postData: ${postData.toString()}`;
	})).then(function(responses) {
		console.log('Edition updated successfully:', responses);
		document.location.reload();
	}, function(reason) {
		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 = function(evt) {
		if (!evt.ctrlKey || !evt.currentTarget.dataset.url) return true;
		GM_openInTab(evt.currentTarget.dataset.url, false);
		return false;
	};
	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);
	};
}

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 (!target.disabled) 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 (Boolean(target.dataset.haveResponse)) {
			if (!('results' in target.dataset)) return false;
			const results = JSON.parse(target.dataset.results);
			for (let result of results.releases.reverse())
				GM_openInTab('https://musicbrainz.org/release/' + result.id, false);
			// if (results.mbDiscID) GM_openInTab(baseUrl +
			// 	(evt.shiftKey ? 'attach?toc=' + results.mbTOC.join(' ') : results.mbDiscID), 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');
				}
				target.dataset.haveResponse = true;
				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 (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 result of Array.from(results[0].releases).reverse())
				// 	GM_openInTab('https://musicbrainz.org/release/' + result.id, true);
				target.dataset.results = JSON.stringify(results[0]);
				if (!('edition' in target.parentNode.dataset) || !['redacted.ch'].includes(document.domain)
						|| Boolean(target.parentNode.dataset.haveQuery)) return;
				results = results[0].releases.filter(release => release.media.reduce((totalDiscs, media) =>
					totalDiscs + Number(!media.format || /\b(?:HD|HQ)?CD\b/.test(media.format)), 0) == results.length);
				if (results.length > 0) target.parentNode.dataset.haveQuery = true; else return;
				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);
					if (exactMatch && !isCompleteInfo) {
						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 (filteredResults.length > 0 && filteredResults.length < (isUnknownRelease ? 2 : 4)
								&& releaseYear > 0 && !filteredResults.some(release1 => filteredResults.some(release2 =>
								new Date(release2.date).getUTCFullYear() != new Date(release1.date).getUTCFullYear()))) {
							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 = [ ];
							for (let release of filteredResults) {
								if ('label-info' in release) Array.prototype.push.apply(editionInfo, release['label-info'].map(labelInfo => ({
									label: labelInfo.label && labelInfo.label.name,
									catNo: labelInfo['catalog-number'],
								})));
								if (release.barcode) editionInfo.push({ catNo: release.barcode });
							}
							if (editionInfo.length > 0) a.dataset.editionInfo = JSON.stringify(editionInfo);
							if (filteredResults.length < 2 && !response.torrent.description.includes(filteredResults[0].id))
								a.dataset.description = ((response.torrent.description.trim() + '\n\n').trimLeft() +
								'[url]https://musicbrainz.org/release/' + filteredResults[0].id + '[/url]').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 parent = document.getElementById('release_' + torrentId);
					if (parent == null) throw '#release_' + torrentId + ' not found';
					const [bq, thead, table, tbody] = ['BLOCKQUOTE', 'DIV', 'TABLE', 'TBODY']
						.map(Document.prototype.createElement.bind(document));
					thead.style = 'margin-bottom: 5pt; font-weight: bold;';
					thead.textContent = `Applicable MusicBrainz matches (${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;
					for (let release of results) {
						const [tr, artist, album, _release, editionInfo, barcode] =
							['TR', '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;
						const releaseYear = new Date(release.date).getUTCFullYear();
						if (!isCompleteInfo && releaseYear && (!isUnknownRelease/* || exactMatch*/)) {
							tr.dataset.releaseYear = releaseYear;
							const _editionInfo = release['label-info'].map(labelInfo =>
								({ label: labelInfo.label && labelInfo.label.name, catNo: labelInfo['catalog-number'] }));
							if (release.barcode) _editionInfo.push({ catNo: release.barcode });
							if (_editionInfo.length > 0) tr.dataset.editionInfo = JSON.stringify(_editionInfo);
							if (!response.torrent.description.includes(release.id)) tr.dataset.description =
								((response.torrent.description.trim() + '\n\n').trimLeft() +
								'[url]https://musicbrainz.org/release/' + release.id + '[/url]').trim();
							applyOnClick(tr);
						} else {
							tr.dataset.url = 'https://musicbrainz.org/release/' + release.id;
							openOnClick(tr);
						}
						tr.append(artist, album, _release, editionInfo, barcode);
						tbody.append(tr);
					}
					table.append(tbody);
					bq.append(thead, table);
					parent.append(bq);
				}, 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 (!target.disabled) 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 {
			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) {
				target.dataset.haveResponse = true;
				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);
			}).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) {
			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 = (metadata = 'fast') => lookupByToc(torrentId, function(tocEntries, volumeNdx) {
			if (volumeNdx > 0) return Promise.resolve(null);
			const url = new URL('https://db.cue.tools/lookup2.php');
			url.searchParams.set('version', 3);
			url.searchParams.set('ctdb', 1);
			url.searchParams.set('metadata', metadata);
			url.searchParams.set('fuzzy', 0);
			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) results:', metadata, 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 => (torrentId => getLogs(torrentId).then(logfiles => logfiles.length > 0 ? logfiles.map(function(logfile, volumeNdx) {
				if (rxRR.test(logfile)) return Promise.reject('range rip');
				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)\\s*:\\s*([\\da-fA-F]{8})$/gm,
				];
				const matches = logfile.match(rx[0]) || logfile.match(rx[1]);
				return matches == null ? Promise.reject('no checksums found in logfile')
					: matches.map(match => parseInt(rx.reduce((m, rx) => m || (rx.lastIndex = 0, rx.exec(match)), null)[1], 16));
			}) : Promise.reject('no valid logfiles found')))(torrentId).then(function(checksums) {
				if (checksums.length > 0 && checksums[0] != null) checksums = checksums[0];
					else return Promise.reject('no checksums found in logfile');
				if (checksums.length < 3) return Promise.reject('tracklist too short');
				return (entries = entries.reduce((sum, entry) => sum + (entry.trackcrcs.length == checksums.length
					&& entry.trackcrcs.every((crc32, ndx, arr) => !(ndx > 0 && ndx < arr.length - 1)
						|| crc32 == checksums[ndx]) ? entry.confidence || 0 : 0), 0)) > 0 ? entries : Promise.reject('no matches');
			}))(results[0].entries)})).length > 0 ? results : Promise.reject('No matches');
		});
		const methods = ['fast', 'default', 'extensive'];
		(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];
			confidence.then(function(confidence) {
				const span = document.createElement('SPAN');
				span.innerHTML = `
<svg height="10px" version="1.1" viewBox="0 0 4120.39 4120.39" class="checkmark" xml:space="preserve" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd">
	<circle fill="#0a0" 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>`;
				span.className = 'ctdb-verified';
				setTooltip(span, `Checksums match (confidence ${confidence})`);
				target.parentNode.append(' ', span);
			}, reason => { console.log('Checksums mismatch for the reason', reason) });
			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;
			if (method == 'fast' && !isCompleteInfo) {
				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 (filteredResults.length > 0 && filteredResults.length < (isUnknownRelease ? 2 : 4)
						&& releaseYear > 0 && 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)';
					a.style.fontWeight = filteredResults.length < 2
						&& (!filteredResults[0].relevance || filteredResults[0].relevance >= 100) ? 'bold' : 300;
					a.dataset.releaseYear = releaseYear;
					const editionInfo = [ ];
					for (let metadata of filteredResults) {
						Array.prototype.push.apply(editionInfo, metadata.labelInfo.map(labelInfo =>
							({ label: labelInfo.name, catNo: labelInfo.catno })));
						if (metadata.barcode) editionInfo.push({ catNo: metadata.barcode });
					}
					if (editionInfo.length > 0) a.dataset.editionInfo = JSON.stringify(editionInfo);
					if (filteredResults.length < 2 && !response.torrent.description.includes(filteredResults[0].id))
						a.dataset.description = ((response.torrent.description.trim() + '\n\n').trimLeft() + '[url]' + {
							musicbrainz: 'https://musicbrainz.org/release/' + results[0].id,
							discogs: 'https://www.discogs.com/release/' + results[0].id,
						}[filteredResults[0].source] + '[/url]').trim();
					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;
					if (filteredResults.length > 1 || filteredResults[0].relevance < 100) a.dataset.confirm = true;
					if (!isUnknownRelease) target.parentNode.append(' ', a);
					else confidence.then(confidence => { target.parentNode.append(' ', a) });
				}
			}
			const parent = document.getElementById('release_' + torrentId);
			if (parent == null) throw '#release_' + torrentId + ' not found';
			const [bq, thead, table, tbody] = ['BLOCKQUOTE', 'DIV', 'TABLE', 'TBODY']
				.map(Document.prototype.createElement.bind(document));
			thead.style = 'margin-bottom: 5pt; font-weight: bold;';
			thead.textContent = `Applicable CTDB matches (method: ${method})`;
			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;';
				[source, release, barcode, relevance].forEach(elem => { elem.style.whiteSpace = 'nowrap' });
				source.innerHTML = `<img src="http://s3.cuetools.net/icons/${metadata.source}.png" />`;
				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 =>
					[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';
				}
				const releaseYear = getReleaseYear(metadata);
				if (!isCompleteInfo && releaseYear > 0 && (/*method == 'fast' || */!isUnknownRelease)) {
					tr.dataset.releaseYear = releaseYear;
					const _editionInfo = metadata.labelInfo.map(labelInfo =>
						({ label: labelInfo.name, catNo: labelInfo.catno }));
					if (metadata.barcode) _editionInfo.push({ catNo: metadata.barcode });
					if (_editionInfo.length > 0) tr.dataset.editionInfo = JSON.stringify(_editionInfo);
					if (!response.torrent.description.includes(metadata.id)) tr.dataset.description =
						((response.torrent.description.trim() + '\n\n').trimLeft() + '[url]' + {
							musicbrainz: 'https://musicbrainz.org/release/' + metadata.id,
							discogs: 'https://www.discogs.com/release/' + metadata.id,
						}[metadata.source] + '[/url]').trim();
					applyOnClick(tr);
				} else {
					tr.dataset.url = {
						musicbrainz: 'https://musicbrainz.org/release/' + metadata.id,
						discogs: 'https://www.discogs.com/release/' + metadata.id,
					}[metadata.source];
					openOnClick(tr);
				}
				tr.append(source, artist, album, release, editionInfo, barcode, relevance);
				tbody.append(tr);
			}
			table.append(tbody);
			bq.append(thead, table);
			parent.append(bq);
		})).catch(console.warn).then(() => { target.parentNode.dataset.haveQuery = true });
		return false;
	}, 'Lookup edition in CUETools DB (TOCID)');
}

}