[RED] Import release details from Bandcamp

Lets find music release on Bandcamp and import release about, personnel credits, cover image and tags.

目前為 2022-11-07 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         [RED] Import release details from Bandcamp
// @namespace    https://greasyfork.org/users/321857-anakunda
// @version      0.25.0
// @match        https://redacted.ch/upload.php
// @match        https://redacted.ch/upload.php&url=*
// @match        https://redacted.ch/requests.php?action=new
// @match        https://redacted.ch/requests.php?action=new&groupid=*
// @match        https://redacted.ch/requests.php?action=new&url=*
// @match        https://redacted.ch/torrents.php?id=*
// @match        https://redacted.ch/torrents.php?page=*&id=*
// @match        https://orpheus.network/upload.php
// @match        https://orpheus.network/upload.php?url=*
// @match        https://orpheus.network/requests.php?action=new
// @match        https://orpheus.network/requests.php?action=new&groupid=*
// @match        https://orpheus.network/requests.php?action=new&url=*
// @match        https://orpheus.network/torrents.php?id=*
// @match        https://orpheus.network/torrents.php?page=*&id=*
// @run-at       document-end
// @iconURL      https://s4.bcbits.com/img/favicon/favicon-32x32.png
// @author       Anakunda
// @description  Lets find music release on Bandcamp and import release about, personnel credits, cover image and tags.
// @copyright    2022, Anakunda (https://greasyfork.org/users/321857-anakunda)
// @license      GPL-3.0-or-later
// @connect      *
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @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
// ==/UserScript==

'use strict';

const imageHostHelper = (function() {
	const input = document.head.querySelector('meta[name="ImageHostHelper"]');
	return (input != null ? Promise.resolve(input) : new Promise(function(resolve, reject) {
		const mo = new MutationObserver(function(mutationsList, mo) {
			for (let mutation of mutationsList) for (let node of mutation.addedNodes) {
				if (node.nodeName != 'META' || node.name != 'ImageHostHelper') continue;
				clearTimeout(timer); mo.disconnect();
				return resolve(node);
			}
		}), timer = setTimeout(function(mo) {
			mo.disconnect();
			reject('Timeout reached');
		}, 15000, mo);
		mo.observe(document.head, { childList: true });
	})).then(function(node) {
		console.assert(node instanceof HTMLElement);
		const propName = node.getAttribute('propertyname');
		console.assert(propName);
		return unsafeWindow[propName] || Promise.reject(`Assertion failed: '${propName}' not in unsafeWindow`);
	});
})();

const nothingFound = 'Nothing found';
const stripText = text => text ? [
	[/\r\n/gm, '\n'], [/[^\S\n]+$/gm, ''], [/\n{3,}/gm, '\n\n'],
].reduce((text, subst) => text.replace(...subst), text.trim()) : '';

function getSearchResults(torrentGroup, fullSearchResults = true, thoroughSearch = false) {
	function tryQuery(terms) {
		if (!Array.isArray(terms)) throw 'Invalid qrgument';
		if (terms.length <= 0) return Promise.reject(nothingFound);
		const url = new URL('https://bandcamp.com/search');
		url.searchParams.set('q', terms.map(term => '"' + term + '"').join(' '));
		const searchType = itemType => (function getPage(page = 1) {
			if (itemType) url.searchParams.set('item_type', itemType); else url.searchParams.delete('item_type');
			url.searchParams.set('page', page);
			return globalXHR(url).then(function({document}) {
				const results = Array.prototype.filter.call(document.body.querySelectorAll('div.search ul.result-items > li.searchresult'), function(li) {
					let searchType = li.dataset.search;
					if (searchType) try { searchType = JSON.parse(searchType).type.toLowerCase() } catch(e) { console.warn(e) }
					return !searchType || ['a', 't'].includes(searchType);
				});
				const nextLink = document.body.querySelector('div.pager > a.next');
				return nextLink != null ? getPage(page + 1, itemType).then(_results => results.concat(_results)) : results;
			});
		})().then(results => results.length > 0 ? results : Promise.reject(nothingFound));
		return searchType();
		//return searchType('a').catch(reason => torrentGroup.group.releaseType == 9 && reason == nothingFound ? searchType('t') : Promise.reject(reason));
	}

	if (!torrentGroup) return Promise.reject('Assertion failed: invalid argument (torrentGroup)');
	const artists = torrentGroup.group.releaseType != 7 && torrentGroup.group.musicInfo
		&& Array.isArray(torrentGroup.group.musicInfo.artists) && torrentGroup.group.musicInfo.artists.length > 0 ?
			torrentGroup.group.musicInfo.artists.map(artist => artist.name.trim()).slice(0, 2) : null;
	const album = [
		/\s+(?:EP|E\.\s?P\.|-\s+(?:EP|E\.\s?P\.))$/i,
		/\s+\((?:EP|E\.\s?P\.|Live)\)$/i, /\s+\[(?:EP|E\.\s?P\.|Live)\]$/i,
		/\s+\((?:feat\.|ft\.|featuring\s).+\)$/i, /\s+\[(?:feat\.|ft\.|featuring\s).+\]$/i,
	].reduce((title, rx) => title.replace(rx, ''), torrentGroup.group.name.trim());
	if (!album) return Promise.reject('Album title missing');
	const bracketStripper = /(?:\s+(?:\([^\(\)]+\)|\[[^\[\]]+\]|\{[^\{\}]+\}))+$/g;
	const searchAlbumOnly = () => tryQuery([album]).catch(reason => reason == nothingFound ? bracketStripper.test(album) ?
		tryQuery([album.replace(bracketStripper, '')]) : Promise.reject(nothingFound) : Promise.reject(reason));
	if (artists == null) return searchAlbumOnly();
	let lookupWorker = tryQuery(artists.concat(album)).catch(reason => reason == nothingFound ?
		artists.some(artist => bracketStripper.test(artist)) || bracketStripper.test(album) ?
			tryQuery(artists.map(artist => artist.replace(bracketStripper, '')).concat(album.replace(bracketStripper, '')))
				: Promise.reject(nothingFound) : Promise.reject(reason));
	if (thoroughSearch) lookupWorker = lookupWorker.catch(reason =>
		reason == nothingFound ? searchAlbumOnly() : Promise.reject(reason));
	return lookupWorker;
}

