[GMT] Collage Extensions

Direct browsing from torrent pages; quick groups removal, custom quick Add To Collage form

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         [GMT] Collage Extensions
// @version      1.25.0
// @description  Direct browsing from torrent pages; quick groups removal, custom quick Add To Collage form
// @author       Anakunda
// @license      GPL-3.0-or-later
// @copyright    2020, Anakunda (https://openuserjs.org/users/Anakunda)
// @namespace    https://greasyfork.org/users/321857-anakunda
// @match        https://*/collages.php?id=*
// @match        https://*/collages.php?page=*&id=*
// @match        https://*/collage.php?id=*
// @match        https://*/collage.php?page=*&id=*
// @match        https://*/artist.php?*id=*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @grant        GM_openInTab
// @grant        GM_setClipboard
// @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/libCtxtMenu.min.js
// ==/UserScript==

'use strict';

let userAuth = document.body.querySelector('input[name="auth"][value]');
if (userAuth != null) userAuth = userAuth.value;
	else if ((userAuth = document.body.querySelector('li#nav_logout > a')) == null
			|| !(userAuth = new URLSearchParams(userAuth.search).get('auth'))) throw 'Auth not found';
let userId = document.body.querySelector('li#nav_userinfo > a.username');
if (userId != null) {
	userId = new URLSearchParams(userId.search);
	userId = parseInt(userId.get('id'));
}

function addToTorrentCollage(collageId, groupId) {
	if (!(collageId > 0)) throw 'collage id invalid';
	if (!(groupId > 0)) throw 'torrent group id invalid';
	return (ajaxApiKey ? queryAjaxAPI('addtocollage', { collageid: collageId }, { groupids: groupId })
		.then(response => response.groupsadded.includes(groupId) ? true : Promise.reject('Rejected'))
			: Promise.reject('API key not set')).catch(reason => queryAjaxAPI('collage', { id: collageId, showonlygroups: 1 }).then(collage =>
		!collage.torrentgroups.map(torrentgroup => parseInt(torrentgroup.id)).includes(groupId) ? collage.id
			: Promise.reject('already in collage')).then(collageId => new Promise(function(resolve, reject) {
		const xhr = new XMLHttpRequest, formData = new URLSearchParams({
			action: 'add_torrent',
			collageid: collageId,
			groupid: groupId,
			url: document.location.origin.concat('/torrents.php?id=', groupId),
			auth: userAuth,
		});
		xhr.open('POST', '/collages.php', true);
		xhr.onreadystatechange = function() {
			if (xhr.readyState < XMLHttpRequest.DONE) return;
			if (xhr.status >= 200 && xhr.status < 400) resolve(xhr.readyState); else reject(defaultErrorHandler(xhr));
		};
		xhr.onerror = function() { reject(defaultErrorHandler(xhr)) };
		xhr.ontimeout = function() { reject(defaultTimeoutHandler(xhr)) };
		xhr.send(formData);
	})).then(readyState => queryAjaxAPI('collage', { id: collageId, showonlygroups: 1 }).then(collage =>
		collage.torrentgroups.map(torrentgroup => parseInt(torrentgroup.id)).includes(groupId)
			|| Promise.reject('Error: not added for unknown reason'))));
}

function removeFromTorrentCollage(collageId, groupId, question) {
	if (!confirm(question)) return Promise.reject('Cancelled');
	return new Promise(function(resolve, reject) {
		let xhr = new XMLHttpRequest, formData = new URLSearchParams({
			action: 'manage_handle',
			collageid: collageId,
			groupid: groupId,
			auth: userAuth,
			submit: 'Remove',
		});
		xhr.open('POST', '/collages.php', true);
		xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
		xhr.onreadystatechange = function() {
			if (xhr.readyState < XMLHttpRequest.HEADERS_RECEIVED) return;
			if (xhr.status >= 200 && xhr.status < 400) resolve(xhr.status); else reject(defaultErrorHandler(xhr));
			xhr.abort();
		};
		xhr.onerror = function() { reject(defaultErrorHandler(xhr)) };
		xhr.ontimeout = function() { reject(defaultTimeoutHandler(xhr)) };
		xhr.send(formData);
	});
}

function addToArtistCollage(collageId, artistId) {
	if (!(collageId > 0)) throw 'collage id invalid';
	if (!(artistId > 0)) throw 'artist id invalid';
	return queryAjaxAPI('collage', { id: collageId, showonlygroups: 1 }).then(collage =>
		!collage.artists.map(artist => parseInt(artist.id)).includes(artistId) ? collage.id
			: Promise.reject('already in collage')).then(collageId => new Promise(function(resolve, reject) {
		const xhr = new XMLHttpRequest, formData = new URLSearchParams({
			action: 'add_artist',
			collageid: collageId,
			artistid: artistId,
			url: document.location.origin.concat('/artist.php?id=', artistId),
			auth: userAuth,
		});
		xhr.open('POST', '/collages.php', true);
		xhr.onreadystatechange = function() {
			if (xhr.readyState < XMLHttpRequest.DONE) return;
			if (xhr.status >= 200 && xhr.status < 400) resolve(xhr.readyState); else reject(defaultErrorHandler(xhr));
		};
		xhr.onerror = function() { reject(defaultErrorHandler(xhr)) };
		xhr.ontimeout = function() { reject(defaultTimeoutHandler(xhr)) };
		xhr.send(formData);
	})).then(readyState => queryAjaxAPI('collage', { id: collageId, showonlygroups: 1 }).then(collage =>
		collage.artists.map(artist => parseInt(artist.id)).includes(artistId)
			|| Promise.reject('Error: not added for unknown reason')));
}

function removeFromArtistCollage(collageId, artistId, question) {
	if (!confirm(question)) return Promise.reject('Cancelled');
	return new Promise(function(resolve, reject) {
		let xhr = new XMLHttpRequest, formData = new URLSearchParams({
			action: 'manage_artists_handle',
			collageid: collageId,
			artistid: artistId,
			auth: userAuth,
			submit: 'Remove',
		});
		xhr.open('POST', '/collages.php', true);
		xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
		xhr.onreadystatechange = function() {
			if (xhr.readyState < XMLHttpRequest.HEADERS_RECEIVED) return;
			if (xhr.status >= 200 && xhr.status < 400) resolve(xhr.status); else reject(defaultErrorHandler(xhr));
			xhr.abort();
		};
		xhr.onerror = function() { reject(defaultErrorHandler(xhr)) };
		xhr.ontimeout = function() { reject(defaultTimeoutHandler(xhr)) };
		xhr.send(formData);
	});
}

