[GMT] Tags Helper

Improvements for working with groups of tags + increased efficiency of new requests creation

目前为 2021-10-18 提交的版本。查看 最新版本

// ==UserScript==
// @name         [GMT] Tags Helper
// @version      1.01.7
// @author       Anakunda
// @copyright    2021, Anakunda (https://gf.qytechs.cn/users/321857-anakunda)
// @license      GPL-3.0-or-later
// @namespace    https://gf.qytechs.cn/users/321857-anakunda
// @run-at       document-end
// @iconURL      https://i.ibb.co/ws8w9Jc/Tag-3-icon.png
// @match        https://*/artist.php?id=*
// @match        https://*/artist.php?*&id=*
// @match        https://*/requests.php
// @match        https://*/requests.php?submit=true&*
// @match        https://*/requests.php?type=*
// @match        https://*/requests.php?page=*
// @match        https://*/requests.php?action=new*
// @match        https://*/requests.php?action=view&id=*
// @match        https://*/requests.php?action=view&*&id=*
// @match        https://*/requests.php?action=edit&id=*
// @match        https://*/torrents.php?id=*
// @match        https://*/torrents.php
// @match        https://*/torrents.php?action=advanced
// @match        https://*/torrents.php?action=advanced&*
// @match        https://*/torrents.php?*&action=advanced
// @match        https://*/torrents.php?*&action=advanced&*
// @match        https://*/torrents.php?action=basic
// @match        https://*/torrents.php?action=basic&*
// @match        https://*/torrents.php?*&action=basic
// @match        https://*/torrents.php?*&action=basic&*
// @match        https://*/torrents.php?page=*
// @match        https://*/torrents.php?action=notify
// @match        https://*/torrents.php?action=notify&*
// @match        https://*/torrents.php?type=*
// @match        https://*/collages.php?id=*
// @match        https://*/collages.php?page=*&id=*
// @match        https://*/collages.php?action=new
// @match        https://*/collages.php?action=edit&collageid=*
// @match        https://*/bookmarks.php?type=*
// @match        https://*/bookmarks.php?page=*
// @match        https://*/upload.php
// @match        https://*/upload.php?url=*
// @match        https://*/upload.php?tags=*
// @match        https://*/bookmarks.php?type=torrents
// @match        https://*/bookmarks.php?page=*&type=torrents
// @match        https://*/top10.php
// @match        https://*/top10.php?*
// @grant        GM_getValue
// @grant        GM_setClipboard
// @grant        GM_registerMenuCommand
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @require      https://openuserjs.org/src/libs/Anakunda/xhrLib.min.js
// @require      https://openuserjs.org/src/libs/Anakunda/libLocks.min.js
// @require      https://openuserjs.org/src/libs/Anakunda/gazelleApiLib.min.js
// @require      https://openuserjs.org/src/libs/Anakunda/QobuzLib.min.js
// @require      https://openuserjs.org/src/libs/Anakunda/GazelleTagManager.min.js
// @description Improvements for working with groups of tags + increased efficiency of new requests creation
// ==/UserScript==

'use strict';

let userAuth = document.body.querySelector('input[name="auth"]');
if (userAuth != null) userAuth = userAuth.value; else throw 'User auth could not be located';
const urlParams = new URLSearchParams(document.location.search),
			action = urlParams.get('action'),
			artistEdit = Boolean(action) && action.toLowerCase() == 'edit',
			artistId = parseInt(urlParams.get('artistid') || urlParams.get('id'));
if (!(artistId > 0)) throw 'Assertion failed: could not extract artist id';
let userId = document.body.querySelector('li#nav_userinfo > a.username');
if (userId != null) {
	userId = new URLSearchParams(userId.search);
	userId = parseInt(userId.get('id')) || null;
}
const hasStyleSheet = styleSheet => document.styleSheetSets && document.styleSheetSets.contains(styleSheet);
const isLightTheme = ['postmod', 'shiro', 'layer_cake', 'proton', 'red_light'].some(hasStyleSheet);
const isDarkTheme = ['kuro', 'minimal', 'red_dark'].some(hasStyleSheet);
const createElements = (...tagNames) => tagNames.map(Document.prototype.createElement.bind(document));

