MB Release Seeding Helper

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

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

您需要先安装一个扩展,例如 篡改猴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.03
// @description  Give better clues for reusing of existing releases/recordings in new release
// @match        https://*musicbrainz.org/release/add
// @run-at       document-end
// @author       Anakunda
// @iconURL      https://musicbrainz.org/static/images/entity/release.svg
// @license      GPL-3.0-or-later
// @require      https://openuserjs.org/src/libs/Anakunda/libStringDistance.min.js
// @require      https://openuserjs.org/src/libs/Anakunda/libTextDiff.min.js
// ==/UserScript==

{

'use strict';

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));
const mbRequestRate = 1000, mbRequestsCache = new Map;
let mbLastRequest = null;

function mbApiRequest(endPoint, params) {
	function errorHandler(response) {
		console.error('HTTP error:', response);
		let reason = 'HTTP error ' + response.status;
		if (response.status == 0) reason += '/' + response.readyState;
		let statusText = response.statusText;
		if (response.response) try {
			if (typeof response.response.error == 'string') statusText = response.response.error;
		} catch(e) { }
		if (statusText) reason += ' (' + statusText + ')';
		return reason;
	}
	function timeoutHandler(response) {
		console.error('HTTP timeout:', response);
		let reason = 'HTTP timeout';
		if (response.timeout) reason += ' (' + response.timeout + ')';
		return reason;
	}

	if (!endPoint) throw 'Endpoint is missing';
	const url = new URL('/ws/2/' + endPoint.replace(/^\/+|\/+$/g, ''), 'https://musicbrainz.org');
	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 recoverableHttpErrors = [429, 500, 502, 503, 504, 520, /*521, */522, 523, 524, 525, /*526, */527, 530];
	const request = new Promise(function(resolve, reject) {
		let retryCounter = 0;
		const xhr = Object.assign(new XMLHttpRequest, {
			responseType: 'json',
			timeout: 60e3,
			onload: function() {
				mbLastRequest = Date.now();
				if (this.status >= 200 && this.status < 400) resolve(this.response);
				else if (recoverableHttpErrors.includes(this.status))
					if (++retryCounter < 60) setTimeout(request, 1000); else reject('Request retry limit exceeded');
				else reject(errorHandler(this));
			},
			onerror: function() { mbLastRequest = Date.now(); reject(errorHandler(this)); },
			ontimeout: function() { mbLastRequest = Date.now(); reject(timeoutHandler(this)); },
		});
		(function request() {
			if (mbLastRequest == Infinity) return setTimeout(request, 50);
			const availableAt = mbLastRequest + mbRequestRate, now = Date.now();
			if (now < availableAt) return setTimeout(request, availableAt - now); else mbLastRequest = Infinity;
			xhr.open('GET', url, true);
			xhr.setRequestHeader('Accept', 'application/json');
			xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
			xhr.send();
		})();
	});
	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;
			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 = textDiff.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();
}

function highlightTrack(tr) {
	if (!(tr instanceof HTMLElement)) throw 'Invalid argument';
	const bdis = tr.querySelectorAll('td.name bdi'), lengths = tr.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) {
		elem.style.backgroundColor = titles[0] == titles[1] ? '#0f01' : 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})` });
		const diffs = textDiff.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]);
		let styles = delta > 5 ? ['color: white', 'font-weight: bold'] : [ ];
		styles.push('background-color: ' +
			(delta > 5 ? '#f00' : delta < 1 ? '#0f01' : `rgb(255, 0, 0, ${delta / 25})`));
		styles = styles.map(style => style + ';').join(' ');
		lengths.forEach(td => { td.innerHTML = `<span style="${styles}">${td.textContent}</span>` });
	} else lengths.forEach(td => { td.textContent = td.textContent.trim() });
	let artists = tr.nextElementSibling;
	if (artists != null && artists.matches('tr.artist') && (artists = artists.cells).length > 0)
		for (let as of (artists = Array.from(artists, cell => cell.getElementsByTagName('a')))) for (let a of as)
			a.style.backgroundColor = artists.length >= 2 && artists.every(as => as.length > 0)
				&& (a = mbIdExtractor(a.href, 'artist')) ? artists.every(as =>
					Array.prototype.some.call(as, a2 => mbIdExtractor(a2, 'artist') == a)) ? '#0f01' : '#f002' : null;
}

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 textDiff = new TextDiff({ timeout: 1000 });
const recalcScores = () => { for (let tBody of dupesTbl.tBodies) tBody.rows.forEach(recalcScore) };
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) mutation.target.querySelectorAll('table#track-recording-assignation > tbody > tr.track').forEach(function(tr) {
		const active = mutation.target.style.display != 'none';
		if (active) highlightTrack(tr);
		const artistTR = tr.nextElementSibling;
		if (artistTR == null || !artistTR.matches('tr.artist') || !artistTR.cells[1]) return; else if (active) {
			artistTR.mo = new MutationObserver(ml => { highlightTrack(tr) });
			artistTR.mo.observe(artistTR.cells[1], { childList: true });
		} else if (artistTR.mo instanceof MutationObserver) {
			artistTR.mo.disconnect();
			delete artistTR.mo;
		}
	});
}).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);

}