[RED] Similar CD Detector

Simple script for testing CD releases for duplicity

// ==UserScript==
// @name         [RED] Similar CD Detector
// @namespace    https://greasyfork.org/users/321857-anakunda
// @version      1.09
// @description  Simple script for testing CD releases for duplicity
// @match        https://redacted.ch/torrents.php?id=*
// @match        https://redacted.ch/torrents.php?page=*&id=*
// @match        https://redacted.ch/upload.php?groupid=*
// @run-at       document-end
// @author       Anakunda
// @license      GPL-3.0-or-later
// @grant        GM_registerMenuCommand
// @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';

	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;
		throw 'Failed to get torrent id';
	}

	const dupePrecheckInLinkbox = false; // set to true to have own rip dupe precheck in group page linkbox
	const progressivePeaksComparison = true; // progressive method tries to de-round track peaks expressed in %
	const maxRemarks = 60, allowReports = true, matchInAnyOrder = true;
	const sessionsCache = new Map, getTorrentIds = (...trs) => trs.map(getTorrentId);
	let selected = null, sessionsSessionCache;
	// const rxStackedLogReducer = /^[\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 => rxStackedLogReducer.test(logFile) ?
	// 	logFile.replace(rxStackedLogReducer, '') : logFile;
	const msf = 75, preGap = 2 * msf, msfTime = /(?:(\d+):)?(\d+):(\d+)[\.\:](\d+)/.source;
	const msfToSector = time => Array.isArray(time) || (time = new RegExp('^\\s*' + msfTime + '\\s*$').exec(time)) != null ?
		(((time[1] ? parseInt(time[1]) : 0) * 60 + parseInt(time[2])) * 60 + parseInt(time[3])) * msf + parseInt(time[4]) : NaN;
	const rxRangeRip = /^(?:Selected range|Выбранный диапазон|Âûáðàííûé äèàïàçîí|已选择范围|選択された範囲|Gewählter Bereich|Intervallo selezionato|Geselecteerd bereik|Utvalt område|Seleccionar gama|Избран диапазон|Wybrany zakres|Izabrani opseg|Vybraný rozsah)(?:[^\S\r\n]+\((?:Sectors|Секторы|扇区|Sektoren|Settori|Sektorer|Sectores|Сектори|Sektora|Sektory)[^\S\r\n]+(\d+)[^\S\r\n]*-[^\S\r\n]*(\d+)\))?$/m;
	const sessionHeader = '(?:' + [
		'(?:EAC|XLD) (?:extraction logfile from |Auslese-Logdatei vom |extraheringsloggfil från |uitlezen log bestand van |log súbor extrakcie z |抓取日志文件从)',
		'File di log (?:EAC|XLD) per l\'estrazione del ', 'Archivo Log de extracciones desde ',
		'Отчёт (?:EAC|XLD) об извлечении, выполненном ', 'Отчет на (?:EAC|XLD) за извличане, извършено на ',
		'Protokol extrakce (?:EAC|XLD) z ', 'Sprawozdanie ze zgrywania programem (?:EAC|XLD) z ',
		'(?:EAC|XLD)-ov fajl dnevnika ekstrakcije iz ',
		'(?:Log created by: whipper|EZ CD Audio Converter) .+(?:\\r?\\n)+Log creation date: ',
		'morituri extraction logfile from ', 'Rip .+ Audio Extraction Log',
	].join('|') + ')';
	const rxTrackExtractor = /^(?:(?:(?:Track|Трек|Òðåê|音轨|Traccia|Spår|Pista|Трак|Utwór|Stopa)[^\S\r\n]+\d+\b.*|Track \d+ saved to\b.+)$(?:\r?\n^(?:[^\S\r\n]+.*)?$)*| +\d+:$\r?\n^ {4,}Filename:.+$(?:\r?\n^(?: {4,}.*)?$)*)/gm;

	function getTocEntries(session) {
		if (!session) return null;
		const tocParsers = [
			'^\\s+' + ['\\d+', msfTime, msfTime, '\\d+', '\\d+'] // EAC / XLD
				.map(pattern => '(' + pattern + ')').join('\\s+\\|\\s+') + '\\s*$',
			'^\\s+\[X\]\\s+' + ['\\d+', msfTime, msfTime, '\\d+', '\\d+'] // EZ CD
				.map(pattern => '(' + pattern + ')').join('\\s+') + '\\b',
			// whipper
			'^ +(\\d+): *' + [['Start', msfTime], ['Length', msfTime], ['Start sector', '\\d+'], ['End sector', '\\d+']]
				.map(([label, capture]) => `\\r?\\n {4,}${label}: *(${capture})\\b *`).join(''),
			// Rip
			'^\\s+' + ['(\\d+)', '(' + msfTime + ')', '(?:(?:\\d+):)?\\d+:\\d+[\\.\\:]\\d+', '(' + msfTime + ')', '(\\d+)', '(\\d+)', '\\d+']
				.join('\\s+\\|\\s+') + '\\s*$',
		];
		let tocEntries = tocParsers.reduce((m, rx) => m || session.match(new RegExp(rx, 'gm')), null);
		if (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`;
			const [startSector, endSector] = [12, 13].map(index => parseInt(tocEntry[index]));
			console.assert(msfToSector(tocEntry[2]) == startSector && msfToSector(tocEntry[7]) == endSector + 1 - startSector
				&& endSector >= startSector, 'TOC table entry validation failed:', tocEntry);
			return { trackNumber: parseInt(tocEntry[1]), startSector: startSector, endSector: endSector };
		})).length <= 0) return null;
		if (!tocEntries.every((tocEntry, trackNdx) => tocEntry.trackNumber == trackNdx + 1)) {
			tocEntries = Object.assign.apply({ }, tocEntries.map(tocEntry => ({ [tocEntry.trackNumber]: tocEntry })));
			tocEntries = Object.keys(tocEntries).sort((a, b) => parseInt(a) - parseInt(b)).map(key => tocEntries[key]);
		}
		console.assert(tocEntries.every((tocEntry, trackNdx, tocEntries) => tocEntry.trackNumber == trackNdx + 1
			&& tocEntry.endSector >= tocEntry.startSector && (trackNdx <= 0 || tocEntry.startSector > tocEntries[trackNdx - 1].endSector)),
			'TOC table structure validation failed:', tocEntries);
		return tocEntries;
	}

	function getUniqueSessions(logFiles, detectCombinedLogs = true) {
		logFiles = Array.prototype.map.call(logFiles, function(logFile) {
			while (logFile.startsWith('\uFEFF')) logFile = logFile.slice(1);
			return logFile;
		});
		const rxRipperSignatures = '(?:(?:' + [
			'Exact Audio Copy V', 'X Lossless Decoder version ', 'CUERipper v',
			'EZ CD Audio Converter ', 'Log created by: whipper ', 'morituri version ', 'Rip ',
		].join('|') + ')\\d+)';
		if (!detectCombinedLogs) {
			const rxStackedLog = new RegExp('^[\\S\\s]*(?:\\r?\\n)+(?=' + rxRipperSignatures + ')');
			logFiles = logFiles.map(logFile => rxStackedLog.test(logFile) ? logFile.replace(rxStackedLog, '') : logFile)
				.filter(RegExp.prototype.test.bind(new RegExp('^(?:' + rxRipperSignatures + '|' + sessionHeader + ')')));
			return logFiles.length > 0 ? logFiles : null;
		} else if ((logFiles = logFiles.map(function(logFile) {
			let rxSessionsIndexer = new RegExp('^' + rxRipperSignatures, 'gm'), indexes = [ ], match;
			while ((match = rxSessionsIndexer.exec(logFile)) != null) indexes.push(match.index);
			if (indexes.length <= 0) {
				rxSessionsIndexer = new RegExp('^' + sessionHeader, 'gm');
				while ((match = rxSessionsIndexer.exec(logFile)) != null) indexes.push(match.index);
			}
			return (indexes = indexes.map((index, ndx, arr) => logFile.slice(index, arr[ndx + 1])).filter(function(logFile) {
				const rr = rxRangeRip.exec(logFile);
				if (rr == null) return true;
				// Ditch HTOA logs
				const tocEntries = getTocEntries(logFile);
				return tocEntries == null || parseInt(rr[1]) != 0 || parseInt(rr[2]) + 1 != tocEntries[0].startSector;
			})).length > 0 ? indexes : null;
		}).filter(Boolean)).length <= 0) return null;
		const sessions = new Map;
		for (const logFile of logFiles) for (const session of logFile) {
			let uniqueKey = getTocEntries(session), title;
			if (uniqueKey != null) uniqueKey = [uniqueKey[0].startSector].concat(uniqueKey.map(tocEntry =>
				tocEntry.endSector + 1)).map(offset => offset.toString(32).padStart(4, '0')).join(''); else continue;
			if ((title = new RegExp('^' + sessionHeader + '(.+)$(?:\\r?\\n)+^(.+ [\\/\\-] .+)$', 'm').exec(session)) != null)
				title = title[2];
			else if ((title = /^ +Release: *$\r?\n^ +Artist: *(.+)$\r?\n^ +Title: *(.+)$/m.exec(session)) != null)
				title = title[1] + ' / ' + title[2]; // Whipper?
			else if ((title = /^Compact Disc Information\r?\n=+\r?\nName: *(.+)$/m.exec(session)) != null)
				title = title[1]; // Rip
			if (title != null) uniqueKey += '/' + title.replace(/\s+/g, '').toLowerCase();
			sessions.set(uniqueKey, session);
		}
		//console.info('Unique keys:', Array.from(sessions.keys()));
		return sessions.size > 0 ? Array.from(sessions.values()) : null;
	}

	function getSessions(torrentId) {
		if (Array.isArray(torrentId) && torrentId.every(e => typeof e == 'string')) return Promise.resolve(torrentId);
		else if (typeof torrentId == 'string' && torrentId > 0) torrentId = parseInt(torrentId);
		if (!(torrentId > 0)) throw 'Invalid argument';
		if (sessionsCache.has(torrentId)) return sessionsCache.get(torrentId);
		if (!sessionsSessionCache && 'ripSessionsCache' in sessionStorage) try {
			sessionsSessionCache = JSON.parse(sessionStorage.getItem('ripSessionsCache'));
		} catch(e) {
			console.warn(e);
			sessionStorage.removeItem('ripSessionsCache');
			sessionsSessionCache = undefined;
		}
		if (sessionsSessionCache && torrentId in sessionsSessionCache)
			return Promise.resolve(sessionsSessionCache[torrentId]);
		// let request = queryAjaxAPICached('torrent', { id: torrentId }).then(({torrent}) => torrent.logCount > 0 ?
		// 		Promise.all(torrent.ripLogIds.map(ripLogId => queryAjaxAPICached('riplog', { id: torrentId, logid: ripLogId })
		// 			.then(response => response))) : Promise.reject('No logfiles attached'));
		let request = localXHR('/torrents.php?' + new URLSearchParams({ action: 'loglist', torrentid: torrentId }));
		request = request.then(document => Array.from(document.body.querySelectorAll(':scope > blockquote > pre:first-child'),
			pre => pre.textContent));
		sessionsCache.set(torrentId, (request = request.then(getUniqueSessions).then(function(sessions) {
			if (sessions == null) return Promise.reject('No valid logfiles attached'); else try {
				if (!sessionsSessionCache) sessionsSessionCache = { };
				sessionsSessionCache[torrentId] = sessions;
				sessionStorage.setItem('ripSessionsCache', JSON.stringify(sessionsSessionCache));
			} catch(e) { console.warn(e) }
			return sessions;
		})));
		return request;
	}

	function testSimilarity(...torrentIds) {
		if (torrentIds.length < 2 || !torrentIds.every(Boolean)) return Promise.reject('Invalid argument');
		return Promise.all(torrentIds.map(getSessions)).then(function(sessions) {
			function compareMedium(...indexes) {
				function addTrackRemark(trackNdx, remark) {
					if (!(trackNdx in volRemarks)) volRemarks[trackNdx] = [ ];
					volRemarks[trackNdx].push(remark);
				}
				function processTrackValues(patterns, ...callbacks) {
					if (!Array.isArray(patterns) || patterns.length <= 0 || typeof callbacks[patterns.length] != 'function') return;
					const rxs = patterns.map(pattern => new RegExp('^[^\\S\\r\\n]+' + pattern + '\\s*$', 'm'));
					const values = trackRecords.map(trackRecords => trackRecords != null ? trackRecords.map(function(trackRecord, trackNdx) {
						trackRecord = rxs.map(rx => rx.exec(trackRecord));
						for (let index = 0; index < trackRecord.length; ++index) if (trackRecord[index] != null)
							return typeof callbacks[index] == 'function' ? callbacks[index](trackRecord[index]) : trackRecord[index];
					}) : [ ]);
					for (let trackNdx = 0; trackNdx < Math.max(values[0].length, values[1].length); ++trackNdx)
						callbacks[patterns.length](values[0][trackNdx], values[1][trackNdx], trackNdx);
				}
				function getLayoutType(tocEntries) {
					for (let index = 0; index < tocEntries.length - 1; ++index) {
						const gap = tocEntries[index + 1].startSector - tocEntries[index].endSector - 1;
						if (gap != 0) return (gap == 11400 ? tocEntries.length - index : 0) - 1;
					}
					return 0;
				}

				console.assert(indexes.length > 0, indexes);
				if (indexes.length <= 0) throw 'No indexes provided'; else if (indexes.length < 2) indexes[1] = indexes[0];
				const isRangeRip = sessions.map((sessions, index) => rxRangeRip.test(sessions[indexes[index]]));
				const remarks = [ ], volRemarks = [ ];
				let volDescriptor = indexes[0] + 1;
				if (indexes[0] != indexes[1]) volDescriptor += '↔' + (indexes[1] + 1);
				if (isRangeRip.some(Boolean))
					remarks.push(`disc ${volDescriptor} having at least one release as range rip, skipping peaks comparison`);
				const tocEntries = sessions.map((sessions, index) => getTocEntries(sessions[indexes[index]]));
				if (tocEntries.some(toc => toc == null)) throw `disc ${volDescriptor} ToC not found for at least one release`;
				for (const _tocEntries of tocEntries) {
					let layoutType = getLayoutType(_tocEntries);
					if (layoutType < 0) remarks.push(`disc ${volDescriptor} unknown layout type`);
					else while (layoutType-- > 0) _tocEntries.pop(); // ditch data tracks for CD with data track(s)
				}
				if (tocEntries[0].length != tocEntries[1].length) throw `disc ${volDescriptor} ToC lengths mismatch`;
				const trackRecords = sessions.map((sessions, index) => sessions[indexes[index]].match(rxTrackExtractor));
				if (trackRecords.some((trackRecords, ndx) => !isRangeRip[ndx] && trackRecords == null))
					throw `disc ${volDescriptor} no track records could be extracted for at least one rip`;
				else if (!isRangeRip.some(Boolean) && trackRecords[0].length != trackRecords[1].length)
					throw `disc ${volDescriptor} track records count mismatch (${trackRecords[0].length} <> ${trackRecords[1].length})`;
				const htoaCount = tocEntries.filter(tocEntries => tocEntries[0].startSector > 150).length;
				if (htoaCount > 0) remarks.push(`disc ${volDescriptor} ${htoaCount < tocEntries.length ? 'one rip' : 'both rips'} possibly containing leading hidden track (ToC starting at non-zero offset)`);
				// Compare TOCs
				const tocThresholds = [
					{ maxShift: 50, maxDrift: 10 }, // WiKi standard
					// { maxShift: 40, maxDrift: 40 }, // staff standard
				], maxPeakDelta = 0.001;
				const tocShifts = tocEntries[0].map((_, trackNdx) =>
					(tocEntries[1][trackNdx].endSector - tocEntries[1][0].startSector) -
					(tocEntries[0][trackNdx].endSector - tocEntries[0][0].startSector));
				const tocShiftOf = shifts => shifts.length > 0 ? Math.max(...shifts.map(Math.abs)) : 0;
				const tocDriftOf = shifts => shifts.length > 0 ? Math.max(...shifts) - Math.min(...shifts) : 0;
				let shiftsPool = tocShifts.length > 1 ? tocShiftOf(tocShifts.slice(0, -1)) : undefined;
				shiftsPool = tocShifts.find(trackShift => Math.abs(trackShift) == shiftsPool) || 0;
				const hasPostGap = [shiftsPool + 150, shiftsPool - 150].includes(tocShifts[tocShifts.length - 1]); // ??
				shiftsPool = !hasPostGap ? tocShifts : tocShifts.slice(0, -1);
				const tocShift = tocShiftOf(shiftsPool), tocDrift = tocDriftOf(shiftsPool);
				console.assert(tocDrift >= 0);
				let getTid = index => torrentIds[index] > 0 ? 'tid' + torrentIds[index] : '<user input>';
				const label = `ToC comparison for ${getTid(0)} and ${getTid(1)} disc ${volDescriptor}`;
				console.group(label);
				getTid = index => torrentIds[index] > 0 ? torrentIds[index] : '[UI]';
				console.table(tocEntries[0].map((_, trackNdx) => ({
					['track#']: trackNdx + 1,
					['start' + getTid(0)]: tocEntries[0][trackNdx].startSector,
					['end' + getTid(0)]: tocEntries[0][trackNdx].endSector,
					['length' + getTid(0)]: tocEntries[0][trackNdx].endSector + 1 - tocEntries[0][trackNdx].startSector,
					['start' + getTid(1)]: tocEntries[1][trackNdx].startSector,
					['end' + getTid(1)]: tocEntries[1][trackNdx].endSector,
					['length' + getTid(1)]: tocEntries[1][trackNdx].endSector + 1 - tocEntries[1][trackNdx].startSector,
					['tocShift']: tocShifts[trackNdx],
				})));
				console.info(`ToC shift = ${tocShift}`);
				console.info(`ToC drift = ${Math.max(...tocShifts)} - ${Math.min(...tocShifts)} = ${Math.max(...tocShifts) - Math.min(...tocShifts)}`);
				console.groupEnd(label);
				if (tocThresholds.length > 0) (function tryIndex(reason, index = 0) {
					if (index < tocThresholds.length) try {
						if (!Object.keys(tocThresholds[index]).every(key => tocThresholds[index][key] > 0)) throw 'Invalid parameter';
						if (tocShift >= tocThresholds[index].maxShift)
							throw `disc ${volDescriptor} ToC shift not below ${tocThresholds[index].maxShift} sectors`;
						if (tocDrift >= tocThresholds[index].maxDrift)
							throw `disc ${volDescriptor} ToC drift not below ${tocThresholds[index].maxDrift} sectors`;
					} catch(reason) { tryIndex(reason, index + 1) } else throw reason || 'unknown reason';
				})();
				if (tocDrift > 0) remarks.push(`Disc ${volDescriptor} shifted ToCs by ${tocShift} sectors with ${tocDrift} sectors drift`);
				else if (tocShifts[0] != 0) remarks.push(`Disc ${volDescriptor} shifted ToCs by ${tocShift} sectors`);
				if (hasPostGap) remarks.push(`Disc ${volDescriptor} with post-gap`);
				for (let trackNdx = 0; trackNdx < tocEntries[0].length; ++trackNdx) { // just informational
					const mismatches = [ ];
					if (tocEntries[0][trackNdx].startSector != tocEntries[1][trackNdx].startSector) mismatches.push('offsets');
					if (tocEntries[0][trackNdx].endSector - tocEntries[0][trackNdx].startSector
							!= tocEntries[1][trackNdx].endSector - tocEntries[1][trackNdx].startSector) mismatches.push('lengths');
					if (mismatches.length > 0) addTrackRemark(trackNdx, mismatches.join(' and ') + ' mismatch');
				}
				// Compare pre-gaps - just informational
				if (!isRangeRip.some(Boolean)) processTrackValues([
					'(?:Pre-gap length|Длина предзазора|Äëèíà ïðåäçàçîðà|前间隙长度|Pausenlänge|Durata Pre-Gap|För-gap längd|Longitud Pre-gap|Дължина на предпразнина|Długość przerwy|Pre-gap dužina|[Dd]élka mezery|Dĺžka medzery pred stopou)\\s+' + msfTime, // 1270
					'(?:Pre-gap length)\\s*:\\s*' + msfTime,
				], msfToSector, msfToSector, function(preGap1, preGap2, trackNdx) {
					if ((preGap1 || 0) != (preGap2 || 0)) addTrackRemark(trackNdx, 'pre-gaps mismatch');
				});
				const identicalTracks = new Set, crc32Extractors = [
					'(?:(?:Copy|复制|Kopie|Copia|Kopiera|Copiar|Копиран) CRC|CRC (?:копии|êîïèè|kopii|kopije|kopie|kópie)|コピーCRC)\\s+([\\da-fA-F]{8})', // 1272
					'(?:CRC32 hash|Copy CRC)\\s*:\\s*([\\da-fA-F]{8})',
				], h2i = m => parseInt(m[1], 16);
				if (!isRangeRip.some(Boolean)) processTrackValues(crc32Extractors, h2i, h2i, function(checksum1, checksum2, trackNdx) {
					if (checksum1 != undefined && checksum2 != undefined && checksum1 == checksum2) identicalTracks.add(trackNdx);
				});
				// Compare peaks
				if (!isRangeRip.every(Boolean)) processTrackValues([
					'(?:Peak level|Пиковый уровень|Ïèêîâûé óðîâåíü|峰值电平|ピークレベル|Spitzenpegel|Pauze lengte|Livello di picco|Peak-nivå|Nivel Pico|Пиково ниво|Poziom wysterowania|Vršni nivo|[Šš]pičková úroveň)\\s+(\\d+(?:\\.\\d+)?)\\s*\\%', // 1217
					'(?:Peak(?: level)?)\\s*:\\s*(\\d+(?:\\.\\d+)?)',
				], m => [parseFloat(m[1]) * 10, true], m => [parseFloat(m[1]) * 1000, false], function(peak1, peak2, trackNdx) {
					if (peak1 == undefined && !isRangeRip[0] || peak2 == undefined && !isRangeRip[1])
						throw `disc ${volDescriptor} track ${trackNdx + 1} peak missing or invalid format`;
					if (isRangeRip.some(Boolean) || identicalTracks.has(trackNdx)) return;
					//const norm = [peak => peak[0], peak => Math.min(peak[1] ? Math.floor(peak[0]) + 0.748 : peak[0], 1000)];
					const norm = (progressivePeaksComparison ? [-0.031, 0.901] : [0]).map(offset =>
						peak => Math.max(Math.min(peak[1] ? Math.floor(peak[0]) + offset : peak[0], 1000)), 0);
					if (norm.every(fn => Math.abs(fn(peak1) - fn(peak2)) >= maxPeakDelta * 1000))
						throw `disc ${volDescriptor} track ${trackNdx + 1} peak difference above ${maxPeakDelta}`;
					else if (peak1[1] == peak2[1] && peak1[0] != peak2[0]) addTrackRemark(trackNdx, 'peak levels mismatch');
				});
				// Compare checksums - just informational
				if (!isRangeRip.every(Boolean)) processTrackValues(crc32Extractors, h2i, h2i, function(checksum1, checksum2, trackNdx) {
					if (checksum1 == undefined && !isRangeRip[0] || checksum2 == undefined && !isRangeRip[1])
						addTrackRemark(trackNdx, 'checksum missing or invalid format');
					if (isRangeRip.some(Boolean)) return;
					if (checksum1 != checksum2) addTrackRemark(trackNdx, 'checksums mismatch');
				});
				// Compare AR signatures - just informational
				if (!isRangeRip.every(Boolean)) for (let v = 2; v > 0; --v) processTrackValues([
					'.+?\\[([\\da-fA-F]{8})\\].+\\(AR v' + v + '\\)',
					'(?:AccurateRip v' + v + ' signature)\\s*:\\s*([\\da-fA-F]{8})',
				], m => parseInt(m[1], 16), m => parseInt(m[1], 16), function(hash1, hash2, trackNdx) {
					// if (hash1 == undefined && !isRangeRip[0] || hash2 == undefined && !isRangeRip[1])
					// 	addTrackRemark(trackNdx, 'AR v' + v + ' hash missing');
					if (isRangeRip.some(Boolean)) return;
					if (hash1 != hash2) addTrackRemark(trackNdx, 'AR v' + v + ' signatures mismatch');
				});
				for (let trackNdx of volRemarks.sort()) if (volRemarks[trackNdx])
					remarks.push(`Disc ${volDescriptor} track ${parseInt(trackNdx) + 1}: ${volRemarks[trackNdx].join(', ')}`);
				const rxTimestampExtractor = new RegExp('^' + sessionHeader + '(.+)$', 'm');
				const timeStamps = sessions.map((sessions, index) => rxTimestampExtractor.exec(sessions[indexes[index]]));
				if (timeStamps.every(Boolean) && timeStamps.map(timeStamp => timeStamp[0]).every((timeStamp, ndx, arr) => timeStamp == arr[0]))
					remarks.push(`Disc ${volDescriptor} ${identicalRip}`);
				return remarks;
			}

			if (sessions.some(lf1 => sessions.some(lf2 => lf1.length != lf2.length))) throw 'disc count mismatch';
			const identicalRip = 'originates in same ripping session';
			const isIdenticalRip = remarks => remarks.filter(remark => (remark || '').endsWith(identicalRip)).length
				>= Math.max(...sessions.map(sessions => sessions.length));
			let remarks = [ ];
			if (sessions.some(sessions => sessions.length > 1) && matchInAnyOrder) {
				const volumesMapping = new Map;
				outerLoop: for (let index1 = 0; index1 < sessions[0].length; ++index1) {
					for (let index2 = 0; index2 < sessions[1].length; ++index2) if (!volumesMapping.has(index2)) try {
						Array.prototype.push.apply(remarks, compareMedium(index1, index2));
						volumesMapping.set(index2, index1);
						continue outerLoop;
					} catch(e) { console.info(e) }
					break;
				}
				if (volumesMapping.size >= sessions[1].length) return isIdenticalRip(remarks) ? true : remarks;
				remarks = [ ];
			}
			for (let volumeNdx = 0; volumeNdx < sessions[0].length; ++volumeNdx)
				Array.prototype.push.apply(remarks, compareMedium(volumeNdx));
			return isIdenticalRip(remarks) ? true : remarks;
		});
	}

	(typeof unsafeWindow == 'object' ? unsafeWindow : window).similarCDDetector = {
		getUniqueSessions: getUniqueSessions,
		testSimilarity: testSimilarity,
	};

	function getEditionTitle(elem) {
		while (elem != null && !elem.classList.contains('edition')) elem = elem.previousElementSibling;
		if (elem != null && (elem = elem.querySelector('td.edition_info > strong')) != null)
			return elem.textContent.trimRight().replace(/^[\s\-\−]+/, '').replace(/\s*\/\s*CD$/, '');
	}

	switch (document.location.pathname) {
		case '/torrents.php': {
			function countSimilar(groupId) {
				if (groupId > 0) return queryAjaxAPI('torrentgroup', { id: groupId }).then(function({torrents}) {
					const torrentIds = torrents.filter(torrent => torrent.media == 'CD'
						&& torrent.format == 'FLAC' && torrent.encoding == 'Lossless' && torrent.hasLog).map(torrent => torrent.id);
					const compareWorkers = [ ];
					torrentIds.forEach(function(torrentId1, ndx1) {
						torrentIds.forEach(function(torrentId2, ndx2) {
							if (ndx2 > ndx1) compareWorkers.push(testSimilarity(torrentId1, torrentId2).then(remarks => true, reason => false));
						});
					});
					return Promise.all(compareWorkers).then(results => results.filter(Boolean).length);
				}); else throw 'Invalid argument';
			}

			for (let selector of [
				'table.torrent_table > tbody > tr.group div.group_info > strong > a:last-of-type',
				'table.torrent_table > tbody > tr.torrent div.group_info > strong > a:last-of-type',
				'table.torrent_table > tbody > tr.group div.group_info > a:last-of-type',
				'table.torrent_table > tbody > tr.torrent div.group_info > a:last-of-type',
			]) for (let a of document.body.querySelectorAll(selector)) {
				a.onclick = function altClickHandler(evt) {
					if (!evt.altKey) return true;
					let groupId = new URLSearchParams(evt.currentTarget.search);
					if ((groupId = parseInt(groupId.get('id'))) > 0) countSimilar(groupId).then(count =>
						{ alert(count > 0 ? `Total ${count} CDs potentially duplicates` : 'No similar CDs found') }, alert);
					return false;
				};
				a.title = 'Use Alt + click to count considerable CD dupes in release group';
			}

			const torrents = 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));

			if (torrents.length < 1) break;
			function findLog() {
				const dialog = document.createElement('dialog'), submit = 'Check for duplicity';
				dialog.innerHTML = `
<form method="dialog" style="padding: 1rem; background-color: darkslategray; color: white; display: flex; flex-flow: column nowrap; row-gap: 10pt;">
	<div name="cd-log-text">
		<div style="margin-bottom: 5pt;">Paste .LOG content (for multi disc albums paste all logs one after another)</div>
		<textarea rows="40" cols="80" spellcheck="false" wrap="off" style="font: 8pt monospace; padding: 5px; color: white; background-color: #152323;"></textarea>
	</div>
	<div name="cd-log-files">Or select file(s): <input type="file" accept=".log" multiple style="font-size: 9pt;" /></div>
	<div style="text-align: center; display: flex; flex-flow: row; justify-content: flex-start; column-gap: 10pt;"><input type="submit" value="${submit}" style="margin: 0;" /><input type="button" name="close" value="Close" style="margin: 0;" /></div>
</form>
`;
				dialog.style = 'position: fixed; top: 0; left: 0; margin: auto; max-width: 75%; max-height: 90%; box-shadow: 5px 5px 10px black; z-index: 99999;';
				dialog.onclose = function(evt) {
					document.body.removeChild(evt.currentTarget);
					if (evt.currentTarget.returnValue != submit) return;
					const logFilesAdaptor = logFiles => (logFiles = getUniqueSessions(logFiles, true)) != null ?
						Promise.resolve(logFiles) : Promise.reject('No valid input');
					logFilesAdaptor(Array.from(form.querySelectorAll('[name="cd-log-text"] textarea'),
							textArea => textArea.value)).catch(reason => Promise.all(Array.prototype.concat.apply([ ],
								Array.from(form.querySelectorAll('[name="cd-log-files"] input[type="file"]'),
									input => Array.from(input.files, file => new Promise(function(resolve, reject) {
						const fr = new FileReader;
						fr.onload = evt => { resolve(evt.currentTarget.result) };
						fr.onerror = evt => { reject('File reading error') };
						fr.readAsText(file);
					}))))).then(logFilesAdaptor)).then(function(sessions) {
						Promise.all(torrents.map(torrent => testSimilarity(sessions, ...getTorrentIds(torrent)).then(remarks => ({
							torrent: torrent,
							remarks: remarks,
						}), reason => null))).then(function(results) {
							if ((results = results.filter(Boolean)).length > 0) {
								alert('This mastering is considered dupe to\n\n' + results.map(function(result, index) {
									let message = getEditionTitle(result.torrent);
									if (result.remarks === true) message += '\nIdentical rips';
									else if (Array.isArray(result.remarks) && result.remarks.length > 0)
										message += '\n' + result.remarks.map(remark => '\t' + remark).join('\n');
									return message;
								}).join('\n\n'));
							} else alert('This mastering is unique within the release group');
						});
					}, alert);
				};
				const form = dialog.firstElementChild;
				form.elements.namedItem('close').onclick = evt => { dialog.close() };
				document.body.append(dialog);
				dialog.showModal();
			}

			const linkbox = document.body.querySelector('div.header > div.linkbox');
			if (dupePrecheckInLinkbox && linkbox != null) linkbox.append(' ', Object.assign(document.createElement('A'), {
				href: '#',
				className: 'brackets',
				textContent: 'Check CD uniqueness',
				onclick: evt => (findLog(), false),
				title: 'Verify if ripped CD is considered distinct edition within the release group',
			}));
			GM_registerMenuCommand('CD rip duplicity precheck', findLog, 'd');

			if (torrents.length < 2) break; else for (let tr of torrents) {
				let torrentId = /^torrent(\d+)$/.exec(tr.id);
				if (torrentId == null || !((torrentId = parseInt(torrentId[1])) > 0)) continue;
				const div = document.createElement('DIV');
				div.innerHTML = '<svg height="14" viewBox="0 0 24 24" fill="gray"><path d="M14.0612 4.7156l4.0067-.7788a.9998.9998 0 10-.3819-1.9629l-4.0264.7826a2.1374 2.1374 0 00-3.7068.7205l-4.0466.7865a.9998.9998 0 10.3819 1.9629l4.0221-.7818a2.1412 2.1412 0 003.751-.729zM7.1782 9.5765a.9997.9997 0 00-1.8115 0l-3.2725 7A.9977.9977 0 002 16.9998v.7275a4.2727 4.2727 0 008.5454 0v-.7275a.9977.9977 0 00-.0942-.4233zm-.9057 2.7846l1.7014 3.6387H4.5713zm.0005 7.6387a2.268 2.268 0 01-2.2454-2h4.4902a2.268 2.268 0 01-2.2448 2zM18.6558 7.5765a.9997.9997 0 00-1.8116 0l-3.273 7a.9977.9977 0 00-.0941.4233v.7275a4.2727 4.2727 0 008.5454 0l.0005-.726a.997.997 0 00-.0943-.4248zm-.9058 2.7841l1.7017 3.6392h-3.4032zm0 7.6392a2.268 2.268 0 01-2.2454-2h4.4903a2.268 2.268 0 01-2.2449 2z" /></svg>';
				div.style = 'float: right; margin-left: 5pt; margin-right: 5pt; padding: 0; visibility: visible; cursor: pointer;';
				div.className = 'compare-release';
				div.onclick = function(evt) {
					console.assert(evt.currentTarget instanceof HTMLElement);
					const setActive = (elem, active = true) => { elem.children[0].setAttribute('fill', active ? 'orange' : 'gray') };
					if (selected instanceof HTMLElement) {
						if (selected == evt.currentTarget) {
							selected = null;
							setActive(evt.currentTarget, false);
						} else {
							const target = evt.currentTarget;
							setActive(target, true);
							const trs = [selected.parentNode.parentNode, target.parentNode.parentNode];
							testSimilarity(...getTorrentIds(...trs)).then(function(remarks) {
								const permaLink = document.location.origin + '/torrents.php?torrentid=${torrentid}';
								if (remarks === true) {
									var message = 'Identical rips';
									var report = `Identical rip to [url=${permaLink}]\${editiontitle}[/url]`;
								} else {
									message = 'Releases may be duplicates (ToC shift/drift + peaks are too similar)';
									if (remarks.length > 0) message += '\n\n' + (maxRemarks > 0 && remarks.length > maxRemarks ?
										remarks.slice(0, maxRemarks - 1).join('\n') + '\n...' : remarks.join('\n'));
									const shorten = (index, sameStr) => report[index].length == 1 && report[index][0].startsWith('Disc 1') ?
										report[index][0].replace(/^Disc\s+\d+\s+/, '') : report[index].length > 0 ? report[index].join(', ') : sameStr;
									report = [remark => remark.includes('shifted'), remark => remark.includes('post-gap')].map(fn => remarks.filter(fn));
									report = `Same pressing as [url=${permaLink}]\${editiontitle}[/url] possible (${shorten(0, 'similar ToC(s)/peaks')}${shorten(1, '') ? ' with post-gap' : ''})`;
								}
								const getTorrentText = elem => (elem = elem.querySelector('a[href="#"][onclick]')) != null ? elem.textContent : undefined;
								const characteristics = trs.map(tr => [
									/* 0 */ /\b(?:(?:Pre|De)[\-\− ]?emphas|Pre-?gap\b|HTOA\b|Hidden\s+track|Clean|Censor)/i.test(getEditionTitle(tr)), // allowed to coexist
									/* 1 */ tr.querySelector('strong.tl_reported') != null,
									/* 2 */ (function(numberColumns) {
										console.assert(numberColumns.length == 4);
										return numberColumns.length >= 3 ? parseInt(numberColumns[2].textContent) : -1;
									})(tr.getElementsByClassName('number_column')),
									/* 3 */ Array.prototype.some.call(tr.querySelectorAll('strong.tl_notice'), strong => strong.textContent.trim() == 'Trumpable'),
									/* 4 */ (text => (text = /\bLog\s*\((\-?\d+)\%\)/.exec(text)) != null ?
										(text = parseInt(text[1])) >= 100 ? 100 : text > 0 ? 50 : 0 : NaN)(getTorrentText(tr)),
									/* 5 */ /\bCue\b/i.test(getTorrentText(tr)),
									/* 6 */ getTorrentId(tr),
								]);
								let userAuth = document.body.querySelector('input[name="auth"][value]');
								if (userAuth != null) userAuth = userAuth.value;
								if (!userAuth || characteristics.some(ch => ch[0] || ch[1])
										|| characteristics.filter(ch => ch[3]).length > 1) return alert(message);
								const indexByDelta = delta => delta > 0 ? 0 : delta < 0 ? 1 : -1;
								let trumpIndex = indexByDelta(Number(isNaN(characteristics[0][4])) - Number(isNaN(characteristics[1][4])));
								if (trumpIndex < 0) trumpIndex = indexByDelta(characteristics[1][4] - characteristics[0][4]);
								if (trumpIndex < 0) trumpIndex = indexByDelta(Number(characteristics[0][3]) - Number(characteristics[1][3]));
								if (trumpIndex < 0 && characteristics.every(ch => ch[4] >= 100))
									trumpIndex = indexByDelta(Number(characteristics[1][5]) - Number(characteristics[0][5]));
								let dupeIndex = trumpIndex < 0 ? indexByDelta(characteristics[0][6] - characteristics[1][6]) : -1;
								console.assert(trumpIndex < 0 != dupeIndex < 0);
								if (characteristics[1 - (trumpIndex < 0 ? dupeIndex : trumpIndex)][2] <= 0)
									return alert(message + '\n\nReporting not offered for lack of seeds');
								if (trumpIndex >= 0) {
									class ExtraInfo extends Array {
										constructor(torrentId) {
											super();
											if (torrentId > 0) torrentId = document.getElementById('release_' + torrentId); else return;
											if (torrentId != null) for (torrentId of torrentId.querySelectorAll(':scope > blockquote'))
												if ((torrentId = /^Trumpable For:\s+(.+)$/i.exec(torrentId.textContent.trim())) != null)
													Array.prototype.push.apply(this, torrentId[1].split(/\s*,\s*/));
										}
									}

									const extraInfo = new ExtraInfo(characteristics[trumpIndex][6]), trumpMappings = {
										lineage_trump: /\b(?:Lineage)\b/i,
										checksum_trump: /^(?:Bad\/No Checksum\(s\))$/i,
										tag_trump: /^(?:Bad Tags)$/i,
										folder_trump: /\b(?:Folder)\b/i,
										file_trump: /^(?:Bad File Names)$|\b(?:180)\b/i,
										pirate_trump: /\b(?:Pirate)\b/i,
									};
									var trumpType = 'trump';
									for (let type in trumpMappings) if (extraInfo.some(RegExp.prototype.test.bind(trumpMappings[type])))
										trumpType = type;
								}
								message += `\n\nProposed ${trumpIndex < 0 ? 'dupe' : trumpType.replace(/_/g, ' ')} report for ${getEditionTitle(trs[trumpIndex < 0 ? dupeIndex : trumpIndex])}`;
								if (!allowReports || trumpIndex >= 0 && (characteristics[trumpIndex][3] && trumpType == 'trump'
										|| characteristics.every(ch => ch[4] == 50))) return alert(message);
								if (confirm(message + `\n\nTake the report now?`)) localXHR('/reportsv2.php?action=takereport', { responseType: null }, new URLSearchParams({
									auth: userAuth,
									categoryid: 1,
									torrentid: characteristics[trumpIndex < 0 ? dupeIndex : trumpIndex][6],
									type: trumpIndex < 0 ? 'dupe' : trumpType,
									sitelink: permaLink.replace('${torrentid}', characteristics[1 - (trumpIndex < 0 ? dupeIndex : trumpIndex)][6]),
									extra: report.replace('${torrentid}', characteristics[1 - (trumpIndex < 0 ? dupeIndex : trumpIndex)][6])
										.replace('${editiontitle}', getEditionTitle(trs[1 - (trumpIndex < 0 ? dupeIndex : trumpIndex)])),
								})).then(status => { document.location.reload() }, alert);
							}).catch(reason => { alert('Releases not duplicates for the reason ' + reason) }).then(function() {
								for (let elem of [selected, target]) setActive(elem, false);
								selected = null;
							});
						}
					} else setActive(selected = evt.currentTarget, true);
				};
				div.title = 'Compare with different CD for similarity';
				const anchor = tr.querySelector('span.torrent_action_buttons');
				if (anchor != null) anchor.after(div);
			}

			function scanGroup(evt) {
				const compareWorkers = [ ];
				torrents.forEach(function(torrent1, ndx1) {
					torrents.forEach(function(torrent2, ndx2) {
						if (ndx2 > ndx1) compareWorkers.push(testSimilarity(...getTorrentIds(torrent1, torrent2))
							.then(remarks => [torrent1, torrent2], reason => 'distinct'));
					});
				});
				if (compareWorkers.length > 0) Promise.all(compareWorkers).then(function(results) {
					if ((results = results.filter(Array.isArray)).length > 0) try {
						results.forEach(function(sameTorrents, groupNdx) {
							const randColor = () => 0xD0 + Math.floor(Math.random() * (0xF8 - 0xD0));
							const color = ['#dff', '#ffd', '#fdd', '#dfd', '#ddf', '#fdf'][groupNdx]
								|| `rgb(${randColor()}, ${randColor()}, ${randColor()})`;
							for (let elem of sameTorrents) if ((elem = elem.querySelector('div.compare-release')) != null) {
								elem.style.padding = '2px';
								elem.style.border = '1px solid #808080';
								elem.style.borderRadius = '3px';
								elem.style.backgroundColor = color;
							}
						});
						alert('Similar CDs detected in these editions:\n\n' + results.map(sameTorrents =>
							'− ' + getEditionTitle(sameTorrents[0]) + '\n− ' + getEditionTitle(sameTorrents[1])).join('\n\n'));
					} catch (e) { alert(e) } else alert('No similar CDs detected');
				});
			}

			GM_registerMenuCommand('Find CD dupes', scanGroup, 'd');
			const container = document.body.querySelector('table#torrent_details > tbody > tr.colhead_dark > td:first-of-type');
			if (container != null) container.append(Object.assign(document.createElement('SPAN'), {
				className: 'brackets',
				textContent: 'Find CD dupes',
				style: 'margin-left: 5pt; margin-right: 5pt; float: right; cursor: pointer; font-size: 8pt;',
				onclick: scanGroup,
			}));
			break;
		}
		case '/upload.php': {
			function installLogWatchers(logFields) {
				console.assert(logFields instanceof HTMLElement);
				if (!(logFields instanceof HTMLElement)) return;
				const selector = 'input[type="file"]', listener = ['input', function(evt) {
					if (form.querySelector('table#upload-assistant') != null) return;
					dupeStatus = undefined;
					let allLogs = Array.from(logFields.querySelectorAll(selector), input => Array.from(input.files));
					const allSlotsTaken = allLogs.every(files => files.length > 0);
					allLogs = Array.prototype.concat.apply([ ], allLogs)
						.filter(file => file.name.toLowerCase().endsWith('.log'));
					if (allLogs.length > 0) allLogs = Promise.all(allLogs.map(logFile => new Promise(function(resolve, reject) {
						const fr = new FileReader;
						fr.onload = evt => { resolve(evt.currentTarget.result) };
						fr.onerror = evt => { reject(`Log file reading error (${logFile.name})`) };
						fr.readAsText(logFile);
					}))); else return;
					if (!(torrents instanceof Promise)) torrents =
						queryAjaxAPI('torrentgroup', { id: groupId }).then(torrentGroup =>
							torrentGroup.torrents.filter(torrent => torrent.hasLog && !torrent.reported));
					Promise.all([allLogs, torrents]).then(function([logs, torrents]) {
						const uniqueSessions = getUniqueSessions(logs, true);
						if (uniqueSessions != null && torrents.length > 0) Promise.all(torrents.map(torrent => testSimilarity(uniqueSessions, torrent.id).then(remarks => ({
							torrent: torrent,
							remarks: remarks,
						}), function(reason) {
							console.info('Torrent #%d:', torrent.id, reason);
							return null;
						}))).then(results => results.filter(Boolean)).then(function(results) {
							const toMessage = (torrents = results) => torrents.map(function(result, index) {
								let message = `- Torrent #${result.torrent.id}: ${result.torrent.remasterYear > 0 ? `${result.torrent.remasterYear} - ${[
									'remasterRecordLabel',
									'remasterCatalogueNumber',
									'remasterTitle',
								].map(prop => result.torrent[prop]).filter(Boolean).join(' / ')}` : (result.torrent.remastered ? 'unknown' : 'unconfirmed') + ' edition'}`;
								const indent = ' '.repeat(8); // '\t';
								if (result.remarks === true) message += `\n${indent}Identical rips`;
								else if (Array.isArray(result.remarks) && result.remarks.length > 0)
									message += '\n' + result.remarks.map(remark => indent + remark).join('\n');
								return message;
							}).join('\n');
							const strictDupes = results.filter(result => !result.torrent.trumpable && result.torrent.logScore >= 100);
							if (strictDupes.length > 0) dupeStatus = 'Warning: this mastering will be considered dupe to these editions:\n\n' + toMessage(strictDupes);
							else if (results.length > 0) dupeStatus = 'Notice: unless uploading a trump, this mastering will be considered dupe to these editions:\n\n' + toMessage(results);
							if (dupeStatus) alert(dupeStatus);
						}); else console.log('No valid logfiles attached');
					}, reason => { console.warn(`CD duplicity test failed to perform for the reason: ${reason}`) });
				}], setLogWatcher = input => { input.addEventListener(...listener) };
				logFields.querySelectorAll(selector).forEach(setLogWatcher);
				let logsWatcher = new MutationObserver(function(ml, mo) {
					for (let mutation of ml) {
						for (let node of mutation.addedNodes) if (node.nodeType == Node.ELEMENT_NODE
								&& node.matches(selector)) setLogWatcher(node);
						for (let node of mutation.removedNodes) if (node.nodeType == Node.ELEMENT_NODE
								&& node.matches(selector)) node.removeEventListener(...listener);
					}
				});
				logsWatcher.observe(logFields, { childList: true });
			}

			if (document.body.querySelector('form#upload_table table#upload-assistant') != null) break;
			const groupId = parseInt(new URLSearchParams(document.location.search).get('groupid'));
			console.assert(groupId > 0);
			if (!(groupId > 0)) break;
			const form = document.body.querySelector('form#upload_table');
			if (form == null) break; // assertion failed
			const category = form.querySelector('select#categories');
			console.assert(category != null);
			if (category == null || category.options[category.selectedIndex].text != 'Music') break;
			let torrents, dupeStatus;
			installLogWatchers(form.querySelector('div#dynamic_form td#logfields'));
			// form.addEventListener('submit', function(evt) {
			// 	if (!dupeStatus || confirm(dupeStatus + '\n\nUpload anyway?')) return true;
			// 	evt.preventDefault();
			// 	return false;
			// });
			break;
		}
	}
}