Greasy Fork

MB Auto Track Lengths from CD TOC

Autoset track lengths from unique CD-TOC

目前为 2024-05-16 提交的版本。查看 最新版本

// ==UserScript==
// @name         MB Auto Track Lengths from CD TOC
// @version      1.12
// @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
// ==/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 === null) return 'No disc IDs attached';
	if (state > 0 && (state & 1 << 4) != 0) return 'TOC lengths successfully applied on tracks';
	else if (state > 0 && (state & 0b1111) == 0b1111) return 'Unique CD TOC available to apply';
	if ((Math.abs(state) & 1) == 0) return 'Track times already match CD TOC';
	const suspiciousText = () =>
		`Suspicious disc ID assigned (${(Math.abs(state) & 0b1110 << 4) != 0 ? 'severe' : 'considerable'} timing differences)`;
	if (state < 0 && (Math.abs(state) & 0b11 << 6) != 0) return suspiciousText();
	if ((Math.abs(state) & 1 << 3) == 0)
		return `Ambiguity: multiple ${state < 0 ? 'suspicious disc IDs' : 'disc IDs'} attached`;
	if (state < 0 && (Math.abs(state) & 0b11 << 4) != 0) return suspiciousText();
}

function linkStyleByState(element, state) {
	console.assert(element instanceof HTMLElement);
	if (!(element instanceof HTMLElement)) throw 'Invalid argument';
	if (element.offsetParent == null || state >= 1 << 4) return;
	if (state <= -0x1000) element.style = 'color: white; background-color: red;';
	if (state < 0 && (Math.abs(state) & 1 << 4) != 0) element.style = 'color: #ab0;';
	if (state < 0 && (Math.abs(state) & 1 << 5) != 0) element.style = 'color: #ba0;';
	if (state < 0 && (Math.abs(state) & 1 << 6) != 0) element.style = 'color: #f00; background-color: #f002;';
	if (state < 0 && (Math.abs(state) & 1 << 7) != 0) element.style = 'color: #f00; background-color: #f004;';
	if ((Math.abs(state) & 1 << 3) == 0) element.style.color =
		`#${(0xB40 + (state < 0 ? 0xF0 : 0) + ((Math.abs(state) & 2) == 0 ? 0xF0 : 0)).toString(16)}`
	else if (state > 0) element.style.backgroundColor = '#0f01';
	if (loggedIn && state > 0 && (state & 0b11111) == 0b01111) element.style.fontWeight = 'bold';
	else if (loggedIn && state < 0 && (Math.abs(state) & 0b11 << 6) != 0) {
		let removeLink = element.closest('tr');
		if (removeLink != null) removeLink = Array.prototype.find.call(removeLink.getElementsByTagName('a'),
			a => a.pathname == '/cdtoc/remove');
		//if (removeLink) removeLink.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 (considerable > 0) state |= 1 << 4;
	if (severe > 0) state |= 1 << 5;
	if (deltas > 0 && considerable * 2 >= deltas) state |= 1 << 6;
	if (deltas > 0 && severe * 2 >= deltas) state |= 1 << 7;
	return state;
}

const getSetLink = row => Array.prototype.find.call(row.querySelectorAll('td a'), a => getRequestparams(a) != null) || null;
const replacedLinkLabel = () => Object.assign(document.createElement('span'), {
	textContent: 'Track lengths successfully set',
	style: 'color: green;',
});
const addStateBits = (state, bits) => state < 0 ? -(-state | bits) : state | bits;

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;
					return 1 << 0;
				}
				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) {
					if (visible) link.replaceWith(replacedLinkLabel());
					return 1 << 0 | 1 << 4;
				});
			}).catch(function(reason) {
				if (animation) animation.cancel();
				link.disabled = false;
				if (Array.isArray(reason)) {
					const state = 1 << 0 | computeDifferenceState(reason, delta => delta > 5, delta => delta > 30);
					console.assert((state & 0b1111 << 4) != 0, reason);
					return -state;
				} else {
					if (visible) Object.assign(link, { style: 'color: white; background-color: red;', title: reason });
					return -0x1000;
				}
			});
		}
		function processMedium(medium) {
			function clickHandler(evt) {
				const link = evt.currentTarget;
				if (!link.disabled) processTrackLengths(link, true)
					.then(state => { if (state < 1 << 4) document.location.assign(link) });
				return false;
			}

			console.assert(medium instanceof HTMLElement);
			if (!(medium instanceof HTMLElement)) throw 'Invalid argument';
			const discIds = getDiscIds(medium), settable = discIds.filter(Boolean);
			if (discIds.length <= 0) return Promise.resolve(null);
			let state = discIds.length == 1 ? 1 << 3 : 0;
			if (settable.length <= 0) return Promise.resolve(state |= 1 << 2); else state |= 1 << 0;
			if (settable.length >= discIds.length) state |= 1 << 1;
			if (discIds.length > 1) {
				if (loggedIn && visible) settable.forEach(function(link) {
					link.onclick = clickHandler;
					processTrackLengths(link, false).then(function(status) {
						const discIdState = addStateBits(status, state);
						if (visible && discIdState < 1 << 4) linkStyleByState(link, addStateBits(discIdState, 1 << 3));
						if (!(discIdState > 0)) link.onclick = null;
						link.dataset.trackLengthsState = discIdState.toString(2);
					});
				});
				return Promise.resolve(state);
			} else {
				if (loggedIn && visible) settable[0].onclick = autoSet ? evt => !evt.currentTarget.disabled : clickHandler;
				return processTrackLengths(settable[0], autoSet).then(function(status) {
					state |= Math.abs(status);
					if (!(status < 0)) state |= 1 << 2;
					if (status < 0) state = -state;
					if (visible && state < 1 << 4) linkStyleByState(settable[0], state);
					if (!(state > 0)) settable[0].onclick = null;
					settable[0].dataset.trackLengthsState = state.toString(2);
					return state;
				});
			}
		}

		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 ? 1 : -state;
					});
					let state = 0;
					if (discIdStates.some(state => state != 0)) state |= 1 << 0;
					if (!discIdStates.some(state => state == 0)) state |= 1 << 1;
					if (!discIdStates.some(state => state < 0)) state |= 1 << 2;
					if (discIdStates.length > 1) return discIdStates.every(state => state < 0) ?
						-(state | -Math.max(...discIdStates)) : state;
					state |= 1 << 3;
					return discIdStates[0] < 0 ? -(state | -discIdStates[0]) : state;
				});
				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 > 0 && (state & 0b11111) == 0b01111))) return states;
			return localXHR(url).then(processRelease);
		});
	} else throw 'Invalid argument';
}