function addQuickAddForm() {
	if (!userId || !groupId && !artistId) return; // User id missing
	let ref = document.querySelector('div.sidebar');
	if (ref == null) return; // Sidebar missing
	const addSuccess = 'Successfully added to collage.';
	const alreadyInCollage = 'Error: This ' +
		(groupId ? 'torrent group' : artistId ? 'artist' : null) + ' is already in this collage';
	new Promise(function(resolve, reject) {
		try {
			var categories = JSON.parse(GM_getValue(document.location.hostname + '-categories'));
			if (categories.length > 0) resolve(categories); else throw 'empty list cached';
		} catch(e) {
			let xhr = new XMLHttpRequest;
			xhr.open('GET', '/collages.php', true);
			xhr.responseType = 'document';
			xhr.onload = function() {
				if (xhr.status >= 200 && xhr.status < 400) {
					categories = [ ];
					xhr.response.querySelectorAll('tr#categories > td > label').forEach(function(label, index) {
						let input = xhr.response.querySelector('tr#categories > td > input#' + label.htmlFor);
						categories[input != null && /\[(\d+)\]/.test(input.name) ? parseInt(RegExp.$1) : index] = label.textContent.trim();
					});
					if (categories.length > 0) {
						GM_setValue(document.location.hostname + '-categories', JSON.stringify(categories));
						resolve(categories);
					} else reject('Site categories could not be extracted');
				} else reject(defaultErrorHandler(xhr));
			};
			xhr.onerror = function() { reject(defaultErrorHandler(xhr)) };
			xhr.ontimeout = function() { reject(defaultTimeoutHandler()) };
			xhr.send();
		}
	}).then(function(categories) {
		const artistsIndexes = categories
			.map((category, index) => /^(?:Artists)$/i.test(category) ? index : -1)
			.filter(index => index >= 0);
		if (artistId && artistsIndexes.length <= 0) throw 'Artists index not found';
		const isCompatibleCategory = categoryId => categoryId >= 0 && categoryId < categories.length
			&& (groupId && !artistsIndexes.includes(categoryId) || artistId && artistsIndexes.includes(categoryId));
		document.head.appendChild(document.createElement('style')).innerHTML = `
form#addtocollage optgroup { background-color: slategray; color: white; }
form#addtocollage option { background-color: white; color: black; max-width: 290pt; }
div.box_addtocollage > form { padding: 0px 10px; }
`;
		let elem = document.createElement('div');
		elem.className = 'box box_addtocollage';
		elem.style = 'padding: 0 0 10px;';
		elem.innerHTML = `
<div class="head" style="margin-bottom: 5px;"><strong>Add to Collage</strong></div>
<div id="ajax_message" class="hidden center" style="padding: 7px 0px;"></div>
<form id="searchcollages">
	<input id="searchforcollage" placeholder="Collage search" type="text" style="max-width: 10em;">
	<input id="searchforcollagebutton" value="Search" type="submit" style="max-width: 4em;">
</form>
<form id="addtocollage" class="add_form" name="addtocollage">
	<select name="collageid" id="matchedcollages" class="add_to_collage_select" style="width: 96%;">
	<input id="opencollage-btn" value="Open collage" type="button">
	<input id="addtocollage-btn" value="Add to collage" type="button">
</form>
`;
		ref.append(elem);
		let ajaxMessage = document.getElementById('ajax_message');
		let srchForm = document.getElementById('searchcollages');
		if (srchForm == null) throw new Error('#searchcollages missing');
		let searchText = document.getElementById('searchforcollage');
		if (searchText == null) throw new Error('#searchforcollage missing');
		let dropDown = document.getElementById('matchedcollages');
		if (dropDown == null) throw new Error('#matchedcollages missing');
		let doOpen = document.getElementById('opencollage-btn');
		let doAdd = document.getElementById('addtocollage-btn');
		if (doAdd == null) throw new Error('#addtocollage-btn missing');
		let searchforcollagebutton = document.getElementById('searchforcollagebutton');
		if (searchforcollagebutton != null) searchforcollagebutton.disabled = searchText.value.length <= 0;
		srchForm.onsubmit = searchSubmit;
		searchText.ondrop = evt => dataHandler(evt.currentTarget, evt.dataTransfer);
		searchText.onpaste = evt => dataHandler(evt.currentTarget, evt.clipboardData);
		searchText.oninput = function(evt) {
			if (searchforcollagebutton != null) searchforcollagebutton.disabled = evt.currentTarget.value.length <= 0;
		};
		if (doOpen != null) doOpen.onclick = openCollage;
		doAdd.onclick = addToCollage;
		let initTimeCap = GM_getValue('max_preload_time', 0); // max time in ms to preload the dropdown
		if (initTimeCap > 0) findCollages({ userid: userId, contrib: 1 }, initTimeCap);

		function clearList() {
			while (dropDown.childElementCount > 0) dropDown.removeChild(dropDown.firstElementChild);
		}

		function findCollages(query, maxSearchTime) {
			return typeof query == 'object' ? new Promise(function(resolve, reject) {
				let start = Date.now();
				searchFormEnable(false);
				clearList();
				elem = document.createElement('option');
				elem.text = 'Searching...';
				dropDown.add(elem);
				dropDown.selectedIndex = 0;
				let retryCount = 0, options = [ ];
				searchInternal();

				function searchInternal(page) {
					if (maxSearchTime > 0 && Date.now() - start > maxSearchTime) {
						reject('Time limit exceeded');
						return;
					}
					let xhr = new XMLHttpRequest, _query = new URLSearchParams(query);
					if (!page) page = 1;
					_query.set('page', page);
					xhr.open('GET', '/collages.php?' + _query, true);
					xhr.responseType = 'document';
					xhr.onload = function() {
						if (xhr.status < 200 || xhr.status >= 400) throw defaultErrorHandler(xhr);
						xhr.response.querySelectorAll('table.collage_table > tbody > tr[class^="row"]').forEach(function(tr, rowNdx) {
							if ((ref = tr.querySelector(':scope > td:nth-of-type(1) > a')) == null) {
								console.warn('Page parsing error');
								return;
							}
							elem = document.createElement('option');
							if ((elem.category = categories.findIndex(category => category.toLowerCase() == ref.textContent.toLowerCase())) < 0
									&& /\b(?:cats)\[(\d+)\]/i.test(ref.search)) elem.category = parseInt(RegExp.$1); // unsafe due to site bug
							if ((ref = tr.querySelector(':scope > td:nth-of-type(2) > a')) == null || !/\b(?:id)=(\d+)\b/i.test(ref.search)) {
								console.warn(`Unknown collage id (${xhr.responseURL}/${rowNdx})`);
								return;
							}
							elem.value = elem.collageId = parseInt(RegExp.$1);
							elem.text = elem.title = ref.textContent.trim();
							if ((ref = tr.querySelector(':scope > td:nth-of-type(3)')) != null) elem.size = parseInt(ref.textContent);
							if ((ref = tr.querySelector(':scope > td:nth-of-type(4)')) != null) elem.subscribers = parseInt(ref.textContent);
							if ((ref = tr.querySelector(':scope > td:nth-of-type(6) > a')) != null
									&& /\b(?:id)=(\d+)\b/i.test(ref.search)) elem.author = parseInt(RegExp.$1);
							if (isCompatibleCategory(elem.category) && (elem.category != 0 || elem.author == userId)) options.push(elem);
						});
						if (xhr.response.querySelector('div.linkbox > a.pager_next') != null) searchInternal(page + 1); else {
							if (!Object.keys(query).includes('order'))
								options.sort((a, b) => (b.size || 0) - (a.size || 0)/*a.title.localeCompare(b.title)*/);
							resolve(options);
						}
					};
					xhr.onerror = function() {
						if (xhr.status == 0 && retryCount++ <= 10) setTimeout(function() { searchInternal(page) }, 200);
						else reject(defaultErrorHandler(xhr));
					};
					xhr.ontimeout = function() { reject(defaultTimeoutHandler()) };
					xhr.send();
				}
			}).then(function(options) {
				clearList();
				categories.forEach(function(category, ndx) {
					let _category = options.filter(option => option.category == ndx);
					if (_category.length <= 0) return;
					elem = document.createElement('optgroup');
					elem.label = category;
					elem.append(..._category);
					dropDown.add(elem);
				});
				dropDown.selectedIndex = 0;
				searchFormEnable(true);
				return options;
			}).catch(function(reason) {
				clearList();
				searchFormEnable(true);
				console.warn(reason);
			}) : Promise.reject('Invalid parameter');
		}

		function searchFormEnable(enabled) {
			for (let i = 0; i < srchForm.length; ++i) srchForm[i].disabled = !enabled;
		}

		function searchSubmit(evt) {
			let searchTerm = searchText.value.trim();
			if (searchTerm.length <= 0) return false;
			let query = {
				action: 'search',
				search: searchTerm,
				type: 'c.name',
				order: 'Updated',
				sort: 'desc',
				order_way: 'Descending',
			};
			categories.map((category, index) => 'cats[' + index + ']')
				.filter((category, index) => isCompatibleCategory(index))
				.forEach(index => { query[index] = 1 });
			findCollages(query);
			return false;
		}

		function addToCollage(evt) {
			(function() {
				evt.currentTarget.disabled = true;
				if (ajaxMessage != null) ajaxMessage.classList.add('hidden');
				let collageId = parseInt(dropDown.value);
				if (!collageId) return Promise.reject('No collage selected');
/*
				if (Array.from(document.querySelectorAll('table.collage_table > tbody > tr:not([class="colhead"]) > td > a'))
						.map(node => /\b(?:id)=(\d+)\b/i.test(node.search) && parseInt(RegExp.$1)).includes(collageId))
					return Promise.reject(alreadyInCollage);
*/
				if (groupId > 0) return addToTorrentCollage(collageId, groupId);
				if (artistId > 0) return addToArtistCollage(collageId, artistId);
				return Promise.reject('munknown page class');
			})().then(function(collage) {
				if (ajaxMessage != null) {
					ajaxMessage.innerHTML = '<span style="color: #0A0;">' + addSuccess + '</span>';
					ajaxMessage.classList.remove('hidden');
				}
				evt.currentTarget.disabled = false;
				let mainColumn = document.querySelector('div.main_column');
				if (mainColumn == null) return collage;
				let tableName = collage.collageCategoryID != 0 ? 'collages' : 'personal_collages'
				let tbody = mainColumn.querySelector('table#' + tableName + ' > tbody');
				if (tbody == null) {
					tbody = document.createElement('tbody');
					tbody.innerHTML = '<tr class="colhead"><td width="85%"><a href="#">↑</a>&nbsp;</td><td># torrents</td></tr>';
					elem = document.createElement('table');
					elem.id = tableName;
					elem.className = 'collage_table';
					elem.append(tbody);
					mainColumn.insertBefore(elem, [
						'table#personal_collages', 'table#vote_matches', 'div.torrent_description',
						'div#similar_artist_map', 'div#artist_information',
					].reduce((acc, selector) => acc || document.querySelector(selector), null));
				}
				tableName = '\xA0This ' + (artistsIndexes.includes(collage.collageCategoryID) ? 'artist' : 'album') + ' is in ' +
					tbody.childElementCount + ' ' + (collage.collageCategoryID != 0 ? 'collage' : 'personal collage');
				if (tbody.childElementCount > 1) tableName += 's';
				tbody.firstElementChild.firstElementChild.childNodes[1].data = tableName;
				elem = document.createElement('tr');
				elem.className = 'collage_rows';
				if (tbody.querySelector('tr.collage_rows.hidden') != null) elem.classList.add('hidden');
				elem.innerHTML = '<td><a href="/collages.php?id=' + collage.id + '">' + collage.name + '</a></td><td class="number_column">' +
					collage[artistsIndexes.includes(collage.collageCategoryID) ? 'artists' : 'torrentgroups'].length + '</td>';
				tbody.append(elem);
				return collage;
			}).catch(function(reason) {
				evt.currentTarget.disabled = false;
				if (ajaxMessage == null) return;
				ajaxMessage.innerHTML = '<span style="color: #A00;">' + reason.toString() + '</span>';
				ajaxMessage.classList.remove('hidden');
			});
		}

		function openCollage(evt) {
			const collageId = parseInt(dropDown.value);
			if (collageId <= 0) return false;
			let win = window.open('/collages.php?id=' + collageId, '_blank');
			win.focus();
		}

		function dataHandler(target, data) {
			const text = data.getData('text/plain');
			if (!text) return false;
			if (searchforcollagebutton != null) searchforcollagebutton.disabled = false;
			target.value = text;
			srchForm.onsubmit();
			return false;
		}
	});
}