const matchingResultsCount = groupId => groupId > 0 ? new Promise(function findSharedTorrentGroup(resolve, reject) {
	const meta = document.head.querySelector(':scope > meta[name="torrentgroup"]');
	if (meta != null && 'torrentGroup' in unsafeWindow) return resolve(unsafeWindow.torrentGroup);
	const mo = new MutationObserver(function(ml, mo) {
		for (let mutation of ml) for (let node of mutation.addedNodes) if (node.tagName == 'META' && node.name == 'torrentgroup') {
			clearTimeout(timeout); mo.disconnect();
			if (unsafeWindow.torrentGroup) resolve(unsafeWindow.torrentGroup);
				else reject('Assertion failed: ' + node.name + ' not in unsafeWindow');
		}
	});
	mo.observe(document.head, { childList: true });
	const timeout = setTimeout(function(mo) {
		mo.disconnect();
		reject('torrentGroup monitor timed out');
	}, GM_getValue('tab_data_timeout', 2500), mo);
}).catch(function(reason) {
	console.log(reason);
	return queryAjaxAPICached('torrentgroup', { id: groupId }, true);
}).then(torrentGroup => getSearchResults(torrentGroup).then(searchResults => searchResults.map(function(li) {
	const searchResult = {
		itemType: li.querySelector('div.itemtype'),
		numTracks: li.querySelector('div.length'),
		releaseYear: li.querySelector('div.released'),
	};
	if (searchResult.itemType != null) searchResult.itemType = searchResult.itemType.textContent.trim().toLowerCase();
	if (searchResult.releaseYear != null
			&& (searchResult.releaseYear = /\b(\d{4})\b/.exec(searchResult.releaseYear.textContent)) != null)
		searchResult.releaseYear = parseInt(searchResult.releaseYear[1]);
	if (searchResult.itemType == 'track') searchResult.numTracks = 1;
	else if (searchResult.numTracks != null
			&& (searchResult.numTracks = /\b(\d+)\s+(?:tracks?)\b/i.exec(searchResult.numTracks.textContent)) != null)
		searchResult.numTracks = parseInt(searchResult.numTracks[1]);
	return searchResult;
})).then(searchResults => searchResults.filter(searchResult => torrentGroup.torrents.some(function(torrent) {
	if (torrent.remasterYear > 0 && searchResult.releaseYear > 0 && torrent.remasterYear != searchResult.releaseYear) return false;
	const audioFileCount = torrent.fileList ? torrent.fileList.split('|||').filter(file =>
		/^(.+\.(?:flac|mp3|m4[ab]|aac|dts(?:hd)?|truehd|ac3|ogg|opus|wv|ape))\{{3}(\d+)\}{3}$/i.test(file)).length : 0;
	console.assert(audioFileCount > 0);
	return searchResult.releaseYear > 0 && (searchResult.numTracks == 0 || audioFileCount == searchResult.numTracks);
})))).then(matchingResults => matchingResults.length > 0 ? matchingResults.length : Promise.reject(nothingFound)) : Promise.reject('Invalid argument');

