Greasy Fork

Greasy Fork is available in English.

MB Auto Track Lengths

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

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

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         MB Auto Track Lengths
// @version      1.05
// @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 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.style.color = 'red';
				link.title = reason;
				link.disabled = false;
				return Array.isArray(reason) ? reason.some(delta => delta > 30) ? -20 : -10 : -100;
			});
		}

		if (!(mode > 0) || group.length > 1) {
			for (let link of group.filter(Boolean)) link.onclick = function(evt) {
				setTrackLengths(link = evt.currentTarget)
					.then(status => { if (status <= -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 releaseScanned() {
		if (entity == null || scannedReleaseIds.includes(entity[2])) return;
		scannedReleaseIds.push(entity[2]);
		sessionStorage.setItem('scanned_discids', JSON.stringify(scannedReleaseIds));
	}

	let scannedReleaseIds = [ ];
	if ('scanned_discids' in sessionStorage) try {
		scannedReleaseIds = JSON.parse(sessionStorage.getItem('scanned_discids'));
	} catch(e) { console.warn(e) }
	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.href.endsWith('/discids'));
	console.assert(tabLinks.length == 1, tabLinks);
	if (document.location.pathname.endsWith('/discids')) processDocument(document);
	if (tabLinks.length == 1 && (entity == null || !scannedReleaseIds.includes(entity[2]))) (function(tabLink) {
		const li = tabLink.closest('li');
		console.assert(li != null);
		if (li.classList.contains('disabled')) {
			releaseScanned();
			return Promise.reject('Release has no disc ids attached');
		} else if (li.classList.contains('sel')) {
			releaseScanned();
			return Promise.reject('Disc ids is current tab');
		}
		const autoSet = GM_getValue('auto_set', true), animation = flashElement(tabLink);
		const processUrl = url => localXHR(url).then(document => processDocument(document, autoSet ? 2 : 1));
		const requestParams = new URLSearchParams({ inc: 'recordings+discids', fmt: 'json' })
		return localXHR(`/ws/2/${entity[1]}/${entity[2]}?${requestParams}`, { responseType: 'json' }).then(function(release) {
			const statuses = release.media.map(function(medium) {
				if (!medium.discs || medium.discs.length <= 0) return -2;
				const discIds = medium.discs.map(function(discId) {
					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;
						const tocLength = Math.floor((hiOffset - discId.offsets[index]) * 1000 / 75);
						//console.debug('Track %d length: %d, TOC length: %d', (index + 1), track.length, tocLength);
						return track.length == tocLength;
					})) return 0;
					const deltas = medium.tracks.map(function lengthsEqual(track, index, tracks) {
						const length = '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;
						return Math.abs(((hiOffset - discId.offsets[index]) / 75) - length);
					});
					return deltas.some(delta => delta > 30) ? -20 : deltas.some(delta => delta > 5) ? -10 : autoSet ? 20 : 10;
				});
				if (discIds.length == 1) return discIds[0];
				if (discIds.some(status => status >= 20)) return 5;
				if (discIds.some(status => status >= 10)) return 4;
				if (!discIds.some(status => status > 0)) return -7;
				return 1;
			});
			return statuses.some(status => status >= 20) ? processUrl(tabLink) : Promise.resolve(statuses);
		}).catch(function(reason) {
			console.warn('MB API request failed:', reason, ', falling back to scraping HTML');
			return processUrl(tabLink);
		}).then(function(statuses) {
			if (animation != null) animation.cancel();
			const tooltips = [ ];
			if (statuses.some(status => status == 20)) {
				li.style.backgroundColor = '#0f02';
				tooltips.push('TOC lengths successfully applied to tracks');
			}
			if (statuses.some(status => status <= -100)) {
				li.style.backgroundColor = '#f006';
				tooltips.push('Network error occured');
			} else if (statuses.some(status => status <= -10)) li.style.backgroundColor = '#f002';
			if (statuses.some(status => status == -20))
				tooltips.push('Potentially wrong TOC assigned (severe timing differences)');
			else if (statuses.some(status => status == -10))
				tooltips.push('Potentially wrong TOC assigned (considerable timing differences)');
			if (statuses.some(status => status == -2)) tooltips.push('Some media have no disc ids attached');
			if (statuses.some(status => status == 10)) {
				li.style.fontWeight = 'bold';
				tooltips.push('CD TOC available to apply');
			}
			if (statuses.some(status => status == -8))
				tooltips.push('Ambiguity: multiple potentially wrong TOCs attached to medium');
			else if (statuses.some(status => [1, 5, 6].includes(status)))
				tooltips.push('Ambiguity: multiple TOCs attached to medium');
			if (statuses.some(status => status == 0))
				tooltips.push('TOC already applied');
			if (tooltips.length <= 0) tooltips.push('Status codes: ' + statuses);
			li.title = tooltips.join('\n');
			if (statuses.every(status => status >= -5)) releaseScanned();
			return statuses;
		}, function(reason) {
			if (animation != null) animation.cancel();
			[li.style.color, li.style.backgroundColor] = ['white', 'red'];
			li.title = `Failed to scan disc ids (${reason})`;
			return Promise.reject(reason);
		});
	})(tabLinks[0]); else releaseScanned();
} else if (document.location.pathname.startsWith('/cdtoc/')) processDocument(document);