Anakunda / MB Auto-retry on upload to CAA error

// ==UserScript==
// @name         MB Auto-retry on upload to CAA error
// @namespace    https://greasyfork.org/users/321857-anakunda
// @version      1.10
// @match        https://musicbrainz.org/release/*/add-cover-art
// @match        https://musicbrainz.org/release/*/add-cover-art?*
// @iconURL      https://coverartarchive.org/img/big_logo.svg
// @run-at       document-end
// @author       Anakunda
// @copyright    © 2023-2024, Anakunda (https://greasyfork.org/users/321857-anakunda)
// @license      GPL-3.0-or-later
// @grant        GM_xmlhttpRequest
// @grant        GM_notification
// @grant        GM_getValue
// @connect      onlinebarcodereader.com
// @require      https://openuserjs.org/src/libs/Anakunda/Requests.min.js
// @description  Just autoretry
// ==/UserScript==

'use strict';

// Auto-retry

function clearTimers() {
	if (timer != undefined) { clearTimeout(timer); timer = undefined; }
	if (countdownTimer != undefined) { clearInterval(countdownTimer); countdownTimer = undefined; }
	if (controls != null) countdown.textContent = '--';
}

const btnSubmit = document.body.querySelector('button#add-cover-art-submit');
if (btnSubmit == null) throw 'Submit button not found';
let haveErrors = false, active = true, timer, controls = null;
let counter, countdownTimer, countdownTime, countdown, countdownWrapper;
let retryCounter = 0, retryDelay = GM_getValue('retry_delay', 5);
const caption = (state = 0) => ['Suspend', 'Resume'][state] + ' autoretry';
new MutationObserver(function(ml, mo) {
	for (let mutation of ml) if (!mutation.target.disabled)
		if (Array.prototype.some.call(document.querySelectorAll('form#add-cover-art > table > tbody > tr > td > span.msg.error'),
				span => span.style.display != 'none' && /^⚠ (?:Server busy|Error)\b/.test(span.textContent.trim()))) {
			haveErrors = true;
			if (controls == null) {
				controls = Object.assign(document.createElement('span'), {
					style: `
position: fixed; right: 10px; bottom: 10px; padding: 10px; background-color: white;
border: solid black thin; box-shadow: 1pt 1pt 2pt gray;
display: inline-flex; flex-flow: row; column-gap: 1em; z-index: 999;`,
					className: 'autoretry-control',
				});
				const infoWrapper = Object.assign(document.createElement('div'), {
					style: 'padding: 5pt 0;',
				});
				countdownWrapper = document.createElement('span');
				countdownWrapper.append(' in ', countdown = Object.assign(document.createElement('span'), {
					style: 'font-weight: bold; display: inline-block; min-width: 2em; text-align: right;',
					id: 'autoretry-countdown',
				}), ' s');
				infoWrapper.append('Retry #', counter = Object.assign(document.createElement('span'), {
					style: 'font-weight: bold; color: red;',
					id: 'retry-counter',
				}), countdownWrapper);
				controls.append(Object.assign(document.createElement('button'), {
					textContent: caption(),
					style: 'padding: 5px 10px; cursor: pointer; border: 1px solid #CCC; border-top: 1px solid #EEE; border-left: 1px solid #EEE;',
					id: 'autoretry',
					onclick: function(evt) {
						if (active) clearTimers();
						if ((active = !active) && !btnSubmit.disabled) btnSubmit.click();
						evt.currentTarget.textContent = caption(active ? 0 : 1);
						countdownWrapper.style.opacity = active ? 1 : 0.3;
						return false;
					},
				}), infoWrapper);
				document.body.append(controls);
			}
			const log10 = Math.log10(retryCounter);
			counter.textContent = ++retryCounter;
			if (log10 > 0 && log10 % 1 == 0 && retryDelay > 0) retryDelay *= log10 + 1;
			if (active) timer = setTimeout(function(elem) {
				clearTimers();
				if (active && elem instanceof HTMLElement && !elem.disabled) elem.click();
			}, retryDelay * 1000 || 0, btnSubmit); else continue;
			if ((countdownTime = retryDelay) > 0) {
				countdown.textContent = countdownTime;
				countdownTimer = setInterval(elem => { elem.textContent = --countdownTime }, 1000, countdown);
			}
		} else if (haveErrors) {
			mo.disconnect();
			if (controls != null) controls.remove();
			GM_notification({ text: 'Cover art upload successfully completed', title: 'MusicBrainz', highlight: false });
			document.location.assign(document.location.origin + document.location.pathname
				.replace(/\/(?:add-cover-art)\/?$/i, '/cover-art'));
		}
	else clearTimers();
}).observe(btnSubmit, { attributes: true, attributeFilter: ['disabled'] });

// Guess artwork type