const fetchBandcampDetails = torrentGroup => getSearchResults(torrentGroup, GM_getValue('full_search_results', true))
		.then(searchResults => new Promise(function(resolve, reject) {
	console.assert(searchResults.length > 0);
	let selectedRow = null, dialog = document.createElement('DIALOG');
	dialog.innerHTML = `
<form method="dialog">
	<div style="margin-bottom: 10pt; padding: 4px; background-color: #111; box-shadow: 1pt 1pt 5px #bbb inset;">
		<ul id="bandcamp-search-results" style="list-style-type: none; width: 645px; max-height: 70vw; overflow-y: auto; overscroll-behavior-y: none; scrollbar-gutter: stable; scroll-behavior: auto; scrollbar-color: #444 #222;" />
	</div>
	<input value="Import" type="button" disabled /><input value="Cancel" type="button" style="margin-left: 5pt;" /><div style="display: inline-block; margin-left: 15pt;">
		<label style="border: 2px groove black; padding: 3pt 3pt 3pt 4pt;"><input name="update-tags" style="margin-right: 4pt;" type="checkbox" />Tags
			<label style="margin-left: 5pt;"><input style="margin-right: 4pt;" name="preserve-tags" type="checkbox" />Preserve</label>
		</label><label style="margin-left: 5pt;border: 2px groove black; padding: 3pt 3pt 3pt 4pt;"><input style="margin-right: 4pt;" name="update-image" type="checkbox" />Cover</label><label style="margin-left: 4pt;border: 2px groove black; padding: 3pt 3pt 3pt 4pt;"><input style="margin-right: 4pt;" name="update-description" type="checkbox" />Description
			<label style="margin-left: 5pt;"><input style="margin-right: 4pt;" name="insert-about" type="checkbox" />About</label><label style="margin-left: 5pt;"><input style="margin-right: 4pt;" name="insert-credits" type="checkbox" />Credits</label><label style="margin-left: 5pt;"><input style="margin-right: 4pt;" name="insert-url" type="checkbox" />URL</label>
		</label>
	</div>
</form>`;
	const form = dialog.querySelector(':scope > form');
	console.assert(form != null);
	dialog.style = 'position: fixed; top: 5%; left: 0; right: 0; padding: 10pt; margin-left: auto; margin-right: auto; background-color: gray; box-shadow: 5px 5px 10px; z-index: 9999;';
	dialog.onkeyup = function(evt) {
		if (evt.key != 'Escape') return true;
		evt.currentTarget.close();
		return false;
	};
	dialog.oncancel = evt => { reject('Cancelled') };
	dialog.onclose = function(evt) {
		if (!evt.currentTarget.returnValue) reject('Cancelled');
		document.body.removeChild(evt.currentTarget);
	};
	const ul = dialog.querySelector('ul#bandcamp-search-results'), buttons = dialog.querySelectorAll('input[type="button"]');
	for (let li of searchResults) {
		for (let a of li.getElementsByTagName('A')) {
			a.onclick = evt => { if (!evt.ctrlKey && !evt.shiftKey) return false };
			a.search = '';
		}
		for (let styleSheet of [
			['.searchresult .art img', 'max-height: 145px; max-width: 145px;'],
			['.result-info', 'display: inline-block; color: white; padding: 1pt 10pt; box-sizing: border-box; vertical-align: top; width: 475px; line-height: 1.4em;'],
			['.itemtype', 'font-size: 10px; color: #999; margin-bottom: 0.5em; padding: 0;'],
			['.heading', 'font-size: 16px; margin-bottom: 0.1em; padding: 0;'],
			['.subhead', 'font-size: 13px; margin-bottom: 0.3em; padding: 0;'],
			['.released', 'font-size: 11px; padding: 0;'],
			['.itemurl', 'font-size: 11px; padding: 0;'],
			['.itemurl a', 'color: #84c67d;'],
			['.tags', 'color: #aaa; font-size: 11px; padding: 0;'],
		]) for (let elem of li.querySelectorAll(styleSheet[0])) elem.style = styleSheet[1];
		li.style = 'cursor: pointer; margin: 0; padding: 4px;';
		for (let child of li.children) child.style.display = 'inline-block';
		let confidence = 1;
		let [itemType, numTracks, releaseYear] = ['div.itemtype', 'div.length', 'div.released'].map(sel => li.querySelector(sel));
		if (itemType != null) itemType = itemType.textContent.trim().toLowerCase();
		if (releaseYear != null && (releaseYear = /\b(\d{4})\b/.exec(releaseYear.textContent)) != null) releaseYear = parseInt(releaseYear[1]);
		if (itemType == 'track') numTracks = 1;
		else if (numTracks != null && (numTracks = /\b(\d+)\s+(?:tracks?)\b/i.exec(numTracks.textContent)) != null)
			numTracks = parseInt(numTracks[1]);
		if (releaseYear > 0 && torrentGroup.group.year > 0 && torrentGroup.group.year > releaseYear) confidence *= 1/2;
		else if ('torrents' in torrentGroup && torrentGroup.torrents.length > 0) {
			if (releaseYear > 0 && !torrentGroup.torrents.some(torrent =>
					!(torrent.remasterYear > 0) || torrent.remasterYear == releaseYear)) confidence *= 3/4;
			if (numTracks > 0 && !torrentGroup.torrents.some(function(torrent) {
				const audioFileCount = torrent.fileList ? torrent.fileList.split('|||').filter(file =>
					/^(.+\.(?:flac|mp3|m4[ab]|aac|dts(?:hd)?|truehd|ac3|ogg|opus|wv|ape))\{{3}(\d+)\}{3}$/i.test(file)).length : 0;
				return [numTracks, 0].includes(audioFileCount);
			})) confidence *= 3/4;
		}
		if (confidence < 1) li.style.opacity = confidence;
		li.onclick = function(evt) {
			if (selectedRow != null) selectedRow.style.backgroundColor = null;
			(selectedRow = evt.currentTarget).style.backgroundColor = '#066';
			buttons[0].disabled = false;
		};
	}
	for (let li of searchResults.sort((a, b) => (parseFloat(b.style.opacity) || 1) - (parseFloat(a.style.opacity) || 1))) ul.append(li);
	buttons[0].onclick = function(evt) {
		evt.currentTarget.disabled = true;
		console.assert(selectedRow instanceof HTMLLIElement);
		const a = selectedRow.querySelector('div.result-info > div.heading > a');
		if (a != null) globalXHR(a.href).then(function({document}) {
			function safeParse(serialized) {
				if (serialized) try { return JSON.parse(serialized) } catch (e) { console.warn('BC meta invalid: %s', e, serialized) }
				return null;
			}

			const savePreset = (prefName, inputName) => { GM_setValue(prefName, form.elements[inputName].checked) };
			savePreset('update_tags', 'update-tags');
			GM_setValue('preserve_tags', form.elements['preserve-tags'].checked ? 1 : 0);
			savePreset('update_image', 'update-image');
			savePreset('update_description', 'update-description');
			savePreset('description_insert_about', 'insert-about');
			savePreset('description_insert_credits', 'insert-credits');
			savePreset('description_insert_url', 'insert-url');

			const details = { };
			let elem = document.head.querySelector(':scope > script[type="application/ld+json"]');
			const releaseMeta = elem && safeParse(elem.text);
			const tralbum = (elem = document.head.querySelector('script[data-tralbum]')) && safeParse(elem.dataset.tralbum);
			if (tralbum != null && Array.isArray(tralbum.packages) && tralbum.packages.length > 0) for (let key in tralbum.packages[0])
				if (!tralbum.current[key] && tralbum.packages.every(pkg => pkg[key] == tralbum.packages[0][key]))
					tralbum.current[key] = tralbum.packages[0][key];
			if (releaseMeta != null && releaseMeta.byArtist) details.artist = releaseMeta.byArtist.name;
			if (releaseMeta != null && releaseMeta.name) details.title = releaseMeta.name;
			if (releaseMeta != null && releaseMeta.numTracks) details.numTracks = releaseMeta.numTracks;
			if (releaseMeta != null && releaseMeta.datePublished) details.releaseDate = new Date(releaseMeta.datePublished);
			else if (tralbum != null && tralbum.current.album_publish_date) details.releaseDate = new Date(tralbum.current.album_publish_date);
			if (releaseMeta != null && releaseMeta.publisher) details.publisher = releaseMeta.publisher.name;
			if (tralbum != null && tralbum.current.upc) details.upc = tralbum.current.upc;
			if (releaseMeta != null && releaseMeta.image) details.image = releaseMeta.image;
			else if ((elem = document.head.querySelector('meta[property="og:image"][content]')) != null) details.image = elem.content;
			else if ((elem = document.querySelector('div#tralbumArt > a.popupImage')) != null) details.image = elem.href;
			if (details.image) details.image = details.image.replace(/_\d+(?=\.\w+$)/, '_10');
			details.tags = releaseMeta != null && Array.isArray(releaseMeta.keywords) ? new TagManager(...releaseMeta.keywords)
				: new TagManager(...Array.from(document.querySelectorAll('div.tralbum-tags > a.tag'), a => a.textContent.trim()));
			if (details.tags.length < 0) delete details.tags;
			if (tralbum != null && tralbum.current.minimum_price <= 0) details.tags.add('freely.available');
			if (releaseMeta != null && releaseMeta.description) details.description = releaseMeta.description;
			else if (tralbum != null && tralbum.current.about) details.description = tralbum.current.about;
			if (details.description) details.description = stripText(details.description)
				.replace(/^24[^\S\n]*bits?[^\S\n]*\/[^\S\n]*\d+(?:\.\d+)?[^\S\n]*k(?:Hz)?$\n+/m, '');
			if (releaseMeta != null && releaseMeta.creditText) details.credits = tralbum.current.credits;
			else if (tralbum != null && tralbum.current.credits) details.credits = tralbum.current.credits;
			if (details.credits) details.credits = stripText(details.credits);
			if (releaseMeta != null && releaseMeta.mainEntityOfPage) details.url = releaseMeta.mainEntityOfPage;
			else if (tralbum != null && tralbum.url) details.url = tralbum.url;
			if (tralbum != null && tralbum.art_id) details.artId = tralbum.art_id;
			if (tralbum != null && tralbum.current.album_id) details.id = tralbum.current.album_id;
			resolve(details);
		}, reject); else reject('Assertion failed: BC release link not found');
		dialog.close(a != null ? a.href : '');
	};
	buttons[1].onclick = evt => { dialog.close() };

	const loadPreset = (inputName, presetName, presetDefault) =>
		{ form.elements[inputName].checked = GM_getValue(presetName, presetDefault) };
	loadPreset('update-tags', 'update_tags', true);
	form.elements['update-tags'].onchange = function(evt) {
		form.elements['preserve-tags'].disabled = !evt.currentTarget.checked;
	};
	form.elements['update-tags'].dispatchEvent(new Event('change'));
	form.elements['preserve-tags'].checked = GM_getValue('preserve_tags', 0) > 0;
	loadPreset('update-image', 'update_image', true);
	loadPreset('update-description', 'update_description', true);
	form.elements['update-description'].onchange = function(evt) {
		form.elements['insert-about'].disabled = form.elements['insert-credits'].disabled =
			form.elements['insert-url'].disabled = !evt.currentTarget.checked;
	};
	form.elements['update-description'].dispatchEvent(new Event('change'));
	loadPreset('insert-about', 'description_insert_about', true);
	loadPreset('insert-credits', 'description_insert_credits', true);
	loadPreset('insert-url', 'description_insert_url', true);

	document.body.append(dialog);
	dialog.showModal();
	ul.focus();
}));