const contextId = 'context-9b7e0e42-1e35-4518-ac5f-b6bb31cce23f';
let menu = document.createElement('menu');
menu.type = 'context';
menu.id = contextId;
function contextUpdater(evt) { menu = evt.currentTarget }
menu.innerHTML = '<menuitem label="Remove from this collage" icon="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsSAAALEgHS3X78AAADaElEQVQ4y0XTT0xcVRQG8O+ce+fNAOUN7QiFFoHSNq2SIDQ2QGfQ0hYtSatpuvFPjK0mxtDWRF24MG7UbVeasDGmiTrWgG2NjRlaUyAsirrQoCZVF5QZhsKUP/NmKMwM993jwlG/zfdtT3J+IGYCwPE3zye+f//d6UrmMAAwkUI5/+6qilD4q7Z90x9tjyQIYAIIAHjo1ZfjMn5dNm9+LdfeOjdZQeSCCEzETMQAUBl03C/6j0wmD0Vl7kCnfFgXiQNgFT/32ndnTp84lVnK+A+8rHTubW7piNT2Xfvp5xEDFABIheLwZ6dPjR4u+odWvKxvQ0Hpcd32RrFdvD1cXV9az8Ffz4sqFdT8fNr0P76/O372hYQDbHFEtnz+4vOJJ6C7M/fmTSjoKBYrOuAgEnDqqZI5HH/97LcDPe29CytLJmCtLpV8U1dVo6+M3R6DdnCirr4vOTFhKiortEBMrVJ6ODU3eSGVOkkAUKmU++WZ50aPdu7pvp/NmwAcbQzZ6mA12VUPmbFb4hAYzCbCrK+m01ODs8mni0BOMZEqiRS++eX3kQ5365PtO3c059dLVvuai+tFyf34A3SxwBxQ9iHF6mo6fXtwNjlQBHIMKCWAMBEbSOHqb3fie0NVsUdrd+za9MWaP+6wZBZJBQO2CsSXU+mJ88nUUwZYY4AtYDUAQAQgghWB2SiCfIGZT8OfS0E7DhiAIoEAsOXfkHIrJlIWZEMk4Uv9x248+9iBWHZ12dq//mSGWMUkmsDCsNFtNbsaoY6MetkRHyj8d0KI4H7S1zc68Ehbz8pazmBmRmFjw1bpAAUJJAxRAi5AzMEat7mBAodveN6wDxRUiBAeisWun9zfFl1ZXzOcWdT+6rKJBIPqcjI9/msufze6raa1ADEk0EWI6drqNkVEx2553hX9aTQ6fnTPvo7lnGf0xgNtlpZMJODo4dT81Nup5DMA4LC6+VJTQ3ceviGBzlnfvNJc3yuWxzmTzy+YnAdd3CB/6b4fUUoPp+9NXZi9e9yA1gyw9sbszPFLyYUpl5UWgh9gULZgsVjaXAAB/F7r7ngyGpPF7i75uKll0gFcACiLYwAIgNyLD7dOer09MnuwSwYjO+MAGGWS/EFdQ2KosWU6RPQPZ+B/zuUdJApfbNw9/U5dUwJlzn8DiOOFPhubkGUAAAAASUVORK5CYII=" /><menuitem label="-" />';

