Torrent Collage Extensions for Gazelle

Click on collage size = browse through this collage; Alt + click on collage name = remove from this collage

当前为 2020-12-15 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Torrent Collage Extensions for Gazelle
// @version      1.20
// @description  Click on collage size = browse through this collage; Alt + click on collage name = remove from this collage
// @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://*/torrents.php?id=*
// @match        https://*/collages.php?*id=*
// @match        https://*/artist.php?*id=*
// @grant        GM_getValue
// @grant        GM_setValue
// ==/UserScript==

'use strict';

var auth = document.querySelector('input[name="auth"][value]');
if (auth != null) auth = auth.value; else {
	auth = document.querySelector('li#nav_logout > a');
	if (auth != null && /\b(?:auth)=(\w+)\b/.test(auth.search)) auth = RegExp.$1; else throw 'Auth not found';
}
let userId = document.querySelector('li#nav_userinfo > a.username');
if (userId != null) {
	userId = new URLSearchParams(userId.search);
	userId = parseInt(userId.get('id'));
}

const siteApiTimeframeStorageKey = document.location.hostname + ' API time frame';
const gazelleApiFrame = 10500;
if (typeof GM_getValue == 'function') var redacted_api_key = GM_getValue('redacted_api_key');

function queryAjaxAPI(action, params) {
	if (!action) return Promise.reject('Action missing');
	let retryCount = 0;
	return new Promise(function(resolve, reject) {
		params = new URLSearchParams(params || undefined);
		params.set('action', action);
		let url = '/ajax.php?' + params, xhr = new XMLHttpRequest;
		queryInternal();

		function queryInternal() {
			let now = Date.now();
			try { var apiTimeFrame = JSON.parse(window.localStorage[siteApiTimeframeStorageKey]) } catch(e) { apiTimeFrame = {} }
			if (!apiTimeFrame.timeStamp || now > apiTimeFrame.timeStamp + gazelleApiFrame) {
				apiTimeFrame.timeStamp = now;
				apiTimeFrame.requestCounter = 1;
			} else ++apiTimeFrame.requestCounter;
			window.localStorage[siteApiTimeframeStorageKey] = JSON.stringify(apiTimeFrame);
			if (apiTimeFrame.requestCounter <= 5) {
				xhr.open('GET', url, true);
				xhr.setRequestHeader('Accept', 'application/json');
				if (redacted_api_key) xhr.setRequestHeader('Authorization', redacted_api_key);
				xhr.responseType = 'json';
				//xhr.timeout = 5 * 60 * 1000;
				xhr.onload = function() {
					if (xhr.status == 404) return reject('not found');
					if (xhr.status < 200 || xhr.status >= 400) return reject(defaultErrorHandler(xhr));
					if (xhr.response.status == 'success') return resolve(xhr.response.response);
					if (xhr.response.error == 'not found') return reject(xhr.response.error);
					console.warn('queryAjaxAPI.queryInternal(...) response:', xhr, xhr.response);
					if (xhr.response.error == 'rate limit exceeded') {
						console.warn('queryAjaxAPI.queryInternal(...) ' + xhr.response.error + ':', apiTimeFrame, now, retryCount);
						if (retryCount++ <= 10) return setTimeout(queryInternal, apiTimeFrame.timeStamp + gazelleApiFrame - now);
					}
					reject('API ' + xhr.response.status + ': ' + xhr.response.error);
				};
				xhr.onerror = function() { reject(defaultErrorHandler(xhr)) };
				xhr.ontimeout = function() { reject(defaultTimeoutHandler(xhr)) };
				xhr.send();
			} else {
				setTimeout(queryInternal, apiTimeFrame.timeStamp + gazelleApiFrame - now);
				console.debug('AJAX API request quota exceeded: /ajax.php?action=' +
					action + ' (' + apiTimeFrame.requestCounter + ')');
			}
		}
	});
}

