Greasy Fork

Greasy Fork is available in English.

MB Auto Track Lengths from CD TOC

Autoset track lengths from unique CD-TOC when attached.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         MB Auto Track Lengths from CD TOC
// @version      1.07
// @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 when attached.
// @grant        GM_getValue
// @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);

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

const getSetLink = row => Array.prototype.find.call(row.querySelectorAll('td a'), a => getRequestparams(a) != null) || null;

function processRelease(param, 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, makeVotable = false) {
			console.assert(link instanceof HTMLAnchorElement);
			if (!(link instanceof HTMLAnchorElement)) throw 'Invalid argument';
			if (link.disabled) return Promise.reject('Already processed');
			if (loggedIn && autoSet) 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 (!loggedIn || !autoSet) 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) {
				link.disabled = false;
				if (animation) animation.cancel();
				if (!Array.isArray(reason)) {
					link.style = 'color: red; background-color: #f002;';
					link.title = reason;
					return -0x1000;
				} else if (reason.some(delta => delta > 30)) {
					link.style = 'color: red; background-color: #f002; font-weight: bold;';
					link.title = 'Severe timing differences';
					return -0x110;
				} else {
					link.style = 'color: red;';
					link.title = 'Considerable timing differences';
					return -0x100;
				}
			});
		}
		function processMedium(medium) {
			function clickHandler(evt) {
				const link = evt.currentTarget;
				processTrackLengths(link, true).then(state => { if (state < 0x180) document.location.assign(link) });
				return false;
			}

			console.assert(medium instanceof HTMLElement);
			if (!(medium instanceof HTMLElement)) throw 'Invalid argument';
			let discIds = getDiscIds(medium);
			if (discIds.length <= 0) return Promise.resolve(null); else if (discIds.length > 1) {
				if (loggedIn && visible) for (let link of discIds.filter(Boolean)) link.onclick = clickHandler;
				return Promise.resolve(0x10 | (discIds.filter(Boolean).length < discIds.length ? 0 : 8));
			} else if (discIds.length > 0) discIds = discIds.filter(Boolean);
			if (discIds.length <= 0) return Promise.resolve(0x00);
			if (loggedIn && visible) discIds[0].onclick = autoSet ? evt => !evt.currentTarget.disabled : clickHandler;
			return processTrackLengths(discIds[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, setParams));
		return (param in discIdStates ? Promise.resolve(discIdStates[param]) : (function() {
			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 (!autoSet || states && !states.some(state => state >= 0x100)) return states;
			return localXHR(url).then(processRelease);
		});
	} else throw 'Invalid argument';
}

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

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).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).then(currentPageStates);
	const animation = flashElement(tabLink);
	processRelease(release[2]).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) {
		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);
		const animation = flashElement(setLink);
		processRelease(release[2], 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 (state <= -0x100) setLink.style = 'color: red;';
				setLink.disabled = false;
				if (animation) animation.cancel();
				setLink.title = tooltipFromState(state);
			}
		}, function(reason) {
			setLink.style = 'color: red; background-color: #f002;';
			setLink.disabled = false;
			if (animation) animation.cancel();
			setLink.title = reason;
		});
	});