function subscribeCallback(evt) {
	let link = menu || evt.relatedTarget || document.activeElement;
	if (!(link instanceof HTMLAnchorElement)) return true;
	let collageId = parseInt(new URLSearchParams(link.search).get('id'));
	if (!collageId) {
		console.warn('Assertion failed: no collage id', link);
		throw 'no id';
	}
	let xhr = new XMLHttpRequest;
	xhr.open('GET', '/userhistory.php?' + new URLSearchParams({
		action: 'collage_subscribe',
		collageid: collageId,
		auth: userAuth,
	}), true);
	xhr.onreadystatechange = function() {
		if (xhr.readyState < XMLHttpRequest.HEADERS_RECEIVED) return;
		if (xhr.status >= 200 && xhr.status < 400) { console.info('Subscribed to collage id', collageId) }
			else console.error(defaultErrorHandler(xhr));
		xhr.abort();
	};
	xhr.send();
}

const maxOpenTabs = GM_getValue('max_open_tabs', 25), autoCloseTimeout = GM_getValue('tab_auto_close_timeout', 0);
let openedTabs = [ ], lastOnQueue;
function openTabLimited(endpoint, params, hash) {
	if (typeof GM_openInTab != 'function') return Promise.reject('Not supported');
	if (!endpoint) return Promise.reject('Invalid argument');
	const waitFreeSlot = () => (maxOpenTabs > 0 && openedTabs.length >= maxOpenTabs ?
			Promise.race(openedTabs.map(tabHandler => new Promise(function(resolve) {
		console.assert(!tabHandler.closed);
		if (!tabHandler.closed) tabHandler.resolver = resolve; else resolve(tabHandler);
	}))) : Promise.resolve(null)).then(function(tabHandler) {
		console.assert(openedTabs.length <= maxOpenTabs);
		const url = new URL(endpoint + '.php', document.location.origin);
		if (params) for (let param in params) url.searchParams.set(param, params[param]);
		if (hash) url.hash = hash;
		(tabHandler = GM_openInTab(url.href, true)).onclose = function() {
			console.assert(this.closed);
			if (this.autoCloseTimer >= 0) clearTimeout(this.autoCloseTimer);
			const index = openedTabs.indexOf(this);
			console.assert(index >= 0);
			if (index >= 0) openedTabs.splice(index, 1);
				else openedTabs = openedTabs.filter(opernGroup => !opernGroup.closed);
			if (typeof this.resolver == 'function') this.resolver(this);
		}.bind(tabHandler);
		if (autoCloseTimeout > 0) tabHandler.autoCloseTimer = setTimeout(tabHandler =>
			{ if (!tabHandler.closed) tabHandler.close() }, autoCloseTimeout * 1000, tabHandler);
		openedTabs.push(tabHandler);
		return tabHandler;
	});
	return lastOnQueue = lastOnQueue instanceof Promise ? lastOnQueue.then(waitFreeSlot) : waitFreeSlot();
}