const siteTagsCache = 'siteTagsCache' in localStorage ? (function(serialized) {
	try { return JSON.parse(serialized) } catch(e) { return { } }
})(localStorage.getItem('siteTagsCache')) : { };
function getVerifiedTags(tags, confidencyThreshold = GM_getValue('tags_confidency_threshold', 1)) {
	if (!Array.isArray(tags)) throw 'Invalid argument';
	return Promise.all(tags.map(function(tag) {
		if (!(confidencyThreshold > 0) || tmWhitelist.includes(tag) || siteTagsCache[tag] >= confidencyThreshold)
			return Promise.resolve(tag);
		return queryAjaxAPICached('browse', { taglist: tag }).then(function(response) {
			const usage = response.pages > 1 ? (response.pages - 1) * 50 + 1 : response.results.length;
			if (usage < confidencyThreshold) return false;
			siteTagsCache[tag] = usage;
			Promise.resolve(siteTagsCache).then(cache => { localStorage.setItem('siteTagsCache', JSON.stringify(cache)) });
			return tag;
		}, reason => false);
	})).then(results => results.filter(Boolean));
}

function importToBody(bcReleaseDetails, body) {
	function insertSection(section, afterBegin = true, beforeLinks = true, beforeEnd = true) {
		if (section) if (!body) body = section;
		else if (afterBegin && rx.afterBegin.some(rx => rx.test(body)))
			body = RegExp.lastMatch + section + '\n\n' + RegExp.rightContext.trimLeft();
		else if (beforeLinks && rx.beforeLinks.test('\n' + body))
			body = (RegExp.leftContext + '\n\n').trimLeft() + section + '\n\n' + RegExp.lastMatch.trimLeft();
		else if (beforeEnd && rx.beforeEnd.some(rx => rx.test(body)))
			body = RegExp.leftContext.trimRight() + '\n\n' + section + RegExp.lastMatch.trimLeft();
		else body += '\n\n' + section;
	}

	body = stripText(body);
	const rx = {
		afterBegin: [/^\[pad=\d+\|\d+\]/i, /^Releas(?:ing|ed) .+\d{4}$(?:\r?\n){2,}/im],
		beforeLinks: /(?:(?:\r?\n)+(?:(?:More info(?:rmation)?:|\[b\]More info(?:rmation)?:\[\/b\])[^\S\r\n]+)?(?:\[url(?:=[^\[\]]+)?\].+\[\/url\]|https?:\/\/\S+))+(?:\[\/size\]\[\/pad\])?$/i,
		beforeEnd: [/\[\/size\]\[\/pad\]$/i],
	};
	if (bcReleaseDetails.description && bcReleaseDetails.description.length > 10
			&& ![ ].some(rx => rx.test(bcReleaseDetails.description))
			&& GM_getValue('description_insert_about', true) && !body.includes(bcReleaseDetails.description))
		insertSection('[quote][plain]' + bcReleaseDetails.description + '[/plain][/quote]', true, true, true);
	if (document.location.pathname != '/requests.php' && bcReleaseDetails.credits && bcReleaseDetails.credits.length > 10
			&& ![ ].some(rx => rx.test(bcReleaseDetails.credits))
			&& GM_getValue('description_insert_credits', true) && !body.includes(bcReleaseDetails.credits))
		insertSection('[hide=Credits][plain]' + bcReleaseDetails.credits + '[/plain][/hide]', false, true, true);
	if (bcReleaseDetails.url && GM_getValue('description_insert_url', true) && !body.includes(bcReleaseDetails.url))
		insertSection('[url=' + bcReleaseDetails.url + ']Bandcamp[/url]', false, false, true);
	return body.replace(/(\[\/quote\])(?:\r?\n){2,}/ig, '$1\n');
}

