您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Give better clues for reusing of existing releases/recordings in new release
当前为
// ==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) { 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 request = new Promise(function(resolve, reject) { const recoverableHttpErrors = [429, 500, 502, 503, 504, 520, /*521, */522, 523, 524, 525, /*526, */527, 530]; const xhr = new XMLHttpRequest; xhr.responseType = 'json'; xhr.timeout = 60e3; xhr.onerror = function() { mbLastRequest = Date.now(); console.error(this); reject(`HTTP error ${this.status} (${this.statusText})`); }; xhr.ontimeout = function() { mbLastRequest = Date.now(); console.error(this); reject('HTTP timeout'); }; (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; xhr.open('GET', url, true); xhr.setRequestHeader('Accept', 'application/json'); xhr.onload = function() { mbLastRequest = Date.now(); if (this.status >= 200 && this.status < 400) resolve(this.response); else if (recoverableHttpErrors.includes(this.status)) setTimeout(request, 1000, reqCounter + 1); else { console.error(this); reject(`HTTP error ${this.status} (${this.statusText})`) } }; 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); }