Greasy Fork

来自缓存

Greasy Fork is available in English.

[RED] Similar CD Detector

Simple script for testing CD releases for duplicity

当前为 2023-04-07 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         [RED] Similar CD Detector
// @namespace    http://greasyfork.icu/users/321857-anakunda
// @version      1.06
// @description  Simple script for testing CD releases for duplicity
// @match        https://redacted.ch/torrents.php?id=*
// @match        https://redacted.ch/torrents.php?page=*&id=*
// @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
// ==/UserScript==

{

'use strict';

const dupePrecheckInLinkbox = false; // set to true to have own rip dupe precheck in group page linkbox
const maxRemarks = 60, allowReports = true;
const requestsCache = new Map, getTorrentIds = (...trs) => trs.map(getTorrentId);
let selected = null;

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

function logFileValidator(logFile) {
	if (!/^(?:Exact Audio Copy|EAC|X Lossless Decoder|CUERipper|EZ CD Audio Converter)\b/.test(logFile)) return false;
	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;
}

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';
}
function testSimilarity(...torrentIds) {
	function getTocEntries(logFile) {
		if (!logFile) 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',
		];
		let tocEntries = tocParsers.reduce((m, rx) => m || logFile.match(new RegExp(rx, 'gm')), null);
		return tocEntries != null && (tocEntries = tocEntries.map(function(tocEntry, trackNdx) {
			if ((tocEntry = tocParsers.reduce((m, rx) => m || new RegExp(rx).exec(tocEntry), null)) == null)
				throw `assertion failed: track ${trackNdx + 1} ToC entry invalid format`;
			console.assert(msfToSector(tocEntry[2]) == parseInt(tocEntry[12]));
			console.assert(msfToSector(tocEntry[7]) == parseInt(tocEntry[13]) + 1 - parseInt(tocEntry[12]));
			return {
				trackNumber: parseInt(tocEntry[1]),
				startSector: parseInt(tocEntry[12]),
				endSector: parseInt(tocEntry[13]),
			};
		})).length > 0 ? tocEntries : null;
	}

	if (torrentIds.length < 2 || !torrentIds.every(Boolean)) return Promise.reject('Invalid argument');
	const msfTime = '(?:(\\d+):)?(\\d+):(\\d+)[\\.\\:](\\d+)';
	const msfToSector = time => Array.isArray(time) || (time = new RegExp('^\\s*' + msfTime + '\\s*$').exec(time)) != null ?
		(((time[1] ? parseInt(time[1]) : 0) * 60 + parseInt(time[2])) * 60 + parseInt(time[3])) * 75 + parseInt(time[4]) : NaN;
	return Promise.all(torrentIds.map(function loadLogFile(torrentId) {
		if (typeof torrentId == 'string' && torrentId > 0) torrentId = parseInt(torrentId);
		else if (Array.isArray(torrentId) && torrentId.every(e => typeof e == 'string'))
			return Promise.resolve(torrentId);
		if (requestsCache.has(torrentId)) return requestsCache.get(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 => stackedLogReducer(response)))) : Promise.reject('No logfiles attached'));
		let request = localXHR('/torrents.php?' + new URLSearchParams({ action: 'loglist', torrentid: torrentId }))
			.then(document => Array.from(document.body.querySelectorAll(':scope > blockquote > pre:first-child'), pre =>
				stackedLogReducer(pre.textContent.trimLeft())));
		request = request.then(logFiles => (logFiles = logFiles.filter(logFileValidator)).length > 0 ?
			logFiles : Promise.reject('No valid logfiles attached'));
		requestsCache.set(torrentId, request);
		return request;
	})).then(function(logfiles) {
		if (logfiles.some(lf1 => logfiles.some(lf2 => lf1.length != lf2.length))) throw 'disc count mismatch';
		const remarks = [ ];
		for (let volumeNdx = 0; volumeNdx < logfiles[0].length; ++volumeNdx) {
			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 && index == tocEntries.length - 2 ? 1 : -1;
				}
				return 0;
			}

			const isRangeRip = logfiles.map(logfiles => rxRangeRip.test(logfiles[volumeNdx])), volRemarks = { };
			if (isRangeRip.some(Boolean))
				remarks.push(`disc ${volumeNdx + 1} having at least one release as range rip, skipping peaks comparison`);
			const tocEntries = logfiles.map(logfiles => getTocEntries(logfiles[volumeNdx]));
			if (tocEntries.some(toc => toc == null)) throw `disc ${volumeNdx + 1} ToC not found for at least one release`;
			for (const _tocEntries of tocEntries) {
				const layoutType = getlayoutType(_tocEntries);
				if (layoutType == 1) _tocEntries.pop(); // ditch data track for CD Extra
				else if (layoutType != 0) remarks.push(`disc ${volumeNdx + 1} unknown layout type`);
			}
			if (tocEntries[0].length != tocEntries[1].length) throw `disc ${volumeNdx + 1} ToC lengths mismatch`;
			const trackRecords = logfiles.map(logfiles => logfiles[volumeNdx].match(/^(?:Track|Трек|Òðåê|音轨|Traccia|Spår|Pista|Трак|Utwór|Stopa)\s+\d+[^\S\r\n]*$\r?\n(?:^(?:[^\S\r\n]+.*)?$\r?\n)*/gm));
			if (trackRecords.some((trackRecords, ndx) => !isRangeRip[ndx] && trackRecords == null))
				throw `disc ${volumeNdx + 1} no track records could be extracted for at least one rip`;
			else if (!isRangeRip.some(Boolean) && trackRecords[0].length != trackRecords[1].length)
				throw `disc ${volumeNdx + 1} 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 ${volumeNdx + 1} ${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[0][trackNdx].endSector);
			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);
			const label = `ToC comparison for tid${torrentIds[0]} and tid${torrentIds[1]} disc ${volumeNdx + 1}`;
			console.group(label);
			console.table(tocEntries[0].map((_, trackNdx) => ({
				['track#']: trackNdx + 1,
				['start' + torrentIds[0]]: tocEntries[0][trackNdx].startSector,
				['end' + torrentIds[0]]: tocEntries[0][trackNdx].endSector,
				['length' + torrentIds[0]]: tocEntries[0][trackNdx].endSector + 1 - tocEntries[0][trackNdx].startSector,
				['start' + torrentIds[1]]: tocEntries[1][trackNdx].startSector,
				['end' + torrentIds[1]]: tocEntries[1][trackNdx].endSector,
				['length' + torrentIds[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 ${volumeNdx + 1} ToC shift above ${tocThresholds[index].maxShift} sectors`;
					if (tocDrift > tocThresholds[index].maxDrift)
						throw `disc ${volumeNdx + 1} ToC drift above ${tocThresholds[index].maxDrift} sectors`;
				} catch(reason) { tryIndex(reason, index + 1) } else throw reason || 'unknown reason';
			})();
			if (tocDrift > 0) remarks.push(`Disc ${volumeNdx + 1} shifted ToCs by ${tocShift} sectors with ${tocDrift} sectors drift`);
			else if (tocShifts[0] != 0) remarks.push(`Disc ${volumeNdx + 1} shifted ToCs by ${tocShift} sectors`);
			if (hasPostGap) remarks.push(`Disc ${volumeNdx + 1} 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');
			});
			// 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)\\s*:\\s*(\\d+(?:\\.\\d+)?)',
			], m => parseFloat(m[1]) / 100, m => parseFloat(m[1]), function(peak1, peak2, trackNdx) {
				if (peak1 == undefined && !isRangeRip[0] || peak2 == undefined && !isRangeRip[1])
					throw `disc ${volumeNdx + 1} track ${trackNdx + 1} peak missing or invalid format`;
				if (isRangeRip.some(Boolean)) return;
				if (Math.round(Math.abs(peak2 - peak1) * 1000) >= Math.round(maxPeakDelta * 1000))
					throw `disc ${volumeNdx + 1} track ${trackNdx + 1} peak difference above ${maxPeakDelta}`;
				else if (Math.round(peak2 * 1000) != Math.round(peak1 * 1000)) addTrackRemark(trackNdx, 'peak levels mismatch');
			});
			// Compare checksums - just informational
			if (!isRangeRip.every(Boolean)) processTrackValues([
				'(?:(?:Copy|复制|Kopie|Copia|Kopiera|Copiar|Копиран) CRC|CRC (?:копии|êîïèè|kopii|kopije|kopie|kópie)|コピーCRC)\\s+([\\da-fA-F]{8})', // 1272
				'(?:CRC32 hash)\\s*:\\s*([\\da-fA-F]{8})',
			], m => parseInt(m[1], 16), m => parseInt(m[1], 16), 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 in volRemarks)
				remarks.push(`Disc ${volumeNdx + 1} track ${parseInt(trackNdx) + 1}: ${volRemarks[trackNdx].join(', ')}`);
			const timeStamps = logfiles.map(logfiles => /^(EAC|XLD) (?:extraction logfile from) (.+)$/m.exec(logfiles[volumeNdx]));
			if (timeStamps.every(Boolean) && timeStamps.map(timeStamp => timeStamp[0]).every((timeStamp, ndx, arr) => timeStamp == arr[0]))
				remarks.push(`Disc ${volumeNdx + 1} originates in same ripping session`);
		}
		if (remarks.filter(remark => remark.endsWith('originates in same ripping session')).length == logfiles[0].length) return true;
		return remarks;
	});
}

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

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$/, '');
}

function findLog() {
	const dialog = document.createElement('DIALOG');
	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 (only single disc albums supported)</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="Check for duplicity" 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 = evt => { document.body.removeChild(evt.currentTarget) };
	let form = dialog.firstElementChild;
	form.onsubmit = function(evt) {
		const logFilesAdaptor = logFiles => (logFiles = logFiles.map(stackedLogReducer)
			.filter(logFileValidator)).length > 0 ? Promise.resolve(logFiles) : Promise.reject('No valid input');
		logFilesAdaptor(Array.from((form = evt.currentTarget).querySelectorAll('[name="cd-log-text"] textarea'),
				textArea => textArea.value.trimLeft())).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(logFiles) {
			Promise.all(torrents.map(torrent => testSimilarity(logFiles, ...getTorrentIds(torrent)).then(remarks => ({
				torrent: torrent,
				remarks: remarks,
			}), reason => null))).then(function(results) {
				if ((results = results.filter(Boolean)).length > 0) {
					let message = 'This mastering is considered dupe to ' + getEditionTitle(results[0].torrent);
					if (results.length > 1) message += ` and ${results.length - 1} others`;
					if (results[0].remarks === true) message += '\n\nIdentical rips';
					else if (Array.isArray(results[0].remarks)) message += '\n\n' + results[0].remarks.join('\n');
					alert(message);
				} else alert('This mastering is unique within the release group');
			});
		}, alert);
	};
	form.elements.namedItem('close').onclick = evt => { dialog.close() };
	document.body.append(dialog);
	dialog.showModal();
}

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 > 0) {
	const linkbox = document.body.querySelector('div.header > div.linkbox');
	if (dupePrecheckInLinkbox && linkbox != null) {
		const a = document.createElement('A');
		a.onclick = evt => (findLog(), false);
		a.href = '#';
		a.className = 'brackets';
		a.textContent = 'Check CD uniqueness';
		a.title = 'Verify if ripped CD is considered distinct edition within the release group';
		linkbox.append(' ', a);
	}
	GM_registerMenuCommand('CD rip duplicity precheck', findLog, 'c');
}
if (torrents.length < 2) return;

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 edition as [url=${permaLink}]\${editiontitle}[/url] within tolerance possible (${shorten(0, 'same ToCs')}${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 suggested 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) throw 'Torrent table header not found';
const span = document.createElement('SPAN');
span.className = 'brackets';
span.textContent = 'Find CD dupes';
span.style = 'margin-left: 5pt; margin-right: 5pt; float: right; cursor: pointer; font-size: 8pt;';
span.onclick = scanGroup;
container.append(span);

}