function loadArtist() {
	const siteArtistsCache = { }, notSiteArtistsCache = [ ], artistlessGroups = new Set;

	function getSiteArtist(artistName) {
		if (!artistName) throw 'Invalid argument';
		if (notSiteArtistsCache.some(a => a.toLowerCase() == artistName.toLowerCase()))
			return Promise.reject('not found');
		const key = Object.keys(siteArtistsCache).find(artist => artist.toLowerCase() == artistName.toLowerCase());
		return key ? Promise.resolve(siteArtistsCache[key]) : queryAjaxAPI('artist', {
			artistname: artistName,
			//artistreleases: 1,
		}).then(function(artist) {
			for (let prop of [/*'torrentgroup', */'requests']) if (prop in artist) delete artist[prop];
			return siteArtistsCache[artistName] = artist;
		}, function(reason) {
			if (reason == 'not found' && !notSiteArtistsCache.includes(artistName)) notSiteArtistsCache.push(artistName);
			return Promise.reject(reason);
		});
	}

	return queryAjaxAPI('artist', { id: artistId }).then(function(artist) {
		const tagsExclusions = tag => !/^(?:freely\.available|staff\.picks|delete\.this\.tag)$/i.test(tag);
		if (artist.tags) artist.tags = new TagManager(...artist.tags.map(tag => tag.name).filter(tagsExclusions));
		siteArtistsCache[artist.name] = artist;
		const rdExtractor = /\(\s*writes\s+redirect\s+to\s+(\d+)\s*\)/i;
		let activeElement = null;

		function getAlias(li) {
			console.assert(li instanceof HTMLLIElement, 'li instanceof HTMLLIElement', li);
			if (!(li instanceof HTMLLIElement)) return;
			if (typeof li.alias == 'object') return li.alias;
			const alias = {
				id: li.querySelector(':scope > span:nth-of-type(1)'),
				name: li.querySelector(':scope > span:nth-of-type(2)'),
				redirectId: rdExtractor.exec(li.textContent),
			};
			if (alias.id == null || alias.name == null || !(alias.id = parseInt(alias.id.textContent))
				|| !(alias.name = alias.name.textContent)) return;
			if (alias.redirectId != null) alias.redirectId = parseInt(alias.redirectId[1]); else delete alias.redirectId;
			return alias;
		}

		const findArtistId = artistName => artistName ? localXHR('/artist.php?' + new URLSearchParams({
			artistname: artistName,
		}).toString(), { method: 'HEAD' }).then(function(xhr) {
			const url = new URL(xhr.responseURL);
			return url.pathname == '/artist.php' && parseInt(url.searchParams.get('id')) || Promise.reject('Artist wasnot found');
		}) : Promise.reject('Invalid argument');
		const resolveArtistId = (artistIdOrName = artist.id) => artistIdOrName > 0 ? Promise.resolve(artistIdOrName)
			: typeof artistIdOrName == 'string' ? findArtistId(artistIdOrName) : Promise.resolve(artist.id);
		const resloveArtistName = artistIdOrName => artistIdOrName > 0 ? artistIdOrName == artist.id ? artist.name
			: queryAjaxAPI('artist', { id: artistIdOrName }).then(artist => artist.name) : Promise.resolve(artistIdOrName);
		function findAlias(aliasIdOrName, resolveFinalAlias = false, document = window.document) {
			const addForm = document.body.querySelector('form.add_form');
			if (addForm == null) throw 'Invalid page structure';
			for (let li of addForm.parentNode.parentNode.querySelectorAll('div.box > div > ul > li')) {
				const alias = getAlias(li);
				if (alias && (aliasIdOrName > 0 && alias.id == aliasIdOrName
						|| typeof aliasIdOrName == 'string' && alias.name.toLowerCase() == aliasIdOrName.toLowerCase()))
					return alias.redirectId > 0 && resolveFinalAlias ? findAlias(alias.redirectId, true, document) : alias;
			}
			return null;
		}
		const findArtistAlias = (aliasIdOrName, artistIdOrName, resolveFinalAlias = false) => (function() {
			if ((!artistIdOrName || artistIdOrName < 0) && artistEdit) return Promise.resolve(window.document);
			return resolveArtistId(artistIdOrName).then(artistId => localXHR('/artist.php?' + new URLSearchParams({
				action: 'edit',
				artistid: artistId,
			}).toString()));
		})().then(document => findAlias(aliasIdOrName, resolveFinalAlias, document)
			|| Promise.reject('Alias id/name not defined for this artist'));
		const resolveAliasId = (aliasIdOrName, artistIdOrName, resolveFinalAlias = false) =>
			aliasIdOrName >= 0 ? Promise.resolve(aliasIdOrName) : typeof aliasIdOrName == 'string' ?
				findArtistAlias(aliasIdOrName, artistIdOrName, resolveFinalAlias).then(alias => alias.id)
			: Promise.reject('Invalid argument');

		const addAlias = (name, redirectTo = 0, artistIdOrName) => resolveArtistId(artistIdOrName).then(artistId =>
					resolveAliasId(redirectTo, artistIdOrName && artistId, true).then(redirectTo => localXHR('/artist.php', { responseType: null }, new URLSearchParams({
				action: 'add_alias',
				artistid: artistId,
				name: name,
				redirect: redirectTo > 0 ? redirectTo : 0,
				auth: userAuth,
			}))));
		const deleteAlias = (aliasIdOrName, artistIdOrName) => resolveAliasId(aliasIdOrName, artistIdOrName)
			.then(aliasId => localXHR('/artist.php?' + new URLSearchParams({
				action: 'delete_alias',
				aliasid: aliasId,
				auth: userAuth,
			}).toString())).then(function(document) {
				if (!/^\s*(?:Error)\b/.test(document.head.textContent)) return true;
				const box = document.body.querySelector('div#content div.box');
				if (box != null) alert(`Alias "${aliasIdOrName}" deletion failed:\n\n${box.textContent.trim()}`);
				return false;
			});

		const renameArtist = (newName, artistIdOrName = artist.id) => resolveArtistId(artistIdOrName)
			.then(artistId => localXHR('/artist.php', { responseType: null }, new URLSearchParams({
				action: 'rename',
				artistid: artistId,
				name: newName,
				auth: userAuth,
			})));
		const addSimilarArtist = (relatedIdOrName, artistIdOrName = artist.id) => resolveArtistId(artistIdOrName)
				.then(artistId => resloveArtistName(relatedIdOrName).then(artistName =>
					localXHR('/artist.php', { responseType: null }, new URLSearchParams({
			action: 'add_similar',
			artistid: artistId,
			artistname: artistName,
			auth: userAuth,
		}))));
		const addSimilarArtists = (similarArtists, artistIdOrName) => resolveArtistId(artistIdOrName)
			.then(artistId => Promise.all(similarArtists.map(similarArtist =>
				addSimilarArtist(similarArtist, artistIdOrName && artistId || undefined))));
		const changeArtistId = (newArtistIdOrName, artistIdOrName = artist.id) =>
			resolveArtistId(artistIdOrName).then(artistId => resolveArtistId(newArtistIdOrName)
				.then(newArtistId => localXHR('/artist.php', { responseType: null }, new URLSearchParams({
					action: 'change_artistid',
					artistid: artistId,
					newartistid: newArtistId,
					confirm: 1,
					auth: userAuth,
				}))));
		const editArtist = (image = artist.image, body = artist.body, summary, artistIdOrName = artist.id, editNotes) =>
				(image && unsafeWindow.imageHostHelper ? unsafeWindow.imageHostHelper.rehostImageLinks([image], true, false, false)
					.then(unsafeWindow.imageHostHelper.singleImageGetter, function(reason) {
			console.warn(reason);
			return image;
		}) : Promise.resolve(image)).then(image => resolveArtistId(artistIdOrName).then(artistId =>
				localXHR('/artist.php', { responseType: null }, new URLSearchParams({
			action: 'edit',
			artistid: artistId,
			image: image || '',
			body: body || '',
			summary: summary || '',
			artisteditnotes: editNotes || '',
			auth: userAuth,
		}))));

		function addAliasToGroup(groupId, aliasName, importances) {
			if (!(groupId > 0) || !aliasName || !Array.isArray(importances))
				return Promise.resolve('One or more arguments invalid');
			const payLoad = new URLSearchParams({
				action: 'add_alias',
				groupid: groupId,
				auth: userAuth,
			});
			for (let importance of importances) {
				payLoad.append('aliasname[]', aliasName);
				payLoad.append('importance[]', importance);
			}
			return localXHR('/torrents.php', { responseType: null }, payLoad);
		}
		const deleteArtistFromGroup = (groupId, artistIdOrName = artist.id, importances) =>
			groupId > 0 && Array.isArray(importances) ? resolveArtistId(artistIdOrName)
				.then(artistId => Promise.all(importances.map(importance => localXHR('/torrents.php?' + new URLSearchParams({
					action: 'delete_alias',
					groupid: groupId,
					artistid: artistId,
					importance: importance,
					auth: userAuth,
				}).toString(), { responseType: null })))) : Promise.reject('One or more arguments invalid');

		function gotoArtistPage(artistIdOrName = artist.id) {
			resolveArtistId(artistIdOrName)
				.then(artistId => { document.location.assign('/artist.php?id=' + artistId.toString()) });
		}
		function gotoArtistEditPage(artistIdOrName = artist.id) {
			resolveArtistId(artistIdOrName).then(function(artistId) {
				document.location.assign('/artist.php?' + new URLSearchParams({
					action: 'edit',
					artistid: artistId,
				}).toString() + '#aliases');
			});
		}
		const wait = param => new Promise(resolve => { setTimeout(param => { resolve(param) }, 200, param) });

		const clearRecoveryInfo = () => { GM_deleteValue('damage_control') };
		function hasRecoveryInfo() {
			const recoveryInfo = GM_getValue('damage_control');
			return recoveryInfo && recoveryInfo.artist.id == artist.id;
		}

		const sameArtistConfidence = 0.88, sameTitleConfidence = 0.90;
		const dcToken = GM_getValue('discogs_token', 'fJGcklUZogHYsgHaIWtqWWcdChKvJhpNknDKFHFk'),
					dcKey = GM_getValue('discogs_key'), dcSecret = GM_getValue('discogs_secret'),
					dcApiRateControl = { }, dcArtistCache = new Map, dcArtistReleasesCache = new Map;
		const stripRlsSuffix = title => title.replace(/\s+(?:EP|E\.\s?P\.|\(EP\)|\(E\.\s?P\.\)|-\s*EP|-\s*E\.\s?P\.|\(Live\)|- Live)$/, '');
		const titleCmpNorm = title => stripRlsSuffix(title).replace(/[^\w\u0080-\uFFFF]/g, '').toLowerCase();

		function queryDiscogsAPI(endPoint, params) {
			if (endPoint) endPoint = new URL(endPoint, 'https://api.discogs.com');
				else return Promise.reject('No endpoint provided');
			if (typeof params == 'object') for (let key in params) endPoint.searchParams.set(key, params[key]);
				else if (params) endPoint.search = new URLSearchParams(params);
			const authHeader = { };
			if (dcKey && dcSecret) authHeader.Authorization = `Discogs key=${dcKey}, secret=${dcSecret}`;
				else if (dcToken) authHeader.Authorization = `Discogs token=${dcToken}`;
					else console.warn('Discogs API: no authentication credentials are configured, the functionality related to Discogs is limited');
			return new Promise((resolve, reject) => (function request(retryCounter = 0) {
				const postpone = () => { setTimeout(request, dcApiRateControl.timeFrameExpiry - now, retryCounter + 1) };
				const now = Date.now();
				if (!dcApiRateControl.timeFrameExpiry || now > dcApiRateControl.timeFrameExpiry) {
					dcApiRateControl.timeFrameExpiry = now + 60 * 1000 + 500;
					dcApiRateControl.requestCounter = 0;
				}
				if (++dcApiRateControl.requestCounter <= 60) GM_xmlhttpRequest({ method: 'GET', url: endPoint,
					responseType: 'json',
					headers: Object.assign({
						'Accept': 'application/json',
						'X-Requested-With': 'XMLHttpRequest',
					}, authHeader),
					onload: function(response) {
						if (response.status >= 200 && response.status < 400) resolve(response.response); else {
							if (response.status == 429/* && retryCounter < 25*/) {
								console.warn(defaultErrorHandler(response), response.response.message, '(' + retryCounter + ')');
								postpone();
							} else reject(defaultErrorHandler(response));
						}
					},
					onerror: response => { reject(defaultErrorHandler(response)) },
					ontimeout: response => { reject(defaultTimeoutHandler(response)) },
				}); else postpone();
			})());
		}
		function getDiscogsArtist(artistId) {
			if (!(artistId > 0)) return Promise.reject('Invalid artist id');
			if (dcArtistCache.has(artistId)) return dcArtistCache.get(artistId);
			const result = queryDiscogsAPI('artists/' + artistId.toString());
			dcArtistCache.set(artistId, result);
			return result;
		}
		function getDiscogsArtistReleases(artistId) {
			if (!(artistId > 0)) return Promise.reject('Invalid artist id');
			if (dcArtistReleasesCache.has(artistId)) return dcArtistReleasesCache.get(artistId);
			const releases = [ ], result = (function getReleasePage(page = 1) {
				return queryDiscogsAPI(`artists/${artistId}/releases`, { page: page, per_page: 500 }).then(function(response) {
					Array.prototype.push.apply(releases, response.releases);
					return response.pagination.page < response.pagination.pages ? getReleasePage(page + 1) : releases;
				});
			})().catch(function(reason) {
				console.warn(`Could not get Discogs releases of ${artistId}:`, reason);
				return [ ];
			});
			dcArtistReleasesCache.set(artistId, result);
			return result;
		}
		const dcNameNormalizer = artist => artist.replace(/\s+/g, ' ').replace(/\s+\(\d+\)$/, '');
		const getDiscogsMatches = (artistId, torrentGroups) => artistId > 0 && Array.isArray(torrentGroups) ?
				getDiscogsArtistReleases(artistId).then(function(releases) {
			return torrentGroups.filter(function(torrentGroup) {
				const titleNorm = [titleCmpNorm(torrentGroup.groupName), torrentGroup.groupName.toLowerCase()];
				return releases.some(release => release.year == torrentGroup.groupYear
					&& (titleCmpNorm(release.title) == titleNorm[0]
						|| jaroWrinkerSimilarity(release.title.toLowerCase(), titleNorm[1]) >= sameTitleConfidence));
			}).map(torrentGroup => torrentGroup.groupId);
		}) : Promise.reject('Invalid argument');
		const dcSearchArtist = (searchTerm = artist.name, torrentGroups, anvs) => searchTerm ? queryDiscogsAPI('database/search', {
			query: dcNameNormalizer(searchTerm),
			type: 'artist',
			sort: 'score,desc',
			strict: !Array.isArray(anvs),
		}).then(function(response) {
			const results = response.results.filter(result => result.type == 'artist' && (function() {
				const anvMatch = anv => dcNameNormalizer(result.title).toLowerCase() == anv.toLowerCase()
					|| Array.isArray(torrentGroups) && jaroWrinkerSimilarity(dcNameNormalizer(result.title).toLowerCase(),
						searchTerm.toLowerCase()) >= sameArtistConfidence;
				return anvMatch(searchTerm) || Array.isArray(anvs) && anvs.map(dcNameNormalizer).some(anvMatch);
			})());
			if (results.length <= 0) {
				const m = /^(.+?)\s*\((.+)\)$/.exec(searchTerm);
				return m != null ? dcSearchArtist(m[1], torrentGroups, anvs).catch(function(reason) {
					if (reason == 'No matches' && isNaN(parseInt(m[2]))) return dcSearchArtist(m[2], torrentGroups, anvs);
					return Promise.reject(reason);
				}) : Promise.reject('No matches');
			}
			for (let result of results) if (result.uri)
				result.uri = 'https://www.discogs.com' + result.uri;
			console.log('[AAM] Discogs search results for "' + searchTerm + '":', results);
			if (!Array.isArray(torrentGroups) || torrentGroups.length <= 0) return Promise.resolve(Object.assign(results, {
				bestMatch: results[0],
			}));
			return Promise.all(results.map(result => getDiscogsMatches(result.id, torrentGroups)
					.then(matches => Object.assign(result, { matchedGroups: matches })))).then(function(results) {
				results.matchedCount = results.filter(result => result.matchedGroups.length > 0).length;
				const maxMatches = results.matchedCount > 0 ?
					Math.max(...results.map(result => result.matchedGroups.length || 0)) : undefined;
				results.maxIndex = results.matchedCount > 0 ?
					results.findIndex(result => result.matchedGroups.length == maxMatches) : 0;
				results.bestMatch = results[results.maxIndex];
				if (results.matchedCount > 1) {
					results.otherArtistsReleases = [ ];
					for (let result of results.filter(result => result.id != results.bestMatch.id))
						Array.prototype.push.apply(results.otherArtistsReleases, result.matchedGroups
							.map(groupId => document.location.origin + '/torrents.php?id=' + groupId.toString()));
					console.log('Shared group ' + searchTerm + ' other artists releases:', ...results.otherArtistsReleases);
				};
				return results;
			})
		}) : Promise.reject('Invalid argiment');

		if (artistEdit) {
			String.prototype.toASCII = function() {
				return this.normalize("NFKD").replace(/[\x00-\x1F\u0080-\uFFFF]/g, '');
			};
			String.prototype.properTitleCase = function() {
				return [this.toUpperCase(), this.toLowerCase()].some(str => this == str) ? this
					: caseFixes.reduce((result, replacer) => result.replace(...replacer), this);
			};

			const caseFixes = [
				[
					new RegExp(`(\\w+|[\\,\\)\\]\\}\\"\\'\\‘\\’\\“\\‟\\”]) +(${[
						'And In', /*'And His', 'And Her', */'And', 'By A', 'By An', 'By The', 'By', 'For A',
						'For An', 'For', 'From', 'If', 'In To', 'In', 'Into', 'Nor', 'Not', 'Of An', 'Of The', 'Of',
						'Off', 'On', 'Onto', 'Or', 'Out Of', 'Out', 'Over', 'With', 'Without', 'Yet',
						'Y Su', 'Y Sua', 'Y Suo', 'De', 'Y', 'E La Sua', 'E Sua', 'E Il Suo', 'La Sua',
						'Et Son', 'Et Ses', 'Et Le', 'Et Sa', 'Et Sua', 'E Seu', 'Di',
						'Und Sein', 'Und Seine', 'Und', 'Mit Seinem', 'Mit Seiner', 'Mit',
						'En Zijn', 'Og',
					].join('|')})(?=\\s+)`, 'g'), (match, preWord, shortWord) => preWord + ' ' + shortWord.toLowerCase(),
				], [
					new RegExp(`\\b(${['by', 'in', 'of', 'on', 'or', 'to', 'for', 'out', 'into', 'from', 'with'].join('|')})$`, 'g'),
					(match, shortWord) => ' ' + shortWord[0].toUpperCase() + shortWord.slice(1).toLowerCase(),
				],
				[/([\-\:\&\;]) +(the|an?)(?=\s+)/g, (match, sym, article) => sym + ' ' + article[0].toUpperCase() + article.slice(1).toLowerCase()],
			];

			function rmDelLink(li) {
				for (let a of li.getElementsByTagName('A')) if (a.textContent.trim() == 'X') li.removeChild(a);
			}

			let aliasesRoot = document.body.querySelector('form.add_form');
			if (aliasesRoot != null) (aliasesRoot = aliasesRoot.parentNode.parentNode).id = 'aliases';
				else throw 'Add alias form could not be located';
			console.assert(aliasesRoot.nodeName == 'DIV' && aliasesRoot.className == 'box pad',
				"aliasesRoot.nodeName == 'DIV' && aliasesRoot.className == 'box pad'");
			const aliases = aliasesRoot.querySelectorAll('div.box > div > ul > li'),
						dropDown = aliasesRoot.querySelector('select[name="redirect"]');
			const epitaph = `

Don't navigate away, close or reload current page, till it reloads self.

The operation can take longer to complete and can be reverted only
by hand, sure to proceed?`;
			let mainIdentityId, inProgress = false;

			function decodeHTML(html) {
				const textArea = document.createElement("textarea");
				textArea.innerHTML = html;
				return textArea.value;
			}
			function reportArtistlessGroup(groupId, title, bold = false) {
				console.assert(groupId > 0, 'groupId > 0');
				let container = document.getElementById('artistless-groups');
				if (container == null) {
					const ref = aliasesRoot.querySelector(':scope > br:first-of-type'), hdr = document.createElement('H4');
					hdr.innerHTML = 'List of artistless groups<br><span style="font-size: 8pt; font-weight: normal;">(Bold printed groups are missing artist info entirely and are potential source for complex operations to fail; review and fix these groups first to avoid later problems)</span>';
					hdr.style = 'color: red; font-weight: bold;';
					aliasesRoot.insertBefore(hdr, ref);
					container = document.createElement('DIV');
					container.id = 'artistless-groups';
					container.style = 'padding: 1em;';
					aliasesRoot.insertBefore(container, ref);
				}
				if (container.childElementCount > 0) container.append(', ');
				const a = document.createElement('A');
				a.href = '/torrents.php?id=' + groupId;
				a.target = '_blank';
				a.textContent = title ? decodeHTML(title) : groupId.toString();
				a.style.fontWeight = bold ? 'bold' : 'normal';
				container.append(a);
				return a;
			}

			class TorrentGroupsManager {
				constructor(aliasId) {
					if (!(aliasId > 0)) throw 'Invalid argument';
					this.groups = { };
					for (let torrentGroup of artist.torrentgroup) {
						console.assert(!(torrentGroup.groupId in this.groups), '!(torrentGroup.groupId in this.groups)');
						if (!torrentGroup.extendedArtists || !Array.isArray(torrentGroup.extendedArtists[1])
								|| torrentGroup.extendedArtists[1].length <= 0) {
							if (!artistlessGroups.has(torrentGroup.groupId)) {
								reportArtistlessGroup(torrentGroup.groupId, torrentGroup.groupName, !torrentGroup.extendedArtists);
								artistlessGroups.add(torrentGroup.groupId);
							}
							continue;
						}
						const importances = Object.keys(torrentGroup.extendedArtists)
							.filter(importance => Array.isArray(torrentGroup.extendedArtists[importance])
								&& torrentGroup.extendedArtists[importance].some(artist => artist.aliasid == aliasId))
							.map(key => parseInt(key)).filter((el, ndx, arr) => arr.indexOf(el) == ndx);
						if (importances.length > 0) this.groups[torrentGroup.groupId] = importances;
					}
				}

				get size() {
					return this.groups ? Object.keys(this.groups).filter(groupId =>
						Array.isArray(this.groups[groupId]) && this.groups[groupId].length > 0).length : 0;
				}
				get aliasUsed() { return this.size > 0 }

				removeAliasFromGroups() {
					if (!this.aliasUsed) return Promise.resolve('No groups block this alias');
					const groupIds = Object.keys(this.groups), removeAliasFromGroup = [
						groupId => deleteArtistFromGroup(groupId, artist.id, this.groups[groupId]),
						function(index = 0) {
							if (!(index >= 0 && index < groupIds.length))
								return Promise.resolve('Artist alias removed from all groups');
							const importances = this.groups[groupIds[index]];
							console.assert(Array.isArray(importances) && importances.length > 0,
								'Array.isArray(importances) && importances.length > 0');
							return Array.isArray(importances) && importances.length > 0 ?
								deleteArtistFromGroup(groupIds[index], artist.id, importances)
									.then(results => removeAliasFromGroup[1].call(this, index + 1))
								: removeAliasFromGroup[1].call(this, index + 1);
						},
					];
					return (groupIds.length > 100 ? removeAliasFromGroup[1].call(this) : groupIds.length > 1 ?
							Promise.all(groupIds.slice(0, -1).map(removeAliasFromGroup[0])).then(() =>
								wait(groupIds[groupIds.length - 1]).then(removeAliasFromGroup[0])).catch(function(reason) {
						console.warn('TorrentGroupsManager.removeAliasFromGroups parallely failed, trying serially:', reason);
						return removeAliasFromGroup[1].call(this);
					}) : removeAliasFromGroup[0](groupIds[groupIds.length - 1])).then(wait);
				}
				addAliasToGroups(aliasName = artist.name) {
					if (!this.aliasUsed) return Promise.resolve('No groups block this alias');
					if (!aliasName) return Promise.reject('Argument is invalid');
					const groupIds = Object.keys(this.groups), _addAliasToGroup = [
						groupId => addAliasToGroup(groupId, aliasName, this.groups[groupId]),
						function(index = 0) {
							if (!(index >= 0 && index < groupIds.length)) return Promise.resolve('Artist alias re-added to all groups');
							const importances = this.groups[groupIds[index]];
							console.assert(Array.isArray(importances) && importances.length > 0,
								'Array.isArray(importances) && importances.length > 0');
							return Array.isArray(importances) && importances.length > 0 ?
								addAliasToGroup(groupIds[index], aliasName, importances)
									.then(result => _addAliasToGroup[1].call(this, index + 1))
								: _addAliasToGroup[1].call(this, index + 1);
						}
					];
					return groupIds.length > 100 ? _addAliasToGroup[1].call(this).then(wait) : groupIds.length > 1 ?
							_addAliasToGroup[0](groupIds[0]).then(wait).then(() => Promise.all(groupIds.slice(1).map(_addAliasToGroup[0]))).catch(function(reason) {
						console.warn('TorrentGroupsManager.addAliasToGroups parallely failed, trying serially:', reason);
						return _addAliasToGroup[1].call(this).then(wait);
					}) : _addAliasToGroup[0](groupIds[0]).then(wait);
				}
			}

			class AliasDependantsManager {
				constructor(aliasId) {
					console.assert(aliasId > 0, 'aliasId > 0');
					if (aliasId > 0) this.redirectTo = aliasId; else throw 'Invalid argument';
					if ((this.aliases = Array.from(aliases).map(function(li) {
						const alias = getAlias(li);
						if (alias && alias.redirectId == aliasId) return alias;
					}).filter(Boolean)).length <= 0) delete this.aliases;
				}

				get size() { return Array.isArray(this.aliases) ? this.aliases.length : 0 }
				get hasDependants() { return this.size > 0 }

				removeAll() {
					return this.hasDependants ? Promise.all(this.aliases.map(function(alias) {
						let worker = Promise.resolve();
						if (alias.tgm.aliasUsed) worker = alias.tgm.removeAliasFromGroups();
						return worker.then(() => deleteAlias(alias.id));
					})) : Promise.resolve('No dependants');
				}
				restoreAll(redirectTo = this.redirectTo, artistIdOrName) {
					return this.hasDependants ? resolveArtistId(artistIdOrName)
							.then(artistId => resolveAliasId(redirectTo, (artistIdOrName || !(redirectTo >= 0)) && artistId, true)
								.then(redirectTo => Promise.all(this.aliases.map(alias => {
						let worker = addAlias(alias.name, redirectTo, artistIdOrName ? artistId : undefined).then(wait);
						if (alias.tgm.aliasUsed) worker = worker.then(() => findArtistAlias(redirectTo, artistId))
							.then(newAlias => alias.tgm.addAliasToGroups(newAlias.name));
						return worker;
					})))) : Promise.resolve('No dependants');
				}
			}

			class ArtistGroupKeeper {
				constructor() {
					for (let torrentGroup of artist.torrentgroup) if (torrentGroup.extendedArtists) for (let importance in torrentGroup.extendedArtists) {
						const artists = torrentGroup.extendedArtists[importance];
						if (Array.isArray(artists) && artists.length > 0) continue;
						this.artistId = artist.id;
						this.aliasName = `__${artist.id.toString()}__${Date.now().toString(16)}`;
						this.groupId = torrentGroup.groupId;
						this.importance = parseInt(importance);
						this.locked = false;
						return this;
					}
					throw 'Unable to find a spare group';
				}

				hold() {
					if (this.locked) return Promise.reject('Not available');
					if (!this.groupId) throw 'Unable to find a spare group';
					this.locked = true;
					return addAlias(this.aliasName).then(wait)
						.then(() => addAliasToGroup(this.groupId, this.aliasName, [this.importance])).then(() => this.locked);
				}
				release(artistIdOrName = this.artistId) {
					if (!this.locked) return Promise.reject('Not available');
					return resolveArtistId(artistIdOrName).then(artistId =>
						deleteArtistFromGroup(this.groupId, artistId, [this.importance])
							.then(wait().then(() => deleteAlias(this.aliasName, artistIdOrName && artistId))).then(() => this.locked = false));
				}
			}

			function getSelectedRedirect(defaultsToMain = false) {
				let redirect = aliasesRoot.querySelector('select[name="redirect"]');
				if (redirect == null) throw 'Assertion failed: can not locate redirect selector';
				redirect = {
					id: parseInt(redirect.options[redirect.selectedIndex].value),
					name: redirect.options[redirect.selectedIndex].label,
				};
				console.assert(redirect.id >= 0 && redirect.name, 'redirect.id >= 0 && redirect.name');
				if (defaultsToMain && redirect.id == 0) {
					redirect.id = mainIdentityId;
					redirect.name = artist.name;
				}
				return Object.freeze(redirect);
			}
			function failHandler(reason) {
				if (activeElement instanceof HTMLElement && activeElement.parentNode != null) {
					activeElement.style.color = null;
					if (activeElement.dataset.caption) activeElement.value = activeElement.dataset.caption;
					activeElement.disabled = false;
					activeElement = null;
					inProgress = false;
				}
				alert(reason);
			}

			// Damage control
			function setRecoveryInfo(action, aliases, param) {
				console.assert(aliases && (typeof aliases == 'object' || Array.isArray(aliases)) && action,
					"aliases && (typeof aliases == 'object' || Array.isArray(aliases)) && action");
				const damageControl = {
					artist: artist,
					action: action,
					aliases: aliases,
				};
				if (param) damageControl.param = param;
				GM_setValue('damage_control', damageControl);
			}
			function recoverFromFailure() {
				const recoveryInfo = GM_getValue('damage_control');
				if (!recoveryInfo) return Promise.reject('No unfinished operation present');
				if (recoveryInfo.artist.id != artist.id)
					return Promise.reject('Unfinished operation for this artist not present');
				//artist = recoveryInfo.artist; // ?
				return eval(recoveryInfo.action)(recoveryInfo.artist, aliases, recoveryInfo.param).then(clearRecoveryInfo);
			}

			function dupesCleanup(alias) {
				if (!alias || !alias.id) throw 'Invalid argument';
				const target = findAlias(alias.redirectId) || alias, workers = [ ], dupes = [ ];
				aliases.forEach(function(li) {
					const dupe = getAlias(li);
					if (!dupe || dupe.id == alias.id || dupe.name.toLowerCase() != alias.name.toLowerCase()) return;
					let index;
					const ancestor = findAlias(dupe.redirectId);
					if (ancestor && 'dependants' in ancestor && ancestor.dependants.hasDependants) {
						while ((index = ancestor.dependants.aliases.indexOf(alias => alias.id == dupe.id)) >= 0)
							ancestor.dependants.aliases.splice(index, 1);
					}
					if ('dependants' in dupe && dupe.dependants.hasDependants) {
						while ((index = dupe.dependants.aliases.indexOf(dependant => dependant.name.toLowerCase() == alias.name.toLowerCase())) >= 0)
							dupe.dependants.aliases.splice(index, 1);
						if (dupe.dependants.hasDependants) workers.push(dupe.dependants.removeAll());
					}
					workers.push(dupe.tgm.aliasUsed ? dupe.tgm.removeAliasFromGroups().then(() => deleteAlias(dupe.id))
						: deleteAlias(dupe.id));
					dupes.push(dupe);
				});
				return workers.length > 0 ? Promise.all(workers).then(() => Promise.all(dupes.map(function(dupe) {
					const workers = [ ];
					if (dupe.tgm.aliasUsed) workers.push(dupe.tgm.addAliasToGroups(target.name)
						.then(() => { Object.assign(target.tgm.groups, dupe.tgm.groups) }));
					if ('dependants' in dupe && dupe.dependants.hasDependants) workers.push(dupe.dependants.restoreAll(target.id)
							.then(wait).then(() => Promise.all(dupe.dependants.aliases.map(alias => resolveAliasId(alias.name, artist.id)
								.then(aliasId => (alias.id = aliasId, alias))))).then(function(aliases) {
						if (!('dependants' in target)) target.dependants = new AliasDependantsManager(target.id);
						if (!Array.isArray(target.dependants.aliases)) target.dependants.aliases = [ ];
						Array.prototype.push.apply(target.dependants.aliases, aliases);
					}));
					if (workers.length > 0) return Promise.all(workers);
				}))) : Promise.resolve('No duplicate aliases');
			}
			function prologue(alias, agk) {
				let worker = dupesCleanup(alias).then(function() {
					const workers = [ ];
					if (agk && alias.tgm.size >= artist.torrentgroup.length) workers.push(agk.hold());
					if ('dependants' in alias && alias.dependants.hasDependants) workers.push(alias.dependants.removeAll());
					if (workers.length > 0) return Promise.all(workers);
				});
				if (alias.tgm.aliasUsed) worker = worker.then(() => alias.tgm.removeAliasFromGroups());
				return worker;
			}
			function epilogue(alias, agk, id1, id2 = id1) {
				function finish() {
					const workers = [ ];
					if ('dependants' in alias && alias.dependants.hasDependants) workers.push(alias.dependants.restoreAll(id2));
					if (agk && alias.tgm.size >= artist.torrentgroup.length) workers.push(agk.release());
					return Promise.all(workers);
				}

				if (!alias || !id1) throw 'Invalid argument';
				return alias.tgm.aliasUsed ? alias.tgm.addAliasToGroups(id1).then(finish) : finish();
			}
			function redirectAliasTo(alias, redirectIdOrName) {
				if (!alias) throw 'Invalid argument';
				return resolveAliasId(redirectIdOrName, -1, true).then(function(redirectId) {
					if (redirectId == alias.id) return Promise.reject('Alias can\'t redirect to itself');
					if (alias.redirectId == redirectId) return Promise.resolve('Redirect doesnot change');
					const agk = new ArtistGroupKeeper, workers = [ ];
					if (alias.tgm.size >= artist.torrentgroup.length) workers.push(agk.hold());
					if ('dependants' in alias && alias.dependants.hasDependants) workers.push(alias.dependants.removeAll());
					let worker = Promise.all(workers);
					if (alias.tgm.aliasUsed) worker = worker.then(() => alias.tgm.removeAliasFromGroups());
					return worker.then(() => deleteAlias(alias.id)).then(wait)
						.then(() => addAlias(alias.name, redirectId)).then(wait)
						.then(() => epilogue(alias, agk, alias.name, redirectId));
				});
			}
			function renameAlias(alias, newName) {
				if (!alias) throw 'Invalid argument';
				const agk = new ArtistGroupKeeper;
				return prologue(alias, agk).then(() => deleteAlias(alias.id)).then(() => wait(newName).then(addAlias))
					.then(wait).then(() => epilogue(alias, agk, newName));
			}
			function resolveRDA(alias) {
				if (!alias || !alias.id || !alias.redirectId) return Promise.reject('Invalid argument');
				if (!alias.tgm.aliasUsed) return Promise.resolve('Alias fully resolved');
				const target = findAlias(alias.redirectId, true);
				if (!target) throw 'Assertion failed: target alias not found';
				return alias.tgm.removeAliasFromGroups().then(() => alias.tgm.addAliasToGroups(target.name)).then(function() {
					if (target.tgm) Object.assign(target.tgm.groups, alias.tgm.groups);
					alias.tgm.groups = { };
				});
			}

			const recoveryQuestion = `Last operation for current artist was not successfull,
if you continue, recovery information will be invalidated or lost.`;

			// NRA actions

			function makeItMain(evt) {
				if (inProgress || hasRecoveryInfo() && !confirm(recoveryQuestion)) return false;
				console.assert(evt.currentTarget.nodeName == 'A', "evt.currentTarget.nodeName == 'A'");
				const alias = getAlias(evt.currentTarget.parentNode);
				console.assert(alias && typeof alias == 'object');
				console.assert(alias.id != mainIdentityId, 'alias.id != mainIdentityId');
				let nagText = `CAUTION

This action makes alias "${alias.name}" the main identity for artist ${artist.name},
while "${artist.name}" becomes it's subordinate N-R alias.`;
				if (alias.tgm.aliasUsed) nagText += '\n\nBlocked by ' + alias.tgm.size + ' groups';
				if (!confirm(nagText + epitaph)) return false;
				inProgress = true;
				activeElement = evt.currentTarget;
				evt.currentTarget.textContent = 'processing ...';
				evt.currentTarget.style.color = 'red';
				const agk = new ArtistGroupKeeper;
				if (alias.tgm.aliasUsed) prologue(alias, agk).then(() => deleteAlias(alias.id)).then(wait)
					.then(() => alias.tgm.addAliasToGroups(alias.name)).then(() => findArtistId(alias.name))
					.then(function(newArtistId) {
						let worker = changeArtistId(newArtistId).then(wait);
						if (alias.tgm.size >= artist.torrentgroup.length) worker = worker.then(() => agk.release(newArtistId));
						if ('dependants' in alias && alias.dependants.hasDependants)
							worker = worker.then(() => alias.dependants.restoreAll(alias.name, newArtistId));
						return worker.then(function() {
							let body = document.getElementById('body');
							body = body != null && body.value.trim() || artist.body;
							let image = document.body.querySelector('input[type="text"][name="image"]');
							image = image != null && image.value.trim() || artist.image;
							const workers = [ ];
							if (body || image) workers.push(editArtist(image, body, 'Wiki transfer (AAM)', newArtistId));
							const similarArtists = artist.similarArtists ?
								artist.similarArtists.map(similarArtist => similarArtist.name) : [ ];
							if (similarArtists.length > 0) workers.push(addSimilarArtists(similarArtists, newArtistId));
							if (workers.length > 0) return Promise.all(workers);
						}).then(() => { gotoArtistEditPage(newArtistId) });
					}).catch(failHandler);
				else {
					const mainIdentity = findAlias(mainIdentityId);
					console.assert(mainIdentity != null, 'mainIdentity != null');
					let worker = dupesCleanup(mainIdentity).then(function() {
						const workers = [mainIdentity, alias].filter(alias => 'dependants' in alias && alias.dependants.hasDependants)
							.map(alias => alias.dependants.removeAll());
						if (mainIdentity.tgm.size >= artist.torrentgroup.length) workers.push(agk.hold());
						if (workers.length > 0) return Promise.all(workers);
					});
					if (mainIdentity.tgm.aliasUsed) worker = worker.then(() => mainIdentity.tgm.removeAliasFromGroups());
					worker = worker.then(() => renameArtist(alias.name)).then(wait)
						.then(() => deleteAlias(artist.name)).then(wait).then(() => addAlias(artist.name)).then(wait);
					if (mainIdentity.tgm.aliasUsed) worker = worker.then(() => mainIdentity.tgm.addAliasToGroups(artist.name));
					if (mainIdentity.tgm.size >= artist.torrentgroup.length) worker = worker.then(() => agk.release());
					worker = worker.then(function() {
						const workers = [ ];
						if ('dependants' in alias && alias.dependants.hasDependants)
							workers.push(alias.dependants.restoreAll(alias.name));
						if ('dependants' in mainIdentity && mainIdentity.dependants.hasDependants)
							workers.push(mainIdentity.dependants.restoreAll(artist.name));
						if (workers.length > 0) return Promise.all(workers);
					}).then(() => { document.location.reload() }, failHandler);
				}
				return false;
			}

			function changeToRedirect(evt) {
				if (inProgress || hasRecoveryInfo() && !confirm(recoveryQuestion)) return false;
				console.assert(evt.currentTarget.nodeName == 'A', "evt.currentTarget.nodeName == 'A'");
				const alias = getAlias(evt.currentTarget.parentNode), redirect = getSelectedRedirect(true);
				console.assert(alias && typeof alias == 'object');
				if (redirect.id == alias.id) return false;
				let nagText = `CAUTION

This action makes alias "${alias.name}" redirect to artist\'s variant "${redirect.name}",
and replaces the alias in all involved groups (if any) with this variant.`;
				if (alias.tgm.aliasUsed) nagText += '\n\nBlocked by ' + alias.tgm.size + ' groups';
				if (!confirm(nagText + epitaph)) return false;
				inProgress = true;
				activeElement = evt.currentTarget;
				evt.currentTarget.textContent = 'processing ...';
				evt.currentTarget.style.color = 'red';
				redirectAliasTo(alias, redirect.id).then(() => { document.location.reload() }, failHandler);
				return false;
			}

			function renameNRA(evt) {
				if (inProgress || hasRecoveryInfo() && !confirm(recoveryQuestion)) return false;
				console.assert(evt.currentTarget.nodeName == 'A', "evt.currentTarget.nodeName == 'A'");
				const alias = getAlias(evt.currentTarget.parentNode);
				console.assert(alias && typeof alias == 'object');
				let nagText = `CAUTION

This action renames alias
"${alias.name}",
and replaces the alias in all involved groups (if any) with the new name.
New name can't be artist name or alias already taken on the site.`;
				if (alias.tgm.aliasUsed) nagText += '\n\nBlocked by ' + alias.tgm.size + ' groups';
				let newName = prompt(nagText + '\n\nThe operation can be reverted only by hand, to proceed enter and confirm new name\n\n', alias.name);
				if (newName) newName = newName.trim(); else return false;
				if (!newName || newName == alias.name) return false;
				const currentTarget = evt.currentTarget;
				(newName.toLowerCase() == alias.name.toLowerCase() ? Promise.reject('Case change')
				 		: findArtistAlias(newName).then(function(alias) {
					alert(`Name is already taken by alias id ${alias.id}, the operation is aborted`);
				}, reason => queryAjaxAPI('artist', { artistname: newName }).then(function(dupeTo) {
					siteArtistsCache[dupeTo.name] = dupeTo;
					alert(`Name is already taken by artist "${dupeTo.name}" (${dupeTo.id}), the operation is aborted`);
					GM_openInTab(document.location.origin + '/artist.php?id=' + dupeTo.id.toString(), false);
				}))).catch(function(reason) {
					inProgress = true;
					activeElement = currentTarget;
					currentTarget.textContent = 'processing ...';
					currentTarget.style.color = 'red';
					//setRecoveryInfo('renameAlias', alias, newName);
					renameAlias(alias, newName).then(() => { document.location.reload() }, failHandler);
				});
				return false;
			}

			function cutOffNRA(evt) {
				if (inProgress || hasRecoveryInfo() && !confirm(recoveryQuestion)) return false;
				console.assert(evt.currentTarget.nodeName == 'A', "evt.currentTarget.nodeName == 'A'");
				const alias = getAlias(evt.currentTarget.parentNode);
				console.assert(alias && typeof alias == 'object');
				console.assert('dependants' in alias, "'dependants' in alias");
				let nagText = 'CAUTION\n\nThis action ';
				nagText += alias.tgm.aliasUsed ? `cuts off identity "${alias.name}"
from artist ${artist.name} and leaves it in separate group.

Blocked by ${alias.tgm.size} groups`
					: `deletes identity "${alias.name}" and all it's dependants (${alias.dependants.size}).

(Not used in any release)`;
				if (artist.torrentgroup.length <= alias.tgm.size) nagText += `

This action also vanishes this artist group as no other name variants
are used in any release`;
				if (!confirm(nagText + epitaph)) return false;
				inProgress = true;
				activeElement = evt.currentTarget;
				evt.currentTarget.textContent = 'processing ...';
				evt.currentTarget.style.color = 'red';
				const agk = new ArtistGroupKeeper;
				let worker = prologue(alias, agk).then(() => deleteAlias(alias.id)).then(wait);
				if (alias.tgm.aliasUsed) worker = worker.then(() => alias.tgm.addAliasToGroups(alias.name));
				worker = worker.then(alias.tgm.aliasUsed ? () => findArtistId(alias.name).then(function(newArtistId) {
					let worker = Promise.resolve();
					if ('dependants' in alias && alias.dependants.hasDependants)
						worker = worker.then(() => alias.dependants.restoreAll(alias.name, newArtistId));
					return worker.then(function() {
						if (artist.torrentgroup.length > alias.tgm.size) {
							GM_openInTab(document.location.origin + '/artist.php?id=' + newArtistId.toString(), true);
							document.location.reload();
						} else gotoArtistPage(newArtistId);
					});
				}) : () => { document.location.reload() }).catch(failHandler);
				return false;
			}

			function split(evt) {
				if (inProgress || hasRecoveryInfo() && !confirm(recoveryQuestion)) return false;
				console.assert(evt.currentTarget.nodeName == 'A', "evt.currentTarget.nodeName == 'A'");
				let newName, newNames = [ ];
				const alias = getAlias(evt.currentTarget.parentNode);
				console.assert(alias && typeof alias == 'object');
				if (!alias.tgm.aliasUsed) return false;
				const prologue = () => {
					let result = `CAUTION

This action splits artist's identity "${alias.name}" into two or more names
and replaces the identity in all involved groups with new names. No linking of new names
to current artist will be performed, profile pages of names that aren't existing aliases already
will open in separate tabs for review.`;
					if (alias.tgm.aliasUsed) result += '\n\nBlocked by ' + alias.tgm.size + ' groups';
					if (newNames.length > 0) result += '\n\n' + newNames.map(n => '\t' + n).join('\n');
					return result;
				};
				do {
					if ((newName = prompt(prologue().replace(/^CAUTION\s*/, '') +
						`\n\nEnter carefully new artist name #${newNames.length + 1}, to finish submit empty input\n\n`,
						newNames.length < 2 ? alias.name : undefined)) == undefined) return false;
					if ((newName = newName.trim()) && !newNames.includes(newName)) newNames.push(newName);
				} while (newName);
				if (newNames.length < 2 || !confirm(prologue() + epitaph)) return false;
				inProgress = true;
				activeElement = evt.currentTarget;
				evt.currentTarget.textContent = 'processing ...';
				evt.currentTarget.style.color = 'red';
				console.info(alias.name, 'present in these groups:', alias.tgm.groups);
				//alias.dependants.removeAll();
				alias.tgm.removeAliasFromGroups().then(() =>
						Promise.all(newNames.map(TorrentGroupsManager.prototype.addAliasToGroups.bind(alias.tgm)))).then(function() {
					newNames.forEach(function(newName, index) {
						if (index > 0 || artist.torrentgroup.length > alias.tgm.size) findArtistId(newName).then(artistId =>
							{ if (artistId != artist.id) GM_openInTab(document.location.origin + '/artist.php?id=' + artistId.toString(), true) });
					});
					if (artist.torrentgroup.length > alias.tgm.size) document.location.reload();
						else findArtistId(newNames[0]).then(artistId => { if (artistId != artist.id) gotoArtistPage(artistId) });
				}, failHandler);
				return false;
			}

			function select(evt) {
				console.assert(evt.currentTarget.nodeName == 'A', "evt.currentTarget.nodeName == 'A'");
				console.assert(dropDown instanceof HTMLSelectElement, 'dropDown instanceof HTMLSelectElement');
				const alias = getAlias(evt.currentTarget.parentNode);
				console.assert(alias && typeof alias == 'object');
				if (!alias.redirectId && dropDown != null) {
					dropDown.value = alias.id;
					if (typeof dropDown.onchange == 'function') dropDown.onchange();
				}
				return false;
			}

			// RDA actions

			function changeToNra(evt) {
				if (inProgress || hasRecoveryInfo() && !confirm(recoveryQuestion)) return false;
				console.assert(evt.currentTarget.nodeName == 'A', "evt.currentTarget.nodeName == 'A'");
				const alias = getAlias(evt.currentTarget.parentNode);
				console.assert(alias && typeof alias == 'object');
				if (!confirm(`This action makes artist's identity "${alias.name}" distinct`)) return false;
				console.assert(alias && typeof alias == 'object');
				inProgress = true;
				evt.currentTarget.textContent = 'processing ...';
				evt.currentTarget.style.color = 'red';
				let worker = alias.tgm.aliasUsed ? alias.tgm.removeAliasFromGroups() : Promise.resolve();
				worker = worker.then(() => deleteAlias(alias.id)).then(() => wait(alias.name).then(addAlias));
				if (alias.tgm.aliasUsed) worker = worker.then(() => alias.tgm.addAliasToGroups(alias.name));
				worker = worker.then(() => { document.location.reload() }, failHandler);
				return false;
			}

			function changeRedirect(evt) {
				if (inProgress || hasRecoveryInfo() && !confirm(recoveryQuestion)) return false;
				console.assert(evt.currentTarget.nodeName == 'A', "evt.currentTarget.nodeName == 'A'");
				const alias = getAlias(evt.currentTarget.parentNode), redirect = getSelectedRedirect();
				console.assert(alias && typeof alias == 'object');
				if (redirect.id == 0) return changeToNra(evt); else if (redirect.id == alias.redirectId) return false;
				if (!confirm(`This action changes alias "${alias.name}"'s to resolve to "${redirect.name}"`))
					return false;
				inProgress = true;
				evt.currentTarget.textContent = 'processing ...';
				evt.currentTarget.style.color = 'red';
				let worker = alias.tgm.aliasUsed ? resolveRDA(alias) : Promise.resolve();
				worker = worker.then(() => redirectAliasTo(alias, redirect.id)).then(() => { document.location.reload() }, failHandler);
				return false;
			}

			function renameRDA(evt) {
				if (inProgress || hasRecoveryInfo() && !confirm(recoveryQuestion)) return false;
				console.assert(evt.currentTarget.nodeName == 'A', "evt.currentTarget.nodeName == 'A'");
				const alias = getAlias(evt.currentTarget.parentNode);
				console.assert(alias && typeof alias == 'object');
				console.assert(alias.redirectId, 'alias.redirectId');
				let newName = prompt(`This action renames alias "${alias.name}"`, alias.name);
				if (newName) newName = newName.trim(); else return false;
				if (!newName || newName == alias.name) return false;
				const currentTarget = evt.currentTarget;
				findArtistAlias(newName).then(function(alias) {
					alert(`Name is already taken by alias id ${alias.id}, the operation is aborted`);
				}, reason => queryAjaxAPI('artist', { artistname: newName }).then(function(dupeTo) {
					siteArtistsCache[dupeTo.name] = dupeTo;
					alert(`Name is already taken by artist "${dupeTo.name}" (${dupeTo.id}), the operation is aborted`);
					GM_openInTab(document.location.origin + '/artist.php?id=' + dupeTo.id.toString(), false);
				})).catch(function(reason) {
					inProgress = true;
					activeElement = currentTarget;
					currentTarget.textContent = 'processing ...';
					currentTarget.style.color = 'red';
					renameAlias(alias, newName).then(() => { document.location.reload() }, failHandler);
				});
				return false;
			}

			function fixRDA(evt) {
				if (inProgress || hasRecoveryInfo() && !confirm(recoveryQuestion)) return false;
				console.assert(evt.currentTarget.nodeName == 'A', "evt.currentTarget.nodeName == 'A'");
				const alias = getAlias(evt.currentTarget.parentNode);
				console.assert(alias && typeof alias == 'object');
				if (!alias.redirectId) return false;
				const target = findAlias(alias.redirectId, true);
				if (!target) throw 'Assertion failed: redirecting alias was not found';
				if (!confirm(`This action forces alias "${alias.name}"'s to resolve to "${target.name}" in all still linked releases.`))
					return false;
				inProgress = true;
				evt.currentTarget.textContent = 'processing ...';
				evt.currentTarget.style.color = 'red';
				resolveRDA(alias).then(() => { document.location.reload() }, failHandler);
				return false;
			}

			function X(evt) {
				if (inProgress || hasRecoveryInfo() && !confirm(recoveryQuestion)) return false;
				console.assert(evt.currentTarget.nodeName == 'A', "evt.currentTarget.nodeName == 'A'");
				const alias = getAlias(evt.currentTarget.parentNode);
				console.assert(alias && typeof alias == 'object');
				if (!confirm('Delete this alias?')) return false;
				inProgress = true;
				activeElement = evt.currentTarget;
				evt.currentTarget.textContent = 'processing ...';
				evt.currentTarget.style.color = 'red';
				deleteAlias(alias.id).then(() => { document.location.reload() }, failHandler);
				return false;
			}

			// batch actions
			function batchAction(actions, condition) {
				console.assert(typeof actions == 'function', "typeof actions == 'function'");
				if (typeof actions != 'function') throw 'Invalid argument';
				let selAliases = aliasesRoot.querySelectorAll('div > ul > li > input.aam[type="checkbox"]:checked');
				if (selAliases.length <= 0) return Promise.reject('No aliases selected');
				selAliases = Array.from(selAliases).map(checkbox => getAlias(checkbox.parentNode));
				console.assert(selAliases.every(Boolean), 'selAliases.every(Boolean)');
				if (!selAliases.every(Boolean)) throw 'Assertion failed: element(s) without linked alias';
				if (typeof condition == 'function') selAliases = selAliases.filter(condition);
				if (selAliases.length <= 0) return Promise.reject('No alias fulfils for this action');
				//setRecoveryInfo('batchRecovery', selAliases, actions.toString());
				let worker = alias => dupesCleanup(alias).then(() => actions(alias));
				return (artist.torrentgroup.every(torrentGroup =>
					selAliases.some(alias => !alias.redirectId && alias.tgm
						&& Object.keys(alias.tgm).includes(torrentGroup.groupId))) ? (function() {
					const agk = new ArtistGroupKeeper;
					return agk.hold().then(() => Promise.all(selAliases.map(worker))).then(() => agk.release());
				})() : Promise.all(selAliases.map(actions))).then(function() {
					clearRecoveryInfo();
					document.location.reload();
				}, failHandler);
			}
			function batchRecovery(artist, aliases, actions) {
				if (typeof actions == 'string') actions = eval(actions);
				if (typeof actions != 'function') return Promise.reject('Action not valid callback');
				console.assert(Array.isArray(aliases) && aliases.length > 0, 'Array.isArray(aliases) && aliases.length > 0');
				return Promise.all(aliases.map(actions));
			}

			function batchChangeToRDA(evt) {
				if (inProgress || hasRecoveryInfo() && !confirm(recoveryQuestion)) return;
				const redirect = getSelectedRedirect();
				if (redirect.id == 0) return batchChangeToNRA(evt);
				let nagText = `CAUTION

This action makes all selected aliases redirect to artist\'s variant
"${redirect.name}",
and replaces all non-redirect aliases in their involved groups (if any) with this variant.`;
				if (!confirm(nagText + epitaph)) return false;
				inProgress = true;
				activeElement = evt.currentTarget;
				evt.currentTarget.disabled = true;
				evt.currentTarget.textContent = 'processing ...';
				evt.currentTarget.style.color = 'red';
				batchAction(alias => redirectAliasTo(alias, redirect.id), alias => alias.id != redirect.id).catch(failHandler);
			}

			function batchChangeToNRA(evt) {
				if (inProgress || hasRecoveryInfo() && !confirm(recoveryQuestion)) return;
				let nagText = `This action makes all selected RDAs distinct within artist`;
				if (!confirm(nagText)) return;
				inProgress = true;
				activeElement = evt.currentTarget;
				evt.currentTarget.disabled = true;
				evt.currentTarget.textContent = 'processing ...';
				evt.currentTarget.style.color = 'red';
				batchAction(alias => deleteAlias(alias.id).then(() => wait(alias.name).then(addAlias)),
					alias => alias.redirectId > 0).catch(failHandler);
			}

			function batchRemove(evt) {
				if (inProgress || hasRecoveryInfo() && !confirm(recoveryQuestion)) return;
				let nagText = `This action deletes all selected RDAs and unused NRAs`;
				if (!confirm(nagText)) return;
				inProgress = true;
				activeElement = evt.currentTarget;
				evt.currentTarget.textContent = 'processing ...';
				evt.currentTarget.style.color = 'red';
				batchAction(alias => deleteAlias(alias.id), alias => alias.redirectId > 0 || !alias.tgm.aliasUsed)
					.catch(failHandler);
			}

			if (hasRecoveryInfo()) for (let h2 of document.body.querySelectorAll('div#content h2')) {
				if (!h2.textContent.includes('Artist aliases')) continue;
				let input = document.createElement('INPUT');
				input.type = 'button';
				input.dataset.caption = input.value = 'Recover from unfinished operation';
				window.tooltipster.then(() => { input.tooltipster({
					content: 'Unfinished operation information was found for this artist<br>Recovery will try to finish.',
				}) });
				input.style.marginLeft = '2em';
				input.value = '[ processing ... ]';
				input.onclick = function(evt) {
					if (inProgress || !confirm('This will try to finalize last interrputed operation. Continue?')) return;
					inProgress = true;
					(activeElement = evt.currentTarget).disabled = true;
					activeElement.style.color = 'red';
					recoverFromFailure().then(function() {
						activeElement.value = 'Recovery successfull, reloading...';
						//document.location.reload();
					}, function(reason) {
						activeElement.style.color = null;
						activeElement.value = input.dataset.caption;
						activeElement.disabled = false;
						alert('Recovery was not successfull: ' + reason);
						document.location.reload();
					});
				};
				h2.insertAdjacentElement('afterend', input);
			}

			for (let li of aliases) if (!rdExtractor.test(li.textContent)) {
				const alias = {
					id: li.querySelector(':scope > span:nth-of-type(1)'),
					name: li.querySelector(':scope > span:nth-of-type(2)'),
				};
				if (Object.keys(alias).some(key => key == null) || !(alias.id = parseInt(alias.id.textContent))) continue;
				const elem = alias.name;
				if (!(alias.name = alias.name.textContent) || alias.name != artist.name) continue;
				mainIdentityId = alias.id;
				rmDelLink(li);
				elem.style.fontWeight = 900;
				break;
			}
			console.assert(mainIdentityId > 0, 'mainIdentityId > 0');

			function applyDynaFilter(str) {
				const filterById = Number.isInteger(str), norm = str => str.toLowerCase();
				if (!filterById) str = str ? parseInt(str) || (function() {
					const rx = /^\s*\/(.+)\/([dgimsuy]+)?\s*$/i.exec(str);
					if (rx != null) try { return new RegExp(...rx) } catch(e) { /*console.info(e)*/ }
				})() || norm(str.trim()) : undefined;

				function isHidden(li) {
					if (!str) return false;
					let elem = li.querySelector(':scope > span:nth-of-type(2)');
					console.assert(elem != null, 'elem != null');
					if (!filterById && (elem == null || (str instanceof RegExp ? str.test(elem.textContent)
							: norm(elem.textContent).includes(str)))) return false;
					if (!Number.isInteger(str)) return true;
					elem = li.querySelector(':scope > span:nth-of-type(1)');
					if (elem != null && str == parseInt(elem.textContent)) return false;
					return (elem = rdExtractor.exec(li.textContent)) == null || str != parseInt(elem[1]);
				}
				for (let li of aliases) li.hidden = isHidden(li);
			}

			for (let li of aliases) {
				li.alias = {
					id: li.querySelector(':scope > span:nth-of-type(1)'),
					name: li.querySelector(':scope > span:nth-of-type(2)'),
					redirectId: rdExtractor.exec(li.textContent),
				};
				if (li.alias.id == null || li.alias.name == null) {
					delete li.alias;
					continue;
				}
				if (li.alias.redirectId == null) {
					li.alias.id.style.cursor = 'pointer';
					li.alias.id.onclick = function(evt) {
						const aliasId = parseInt(evt.currentTarget.textContent);
						console.assert(aliasId >= 0, 'aliasId >= 0');
						if (!(aliasId >= 0)) throw 'Invalid node value';
						applyDynaFilter(aliasId);
						const dynaFilter = document.getElementById('aliases-dynafilter');
						if (dynaFilter != null) dynaFilter.value = aliasId;
					};
					li.alias.id.title = 'Click to filter';
					(elem => { window.tooltipster.then(() => { $(elem).tooltipster() }) })(li.alias.id);
				}
				if (!(li.alias.id = parseInt(li.alias.id.textContent)) || !(li.alias.name = li.alias.name.textContent)) continue; // assertion failed
				li.alias.tgm = new TorrentGroupsManager(li.alias.id);

				function addButton(caption, tooltip, cls, margin, callback) {
					const a = document.createElement('A');
					a.className = 'brackets';
					if (cls) a.classList.add(cls);
					if (margin) a.style.marginLeft = margin;
					a.href = '#';
					if (caption) a.dataset.caption = a.textContent = caption.toUpperCase();
					if (tooltip) window.tooltipster.then(() => { $(a).tooltipster({ content: tooltip.replace(/\r?\n/g, '<br>') }) }).catch(function(reason) {
						a.title = tooltip;
						console.warn(reason);
					});
					if (typeof callback == 'function') a.onclick = callback;
					li.append(a);
				}

				if (li.alias.redirectId != null) { // RDA
					li.alias.redirectId = parseInt(li.alias.redirectId[1]);
					console.assert(li.alias.redirectId > 0, 'li.alias.redirectId > 0');
					for (let span of li.getElementsByTagName('SPAN')) if (parseInt(span.textContent) == li.alias.redirectId) {
						const deref = findAlias(li.alias.redirectId);
						if (deref) window.tooltipster.then(function() {
							const tooltip = '<span style="font-size: 10pt; padding: 1em;">' + deref.name + '</span>';
							if ($(span).data('plugin_tooltipster'))
								$(span).tooltipster('update', tooltip).data('plugin_tooltipster').options.delay = 100;
							else $(span).tooltipster({ delay: 100, content: tooltip });
						}).catch(function(reason) {
							//span.textContent = deref + ' (' + span.textContent + ')';
							span.title = deref.name;
							console.warn(reason);
						});
						span.style.cursor = 'pointer';
						span.onclick = function(evt) {
							applyDynaFilter(li.alias.redirectId);
							const dynaFilter = document.getElementById('aliases-dynafilter');
							if (dynaFilter != null) dynaFilter.value = li.alias.redirectId;
						};
					}

					addButton('NRA', 'Change to non-redirecting alias', 'make-nra', '3pt', changeToNra);
					addButton('CHG', 'Change redirect', 'redirect-to', '5pt', changeRedirect);
					addButton('RN', 'Rename this alias', 'rename', '5pt', renameRDA);
					if (li.alias.tgm.aliasUsed) addButton('FIX', 'Renounce - this alias is still linked to torrent groups. Fix forces resolve the alias to it\'s redirect',
						'fix-rda', '5pt', fixRDA);
				} else { // NRA
					delete li.alias.redirectId;
					li.style.color = isLightTheme ? 'peru' : isDarkTheme ? 'antiquewhite' : 'darkorange';
					if (li.alias.name != artist.name) {
						addButton('MAIN', 'Make this alias main artist\'s identity', 'make-main', '3pt', makeItMain);
						addButton('RD', 'Change to redirecting alias to artist\'s identity selected in dropdown below',
							'redirect-to', '5pt', changeToRedirect);
						addButton('RN', 'Rename this alias while keeping it distinguished from the main identity',
							'rename', '5pt', renameNRA);
						addButton('CUT', 'Just unlink this alias from the artist and leave it in separate group; unused aliases will be deleted',
							'cut-off', '5pt', cutOffNRA);
					}
					if (li.alias.tgm.aliasUsed) addButton('S', 'Split this ' + (li.alias.name == artist.name ? 'artist': 'alias') +
						' to two or more names', 'split', '5pt', split);
					addButton('SEL', 'Select as redirect target', 'select', '5pt', select);
				}
				if (li.alias.tgm.aliasUsed) {
					rmDelLink(li);
					const span = document.createElement('span');
					span.textContent = '(' + li.alias.tgm.size + ')';
					span.style.marginLeft = '5pt';
					if (li.alias.redirectId > 0) span.style.color = 'red';
					window.tooltipster.then(() => { $(span).tooltipster({ content: 'Amount of groups blocking this alias' }) }).catch(function(reason) {
						span.title = 'Amount of groups blocking this alias';
						console.warn(reason);
					});
					li.append(span);
				}
				for (let a of li.getElementsByTagName('A')) if (a.textContent.trim() == 'X') {
					a.href = '#';
					a.dataset.caption = a.textContent;
					a.onclick = X;
				}
				for (let a of li.getElementsByTagName('A')) if (a.textContent.trim() == 'User') {
					const href = new URL(a.href);
					if (userId > 0 && parseInt(href.searchParams.get('id')) == userId) {
						const span = document.createElement('SPAN');
						span.className = 'brackets';
						span.style.color = 'skyblue';
						span.textContent = 'Me';
						li.replaceChild(span, a);
					}
				}
			}
			for (let li of aliases) if ('alias' in li && !(li.alias.redirectId > 0))
				li.alias.dependants = new AliasDependantsManager(li.alias.id);

			const h3 = aliasesRoot.getElementsByTagName('H3');
			if (h3.length > 0 && aliases.length > 1) {
				const elems = createElements('LABEL', 'INPUT', 'INPUT', 'DIV', 'LABEL', 'INPUT', 'IMG', 'SPAN');
				elems[3].style = 'transition: height 0.5s; height: 0; overflow: hidden;';
				elems[3].id = 'batch-controls';
				elems[4].style = 'margin-left: 15pt; padding: 5pt; line-height: 0;';
				elems[5].type = 'checkbox';
				elems[5].onclick = function(evt) {
					for (let input of aliasesRoot.querySelectorAll('div > ul > li > input.aam[type="checkbox"]'))
						if (!input.parentNode.hidden) input.checked = evt.currentTarget.checked;
				};
				elems[4].append(elems[5]);
				elems[3].append(elems[4]);

				function addButton(caption, callback, tooltip, margin = '5pt') {
					const input = document.createElement('INPUT');
					input.type = 'button';
					if (caption) input.dataset.caption = input.value = caption;
					if (margin) input.style.marginLeft = margin;
					if (tooltip) window.tooltipster.then(() => { $(input).tooltipster({ content: tooltip.replace(/\r?\n/g, '<br>') }) })
						.catch(reason => { console.warn(reason) });
					if (typeof callback == 'function') input.onclick = callback;
					elems[3].append(input);
				}
				addButton('Redirect', batchChangeToRDA, 'Make selected aliases redirect to selected identity', '1em');
				addButton('Distinct', batchChangeToNRA, 'Make selected aliases distinct (make them NRA)');
				addButton('Delete', batchRemove, 'Remove all selected aliases (except used NRAs)');

				h3[0].insertAdjacentElement('afterend', elems[3]);
				elems[2].type = 'button';
				elems[2].value = 'Show batch controls';
				elems[2].style.marginLeft = '2em';
				elems[2].onclick = function(evt) {
					if ((elems[3] = document.getElementById('batch-controls')) != null) elems[3].style.height = 'auto';
					evt.currentTarget.remove();
					let tabIndex = 0;
					for (let li of aliasesRoot.querySelectorAll('div > ul > li')) {
						let elem = li.querySelector(':scope > span:nth-of-type(2)');
						if (elem == null || elem.textContent == artist.name) continue;
						elem = document.createElement('INPUT');
						elem.type = 'checkbox';
						elem.className = 'aam';
						elem.tabIndex = ++tabIndex;
						elem.style = 'margin-right: 2pt; position: relative; left: -2pt;';
						li.prepend(elem);
						li.style.listStyleType = 'none';
					}
				};
				h3[0].insertAdjacentElement('afterend', elems[2]);
				elems[0].textContent = 'Filter by';
				elems[1].type = 'text';
				elems[1].id = 'aliases-dynafilter';
				elems[1].style = 'margin-left: 1em; width: 20em; padding-right: 20pt;';
				elems[1].ondblclick = evt => { applyDynaFilter(evt.currentTarget.value = '') };
				elems[1].oninput = evt => { applyDynaFilter(evt.currentTarget.value) };
				elems[1].ondragover = elems[1].onpaste = evt => { evt.currentTarget.value = '' };
				elems[6].height = 17;
				elems[6].style = 'position: relative; left: -18pt; top: 2pt;';
				elems[6].src = 'data:image/png;base64,' +
					'iVBORw0KGgoAAAANSUhEUgAAABsAAAAbCAYAAACN1PRVAAAACXBIWXMAAA7DAAAOwwHHb6hk' +
					'AAAEbklEQVR4nL2W609bZRzHf0i59oJtDR1VioO2w2SwgWzSDUorVBacWdCpBBNlXBL/AGdm' +
					'ssQZEk18tYTEaOZiZmK4bCgmxoQwKLSOS6UQRkL3ktvY+gKyQS+0HeLzPdDjOaXcNPGbNOf0' +
					'PM95Ps/v+hzJ5uYm/V+SHGTSmQpry9GXj5bp9XqVTCZLlkmlyT6/P7yyshJ8tLS05nnw4Pex' +
					'e472/wR7q/bid8VFRdpLHzUoEhMTzcKxlJQUUqtUZNDryWw251xr/bJ+3O1++FtP98eHghW+' +
					'etr0hs12+c2aGmVycrJlvx0zlet0OtJqtUNXrn7e09vb2zb552j/vrCi06WVjQ0NV+RyeVVC' +
					'QsIBOILFJJIKg95ARzQaKVuHJl1ioAgGiz6or7+sUCiqDkURCPvD+y1NzYknSl6LTI2POeLC' +
					'bFVVn7ygVlf/W5BQKSmp1mqb7Qm73Qk7X3vx2/M1NapY16E0/H4/JSUlcUkRq0AgSJFIhFkj' +
					'J+G7uM3NzVXWvlv3/S+3O5pFsMKCgqzYZADo8WMv3e6+Q/q8PKqqrCQ2hx8Ph8Pk/MNJ09PT' +
					'VFdXRy9qtSIg1isuLg6ILDt1puztpkuNithdwyKAvF4v9wPcarFQeno6syhADqeThkdGaGNj' +
					'gzo6OqipsZGUSqVoDZYsstJyy4ejzsEfOZjRYLwQL8XhOlgEEISFQ6EQlZ09S6NjYzTuHmeg' +
					'v4jVIOXp87j5sUJ9nigsXGK3WzCdLjtjxyzaKly4DhYBBE1MTpLH46EQc+EW6DkqOnmSzlWf' +
					'o7TU1HjLUFbWERnvRuXzyviziPM75zpYBBDAgWAwumsqOH6cXrdadwVBmkzNP7C0tLSd9guE' +
					'GMF1sCgKim7EZDJRRkZcx/Bim0rgYeuh0DOpVLrrZCQDYgTXCbW+vk4ul4tU1dXchnYT88oG' +
					'D/P7fGE01XhCeiPrhMkAiwCCS90TE1xsWUMQlYVQPp8/zMPmFxZW0Ujj7IgGh4a203srGRAj' +
					'uA4WAQRhHPVlqUBZpO1YZ3Zu9gkPm/F4ek2lpS/FHiPoDChY1BEsQtYhGRAjuA4WRbN0jMGN' +
					'RiNXKkKxdx0zMzPDPAwF1/rV1++jAwiFOKIzoGBRR8L0RozgOlgEkIVlbE5Ozg6rFhcXV532' +
					'u208DHK73Y80mZlDOCaiz7AQNoDOgIKNTW/ECK6DRQAlScQnFov34P3paW/0Pz/ac6ez+dPP' +
					'rv587Fg+CXsxgLEtSCjEKNZ1ED5t5ubnn0absAgG9fb1XWenrZydR2jv5bsSDqC1tdW7/QMD' +
					'7dR6jX8mgm0fdLbrbd/0yeUKOuRBzQkWhULr9s6urp9c9xydwrG43yA/3LrVyoK/ZjQYVMIY' +
					'7ifEaHZu7umA3d4eC9oVtm2h48I77904VVLih2tpD7civZeXlwOjLtfDHi5GX8Sdt+en3K/d' +
					'XS24mszWplfy8xd02dkKtVqdzuorMRgMPmN9MjLPkmDq/lT/8JD9xl5r7QuLasRhv8kuNw8y' +
					'dy/9DV12xm1VGw5mAAAAAElFTkSuQmCC'; //'https://ptpimg.me/d005eu.png';
				elems[6].onclick = evt => {
					applyDynaFilter();
					const input = document.getElementById('aliases-dynafilter');
					if (input != null) input.value = '';
				};
				elems[0].append(elems[1]);
				elems[0].append(elems[6]);
				h3[0].insertAdjacentElement('afterend', elems[0]);
				elems[7].textContent = '(' + aliases.length + ')';
				elems[7].style = 'margin-left: 1em; font: normal 9pt Helvetica, Arial, sans-serif;';
				h3[0].append(elems[7]);
			}
			if (dropDown != null) dropDown.onchange = function(evt) {
				const redirectId = parseInt((evt instanceof Event ? evt.currentTarget : dropDown).value);
				if (!(redirectId >= 0)) throw 'Unknown selection';
				for (let li of aliases) {
					let id = li.querySelector(':scope > span:nth-of-type(1)');
					if (id != null) id = parseInt(id.textContent); else continue;
					li.style.backgroundColor = id == redirectId ?
						isLightTheme ? 'blanchedalmond' : isDarkTheme ? 'darkslategray' : 'orange' : null;
				}
			};
			if (typeof dropDown.onchange == 'function') dropDown.onchange();

			function addDiscogsImport() {
				const branches = {
					a: { parser: /\[a=?(\d+)\]/ig, endpoint: 'artists', type: 'artist' },
					r: { parser: /\[r=?(\d+)\]/ig, endpoint: 'releases', type: 'release' },
					m: { parser: /\[m=?(\d+)\]/ig, endpoint: 'masters', type: 'master' },
					l: { parser: /\[l=?(\d+)\]/ig, endpoint: 'labels', type: 'label' },
					g: { parser: /\[g=?(\d+)\]/ig, endpoint: 'genres', type: 'genre' },
				};

				function getDcArtistId() {
					console.assert(dcInput instanceof HTMLInputElement, 'dcInput instanceof HTMLInputElement');
					let m = /^(https?:\/\/(?:\w+\.)?discogs\.com\/artist\/(\d+))\b/i.exec(dcInput.value.trim());
					if (m != null) return parseInt(m[2]);
					console.warn('Discogs link isnot valid:', dcInput.value);
					return (m = /^\/artist\/(\d+)\b/i.exec(dcInput.value)) != null ? parseInt(m[1]) : undefined;
				}
				function colorValue(matched, total, colors = [0xccbf00, 0x008000]) {
					if (!total) return;
					console.assert(matched > 0, 'matched > 0');
					if (matched <= 0) return '#' + colors[0].toString(16).padStart(6, '0');
					if (matched >= total) return '#' + colors[1].toString(16).padStart(6, '0');
					const colorIndexRate = Math.min(matched / total / 0.80, 1);
					const colorsAsRGB = colors.map(color => [2, 1, 0].map(index => (color >> (index << 3)) & 0xFF));
					const compositeValue = index => colorsAsRGB[0][index] + Math.round(colorIndexRate * (colorsAsRGB[1][index] - colorsAsRGB[0][index]));
					return `rgb(${compositeValue(0)}, ${compositeValue(1)}, ${compositeValue(2)})`;
				}
				function genDcArtistTooltipHTML(artist, resolveIds = false) {
					if (!artist) throw 'The parameter is required';
					const linkColor = isLightTheme ? '#0A84AF' : isDarkTheme ? 'aqua' : 'cadetblue';
					const link = (key, id, caption = key + id) =>
						`<a href="${encodeURI(`https://www.discogs.com/${branches[key].type}/${id}`)}" target="_blank" style="color: ${linkColor};">${caption}</a>`;
					let html = `<div style="font-size: 10pt;"><b style="color: tomato;">${artist.name}</b>`;
					if (artist.realname && artist.realname != dcNameNormalizer(artist.name)) html += ` (${artist.realname})`;
					html += '</div>';
					if (Array.isArray(artist.images) && artist.images.length > 0)
						html += `<img style="margin-left: 1em; float: right;" src="${artist.images[0].uri150}" />`;
					if (Array.isArray(artist.members)) {
						const members = artist.members.filter(artist => artist.active);
						if (members.length > 0) html += `<div style="margin-top: 5pt;"><b>Active members:</b> ${members
							.map(artist => link('a', artist.id, dcNameNormalizer(artist.name))).join(', ')}</div>`;
					}
					if (artist.profile) {
						let profile = artist.profile.trim()
							.replace(/\[url=(.+?)\](.+?)\[\/url\]/ig, (m, url, caption) =>
								`<a href="${url}" target="_blank" style="color: ${linkColor};">${caption}</a>`)
							.replace(/\[([aglmr])(\d+)\]/ig, (m, key, id) => resolveIds ? m : link(key, id))
							.replace(/\[([aglmr])=(.+?)\]/ig, (m, key, id) =>
								/^\d+$/.test(id) ? resolveIds ? m : link(key, id) : link(key, id, dcNameNormalizer(id)));
						const workers = [ ];
						for (let key in branches) branches[key].matches = profile.match(branches[key].parser);
						for (let key in branches) if (branches[key].matches != null)
							Array.prototype.push.apply(workers, branches[key].matches
								.map(match => parseInt(/\[([armlg])=?(\d+)\]/i.exec(match)[2]))
								.filter((elem, ndx, arr) => elem && arr.indexOf(elem) == ndx)
								.map(id => queryDiscogsAPI(branches[key].endpoint + '/' + id).then(result => ({
									id: result.id,
									type: branches[key].type,
									name: result.name ? dcNameNormalizer(result.name) : result.title,
								}), reason => null)));
						const tagConversions = {
							b: 'font-weight: bold;',
							i: 'font-style: italic;',
							u: 'text-decoration: underline;',
						};
						const BB2Html = str => '<p style="margin-top: 1em;">' + Object.keys(tagConversions).reduce((str, key) =>
							str.replace(new RegExp(`\\[${key}\\](.*?)\\[\\/${key}\\]`, 'ig'),
								`<span style="${tagConversions[key]}">$1</span>`), str).replace(/(?:\r?\n)/g, '<br>') + '</p>';
						return workers.length > 0 ? Promise.all(workers).then(lookups => lookups.filter(Boolean)).then(lookups =>
									html + BB2Html(Object.keys(branches).reduce((str, key) => str.replace(branches[key].parser, function(m, id) {
								id = parseInt(id);
								const lookup = lookups.find(lookup => lookup && (!lookup.type || !branches[key].type
									|| lookup.type.toLowerCase() == branches[key].type.toLowerCase()) && lookup.id == id);
								if (!lookup) console.warn('Discogs item lookup failed:', key, id);
								return lookup ? link(key, id, lookup.name) : link(key, id);
							}), profile))) : Promise.resolve(html + BB2Html(profile));
					} else return Promise.resolve(html);
				}
				function genDcArtistDescriptionBB(artist) {
					if (!artist) throw 'The parameter is required';
					const link = (key, id, caption = key + id) =>
						`[url=${encodeURI(`https://www.discogs.com/${branches[key].type}/${id}`)}][plain]${caption}[/plain][/url]`;
					let members = Array.isArray(artist.members) && artist.members.length > 0;
					let bbCode = !members && artist.realname && artist.realname != dcNameNormalizer(artist.name) ?
						`[b]Real name:[/b] [plain]${artist.realname}[/plain]` : ''
					if (members) members = '\n[b]Members:[/b] ' + artist.members.map(function(artist) {
						let a = `[artist]${dcNameNormalizer(artist.name)}[/artist]${link('a', artist.id, '\u200B')}`;
						if (!artist.active) return document.location.hostname == 'redacted.ch' && `[s]${a}[/s]`;
						return a;
					}).filter(Boolean).join(', ');
					if (artist.profile) {
						bbCode += '\n\n';
						const workers = [ ];
						let profile = artist.profile.trim().replace(/\[([aglmr])=(.+?)\]/ig, function(m, key, id) {
							if (/^\d+$/.test(id)) return m;
							return (key = key.toLowerCase()) == 'a' ? `[artist]${dcNameNormalizer(id)}[/artist]${link(key, id, '\u200B')}`
								: link(key, id, dcNameNormalizer(id));
						});
						for (let key in branches) branches[key].matches = profile.match(branches[key].parser);
						for (let key in branches) if (branches[key].matches != null)
							Array.prototype.push.apply(workers, branches[key].matches
								.map(match => parseInt(/\[([armlg])=?(\d+)\]/i.exec(match)[2]))
								.filter((elem, ndx, arr) => elem && arr.indexOf(elem) == ndx)
								.map(id => queryDiscogsAPI(branches[key].endpoint + '/' + id).then(result => ({
									id: result.id,
									type: branches[key].type,
									name: result.name ? dcNameNormalizer(result.name) : result.title,
								}), reason => null)));
						if (workers.length > 0) return Promise.all(workers).then(lookups => lookups.filter(Boolean)).then(function(lookups) {
							bbCode += Object.keys(branches).reduce((str, key) => str.replace(branches[key].parser, function(m, id) {
								id = parseInt(id);
								console.assert(id > 0, 'id > 0');
								const lookup = lookups.find(lookup => lookup && (!lookup.type || !branches[key].type
									|| lookup.type.toLowerCase() == branches[key].type.toLowerCase()) && lookup.id == id);
								if (!lookup) console.warn('Discogs item lookup failed:', key, id);
								return lookup ? key == 'a' ? `[artist]${lookup.name}[/artist]${link(key, id, '\u200B')}`
									: link(key, id, lookup.name) : link(key, id);
							}), profile);
							if (members) bbCode += '\n' + members;
							return bbCode.trim();
						}); else bbCode += profile;
						if (members) bbCode += '\n' + members;
					} else if (members) bbCode += members;
					return Promise.resolve(bbCode.trim() || Promise.reject('no profile data'));
				}
				function getAliases(evt) {
					function cleanUp() {
						if (info.parentNode != null) info.remove();
						if (button.dataset.caption) button.value = button.dataset.caption;
						button.style.color = null;
					}

					if (inProgress) return false;
					const artistId = getDcArtistId();
					if (artistId > 0) inProgress = true; else throw 'Invalid Discogs link';
					const button = evt.currentTarget;
					button.disabled = true;
					//button.style.color = 'red';
					const info = document.createElement('SPAN');
					info.className = 'discogs-import-info';
					info.style.marginLeft = '2em';
					dcForm.append(info);
					getDiscogsArtist(artistId).then(function(dcArtist) {
						function weakAlias(anv, mainDent = dcArtist.name) {
							if (!anv) throw 'Assertion failed: invalid argument (anv)';
							const norm = str => str.toASCII().toLowerCase(), anl = norm(mainDent), alnv = norm(anv);
							return alnv == anl || 'the ' + alnv == anl || alnv == 'the ' + anl ? 1
								: /^(?:[a-z](?:\.\s*|\s+))+(\w{2,})\w/.test(alnv) ? anl.includes(norm(RegExp.$1)) ? 3 : 2 : 0;
						}

						console.log('Discogs data for ' + artistId.toString() + ':', dcArtist);
						setAjaxApiLogger(function(action, apiTimeFrame, timeStamp) {
							info.textContent = `Please wait... (${apiTimeFrame.requestCounter - 5} name queries queued)`;
						});
						const rxGenericName = /^(?:(?:And|With|La|\&)\s+)?(?:(?:The|His|Her|Sua)\s+)?(?:Orch(?:estra|ester|\.?)|Orquestra|Ensemble|Orkester|(?:Big\s+)?Band|All[\s\-]Stars|Chorus|Choir|Friends|Trio|Quartet(?:te)?|Quintet(?:te?)?|Sextet(?:te?)?|Septet(?:te?)?|Octet(?:te?)?|Nonet(?:te?)?|Tentet(?:te?)?)(?:\s+(?:Members))?$/i;
						const notGenericName = name => !rxGenericName.test(name);
						const resultsAdaptor = results => Array.isArray(results) && (results = results.filter(Boolean)).length > 0 ?
							Object.assign.apply({ }, results) : null;
						const querySiteStatus = (arrRef, artistId = dcArtist.id) => Array.isArray(arrRef) && arrRef.length > 0 ?
								Promise.all(arrRef.map(dcNameNormalizer).filter((name, ndx, arr) => arr.indexOf(name) == ndx).map(function(anv) {
							const result = value => ({ [anv]: value });
							return findArtistAlias(anv).then(alias => result(alias.id),
								reason => !evt.ctrlKey ? getSiteArtist(anv).then(a => result(a.id != artist.id ? Object.assign(a, {
									dcMatches: getDiscogsMatches(artistId, a.torrentgroup),
									dcLookup: dcSearchArtist(a.name, a.torrentgroup).catch(reason => reason != 'No matches' ? Promise.reject(reason)
										: dcSearchArtist(a.name, a.torrentgroup, arrRef)),
								}) : 0 /* implicit RDA */),
								reason => result(null) /* not found on site */) : result(null));
						})).then(resultsAdaptor) : Promise.resolve(null);
						const findAliasRedirectId = alias => findArtistAlias(dcNameNormalizer(alias.name))
								.then(alias => alias.redirectId > 0 ? Promise.reject('Redirecting alias') : alias.id)
								.catch(reason => mainIdentityId);
						const relationsAdaptor = (alias, exploreANVs = true) => getDiscogsArtist(alias.id).then(function(artist) {
							const aliases = [artist.name];
							if (exploreANVs && !evt.shiftKey && Array.isArray(artist.namevariations))
								Array.prototype.push.apply(aliases, artist.namevariations);
							return findAliasRedirectId(alias).then(redirectId => querySiteStatus(aliases.filter(notGenericName)
									.filter((alias, ndx, arr) => arr.indexOf(alias) == ndx), artist.id).then(aliases => ({ [artist.id]: {
								name: artist.name,
								realName: artist.realname,
								image: artist.images && artist.images.length > 0 ? artist.images[0].uri150 : undefined,
								profile: artist.profile,
								urls: artist.urls,
								tooltip: genDcArtistTooltipHTML(artist),
								anvs: aliases,
								redirectTo: redirectId,
								matchByRealName: Boolean(dcArtist.realname && (artist.realname
									&& artist.realname.toLowerCase() == dcArtist.realname.toLowerCase()
									|| dcNameNormalizer(artist.name).toLowerCase() == dcArtist.realname.toLowerCase())
									|| artist.realname && artist.realname.toLowerCase() == dcNameNormalizer(dcArtist.name).toLowerCase()),
								matchByMembers: Array.isArray(artist.members) && Array.isArray(dcArtist.members) && (function() {
									const memberIds = [artist, dcArtist].map(obj => obj.members
										.filter(member => member.active).map(member => member.id).sort());
									return memberIds[0].length == memberIds[1].length
										&& memberIds[0].every((id, ndx) => memberIds[1].indexOf(id) == ndx);
								})(),
							} })));
						}, function(reason) {
							alert(`${alias.name}: ${reason}`);
							return null;
						});
						const basedOnArtist = alias => {
							const cmpNorm = str => dcNameNormalizer(str).toASCII().replace(/\W+/g, '').toLowerCase();
							const testForName = n => (n = cmpNorm(n)).length > 0 && (an.startsWith(n + ' ') || an.endsWith(' ' + n)
								|| an.includes(' ' + n + ' ') || n.length > 4 && an.includes(n));
							const an = cmpNorm(alias.name);
							return an.length > 0 && (testForName(dcArtist.name) || Array.isArray(dcArtist.namevariations)
								&& dcArtist.namevariations.some(testForName));
						};
						const isImported = cls => ['everything', cls + '-only'].some(cls => button.id == 'fetch-' + cls);
						const anvs = [dcArtist.name];
						if (Array.isArray(dcArtist.namevariations)) Array.prototype.push.apply(anvs, dcArtist.namevariations);
						if (dcArtist.realname && !anvs.includes(dcArtist.realname)/*
								&& (!Array.isArray(dcArtist.members) || dcArtist.members.length <= 0)*/)
							anvs.push(dcArtist.realname);
						return Promise.all([
							// Artist's ANVs
							isImported('anvs') ? querySiteStatus(anvs.filter(notGenericName)
								.filter((anv, ndx, arr) => arr.indexOf(anv) == ndx)) : Promise.resolve(null),
							// Music groups based on artist
							isImported('groups') && Array.isArray(dcArtist.groups) ? Promise.all(dcArtist.groups.filter(basedOnArtist)
								.map(group => relationsAdaptor(group))).then(resultsAdaptor) : Promise.resolve(null),
							// Artist's aliases
							isImported('aliases') && Array.isArray(dcArtist.aliases) ? Promise.all(dcArtist.aliases.map(alias =>
								relationsAdaptor(alias))).then(resultsAdaptor) : Promise.resolve(null),
							// Other music groups
							evt.altKey && isImported('groups') && Array.isArray(dcArtist.groups) ?
								Promise.all(dcArtist.groups.filter(group => !basedOnArtist(group)).map(group =>
									relationsAdaptor(group, false))).then(resultsAdaptor) : Promise.resolve(null),
							// Group members
							button.id == 'fetch-members-only' && Array.isArray(dcArtist.members) ?
								Promise.all(dcArtist.members.map(member => relationsAdaptor(member))).then(resultsAdaptor)
									: Promise.resolve(null),
						]).then(function(dcAliases) {
							if (dcAliases.every((el, ndx) => !el)) {
								info.textContent = 'Nothing to import';
								return setTimeout(cleanUp, 5000);
							} else cleanUp();
							console.debug('Discogs fetched aliases:', dcAliases);

							function showModal() {
								if (dropDown == null) throw 'Unexpected document structure';

								function addLinkIcon(target) {
									console.assert(target instanceof HTMLAnchorElement, 'target instanceof HTMLAnchorElement');
									// const bitmap = 'data:image/png;base64,' +
									// 	'iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAACXBIWXMAAA7DAAAOwwHHb6hk' +
									// 	'AAAA2klEQVR4nG3QPwtBURjH8StHNoXJpCxSBjGYrLwBuXW9ApPRIOUFyEswSDc2gySrFyB/' +
									// 	'UorCYJFBLDIc33Nddbvc+jzn9PS75zz3Ciml9vOYnjC1hxTueIg/IT+1gyhyOGMlXCEfNY8Z' +
									// 	'qpohD/TUCwHhCIWoA2Sg5knSa7BOsBB2KEgdQc1Wxx5xTHGDLghF2HTtUAEtNOHFEiVGuKoT' +
									// 	'+0gga89UY1/EzrrFkE91qQqmccKRUIx1iC3ahF7fT1BB3Wpq2ty+foOKM/QJGnLMSeorTaxR' +
									// 	'pndx/943gbU8uSQHli8AAAAASUVORK5CYII=';
									const img = document.createElement('IMG');
									img.height = 10;
									img.style.marginLeft = '3pt';
									img.src = 'https://ptpimg.me/4o67uu.png';
									img.title = 'Linked as similar artist';
									target.parentNode.append(img);
								}
								function visualizeProgress(target) {
									console.assert(target instanceof HTMLAnchorElement, 'target instanceof HTMLAnchorElement');
									function visualizeProgress(target) {
										console.assert(target instanceof HTMLAnchorElement, 'target instanceof HTMLAnchorElement');
										const span = document.createElement('SPAN');
										span.textContent = 'Processing ...';
										span.style.color = 'darkorchid';
										for (let child of Array.from(target.parentNode.childNodes))
											if (child == target) child.hidden = true;
												else target.parentNode.removeChild(child);
										target.parentNode.append(span);
									}
									window.tooltipster.then(() => { $(target).tooltipster('hide') });
									visualizeProgress(target);
									const artistId = parseInt(target.dataset.artistId);
									if (!artistId) return; //throw 'Artist identification missing';
									for (let a of document.body.querySelectorAll('table#dicogs-aliases > tbody > tr > td > a.local-artist-group'))
										if (a != target && parseInt(a.dataset.artistId) == artistId) visualizeProgress(a);
								}
								function visualizeMerge(target, method, color) {
									console.assert(target instanceof HTMLAnchorElement, 'target instanceof HTMLAnchorElement');
									if (method) {
										const span = document.createElement('SPAN');
										span.textContent = 'Merged via ' + method;
										span.style.color = color;
										const parentNode = target.parentNode;
										while (parentNode.firstChild != null) parentNode.removeChild(parentNode.lastChild);
										parentNode.append(span);
									}
								}
								function visualizeMerges(target, method, color) {
									console.assert(target instanceof HTMLAnchorElement, 'target instanceof HTMLAnchorElement');
									visualizeMerge(target, method, color);
									const artistId = parseInt(target.dataset.artistId);
									if (!artistId) return; //throw 'Artist identification missing';
									for (let a of document.body.querySelectorAll('table#dicogs-aliases > tbody > tr > td > a.local-artist-group'))
										if (parseInt(a.dataset.artistId) == artistId) visualizeMerge(a, method, color);
								}
								function mergeNRA(target) {
									console.assert(target instanceof HTMLAnchorElement, 'target instanceof HTMLAnchorElement');
									const artistId = parseInt(target.dataset.artistId), artistName = target.dataset.artistName;
									if (!artistId || !artistName) throw 'Artist identification missing';
									if (!confirm(`Artist "${artistName}" is going to be merged via non-redirecting alias`))
										return;
									visualizeProgress(target);
									changeArtistId(artist.id, artistId).then(function() {
										target.onclick = null;
										if ('similarArtists' in target.dataset) try {
											addSimilarArtists(JSON.parse(target.dataset.similarArtists));
										} catch(e) { console.warn(e) }
										visualizeMerges(target, 'non-redirecting alias', 'lightseagreen');
									}, alert);
								}
								function mergeRD(target) {
									console.assert(target instanceof HTMLAnchorElement, 'target instanceof HTMLAnchorElement');
									const artistId = parseInt(target.dataset.artistId), artistName = target.dataset.artistName;
									if (!artistId || !artistName) throw 'Artist identification missing';
									if (!confirm(`Artist "${artistName}" is going to be merged via redirect to ${artist.name}`))
										return;
									visualizeProgress(target);
									renameArtist(artist.name, artistId).then(function() {
										target.onclick = null;
										if ('similarArtists' in target.dataset) try {
											addSimilarArtists(JSON.parse(target.dataset.similarArtists));
										} catch(e) { console.warn(e) }
										visualizeMerges(target, 'redirect', 'limegreen');
									}, alert);
								}
								function makeSimilar(target) {
									console.assert(target instanceof HTMLAnchorElement, 'target instanceof HTMLAnchorElement');
									const artistId = parseInt(target.dataset.artistId), artistName = target.dataset.artistName;
									if (!artistId || !artistName) throw 'Artist identification missing';
									if ('similar' in target.dataset) alert('This artist is already linked or merged');
									else if (confirm(`Artist "${artistName}" is going to be added as similar artist`))
										addSimilarArtist(artistName).then(function() {
											target.dataset.similar = true;
											addLinkIcon(target);
											for (let a of document.body.querySelectorAll('table#dicogs-aliases > tbody > tr > td > a.local-artist-group'))
												if (parseInt(a.dataset.artistId) == artistId && !('similar' in a.dataset)) {
													a.dataset.similar = true;
													addLinkIcon(a);
												}
											//alert(`${artistName} added as similar artist of ${artist.name}`);
										}, alert);
								}

								const menuId = '16bbbc39-65a0-4c21-b6af-3d6c6ff79166';
								let menu = document.getElementById(menuId);
								if (menu == null) {
									menu = document.createElement('MENU');
									menu.type = 'context';
									menu.id = menuId;

									function addEntry(caption, callback) {
										const menuItem = document.createElement('MENUITEM');
										if (caption) menuItem.label = caption; else return;
										menuItem.type = 'command';
										if (typeof callback == 'function') menuItem.onclick = callback;
										menu.append(menuItem);
									}
									addEntry('Merge with current artist via non-redirecting alias', evt => { mergeNRA(menu) });
									addEntry('Merge with current artist via redirect to main identity', evt => { mergeRD(menu) });
									addEntry('Link as similar artist', evt => { makeSimilar(menu) });
									document.body.append(menu);
								}

								function clickHandler(evt) {
									const artistId = parseInt(evt.currentTarget.dataset.artistId),
												artistName = evt.currentTarget.dataset.artistName;
									if (!artistId || !artistName) throw 'Artist identification missing';
									console.assert(artistId != artist.id, 'artistId != artist.id');
									if (evt.altKey && !evt.ctrlKey && !evt.shiftKey) return makeSimilar(evt.currentTarget), false;
									else if (evt.ctrlKey && !evt.shiftKey && !evt.altKey) return mergeNRA(evt.currentTarget), false;
									else if (evt.shiftKey && !evt.ctrlKey && !evt.altKey) return mergeRD(evt.currentTarget), false;
								}
								function addImportEntry(aliasName, tooltip, status, redirectTo = -1, id = artistId, highlight = false,
										redirectId = mainIdentityId, prefix) {
									var elems = createElements('TR', 'TD', 'A', 'SPAN', 'TD');
									elems[1].style = 'width: 100%; padding: 0 7pt;';
									elems[2].className = 'alias-name';
									elems[2].dataset.aliasName = elems[2].textContent = aliasName.properTitleCase();
									elems[2].style = highlight ? 'font-weight: bold;' : 'font-weight: normal;';
									elems[2].href = 'https://www.discogs.com/artist/' + id.toString() + '?' + new URLSearchParams({
										anv: aliasName,
										filter_anv: 1,
									}).toString();
									elems[2].target = '_blank';
									if (tooltip instanceof Promise) window.tooltipster.then(() => tooltip.then(tooltip => {
										$(elems[2]).tooltipster({ content: tooltip, maxWidth: 500, interactive: true })
											.tooltipster('reposition');
									}));
									elems[1].append(elems[2]);
									elems[3].textContent = '[' + (prefix ? prefix + '/' + id : id) + ']';
									elems[3].style = 'font-weight: 100; font-stretch: condensed; margin-left: 4pt;';
									if (id == artistId) elems[3].hidden = true;
									elems[1].append(elems[3]);
									elems[0].append(elems[1]);
									if (!status) {
										elems[0].style.height = null;
										elems[4].style = 'padding: 0;';
										elems[5] = dropDown.cloneNode(true);
										elems[5].className = 'redirect-to';
										elems[5].style = 'max-width: 25em; margin: 1pt 3pt 1pt 0;';
										if (redirectId > 0) elems[5].dataset.defaultRedirectId = redirectId;
										for (let option of elems[5].children) if (option.nodeName == 'OPTION')
											option.text = parseInt(option.value) == 0 ?
												'Make it non-redirecting alias' : 'Redirect to ' + option.text;
										elems[6] = document.createElement('OPTION');
										elems[6].text = status == null ? 'Do not import' : 'Keep implicit redirect';
										elems[6].value = -1;
										elems[5].prepend(elems[6]);
										elems[5].value = status == 0 ? -1 : redirectTo;
										elems[5].tabIndex = ++tabIndex;
										elems[4].append(elems[5]);
									} else {
										elems[4].style = 'height: 18pt; padding: 0 7pt 0 3pt; text-align: left;';
										elems[4].style.minWidth = 'max-content';
										if (!elems[4].style.minWidth) elems[4].style.minWidth = '-moz-max-content';
										if (status > 0) {
											elems[4].textContent = 'Defined (' + status + ')';
										} else if (typeof status == 'object') {
											elems[4].textContent = 'Taken by artist ';
											elems[5] = document.createElement('A');
											elems[5].className = 'local-artist-group';
											elems[5].href = '/artist.php?id=' + status.id;
											elems[5].target = '_blank';
											elems[5].textContent = status.name;
											elems[5].onclick = clickHandler;
											elems[5].setAttribute('contextmenu', menuId);
											elems[5].oncontextmenu = evt => { menu = evt.currentTarget };
											elems[5].dataset.artistName = status.name;
											elems[5].dataset.artistId = status.id;
											if (status.similarArtists) {
												const similarArtists =  status.similarArtists.map(similarArtist => similarArtist.name)
													.filter(similarArtist => similarArtist.toLowerCase() != artist.name.toLowerCase()
														&& (!artist.similarArtists || !artist.similarArtists.includes(similarArtist)));
												if (similarArtists.length > 0) elems[5].dataset.similarArtists = JSON.stringify(similarArtists);
											}
											let tooltip = `Ctrl + click to merge with current artist via non-redirecting alias
Shift + click to merge with current artist via redirect to main identity`;
											if (artist.similarArtists
													&& artist.similarArtists.some(similarArtist => similarArtist.artistId == status.id))
												elems[5].dataset.similar = true;
											else tooltip += '\nAlt + click to link as similar artist';
											window.tooltipster.then(function() {
												if (status.body) tooltip += '\n\n' + status.body;
												if (status.image) tooltip +=
													`<img style="margin-left: 5pt; float: right; max-width: 90px; max-height: 90px;" src="${status.image}" />`;
												tooltip += `\n\n<b>Releases:</b> ${status.statistics.numGroups}`;
												if (status.tags && status.tags.length > 0) {
													tooltip += '\n\n';
													const tags = new TagManager(...status.tags.map(tag => tag.name).filter(tagsExclusions));
													if (artist.tags) {
														const commonTags = Array.from(tags).filter(tag => artist.tags.includes(tag));
														const setSize = Math.min(artist.tags.length, tags.length);
														const matchRate = commonTags.length / setSize;
														const color = matchRate >= 0.75 ? 'green' : matchRate >= 0.50 ? '#9d9d00'
															: matchRate >= 0.25 ? '#ffb100' : 'red';
														tooltip += `<b>Common tags:</b> ${commonTags.join(', ')} (<span style="color: ${color};">${commonTags.length}/${setSize}</span>)`;
														const unmatchedTags = Array.from(tags).filter(tag => !artist.tags.includes(tag));
														if (unmatchedTags.length > 0) tooltip += `\n<b>Unmatched tags:</b> ${unmatchedTags.join(', ')}`;
													} else tooltip += `<b>Tags:</b> ${tags.toString()}`;
												}
												$(elems[5]).tooltipster({ content: tooltip.replace(/\r?\n/g, '<br>'), maxWidth: 500 });
											}).catch(function(reason) {
												console.warn(reason);
												elems[5].title = tooltip;
											});
											elems[4].append(elems[5]);
											if ('similar' in elems[5].dataset) addLinkIcon(elems[5]);
											if (status.dcMatches) status.dcMatches.then(function(matchedGroups) {
												const span = document.createElement('SPAN');
												span.textContent = `[${matchedGroups.length}/${Object.keys(status.torrentgroup).length}]`;
												span.style.marginLeft = '4pt';
												if (Object.keys(status.torrentgroup).length <= 0
														|| matchedGroups.length >= Object.keys(status.torrentgroup).length) {
													span.style.color = 'green';
													if (Object.keys(status.torrentgroup).length > 0)
														window.tooltipster.then(() => { $(span).tooltipster({
															content: 'Id is exclusive to this physical artist'
														}) });
												} else if (status.dcLookup) status.dcLookup.then(function(results) {
													function getAmbiguityInfo() {
														let tooltip = '';
														for (let result of results) {
															if (!result.matchedGroups || result.matchedGroups.length <= 0) continue;
															let artistRef = `<br><a href="${result.uri}" target="_blank">${result.title}</a> (${result.matchedGroups.length})`;
															if (results.matchedCount > 1 && result.id == results.bestMatch.id) artistRef = '<b>' + artistRef + '</b>';
															tooltip += artistRef;
														}
														if (tooltip) return '<br><br><b>Colliding artists:</b>' + tooltip;
													}

													//console.debug('Lookup results for', status.name, results);
													if (results.matchedCount > 1) {
														span.style.color = matchedGroups.length > 0 ? 'orange' : 'red';
														window.tooltipster.then(() => { $(span).tooltipster({
															content: (matchedGroups.length > 0 ?
																`'Related name belongs to at least ${results.matchedCount} distinct artists sharing this id (do not merge)`
																	: `'This id is shared by at least ${results.matchedCount} distinct artists (do not merge)`)
																+ getAmbiguityInfo(),
															interactive: true,
														}) });
														console.info(`[AAM] Multiple matching artists for alias '${status.name}":`, results.filter(result => result.matchedGroups.length > 0));
													} else if (results.matchedCount == 1) {
														let tooltip;
														if (results.bestMatch.id == id) {
															span.style.color = colorValue(matchedGroups.length, Object.keys(status.torrentgroup).length);
															tooltip = 'Id is likely exclusive to this artist';
														} else {
															span.style.color = matchedGroups.length > 0 ? 'orange' : 'red';
															tooltip = (matchedGroups.length > 0 ? 'Related name belongs to at least 2 distinct artists sharing this id (do not merge)'
																: `This id is entirely used by different artist <a href="https://www.discogs.com/artist/${results.bestMatch.id}" target="_blank">${results.bestMatch.title}</a> (${results.bestMatch.id}), do not merge`)
																+ getAmbiguityInfo();
															console.info(`[AAM] Discogs artist mismatch for alias '${status.name}":`, results.filter(result => result.matchedGroups.length > 0));
														}
														if (tooltip) window.tooltipster.then(function() { $(span).tooltipster({
															content: tooltip,
															interactive: results.bestMatch.id != id,
														}) });
													}
												});
												elems[5].insertAdjacentElement('afterend', span);
											});
										}
									}
									elems[0].append(elems[4]);
									modal[5].append(elems[0]);
								}

								window.tooltipster.then(() => { $(button).tooltipster('hide') });
								const redirect = getSelectedRedirect(true);
								const modal = createElements('DIV', 'DIV', 'DIV', 'TABLE', 'THEAD', 'TBODY', 'DIV', 'INPUT', 'INPUT');
								modal[0].className = 'modal discogs-import';
								modal[0].style = 'position: fixed; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); opacity: 0; visibility: hidden; transform: scale(1.1); transition: visibility 0s linear 0.25s, opacity 0.25s 0s, transform 0.25s; z-index: 999999;';
								modal[0].onclick = evt => { if (evt.target == evt.currentTarget) closeModal() };
								modal[1].className = 'modal-content';
								modal[1].style = 'position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); max-width: 65em; border-radius: 0.5rem; padding: 1rem 1rem 1rem 1rem;';
								if (isLightTheme) modal[1].style.color = 'black';
								modal[1].style.backgroundColor = isDarkTheme ? 'darkslategrey' : 'FloralWhite';
								modal[1].style.width = 'max-content';
								if (!modal[1].style.width) modal[1].style.width = '-moz-max-content';
								// Header
								modal[2].textContent = 'Review how to import ANVs found';
								modal[2].style = 'margin-bottom: 1em; font-weight: bold; font-size: 12pt;'
								modal[1].append(modal[2]);
								// Table
								modal[3].id = 'dicogs-aliases';
								modal[3].style = 'display: block; max-height: 45em; padding: 3pt 0px 2pt; overflow-y: scroll; scroll-behavior: auto;';
								modal[3].append(modal[4]);
								const nonLatin = artistName => dcNameNormalizer(artistName)
									.replace(/[\s\.\-\,\&]+/g, '').toASCII().length <= 0;
								let tabIndex = 0;
								function findMatchingAliasId(anv) {
									const cmpNorm = name => name.toLowerCase().replace(/[\s]+/g, '');
									if (anv) for (let li of aliases) {
										const alias = getAlias(li);
										if (!alias || alias.redirectId > 0) continue;
										if (cmpNorm(alias.name) == cmpNorm(anv)) return alias.id;
									}
								}
								// Artist's NVAs
								if (dcAliases[0]) {
									const tooltip = genDcArtistTooltipHTML(dcArtist);
									for (let anv in dcAliases[0]) addImportEntry(anv, tooltip, dcAliases[0][anv],
										nonLatin(anv) ? -1 : weakAlias(anv) > 0 ? findMatchingAliasId(anv) || mainIdentityId : 0, artistId,
										anv == dcNameNormalizer(dcArtist.name));
								}
								// Music groups based on artist
								if (dcAliases[1]) for (let artistId in dcAliases[1]) for (let anv in dcAliases[1][artistId].anvs)
									addImportEntry(anv, dcAliases[1][artistId].tooltip, dcAliases[1][artistId].anvs[anv],
										/*weakAlias(anv) > 1 ? -1 : */0, artistId, anv == dcNameNormalizer(dcAliases[1][artistId].name),
										findMatchingAliasId(anv) || dcAliases[1][artistId].redirectTo || redirect.id, 'G');
								// Artist's aliases
								if (dcAliases[2]) for (let artistId in dcAliases[2]) for (let anv in dcAliases[2][artistId].anvs)
									addImportEntry(anv, dcAliases[2][artistId].tooltip, dcAliases[2][artistId].anvs[anv],
										dcAliases[2][artistId].matchByRealName || dcAliases[2][artistId].matchByMembers ?
											0 : /*weakAlias(anv) == 1 ? 0 : */-1,
										artistId, anv == dcNameNormalizer(dcAliases[2][artistId].name),
										findMatchingAliasId(anv) || dcAliases[2][artistId].redirectTo || redirect.id, 'A');
								// Other music groups possibly involved in
								if (dcAliases[3]) for (let artistId in dcAliases[3]) for (let anv in dcAliases[3][artistId].anvs)
									addImportEntry(anv, dcAliases[3][artistId].tooltip,dcAliases[3][artistId].anvs[anv],
										weakAlias(anv) == 1 ? 0 : -1, artistId, anv == dcNameNormalizer(dcAliases[3][artistId].name),
										findMatchingAliasId(anv) || dcAliases[3][artistId].redirectTo || redirect.id, 'G');
								// Group members
								if (dcAliases[4]) for (let artistId in dcAliases[4]) for (let anv in dcAliases[4][artistId].anvs)
									addImportEntry(anv, dcAliases[4][artistId].tooltip, dcAliases[4][artistId].anvs[anv],
										-1, artistId, anv == dcNameNormalizer(dcAliases[4][artistId].name),
										findMatchingAliasId(anv) || dcAliases[4][artistId].redirectTo || redirect.id, 'M');
								modal[3].append(modal[5]);
								modal[1].append(modal[3]);
								const allDropdowns = modal[3].querySelectorAll('tbody > tr > td:nth-of-type(2) > select');
								// Buttonbar
								modal[6].style = 'margin-top: 1em;';
								modal[7].type = 'button';
								modal[7].value = 'Import now';
								if (allDropdowns.length <= 0) modal[7].disabled = true;
								modal[7].onclick = function(evt) {
									const importTable = document.body.querySelectorAll('table#dicogs-aliases > tbody > tr');
									closeModal();
									Promise.all(Array.from(importTable).map(function(tr) {
										let aliasName = tr.querySelector('a.alias-name'),
												redirectId = tr.querySelector('select.redirect-to');
										return aliasName != null && redirectId != null && (redirectId = parseInt(redirectId.value)) >= 0 ?
											addAlias(aliasName.dataset.aliasName, redirectId) : null;
									}).filter(Boolean)).then(function(results) {
										console.info('Total', results.length, 'artist aliases imported from Discogs');
										if (results.length > 0) document.location.reload();
									});
								};
								modal[7].tabIndex = ++tabIndex;
								modal[6].append(modal[7]);
								modal[8].type = 'button';
								modal[8].value = 'Close';
								modal[8].onclick = closeModal;
								modal[8].tabIndex = ++tabIndex;
								modal[6].append(modal[8]);

								function addQSBtn(caption, value, margin, tooltip) {
									const a = document.createElement('A');
									a.textContent = caption;
									a.href = '#';
									a.style.color = isDarkTheme ? 'lightgrey' : '#0A84AF';
									if (margin) a.style.marginLeft = margin;
									if (tooltip) {
										a.title = 'Resolve all to ' + tooltip;
										window.tooltipster.then(() => { $(a).tooltipster() }).catch(reason => { console.warn(e) });
									}
									a.onclick = function(evt) {
										for (let select of allDropdowns) switch (typeof value) {
											case 'number': select.value = value; break;
											case 'function': select.value = value(select); break;
										}
										return false;
									};
									modal[6].append(a);
								}
								addQSBtn('Import none', -1, '3em');
								addQSBtn('All NRA', 0, '10pt');
								addQSBtn('All RD', select => select instanceof HTMLElement && select.dataset.defaultRedirectId ?
									parseInt(select.dataset.defaultRedirectId) : redirect.id, '10pt');

								modal[1].append(modal[6]);
								modal[0].append(modal[1]);
								document.body.style.overflow = 'hidden';
								document.body.append(modal[0]);
								modal[0].style.opacity = 1;
								modal[0].style.visibility = 'visible';
								modal[0].style.transform = 'scale(1.0)';
								modal[0].style.transition = 'visibility 0s linear 0s, opacity 0.25s 0s, transform 0.25s';
								if ((elem = modal[5].querySelector('select[tabindex="1"]')) != null) elem.focus();
							}
							function closeModal() {
								document.body.removeChild(document.body.querySelector('div.modal.discogs-import'));
								document.body.style.overflow = 'auto';
							}

							showModal();
							button.disabled = false;
							inProgress = false;
						});
					}).catch(alert).then(function() {
						cleanUp();
						button.disabled = false;
						inProgress = false;
					});
				}
				function addFetchButton(caption, id = caption) {
					const button = document.createElement('INPUT');
					button.type = 'button';
					if (id) button.id = 'fetch-' + id;
					if (caption) button.value = button.dataset.caption = 'Fetch ' + caption;
					button.style.display = 'none';
					button.onclick = getAliases;
					const tooltip = `Available keyboard modifiers (can be combined):
+CTRL: don't perform site names lookup (faster and less API requests consuming, doesn't reveal local separated identities)
+SHIFT: don't include aliases', groups' and members' name variants
+ALT: (only applies if including groups) include also groups not based on artist's name`;
					window.tooltipster.then(() => { $(button).tooltipster({ content: tooltip.replace(/\r?\n/g, '<br>') }) }).catch(function(reason) {
						button.title = tooltip;
						console.warn(reason);
					});
					dcForm.append(button);
				}

				const fetchers = {
					anvs: ['namevariations', 'ANVs'],
					aliases: ['aliases'],
					groups: ['groups'],
					members: ['members'],
				};
				aliasesRoot.append(document.createElement('BR'));
				let elem = document.createElement('H3');
				elem.textContent = 'Import from Discogs';
				aliasesRoot.append(elem);
				elem = document.createElement('DIV');
				elem.className = 'pad';
				const dcForm = document.createElement('FORM');
				dcForm.name = dcForm.className = 'discogs-import';
				const dcInput = document.createElement('INPUT');
				dcInput.type = 'text';
				dcInput.className = 'discogs_link tooltip';
				dcInput.style.width = '30em';
				dcInput.ondragover = dcInput.onpaste = evt => { evt.currentTarget.value = '' };
				dcInput.oninput = function(evt) {
					window.tooltipster.then(() => { $(dcInput).tooltipster('disable') }).catch(reason => { console.warn(reason) });
					getDiscogsArtist(getDcArtistId()).then(function(dcArtist) {
						if ((button = document.getElementById('fetch-everything')) != null) button.style.display = 'inline';
						for (let key in fetchers) if ((button = document.getElementById(`fetch-${key}-only`)) != null)
							button.style.display = Array.isArray(dcArtist[fetchers[key][0]])
								&& dcArtist[fetchers[key][0]].length > 0 ? 'inline' : 'none';
						if ((button = document.getElementById('dc-view')) != null) button.disabled = false;
						if ((button = document.getElementById('dc-update-wiki')) != null) button.updateStatus(dcArtist);
						window.tooltipster.then(() => genDcArtistTooltipHTML(dcArtist, false).then(function(tooltip) {
							$(dcInput).tooltipster('update', tooltip).tooltipster('enable').tooltipster('reposition');
						})).catch(reason => { console.warn(reason) });
					}, function(reason) {
						button.style.display = 'hidden';
						for (let key in fetchers) if ((button = document.getElementById(`fetch-${key}-only`)) != null)
							button.style.display = 'none';
						if ((button = document.getElementById('dc-view')) != null) button.disabled = true;
						if ((button = document.getElementById('dc-update-wiki')) != null) button.updateStatus(false);
					});
				};
				window.tooltipster.then(function() {
					$(dcInput).tooltipster({ maxWidth: 640, content: '</>', interactive: true, delay: 1000 }).tooltipster('disable');
				}).catch(reason => { console.warn(reason) });
				dcForm.append(dcInput);
				let button = document.createElement('INPUT');
				button.type = 'button';
				button.id = 'dc-view';
				button.value = 'View';
				button.disabled = true;
				button.onclick = function(evt) {
					const artistId = getDcArtistId();
					if (artistId) GM_openInTab('https://www.discogs.com/artist/' + artistId.toString(), false);
				};
				dcForm.append(button);
				button = document.createElement('INPUT');
				button.type = 'button';
				button.id = 'dc-update-wiki';
				button.value = 'Update wiki';
				button.disabled = true;
				button.onclick = function(evt) {
					if (!evt.currentTarget.artist) return false;
					const url = `[url=${evt.currentTarget.artist.uri}][img]https://ptpimg.me/v27891.png[/img][/url]`;
					const body = document.getElementById('body');
					if (body != null) genDcArtistDescriptionBB(evt.currentTarget.artist).then(function(bbCode) {
						if (bbCode) {
							bbCode = '[size=3]' + bbCode + '[/size]\n\n[align=left]' + url + '[/align]';
							if (body.value.length <= 0 && document.location.hostname == 'redacted.ch')
								bbCode = '[pad=8]' + bbCode + '[/pad]';
						} else bbCode = url;
						if (body.value.length <= 0) body.value = bbCode; else body.value += '\n\n' + bbCode;
					}, reason => { if (body.value.length <= 0) body.value = url; else body.value += '\n\n' + url });
					if (Array.isArray(evt.currentTarget.artist.images)
							&& evt.currentTarget.artist.images.length > 0) {
						const image = document.body.querySelector('input[type="text"][name="image"]');
						if (image != null && !image.value) {
							if (unsafeWindow.imageHostHelper) unsafeWindow.imageHostHelper
									.rehostImageLinks([evt.currentTarget.artist.images[0].uri], true, false, false)
								.then(unsafeWindow.imageHostHelper.singleImageGetter,
									reason => evt.currentTarget.artist.images[0].uri)
								.then(imageUrl => { image.value = imageUrl });
							else image.value = artist.images[0].uri;
						}
					}
					// evt.currentTarget.style.backgroundColor = 'green';
					// setTimeout(elem => { elem.style.backgroundColor = null }, 1000, evt.currentTarget);
				};
				button.updateStatus = function(artist) {
					if (artist) {
						this.artist = artist;
						const hasPhoto = Array.isArray(artist.images) && artist.images.length > 0,
									hasMembers = Array.isArray(artist.members)
										&& artist.members.filter(artist => artist.active).length > 0,
									hasRealName = artist.realname && artist.realname != dcNameNormalizer(artist.name),
									body = document.getElementById('body'),
									image = document.body.querySelector('input[type="text"][name="image"]');
						let tooltip = `Image: ${hasPhoto ? '<b>yes</b>' : 'no'}<br>Real name: ${hasRealName ? '<b>yes</b>' : 'no'}<br>Members: ${hasMembers ? '<b>yes</b>' : 'no'}<br>Profile info: ${!artist.profile ? 'no' : '<b>' + (artist.profile.length > 600 ? 'long' : artist.profile.length > 300 ? 'moderate' : 'short') + '</b>'}<br><br>Wiki set: ${body != null && body.value.length > 0 ? 'yes' : '<b>no</b>'}<br>Image set: ${image != null && /^https?:\/\//.test(image.value) ? 'yes' : '<b>no</b>'}</b>`;
						window.tooltipster.then(() => { if ($(this).data('plugin_tooltipster'))
							$(this).tooltipster('update', tooltip).tooltipster('enable');
								else $(this).tooltipster({ content: tooltip }) });
						this.disabled = false;
					} else {
						window.tooltipster.then(() => { if ($(this).data('plugin_tooltipster')) $(this).tooltipster('disable') });
						this.disabled = true;
						if ('artist' in this) delete this.artist;
					}
				};
				dcForm.append(button);
				button = document.createElement('INPUT');
				button.type = 'button';
				button.id = 'dc-search';
				button.value = 'Search artist';
				button.onclick = function(evt) {
					if (evt.altKey && evt.currentTarget.matchedArtists
							&& evt.currentTarget.matchedArtists.length > 0) {
						const button = evt.currentTarget;
						Promise.all(evt.currentTarget.matchedArtists.sort((a, b) =>
								(b.matchedGroups ? b.matchedGroups.length : 0) -
									(a.matchedGroups ? a.matchedGroups.length : 0)).map(result => getDiscogsArtist(result.id)
								.then(artist => genDcArtistDescriptionBB(artist).catch(reason => undefined)
									.then(bbCode => Object.assign({ bbCode: bbCode }, artist))))).then(function(artists) {
							let bbCode = artists.map(function(dcArtist, index) {
								if (artists.length > 1) {
									let bbCode = '';
									if (Array.isArray(dcArtist.images) && dcArtist.images.length > 0)
										bbCode = '[img]' + dcArtist.images[0].uri150 + '[/img]';
									if (dcArtist.bbCode) bbCode += '\n' + dcArtist.bbCode.replace(/(?:\r?\n)+/g, '\n');
									if (bbCode && document.location.hostname == 'redacted.ch')
										bbCode = '[pad=5|0|0|13]' + bbCode.trim() + '[/pad]';
									return `[size=3][b]${index + 1}. [url=${dcArtist.uri}]${dcNameNormalizer(dcArtist.name)}[/url][/b] (${button.matchedArtists[index].matchedGroups.length}/${artist.torrentgroup.length})[/size]\n${bbCode}`.trim();
								} else return dcArtist.bbCode || '';
							}).join(/*document.location.hostname == 'redacted.ch' ? '[hr]' : */'\n\n');
							if (bbCode) {
								if (artists.length > 1) {
									bbCode = '[size=4][b]Multiple physical artists share this name:[/b][/size]\n\n' + bbCode;
									if (document.location.hostname == 'redacted.ch') bbCode = '[pad=8]' + bbCode + '[/pad]';
								}
								const body = document.getElementById('body');
								if (body != null) if (body.value.length <= 0) body.value = bbCode;
									else body.value += '\n\n' + bbCode;
							}
							button.style.backgroundColor = 'green';
							setTimeout(elem => { elem.style.backgroundColor = null }, 1000, button);
						});
						const image = document.body.querySelector('input[type="text"][name="image"]');
						if (image != null) image.value = 'https://ptpimg.me/6qap57.png';
					} else GM_openInTab('https://www.discogs.com/search/?' + new URLSearchParams({
						q: artist.name,
						type: 'artist',
						layout: 'med',
					}).toString(), false);
				};
				button.progress = setInterval(function(elem) {
					const phases = '|/-\\';
					let index = /\[(.)\]$/.exec(elem.value);
					index = index != null ? phases.indexOf(index[1]) : -1;
					elem.value = 'Search artist [' + phases[(index + 1) % 4] + ']';
				}, 200, button);
				dcForm.append(button);
				dcForm.append(document.createElement('BR'));
				addFetchButton('everything');
				for (let key in fetchers) addFetchButton((fetchers[key][1] || fetchers[key][0]) + ' only', key + '-only');
				elem.append(dcForm);
				aliasesRoot.append(elem);

				dcSearchArtist(artist.name, artist.torrentgroup).catch(reason => reason != 'No matches' ? Promise.reject(reason)
						: dcSearchArtist(artist.name, artist.torrentgroup, Array.from(aliases).map(getAlias)
							.filter(alias => alias && !alias.redirectId && alias.id != mainIdentityId)
							.map(alias => alias.name))).then(function(results) {
					const button = document.getElementById('dc-search');
					if (button != null) {
						if (button.progress) clearInterval(button.progress);
						button.value = 'Search artist [' + results.length.toString() + ']';
						button.matchedArtists = results.matchedCount > 0 ?
							results.filter(result => result.matchedGroups.length > 0)
								: results.length == 1 ? results : null;
						let tooltip;
						if (results.matchedCount > 1) {
							button.style.color = 'red';
							tooltip = `Shared ID!<br><br>This artist profile unites at least ${results.matchedCount} distinct artists.<br>Be careful about adding aliases and do not merge with other artists<br><br>Alt + click to update wiki by disambiguation info`;
							console.log(`Multiple artists shared by id ${artist.id}:`, button.matchedArtists);
						} else if (results.matchedCount == 1) {
							button.style.color = colorValue(results.bestMatch.matchedGroups.length,
								Object.keys(artist.torrentgroup).length, isLightTheme ? [0xFFD700, 0x32CD32] : undefined);
							//button.style.color = isLightTheme ? 'lightgreen' : 'green';
							tooltip = `Id is likely homogenoeus (${results.bestMatch.matchedGroups.length}/${Object.keys(artist.torrentgroup).length} releases matched)`;
						}
						window.tooltipster.then(function() {
							if ($(button).data('plugin_tooltipster'))
								if (tooltip) $(button).tooltipster('update', tooltip).tooltipster('enable');
									else $(button).tooltipster('disable');
							else if (tooltip) $(button).tooltipster({ delay: 100, content: tooltip });
						});
					}
					dcInput.value = results.bestMatch.uri;
					dcInput.oninput();
				}).catch(function(reason) {
					const button = document.getElementById('dc-search');
					if (button == null) throw 'Assertion failed: search button not found';
					if (button.progress) clearInterval(button.progress);
					button.value = `Search artist [${reason}]`;
				});
			}

			addDiscogsImport();
		} else {
			const selBase = 'div#discog_table > table > tbody > tr.group > td:first-of-type';
			const selCheckboxes = selBase + ' input[type="checkbox"][name="separate"]';
			const check = () => input.value.trim().length > 0
				&& document.body.querySelectorAll(selCheckboxes + ':checked').length > 0;

			function changeArtist(evt) {
				if (!check()) return false;
				const button = evt.currentTarget;
				let newArtist = /^\s*\d+\s*$/.test(input.value) && parseInt(input.value);
				if (!(newArtist > 0) && (newArtist = input.value.trim())) try {
					let url = new URL(newArtist);
					if (url.origin == document.location.origin && url.pathname == '/artist.php'
							&& (url = parseInt(url.searchParams.get('id'))) > 0) newArtist = url;
				} catch(e) { }
				queryAjaxAPI('artist', { [newArtist > 0 ? 'id' : 'artistname']: newArtist }).catch(reason => reason).then(function(targetArtist) {
					if (targetArtist.id && targetArtist.name) siteArtistsCache[targetArtist.name] = targetArtist;
					if (newArtist > 0 && !(newArtist = targetArtist.name))
						return Promise.reject('Artist with this ID doesn\'t exist');
					const selectedGroups = Array.from(document.body.querySelectorAll(selCheckboxes + ':checked'))
						.map(checkbox => checkbox.parentNode.parentNode.parentNode.querySelector('div.group_info > strong > a:last-of-type'))
						.filter(a => a instanceof HTMLElement).map(a => parseInt(new URLSearchParams(a.search).get('id')))
						.filter(groupId => groupId > 0);
					const torrentGroups = { };
					for (let torrentGroup of artist.torrentgroup.filter(tg => selectedGroups.includes(tg.groupId))) {
						console.assert(!(torrentGroup.groupId in torrentGroups), '!(torrentGroup.groupId in this.groups)');
						if (!torrentGroup.extendedArtists) {
							if (!artistlessGroups.has(torrentGroup.groupId)) {
								console.warn(`Warning: artistless group "${decodeHTML(torrentGroup.groupName)}" found; if any script's operation fails, add some artists first`,
									'https://redacted.ch/torrents.php?id=' + torrentGroup.groupId.toString());
// 								alert(`WARNING: artistless group "${torrentGroup.groupName}" found
// (https://redacted.ch/torrents.php?id=${torrentGroup.groupId})

// If any of the operations fails, add some artists first`);
								// GM_openInTab('https://redacted.ch/torrents.php?id=' + torrentGroup.groupId.toString(), true);
								artistlessGroups.add(torrentGroup.groupId);
							}
							continue;
						}
						const importances = Object.keys(torrentGroup.extendedArtists)
							.filter(importance => Array.isArray(torrentGroup.extendedArtists[importance])
								&& torrentGroup.extendedArtists[importance].some(_artist => _artist.id == artist.id))
							.map(key => parseInt(key)).filter((el, ndx, arr) => arr.indexOf(el) == ndx);
						console.assert(importances.length > 0, 'importances.length > 0');
						if (importances.length > 0) torrentGroups[torrentGroup.groupId] = importances;
					}
					const groupIds = Object.keys(torrentGroups);
					console.assert(selectedGroups.length == groupIds.length, 'selectedGroups.length == groupIds.length',
						selectedGroups, groupIds);
					if (groupIds.length <= 0) throw 'Assertion failed: none of selected releases include this artist';
					let nagText = `
You're going to replace all instances of ${artist.name}
in ${groupIds.length} releases by identity "${newArtist}"`;
					if (targetArtist.id) nagText += ' (' + targetArtist.id + ')';
					if (!confirm(nagText + '\n\nConfirm your choice to proceed')) return;
					button.disabled = true;
					button.style.color = 'red';
					button.value = '[ processing... ]';
					button.title = 'Don\'t break the operation, navigate away, reload or close current page';
					const changeArtistInGroup = groupId => torrentGroups[groupId] ?
						deleteArtistFromGroup(groupId, artist.id, torrentGroups[groupId])
							.then(() => addAliasToGroup(groupId, newArtist, torrentGroups[groupId]))
						: Promise.reject('Invalid group id');
					const finalize = () => resolveArtistId(targetArtist.id || newArtist).then(function(newArtistId) {
						if (groupIds.length < artist.torrentgroup.length) {
							if (newArtistId != artist.id)
								GM_openInTab(document.location.origin + '/artist.php?id=' + newArtistId.toString(), false);
							document.location.reload();
						} else if (newArtistId != artist.id) gotoArtistPage(newArtistId); else document.location.reload();
					});
					return (groupIds.length > 0 ? changeArtistInGroup(groupIds[0]).then(wait)
								.then(() => Promise.all(groupIds.slice(1).map(changeArtistInGroup)))
							: changeArtistInGroup(groupIds[0])).then(finalize, function(reason) {
						alert(reason);
						// Old serial method (fallback)
						return (function changeArtistInGroup(index = 0) {
							if (!(index >= 0 && index < groupIds.length)) return Promise.resolve('Current artist removed from all groups');
							const importances = torrentGroups[groupIds[index]];
							console.assert(Array.isArray(importances) && importances.length > 0,
								'Array.isArray(importances) && importances.length > 0');
							return Array.isArray(importances) && importances.length > 0 ?
								deleteArtistFromGroup(groupIds[index], artist.id, importances)
									.then(() => addAliasToGroup(groupIds[index], newArtist, importances))
									.then(() => changeArtistInGroup(index + 1))
								: changeArtistInGroup(index + 1);
						})().then(finalize);
					});
				}).catch(function(reason) {
					button.removeAttribute('title');
					button.value = button.dataset.caption;
					button.style.color = null;
					button.disabled = false;
					alert(reason);
				});
			}

			function selAll(state) {
				for (let input of document.body.querySelectorAll(selCheckboxes)) {
					if (input.checked == state || input.parentNode.parentNode.parentNode.offsetWidth <= 0) continue;
					input.checked = typeof state == 'boolean' ? state : !input.checked;
					input.dispatchEvent(new Event('change'));
				}
			}
			function selByDiscogs(state) {
				let dcInput = prompt('Enter Discogs artist id or URL; site releases found in artist\'s Discogs releases will be matched\n\n');
				if (!dcInput) return;
				let artistId = parseInt(dcInput);
				if (!artistId) {
					artistId = /^(?:https?:\/\/(?:\w+\.)?discogs\.com\/artist\/(\d+))\b/i.exec(dcInput.trim());
					if (artistId == null) artistId = /^\/artist\/(\d+)\b/i.exec(dcInput.trim());
					if (artistId == null || !(artistId = parseInt(artistId[1]))) return;
				}
				const releases = [ ];
				getDiscogsArtistReleases(artistId).then(function(releases) {
					for (let input of document.body.querySelectorAll(selCheckboxes)) {
						if (input.checked == state || input.parentNode.parentNode.parentNode.offsetWidth <= 0) continue;
						let groupInfo = input.parentNode.parentNode.parentNode.querySelector('div.group_info > strong');
						if (groupInfo == null) { // assertion failed
							console.warn('Assertion failed: group info not found (', input.parentNode.parentNode.parentNode, ')');
							continue;
						}
						groupInfo = {
							year: /\b(\d{4})\b/.exec(groupInfo.firstChild.textContent),
							title: groupInfo.getElementsByTagName('A'),
						};
						if (groupInfo.year != null && groupInfo.title.length > 0) {
							groupInfo.year = parseInt(groupInfo.year[1]),
							groupInfo.title = groupInfo.title[0].textContent.trim();
						} else continue; // assertion failed
						const titleNorm = [titleCmpNorm(groupInfo.title), groupInfo.title.toLowerCase()];
						if (!releases.some(release => release.year == groupInfo.year && (titleCmpNorm(release.title) == titleNorm[0]
								|| jaroWrinkerSimilarity(release.title.toLowerCase(), titleNorm[1]) >= sameTitleConfidence))) continue;
						input.checked = typeof state == 'boolean' ? state : !input.checked;
						input.dispatchEvent(new Event('change'));
					}
				}, alert);
			}
			function selByTags(state) {
				let tags = prompt('Enter gazelle tags(s) or list of genres separated by comma; all tags will be matched (AND); to select groups with any of list of tags (OR), repeat the selector more times\n\n');
				if (!tags || (tags = new TagManager(tags)).length <= 0) return;
				for (let input of document.body.querySelectorAll(selCheckboxes)) {
					if (input.checked == state || input.parentNode.parentNode.parentNode.offsetWidth <= 0) continue;
					const groupTags = new TagManager(...Array.from(input.parentNode.parentNode
						.parentNode.querySelectorAll('div.tags > a')).map(a => a.textContent));
					if (groupTags.length <= 0 || !tags.every(tag => groupTags.includes(tag))) continue;
					input.checked = typeof state == 'boolean' ? state : !input.checked;
					input.dispatchEvent(new Event('change'));
				}
			}
			function _selByGroupIds(groupIds, state) {
				if (!Array.isArray(groupIds) || groupIds.length <= 0) return;
				for (let input of document.body.querySelectorAll(selCheckboxes)) {
					if (input.checked == state
							|| input.parentNode.parentNode.parentNode.offsetWidth <= 0) continue;
					let groupId = input.parentNode.parentNode.parentNode
						.querySelector('div.group_info > strong > a:last-of-type');
					if (groupId != null) groupId = new URLSearchParams(groupId.search); else continue;
					if (!groupIds.includes(parseInt(groupId.get('id')))) continue;
					input.checked = typeof state == 'boolean' ? state : !input.checked;
					input.dispatchEvent(new Event('change'));
				}
			}
			function selByGroupIds(state) {
				let groupIds = prompt('Enter torrent group link(s) or id(s) separated by comma or whitespace\n\n');
				if (groupIds) _selByGroupIds(groupIds.split(/(?:\r?\n|[\,\;\s])+/).map(function(expr) {
					let groupId = parseInt(expr = expr.trim());
					if (groupId > 0) return groupId;
					try {
						if ((groupId = new URL(expr)).hostname == document.location.hostname
								&& groupId.pathname == '/torrents.php'
								&& (groupId = groupId.searchParams.get('id')) > 0) return groupId;
					} catch(e) { }
				}).filter(Boolean));
			}
			function selByArtistIds(state) {
				let artistIds = prompt('Enter site artist name(s)/alias(es), link(s) or id(s) separated by comma; all releases from their profiles will be matched\n\n');
				if (artistIds) Promise.all(artistIds.split(/(?:\r?\n|[\,\;])+/).map(function(expr) {
					let artistId = parseInt(expr = expr.trim());
					if (artistId > 0) return queryAjaxAPI('artist', { id: artistId });
					if (/^https?:\/\//i.test(expr)) try {
						if ((artistId = new URL(expr)).hostname == document.location.hostname
								&& artistId.pathname == '/artist.php'
								&& (artistId = artistId.searchParams.get('id')) > 0)
							return queryAjaxAPI('artist', { id: artistId });
					} catch(e) { }
					return getSiteArtist(expr);
				}).map(result => result.then(artist => Array.isArray(artist.torrentgroup) ?
							artist.torrentgroup.map(torrentGroup => torrentGroup.groupId) : [ ], reason => [ ])))
						.then(function(groupIds) {
					if ((groupIds = Array.prototype.concat.apply([ ], groupIds)).length <= 0) return;
					_selByGroupIds(groupIds.filter((groupId, ndx, arr) => arr.indexOf(groupId) == ndx));
				});
			}
			function selByArtists(state) {
				if (!Array.isArray(artist.torrentgroup) || artist.torrentgroup.length <= 0) return;
				let artists = prompt('Enter site artist/alias name(s) or artist id(s) separated by comma; all releases with their appearance will be matched\n\nNote: if literal name is used for an artist unifying more identities, only corresponding alias will be matched. To match any instance of multi-identity artist, enter that artist id instead\n\n');
				if (artists) Promise.all(artists.split(/(?:\r?\n|[\,\;])+/).map(function(expr) {
					let artistId = parseInt(expr = expr.trim());
					if (artistId > 0) return queryAjaxAPI('artist', { id: artistId }).then(artist => artist.id);
					if (/^https?:\/\//i.test(expr)) try {
						if ((artistId = new URL(expr)).hostname == document.location.hostname
								&& artistId.pathname == '/artist.php'
								&& (artistId = artistId.searchParams.get('id')) > 0)
							return queryAjaxAPI('artist', { id: artistId }).then(artist => artist.id);
					} catch(e) { }
					return getSiteArtist(expr).then(artist => resolveAliasId(expr, artist.id, true).then(aliasId => ({
						artistId: artist.id,
						aliasId: aliasId,
					})));
				}).map(result => result.catch(reason => null))).then(function(ids) {
					ids = ids.filter(Boolean);
					for (let input of document.body.querySelectorAll(selCheckboxes)) {
						if (input.checked == state
								|| input.parentNode.parentNode.parentNode.offsetWidth <= 0) continue;
						let groupId = input.parentNode.parentNode.parentNode
							.querySelector('div.group_info > strong > a:last-of-type');
						if (groupId != null) groupId = new URLSearchParams(groupId.search); else continue;
						if (!(groupId = parseInt(groupId.get('id')))) continue;
						if (!(groupId = artist.torrentgroup.find(torrentGroup => torrentGroup.groupId == groupId))
								|| !groupId.extendedArtists) continue;
						if (!Object.keys(groupId.extendedArtists).some(importance =>
							Array.isArray(groupId.extendedArtists[importance])
								&& groupId.extendedArtists[importance].some(groupArtist => ids.some(id =>
									typeof id == 'number' && groupArtist.id == id || typeof id == 'object'
									&& groupArtist.id == id.artistId && groupArtist.aliasid == id.aliasId))))
							continue;
						input.checked = typeof state == 'boolean' ? state : !input.checked;
						input.dispatchEvent(new Event('change'));
					}
				});
			}
			function getGroupsInfo() {
				if (!Array.isArray(artist.torrentgroup) || artist.torrentgroup.length <= 0) return null;
				const groupsInfo = artist.torrentgroup.map(function(torrentGroup) {
					if (!torrentGroup.extendedArtists) return;
					const importances = Object.keys(torrentGroup.extendedArtists).map(function(importance) {
						if (!Array.isArray(torrentGroup.extendedArtists[importance])) return;
						const artists = torrentGroup.extendedArtists[importance]
							.filter(alias => alias.id == artist.id);
						if (artists.length > 0) return { [importance]: artists };
					}).filter(Boolean);
					if (importances.length > 0)
						return { [torrentGroup.groupId]: Object.assign.apply({ }, importances) };
				}).filter(Boolean);
				return groupsInfo.length > 0 ? Object.assign.apply({ }, groupsInfo) : null;
			}

			const form = document.getElementById('artist-replacer');
			if (form == null) throw 'Assertion failed: form cannot be found';
			let elem, div = document.createElement('DIV');
			div.className = 'selecting';
			div.style.padding = '0 5pt 5pt 5pt';

			function addQS(caption, title) {
				elem = document.createElement('A');
				elem.className = 'brackets'
				if (div.childElementCount > 0) elem.style.marginLeft = '6pt';
				if (caption) {
					elem.textContent = caption;
					if (!title) if (caption.endsWith('+')) elem.title = 'Selects matched releases';
						else if (caption.endsWith('-')) elem.title = 'Unselects matched releases';
							else if (caption.endsWith('*')) elem.title = 'Inverts selection on matched releases';
				}
				if (title) elem.title = title;
				elem.href = '#';
				div.append(elem);
				return elem;
			}
			addQS('All+').onclick = evt => (selAll(true), false);
			addQS('All-').onclick = evt => (selAll(false), false);
			addQS('All*').onclick = evt => (selAll(), false);
			addQS('Discogs+').onclick = evt => (selByDiscogs(true), false);
			addQS('Discogs-').onclick = evt => (selByDiscogs(false), false);
			// addQS('Discogs*').onclick = evt => (selByDiscogs(), false);
			addQS('GroupIds+').onclick = evt => (selByGroupIds(true), false);
			addQS('GroupIds-').onclick = evt => (selByGroupIds(false), false);
			// addQS('GroupIds*').onclick = evt => (selByGroupIds(), false);
			addQS('Tags+').onclick = evt => (selByTags(true), false);
			addQS('Tags-').onclick = evt => (selByTags(false), false);
			// addQS('Tags*').onclick = evt => (selByTags(), false);
			addQS('ArtRlss+').onclick = evt => (selByArtistIds(true), false);
			addQS('ArtRlss-').onclick = evt => (selByArtistIds(false), false);
			// addQS('ArtistIds*').onclick = evt => (selByArtistIds(), false);
			addQS('Artists+').onclick = evt => (selByArtists(true), false);
			addQS('Artists-').onclick = evt => (selByArtists(false), false);
			// addQS('Artists*').onclick = evt => (selByArtists(), false);
			form.append(div);

			const input = document.createElement('INPUT');
			input.type = 'text';
			input.placeholder = 'New artist/alias name or artist id';
			input.style.width = '94%';
			input.dataset.gazelleAutocomplete = true;
			input.autocomplete = 'off';
			input.spellcheck = false;
			try { $(input).autocomplete({ serviceUrl: 'artist.php?action=autocomplete' }) } catch(e) { console.error(e) }
			form.append(input);
			form.append(document.createElement('BR'));
			elem = document.createElement('INPUT');
			elem.type = 'button';
			elem.value = elem.dataset.caption = 'GO';
			elem.onclick = changeArtist;
			form.append(elem);
			elem = document.createElement('INPUT');
			elem.type = 'button';
			elem.style = 'margin-left: 1em;';
			elem.value = 'C';
			elem.title = 'Clipboard copy recovery info for case of failure';
			elem.onclick = function(evt) {
				const groupsInfo = getGroupsInfo();
				if (groupsInfo) GM_setClipboard(JSON.stringify(groupsInfo)); else return;
				evt.currentTarget.style.color = 'lightgreen';
				setTimeout(elem => { elem.style.color = null }, 1000, evt.currentTarget);
			};
			form.append(elem);
			elem = document.createElement('INPUT');
			elem.type = 'button';
			elem.style = 'margin-left: 2pt;';
			elem.value = 'R';
			elem.title = 'Use recovery info to restore this artist on all involved releases';
			elem.onclick = function(evt) {
				let groupsInfo = prompt('Paste previously copied object (leave blank to obtain from current page snapshot):\n\n');
				if (groupsInfo == undefined) return;
				if (groupsInfo) try { groupsInfo = JSON.parse(groupsInfo) } catch(e) {
					console.warn(e);
					return;
				} else if (!(groupsInfo = getGroupsInfo())) return;
				const names = new Set;
				for (let torrentGroup in groupsInfo) for (let importance in groupsInfo[torrentGroup])
					for (let alias of groupsInfo[torrentGroup][importance]) names.add(alias.name);
				groupsInfo = Array.from(names.keys()).map(function(name) {
					let groups = Object.keys(groupsInfo).map(function(groupId) {
						let importances = Object.keys(groupsInfo[groupId])
							.filter(importance => groupsInfo[groupId][importance].some(alias => alias.name == name))
							.map(importance => parseInt(importance));
						if (importances.length > 0) return { [groupId]: importances };
					}).filter(Boolean);
					if (groups.length > 0) return { [name]: Object.assign.apply({ }, groups) };
				}).filter(Boolean);
				if (groupsInfo.length > 0) groupsInfo = Object.assign.apply({ }, groupsInfo); else return;
				console.info('groupsInfo:', groupsInfo);
				const currentTarget = evt.currentTarget;
				Promise.all(Object.keys(groupsInfo).map(function(name) {
					const groupIds = Object.keys(groupsInfo[name]);
					const _addAliasToGroup = groupId =>
						addAliasToGroup(groupId, name, groupsInfo[name][groupId]);
						//Promise.resolve(console.log(`addAliasToGroup(${groupId}, '${name}', [${groupsInfo[name][groupId]}]);`));
					return groupIds.length > 1 ? _addAliasToGroup(groupIds[0]).then(wait)
							.then(() => Promise.all(groupIds.slice(1).map(_addAliasToGroup))).catch(function(reason) {
						console.warn('addAliasToGroups parallely failed, trying serially:', reason);
						return (function _addAliasToGroup(index = 0) {
							if (!(index >= 0 && index < groupIds.length))
								return Promise.resolve('Artist alias re-added to all groups');
							const importances = groupsInfo[name][groupIds[index]];
							console.assert(Array.isArray(importances) && importances.length > 0,
								'Array.isArray(importances) && importances.length > 0');
							return Array.isArray(importances) && importances.length > 0 ?
								addAliasToGroup(groupIds[index], name, importances)
									.then(result => _addAliasToGroup(index + 1))
								: _addAliasToGroup(index + 1);
						})();
					}) : _addAliasToGroup(groupIds[0]);
				})).then(function() {
					currentTarget.style.color = 'lightgreen';
					document.location.reload();
				});
			};
			form.append(elem);
			elem = document.createElement('SPAN');
			elem.class = 'totals';
			elem.style = 'float: right; margin: 5pt 1em 0 0;';
			const counter = document.createElement('SPAN');
			counter.id = 'selection-counter';
			counter.textContent = 0;
			elem.append(counter, ' / ' + document.body.querySelectorAll(selBase).length.toString());
			form.append(elem);

			function isOutside(target, related) {
				if (target instanceof HTMLElement) {
					target = target.parentNode;
					while (related instanceof HTMLElement) if ((related = related.parentNode) == target) return false;
				}
				return true;
			}
			for (let td of document.body.querySelectorAll('div#discog_table > table > tbody > tr.colhead_dark > td.small')) {
				const label = document.createElement('LABEL');
				label.style = 'padding: 1pt 5pt; cursor: pointer; transition: 0.25s;';
				elem = document.createElement('INPUT');
				elem.type = 'checkbox';
				elem.name = 'select-category';
				elem.style.cursor = 'pointer';
				elem.onchange = function(evt) {
					for (let input of evt.currentTarget.parentNode.parentNode.parentNode.parentNode
							 .querySelectorAll('tr.group > td:first-of-type input[type="checkbox"][name="separate"]')) {
						if (input.checked == evt.currentTarget.checked
								|| input.parentNode.parentNode.parentNode.offsetWidth <= 0) continue;
						input.checked = evt.currentTarget.checked;
						input.dispatchEvent(new Event('change'));
					}
				};
				label.onmouseenter = evt => { label.style.backgroundColor = 'orange' };
				label.onmouseleave = evt =>
					{ if (isOutside(evt.currentTarget, evt.relatedTarget)) label.style.backgroundColor = null };
				label.append(elem);
				td.append(label);
			}
			for (let tr of document.body.querySelectorAll(['edition', 'torrent_row']
				.map(cls => 'div#discog_table > table > tbody > tr.' + cls).join(', '))) tr.remove();
			for (let td of document.body.querySelectorAll(selBase)) {
				while (td.firstChild != null) td.removeChild(td.firstChild);
				const label = document.createElement('LABEL');
				label.style = 'padding: 7pt; cursor: pointer; opacity: 1; transition: 0.25s;';
				elem = document.createElement('INPUT');
				elem.type = 'checkbox';
				elem.name = 'separate';
				elem.style.cursor = 'pointer';
				elem.onchange = function(evt) {
					evt.currentTarget.parentNode.parentNode.parentNode.style.opacity = evt.currentTarget.checked ? 1 : 0.75;
					if (evt.currentTarget.checked) ++counter.textContent; else --counter.textContent;
				};
				label.onmouseenter = evt => { label.style.backgroundColor = 'orange' };
				label.onmouseleave = evt =>
					{ if (isOutside(evt.currentTarget, evt.relatedTarget)) label.style.backgroundColor = null };
				label.append(elem);
				td.append(label);
				td.parentNode.style.opacity = 0.75;
			}
		}
	});
}

