// ==UserScript== // @name MB Auto Track Lengths // @version 1.04 // @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 for media from unique CD-TOC when attached. // @grant GM_getValue // @require https://openuserjs.org/src/libs/Anakunda/xhrLib.min.js // @downloadURL none // ==/UserScript== 'use strict'; 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; function processDocument(document, mode = 2) { 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; } const isDiscIdRow = row => row instanceof HTMLElement && ['odd', 'even'].some(cls => row.classList.contains(cls)); const getSetLink = row => Array.prototype.find.call(row.querySelectorAll('td a'), a => getRequestparams(a) != null) || null; let rows = document.body.querySelectorAll('table.tbl > tbody > tr.subh'), groups = [ ]; if (rows.length > 0) rows.forEach(function(row) { const discIds = [ ]; while (isDiscIdRow(row = row.nextElementSibling)) discIds.push(getSetLink(row)); groups.push(discIds); }); else { rows = document.body.querySelectorAll('table.tbl > tbody > tr'); groups = Array.prototype.filter.call(rows, isDiscIdRow).map(getSetLink); } return groups.length > 0 ? Promise.all(groups.map(function(group) { function setTrackLengths(link, makeVotable = false) { console.assert(link instanceof HTMLAnchorElement); if (!(link instanceof HTMLAnchorElement)) throw 'Invalid argument'; if (link.disabled) return Promise.resolve(undefined); else link.disabled = true; const animation = flashElement(link); 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 (mode == 1) return 10; const postData = new URLSearchParams({ 'confirm.edit_note': '' }); if (makeVotable) 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; link.replaceWith(Object.assign(document.createElement('span'), { textContent: 'Track lengths successfully set', style: 'color: green;', title: title, })); return 20; }); }).catch(function(reason) { if (animation != null) animation.cancel(); link.style.color = 'red'; link.title = reason; link.disabled = false; return Array.isArray(reason) ? reason.some(delta => delta > 30) ? -20 : -10 : -100; }); } if (!(mode > 0) || group.length > 1) { for (let link of group.filter(Boolean)) link.onclick = function(evt) { setTrackLengths(link = evt.currentTarget) .then(status => { if (status <= -10) document.location.assign(link) }); return false; }; return 1; } else if (group.length > 0) group = group.filter(Boolean); return group.length == 1 ? setTrackLengths(group[0]) : 0; })) : Promise.reject('No disc ids found'); } if (document.location.pathname.startsWith('/release/')) { function releaseScanned() { if (entity == null || scannedReleaseIds.includes(entity[2])) return; scannedReleaseIds.push(entity[2]); sessionStorage.setItem('scanned_discids', JSON.stringify(scannedReleaseIds)); } let scannedReleaseIds = [ ]; if ('scanned_discids' in sessionStorage) try { scannedReleaseIds = JSON.parse(sessionStorage.getItem('scanned_discids')); } catch(e) { console.warn(e) } const entity = /^\/(\w+)\/([\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12})(?=[\/\?]|$)/i.exec(document.location.pathname); console.assert(entity != null && entity[1] == 'release', document.location.pathname); let tabLinks = document.body.querySelectorAll('div#page ul.tabs > li a'); tabLinks = Array.prototype.filter.call(tabLinks, a => a.href.endsWith('/discids')); console.assert(tabLinks.length == 1, tabLinks); if (document.location.pathname.endsWith('/discids')) processDocument(document); if (tabLinks.length == 1 && (entity == null || !scannedReleaseIds.includes(entity[2]))) (function(tabLink) { const li = tabLink.closest('li'); console.assert(li != null); if (li.classList.contains('disabled')) { releaseScanned(); return Promise.reject('Release has no disc ids attached'); } else if (li.classList.contains('sel')) { releaseScanned(); return Promise.reject('Disc ids is current tab'); } const autoSet = GM_getValue('auto_set', true), animation = flashElement(tabLink); const processUrl = url => localXHR(url).then(document => processDocument(document, autoSet ? 2 : 1)); const requestParams = new URLSearchParams({ inc: 'recordings+discids', fmt: 'json' }) return localXHR(`/ws/2/${entity[1]}/${entity[2]}?${requestParams}`, { responseType: 'json' }).then(function(release) { const statuses = release.media.map(function(medium) { if (!medium.discs || medium.discs.length <= 0) return -2; const discIds = medium.discs.map(function(discId) { 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; const tocLength = Math.floor((hiOffset - discId.offsets[index]) * 1000 / 75); //console.debug('Track %d length: %d, TOC length: %d', (index + 1), track.length, tocLength); return track.length == tocLength; })) return 0; const deltas = medium.tracks.map(function lengthsEqual(track, index, tracks) { const length = '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; return Math.abs(((hiOffset - discId.offsets[index]) / 75) - length); }); return deltas.some(delta => delta > 30) ? -20 : deltas.some(delta => delta > 5) ? -10 : autoSet ? 20 : 10; }); return discIds.length > 1 ? 1 : discIds[0]; }); return statuses.some(status => status >= 20) ? processUrl(tabLink) : Promise.resolve(statuses); }).catch(function(reason) { console.warn('MB API request failed:', reason, ', falling back to scraping HTML'); return processUrl(tabLink); }).then(function(statuses) { if (animation != null) animation.cancel(); const tooltips = [ ]; if (statuses.some(status => status == 20)) { li.style.backgroundColor = '#0f02'; tooltips.push('TOC lengths successfully applied to tracks'); } if (statuses.some(status => status <= -100)) { li.style.backgroundColor = '#f006'; tooltips.push('Network error occured'); } else if (statuses.some(status => status <= -10)) li.style.backgroundColor = '#f002'; if (statuses.some(status => status == -20)) tooltips.push('Potentially wrong TOC assigned (severe timing differences)'); else if (statuses.some(status => status == -10)) tooltips.push('Potentially wrong TOC assigned (considerable timing differences)'); if (statuses.some(status => status == -2)) tooltips.push('Some media have no disc ids attached'); if (statuses.some(status => status == 10)) { li.style.fontWeight = 'bold'; tooltips.push('CD TOC available to apply'); } if (statuses.some(status => status == 1)) tooltips.push('Ambiguity: multiple TOCs attached to medium'); if (statuses.some(status => status == 0)) tooltips.push('TOC already applied'); if (tooltips.length <= 0) tooltips.push('Status codes: ' + statuses); li.title = tooltips.join('\n'); if (statuses.every(status => status > -10)) releaseScanned(); return statuses; }, function(reason) { if (animation != null) animation.cancel(); [li.style.color, li.style.backgroundColor] = ['white', 'red']; li.title = `Failed to scan disc ids (${reason})`; return Promise.reject(reason); }); })(tabLinks[0]); else releaseScanned(); } else if (document.location.pathname.startsWith('/cdtoc/')) processDocument(document);