MB Auto Track Lengths from CD TOC

Autoset track lengths from unique CD-TOC when attached.

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

您需要先安裝使用者腳本管理器擴展,如 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.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) {
			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 group[0].onclick = evt => !evt.currentTarget.disabled;
		return setTrackLengths(group[0]);
	})) : Promise.reject('No disc IDs found');
}

if (document.location.pathname.startsWith('/release/')) {
	function saveDiscIdStates(states) {
		if (entity == null || 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(saveDiscIdStates);
	else if (tabLinks.length == 1) {
		const tabLink = tabLinks[0], li = tabLink.closest('li');
		console.assert(li != null);
		if (li.classList.contains('disabled')) {
			saveDiscIdStates([ ]);
			return Promise.reject('Release has no disc IDs attached');
		} else if (li.classList.contains('sel')) return processDocument(document).then(saveDiscIdStates);
		if (entity == null) throw 'Failed to identify entity from page url';
		const autoSet = 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);