// ==UserScript==
// @name MB Auto Track Lengths from CD TOC
// @version 1.08
// @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 when attached.
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @require https://openuserjs.org/src/libs/Anakunda/xhrLib.min.js
// ==/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');
const 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) GM_registerMenuCommand(`Switch to ${autoSet ? 'conservative' : 'full auto'} mode`, function(param) {
GM_setValue('auto_set', !autoSet);
document.location.reload();
});
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 == -0x110) return 'Suspicious disc ID assigned (severe timing differences)';
if (state == -0x100) return 'Suspicious disc ID assigned (considerable timing differences)';
if (state > -0x20 && 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 >= 0x10 && state < 0x20) return 'Ambiguity: multiple disc IDs attached';
if (state == 0x100) return 'Unique CD TOC available to apply';
if (state == 0x180) return 'TOC lengths successfully applied on tracks';
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, makeVotable = false) {
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.style.fontWeight = 'bold';
link.title = tooltipFromState(0x100);
return 0x100;
}
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 0x180;
});
}).catch(function(reason) {
if (animation) animation.cancel();
link.disabled = false;
if (!Array.isArray(reason)) {
link.style = 'color: red; background-color: #f004;';
link.title = reason;
return -0x1000;
} else {
const state = -(0x100 | (reason.some(delta => delta > 30) ? 0x10 : 0));
link.style = state < -0x100 ? 'color: red; background-color: #f002;' : 'color: red;';
link.title = tooltipFromState(state);
return state;
}
});
}
function processMedium(medium) {
function clickHandler(evt) {
const link = evt.currentTarget;
if (!link.disabled) processTrackLengths(link)
.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 (loggedIn && visible) for (let link of settable) {
link.onclick = autoSet ? evt => !evt.currentTarget.disabled : clickHandler;
if (!autoSet) processTrackLengths(link, false);
}
return discIds.length > 1 ? Promise.resolve(0x10 | (discIds.length >= settable.length ? 8 : 0))
: settable.length <= 0 ? Promise.resolve(0x00) : 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() {
const requestParams = new URLSearchParams({ inc: 'recordings+discids', fmt: 'json' })
return localXHR(`/ws/2/release/${param}?${requestParams}`, { responseType: 'json' }).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 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('[%d/%d/%02d] Track length: %.3f (%s), TOC length: %.4f, Delta: %.4f',
mediumIndex + 1, tocIndex + 1, index + 1, trackLength, track.length, tocLength, delta);
return delta;
});
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;
if (deltas.some(delta => delta >= 30.5)) return -0x110;
if (deltas.some(delta => delta >= 5.5)) return -0x100;
return 0x100;
});
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 (document.location.pathname.endsWith('/discids')) processRelease(document, autoSet).then(currentPageStates);
else 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 (li.classList.contains('disabled')) return Promise.reject('Release has no disc IDs attached');
else if (li.classList.contains('sel')) return processRelease(document, autoSet).then(currentPageStates);
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.backgroundColor = '#f006';
else if (states.some(state => state <= -0x110)) li.style.backgroundColor = '#f004';
else if (states.some(state => state <= -0x100)) 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();
if (state <= -0x100) setLink.style = 'color: red;';
if (state == 0x100) setLink.style.fontWeight = 'bold';
if (state < 0x100) {
const ambiguous = state > -0x20 && state <= -0x10 || state >= 0x10 && state < 0x20;
const redirect = `/release/${release[2]}/discids`;
if (userClick) document.location.assign(ambiguous ? redirect : setLink);
else if (ambiguous) setLink.href = redirect;
}
setLink.disabled = false;
setLink.title = tooltipFromState(state);
}
}, function(reason) {
setLink.style = 'color: red; background-color: #f002;';
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);
});