const urlParams = new URLSearchParams(document.location.search);
let groupId, artistId, collageId;
switch (document.location.pathname) {
	case '/torrents.php': {
		function cacheCollage(collage) {
			collages[document.domain][collage.id] = {
				id: collage.id,
				name: collage.name,
				torrentgroups: collage.torrentgroups.map(torrentgroup => ({
					id: torrentgroup.id,
					musicInfo: torrentgroup.musicInfo ? {
						artists: Array.isArray(torrentgroup.musicInfo.artists) ?
							torrentgroup.musicInfo.artists.map(artist => ({ name: artist.name })) : undefined,
					} : undefined,
					name: torrentgroup.name,
					year: parseInt(torrentgroup.year) || undefined,
				})),
			};
			window.sessionStorage.setItem('collages', JSON.stringify(collages));
		}

		if (!(groupId = parseInt(urlParams.get('id'))) > 0) break; // Unexpected URL format
		const searchforcollage = document.getElementById('searchforcollage'),
					submitButton = document.getElementById('searchforcollagebutton'),
					addToCollageSelect = document.body.querySelector('select.add_to_collage_select');
		var collages;
		if (searchforcollage != null) {
			if (submitButton != null) submitButton.disabled = searchforcollage.value.length <= 0;
			if (typeof SearchCollage == 'function') SearchCollage = () => {
				const searchTerm = $('#searchforcollage').val(),
							personalCollages = $('#personalcollages');
				ajax.get(`ajax.php?action=collages&search=${encodeURIComponent(searchTerm)}`, responseText => {
					const { response, status } = JSON.parse(responseText);
					if (status !== 'success') return;
					const categories = response.reduce((accumulator, item) => {
						const { collageCategoryName } = item;
						accumulator[collageCategoryName] = (accumulator[collageCategoryName] || []).concat(item);
						return accumulator;
					}, {});
					personalCollages.children().remove();
					Object.entries(categories).forEach(([category, collages]) => {
						console.log(collages);
						personalCollages.append(`
<optgroup label="${category}">
${collages.reduce((accumulator, { id, name }) =>
	`${accumulator}<option value="${id}">${name}</option>`
	,'')}
</optgroup>
`);
					});
				});
			};

			if (addToCollageSelect != null) addToCollageSelect.selectedIndex = -1;

			function inputHandler(evt, key) {
				const data = evt[key].getData('text/plain').trim();
				if (!data) return true;
				evt.currentTarget.value = data;
				if (submitButton != null) submitButton.disabled = false;
				SearchCollage();
				setTimeout(function() {
					if (addToCollageSelect != null && addToCollageSelect.options.length > 1) {
						// TODO: expand
					}
				}, 3000);
				return false;
			}
			searchforcollage.onpaste = evt => inputHandler(evt, 'clipboardData');
			searchforcollage.ondrop = evt => inputHandler(evt, 'dataTransfer');
			searchforcollage.oninput = function(evt) {
				if (submitButton != null) submitButton.disabled = evt.currentTarget.value.length <= 0;
			};
			searchforcollage.onkeypress = function(evt) {
				if (evt.key == 'Enter' && evt.currentTarget.value.length > 0) SearchCollage();
			};
		} else addQuickAddForm();

		if ('collages' in window.sessionStorage) try { collages = JSON.parse(window.sessionStorage.getItem('collages')) }
			catch(e) { console.warn(e) }
		if (!collages) collages = { };
		if (!(document.domain in collages)) collages[document.domain] = { };

		function callback(evt) {
			switch (evt.currentTarget.nodeName) {
				case 'A':
					if (evt.button != 0 || !evt.altKey) return true;
					var link = evt.currentTarget;
					break;
				case 'MENUITEM':
					link = menu || evt.relatedTarget || document.activeElement;
					break;
			}
			if (!(link instanceof HTMLAnchorElement)) return true;
			let collageId = parseInt(new URLSearchParams(link.search).get('id'));
			if (!collageId) {
				console.warn('Assertion failed: no collage id', link);
				throw 'no id';
			}
			return removeFromTorrentCollage(collageId, groupId,
					'Are you sure to remove this group from collage "' + link.textContent.trim() + '"?').then(function(status) {
				const tr = link.parentNode.parentNode, table = tr.parentNode.parentNode;
				tr.remove();
				if (table.querySelectorAll('tbody > tr:not([class="colhead"])').length <= 0) table.remove();
			});
		}
		menu.children[0].onclick = callback;
		let subscribeCmd = document.createElement('menuitem');
		subscribeCmd.label = 'Subscribe to this collage - toggle (!)';
		subscribeCmd.title = 'Use with care - toggling command; on already subscribed collages performs unsubscribe';
		subscribeCmd.onclick = subscribeCallback;
		menu.insertBefore(subscribeCmd, menu.children[1]);
		document.body.append(menu);

		document.querySelectorAll('table[id$="collages"] > tbody > tr > td > a').forEach(function(link) {
			if (!link.pathname.startsWith('/collages.php') || !/\b(?:id)=(\d+)\b/.test(link.search)) return;
			let collageId = parseInt(RegExp.$1), toggle, navLinks = [ ],
					numberColumn = link.parentNode.parentNode.querySelector('td.number_column');
			link.onclick = callback;
			link.oncontextmenu = contextUpdater;
			link.setAttribute('contextmenu', contextId);
			link.title = 'Use Alt + left click or context menu(FF) to remove from this collage';

			if (numberColumn != null) {
				numberColumn.style.cursor = 'pointer';
				numberColumn.onclick = loadCollage;
				numberColumn.title = collages[document.domain][collageId] ? 'Refresh' : 'Load collage for direct browsing';
			}
			if (collages[document.domain][collageId]) {
				expandSection();
				addCollageLinks(collages[document.domain][collageId]);
			}

			function addCollageLinks(collage) {
				var index = collage.torrentgroups.findIndex(torrentgroup => parseInt(torrentgroup.id) == groupId);
				if (index < 0) {
					console.warn('Assertion failed: torrent', groupId, 'not found in the collage', collage);
					return false;
				}
				link.style.color = 'white';
				link.parentNode.parentNode.style = 'color:white; background-color: darkgoldenrod;';
				var stats = document.createElement('span');
				stats.textContent = (index + 1) + '/' + collage.torrentgroups.length;
				stats.style = 'font-size: 8pt; color: antiquewhite; font-weight: 100; margin-left: 10px;';
				navLinks.push(stats);
				link.parentNode.append(stats);
				if (collage.torrentgroups[index - 1]) {
					var a = document.createElement('a');
					a.href = '/torrents.php?id=' + collage.torrentgroups[index - 1].id;
					//a.classList.add('brackets');
					a.textContent = '[\xA0<\xA0]';
					a.title = getTitle(index - 1);
					a.style = 'color: chartreuse; margin-right: 10px;';
					navLinks.push(a);
					link.parentNode.prepend(a);
					a = document.createElement('a');
					a.href = '/torrents.php?id=' + collage.torrentgroups[0].id;
					//a.classList.add('brackets');
					a.textContent = '[\xA0<<\xA0]';
					a.title = getTitle(0);
					a.style = 'color: chartreuse; margin-right: 5px;';
					navLinks.push(a);
					link.parentNode.prepend(a);
				}
				if (collage.torrentgroups[index + 1]) {
					a = document.createElement('a');
					a.href = '/torrents.php?id=' + collage.torrentgroups[index + 1].id;
					//a.classList.add('brackets');
					a.textContent = '[\xA0>\xA0]';
					a.title = getTitle(index + 1);
					a.style = 'color: chartreuse; margin-left: 10px;';
					navLinks.push(a);
					link.parentNode.append(a);
					a = document.createElement('a');
					a.href = '/torrents.php?id=' + collage.torrentgroups[collage.torrentgroups.length - 1].id;
					//a.classList.add('brackets');
					a.textContent = '[\xA0>>\xA0]';
					a.title = getTitle(collage.torrentgroups.length - 1);
					a.style = 'color: chartreuse; margin-left: 5px;';
					navLinks.push(a);
					link.parentNode.append(a);
				}
				return true;

				function getTitle(index) {
					if (typeof index != 'number' || index < 0 || index >= collage.torrentgroups.length) return undefined;
					let title = collage.torrentgroups[index].musicInfo && Array.isArray(collage.torrentgroups[index].musicInfo.artists) ?
						collage.torrentgroups[index].musicInfo.artists.map(artist => artist.name).join(', ') + ' - ' : '';
					if (collage.torrentgroups[index].name) title += collage.torrentgroups[index].name;
					if (collage.torrentgroups[index].year) title += ' (' + collage.torrentgroups[index].year + ')';
					return title;
				}
			}

			function expandSection() {
				if (toggle === undefined) toggle = link.parentNode.parentNode.parentNode.querySelector('td > a[href="#"][onclick]');
				if (toggle === null || toggle.dataset.expanded) return false;
				toggle.dataset.expanded = true;
				toggle.click();
				return true;
			}

			function loadCollage(evt) {
				evt.currentTarget.disabled = true;
				navLinks.forEach(a => { a.remove() });
				navLinks = [];
				let span = document.createElement('span');
				span.textContent = '[\xA0loading...\xA0]';
				span.style = 'color: red; background-color: white; margin-left: 10px;';
				link.parentNode.append(span);
				queryAjaxAPI('collage', { id: collageId, showonlygroups: 1 }).then(function(collage) {
					span.remove();
					cacheCollage(collage);
					addCollageLinks(collage);
					evt.currentTarget.disabled = false;
				}, function(reason) {
					span.remove();
					evt.currentTarget.disabled = false;
				});
				return false;
			}
		});

		// GM_registerMenuCommand('Add to "Broken covers... collage',
		// 	() => { addToTorrentCollage(31445, groupId).catch(alert) });
		break;
	}
	case '/artist.php': {
		function cacheCollage(collage) {
			collages[document.domain][collage.id] = {
				id: collage.id,
				name: collage.name,
				artists: collage.artists.map(artist => ({
					id: artist.id,
					name: artist.name,
				})),
			};
			window.sessionStorage.setItem('collages', JSON.stringify(collages));
		}

		if (!((artistId = parseInt(urlParams.get('id'))) > 0)) break; // Unexpected URL format
		addQuickAddForm();
		if ('collages' in window.sessionStorage) try { collages = JSON.parse(window.sessionStorage.getItem('collages')) }
			catch(e) { console.warn(e) }
		if (!collages) collages = { };
		if (!(document.domain in collages)) collages[document.domain] = { };

		function callback(evt) {
			switch (evt.currentTarget.nodeName) {
				case 'A':
					if (evt.button != 0 || !evt.altKey) return true;
					var link = evt.currentTarget;
					break;
				case 'MENUITEM':
					link = menu || evt.relatedTarget || document.activeElement;
					break;
			}
			if (!(link instanceof HTMLAnchorElement)) return true;
			let collageId = parseInt(new URLSearchParams(link.search).get('id'));
			if (!collageId) {
				console.warn('Assertion failed: no collage id', link);
				throw 'no id';
			}
			return removeFromArtistCollage(collageId, artistId,
					'Are you sure to remove this artist from collage "' + link.textContent.trim() + '"?').then(function(status) {
				const tr = link.parentNode.parentNode, table = tr.parentNode.parentNode;
				tr.remove();
				if (table.querySelectorAll('tbody > tr:not([class="colhead"])').length <= 0) table.remove();
			});
		}
		menu.children[0].onclick = callback;
		let subscribeCmd = document.createElement('menuitem');
		subscribeCmd.label = 'Subscribe to this collage - toggle (!)';
		subscribeCmd.title = 'Use with care - toggling command; on already subscribed collages performs unsubscribe';
		subscribeCmd.onclick = subscribeCallback;
		menu.insertBefore(subscribeCmd, menu.children[1]);
		document.body.append(menu);

		document.querySelectorAll('table[id$="collages"] > tbody > tr > td > a').forEach(function(link) {
			if (!link.pathname.startsWith('/collages.php') || !/\b(?:id)=(\d+)\b/.test(link.search)) return;
			let collageId = parseInt(RegExp.$1), toggle, navLinks = [],
					numberColumn = link.parentNode.parentNode.querySelector('td:last-of-type');
			link.onclick = callback;
			link.oncontextmenu = contextUpdater;
			link.setAttribute('contextmenu', contextId);
			link.title = 'Use Alt + left click or context menu(FF) to remove from this collage';

			if (numberColumn != null) {
				numberColumn.style.cursor = 'pointer';
				numberColumn.onclick = loadCollage;
				numberColumn.title = collages[document.domain][collageId] ? 'Refresh' : 'Load collage for direct browsing';
			}
			if (collages[document.domain][collageId]) {
				expandSection();
				addCollageLinks(collages[document.domain][collageId]);
			}

			function addCollageLinks(collage) {
				var index = collage.artists.findIndex(artist => artist.id == artistId);
				if (index < 0) {
					console.warn('Assertion failed: torrent', groupId, 'not found in the collage', collage);
					return false;
				}
				link.style.color = 'white';
				link.parentNode.parentNode.style = 'color:white; background-color: darkgoldenrod;';
				var stats = document.createElement('span');
				stats.textContent = `${index + 1} / ${collage.artists.length}`;
				stats.style = 'font-size: 8pt; color: antiquewhite; font-weight: 100; margin-left: 10px;';
				navLinks.push(stats);
				link.parentNode.append(stats);
				if (collage.artists[index - 1]) {
					var a = document.createElement('a');
					a.href = '/artist.php?id=' + collage.artists[index - 1].id;
					a.textContent = '[\xA0<\xA0]';
					a.title = getTitle(index - 1);
					a.style = 'color: chartreuse; margin-right: 10px;';
					navLinks.push(a);
					link.parentNode.prepend(a);
					a = document.createElement('a');
					a.href = '/artist.php?id=' + collage.artists[0].id;
					a.textContent = '[\xA0<<\xA0]';
					a.title = getTitle(0);
					a.style = 'color: chartreuse; margin-right: 5px;';
					navLinks.push(a);
					link.parentNode.prepend(a);
				}
				if (collage.artists[index + 1]) {
					a = document.createElement('a');
					a.href = '/artist.php?id=' + collage.artists[index + 1].id;
					a.textContent = '[\xA0>\xA0]';
					a.title = getTitle(index + 1);
					a.style = 'color: chartreuse; margin-left: 10px;';
					navLinks.push(a);
					link.parentNode.append(a);
					a = document.createElement('a');
					a.href = '/artist.php?id=' + collage.artists[collage.artists.length - 1].id;
					a.textContent = '[\xA0>>\xA0]';
					a.title = getTitle(collage.artists.length - 1);
					a.style = 'color: chartreuse; margin-left: 5px;';
					navLinks.push(a);
					link.parentNode.append(a);
				}
				return true;

				function getTitle(index) {
					console.assert(index >= 0 && index < collage.artists.length, "index >= 0 && index < collage.artists.length");
					if (!(index >= 0 && index < collage.artists.length)) return undefined;
					return collage.artists[index] ? collage.artists[index].name : '';
				}
			}

			function expandSection() {
				if (toggle === undefined) toggle = link.parentNode.parentNode.parentNode.querySelector('td > a[href="#"][onclick]');
				if (toggle === null || toggle.dataset.expanded) return false;
				toggle.dataset.expanded = true;
				toggle.click();
				return true;
			}

			function loadCollage(evt) {
				evt.currentTarget.disabled = true;
				navLinks.forEach(a => { a.remove() });
				navLinks = [ ];
				let span = document.createElement('span');
				span.textContent = '[\xA0loading...\xA0]';
				span.style = 'color: red; background-color: white; margin-left: 10px;';
				link.parentNode.append(span);
				queryAjaxAPI('collage', { id: collageId, showonlygroups: 1 }).then(function(collage) {
					span.remove();
					cacheCollage(collage);
					addCollageLinks(collage);
					evt.currentTarget.disabled = false;
				}, function(reason) {
					span.remove();
					evt.currentTarget.disabled = false;
				});
				return false;
			}
		});
		break;
	}
	case '/collages.php':
	case '/collage.php': {
		function addNotifier(caption, timeout = 20 * 1000) {
			if (!caption) return;
			const notifier = document.createElement('DIV');
			notifier.style = 'position: fixed; z-index: 999; top: 20pt; right: 20pt; padding: 10pt; border: 2pt solid gray; background-color: darkslategrey; color: gold; font: 600 10pt "Segoe UI", sans-serif; cursor: pointer;';
			notifier.textContent = caption;
			notifier.onclick = function(evt) {
				const ststsBox = document.body.querySelector('div.sidebar > div.box_info');
				if (ststsBox != null) ststsBox.scrollIntoView({ behavior: 'smooth', block: 'start' });
			};
			document.body.append(notifier);
			if (timeout > 0) setTimeout(function(elem) {
				if (timeout >= 4000) {
					elem.style.transition = 'opacity 2s';
					elem.style.opacity = 0;
				}
				setTimeout(elem => { document.body.removeChild(elem) }, Math.min(timeout, 2000), elem);
			}, Math.max(timeout - 2000, 0), notifier);
		}
		function updateGalleryPager(li) {
		}

		if (!((collageId = parseInt(urlParams.get('id'))) > 0)) break; // Collage id missing
		let collageSize = document.body.querySelector('div.box_info > ul.stats > li:first-of-type');
		if (collageSize != null && (collageSize = /\b(\d+(?:[\,]\d+)*)\b/.exec(collageSize.textContent)) != null)
			collageSize = parseInt(collageSize[1].replace(/\D/g, ''));
		let category = document.querySelector('div.box_category > div.pad > a'), selectors, callback;
		category = category != null ? category.textContent : undefined;
		console.assert(category, 'category != undefined');
		let watchDogs = GM_getValue('watched_collages');
		if (!watchDogs || typeof watchDogs != 'object') watchDogs = { };
		if (!(document.domain in watchDogs)) watchDogs[document.domain] = { };
		const getCollageItemIds = () => queryAjaxAPI('collage', { id: collageId, showonlygroups: 1 }).then(collage =>
			collage[collage.collageCategoryID == 7 ? 'artists' : 'torrentgroups'].map(item => parseInt(item.id)));
		const saveSnapshot = () => getCollageItemIds().then(function(ids) {
			watchDogs[document.domain][collageId] = ids;
			GM_setValue('watched_collages', watchDogs);
			return watchDogs[document.domain][collageId].length;
		});
		if (collageId in watchDogs[document.domain]) {
			if (collageSize > 0 && collageSize != watchDogs[document.domain][collageId].length
					|| !GM_getValue('savvy_change_detection', true)) getCollageItemIds().then(function(ids) {
				function addDelta(ids, caption) {
					const li = document.createElement('LI'),
								spans = ['SPAN', 'SPAN', 'SPAN'].map(Document.prototype.createElement.bind(document));
					spans[0].style = 'color: red; font-weight: 900;';
					spans[0].textContent = ids.length;
					spans[1].style = 'color: cadetblue; cursor: pointer;';
					spans[1].textContent = 'view';
					spans[1].onclick = function(evt) {
						for (let id of ids) openTabLimited(category == 'Artists' ? 'artist' : 'torrents', { id: id }, 'content');
					};
					spans[2].style = 'color: cadetblue; cursor: pointer;';
					spans[2].textContent = 'copy';
					spans[2].onclick = evt => { GM_setClipboard(ids.map(function(id) {
						if (evt.ctrlKey) {
							const url = new URL(`${category == 'Artists' ? 'artist' : 'torrents'}.php`, document.location.origin);
							url.searchParams.set('id', id);
							return url.href;
						}
						return id.toString();
					}).join('\n') + '\n', 'text') };
					li.append(spans[0], ` ${category == 'Artists' ? 'artist' : 'group'}(s) ${caption} (`);
					li.append(spans[1], ' | ', spans[2], ')');
					count.append(li);
				}

				const count = document.body.querySelector('div.box_info > ul.stats > li:first-of-type');
				const addedIds = ids.filter(id => !watchDogs[document.domain][collageId].includes(id));
				if (addedIds.length > 0) addDelta(addedIds, 'added');
				const removedIds = watchDogs[document.domain][collageId].filter(id => !ids.includes(id));
				if (removedIds.length > 0) addDelta(removedIds, 'removed');
				if (addedIds.length <= 0 && removedIds.length <= 0) return;
				saveSnapshot();
				addNotifier('This collage has changed since last visit');
			});
		}
		const linkBox = document.body.querySelector('div#content div.header > div.linkbox');
		if (linkBox != null) {
			const a = document.createElement('A');
			a.className = 'brackets';
			a.href = '#';
			a.textContent = (a.watched = collageId in watchDogs[document.domain]) ? 'Unwatch' : 'Watch';
			a.title = 'Watched collages will highlight additions/removals since previous view on each load time';
			a.onclick = function toggleWatchState(evt) {
				if (!evt.currentTarget.disabled) evt.currentTarget.disabled = true; else return false;
				const currentTarget = evt.currentTarget;
				if (currentTarget.watched) {
					if (collageId in watchDogs[document.domain]) {
						delete watchDogs[document.domain][collageId];
						GM_setValue('watched_collages', watchDogs);
					}
					currentTarget.watched = false;
					currentTarget.textContent = 'Watch';
					currentTarget.disabled = false;
				} else saveSnapshot().then(function(collageSize) {
					currentTarget.watched = true;
					currentTarget.textContent = 'Unwatch';
					currentTarget.disabled = false;
				});
				return false;
			};
			linkBox.append(' ', a);
		}

		if (category != 'Artists') {
			selectors = [
				'tr.group > td[colspan] > strong > a[href^="torrents.php?id="]',
				'ul.collage_images > li > a[href^="torrents.php?id="]',
			];
			callback = function(evt) {
				switch (evt.currentTarget.nodeName) {
					case 'A':
						if (evt.button != 0 || !evt.altKey) return true;
						var link = evt.currentTarget;
						break;
					case 'MENUITEM':
						link = menu || evt.relatedTarget || document.activeElement;
						break;
				}
				if (!(link instanceof HTMLAnchorElement)) return true;
				let groupId = parseInt(new URLSearchParams(link.search).get('id'));
				if (!groupId) {
					console.warn('Assertion failed: no id', link);
					throw 'no id';
				}
				removeFromTorrentCollage(collageId, groupId, 'Are you sure to remove selected group from this collage?').then(function(status) {
					document.querySelectorAll(selectors.join(', ')).forEach(function(a) {
						if (parseInt(new URLSearchParams(a.search).get('id')) == groupId) switch (a.parentNode.nodeName) {
							case 'STRONG': a.parentNode.parentNode.parentNode.remove(); break;
							case 'LI': a.parentNode.remove(); break;
						}
					});
				});
			};
		} else {
			selectors = [
				'table#discog_table > tbody > tr > td > a[href^="artist.php?id="]',
				'ul.collage_images > li > a[href^="artist.php?id="]',
			];
			callback = function(evt) {
				switch (evt.currentTarget.nodeName) {
					case 'A':
						if (evt.button != 0 || !evt.altKey) return true;
						var link = evt.currentTarget;
						break;
					case 'MENUITEM':
						link = menu || evt.relatedTarget || document.activeElement;
						break;
				}
				if (!(link instanceof HTMLAnchorElement)) return true;
				let artistId = parseInt(new URLSearchParams(link.search).get('id'));
				if (!artistId) {
					console.warn('Assertion failed: no id', evt.currentTarget);
					throw 'no id';
				}
				removeFromArtistCollage(collageId, artistId, 'Are you sure to remove selected artist from this collage?').then(function(status) {
					document.querySelectorAll(selectors.join(', ')).forEach(function(a) {
						if (parseInt(new URLSearchParams(a.search).get('id')) == artistId) switch (a.parentNode.nodeName) {
							case 'TD': a.parentNode.parentNode.remove(); break;
							case 'LI': a.parentNode.remove(); break;
						}
					});
				});
			};
			let artistLink = document.querySelector('form.add_form[name="artist"] input#artist');
			if (artistLink != null) {
				let ref = document.querySelector('form.add_form[name="artist"] > div.submit_div');
				let searchBtn = document.createElement('input');
				searchBtn.value = 'Look up';
				searchBtn.type = 'button';
				searchBtn.onclick = function(evt) {
					let xhr = new XMLHttpRequest;
					xhr.open('HEAD', '/artist.php?artistname=' + encodeURIComponent(artistLink.value.trim()), true);
					xhr.onreadystatechange = function() {
						if (xhr.readyState < XMLHttpRequest.HEADERS_RECEIVED) return;
						artistLink.value = xhr.responseURL.includes('/artist.php?id=') ? xhr.responseURL : '';
					};
					xhr.send();
				};
				ref.append(searchBtn);
			}
		}
		menu.children[0].onclick = callback;
		document.body.append(menu);
		function handlerInstaller(a) {
			a.onclick = callback;
			a.oncontextmenu = contextUpdater;
			a.setAttribute('contextmenu', contextId);
		}
		document.querySelectorAll(selectors.join(', ')).forEach(handlerInstaller);
		let coverart = document.getElementById('coverart');
		if (coverart != null) new MutationObserver(function(ml, mo) {
			for (let mutation of ml) for (let node of mutation.addedNodes) {
				if (node.nodeName != 'UL' || !node.classList.contains('collage_images')) return;
				node.querySelectorAll(':scope > li > a').forEach(handlerInstaller);
				const linkBox = document.body.querySelectorAll('div.main_column > div.linkbox.pager');
				if (linkBox != null && GM_getValue('rearrange_page_control', false)) updateGalleryPager(linkBox);
			}
		}).observe(coverart, { childList: true });
		if (!GM_getValue('rearrange_page_control', false)) break;
		for (let linkBox of document.body.querySelectorAll('div.main_column > div.linkbox')) {
			const page = parseInt(urlParams.get('page')) || 1, numPages = Math.ceil(collageSize / 50);
			if (numPages > 1) if (linkBox.classList.contains('pager')) updateGalleryPager(linkBox); else {
				const strong = linkBox.querySelector(':scope > strong');
				console.assert(strong != null);
				if (strong == null) continue;
				let pager = linkBox.querySelector('a.pager_prev');
				if (pager != null) {
					const divisor = pager.nextSibling;
					divisor.remove();
					strong.insertAdjacentElement('beforebegin', pager);
					strong.insertAdjacentText('beforebegin', divisor.wholeText);
					if ((pager = linkBox.querySelector('a:first-of-type')) != null && pager.textContent.includes('<< First'))
						pager.insertAdjacentText('afterend', divisor.wholeText);
				}
				if ((pager = linkBox.querySelector('a.pager_next')) != null) {
					const divisor = pager.previousSibling;
					divisor.remove();
					strong.insertAdjacentElement('afterend', pager);
					strong.insertAdjacentText('afterend', divisor.wholeText);
					if ((pager = linkBox.querySelector('a:last-of-type')) != null && pager.textContent.includes('Last >>'))
						pager.insertAdjacentText('beforebegin', divisor.wholeText);
				}
				const a = Array.prototype.filter.call(linkBox.querySelectorAll(':scope > a:not([class])'),
					a => /\b(\d+(?:[\,]\d+)*)\s*-\s*(\d+(?:[\,]\d+)*)\b/.test(a.textContent));
				let pageLinks;
				if ((pageLinks = a.filter(a => parseInt(new URLSearchParams(a.search).get('page')) < page)).length > 0) {
					const step = (page - 2) / (pageLinks.length + 1);
					pageLinks.forEach(function(pageLink, index) {
						const a = new URL(pageLink), p = 1 + Math.round((index + 1) * step);
						a.searchParams.set('page', p);
						pageLink.setAttribute('href', a.pathname + a.search);
						pageLink.firstElementChild.textContent = `${p * 50 - 49}-${p * 50}`;
					});
				}
				if ((pageLinks = a.filter(a => parseInt(new URLSearchParams(a.search).get('page')) > page)).length > 0) {
					const step = (numPages - 1 - page) / (pageLinks.length + 1);
					pageLinks.forEach(function(pageLink, index) {
						const a = new URL(pageLink), p = page + 1 + Math.round((index + 1) * step);
						a.searchParams.set('page', p);
						pageLink.setAttribute('href', a.pathname + a.search);
						pageLink.firstElementChild.textContent = `${p * 50 - 49}-${p * 50}`;
					});
				}
			}
		}
		break;
	}
}