MB Release Seeding Helper

Give better clues for reusing of existing releases/recordings in new release

当前为 2023-08-02 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         MB Release Seeding Helper
// @namespace    https://greasyfork.org/users/321857-anakunda
// @version      1.00
// @description  Give better clues for reusing of existing releases/recordings in new release
// @match        https://*musicbrainz.org/release/add
// @run-at       document-end
// @grant        GM_getValue
// @grant        GM_setValue
// @author       Anakunda
// @iconURL      https://upload.wikimedia.org/wikipedia/commons/9/9e/MusicBrainz_Logo_%282016%29.svg
// @license      GPL-3.0-or-later
// @require      https://openuserjs.org/src/libs/Anakunda/xhrLib.min.js
// @require      https://openuserjs.org/src/libs/Anakunda/libStringDistance.min.js
// @require      https://openuserjs.org/src/libs/Anakunda/libTextDiff.min.js
// ==/UserScript==

{

'use strict';

const mbRequestsCache = new Map;
let mbLastRequest = null;
const mbOrigin = 'https://musicbrainz.org';
const createElements = (...tagNames) => tagNames.map(Document.prototype.createElement.bind(document));
const mbID = /([\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12})/i.source;
const rxMBID = new RegExp(`^${mbID}$`, 'i');

const mbRequestRate = 1000;
function mbApiRequest(endPoint, params) {
	if (!endPoint) throw 'Endpoint is missing';
	const url = new URL('/ws/2/' + endPoint.replace(/^\/+|\/+$/g, ''), mbOrigin);
	url.search = new URLSearchParams(Object.assign({ fmt: 'json' }, params));
	const cacheKey = url.pathname.slice(6) + url.search;
	if (mbRequestsCache.has(cacheKey)) return mbRequestsCache.get(cacheKey);
	const request = new Promise((resolve, reject) => { (function request(reqCounter = 1) {
		if (reqCounter > 60) return reject('Request retry limit exceeded');
		if (mbLastRequest == Infinity) return setTimeout(request, 50, reqCounter);
		const now = Date.now();
		if (now <= mbLastRequest + mbRequestRate)
			return setTimeout(request, mbLastRequest + mbRequestRate - now, reqCounter);
		mbLastRequest = Infinity;
		localXHR(url, { responseType: 'json' }).then(function(response) {
			mbLastRequest = Date.now();
			resolve(response);
		}, function(reason) {
			mbLastRequest = Date.now();
			reject(reason);
		});
	})() });
	mbRequestsCache.set(cacheKey, request);
	return request;
}

function mbIdExtractor(expr, entity) {
	if (!expr || !expr) return null;
	let mbId = rxMBID.exec(expr);
	if (mbId) return mbId[1].toLowerCase(); else if (!entity) return null;
	try { mbId = new URL(expr) } catch(e) { return null }
	return mbId.hostname.endsWith('musicbrainz.org')
		&& (mbId = new RegExp(`^\\/${entity}\\/${mbID}\\b`, 'i').exec(mbId.pathname)) != null ?
			mbId[1].toLowerCase() : null;
}

function diffsToHTML(elem, diffs, what) {
	if (!(elem instanceof HTMLElement) || !Array.isArray(diffs)) throw 'Invalid argument';
	while (elem.lastChild != null) elem.removeChild(elem.lastChild);
	for (let diff of diffs) if (diff[0] == what) elem.append(Object.assign(document.createElement('span'), {
		style: 'color: red;',
		textContent: diff[1],
	})); else if (diff[0] == DIFF_EQUAL) elem.append(diff[1]);
}

function recalcScore(row) {
	if (!(row instanceof HTMLTableRowElement)) return;
	if (row.nextElementSibling != null && row.nextElementSibling.matches('tr.similarity-score-detail'))
		row.nextElementSibling.remove();
	let mbid = row.querySelector('input[type="radio"][name="base-release"]');
	if (mbid != null) mbid = mbid.value; else return;
	if (dupesTbl.tHead.querySelector('tr > th.similarity-score') == null)
		dupesTbl.tHead.rows[0].insertBefore(Object.assign(document.createElement('th'), {
			className: 'similarity-score',
			textContent: 'Similarity',
		}), dupesTbl.tHead.rows[0].cells[2]);
	let score = row.querySelector('td.similarity-score');
	if (score == null) row.insertBefore(score = Object.assign(document.createElement('td'),
		{ className: 'similarity-score' }), row.cells[2]);
	[score.style, score.onclick] = ['text-align: center; padding: 0.2em 0.5em;', null];
	delete score.dataset.score;
	const media = Array.from(document.body.querySelectorAll('div#recordings fieldset table#track-recording-assignation'),
		(medium, mediumIndex) => ({ tracks: Array.from(medium.querySelectorAll('tbody > tr.track'), function(track, trackIndex) {
			let position = track.querySelector('td.position');
			position = position != null ? position.textContent.trim() : undefined;
			let name = track.querySelector('td.name');
			name = name != null ? name.textContent.trim() : undefined;
			let length = track.querySelector('td.length');
			length = length != null && (length = /\b(\d+):(\d+)\b/.exec(length.textContent.trim())) != null ?
				(parseInt(length[1]) * 60 + parseInt(length[2])) * 1000 : undefined;
			let artists = track.nextElementSibling != null && track.nextElementSibling.matches('tr.artist') ?
				track.nextElementSibling.cells[0] : null;
			artists = artists != null && artists.querySelectorAll('span.deleted').length <= 0 ?
				Array.from(artists.getElementsByTagName('a'), artist => ({
					id: mbIdExtractor(artist.href, 'artist'),
					name: artist.textContent.trim(),
					join: artist.nextSibling != null && artist.nextSibling.nodeType == Node.TEXT_NODE ?
						artist.nextSibling.textContent : undefined,
				})).filter(artist => artist.id) : undefined;
			if (artists && artists.length <= 0) artists = undefined;
			return { title: name, length: length, artists: artists };
		}) }));
	if (!media.some(medium => medium.tracks.some(track => track.title))) {
		score.textContent = '---';
		return;
	}
	document.body.querySelectorAll('div#tracklist fieldset.advanced-medium').forEach(function(medium, mediumIndex) {
		let format = medium.querySelector('td.format > select');
		format = format != null ? (format.options[format.selectedIndex].text).trim() : undefined;
		let title = medium.querySelector('td.format > input[type="text"]');
		title = title != null ? title.value.trim() : undefined;
		if (media[mediumIndex]) Object.assign(media[mediumIndex], { format: format, title: title });
	});
	let mediaTracks = row.querySelector('td[data-bind="text: tracks"]');
	if (mediaTracks != null) mediaTracks = mediaTracks.textContent.split('+').map(tt => parseInt(tt));
	mediaTracks = mediaTracks && mediaTracks.length > 0 && mediaTracks.every(tt => tt > 0) ?
		mediaTracks.length == media.length && mediaTracks.every((tt, mediaIndex) =>
			media[mediaIndex].tracks.length == tt) : undefined;
	(mediaTracks != false ? mbApiRequest('release/' + mbid, { inc: 'artist-credits recordings' }).then(function(release) {
		function backgroundByScore(elem, score) {
			elem.style.backgroundColor =
				`rgb(${Math.round((1 - score) * 0xFF)}, ${Math.round(score * 0xFF)}, 0, 0.25)`;
		}

		if (release.media.length != media.length) throw 'Media counts mismatch';
		const [tr, td, table, thead, trackNo, artist1, title1, dur1, artist2, title2, dur2] =
			createElements('tr', 'td', 'table', 'thead', 'th', 'th', 'th', 'th', 'th', 'th', 'th');
		tr.style = 'background-color: unset;';
		table.className = 'media-comparison';
		table.style = 'padding-left: 20pt; border-collapse: separate; border-spacing: 0;';
		[tr.className, tr.hidden, td.colSpan] = ['similarity-score-detail', true, 10];
		[
			trackNo.textContent,
			artist1.textContent, title1.textContent, dur1.textContent,
			artist2.textContent, title2.textContent, dur2.textContent,
		] = ['Pos', 'Release artist', 'Release title', 'Len', 'Seeded artist', 'Seeded title', 'Len'];
		[trackNo, artist1, title1, dur1, artist2, title2, dur2]
			.forEach(elem => { elem.style = 'padding: 0 5pt; text-align: left;' });
		[trackNo, artist1, title1, dur1, artist2, title2, dur2]
			.forEach(elem => { elem.style.borderTop = elem.style.borderBottom = 'solid 1px #999' });
		[dur1, dur2].forEach(elem => { elem.style.whiteSpace = 'nowrap' });
		thead.append(trackNo, artist1, title1, dur1, artist2, title2, dur2); table.append(thead);
		const scoreToText = score => (score * 100).toFixed(0) + '%';
		const scores = Array.prototype.concat.apply([ ], release.media.map(function(medium, mediumIndex) {
			if (medium.tracks.length != media[mediumIndex].tracks.length) throw `Medium ${mediumIndex + 1} tracklist length mismatch`;
			const [tbody, thead, mediumNo, mediumTitle1, mediumTitle2] =
				createElements('tbody', 'tr', 'td', 'td', 'td');
			tbody.className = `medium-${mediumIndex + 1}-tracks`;
			[thead.className, thead.style] = ['medium-header', 'font-weight: bold;'];
			let mediaTitles = [
				'#' + (mediumIndex + 1),
				medium.format || 'Unknown medium',
				media[mediumIndex].format || 'Unknown medium',
			];
			if (medium.title) mediaTitles[1] += ': ' + medium.title;
			if (media[mediumIndex].title) mediaTitles[2] += ': ' + media[mediumIndex].title;
			[mediumTitle1, mediumTitle2].forEach(elem => { elem.colSpan = 3 });
			[mediumNo.textContent, mediumTitle1.textContent, mediumTitle2.textContent] = mediaTitles;
			[mediumNo, mediumTitle1, mediumTitle2].forEach(elem =>
				{ elem.style = 'padding: 3pt 5pt; border-top: dotted 1px #999; border-bottom: dotted 1px #999;' });
			mediumNo.style.textAlign = 'right';
			thead.append(mediumNo, mediumTitle1, mediumTitle2); tbody.append(thead);
			const scores = medium.tracks.map(function(track, trackIndex) {
				function insertArtists(elem, artists) {
					if (Array.isArray(artists)) artists.forEach(function(artistCredit, index, array) {
						elem.append(Object.assign(document.createElement('a'), {
							href: '/artist/' + artistCredit.id, target: '_blank',
							textContent: artistCredit.name,
						}));
						if (index > array.length - 2) return;
						elem.append(artistCredit.join || (index < array.length - 2 ? ', ' : ' & '));
					});
				}

				const seedTrack = media[mediumIndex].tracks[trackIndex];
				const [tr, trackNo, artist1, title1, dur1, artist2, title2, dur2, recording] =
					createElements('tr', 'td', 'td', 'td', 'td', 'td', 'td', 'td', 'a');
				const trackTitle = /*track.recording && track.recording.title || */track.title;
				let score = similarity(seedTrack.title, trackTitle);
				[title1, title2].forEach(elem => { backgroundByScore(elem, score); elem.dataset.score = score });
				if (track.recording && track.recording.length > 0 && seedTrack.length > 0) {
					let delta = Math.abs(track.recording.length - seedTrack.length);
					if (delta > 5000) score *= 0.1;
					[dur1, dur2].forEach(elem => { backgroundByScore(elem, delta > 5000 ? 0 : 1 - delta / 10000) });
				}
				if (seedTrack.artists) {
					if (seedTrack.artists.length != track['artist-credit'].length
							|| !seedTrack.artists.every(seedArtist => track['artist-credit']
								.some(artistCredit => artistCredit.artist.id == seedArtist.id))) {
						score *= 0.75;
						[artist1, artist2].forEach(elem => { backgroundByScore(elem, 0) });
					} else [artist1, artist2].forEach(elem => { backgroundByScore(elem, 1) });
				}
				trackNo.textContent = trackIndex + 1;
				insertArtists(artist1, track['artist-credit'].map(artistCredit => ({
					id: artistCredit.artist.id,
					name: artistCredit.name,
					join: artistCredit.joinphrase,
				})));
				if (seedTrack.artists) insertArtists(artist2, seedTrack.artists);
					else [artist2.textContent, artist2.style.color] = ['???', 'grey'];
				[recording.href, recording.target] = ['/recording/' + track.recording.id, '_blank'];
				recording.dataset.title = recording.textContent = trackTitle;
				recording.style.color = 'inherit';
				title1.append(recording);
				title2.dataset.title = title2.textContent = seedTrack.title;
				[recording, title2].forEach(elem => { elem.className = 'name' });
				[dur1.textContent, dur2.textContent] = [track.recording && track.recording.length, seedTrack.length]
					.map(length => length > 0 ? Math.floor((length = Math.round(length / 1000)) / 60) + ':' +
						(length % 60).toString().padStart(2, '0') : '?:??');
				[trackNo, artist1, title1, dur1, artist2, title2, dur2]
					.forEach(elem => { elem.style.padding = '0 5pt' });
				[trackNo, dur1, dur2].forEach(elem => { elem.style.textAlign = 'right' });
				[tr.className, tr.title, tr.dataset.score] = ['track', scoreToText(score), score];
				tr.append(trackNo, artist1, title1, dur1, artist2, title2, dur2); tbody.append(tr);
				tr.cells.forEach(td => { if (!td.style.backgroundColor) backgroundByScore(td, score) });
				return score;
			});
			table.append(tbody);
			const loScore = Math.min(...scores);
			backgroundByScore(thead, loScore);
			const avgScore = scores.reduce((sum, score) => sum + score, 0) / scores.length;
			thead.title = `Average score ${scoreToText(avgScore)} (worst: ${scoreToText(loScore)})`;
			thead.dataset.score = avgScore;
			return scores;
		}));
		const loScore = Math.min(...scores);
		const avgScore = scores.reduce((sum, score) => sum + score, 0) / scores.length;
		[score.textContent, score.dataset.score] = [scoreToText(avgScore), avgScore];
		score.style.cursor = 'pointer';
		score.style.color = '#' + ((Math.round((1 - avgScore) * 0x80) * 2**16) +
			(Math.round(avgScore * 0x80) * 2**8)).toString(16).padStart(6, '0');
		if (loScore >= 0.8) score.style.fontWeight = 'bold';
		backgroundByScore(score, loScore);
		score.onclick = function(evt) {
			const tr = evt.currentTarget.parentNode.nextElementSibling;
			console.assert(tr != null);
			if (tr == null) return alert('Assertion failed: table row not exist');
			tr.hidden = !tr.hidden;
			if (!useDiff) return;
			const diff = new Diff({ timeout: 1000 });
			for (let row of tr.querySelectorAll('table.media-comparison > tbody > tr.track')) {
				if (row.classList.contains('highlight')) continue; else row.classList.add('highlight');
				const nodes = row.getElementsByClassName('name');
				if (nodes.length < 2) continue; // assertion failed
				const diffs = diff.main(...Array.from(nodes, node => node.dataset.title));
				diffsToHTML(nodes[0], diffs, DIFF_DELETE);
				diffsToHTML(nodes[1], diffs, DIFF_INSERT);
			}
		};
		score.title = 'Worst track: ' + scoreToText(loScore);
		td.append(table); tr.append(td); row.after(tr);
	}) : Promise.reject('Media/track counts mismatch')).catch(function(reason) {
		score.textContent = /\b(?:mismatch)\b/i.test(reason) ? 'Mismatch' : 'Error';
		[score.style.color, score.title] = ['red', reason];
	});
}

function installObserver(root, changeListener) {
	const mo = new MutationObserver(function(ml) {
		for (let mutation of ml) {
			for (let node of mutation.removedNodes) changeListener(node, 'remove', true);
			for (let node of mutation.addedNodes) changeListener(node, 'add', true);
		}
	});
	mo.observe(root, { childList: true });
	return mo;
}

function changeTrackListener(node, what, autoRecalc = false) {
	if (node.nodeType != Node.ELEMENT_NODE || !node.matches('tr.track')) return;
	const bdi = node.querySelector('td.name > bdi');
	console.assert(bdi != null, 'Failed to select track title', node);
	if (bdi != null) if (what == 'add') {
		bdi.mo = new MutationObserver(recalcScores);
		bdi.mo.observe(bdi.firstChild, { characterData: true });
	} else if (what == 'remove' && bdi.mo instanceof MutationObserver) bdi.mo.disconnect();
	const artist = node.nextElementSibling != null && node.nextElementSibling.matches('tr.artist') ?
		node.nextElementSibling.querySelector(':scope > td:first-of-type') : null;
	console.assert(artist != null, 'Failed to select track artist', node);
	if (artist != null) if (what == 'add') {
		artist.mo = new MutationObserver(ml =>
			{ if (ml.some(mutation => mutation.addedNodes.length > 0)) recalcScores() });
		artist.mo.observe(artist, { childList: true });
	} else if (what == 'remove' && artist.mo instanceof MutationObserver) artist.mo.disconnect();
	if (what == 'add' && autoRecalc) recalcScores();
}

function changeTableObserver(node, what, autoRecalc = false) {
	if (node.nodeName == 'TBODY') if (what == 'add') {
		const tracks = node.querySelectorAll('tr.track');
		tracks.forEach(track => { changeTrackListener(track, what, false) });
		node.mo = installObserver(node, changeTrackListener);
		if (tracks.length > 0 && autoRecalc) recalcScores();
	} else if (what == 'remove' && node.mo instanceof MutationObserver) node.mo.disconnect();
}

function changeMediumListener(node, what, autoRecalc = false) {
	if (node.nodeName != 'FIELDSET'
			|| (node = node.querySelector('table#track-recording-assignation')) == null) return;
	if (what == 'add') {
		node.tBodies.forEach(tBody => { changeTableObserver(tBody, what, false) });
		node.mo = installObserver(node, changeTableObserver);
		if (node.tBodies.length > 0 && autoRecalc) recalcScores();
	} else if (what == 'remove') {
		node.tBodies.forEach(tBody => { changeTableObserver(tBody, what) });
		if (node.mo instanceof MutationObserver) node.mo.disconnect();
	}
}

function changeListener(node, what, autoRecalc = false) {
	if (node.nodeType != Node.ELEMENT_NODE || !node.matches('fieldset.advanced-medium')
			|| (node = node.querySelector('table.advanced-format > tbody > tr > td.format')) == null) return;
	['select', 'input[type="text"]'].map(selector => node.querySelector(':scope > ' + selector))
		.forEach(elem => { if (elem != null) elem[what + 'EventListener']('change', recalcScores) });
	if (what == 'add' && autoRecalc) recalcScores();
}

const dupesTbl = document.body.querySelector('div#duplicates-tab > fieldset table');
if (dupesTbl == null) return;
// const similarityAlgo = (strA, strB) => Math.pow(0.985, sift4distance(strA, strB, 5, {
// 	tokenizer: characterFrequencyTokenizer,
// localLengthEvaluator: rewardLengthEvaluator,
// 	//transpositionsEvaluator: longerTranspositionsAreMoreCostly,
// }));
const similarityAlgo = (strA, strB) => Math.pow(jaroWinklerSimilarity(strA, strB), 6);
const similarity = (...str) => (str = str.slice(0, 2)).every(Boolean) ?
	similarityAlgo(...str.map(title => title.toLowerCase())) : 0;
const recalcScores = () => { for (let tBody of dupesTbl.tBodies) tBody.rows.forEach(recalcScore) };
const useDiff = GM_getValue('mb_use_diff', true);
for (let tBody of dupesTbl.tBodies) new MutationObserver(function(ml) {
	for (let mutation of ml) {
		for (let node of mutation.removedNodes) if (node.tagName == 'TR' && node.nextElementSibling != null
				&& node.nextElementSibling.matches('tr.similarity-score-detail')) node.nextElementSibling.remove();
		mutation.addedNodes.forEach(recalcScore);
	}
}).observe(tBody, { childList: true });
if (!GM_getValue('mb_score_auto_refresh', true)) return;
let root = document.querySelector('div#recordings');
if (root != null) new MutationObserver(function(ml) {
	for (let mutation of ml) if (mutation.target.style.display != 'none') {
		const diff = useDiff ? new Diff({ timeout: 1000 }) : null;
		for (let track of mutation.target.querySelectorAll('table#track-recording-assignation > tbody > tr.track')) {
			const bdis = track.querySelectorAll('td.name bdi');
			bdis.forEach(bdi => { if (bdi.childElementCount <= 0) bdi.dataset.title = bdi.textContent.trim() });
			const titles = Array.from(bdis, bdi => bdi.dataset.title);
			if (bdis.length < 2 || titles[0] == titles[1]) bdis.forEach(function(elem) {
				if (elem.style.backgroundColor) elem.style.backgroundColor = null;
				if (elem.dataset.title && elem.childElementCount > 0) elem.textContent = elem.dataset.title;
			}); else {
				const score = similarity(...titles);
				bdis.forEach(bdi => { bdi.style.backgroundColor = `rgb(255, 0, 0, ${0.3 - 0.2 * score})` });
				if (!useDiff) continue;
				const diffs = diff.main(...titles);
				diffsToHTML(bdis[0], diffs, DIFF_DELETE);
				diffsToHTML(bdis[1], diffs, DIFF_INSERT);
			}
		}
	}
}).observe(root, { attributes: true, attributeFilter: ['style'] });
if (root == null || (root = root.querySelector('div.half-width > div')) == null) return;
const fieldsets = root.getElementsByTagName('fieldset');
fieldsets.forEach(fieldset => { changeMediumListener(fieldset, 'add', false) });
installObserver(root, changeMediumListener);
if (fieldsets.length > 0) recalcScores();
root = document.body.querySelector('div#tracklist > div[data-bind="with: rootField.release"]');
if (root == null) return; else root.querySelectorAll('fieldset.advanced-medium')
	.forEach(fieldset => { changeListener(fieldset, 'add', false) });
installObserver(root, changeListener);

}