MB Auto Track Lengths from CD TOC

Autoset track lengths from unique CD-TOC when attached.

目前為 2024-05-06 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==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');
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: autosets times in background and reports the status as style/tooltip
Conservative mode: evaluate status and autoset times on click
` });
	let menuId = setMenu();
}

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);
	});