if (document.location.pathname.startsWith('/release/')) {
	let releaseId = getEntity(document.location);
	console.assert(releaseId != null && releaseId[1] == 'release', document.location.pathname);
	if (releaseId != null &&  releaseId[1] == 'release') releaseId = releaseId[2];
		else 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')) {
		saveDiscIdStates(releaseId);
		processRelease(document, autoSet).then(function(states) {
			if (states.every(state => state === null || state == 1 << 2)) saveDiscIdStates(releaseId, states);
		});
		return;
	} else if (li.classList.contains('disabled')) return Promise.reject('Release has no disc IDs attached');
	const animation = flashElement(tabLink);
	processRelease(releaseId, autoSet).then(function(states) {
		if (!states.some(state => state <= -0x1000)) saveDiscIdStates(releaseId,
			states.map(state => state > 0 ? state & ~(1 << 4) : state));
		if (animation) animation.cancel();
		if (states.some(state => state <= -0x1000)) li.style = 'color: white; background-color: red;';
		else if (states.some(state => state < 0 && (Math.abs(state) & 0b1110 << 4) != 0)) li.style.backgroundColor = '#f004';
		else if (states.some(state => state < 0 && (Math.abs(state) & 0b1111 << 4) != 0)) li.style.backgroundColor = '#f002';
		else if (states.some(state => state > 0 && (state & 1 << 4) != 0)) li.style.backgroundColor = '#0f02';
		if (states.some(state => state > 0 && (state & 0b11111) == 0b01111)) li.style.fontWeight = 'bold';
		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) {
				if (state > 0 && (state & 1 << 4) != 0) {
					saveDiscIdStates(release[2]);
					return setLink.replaceWith(replacedLinkLabel());
				}
				if (animation) animation.cancel();
				linkStyleByState(setLink, state);
				setLink.dataset.trackLengthsState = state.toString(2);
				const redirect = (Math.abs(state) & 1 << 3) == 0 ? `/release/${release[2]}/discids` : undefined;
				if (userClick) return document.location.assign(redirect || setLink);
				if (redirect) setLink.href = redirect;
				[setLink.disabled, setLink.onclick] = [false, null];
			}, function(reason) {
				if (animation) animation.cancel();
				setLink.style = 'color: white; background-color: red;';
				[setLink.disabled, setLink.title] = [false, 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);
	});