function readBarcode(image) {
	if (!(image instanceof Blob)) throw 'Invalid argument';
	const origin = 'https://www.onlinebarcodereader.com', formData = new FormData;
	formData.set('userfile', image);
	formData.set('requiredfile_userfile', 1);
	return GlobalXHR.post(origin + '/', formData).then(function({document}) {
		for (let script of document.getElementsByTagName('script')) {
			const url = /\bvar\s+url\s*=\s*'(.+?)';/.exec(script.text);
			const pollInterval = /\bvar\s+pollInterval\s*=\s*(\d+);/.exec(script.text);
			if (url != null) return Promise.resolve({
				pollInterval: pollInterval != null ? parseInt(pollInterval[1]) : 1000,
				url: origin + url[1],
			});
		}
		return Promise.reject('Request params not found');
	}).then(params => new Promise((resolve, reject) => { (function request() {
		globalXHR.get(params.url).then(function(response) {
			if (response.responseText.startsWith('__wait__')) return setTimeout(request, params.pollInterval);
			let scanned = response.document.querySelector('textarea#result-textarea');
			scanned = scanned != null && scanned.value.trim() || undefined;
			if (scanned && scanned.length >= 8) resolve(scanned); else reject('No valid barcode detected');
		}, reject);
	})() }));
}

const tBody = document.body.querySelector('form#add-cover-art table > tbody[data-bind]');
if (tBody != null) new MutationObserver(function(ml, mo) {
	for (let mutation of ml) for (let node of mutation.addedNodes) if (node.nodeName == 'TR') {
		function selectType(type, checked = true) {
			if (!(type in checkBoxes)) return;
			checkBoxes[type].checked = checked;
			checkBoxes[type].dispatchEvent(new Event('click', { bubbles: true }));
		}

		let name = node.querySelector('div.file-info > span[data-bind="text: name"]');
		if (name == null || !(name = name.textContent.trim())) continue;
		let size = node.querySelector('div.file-info > span[data-bind="text: size"]');
		size = size != null && (size = /^(\d+(?:\.\d+))\s+([kM]B)$/.exec(size.textContent.trim())) != null ?
			Math.round(parseFloat(size[1]) * Math.pow(10, ['B', 'kB', 'MB'].indexOf(size[2]) * 3)) : undefined;
		const checkBoxes = Object.assign.apply({ }, Array.from(node.querySelectorAll('ul.cover-art-type-checkboxes > li'), function(li) {
			const name = li.querySelector('label > span');
			const input = li.querySelector('label > input[type="checkbox"]');
			if (name != null && input != null) return { [name.textContent.trim()] : input };
		}));
		let guesssedFromName = {
			'Front': /(?:front|cover)/i,
			'Back': /(?:back|rear|tray[\-\_]?(?:out|front))/i,
			'Booklet': /(?:book(?:let)?|insert)/i,
			'Medium': /(?:medium|cd|dis[ck])/i,
			'Obi': /(?:obi)/i,
			'Spine': /(?:spine)/i,
			'Tray': /(?:tray)/i,
			'Sticker': /(?:sticker)/i,
			'Poster': /(?:poster)/i,
			'Liner': /(?:liner)/i,
			'Watermark': /(?:watermark)/i,
			'Matrix/Runout': /(?:matrix|runout)/i,
			'Top': /(?:top)/i,
			'Bottom': /(?:bottom)/i,
			//'Other': /(?:other)/i,
			//'Raw/Unedited': /(?:raw)/i,
		};
		guesssedFromName = Object.keys(guesssedFromName).filter(type => guesssedFromName[type].test(name));
		for (let type of guesssedFromName) selectType(type, true);
		const image = node.querySelector('img.uploader-preview-image');
		if (image != null) (image.naturalWidth > 0 && image.naturalHeight > 0 ? Promise.resolve(image) : new Promise(function(resolve, reject) {
			image.onload = evt => { resolve(evt.currentTarget) };
			//image.onerror = evt => { reject('Error loading image') };
		})).then(function(image) {
			const shape = image.naturalWidth / image.naturalHeight;
			if (shape >= 0.40 && shape <= 0.50) selectType('Obi', true);
			if (shape >= 1.25 && shape <= 1.35) selectType('Spine', true); // tray + spine
			if (shape >= 1.90 && shape <= 2.10) selectType('Booklet', true); // booklet 2 pages
			//if (shape >= 2.25 && shape <= 2.35) selectType('Front', true); selectType('Back', true); selectType('Spine', true); // digipak
			if (shape >= 2.45 && shape <= 2.90) selectType('Other', true);
			if (shape >= 2.95 && shape <= 3.35) selectType('Booklet', true); // booklet 3 pages
			if ((shape >= 2.95 && shape <= 3.35 || shape >= 2.20 && shape <= 2.35 || shape >= 1.25 && shape <= 1.35
					|| shape >= 1.05 && shape <= 1.15) && (!size || size < 2**20)) fetch(image.src).then(response => response.blob()).then(function(blob) {
				const image = new File([blob], name, { type: blob.type });
				readBarcode(image).then(function haveBarcode(barcode) {
					if (shape >= 1.05 && shape <= 1.15 || shape >= 1.25 && shape <= 1.35) selectType('Back', true);
					if (shape >= 2.20 && shape <= 2.35) for (let type of ['Front', 'Back', 'Spine']) selectType(type, true);
				}, function haveNotBarcode(reason) {
					if (shape >= 2.20 && shape <= 2.35) for (let type of ['Booklet', 'Tray']) selectType(type, true);
				});
			});
		});
		const comment = node.querySelector('input.comment[type="text"]');
		if (comment != null) {
			let description;
			if (description) {
				comment.value = name;
				comment.dispatchEvent(new Event('change', { bubbles: true }));
			}
		}
	}
}).observe(tBody, { childList: true })