// ==UserScript== // @name MB Auto Track Lengths from CD TOC // @version 1.09 // @match https://musicbrainz.org/release/* // @match https://beta.musicbrainz.org/release/* // @match https://musicbrainz.org/cdtoc/* // @match https://beta.musicbrainz.org/cdtoc/* // @run-at document-end // @author Anakunda // @namespace https://greasyfork.org/users/321857 // @copyright 2024, Anakunda (https://greasyfork.org/users/321857) // @license GPL-3.0-or-later // @description Autoset track lengths from unique CD-TOC // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @require https://openuserjs.org/src/libs/Anakunda/xhrLib.min.js // @downloadURL none // ==/UserScript== 'use strict'; const loggedIn = document.body.querySelector('div.links-container > ul.menu > li.account') != null; if (!loggedIn) console.warn('Not logged in: the script functionality is limited'); let autoSet = loggedIn && GM_getValue('auto_set', true); const flashElement = elem => elem instanceof HTMLElement ? elem.animate([ { offset: 0.0, opacity: 1 }, { offset: 0.4, opacity: 1 }, { offset: 0.5, opacity: 0.1 }, { offset: 0.9, opacity: 0.1 }, ], { duration: 600, iterations: Infinity }) : null; const getTime = str => str ? str.split(':').reverse().reduce((t, v, n) => t + parseInt(v) * Math.pow(60, n), 0) : NaN; if ('mbDiscIdStates' in sessionStorage) try { var discIdStates = JSON.parse(sessionStorage.getItem('mbDiscIdStates')); } catch(e) { console.warn(e) } if (typeof discIdStates != 'object') discIdStates = { }; const getEntity = url => /^\/(\w+)\/([\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12})(?=[\/\?]|$)/i.exec(url.pathname); if (loggedIn) { const setMenu = oldId => GM_registerMenuCommand(`Switch to ${autoSet ? 'conservative' : 'full auto'} mode`, function(param) { GM_setValue('auto_set', autoSet = !autoSet); if (autoSet) document.location.reload(); else menuId = setMenu(menuId); }, { id: oldId, autoClose: false, title: `Operating in ${autoSet ? 'full auto' : 'conservative'} mode Full auto mode: autoset times in background and report the status as style/tooltip Conservative mode: evaluate status in background and autoset times on user click` }); let menuId = setMenu(); } const mbRequestRate = 1000, mbRequestsCache = new Map; let mbLastRequest = null; function apiRequest(endPoint, params) { if (!endPoint) throw 'Endpoint is missing'; const url = new URL('/ws/2/' + endPoint.replace(/^\/+|\/+$/g, ''), 'https://musicbrainz.org'); if (params) for (let key in params) url.searchParams.set(key, params[key]); url.searchParams.set('fmt', 'json'); 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) { 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(); } 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; } 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(); console.error('HTTP timeout:', this); let reason = 'HTTP timeout'; if (this.timeout) reason += ' (' + this.timeout + ')'; reject(reason); }, }); request(); }); mbRequestsCache.set(cacheKey, request); return request; } function saveDiscIdStates(releaseId, states) { if (states) discIdStates[releaseId] = states; else delete discIdStates[releaseId]; sessionStorage.setItem('mbDiscIdStates', JSON.stringify(discIdStates)); } function getRequestparams(link) { console.assert(link instanceof HTMLAnchorElement); if (!(link instanceof HTMLAnchorElement)) throw 'Invalid argument'; const params = { discId: /^\/cdtoc\/([\w\_\.\-]+)\/set-durations$/i.exec(link.pathname) }; if (params.discId != null) params.discId = params.discId[1]; else return null; const query = new URLSearchParams(link.search); if (!query.has('medium')) return null; console.assert(link.textContent.trim() == 'Set track lengths', link); params.mediumId = parseInt(query.get('medium')); console.assert(params.mediumId > 0, link.href); return params.mediumId >= 0 ? params : null; } function tooltipFromState(state) { if (state <= -0x1000) return 'Unhandled error occured (see browser console for more details)'; if (state <= -0x40) return 'Suspicious disc ID assigned (severe timing differences)'; if (state <= -0x20) return 'Suspicious disc ID assigned (considerable timing differences)'; if (state <= -0x10) return 'Ambiguity: multiple suspicious disc IDs attached'; if (state === null) return 'No disc IDs attached'; if (state === 0) return 'Track times already match CD TOC'; if (state >= 0x180) return 'TOC lengths successfully applied on tracks'; if (state >= 0x100) return 'Unique CD TOC available to apply'; if (state >= 0x10) return 'Ambiguity: multiple disc IDs attached'; return `Unknown status 0x${state.toString(16).toUpperCase()}`; } function styleByState(element, state) { console.assert(element instanceof HTMLElement); if (!(element instanceof HTMLElement)) throw 'Invalid argument'; if (state <= -0x1000) element.style = 'color: white; background-color: red;'; else if (state <= -0x40) element.style = 'color: #f00; background-color: #f003;'; else if (state <= -0x20) element.style = 'color: #d20;'; else if (state <= -0x10) element.style = 'color: #b40;'; else if ((state & 0x18) == 0x10) element.style = 'color: #960;'; else if ((state & 0x18) == 0x18) element.style = 'color: #780'; if (state == 0x100 && loggedIn) element.style.fontWeight = 'bold'; element.title = tooltipFromState(state); } function computeDifferenceState(deltas, considerable, severe) { if (!Array.isArray(deltas) || typeof considerable != 'function' || typeof severe != 'function') throw 'Invalid argument'; [considerable, severe] = [considerable, severe].map(fn => deltas.filter(fn).length); deltas = deltas.filter(delta => !isNaN(delta)).length; let state = 0; if (deltas > 0 && severe * 2 >= deltas) state |= 0x100; if (deltas > 0 && considerable * 2 >= deltas) state |= 0x80; if (severe > 0) state |= 0x40; if (considerable > 0) state |= 0x20; return -state; } const getSetLink = row => Array.prototype.find.call(row.querySelectorAll('td a'), a => getRequestparams(a) != null) || null; function processRelease(param, autoSet = true, setParams) { if (param instanceof HTMLDocument) { function getDiscIds(row) { console.assert(row instanceof HTMLElement); const discIds = [ ], isDiscIdRow = row => row instanceof HTMLElement && ['odd', 'even'] .some(cls => row.classList.contains(cls)); while (isDiscIdRow(row = row.nextElementSibling)) discIds.push(getSetLink(row)); return discIds; } function processTrackLengths(link, autoSet = true) { console.assert(link instanceof HTMLAnchorElement); if (!(link instanceof HTMLAnchorElement)) throw 'Invalid argument'; if (loggedIn && autoSet && visible) link.disabled = true; const animation = visible ? flashElement(link) : null; return localXHR(link).then(function(document) { const deltas = Array.from(document.body.querySelectorAll('div#page > table.wrap-block.details'), function(track) { const times = ['old', 'new'].map(function(cls) { if ((cls = track.querySelector('td.' + cls)) != null) cls = cls.textContent; else return NaN; return getTime(cls); }); return Math.abs(times[1] - times[0]); }); return Promise[deltas.some(delta => delta > 5) ? 'reject' : 'resolve'](deltas); }).then(function(deltas) { if (!loggedIn || !autoSet) { if (animation) animation.cancel(); link.disabled = false; if (visible) styleByState(link, 0x100); return 0x100; } const postData = new URLSearchParams({ 'confirm.edit_note': GM_getValue('edit_note', '') }); if (GM_getValue('make_votable', false)) postData.set('confirm.make_votable', 1); return localXHR(link, { responseType: null }, postData).then(function(statusCode) { let title = `Status code: ${statusCode}`; if (deltas.length > 0) title = `Deltas: ${deltas}\n${title}`; if (visible) link.replaceWith(Object.assign(document.createElement('span'), { textContent: 'Track lengths successfully set', style: 'color: green;', title: title, })); return 0x180; }); }).catch(function(reason) { if (animation) animation.cancel(); link.disabled = false; if (Array.isArray(reason)) { const state = computeDifferenceState(reason, delta => delta > 5, delta => delta > 30); if (visible) styleByState(link, state); return state; } else { if (visible) styleByState(link, -0x1000); return -0x1000; } }); } function processMedium(medium) { function clickHandler(evt) { const link = evt.currentTarget; if (!link.disabled) processTrackLengths(link, true) .then(state => { if (state < 0x180) document.location.assign(link) }); return false; } console.assert(medium instanceof HTMLElement); if (!(medium instanceof HTMLElement)) throw 'Invalid argument'; const discIds = getDiscIds(medium); if (discIds.length <= 0) return Promise.resolve(null); const settable = discIds.filter(Boolean); if (discIds.length > 1) { const state = 0x10 | (settable.length < discIds.length ? 0 : 8); if (loggedIn && visible) for (let link of settable) { link.onclick = clickHandler; processTrackLengths(link, false).then(status => { if (status >= 0) styleByState(link, state) }); } return Promise.resolve(state); } else if (settable.length <= 0) return Promise.resolve(0x00); else { if (loggedIn && visible) settable[0].onclick = autoSet ? evt => !evt.currentTarget.disabled : clickHandler; return processTrackLengths(settable[0], autoSet); } } const visible = param == window.document; const media = param.body.querySelectorAll('table.tbl > tbody > tr.subh'); if (media.length <= 0) return Promise.reject('No media found'); else if (setParams && typeof setParams == 'object') { const medium = Array.prototype.find.call(media, medium => getDiscIds(medium).some(function(link) { if (link == null) return false; const requestParams = getRequestparams(link); return requestParams != null && (requestParams.discId == setParams.discId) && (requestParams.mediumId == setParams.mediumId); })); console.assert(medium, setParams, media); return medium ? processMedium(medium) : Promise.reject('Medium not found'); } else return Promise.all(Array.from(media, processMedium)); } else if (param) { const url = `/release/${param}/discids`; if (setParams) return localXHR(url).then(document => processRelease(document, autoSet, setParams)); return (param in discIdStates ? Promise.resolve(discIdStates[param]) : (function getDisdIdStates() { return apiRequest('release/' + param, { inc: 'recordings+discids'}).then(function(release) { const states = release.media.map(function(medium, mediumIndex) { if (!medium.discs || medium.discs.length <= 0) return null; const discIdStates = medium.discs.map(function(discId, tocIndex) { const grpLabel = `Medium ${mediumIndex + 1} / Disc ID ${tocIndex + 1}`; console.groupCollapsed(grpLabel); const deltas = medium.tracks.map(function lengthsEqual(track, index, tracks) { const trackLength = 'length' in track ? typeof track.length == 'number' ? track.length / 1000 : typeof track.length == 'string' ? getTime(track.length) : NaN : NaN; const hiOffset = (index + 1) in discId.offsets ? discId.offsets[index + 1] : discId.sectors; const tocLength = (hiOffset - discId.offsets[index]) / 75; const delta = Math.abs(tocLength - trackLength); console.debug('[%02d] Track length: %.3f (%s), TOC length: %.4f, Delta: %.4f', index + 1, trackLength, track.length, tocLength, delta); return delta; }); console.groupEnd(grpLabel); if (medium.tracks.every(function lengthsEqual(track, index, tracks) { if (typeof track.length != 'number') return false; const hiOffset = (index + 1) in discId.offsets ? discId.offsets[index + 1] : discId.sectors; return track.length == Math.floor((hiOffset - discId.offsets[index]) * 1000 / 75); })) return 0; const state = computeDifferenceState(deltas, delta => delta >= 5.5, delta => delta >= 30.5); return state == 0 ? 0x100 : state; }); if (discIdStates.length == 1) return discIdStates[0]; let state = 0x10; if (!discIdStates.some(state => state == 0)) state |= 8; if (!discIdStates.some(state => state < 0)) state |= 4; return discIdStates.some(state => state > 0) ? state : -state; }); saveDiscIdStates(param, states); return states; }).catch(reason => { console.warn('Disc ID states query failed:', reason, '; falling back to scraping HTML') }); })()).then(function(states) { if (states && !(autoSet && states.some(state => state >= 0x100))) return states; return localXHR(url).then(processRelease); }); } else throw 'Invalid argument'; } if (document.location.pathname.startsWith('/release/')) { function currentPageStates(states) { saveDiscIdStates(release[2], states.every(state => [0x00, null].includes(state)) ? states : undefined); return states; } const release = getEntity(document.location); console.assert(release != null && release[1] == 'release', document.location.pathname); if (release == null) throw 'Failed to identify entity from page url'; let tabLinks = document.body.querySelectorAll('div#page ul.tabs > li a'); tabLinks = Array.prototype.filter.call(tabLinks, a => a.pathname.endsWith('/discids')); console.assert(tabLinks.length == 1, tabLinks); if (tabLinks.length != 1) throw 'Assertion failed: Disc ID tab links mismatch'; const tabLink = tabLinks[0], li = tabLink.closest('li'); console.assert(li != null); if (document.location.pathname.endsWith('/discids') || li.classList.contains('sel')) return processRelease(document, autoSet).then(currentPageStates); else if (li.classList.contains('disabled')) return Promise.reject('Release has no disc IDs attached'); const animation = flashElement(tabLink); processRelease(release[2], autoSet).then(function(states) { console.debug('Media disc ID states:', states); saveDiscIdStates(release[2], states.some(state => state <= -0x1000) ? undefined : states.map(state => state == 0x180 ? 0 : state)); if (states.some(state => state <= -0x1000)) li.style = 'color: white; background-color: red;'; else if (states.some(state => state <= -0x40)) li.style.backgroundColor = '#f004'; else if (states.some(state => state <= -0x20)) li.style.backgroundColor = '#f002'; else if (states.some(state => state == 0x180)) li.style.backgroundColor = '#0f02'; if (states.some(state => state == 0x100)) li.style.fontWeight = 'bold'; if (animation) animation.cancel(); li.title = states.map(tooltipFromState).map((state, index) => `Medium ${index + 1}: ${state}`).join('\n'); }, function(reason) { if (animation) animation.cancel(); [li.style, li.title] = ['color: white; background-color: red;', 'Something went wrong: ' + reason]; }); } else if (document.location.pathname.startsWith('/cdtoc/')) document.body.querySelectorAll('table.tbl > tbody > tr').forEach(function(medium, index) { function processLink(userClick) { setLink.disabled = true; const animation = flashElement(setLink); processRelease(release[2], userClick || autoSet, setparams).then(function(state) { saveDiscIdStates(release[2]); if (state == 0x180) setLink.replaceWith(Object.assign(document.createElement('span'), { textContent: 'Track lengths successfully set', style: 'color: green;', })); else { if (animation) animation.cancel(); styleByState(setLink, state); if (state < 0x100) { const ambiguous = (Math.abs(state) & 0x10) != 0; const redirect = `/release/${release[2]}/discids`; if (userClick) document.location.assign(ambiguous ? redirect : setLink); else if (ambiguous) setLink.href = redirect; } setLink.disabled = false; } }, function(reason) { setLink.style = 'color: white; background-color: red;'; setLink.disabled = false; if (animation) animation.cancel(); setLink.title = reason; }); } let release = Array.prototype.find.call(medium.querySelectorAll(':scope > td a'), a => (a = getEntity(a)) != null && a[1] == 'release'); console.assert(release, medium); if (release) release.pathname += '/discids'; const setLink = getSetLink(medium); if (setLink != null && release) release = getEntity(release); else return; console.assert(release != null, medium); const setparams = getRequestparams(setLink); console.assert(setparams != null, setLink); if (loggedIn) setLink.onclick = autoSet ? evt => !evt.currentTarget.disabled : function(evt) { if (!evt.currentTarget.disabled) processLink(true); return false; }; processLink(false); });