const urlParams = new URLSearchParams(document.location.search);
switch (document.location.pathname) {
	case '/torrents.php': {
		if (document.querySelector('div.sidebar > div.box_artists') == null) break; // Nothing to do here - not music torrent
		const groupId = parseInt(urlParams.get('id'));
		if (!(groupId > 0)) throw 'Invalid group id';
		const linkBox = document.body.querySelector('div.header > div.linkbox');
		if (linkBox == null) throw 'LinkBox not found';
		const a = document.createElement('A');
		a.textContent = 'Bandcamp import';
		a.href = '#';
		a.title = 'Import album textual description, tags and cover image from Bandcamp release page';
		a.className = 'brackets';
		a.onclick = function(evt) {
			if (!this.disabled) this.disabled = true; else return false;
			this.style.color = 'orange';
			queryAjaxAPI('torrentgroup', { id: groupId }).then(torrentGroup => fetchBandcampDetails(torrentGroup).then(function(bcRelease) {
				const updateWorkers = [ ];
				if (bcRelease.tags instanceof TagManager && GM_getValue('update_tags', true) && bcRelease.tags.length > 0) {
					const releaseTags = Array.from(document.body.querySelectorAll('div.box_tags ul > li'), function(li) {
						const tag = { name: li.querySelector(':scope > a'), id: li.querySelector('span.remove_tag > a') };
						if (tag.name != null) tag.name = tag.name.textContent.trim();
						if (tag.id != null) tag.id = parseInt(new URLSearchParams(tag.id.search).get('tagid'));
						return tag.name && tag.id ? tag : null;
					}).filter(Boolean);
					let bcTags = [ ];
					if (torrentGroup.group.musicInfo) for (let importance of Object.keys(torrentGroup.group.musicInfo))
						if (Array.isArray(torrentGroup.group.musicInfo[importance]))
							Array.prototype.push.apply(bcTags, torrentGroup.group.musicInfo[importance].map(artist => artist.name));
					if (Array.isArray(torrentGroup.torrents)) for (let torrent of torrentGroup.torrents) {
						if (!torrent.remasterRecordLabel) continue;
						const labels = torrent.remasterRecordLabel.split('/').map(label => label.trim());
						if (labels.length > 0) {
							Array.prototype.push.apply(bcTags, labels);
							Array.prototype.push.apply(bcTags, labels.map(function(label) {
								const bareLabel = label.replace(/(?:\s+(?:under license.+|Records|Recordings|(?:Ltd|Inc)\.?))+$/, '');
								if (bareLabel != label) return bareLabel;
							}).filter(Boolean));
						}
					}
					bcTags = new TagManager(...bcTags);
					bcTags = Array.from(bcRelease.tags).filter(tag => !bcTags.includes(tag));
					if (bcTags.length > 0 && (releaseTags.length <= 0 || !(Number(GM_getValue('preserve_tags', 0)) > 1)))
							updateWorkers.push(getVerifiedTags(bcTags, 3).then(function(verifiedBcTags) {
						if (verifiedBcTags.length <= 0) return false;
						let userAuth = document.body.querySelector('input[name="auth"][value]');
						if (userAuth != null) userAuth = userAuth.value; else throw 'Failed to capture user auth';
						const updateWorkers = [ ];
						const addTags = verifiedBcTags.filter(tag => !releaseTags.map(tag => tag.name).includes(tag));
						if (addTags.length > 0) Array.prototype.push.apply(updateWorkers, addTags.map(tag => localXHR('/torrents.php', { responseType: null }, new URLSearchParams({
							action: 'add_tag',
							groupid: torrentGroup.group.id,
							tagname: tag,
							auth: userAuth,
						}))));
						const deleteTags = releaseTags.filter(tag => !verifiedBcTags.includes(tag.name)).map(tag => tag.id);
						if (deleteTags.length > 0 && !(Number(GM_getValue('preserve_tags', 0)) > 0))
								Array.prototype.push.apply(updateWorkers, deleteTags.map(tagId => localXHR('/torrents.php?' + new URLSearchParams({
							action: 'delete_tag',
							groupid: torrentGroup.group.id,
							tagid: tagId,
							auth: userAuth,
						}), { responseType: null })));
						return updateWorkers.length > 0 ? Promise.all(updateWorkers.map(updateWorker =>
								updateWorker.then(responseCode => true, reason => reason))).then(function(results) {
							if (results.some(result => result === true)) return results;
							return Promise.reject(`All of ${results.length} tag update workers failed (see browser console for more details)`);
						}) : false;
					}));
				}
				const rehostWorker = bcRelease.image && GM_getValue('update_image', true) ?
					imageHostHelper.then(ihh => ihh.rehostImageLinks([bcRelease.image])
						.then(ihh.singleImageGetter)).catch(reason => bcRelease.image) : Promise.resolve(null);
				if (ajaxApiKey) {
					let origBody = document.createElement('TEXTAREA');
					origBody.innerHTML = torrentGroup.group.bbBody;
					const body = importToBody(bcRelease, (origBody = origBody.textContent)), formData = new FormData;
					updateWorkers.push(rehostWorker.then(function(rehostedImageUrl) {
						const updateImage = rehostedImageUrl != null && (!torrentGroup.group.wikiImage || !GM_getValue('preserve_image', false))
							&& rehostedImageUrl != torrentGroup.group.wikiImage;
						if (updateImage || GM_getValue('update_description', true) && body != origBody.trim()) {
							if (updateImage) formData.set('image', rehostedImageUrl);
							if (GM_getValue('update_description', true) && body != origBody) formData.set('body', body);
							formData.set('summary', 'Additional description/cover image import from Bandcamp');
							return queryAjaxAPI('groupedit', { id: torrentGroup.group.id }, formData).then(function(response) {
								console.log(response);
								return true;
							});
						} else return false;
					}));
				} else {
					updateWorkers.push(localXHR('/torrents.php?' + new URLSearchParams({
						action: 'editgroup',
						groupid: torrentGroup.group.id,
					})).then(function(document) {
						const editForm = document.querySelector('form.edit_form');
						if (editForm == null) throw 'Edit form not exists';
						const formData = new FormData(editForm);
						const image = editForm.elements.namedItem('image').value, origBody = editForm.elements.namedItem('body').value;
						const body = importToBody(bcRelease, origBody);
						return rehostWorker.then(function(resolvedImageUrl) {
							const updateImage = resolvedImageUrl != null && (!image || !GM_getValue('preserve_image', false))
								&& resolvedImageUrl != image;
							if (updateImage || GM_getValue('update_description', true) && body != origBody.trim()) {
								if (updateImage) formData.set('image', resolved[1]);
								if (GM_getValue('update_description', true) && body != origBody) formData.set('body', body);
								formData.set('summary', 'Additional description/cover image import from Bandcamp');
								return localXHR('/torrents.php', { responseType: null }, formData).then(responseCode => true);
							} else return false;
						});
					}));
					if (document.domain == 'redacted.ch' && !(GM_getValue('red_nag_shown', 0) >= 3)) {
						const cpLink = new URL('/user.php?action=edit#api_key_settings', document.location.origin);
						let userId = document.body.querySelector('#userinfo_username a.username');
						if (userId != null) userId = parseInt(new URLSearchParams(userId.search).get('id'));
						if (userId > 0) cpLink.searchParams.set('userid', userId);
						alert('Please consider generating your personal API token (' + cpLink.href + ')\nSet up as "redacted_api_key" script storage entry');
						GM_setValue('red_nag_shown', GM_getValue('red_nag_shown', 0) + 1 || 1);
						// updateWorkers.push(localXHR('/user.php?' + URLSearchParams({ action: 'edit', userid: userId })).then(function(document) {
						// 	const form = document.body.querySelector('form#userform');
						// 	if (form == null) throw 'User form not found';
						// 	const formData = new FormData(form), newApiKey = formData.get('new_api_key');
						// 	if (newApiKey) formData.set('confirmapikey', 'on'); else throw 'API key not exist';
						// 	formData.set('api_torrents_scope', 'on');
						// 	for (let name of ['api_user_scope', 'api_requests_scope', 'api_forums_scope', 'api_wiki_scope']) formData.delete(name);
						// 	return localXHR('/user.php', { responseType: null }, formData).then(statusCode => newApiKey);
						// }).then(function(newApiKey) {
						// 	GM_setValue('redacted_api_key', newApiKey);
						// 	alert('Your personal API key [' + newApiKey + '] was successfulloy created and saved');
						// 	return false;
						// }));
					}
				}
				if (updateWorkers.length > 0) return Promise.all(updateWorkers.map(updateWorker =>
						updateWorker.then(response => Boolean(response), function(reason) {
					console.warn('Update worker failed with reason ' + reason);
					return reason;
				}))).then(function(results) {
					if (results.filter(Boolean).length > 0 && !results.some(result => result === true))
						return Promise.reject(`All of ${results.length} update workers failed (see browser console for more details)`);
					if (results.some(result => result === true)) return (document.location.reload(), true);
				});
			})).catch(reason => { if (!['Cancelled'].includes(reason)) alert(reason) }).then(status => {
				this.style.color = status ? 'springgreen' : null;
				this.disabled = false;
			});
			return false;
		};
		linkBox.append(' ', a);

		if (urlParams.has('presearch-bandcamp')) matchingResultsCount(groupId).then(function(matchedCount) {
			a.style.fontWeight = 'bold';
			a.title += `\n\n${matchedCount} possibly matching release(s)`;
		}, reason => { a.style.color = 'gray' });
		break;
	}
	case '/upload.php':
		if (urlParams.has('groupid')) break;
	case '/requests.php': {
		function hasStyleSheet(name) {
			if (name) name = name.toLowerCase(); else throw 'Invalid argument';
			const hrefRx = new RegExp('\\/' + name + '\\b', 'i');
			if (document.styleSheets) for (let styleSheet of document.styleSheets)
				if (styleSheet.title && styleSheet.title.toLowerCase() == name) return true;
					else if (styleSheet.href && hrefRx.test(styleSheet.href)) return true;
			return false;
		}
		function checkFields() {
			const visible = ['0', 'Music'].includes(categories.value) && title.textLength > 0;
			if (container.hidden != !visible) container.hidden = !visible;
		}

		const categories = document.getElementById('categories');
		if (categories == null) throw 'Categories select not found';
		const form = document.getElementById('upload_table') || document.getElementById('request_form');
		if (form == null) throw 'Main form not found';
		let title = form.elements.namedItem('title');
		if (title != null) title.addEventListener('input', checkFields); else throw 'Title select not found';
		const dynaForm = document.getElementById('dynamic_form');
		if (dynaForm != null) new MutationObserver(function(ml, mo) {
			for (let mutation of ml) if (mutation.addedNodes.length > 0) {
				if (title != null) title.removeEventListener('input', checkFields);
				if ((title = document.getElementById('title')) != null) title.addEventListener('input', checkFields);
					else throw 'Assertion failed: title input not found!';
				container.hidden = true;
			}
		}).observe(dynaForm, { childList: true });
		const isLightTheme = ['postmod', 'shiro', 'layer_cake', 'proton', 'red_light', '2iUn3'].some(hasStyleSheet);
		if (isLightTheme) console.log('Light Gazelle theme detected');
		const isDarkTheme = ['kuro', 'minimal', 'red_dark', 'Vinyl'].some(hasStyleSheet);
		if (isDarkTheme) console.log('Dark Gazelle theme detected');
		const container = document.createElement('DIV');
		container.style = 'position: fixed; top: 64pt; right: 10pt; padding: 5pt; border-radius: 50%; z-index: 999;';
		container.style.backgroundColor = `#${isDarkTheme ? '2f4f4f' : 'b8860b'}80`;
		const bcButton = document.createElement('BUTTON'), img = document.createElement('IMG');
		bcButton.id = 'import-from-bandcamp';
		bcButton.style = `
padding: 10px; color: white; background-color: white; cursor: pointer;
border: none; border-radius: 50%; transition: background-color 200ms;
`;
		bcButton.dataset.backgroundColor = bcButton.style.backgroundColor;
		bcButton.setDisabled = function(disabled = true) {
			this.disabled = disabled;
			this.style.opacity = disabled ? 0.5 : 1;
			this.style.cursor = disabled ? 'not-allowed' : 'pointer';
		};
		bcButton.onclick = function(evt) {
			this.setDisabled(true);
			this.style.backgroundColor = 'red';
			const torrentGroup = { group: {
				name: title.value,
				musicInfo: { },
				releaseType: parseInt(form.elements.namedItem('releasetype').value),
				year: parseInt(form.elements.namedItem('year').value),
			} };
			for (let artist of form.querySelectorAll('input[name="artists[]"]')) {
				const importance = ['artists', 'with', 'composers', 'conductor', 'dj', 'remixedby', 'producer']
					[parseInt(artist.nextElementSibling.value) - 1];
				if (!importance || !(artist = artist.value.trim())) continue;
				if (!(importance in torrentGroup.group.musicInfo)) torrentGroup.group.musicInfo[importance] = [ ];
				torrentGroup.group.musicInfo[importance].push({ name: artist });
			}
			const remasterYear = parseInt(form.elements.namedItem('remaster_year').value);
			if (remasterYear > 0) torrentGroup.torrents = [{ remasterYear: remasterYear }];
			fetchBandcampDetails(torrentGroup).then(function(bcRelease) {
				const tags = form.elements.namedItem('tags'), image = form.elements.namedItem('image'),
							description = form.elements.namedItem('album_desc') || form.elements.namedItem('description');
				if (tags != null && bcRelease.tags instanceof TagManager && bcRelease.tags.length > 0 && GM_getValue('update_tags', true)
						&& (!tags.value || !(Number(GM_getValue('preserve_tags', 0)) > 1))) {
					let bcTags = Array.from(form.querySelectorAll('input[name="artists[]"]'), input => input.value.trim()).filter(Boolean);
					let labels = form.elements.namedItem('remaster_record_label') || form.elements.namedItem('record_label');
					if (labels != null && (labels = labels.value.trim().split('/').map(label => label.trim())).length > 0) {
						Array.prototype.push.apply(bcTags, labels);
						Array.prototype.push.apply(bcTags, labels.map(function(label) {
							const bareLabel = label.replace(/(?:\s+(?:under license.+|Records|Recordings|(?:Ltd|Inc)\.?))+$/, '');
							if (bareLabel != label) return bareLabel;
						}).filter(Boolean));
					}
					bcTags = new TagManager(...bcTags);
					bcTags = Array.from(bcRelease.tags).filter(tag => !bcTags.includes(tag));
					getVerifiedTags(bcTags).then(function(bcVerifiedTags) {
						if (bcVerifiedTags.length <= 0) return;
						if (Number(GM_getValue('preserve_tags', 0)) > 0) {
							const mergedTags = new TagManager(tags.value, ...bcVerifiedTags);
							tags.value = mergedTags.toString();
						} else tags.value = bcVerifiedTags.join(', ');
					});
				}
				if (image != null && bcRelease.image && GM_getValue('update_image', true) && (!image.value || !GM_getValue('preserve_image', false))) {
					image.value = bcRelease.image;
					imageHostHelper.then(ihh => { ihh.rehostImageLinks([bcRelease.image]).then(ihh.singleImageGetter).then(rehostedUrl =>
						{ image.value = rehostedUrl }) });
				}
				if (description != null && GM_getValue('update_description', true)) {
					const body = importToBody(bcRelease, description.value);
					if (body != description.value) description.value = body;
				}
			}, reason => { if (!['Cancelled'].includes(reason)) alert(reason) }).then(() => {
				this.style.backgroundColor = this.dataset.backgroundColor;
				this.setDisabled(false);
			});
		};
		bcButton.onmouseenter = bcButton.onmouseleave = function(evt) {
			if (evt.relatedTarget == evt.currentTarget || evt.currentTarget.disabled) return false;
			evt.currentTarget.style.backgroundColor = evt.type == 'mouseenter' ? 'orange'
				: evt.currentTarget.dataset.backgroundColor || null;
		};
		bcButton.title = 'Import description, cover image and tags from Bandcamp';
		img.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAMAAAD04JH5AAAAbFBMVEX////3///v///v9//m9//m9/fe9/fe7/fW7/fO7/fF7/fF5ve95u+15u+13u+t3u+l3u+l3uac1uaU1uaM1uaMzuaEzt57zt57xd5zxd5rxd5jvdZavdZSvdZStdZKtdZKtc5Ctc5Crc4ZpcWSSeq2AAAAAXRSTlMAQObYZgAAAAlwSFlzAAALEgAACxIB0t1+/AAAAoBJREFUeJzt2mtvgjAUBuCC93sUdR/2/3/bsqlbNqfzVpgGExXa02N5ZVly+gVBpU+a0retVtUfl6oABCAAAQhAAAIQgAAEIAABCEAAArjjs60oUSpI0pNCx/jFBxA8Ve7QkmV9eXkHYAKrX7/5ACptVP3xSvsAprAGSGZXJ2xAo4GqX39dn7EBY1gDqHcfQKuGql5/3JxyAZPw/CIJCh7Vpw+gG56/r4oe4/ntnZkAXA+Iv30AA1T1Si8yF3iAIawBDisfwAhVvdLz7BUWoA9rgN3GBzBBVa/0LHeJAQg6pwYo/Pyfjpu9DyCdBiDGAf2av7sbUGu6jbyiV4kPADcPUfkewAA066jqb2OYDXhUDHMBndDxAXbJxDAXEMHmAZkYZgK6IeT5P5ZDNoV4gEsPKDoOJJkY5gFwMbw3PYJuAC6G9Y8P4JExzALgYni79QFMA+LNu8r1YpAPqLRhg9BaW98iAGkKIcYBwzyEATjHMGAeEC8NMewGAFfDlkGQBjRxQ4A5BFyAMS6FzDHoAHRg22faOA1wAiLcYtA4EXIB+rh5CN0ANsCogpoHaEsM04AhZh2gqBQiAQNYD9jbYpgERKjqyUGYAPRwMbzzAZQSwwQAF8MxEcMEAJhC7gYwAOrpnixgHNBLBjIPOK+GEeMAFcNWAHBPloxhKwCXQnQM2wDdsmLYBhiVFcMWAC6G95wemAcAGyC7J8sCDEH7gcRqmAYcYxg1D7AuBilAVGYKmQA9WBc07MkyAMAY5vaAG0C5MWwAlBvDecCjfhplA57THgAYB0JeCmQBC9iutGspYAGw0htf/tV/SAQgAAEIQAACEIAABCAAAQhAAAJ4SPkFdtLUKHgfCmoAAAAASUVORK5CYII=' // https://s4.bcbits.com/img/favicon/apple-touch-icon.png
		img.width = 32;
		bcButton.append(img);
		container.append(bcButton);
		checkFields();
		document.body.append(container);
		break;
	}
}