MB Release Seeding Helper

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

目前為 2023-08-02 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         MB Release Seeding Helper
// @namespace    https://greasyfork.org/users/321857-anakunda
// @version      1.01
// @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';

let mbLastRequest = null;
const mbRequestRate = 1000, mbRequestsCache = new Map, mbOrigin = 'https://musicbrainz.org';
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 createElements = (...tagNames) => tagNames.map(Document.prototype.createElement.bind(document));

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;
			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);
const diff = new Diff({ timeout: 1000 });
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 });
let root = document.querySelector('div#recordings');
if (root != null) new MutationObserver(function(ml) {
	for (let mutation of ml) if (mutation.target.style.display != 'none') {
		for (let track of mutation.target.querySelectorAll('table#track-recording-assignation > tbody > tr.track')) {
			const bdis = track.querySelectorAll('td.name bdi'), lengths = track.querySelectorAll('td.length');
			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);
			}
			const times = Array.from(lengths, td => (td = /\b(\d+):(\d{2})\b/.exec(td.textContent)) != null
				&& (td = parseInt(td[1]) * 60 + parseInt(td[2])) > 0 ? td : undefined);
			if (times.length >= 2 && times.every(Boolean)) {
				const delta = Math.abs(times[0] - times[1]);
				lengths.forEach(td => { td.innerHTML = `<span style="background-color: rgb(255, 0, 0, ${delta > 5 ? 0.5 : delta / 25});">${td.textContent}</span>` });
			} else lengths.forEach(td => { td.style.backgroundColor = null });
		}
	}
}).observe(root, { attributes: true, attributeFilter: ['style'] });
if (!GM_getValue('mb_score_auto_refresh', true)) return;
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);

}