Greasy Fork

MB Auto Track Lengths

Autoset track lengths for media from unique CD-TOC when attached.

目前为 2024-05-04 提交的版本。查看 最新版本

// ==UserScript==
// @name         MB Auto Track Lengths
// @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 for media 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);