[RED] Similar CD Detector

Simple script for testing CD releases for duplicity

Pada tanggal 27 April 2024. Lihat %(latest_version_link).

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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) {
				function logWatcher(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 (!(groupTorrentIds instanceof Promise)) groupTorrentIds =
						queryAjaxAPI('torrentgroup', { id: groupId }).then(torrentGroup =>
							torrentGroup.torrents.filter(torrent => torrent.hasLog && !torrent.reported));
					Promise.all([allLogs, groupTorrentIds]).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}: ${[
									'remasterYear', 'remasterRecordLabel', 'remasterCatalogueNumber', 'remasterTitle',
								].map(prop => result.torrent[prop]).filter(Boolean).join(' - ') || 'unknown edition'}`;
								if (result.remarks === true) message += '\n      Identical rips';
								else if (Array.isArray(result.remarks) && result.remarks.length > 0)
									message += '\n' + result.remarks.map(remark => '      ' + 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}`) });
				}

				console.assert(logFields instanceof HTMLElement);
				if (!(logFields instanceof HTMLElement)) return;
				const selector = 'input[type="file"]';
				const setLogWatcher = input => { input.addEventListener('input', logWatcher) };
				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('input', logWatcher);
					}
				});
				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 groupTorrentIds, 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;
		}
	}
}