function addToTorrentCollage(collageId, torrentGroupId) {
	return collageId ? torrentGroupId ? queryAjaxAPI('collage', { id: collageId }).then(
		collage => !collage.torrentGroupIDList.map(groupId => parseInt(groupId)).includes(torrentGroupId) ? collageId
			: Promise.reject('already in collage')
	).then(collageId => new Promise(function(resolve, reject) {
		let xhr = new XMLHttpRequest, formData = new URLSearchParams({
			action: 'add_torrent',
			collageid: collageId,
			groupid: torrentGroupId,
			url: document.location.origin.concat('/torrents.php?id=', torrentGroupId),
			auth: auth,
		});
		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(collageId); else reject(defaultErrorHandler(xhr));
			xhr.abort();
		};
		xhr.onerror = function() { reject(defaultErrorHandler(xhr)) };
		xhr.ontimeout = function() { reject(defaultTimeoutHandler(xhr)) };
		xhr.send(formData);
	})).then(collageId => queryAjaxAPI('collage', { id: collageId }).then(
		collage => collage.torrentGroupIDList.map(groupId => parseInt(groupId)).includes(torrentGroupId) ? collage
			: Promise.reject('Error: not added for unknown reason')
	)) : Promise.reject('torrent group id not defined') : Promise.reject('collage id not defined');
}

function removeFromTorrentCollage(collageId, torrentGroupId, 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: torrentGroupId,
			auth: auth,
			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) {
	return collageId ? artistId ? queryAjaxAPI('collage', { id: collageId }).then(
		collage => !collage.artists.map(artistId => parseInt(artistId)).includes(artistId) ? collageId
			: Promise.reject('already in collage')
	).then(collageId => new Promise(function(resolve, reject) {
		let xhr = new XMLHttpRequest, formData = new URLSearchParams({
			action: 'add_artist',
			collageid: collageId,
			artistid: artistId,
			url: document.location.origin.concat('/artist.php?id=', artistId),
			auth: auth,
		});
		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(collageId); else reject(defaultErrorHandler(xhr));
			xhr.abort();
		};
		xhr.onerror = function() { reject(defaultErrorHandler(xhr)) };
		xhr.ontimeout = function() { reject(defaultTimeoutHandler(xhr)) };
		xhr.send(formData);
	})).then(collageId => queryAjaxAPI('collage', { id: collageId }).then(
		collage => collage.artists.map(artistId => parseInt(artistId)).includes(artistId) ? collage
			: Promise.reject('Error: not added for unknown reason')
	)) : Promise.reject('artist id not defined') : Promise.reject('collage id not defined');
}

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: auth,
			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 defaultErrorHandler(response) {
	console.error('HTTP error:', response);
	let e = 'HTTP error ' + response.status;
	if (response.statusText) e += ' (' + response.statusText + ')';
	if (response.error) e += ' (' + response.error + ')';
	return e;
}

function defaultTimeoutHandler(response) {
	console.error('HTTP timeout:', response);
	const e = 'HTTP timeout';
	return e;
}

function addQuickAddForm() {
	const timeout = 30000; // some collages take to load long
	const initTimeCap = 5000; // max time in ms to preload the dropdown

	if (!userId || !torrentGroupId && !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 ' +
		(torrentGroupId ? '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.timeout = timeout;
			xhr.send();
		}
	}).then(function(categories) {
		const artistsIndex = categories.indexOf('Artists');
		if (artistsIndex < 0) throw 'Artists index not found';
		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">
	<input id="searchforcollagebutton" value="Search" type="submit">
</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');
		srchForm.onsubmit = searchSubmit;
		searchText.ondrop = evt => dataHandler(evt.target, evt.dataTransfer);
		searchText.onpaste = evt => dataHandler(evt.target, evt.clipboardData);
		if (doOpen != null) doOpen.onclick = openCollage;
		doAdd.onclick = addToCollage;
		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 (elem.category < categories.length
									&& (torrentGroupId && elem.category != artistsIndex || artistId && elem.category == artistsIndex)
									&& (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.timeout = timeout;
					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, ndx) => 'cats[' + ndx + ']')
				.filter((category, ndx) => torrentGroupId && ndx != artistsIndex || artistId && ndx == artistsIndex)
				.forEach(param => { query[param] = 1 });
			findCollages(query);
			return false;
		}

		function addToCollage(evt) {
			(function() {
				evt.target.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 (torrentGroupId) return addToTorrentCollage(collageId, torrentGroupId);
				if (artistId) 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.target.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 ' + (collage.collageCategoryID != artistsIndex ? 'album' : 'artist') + ' 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.collageCategoryID != artistsIndex ? collage.torrentgroups : collage.artists).length + '</td>';
				tbody.append(elem);
				return collage;
			}).catch(function(reason) {
				evt.target.disabled = false;
				if (ajaxMessage == null) return;
				ajaxMessage.innerHTML = '<span style="color: #A00;">' + reason.toString() + '</span>';
				ajaxMessage.classList.remove('hidden');
			});
		}

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

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

