MB Release Seeding Helper

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

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

  1. // ==UserScript==
  2. // @name MB Release Seeding Helper
  3. // @namespace https://greasyfork.org/users/321857-anakunda
  4. // @version 1.03
  5. // @description Give better clues for reusing of existing releases/recordings in new release
  6. // @match https://*musicbrainz.org/release/add
  7. // @run-at document-end
  8. // @author Anakunda
  9. // @iconURL https://musicbrainz.org/static/images/entity/release.svg
  10. // @license GPL-3.0-or-later
  11. // @require https://openuserjs.org/src/libs/Anakunda/libStringDistance.min.js
  12. // @require https://openuserjs.org/src/libs/Anakunda/libTextDiff.min.js
  13. // ==/UserScript==
  14.  
  15. {
  16.  
  17. 'use strict';
  18.  
  19. const mbID = /([\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12})/i.source;
  20. const rxMBID = new RegExp(`^${mbID}$`, 'i');
  21. const createElements = (...tagNames) => tagNames.map(Document.prototype.createElement.bind(document));
  22. const mbRequestRate = 1000, mbRequestsCache = new Map;
  23. let mbLastRequest = null;
  24.  
  25. function mbApiRequest(endPoint, params) {
  26. if (!endPoint) throw 'Endpoint is missing';
  27. const url = new URL('/ws/2/' + endPoint.replace(/^\/+|\/+$/g, ''), 'https://musicbrainz.org');
  28. url.search = new URLSearchParams(Object.assign({ fmt: 'json' }, params));
  29. const cacheKey = url.pathname.slice(6) + url.search;
  30. if (mbRequestsCache.has(cacheKey)) return mbRequestsCache.get(cacheKey);
  31. const request = new Promise(function(resolve, reject) {
  32. const recoverableHttpErrors = [429, 500, 502, 503, 504, 520, /*521, */522, 523, 524, 525, /*526, */527, 530];
  33. const xhr = new XMLHttpRequest;
  34. xhr.responseType = 'json';
  35. xhr.timeout = 60e3;
  36. xhr.onerror = function() {
  37. mbLastRequest = Date.now();
  38. console.error(this);
  39. reject(`HTTP error ${this.status} (${this.statusText})`);
  40. };
  41. xhr.ontimeout = function() {
  42. mbLastRequest = Date.now();
  43. console.error(this);
  44. reject('HTTP timeout');
  45. };
  46. (function request(reqCounter = 1) {
  47. if (reqCounter > 60) return reject('Request retry limit exceeded');
  48. if (mbLastRequest == Infinity) return setTimeout(request, 50, reqCounter);
  49. const now = Date.now();
  50. if (now <= mbLastRequest + mbRequestRate)
  51. return setTimeout(request, mbLastRequest + mbRequestRate - now, reqCounter);
  52. mbLastRequest = Infinity;
  53. xhr.open('GET', url, true);
  54. xhr.setRequestHeader('Accept', 'application/json');
  55. xhr.onload = function() {
  56. mbLastRequest = Date.now();
  57. if (this.status >= 200 && this.status < 400) resolve(this.response);
  58. else if (recoverableHttpErrors.includes(this.status)) setTimeout(request, 1000, reqCounter + 1);
  59. else {
  60. console.error(this);
  61. reject(`HTTP error ${this.status} (${this.statusText})`)
  62. }
  63. };
  64. xhr.send();
  65. })();
  66. });
  67. mbRequestsCache.set(cacheKey, request);
  68. return request;
  69. }
  70.  
  71. function mbIdExtractor(expr, entity) {
  72. if (!expr || !expr) return null;
  73. let mbId = rxMBID.exec(expr);
  74. if (mbId) return mbId[1].toLowerCase(); else if (!entity) return null;
  75. try { mbId = new URL(expr) } catch(e) { return null }
  76. return mbId.hostname.endsWith('musicbrainz.org')
  77. && (mbId = new RegExp(`^\\/${entity}\\/${mbID}\\b`, 'i').exec(mbId.pathname)) != null ?
  78. mbId[1].toLowerCase() : null;
  79. }
  80.  
  81. function diffsToHTML(elem, diffs, what) {
  82. if (!(elem instanceof HTMLElement) || !Array.isArray(diffs)) throw 'Invalid argument';
  83. while (elem.lastChild != null) elem.removeChild(elem.lastChild);
  84. for (let diff of diffs) if (diff[0] == what) elem.append(Object.assign(document.createElement('span'), {
  85. style: 'color: red;',
  86. textContent: diff[1],
  87. })); else if (diff[0] == DIFF_EQUAL) elem.append(diff[1]);
  88. }
  89.  
  90. function recalcScore(row) {
  91. if (!(row instanceof HTMLTableRowElement)) return;
  92. if (row.nextElementSibling != null && row.nextElementSibling.matches('tr.similarity-score-detail'))
  93. row.nextElementSibling.remove();
  94. let mbid = row.querySelector('input[type="radio"][name="base-release"]');
  95. if (mbid != null) mbid = mbid.value; else return;
  96. if (dupesTbl.tHead.querySelector('tr > th.similarity-score') == null)
  97. dupesTbl.tHead.rows[0].insertBefore(Object.assign(document.createElement('th'), {
  98. className: 'similarity-score',
  99. textContent: 'Similarity',
  100. }), dupesTbl.tHead.rows[0].cells[2]);
  101. let score = row.querySelector('td.similarity-score');
  102. if (score == null) row.insertBefore(score = Object.assign(document.createElement('td'),
  103. { className: 'similarity-score' }), row.cells[2]);
  104. [score.style, score.onclick] = ['text-align: center; padding: 0.2em 0.5em;', null];
  105. delete score.dataset.score;
  106. const media = Array.from(document.body.querySelectorAll('div#recordings fieldset table#track-recording-assignation'),
  107. (medium, mediumIndex) => ({ tracks: Array.from(medium.querySelectorAll('tbody > tr.track'), function(track, trackIndex) {
  108. let position = track.querySelector('td.position');
  109. position = position != null ? position.textContent.trim() : undefined;
  110. let name = track.querySelector('td.name');
  111. name = name != null ? name.textContent.trim() : undefined;
  112. let length = track.querySelector('td.length');
  113. length = length != null && (length = /\b(\d+):(\d+)\b/.exec(length.textContent.trim())) != null ?
  114. (parseInt(length[1]) * 60 + parseInt(length[2])) * 1000 : undefined;
  115. let artists = track.nextElementSibling != null && track.nextElementSibling.matches('tr.artist') ?
  116. track.nextElementSibling.cells[0] : null;
  117. artists = artists != null && artists.querySelectorAll('span.deleted').length <= 0 ?
  118. Array.from(artists.getElementsByTagName('a'), artist => ({
  119. id: mbIdExtractor(artist.href, 'artist'),
  120. name: artist.textContent.trim(),
  121. join: artist.nextSibling != null && artist.nextSibling.nodeType == Node.TEXT_NODE ?
  122. artist.nextSibling.textContent : undefined,
  123. })).filter(artist => artist.id) : undefined;
  124. if (artists && artists.length <= 0) artists = undefined;
  125. return { title: name, length: length, artists: artists };
  126. }) }));
  127. if (!media.some(medium => medium.tracks.some(track => track.title))) {
  128. score.textContent = '---';
  129. return;
  130. }
  131. document.body.querySelectorAll('div#tracklist fieldset.advanced-medium').forEach(function(medium, mediumIndex) {
  132. let format = medium.querySelector('td.format > select');
  133. format = format != null ? (format.options[format.selectedIndex].text).trim() : undefined;
  134. let title = medium.querySelector('td.format > input[type="text"]');
  135. title = title != null ? title.value.trim() : undefined;
  136. if (media[mediumIndex]) Object.assign(media[mediumIndex], { format: format, title: title });
  137. });
  138. let mediaTracks = row.querySelector('td[data-bind="text: tracks"]');
  139. if (mediaTracks != null) mediaTracks = mediaTracks.textContent.split('+').map(tt => parseInt(tt));
  140. mediaTracks = mediaTracks && mediaTracks.length > 0 && mediaTracks.every(tt => tt > 0) ?
  141. mediaTracks.length == media.length && mediaTracks.every((tt, mediaIndex) =>
  142. media[mediaIndex].tracks.length == tt) : undefined;
  143. (mediaTracks != false ? mbApiRequest('release/' + mbid, { inc: 'artist-credits recordings' }).then(function(release) {
  144. function backgroundByScore(elem, score) {
  145. elem.style.backgroundColor =
  146. `rgb(${Math.round((1 - score) * 0xFF)}, ${Math.round(score * 0xFF)}, 0, 0.25)`;
  147. }
  148.  
  149. if (release.media.length != media.length) throw 'Media counts mismatch';
  150. const [tr, td, table, thead, trackNo, artist1, title1, dur1, artist2, title2, dur2] =
  151. createElements('tr', 'td', 'table', 'thead', 'th', 'th', 'th', 'th', 'th', 'th', 'th');
  152. tr.style = 'background-color: unset;';
  153. table.className = 'media-comparison';
  154. table.style = 'padding-left: 20pt; border-collapse: separate; border-spacing: 0;';
  155. [tr.className, tr.hidden, td.colSpan] = ['similarity-score-detail', true, 10];
  156. [
  157. trackNo.textContent,
  158. artist1.textContent, title1.textContent, dur1.textContent,
  159. artist2.textContent, title2.textContent, dur2.textContent,
  160. ] = ['Pos', 'Release artist', 'Release title', 'Len', 'Seeded artist', 'Seeded title', 'Len'];
  161. [trackNo, artist1, title1, dur1, artist2, title2, dur2]
  162. .forEach(elem => { elem.style = 'padding: 0 5pt; text-align: left;' });
  163. [trackNo, artist1, title1, dur1, artist2, title2, dur2]
  164. .forEach(elem => { elem.style.borderTop = elem.style.borderBottom = 'solid 1px #999' });
  165. [dur1, dur2].forEach(elem => { elem.style.whiteSpace = 'nowrap' });
  166. thead.append(trackNo, artist1, title1, dur1, artist2, title2, dur2); table.append(thead);
  167. const scoreToText = score => (score * 100).toFixed(0) + '%';
  168. const scores = Array.prototype.concat.apply([ ], release.media.map(function(medium, mediumIndex) {
  169. if (medium.tracks.length != media[mediumIndex].tracks.length) throw `Medium ${mediumIndex + 1} tracklist length mismatch`;
  170. const [tbody, thead, mediumNo, mediumTitle1, mediumTitle2] =
  171. createElements('tbody', 'tr', 'td', 'td', 'td');
  172. tbody.className = `medium-${mediumIndex + 1}-tracks`;
  173. [thead.className, thead.style] = ['medium-header', 'font-weight: bold;'];
  174. let mediaTitles = [
  175. '#' + (mediumIndex + 1),
  176. medium.format || 'Unknown medium',
  177. media[mediumIndex].format || 'Unknown medium',
  178. ];
  179. if (medium.title) mediaTitles[1] += ': ' + medium.title;
  180. if (media[mediumIndex].title) mediaTitles[2] += ': ' + media[mediumIndex].title;
  181. [mediumTitle1, mediumTitle2].forEach(elem => { elem.colSpan = 3 });
  182. [mediumNo.textContent, mediumTitle1.textContent, mediumTitle2.textContent] = mediaTitles;
  183. [mediumNo, mediumTitle1, mediumTitle2].forEach(elem =>
  184. { elem.style = 'padding: 3pt 5pt; border-top: dotted 1px #999; border-bottom: dotted 1px #999;' });
  185. mediumNo.style.textAlign = 'right';
  186. thead.append(mediumNo, mediumTitle1, mediumTitle2); tbody.append(thead);
  187. const scores = medium.tracks.map(function(track, trackIndex) {
  188. function insertArtists(elem, artists) {
  189. if (Array.isArray(artists)) artists.forEach(function(artistCredit, index, array) {
  190. elem.append(Object.assign(document.createElement('a'), {
  191. href: '/artist/' + artistCredit.id, target: '_blank',
  192. textContent: artistCredit.name,
  193. }));
  194. if (index > array.length - 2) return;
  195. elem.append(artistCredit.join || (index < array.length - 2 ? ', ' : ' & '));
  196. });
  197. }
  198.  
  199. const seedTrack = media[mediumIndex].tracks[trackIndex];
  200. const [tr, trackNo, artist1, title1, dur1, artist2, title2, dur2, recording] =
  201. createElements('tr', 'td', 'td', 'td', 'td', 'td', 'td', 'td', 'a');
  202. const trackTitle = /*track.recording && track.recording.title || */track.title;
  203. let score = similarity(seedTrack.title, trackTitle);
  204. [title1, title2].forEach(elem => { backgroundByScore(elem, score); elem.dataset.score = score });
  205. if (track.recording && track.recording.length > 0 && seedTrack.length > 0) {
  206. let delta = Math.abs(track.recording.length - seedTrack.length);
  207. if (delta > 5000) score *= 0.1;
  208. [dur1, dur2].forEach(elem => { backgroundByScore(elem, delta > 5000 ? 0 : 1 - delta / 10000) });
  209. }
  210. if (seedTrack.artists) {
  211. if (seedTrack.artists.length != track['artist-credit'].length
  212. || !seedTrack.artists.every(seedArtist => track['artist-credit']
  213. .some(artistCredit => artistCredit.artist.id == seedArtist.id))) {
  214. score *= 0.75;
  215. [artist1, artist2].forEach(elem => { backgroundByScore(elem, 0) });
  216. } else [artist1, artist2].forEach(elem => { backgroundByScore(elem, 1) });
  217. }
  218. trackNo.textContent = trackIndex + 1;
  219. insertArtists(artist1, track['artist-credit'].map(artistCredit => ({
  220. id: artistCredit.artist.id,
  221. name: artistCredit.name,
  222. join: artistCredit.joinphrase,
  223. })));
  224. if (seedTrack.artists) insertArtists(artist2, seedTrack.artists);
  225. else [artist2.textContent, artist2.style.color] = ['???', 'grey'];
  226. [recording.href, recording.target] = ['/recording/' + track.recording.id, '_blank'];
  227. recording.dataset.title = recording.textContent = trackTitle;
  228. recording.style.color = 'inherit';
  229. title1.append(recording);
  230. title2.dataset.title = title2.textContent = seedTrack.title;
  231. [recording, title2].forEach(elem => { elem.className = 'name' });
  232. [dur1.textContent, dur2.textContent] = [track.recording && track.recording.length, seedTrack.length]
  233. .map(length => length > 0 ? Math.floor((length = Math.round(length / 1000)) / 60) + ':' +
  234. (length % 60).toString().padStart(2, '0') : '?:??');
  235. [trackNo, artist1, title1, dur1, artist2, title2, dur2]
  236. .forEach(elem => { elem.style.padding = '0 5pt' });
  237. [trackNo, dur1, dur2].forEach(elem => { elem.style.textAlign = 'right' });
  238. [tr.className, tr.title, tr.dataset.score] = ['track', scoreToText(score), score];
  239. tr.append(trackNo, artist1, title1, dur1, artist2, title2, dur2); tbody.append(tr);
  240. tr.cells.forEach(td => { if (!td.style.backgroundColor) backgroundByScore(td, score) });
  241. return score;
  242. });
  243. table.append(tbody);
  244. const loScore = Math.min(...scores);
  245. backgroundByScore(thead, loScore);
  246. const avgScore = scores.reduce((sum, score) => sum + score, 0) / scores.length;
  247. thead.title = `Average score ${scoreToText(avgScore)} (worst: ${scoreToText(loScore)})`;
  248. thead.dataset.score = avgScore;
  249. return scores;
  250. }));
  251. const loScore = Math.min(...scores);
  252. const avgScore = scores.reduce((sum, score) => sum + score, 0) / scores.length;
  253. [score.textContent, score.dataset.score] = [scoreToText(avgScore), avgScore];
  254. score.style.cursor = 'pointer';
  255. score.style.color = '#' + ((Math.round((1 - avgScore) * 0x80) * 2**16) +
  256. (Math.round(avgScore * 0x80) * 2**8)).toString(16).padStart(6, '0');
  257. if (loScore >= 0.8) score.style.fontWeight = 'bold';
  258. backgroundByScore(score, loScore);
  259. score.onclick = function(evt) {
  260. const tr = evt.currentTarget.parentNode.nextElementSibling;
  261. console.assert(tr != null);
  262. if (tr == null) return alert('Assertion failed: table row not exist');
  263. tr.hidden = !tr.hidden;
  264. for (let row of tr.querySelectorAll('table.media-comparison > tbody > tr.track')) {
  265. if (row.classList.contains('highlight')) continue; else row.classList.add('highlight');
  266. const nodes = row.getElementsByClassName('name');
  267. if (nodes.length < 2) continue; // assertion failed
  268. const diffs = textDiff.main(...Array.from(nodes, node => node.dataset.title));
  269. diffsToHTML(nodes[0], diffs, DIFF_DELETE);
  270. diffsToHTML(nodes[1], diffs, DIFF_INSERT);
  271. }
  272. };
  273. score.title = 'Worst track: ' + scoreToText(loScore);
  274. td.append(table); tr.append(td); row.after(tr);
  275. }) : Promise.reject('Media/track counts mismatch')).catch(function(reason) {
  276. score.textContent = /\b(?:mismatch)\b/i.test(reason) ? 'Mismatch' : 'Error';
  277. [score.style.color, score.title] = ['red', reason];
  278. });
  279. }
  280.  
  281. function installObserver(root, changeListener) {
  282. const mo = new MutationObserver(function(ml) {
  283. for (let mutation of ml) {
  284. for (let node of mutation.removedNodes) changeListener(node, 'remove', true);
  285. for (let node of mutation.addedNodes) changeListener(node, 'add', true);
  286. }
  287. });
  288. mo.observe(root, { childList: true });
  289. return mo;
  290. }
  291.  
  292. function changeTrackListener(node, what, autoRecalc = false) {
  293. if (node.nodeType != Node.ELEMENT_NODE || !node.matches('tr.track')) return;
  294. const bdi = node.querySelector('td.name > bdi');
  295. console.assert(bdi != null, 'Failed to select track title', node);
  296. if (bdi != null) if (what == 'add') {
  297. bdi.mo = new MutationObserver(recalcScores);
  298. bdi.mo.observe(bdi.firstChild, { characterData: true });
  299. } else if (what == 'remove' && bdi.mo instanceof MutationObserver) bdi.mo.disconnect();
  300. const artist = node.nextElementSibling != null && node.nextElementSibling.matches('tr.artist') ?
  301. node.nextElementSibling.querySelector(':scope > td:first-of-type') : null;
  302. console.assert(artist != null, 'Failed to select track artist', node);
  303. if (artist != null) if (what == 'add') {
  304. artist.mo = new MutationObserver(ml =>
  305. { if (ml.some(mutation => mutation.addedNodes.length > 0)) recalcScores() });
  306. artist.mo.observe(artist, { childList: true });
  307. } else if (what == 'remove' && artist.mo instanceof MutationObserver) artist.mo.disconnect();
  308. if (what == 'add' && autoRecalc) recalcScores();
  309. }
  310.  
  311. function changeTableObserver(node, what, autoRecalc = false) {
  312. if (node.nodeName == 'TBODY') if (what == 'add') {
  313. const tracks = node.querySelectorAll('tr.track');
  314. tracks.forEach(track => { changeTrackListener(track, what, false) });
  315. node.mo = installObserver(node, changeTrackListener);
  316. if (tracks.length > 0 && autoRecalc) recalcScores();
  317. } else if (what == 'remove' && node.mo instanceof MutationObserver) node.mo.disconnect();
  318. }
  319.  
  320. function changeMediumListener(node, what, autoRecalc = false) {
  321. if (node.nodeName != 'FIELDSET'
  322. || (node = node.querySelector('table#track-recording-assignation')) == null) return;
  323. if (what == 'add') {
  324. node.tBodies.forEach(tBody => { changeTableObserver(tBody, what, false) });
  325. node.mo = installObserver(node, changeTableObserver);
  326. if (node.tBodies.length > 0 && autoRecalc) recalcScores();
  327. } else if (what == 'remove') {
  328. node.tBodies.forEach(tBody => { changeTableObserver(tBody, what) });
  329. if (node.mo instanceof MutationObserver) node.mo.disconnect();
  330. }
  331. }
  332.  
  333. function changeListener(node, what, autoRecalc = false) {
  334. if (node.nodeType != Node.ELEMENT_NODE || !node.matches('fieldset.advanced-medium')
  335. || (node = node.querySelector('table.advanced-format > tbody > tr > td.format')) == null) return;
  336. ['select', 'input[type="text"]'].map(selector => node.querySelector(':scope > ' + selector))
  337. .forEach(elem => { if (elem != null) elem[what + 'EventListener']('change', recalcScores) });
  338. if (what == 'add' && autoRecalc) recalcScores();
  339. }
  340.  
  341. function highlightTrack(tr) {
  342. if (!(tr instanceof HTMLElement)) throw 'Invalid argument';
  343. const bdis = tr.querySelectorAll('td.name bdi'), lengths = tr.querySelectorAll('td.length');
  344. bdis.forEach(bdi => { if (bdi.childElementCount <= 0) bdi.dataset.title = bdi.textContent.trim() });
  345. const titles = Array.from(bdis, bdi => bdi.dataset.title);
  346. if (bdis.length < 2 || titles[0] == titles[1]) bdis.forEach(function(elem) {
  347. elem.style.backgroundColor = titles[0] == titles[1] ? '#0f01' : null;
  348. if (elem.dataset.title && elem.childElementCount > 0) elem.textContent = elem.dataset.title;
  349. }); else {
  350. const score = similarity(...titles);
  351. bdis.forEach(bdi => { bdi.style.backgroundColor = `rgb(255, 0, 0, ${0.3 - 0.2 * score})` });
  352. const diffs = textDiff.main(...titles);
  353. diffsToHTML(bdis[0], diffs, DIFF_DELETE);
  354. diffsToHTML(bdis[1], diffs, DIFF_INSERT);
  355. }
  356. const times = Array.from(lengths, td => (td = /\b(\d+):(\d{2})\b/.exec(td.textContent)) != null
  357. && (td = parseInt(td[1]) * 60 + parseInt(td[2])) > 0 ? td : undefined);
  358. if (times.length >= 2 && times.every(Boolean)) {
  359. const delta = Math.abs(times[0] - times[1]);
  360. let styles = delta > 5 ? ['color: white', 'font-weight: bold'] : [ ];
  361. styles.push('background-color: ' +
  362. (delta > 5 ? '#f00' : delta < 1 ? '#0f01' : `rgb(255, 0, 0, ${delta / 25})`));
  363. styles = styles.map(style => style + ';').join(' ');
  364. lengths.forEach(td => { td.innerHTML = `<span style="${styles}">${td.textContent}</span>` });
  365. } else lengths.forEach(td => { td.textContent = td.textContent.trim() });
  366. let artists = tr.nextElementSibling;
  367. if (artists != null && artists.matches('tr.artist') && (artists = artists.cells).length > 0)
  368. for (let as of (artists = Array.from(artists, cell => cell.getElementsByTagName('a')))) for (let a of as)
  369. a.style.backgroundColor = artists.length >= 2 && artists.every(as => as.length > 0)
  370. && (a = mbIdExtractor(a.href, 'artist')) ? artists.every(as =>
  371. Array.prototype.some.call(as, a2 => mbIdExtractor(a2, 'artist') == a)) ? '#0f01' : '#f002' : null;
  372. }
  373.  
  374. const dupesTbl = document.body.querySelector('div#duplicates-tab > fieldset table');
  375. if (dupesTbl == null) return;
  376. // const similarityAlgo = (strA, strB) => Math.pow(0.985, sift4distance(strA, strB, 5, {
  377. // tokenizer: characterFrequencyTokenizer,
  378. // localLengthEvaluator: rewardLengthEvaluator,
  379. // //transpositionsEvaluator: longerTranspositionsAreMoreCostly,
  380. // }));
  381. const similarityAlgo = (strA, strB) => Math.pow(jaroWinklerSimilarity(strA, strB), 6);
  382. const similarity = (...str) => (str = str.slice(0, 2)).every(Boolean) ?
  383. similarityAlgo(...str.map(title => title.toLowerCase())) : 0;
  384. const textDiff = new TextDiff({ timeout: 1000 });
  385. const recalcScores = () => { for (let tBody of dupesTbl.tBodies) tBody.rows.forEach(recalcScore) };
  386. for (let tBody of dupesTbl.tBodies) new MutationObserver(function(ml) {
  387. for (let mutation of ml) {
  388. for (let node of mutation.removedNodes) if (node.tagName == 'TR' && node.nextElementSibling != null
  389. && node.nextElementSibling.matches('tr.similarity-score-detail')) node.nextElementSibling.remove();
  390. mutation.addedNodes.forEach(recalcScore);
  391. }
  392. }).observe(tBody, { childList: true });
  393. let root = document.querySelector('div#recordings');
  394. if (root != null) new MutationObserver(function(ml) {
  395. for (let mutation of ml) mutation.target.querySelectorAll('table#track-recording-assignation > tbody > tr.track').forEach(function(tr) {
  396. const active = mutation.target.style.display != 'none';
  397. if (active) highlightTrack(tr);
  398. const artistTR = tr.nextElementSibling;
  399. if (artistTR == null || !artistTR.matches('tr.artist') || !artistTR.cells[1]) return; else if (active) {
  400. artistTR.mo = new MutationObserver(ml => { highlightTrack(tr) });
  401. artistTR.mo.observe(artistTR.cells[1], { childList: true });
  402. } else if (artistTR.mo instanceof MutationObserver) {
  403. artistTR.mo.disconnect();
  404. delete artistTR.mo;
  405. }
  406. });
  407. }).observe(root, { attributes: true, attributeFilter: ['style'] });
  408. if (root == null || (root = root.querySelector('div.half-width > div')) == null) return;
  409. const fieldsets = root.getElementsByTagName('fieldset');
  410. fieldsets.forEach(fieldset => { changeMediumListener(fieldset, 'add', false) });
  411. installObserver(root, changeMediumListener);
  412. if (fieldsets.length > 0) recalcScores();
  413. root = document.body.querySelector('div#tracklist > div[data-bind="with: rootField.release"]');
  414. if (root == null) return; else root.querySelectorAll('fieldset.advanced-medium')
  415. .forEach(fieldset => { changeListener(fieldset, 'add', false) });
  416. installObserver(root, changeListener);
  417.  
  418. }