MB Release Seeding Helper

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

安装此脚本
作者推荐脚本

您可能也喜欢[GMT] Edition lookup by CD TOC

安装此脚本
  1. // ==UserScript==
  2. // @name MB Release Seeding Helper
  3. // @namespace https://greasyfork.org/users/321857-anakunda
  4. // @version 1.10
  5. // @description Give better clues on reusing of existing releases/recordings for 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. // @grant GM_getValue
  12. // @require https://openuserjs.org/src/libs/Anakunda/libStringDistance.min.js
  13. // ==/UserScript==
  14.  
  15. 'use strict';
  16.  
  17. const mbID = /([\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12})/i.source;
  18. const rxMBID = new RegExp(`^${mbID}$`, 'i');
  19. const createElements = (...tagNames) => tagNames.map(Document.prototype.createElement.bind(document));
  20. const mbRequestRate = 1000, mbRequestsCache = new Map, titleDiffThreshold = 5 /* % */;
  21. let mbLastRequest = null;
  22. const debugMode = false;
  23. const timeParser = str => (str = /\b(\d+):(\d+)\b/.exec(str)) != null ?
  24. (parseInt(str[1]) * 60 + parseInt(str[2])) * 1000 : undefined;
  25.  
  26. function mbApiRequest(endPoint, params) {
  27. function errorHandler(response) {
  28. console.error('HTTP error:', response);
  29. let reason = 'HTTP error ' + response.status;
  30. if (response.status == 0) reason += '/' + response.readyState;
  31. let statusText = response.statusText;
  32. if (response.response) try {
  33. if (typeof response.response.error == 'string') statusText = response.response.error;
  34. } catch(e) { }
  35. if (statusText) reason += ' (' + statusText + ')';
  36. return reason;
  37. }
  38. function timeoutHandler(response) {
  39. console.error('HTTP timeout:', response);
  40. let reason = 'HTTP timeout';
  41. if (response.timeout) reason += ' (' + response.timeout + ')';
  42. return reason;
  43. }
  44.  
  45. if (!endPoint) throw 'Endpoint is missing';
  46. const url = new URL('/ws/2/' + endPoint.replace(/^\/+|\/+$/g, ''), document.location.origin);
  47. url.search = new URLSearchParams(Object.assign({ fmt: 'json' }, params));
  48. const cacheKey = url.pathname.slice(6) + url.search;
  49. if (mbRequestsCache.has(cacheKey)) return mbRequestsCache.get(cacheKey);
  50. const recoverableHttpErrors = [429, 500, 502, 503, 504, 520, /*521, */522, 523, 524, 525, /*526, */527, 530];
  51. const request = new Promise(function(resolve, reject) {
  52. function request() {
  53. if (mbLastRequest == Infinity) return setTimeout(request, 50);
  54. const availableAt = mbLastRequest + mbRequestRate, now = Date.now();
  55. if (now < availableAt) return setTimeout(request, availableAt - now); else mbLastRequest = Infinity;
  56. xhr.open('GET', url, true);
  57. xhr.setRequestHeader('Accept', 'application/json');
  58. xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
  59. xhr.send();
  60. }
  61.  
  62. let retryCounter = 0;
  63. const xhr = Object.assign(new XMLHttpRequest, {
  64. responseType: 'json',
  65. timeout: 60e3,
  66. onload: function() {
  67. mbLastRequest = Date.now();
  68. if (this.status >= 200 && this.status < 400) resolve(this.response);
  69. else if (recoverableHttpErrors.includes(this.status))
  70. if (++retryCounter < 60) setTimeout(request, 1000); else reject('Request retry limit exceeded');
  71. else reject(errorHandler(this));
  72. },
  73. onerror: function() { mbLastRequest = Date.now(); reject(errorHandler(this)); },
  74. ontimeout: function() { mbLastRequest = Date.now(); reject(timeoutHandler(this)); },
  75. });
  76. request();
  77. });
  78. mbRequestsCache.set(cacheKey, request);
  79. return request;
  80. }
  81.  
  82. function mbIdExtractor(expr, entity) {
  83. if (!expr || !expr) return null;
  84. let mbId = rxMBID.exec(expr);
  85. if (mbId) return mbId[1].toLowerCase(); else if (!entity) return null;
  86. try { mbId = new URL(expr) } catch(e) { return null }
  87. return mbId.hostname.endsWith('musicbrainz.org')
  88. && (mbId = new RegExp(`^\\/${entity}\\/${mbID}\\b`, 'i').exec(mbId.pathname)) != null ?
  89. mbId[1].toLowerCase() : null;
  90. }
  91.  
  92. function textDiff(...strings) {
  93. if (strings.length < 1 || !strings.some(Boolean)) return [ ];
  94. if (!strings[1]) return [[-1, strings[0]]]; else if (!strings[0]) return [[+1, strings[1]]];
  95. if (strings[0] == strings[1]) return [[0, strings[0]]];
  96. let length, indexes = [ ];
  97. outer: for (length = Math.min(...strings.map(string => string.length)); length > 0; --length)
  98. for (indexes[0] = 0; indexes[0] <= strings[0].length - length; ++indexes[0])
  99. if ((indexes[1] = strings[1].indexOf(strings[0].slice(indexes[0], indexes[0] + length))) >= 0) break outer;
  100. console.assert(!(length > 0) || indexes.every(ndx => ndx >= 0), strings, length, indexes);
  101. return length > 0 ? textDiff(...strings.map((string, index) => string.slice(0, indexes[index])))
  102. .concat([[0, strings[0].slice(indexes[0], indexes[0] + length)]],
  103. textDiff(...strings.map((string, index) => string.slice(indexes[index] + length))))
  104. : [[-1, strings[0]]].concat(strings.slice(1).map(string => [+1, string]));
  105. }
  106.  
  107. function diffsToHTML(elem, diffs, type) {
  108. if (!(elem instanceof HTMLElement) || !Array.isArray(diffs)) throw 'Invalid argument';
  109. while (elem.lastChild != null) elem.removeChild(elem.lastChild);
  110. for (let diff of diffs) if (diff[0] == type) elem.append(Object.assign(document.createElement('span'), {
  111. style: 'color: red;',
  112. textContent: diff[1],
  113. })); else if (diff[0] == 0) elem.append(diff[1]);
  114. }
  115.  
  116. function recalcScore(row) {
  117. if (!(row instanceof HTMLTableRowElement)) return;
  118. if (row.nextElementSibling != null && row.nextElementSibling.matches('tr.similarity-score-detail'))
  119. row.nextElementSibling.remove();
  120. let mbid = row.querySelector('input[type="radio"][name="base-release"]');
  121. if (mbid != null) mbid = mbid.value; else return;
  122. if (dupesTbl.tHead.querySelector('tr > th.similarity-score') == null)
  123. dupesTbl.tHead.rows[0].insertBefore(Object.assign(document.createElement('th'), {
  124. className: 'similarity-score',
  125. textContent: 'Similarity',
  126. }), dupesTbl.tHead.rows[0].cells[2]);
  127. let score = row.querySelector('td.similarity-score');
  128. if (score == null) row.insertBefore(score = Object.assign(document.createElement('td'),
  129. { className: 'similarity-score' }), row.cells[2]);
  130. [score.style, score.onclick] = ['text-align: center; padding: 0.2em 0.5em;', null];
  131. delete score.dataset.score;
  132. const media = Array.from(document.body.querySelectorAll('div#recordings fieldset table#track-recording-assignation'),
  133. (medium, mediumIndex) => ({ tracks: Array.from(medium.querySelectorAll('tbody > tr.track'), function(track, trackIndex) {
  134. let position = track.querySelector('td.position');
  135. position = position != null ? position.textContent.trim() : undefined;
  136. let name = track.querySelector('td.name');
  137. name = name != null ? name.textContent.trim() : undefined;
  138. let length = track.querySelector('td.length');
  139. length = length != null ? timeParser(length.textContent) : undefined;
  140. let artists = track.nextElementSibling != null && track.nextElementSibling.matches('tr.artist') ?
  141. track.nextElementSibling.cells[0] : null;
  142. artists = artists != null && artists.querySelectorAll('span.deleted').length <= 0 ?
  143. Array.from(artists.getElementsByTagName('a'), artist => ({
  144. id: mbIdExtractor(artist.href, 'artist'),
  145. name: artist.textContent.trim(),
  146. join: artist.nextSibling != null && artist.nextSibling.nodeType == Node.TEXT_NODE ?
  147. artist.nextSibling.textContent : undefined,
  148. })).filter(artist => artist.id) : undefined;
  149. if (artists && artists.length <= 0) artists = undefined;
  150. return { title: name, length: length, artists: artists };
  151. }) }));
  152. if (!media.some(medium => medium.tracks.some(track => track.title))) {
  153. score.textContent = '---';
  154. return;
  155. }
  156. document.body.querySelectorAll('div#tracklist fieldset.advanced-medium').forEach(function(medium, mediumIndex) {
  157. let format = medium.querySelector('td.format > select');
  158. format = format != null && format.selectedIndex > 0 ? format.options[format.selectedIndex].text.trim() : undefined;
  159. let title = medium.querySelector('td.format > input[type="text"]');
  160. title = title != null ? title.value.trim() : undefined;
  161. if (media[mediumIndex]) Object.assign(media[mediumIndex], { format: format, title: title });
  162. });
  163. const dataTrackFormats = ['Enhanced CD'];
  164. let mediaTracks = row.querySelector('td[data-bind="text: tracks"]');
  165. if (mediaTracks != null) mediaTracks = mediaTracks.textContent.split(' + ').map(tt => parseInt(tt));
  166. let mediaFormats = row.querySelector('td[data-bind="text: formats"]');
  167. if (mediaFormats != null) mediaFormats = mediaFormats.textContent.split(' + ')
  168. .map(format => !/^(?:Unknown(?: Medium)?|Medium)$/.test(format = format.trim()) ? format : undefined);
  169. let preCheck = mediaTracks && mediaTracks.length > 0 && mediaTracks.every(tt => tt > 0) ?
  170. mediaTracks.length == media.length && mediaTracks.every(function(tt, mediaIndex) {
  171. if (media[mediaIndex].tracks.length == tt) return true;
  172. if (mediaFormats && mediaFormats[mediaIndex] && !dataTrackFormats.includes(mediaFormats[mediaIndex]))
  173. return false;
  174. if (!(medium => medium && (!medium.format || dataTrackFormats.includes(medium.format)))(media[mediaIndex]))
  175. return false;
  176. return media[mediaIndex].tracks.length >= tt;
  177. }) : undefined;
  178. (preCheck != false ? mbApiRequest('release/' + mbid, { inc: 'artist-credits+recordings' }).then(function(release) {
  179. function backgroundByScore(elem, score) {
  180. elem.style.backgroundColor =
  181. `rgb(${Math.round((1 - score) * 0xFF)}, ${Math.round(score * 0xFF)}, 0, 0.25)`;
  182. }
  183.  
  184. if (release.media.length != media.length) throw 'Media counts mismatch';
  185. const [tr, td, table, thead, trackNo, artist1, title1, dur1, artist2, title2, dur2] =
  186. createElements('tr', 'td', 'table', 'thead', 'th', 'th', 'th', 'th', 'th', 'th', 'th');
  187. tr.style = 'background-color: unset;';
  188. table.className = 'media-comparison';
  189. table.style = 'padding-left: 20pt; border-collapse: separate; border-spacing: 0;';
  190. [tr.className, tr.hidden, td.colSpan] = ['similarity-score-detail', true, 10];
  191. [
  192. trackNo.textContent,
  193. artist1.textContent, title1.textContent, dur1.textContent,
  194. artist2.textContent, title2.textContent, dur2.textContent,
  195. ] = ['Pos', 'Release artist', 'Release title', 'Len', 'Seeded artist', 'Seeded title', 'Len'];
  196. [trackNo, artist1, title1, dur1, artist2, title2, dur2]
  197. .forEach(elem => { elem.style = 'padding: 0 5pt; text-align: left;' });
  198. [trackNo, artist1, title1, dur1, artist2, title2, dur2]
  199. .forEach(elem => { elem.style.borderTop = elem.style.borderBottom = 'solid 1px #999' });
  200. [dur1, dur2].forEach(elem => { elem.style.whiteSpace = 'nowrap' });
  201. thead.append(trackNo, artist1, title1, dur1, artist2, title2, dur2); table.append(thead);
  202. const scoreToText = score => (score * 100).toFixed(0) + '%';
  203. const scores = Array.prototype.concat.apply([ ], release.media.map(function(medium, mediumIndex) {
  204. const tracks = (medium.tracks || [ ]).concat(medium['data-tracks'] || [ ]);
  205. if (tracks.length != media[mediumIndex].tracks.length)
  206. throw `Medium ${mediumIndex + 1} tracklist length mismatch`;
  207. const [tbody, thead, mediumNo, mediumTitle1, mediumTitle2] =
  208. createElements('tbody', 'tr', 'td', 'td', 'td');
  209. tbody.className = `medium-${mediumIndex + 1}-tracks`;
  210. [thead.className, thead.style] = ['medium-header', 'font-weight: bold;'];
  211. let mediaTitles = [
  212. '#' + (mediumIndex + 1),
  213. medium.format || 'Unknown medium',
  214. media[mediumIndex].format || 'Unknown medium',
  215. ];
  216. if (medium.title) mediaTitles[1] += ': ' + medium.title;
  217. if (media[mediumIndex].title) mediaTitles[2] += ': ' + media[mediumIndex].title;
  218. [mediumTitle1, mediumTitle2].forEach(elem => { elem.colSpan = 3 });
  219. [mediumNo.textContent, mediumTitle1.textContent, mediumTitle2.textContent] = mediaTitles;
  220. [mediumNo, mediumTitle1, mediumTitle2].forEach(elem =>
  221. { elem.style = 'padding: 3pt 5pt; border-top: dotted 1px #999; border-bottom: dotted 1px #999;' });
  222. mediumNo.style.textAlign = 'right';
  223. thead.append(mediumNo, mediumTitle1, mediumTitle2); tbody.append(thead);
  224. const scores = tracks.map(function(track, trackIndex) {
  225. function insertArtists(elem, artists) {
  226. if (Array.isArray(artists)) artists.forEach(function(artistCredit, index, array) {
  227. elem.append(Object.assign(document.createElement('a'), {
  228. href: `/artist/${artistCredit.id}/recordings`, target: '_blank',
  229. textContent: artistCredit.name,
  230. }));
  231. if (index > array.length - 2) return;
  232. elem.append(artistCredit.join || (index < array.length - 2 ? ', ' : ' & '));
  233. });
  234. }
  235.  
  236. const seedTrack = media[mediumIndex].tracks[trackIndex];
  237. const [tr, trackNo, artist1, title1, dur1, artist2, title2, dur2, recording] =
  238. createElements('tr', 'td', 'td', 'td', 'td', 'td', 'td', 'td', 'a');
  239. const trackTitle = titleFromRecording && track.recording && track.recording.title || track.title;
  240. let score = similarity(seedTrack.title, trackTitle);
  241. [title1, title2].forEach(elem => { backgroundByScore(elem, score); elem.dataset.score = score });
  242. if (score < titleDiffThreshold / 100) {
  243. [title1, title2].forEach(elem => { elem.style.color = 'red' });
  244. tr.classList.add('highlight');
  245. }
  246. const trackLength = timeFromRecording && track.recording && track.recording.length
  247. || timeParser(track.length) || track.length;
  248. if (trackLength > 0 && seedTrack.length > 0) {
  249. let delta = Math.abs(trackLength - seedTrack.length);
  250. if (delta > 5000) score *= 0.1;
  251. [dur1, dur2].forEach(elem => { backgroundByScore(elem, delta > 5000 ? 0 : 1 - delta / 10000) });
  252. }
  253. if (seedTrack.artists) {
  254. if (seedTrack.artists.length != track['artist-credit'].length
  255. || !seedTrack.artists.every(seedArtist => track['artist-credit']
  256. .some(artistCredit => artistCredit.artist.id == seedArtist.id))) {
  257. score *= 0.75;
  258. [artist1, artist2].forEach(elem => { backgroundByScore(elem, 0) });
  259. } else [artist1, artist2].forEach(elem => { backgroundByScore(elem, 1) });
  260. }
  261. trackNo.textContent = trackIndex + 1;
  262. insertArtists(artist1, track['artist-credit'].map(artistCredit => ({
  263. id: artistCredit.artist.id,
  264. name: artistCredit.name,
  265. join: artistCredit.joinphrase,
  266. })));
  267. if (seedTrack.artists) insertArtists(artist2, seedTrack.artists);
  268. else [artist2.textContent, artist2.style.color] = ['???', 'grey'];
  269. [recording.href, recording.target] = ['/recording/' + track.recording.id, '_blank'];
  270. recording.dataset.title = recording.textContent = trackTitle;
  271. recording.style.color = 'inherit';
  272. title1.append(recording);
  273. title2.dataset.title = title2.textContent = seedTrack.title;
  274. [recording, title2].forEach(elem => { elem.className = 'name' });
  275. [dur1.textContent, dur2.textContent] = [trackLength, seedTrack.length].map(length =>
  276. length > 0 ? Math.floor((length = Math.round(length / 1000)) / 60) + ':' +
  277. (length % 60).toString().padStart(2, '0') : '?:??');
  278. [dur1, dur2].forEach(elem => { if (!timeParser(elem.textContent)) elem.style.color = '#aaa' });
  279. [trackNo, artist1, title1, dur1, artist2, title2, dur2]
  280. .forEach(elem => { elem.style.padding = '0 5pt' });
  281. [trackNo, dur1, dur2].forEach(elem => { elem.style.textAlign = 'right' });
  282. [tr.className, tr.title, tr.dataset.score] = ['track', scoreToText(score), score];
  283. tr.append(trackNo, artist1, title1, dur1, artist2, title2, dur2); tbody.append(tr);
  284. for (let td of tr.cells) if (!td.style.backgroundColor) backgroundByScore(td, score);
  285. return score;
  286. });
  287. table.append(tbody);
  288. const loScore = Math.min(...scores);
  289. backgroundByScore(thead, loScore);
  290. const avgScore = scores.reduce((sum, score) => sum + score, 0) / scores.length;
  291. thead.title = `Average score ${scoreToText(avgScore)} (worst: ${scoreToText(loScore)})`;
  292. thead.dataset.score = avgScore;
  293. return scores;
  294. }));
  295. const loScore = Math.min(...scores);
  296. const avgScore = scores.reduce((sum, score) => sum + score, 0) / scores.length;
  297. [score.textContent, score.dataset.score] = [scoreToText(avgScore), avgScore];
  298. score.style.cursor = 'pointer';
  299. score.style.color = '#' + ((Math.round((1 - avgScore) * 0x80) * 2**16) +
  300. (Math.round(avgScore * 0x80) * 2**8)).toString(16).padStart(6, '0');
  301. if (loScore >= 0.8) score.style.fontWeight = 'bold';
  302. backgroundByScore(score, loScore);
  303. score.onclick = function(evt) {
  304. const tr = evt.currentTarget.parentNode.nextElementSibling;
  305. console.assert(tr != null);
  306. if (tr == null) return alert('Assertion failed: table row not exist');
  307. if (!(tr.hidden = !tr.hidden)) for (let row of tr.querySelectorAll('table.media-comparison > tbody > tr.track')) {
  308. if (row.classList.contains('highlight')) continue; else row.classList.add('highlight');
  309. const nodes = row.getElementsByClassName('name');
  310. if (nodes.length < 2) continue; // assertion failed
  311. const diffs = textDiff(...Array.from(nodes, node => node.dataset.title));
  312. diffsToHTML(nodes[0], diffs, -1);
  313. diffsToHTML(nodes[1], diffs, +1);
  314. }
  315. };
  316. score.title = 'Worst track: ' + scoreToText(loScore);
  317. td.append(table); tr.append(td); row.after(tr);
  318. }) : Promise.reject('Media/track counts mismatch')).catch(function(reason) {
  319. score.textContent = /\b(?:mismatch)\b/i.test(reason) ? 'Mismatch' : 'Error';
  320. [score.style.color, score.title] = ['red', reason];
  321. });
  322. }
  323.  
  324. function recalcScores() {
  325. if (debugMode) console.trace('recalcScores()');
  326. for (let tBody of dupesTbl.tBodies) for (let row of tBody.rows) recalcScore(row);
  327. }
  328.  
  329. function installObserver(node, changeListener) {
  330. const mo = new MutationObserver(function(ml) {
  331. for (let mutation of ml) {
  332. for (let node of mutation.removedNodes) changeListener(node, 'remove', true);
  333. for (let node of mutation.addedNodes) changeListener(node, 'add', true);
  334. }
  335. });
  336. mo.observe(node, { childList: true });
  337. return mo;
  338. }
  339.  
  340. function mediumChangeListener(node, action, autoRecalc = false) {
  341. if (debugMode) console.debug('mediumChangeListener(%o)', node);
  342. if (!(node instanceof HTMLTableRowElement)) return; else if (action == 'add') {
  343. if (node.classList.contains('track')) {
  344. const bdi = node.querySelector('td.name > bdi');
  345. console.assert(bdi != null && bdi.firstChild != null, node);
  346. if (bdi != null && bdi.firstChild != null) (node.trackListener = new MutationObserver(recalcScores))
  347. .observe(bdi.firstChild, { characterData: true });
  348. } else if (node.classList.contains('artist')) {
  349. const td = node.cells[0];
  350. console.assert(td instanceof HTMLTableCellElement, node);
  351. if (td instanceof HTMLTableCellElement) (node.trackListener = new MutationObserver(function(ml) {
  352. // for (let mutation of ml) {
  353. // for (let node of mutation.removedNodes) if (node.trackListener instanceof MutationObserver) {
  354. // node.trackListener.disconnect();
  355. // if (debugMode) alert('disconnected 1!');
  356. // }
  357. // for (let node of mutation.addedNodes) if (node.nodeName == 'SPAN') (node.trackListener = new MutationObserver(function(ml) {
  358. // if (debugMode) alert('MutationObserver(TD)');
  359. // if (ml.some(mutation => mutation.addedNodes.length > 0)) {
  360. // recalcScores();
  361. // if (debugMode) alert('recalc()!');
  362. // }
  363. // })).observe(node, { childList: true });
  364. // }
  365. recalcScores();
  366. })).observe(td, { childList: true });
  367. }
  368. } else if (action == 'remove') {
  369. // if (node.classList.contains('artist')) {
  370. // const td = node.cells[0];
  371. // if (td instanceof HTMLTableCellElement) for (let node of td.children)
  372. // if (node.trackListener instanceof MutationObserver) {
  373. // node.trackListener.disconnect();
  374. // if (debugMode) alert('disconnected 2!');
  375. // }
  376. // }
  377. if (node.trackListener instanceof MutationObserver) node.trackListener.disconnect();
  378. }
  379. if (autoRecalc && node.classList.contains('artist')) recalcScores();
  380. }
  381.  
  382. function mediaChangeListener(node, action, autoRecalc = false) {
  383. if (debugMode) console.debug('mediaChangeListener(%o)', node);
  384. if (!(node instanceof HTMLTableSectionElement)) return;
  385. for (let track of node.rows) mediumChangeListener(track, action, false);
  386. if (action == 'add') node.mediumListener = installObserver(node, mediumChangeListener);
  387. else if (action == 'remove' && node.mediumListener instanceof MutationObserver) node.mediumListener.disconnect();
  388. if (autoRecalc) recalcScores();
  389. }
  390.  
  391. function releaseChangeListener1(node, action, autoRecalc = false) {
  392. if (debugMode) console.debug('releaseChangeListener1(%o)', node);
  393. if (!(node instanceof HTMLFieldSetElement)
  394. || (node = node.querySelector('table#track-recording-assignation')) == null) return;
  395. for (let tBody of node.tBodies) mediaChangeListener(tBody, action, false);
  396. if (action == 'add') node.mediaListener = installObserver(node, mediaChangeListener);
  397. else if (action == 'remove' && node.mediaListener instanceof MutationObserver) node.mediaListener.disconnect();
  398. if (autoRecalc) recalcScores();
  399. }
  400.  
  401. function releaseChangeListener2(node, action, autoRecalc = false) {
  402. if (debugMode) console.debug('releaseChangeListener2(%o)', node);
  403. if (!(node instanceof HTMLFieldSetElement) || !node.classList.contains('advanced-medium')
  404. || (node = node.querySelector('table.advanced-format > tbody > tr > td.format')) == null) return;
  405. ['select', 'input[type="text"]'].map(selector => node.querySelector(':scope > ' + selector))
  406. .forEach(elem => { if (elem != null) elem[action + 'EventListener']('change', recalcScores) });
  407. //if (autoRecalc) recalcScores();
  408. }
  409.  
  410. function highlightTrack(tr) {
  411. if (!(tr instanceof HTMLTableRowElement)) throw 'Invalid argument';
  412. if (debugMode) console.trace('highlightTrack(...)');
  413. const bdis = tr.querySelectorAll('td.name > bdi, td.name :not(span.comment) bdi');
  414. const lengths = tr.querySelectorAll('td.length');
  415. bdis.forEach(bdi => { if (bdi.childElementCount <= 0) bdi.dataset.title = bdi.textContent.trim() });
  416. const titles = Array.from(bdis, bdi => bdi.dataset.title);
  417. if (bdis.length < 2 || titles[0] == titles[1]) bdis.forEach(function(bdi) {
  418. bdi.style.color = null;
  419. bdi.style.backgroundColor = titles[0] == titles[1] ? '#0f01' : null;
  420. if (bdi.dataset.title && bdi.childElementCount > 0) bdi.textContent = bdi.dataset.title;
  421. }); else {
  422. const score = similarity(...titles);
  423. bdis.forEach(bdi => { bdi.style.backgroundColor = `rgb(255, 0, 0, ${0.3 - 0.2 * score})` });
  424. console.debug('Score:', score);
  425. if (score < titleDiffThreshold / 100) bdis.forEach(function(bdi) {
  426. bdi.style.color = 'red';
  427. if (bdi.dataset.title && bdi.childElementCount > 0) bdi.textContent = bdi.dataset.title;
  428. }); else {
  429. bdis.forEach(bdi => { bdi.style.color = null });
  430. const diffs = textDiff(...titles);
  431. diffsToHTML(bdis[0], diffs, -1);
  432. diffsToHTML(bdis[1], diffs, +1);
  433. }
  434. }
  435. const times = Array.from(lengths, td => timeParser(td.textContent));
  436. if (times.length >= 2 && times.every(Boolean)) {
  437. const delta = Math.abs(times[0] - times[1]);
  438. let styles = delta > 5000 ? ['color: white', 'font-weight: bold'] : [ ];
  439. styles.push('background-color: ' +
  440. (delta > 5000 ? '#f00' : delta < 1000 ? '#0f01' : `rgb(255, 0, 0, ${delta / 25000})`));
  441. styles = styles.map(style => style + ';').join(' ');
  442. lengths.forEach(td => { td.innerHTML = `<span style="${styles}">${td.textContent}</span>` });
  443. } else lengths.forEach(td => { td.textContent = td.textContent.trim() });
  444. lengths.forEach(td => { td.style.color = timeParser(td.textContent) > 0 ? null : 'grey' });
  445. let artists = tr.nextElementSibling;
  446. if (artists != null && artists.matches('tr.artist') && (artists = artists.cells).length > 0)
  447. for (let as of (artists = Array.from(artists, cell => cell.getElementsByTagName('a')))) for (let a of as)
  448. a.style.backgroundColor = artists.length >= 2 && artists.every(as => as.length > 0)
  449. && (a = mbIdExtractor(a.href, 'artist')) ? artists.every(as =>
  450. Array.prototype.some.call(as, a2 => mbIdExtractor(a2, 'artist') == a)) ? '#0f01' : '#f002' : null;
  451. }
  452.  
  453. function highlightAssocBubble(tr, bubbleTr) {
  454. if (!(tr instanceof HTMLTableRowElement) || !(bubbleTr instanceof HTMLTableRowElement)) throw 'Invalid argument';
  455. if (bubbleTr.querySelector('td.select') == null) return;
  456. const bdi = tr.querySelectorAll('td.name > bdi, td.name :not(span.comment) bdi')[0];
  457. const bubbleName = bubbleTr.querySelector('td.recording bdi');
  458. if (bdi && bubbleName != null) {
  459. const titles = [bdi.dataset.title, bubbleName.textContent.trim()];
  460. if (titles[0] == titles[1]) bubbleName.style.backgroundColor = '#0f01'; else {
  461. const score = similarity(...titles);
  462. bubbleName.style.backgroundColor = `rgb(255, 0, 0, ${0.3 - 0.2 * score})`;
  463. if (score < titleDiffThreshold / 100) bubbleName.style.color = 'red'; else {
  464. const diffs = textDiff(...titles);
  465. diffsToHTML(bubbleName, diffs, +1);
  466. }
  467. }
  468. }
  469. const length = tr.querySelectorAll('td.length')[0], bubbleLength = bubbleTr.querySelector('td.length');
  470. if (length && bubbleLength != null) {
  471. const times = [length, bubbleLength].map(td => timeParser(td.textContent));
  472. if (isNaN(times[1])) bubbleLength.style.color = 'grey';
  473. const delta = Math.abs(times[0] - times[1]);
  474. if (!isNaN(delta)) {
  475. let styles = delta > 5000 ? ['color: white', 'font-weight: bold'] : [ ];
  476. styles.push('background-color: ' +
  477. (delta > 5000 ? '#f00' : delta < 1000 ? '#0f01' : `rgb(255, 0, 0, ${delta / 25000})`));
  478. styles = styles.map(style => style + ';').join(' ');
  479. bubbleLength.innerHTML = `<span style="${styles}">${bubbleLength.textContent}</span>`;
  480. }
  481. }
  482. let artists = tr.nextElementSibling, bubbleArtists = bubbleTr.querySelector('td.artist');
  483. if (artists != null && artists.matches('tr.artist') && (artists = artists.cells[0]) && bubbleArtists != null) {
  484. [artists, bubbleArtists] = [artists, bubbleArtists].map(root => root.getElementsByTagName('a'));
  485. for (let bubbleArtist of bubbleArtists) {
  486. const arid = mbIdExtractor(bubbleArtist.href, 'artist');
  487. if (arid) bubbleArtist.style.backgroundColor = Array.prototype.some.call(artists,
  488. artist => mbIdExtractor(artist, 'artist') == arid) ? '#0f01' : '#f002';
  489. }
  490. }
  491. }
  492.  
  493. const timeFromRecording = GM_getValue('time_from_recording', true);
  494. const titleFromRecording = GM_getValue('title_from_recording', false);
  495. const dupesTbl = document.body.querySelector('div#duplicates-tab > fieldset table');
  496. if (dupesTbl == null) return;
  497. // const similarityAlgo = (strA, strB) => Math.pow(0.985, sift4distance(strA, strB, 5, {
  498. // tokenizer: characterFrequencyTokenizer,
  499. // localLengthEvaluator: rewardLengthEvaluator,
  500. // //transpositionsEvaluator: longerTranspositionsAreMoreCostly,
  501. // }));
  502. const similarityAlgo = (strA, strB) => Math.pow(jaroWinklerSimilarity(strA, strB), 6);
  503. const similarity = (...str) => (str = str.slice(0, 2)).every(Boolean) ?
  504. similarityAlgo(...str.map(title => title.toLowerCase())) : 0;
  505. for (let tBody of dupesTbl.tBodies) new MutationObserver(function(ml) {
  506. for (let mutation of ml) {
  507. for (let node of mutation.removedNodes) if (node.tagName == 'TR' && node.nextElementSibling != null
  508. && node.nextElementSibling.matches('tr.similarity-score-detail')) node.nextElementSibling.remove();
  509. mutation.addedNodes.forEach(recalcScore);
  510. }
  511. }).observe(tBody, { childList: true });
  512. let root = document.querySelector('div#recordings');
  513. if (root != null) new MutationObserver(function(ml) {
  514. for (let mutation of ml) {
  515. const active = mutation.target.style.display != 'none';
  516. mutation.target.querySelectorAll('table#track-recording-assignation > tbody > tr.artist').forEach(function(tr) {
  517. const td = tr.cells[1];
  518. if (active) {
  519. const trTrack = tr.previousElementSibling;
  520. console.assert(trTrack instanceof HTMLTableRowElement && trTrack.classList.contains('track'), tr);
  521. if (trTrack instanceof HTMLTableRowElement && trTrack.classList.contains('track')) highlightTrack(trTrack);
  522. console.assert(td instanceof HTMLTableCellElement, tr);
  523. if (td instanceof HTMLTableCellElement) (tr.recordingListener = new MutationObserver(function(ml) {
  524. for (let mutation of ml) {
  525. for (let node of mutation.removedNodes) if (node.recordingListener instanceof MutationObserver)
  526. node.recordingListener.disconnect();
  527. for (let node of mutation.addedNodes) if (node.nodeName == 'SPAN')
  528. (node.recordingListener = new MutationObserver(ml =>
  529. { if (ml.some(mutation => mutation.addedNodes.length > 0)) highlightTrack(trTrack) }))
  530. .observe(node, { childList: true });
  531. }
  532. highlightTrack(trTrack);
  533. })).observe(td, { childList: true });
  534. } else {
  535. if (td instanceof HTMLTableCellElement) for (let node of td.children)
  536. if (node.recordingListener instanceof MutationObserver) node.recordingListener.disconnect();
  537. if (tr.recordingListener instanceof MutationObserver) tr.recordingListener.disconnect();
  538. }
  539. });
  540. }
  541. }).observe(root, { attributes: true, attributeFilter: ['style'] });
  542. if (root == null || (root = root.querySelector('div.half-width > div')) == null) return;
  543. let fieldsets = root.getElementsByTagName('fieldset');
  544. for (let fieldset of fieldsets) releaseChangeListener1(fieldset, 'add', false);
  545. installObserver(root, releaseChangeListener1);
  546. if (fieldsets.length > 0) recalcScores();
  547. if (debugMode) new MutationObserver(ml => { console.debug('Child list changed:', ml) })
  548. .observe(root, { childList: true, subtree: true });
  549. root = document.body.querySelector('div#tracklist > div[data-bind="with: rootField.release"]');
  550. if (root != null) fieldsets = root.querySelectorAll('fieldset.advanced-medium'); else return;
  551. for (let fieldset of fieldsets) releaseChangeListener2(fieldset, 'add', false);
  552. installObserver(root, releaseChangeListener2);
  553. const assocBubble = document.getElementById('recording-assoc-bubble');
  554. if (assocBubble != null) {
  555. const assocBubbleListener = new MutationObserver(function(ml, mo) {
  556. for (let mutation of ml) {
  557. for (let node of mutation.addedNodes) if (node.nodeName == 'TBODY') {
  558. const tr = assocBubble.bubbleDoc.control.closest('tr.track');
  559. if (!(tr instanceof HTMLTableRowElement)) continue;
  560. (node.recordingListener = new MutationObserver(function(ml, mo) {
  561. for (let mutation of ml) for (let node of mutation.addedNodes)
  562. if (node.nodeName == 'TR') highlightAssocBubble(tr, node);
  563. })).observe(node, { childList: true });
  564. for (let row of node.rows) highlightAssocBubble(tr, row);
  565. }
  566. for (let node of mutation.removedNodes) if (node.nodeName == 'TBODY' && node.recordingListener)
  567. node.recordingListener.disconnect();
  568. }
  569. });
  570. assocBubbleListener.observe(assocBubble.querySelector(':scope > table'), { childList: true });
  571. }