if (artistEdit) {
	if (!window.tooltipster) window.tooltipster = typeof jQuery.fn.tooltipster == 'function' ?
			Promise.resolve(jQuery.fn.tooltipster) : new Promise(function(resolve, reject) {
		const script = document.createElement('SCRIPT');
		script.src = '/static/functions/tooltipster.js';
		script.type = 'text/javascript';
		script.onload = function(evt) {
			//console.log('tooltipster.js was successfully loaded', evt);
			if (typeof jQuery.fn.tooltipster == 'function') resolve(jQuery.fn.tooltipster);
				else reject('tooltipster.js loaded but core function was not found');
		};
		script.onerror = evt => { reject('Error loading tooltipster.js') };
		document.head.append(script);
		['style.css'/*, 'custom.css', 'reset.css'*/].forEach(function(css) {
			const link = document.createElement('LINK');
			link.rel = 'stylesheet';
			link.type = 'text/css';
			link.href = '/static/styles/tooltipster/' + css;
			//link.onload = evt => { console.log('style.css was successfully loaded', evt) };
			link.onerror = evt => { (css == 'style.css' ? reject : console.warn)('Error loading ' + css) };
			document.head.append(link);
		});
	});
	loadArtist();
} else {
	function copyGroupIds(root = document.body) {
		if (!(root instanceof HTMLElement)) return; // assertion failed
		const groupIds = Array.from(root.querySelectorAll('tbody > tr.group div.group_info > strong > a:last-of-type')).map(function(a) {
			if (a.parentNode.parentNode.parentNode.parentNode.offsetWidth <= 0) return false;
			a = new URLSearchParams(a.search);
			if ((a = parseInt(a.get('id'))) > 0) return a;
		}).filter(Boolean);
		if (groupIds.length > 0) GM_setClipboard(groupIds.join('\n'), 'text');
	}
	const hdr = document.body.querySelector('div#content div.header > h2');
	if (hdr != null) {
		hdr.style.cursor = 'pointer';
		hdr.onclick = evt => (copyGroupIds(document.getElementById('discog_table')), false);
	}
	for (let strong of document.body.querySelectorAll('table > tbody > tr.colhead_dark > td > strong')) {
		strong.style.cursor = 'pointer';
		strong.onclick = evt => (copyGroupIds(evt.currentTarget.parentNode.parentNode.parentNode.parentNode), false);
	}

	const sidebar = document.body.querySelector('div#content div.sidebar');
	if (sidebar == null) throw 'Assertion failed: sidebar couldnot be located';
	const elems = createElements('DIV', 'FORM', 'DIV', 'INPUT');
	elems.push(document.body.querySelector('div#content div.header > h2'));
	elems[0].className = 'box box_replace_artist';
	elems[0].innerHTML = '<div class="head"><strong>Artist replacer</strong></div>';
	elems[1].id = 'artist-replacer';
	elems[1].style.padding = '6pt';
	elems[2].textContent = 'This tool will replace all instances of ' +
		(elems[4] != null ? elems[4].textContent.trim() : 'artist') +
		' in selected releases with different name, whatever existing or new.';
	elems[2].style = 'margin-bottom: 1em; font-size: 9pt;';
	elems[1].append(elems[2]);
	elems[3].type = 'button';
	elems[3].value = 'Enter selection mode';
	elems[3].onclick = function(evt) {
		while (elems[1].firstChild != null) elems[1].removeChild(elems[1].firstChild);
		loadArtist().catch(function(reason) {
			const span = document.createElement('SPAN');
			span.textContent = 'Error loading artist releases: ' + reason;
			span.style.color = 'red';
			elems[1].append(span);
		});
	}
	elems[1].append(elems[3]);
	elems[0].append(elems[1]);
	sidebar.append(elems[0]);
}

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址