MB Auto Track Lengths from CD TOC

Autoset track lengths from unique CD-TOC when attached.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         MB Auto Track Lengths from CD TOC
// @version      1.06
// @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
// @require      https://openuserjs.org/src/libs/Anakunda/xhrLib.min.js
// ==/UserScript==

'use strict';

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;

function processDocument(document, mode = 2) {
	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 isDiscIdRow = row => row instanceof HTMLElement && ['odd', 'even'].some(cls => row.classList.contains(cls));
	const getSetLink = row => Array.prototype.find.call(row.querySelectorAll('td a'), a => getRequestparams(a) != null) || null;
	let rows = document.body.querySelectorAll('table.tbl > tbody > tr.subh'), groups = [ ];
	if (rows.length > 0) rows.forEach(function(row) {
		const discIds = [ ];
		while (isDiscIdRow(row = row.nextElementSibling)) discIds.push(getSetLink(row));
		groups.push(discIds);
	}); else {
		rows = document.body.querySelectorAll('table.tbl > tbody > tr');
		groups = [Array.prototype.filter.call(rows, isDiscIdRow).map(getSetLink)];
	}
	return groups.length > 0 ? Promise.all(groups.map(function(group) {
		function setTrackLengths(link, makeVotable = false) {
			console.assert(link instanceof HTMLAnchorElement);
			if (!(link instanceof HTMLAnchorElement)) throw 'Invalid argument';
			if (link.disabled) return Promise.resolve(undefined); else 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 (mode == 1) return 10;
				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 20;
				});
			}).catch(function(reason) {
				if (animation != null) animation.cancel();
				link.disabled = false;
				if (!Array.isArray(reason)) {
					link.style = 'color: red; background-color: #f002;';
					link.title = reason;
					return -100;
				} else if (reason.some(delta => delta > 30)) {
					link.style = 'color: red; background-color: #f002; font-weight: bold;';
					link.title = 'Severe timing differences';
					return -20;
				} else {
					link.style = 'color: red;';
					link.title = 'Considerable timing differences';
					return -10;
				}
			});
		}

		if (!(mode > 0) || group.length > 1) {
			if (loggedIn) for (let link of group.filter(Boolean)) link.onclick = function(evt) {
				setTrackLengths(link = evt.currentTarget)
					.then(state => { if (state <= -10) document.location.assign(link) });
				return false;
			};
			return 1;
		} else if (group.length > 0) group = group.filter(Boolean);
		if (group.length != 1) return 0; else if (!loggedIn) return 10;
		group[0].onclick = evt => !evt.currentTarget.disabled;
		return setTrackLengths(group[0]);
	})) : Promise.reject('No disc IDs found');
}

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');
if (document.location.pathname.startsWith('/release/')) {
	function saveDiscIdStates(states) {
		if (entity == null) return;
		if (states) discIdStates[entity[2]] = states; else delete discIdStates[entity[2]];
		sessionStorage.setItem('mbDiscIdStates', JSON.stringify(discIdStates));
	}

	if ('mbDiscIdStates' in sessionStorage) try {
		var discIdStates = JSON.parse(sessionStorage.getItem('mbDiscIdStates'));
	} catch(e) { console.warn(e) }
	if (typeof discIdStates != 'object') discIdStates = { };
	const entity = /^\/(\w+)\/([\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12})(?=[\/\?]|$)/i.exec(document.location.pathname);
	console.assert(entity != null && entity[1] == 'release', document.location.pathname);
	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')) processDocument(document)
		.then(states => { saveDiscIdStates() });
	else if (tabLinks.length == 1) {
		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 processDocument(document)
			.then(states => { saveDiscIdStates() });
		if (entity == null) throw 'Failed to identify entity from page url';
		const autoSet = loggedIn && GM_getValue('auto_set', true), animation = flashElement(tabLink);
		(entity[2] in discIdStates ? Promise.resolve(discIdStates[entity[2]]) : (function getDiscIdStates() {
			const requestParams = new URLSearchParams({ inc: 'recordings+discids', fmt: 'json' })
			return localXHR(`/ws/2/${entity[1]}/${entity[2]}?${requestParams}`, { responseType: 'json' }).then(function(release) {
				const states = release.media.map(function(medium, mediumIndex) {
					if (!medium.discs || medium.discs.length <= 0) return -2;
					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;
						});
						return 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);
						}) ? 0 : deltas.some(delta => delta >= 30.5) ? -20
							: deltas.some(delta => delta >= 5.5) ? -10 : autoSet ? 20 : 10;
					});
					if (discIdStates.length == 1) return discIdStates[0];
					const susp = discIdStates.some(state => state < 0) ? 1 : 0;
					if (discIdStates.some(state => state >= 20)) return 8 - susp;
					if (discIdStates.some(state => state >= 10)) return 5 - susp;
					if (!discIdStates.some(state => state > 0)) return -7 - susp;
					return 2 - susp;
				});
				saveDiscIdStates(states);
				return states;
			});
		})().catch(function(reason) {
			console.warn('Disc ID states query failed:', reason, '; falling back to scraping HTML');
		})).then(states => states && !states.some(state => state >= 20) ? states : localXHR(tabLink)
				.then(document => processDocument(document, autoSet ? 2 : 1)).then(function(states) {
			if (!states.some(state => state <= -100)) saveDiscIdStates(states.map(state => state == 20 ? 0 : state));
			return states;
		})).then(function(states) {
			console.debug('Media disc ID states:', states);
			if (states.some(state => state == 20)) li.style.backgroundColor = '#0f02';
			if (states.some(state => state <= -100)) li.style.backgroundColor = '#f006';
			else if (states.some(state => state <= -20)) li.style.backgroundColor = '#f004';
			else if (states.some(state => state <= -10)) li.style.backgroundColor = '#f002';
			if (states.some(state => state == 10)) li.style.fontWeight = 'bold';
			if (animation != null) animation.cancel();
			li.title = states.map(function(state) {
				if (state == -100) return 'Network error occured (see browser console for more details)';
				if (state == -20) return 'Suspicious disc ID assigned (severe timing differences)';
				if (state == -10) return 'Suspicious disc ID assigned (considerable timing differences)';
				if (state > -10 && state < -5) return 'Ambiguity: multiple suspicious disc IDs attached';
				if (state == -5) return 'Could not be processed';
				if (state == -2) return 'No disc IDs attached';
				if (state == 0) return 'Tracks times already match CD TOC';
				if (state > 0 && state < 10) return 'Ambiguity: multiple disc IDs attached';
				if (state == 10) return 'Unique CD TOC available to apply';
				if (state == 20) return 'TOC lengths successfully applied on tracks';
				return state;
			}).map((state, index) => `Medium ${index + 1}: ${state}`).join('\n');
		}, function(reason) {
			if (animation != null) animation.cancel();
			[li.style.color, li.style.backgroundColor, li.title] = ['white', 'red', 'Something went wrong: ' + reason];
		});
	} else throw 'Assertion failed: Disc ID tab links mismatch';
} else if (document.location.pathname.startsWith('/cdtoc/')) processDocument(document);