switch (document.location.pathname) {
	case '/torrents.php': {
		var torrentGroupId = new URLSearchParams(document.location.search).get('id'), collages;
		if (torrentGroupId) torrentGroupId = parseInt(torrentGroupId); else break; // Unexpected URL format
		const searchforcollage = document.getElementById('searchforcollage');
		if (searchforcollage != null) {
			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>
		`);
					});
				});
			};

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

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

		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 = function(evt) {
				return evt.button == 0 && evt.altKey ? removeFromTorrentCollage(collageId, torrentGroupId,
						'Are you sure to remove this group from collage "' + link.textContent.trim() + '"?')
					.then(status => { link.parentNode.parentNode.remove() }) : true;
			};
			link.title = 'Use Alt + left click 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(group => group.id == torrentGroupId);
				if (index < 0) {
					console.warn('Assertion failed: torrent', torrentGroupId, '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.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.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.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.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.target.disabled = true;
				navLinks.forEach(a => { a.remove() });
				navLinks = [];
				var 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 }).then(function(collage) {
					span.remove();
					cacheCollage(collage);
					addCollageLinks(collage);
					evt.target.disabled = false;
				}, function(reason) {
					span.remove();
					evt.target.disabled = false;
				});
				return false;
			}
		});

		function cacheCollage(collage) {
			collages[document.domain][collage.id] = {
				id: collage.id,
				name: collage.name,
				torrentgroups: collage.torrentgroups.map(group => ({
					id: group.id,
					musicInfo: group.musicInfo ? {
						artists: Array.isArray(group.musicInfo.artists) ?
						group.musicInfo.artists.map(artist => ({ name: artist.name })) : undefined,
					} : undefined,
					name: group.name,
					year: group.year,
				})),
			};
			window.sessionStorage.collages = JSON.stringify(collages);
		}

		break;
	}
	case '/artist.php': {
		var artistId = new URLSearchParams(document.location.search).get('id');
		if (artistId) artistId = parseInt(artistId); else break; // Unexpected URL format
		addQuickAddForm();
		break;
	}
	case '/collages.php': {
		var collageId = new URLSearchParams(document.location.search).get('id');
		if (collageId) collageId = parseInt(collageId); else break; // Collage id missing
		let category = document.querySelector('div.box_category > div.pad > a');
		category = category != null ? category.textContent : undefined;

		function scanPage() {
			if (category != 'Artists') {
				var sel = [
					'tr.group > td[colspan] > strong > a[href^="torrents.php?id="]',
					'ul.collage_images > li > a[href^="torrents.php?id="]',
				]
				document.querySelectorAll(sel.join(', ')).forEach(function(a) {
					a.onclick = function(evt) {
						if (evt.button != 0 || !evt.altKey) return true;
						let torrentGroupId = new URLSearchParams(a.search);
						torrentGroupId = torrentGroupId.get('id');
						if (torrentGroupId) torrentGroupId = parseInt(torrentGroupId); else {
							console.warn('Assertion failed: no id', a);
							throw 'no id';
						}
						removeFromTorrentCollage(collageId, torrentGroupId, 'Are you sure to remove selected group from this collage?').then(function(status) {
							document.querySelectorAll(sel.join(', ')).forEach(function(a) {
								if (parseInt(new URLSearchParams(a.search).get('id')) == torrentGroupId) switch (a.parentNode.nodeName) {
									case 'STRONG': a.parentNode.parentNode.parentNode.remove(); break;
									case 'LI': a.parentNode.remove(); break;
								}
							});
						});
					};
				});
			} else {
				sel = [
					'table#discog_table > tbody > tr > td > a[href^="artist.php?id="]',
					'ul.collage_images > li > a[href^="artist.php?id="]',
				];
				document.querySelectorAll(sel.join(', ')).forEach(function(a) {
					a.onclick = function(evt) {
						if (evt.button != 0 || !evt.altKey) return true;
						let artistId = new URLSearchParams(a.search);
						artistId = artistId.get('id');
						if (artistId) artistId = parseInt(artistId); else {
							console.warn('Assertion failed: no id', a);
							throw 'no id';
						}
						removeFromArtistCollage(collageId, artistId, 'Are you sure to remove selected artist from this collage?').then(function(status) {
							document.querySelectorAll(sel.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;
								}
							});
						});
					};
				});
			}
		}

		scanPage();
		document.querySelectorAll('div#pageslinksdiv > span > a.pageslink')
			.forEach(a => { a.addEventListener('click', evt => { setTimeout(scanPage, 1000) }) });
		break;
	}
}