Greasy Fork

Greasy Fork is available in English.

MB Auto Track Lengths from CD TOC

Autoset track lengths from unique CD-TOC

当前为 2024-05-10 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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    http://greasyfork.icu/users/321857
// @copyright    2024, Anakunda (http://greasyfork.icu/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 (state <= -0x1000) element.style = 'color: white; background-color: red;';
	if (state < 0 && (Math.abs(state) & 1 << 4) != 0) element.style = 'color: #780;';
	if (state < 0 && (Math.abs(state) & 1 << 5) != 0) element.style = 'color: #870;';
	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)}`
	if (loggedIn && state > 0 && (state & 0b11111) == 0b01111) 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 (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 replaceLink = link => link.replaceWith(Object.assign(document.createElement('span'), {
	textContent: 'Track lengths successfully set',
	style: 'color: green;',
}));

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) replaceLink(link);
					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);
					if (visible) linkStyleByState(link, -(1 << 3 | state));
					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) {
						status = (state | Math.abs(status)) * Math.sign(status);
						//if (status > -(1 << 4)) linkStyleByState(link, status);
						if (!(status > 0)) link.onclick = null;
						link.dataset.trackLengthsState = status.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 > 0 && (state & 0b11111) == 0b01111) 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 replaceLink(setLink);
				}
				if (animation) animation.cancel();
				linkStyleByState(setLink, state);
				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);
	});