Anakunda / MB Add entity URL by drag & drop

// ==UserScript==
// @name         MB Add entity URL by drag & drop
// @version      1.00
// @include      /^https?:\/\/(?:beta\.)?musicbrainz\.org\/(?:artist|label|place|series)\/[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}(?=\/|$)/
// @run-at       document-end
// @author       Anakunda
// @namespace    https://greasyfork.org/users/321857
// @copyright    © 2024, Anakunda (https://greasyfork.org/users/321857)
// @license      GPL-3.0-or-later
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @connect      *
// @require      https://openuserjs.org/src/libs/Anakunda/Requests.min.js
// ==/UserScript==

'use strict';

const mbOrigin = 'https://musicbrainz.org', mbRequestsCache = new Map, mbRequestRate = 1000;
let mbLastRequest = null;
function mbApiRequest(endPoint, params) {
	if (!endPoint) throw 'Endpoint is missing';
	const url = new URL('/ws/2/' + endPoint.replace(/^\/+|\/+$/g, ''), mbOrigin);
	if (params) for (let key in params) url.searchParams.set(key, params[key]);
	url.searchParams.set('fmt', 'json');
	const cacheKey = url.pathname.slice(6) + url.search;
	if (mbRequestsCache.has(cacheKey)) return mbRequestsCache.get(cacheKey);
	const request = new Promise(function(resolve, reject) {
		let retryCounter = 0;
		const xhr = {
			method: 'GET', url: url, responseType: 'json', timeout: 60e3,
			headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
			onload: function(response) {
				mbLastRequest = Date.now();
				if (response.status >= 200 && response.status < 400) resolve(response.response);
				else if (XHR.recoverableErrors.has(response.status) && retryCounter++ < 60) {
					console.log('MusicBrainz API request retry #%d on HTTP error %d', retryCounter, response.status);
					setTimeout(request, 1000);
				} else reject(XHR.defaultErrorHandler(response));
			},
			onerror: function(response) {
				mbLastRequest = Date.now();
				if (response.status == 0 && !response.finalUrl && retryCounter++ < 60) {
					console.log('MusicBrainz API request retry #%d on HTTP error %d/%d', retryCounter, response.status, response.readyState);
					setTimeout(request, 1000);
				} else reject(XHR.defaultErrorHandler(response));
			},
			ontimeout: function(response) {
				mbLastRequest = Date.now();
				reject(XHR.defaultTimeoutHandler(response));
			},
		}, request = () => {
			if (mbLastRequest == Infinity) return setTimeout(request, 50);
			const availableAt = mbLastRequest + mbRequestRate, now = Date.now();
			if (now < availableAt) return setTimeout(request, availableAt - now); else mbLastRequest = Infinity;
			GM_xmlhttpRequest(xhr);
		};
		request();
	});
	mbRequestsCache.set(cacheKey, request);
	return request.catch(reason => (mbRequestsCache.delete(cacheKey), Promise.reject(reason)));
}

const mbID = /([\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12})/i.source;
const rxMBID = new RegExp(`^${mbID}$`, 'i');
const [entity, mbid] = new RegExp(`^\\/(\\w+)\\/${mbID}\\b`, 'i').exec(document.location.pathname).slice(1);
const name = document.body.querySelector('div#content h1 a > bdi').textContent.trim();
const dropElement = document.body.querySelector('div#page div#sidebar ul.external_links')
	|| document.body.querySelector('div#page div#sidebar');
if (dropElement == null) throw 'Drop area not found';
dropElement.style.position = 'relative';
const overlay = document.createElement('div');
overlay.style = 'background-color: #ffb300; position: absolute; opacity: 0; transition: opacity 200ms ease-in-out; width: 100%; height: 100%; top: 0; left: 0; pointer-events: none; scale: 1.05 1.1; border-radius: 5pt; box-shadow: 0 0 5pt 5pt #ffb300;';
dropElement.append(overlay);
dropElement.ondragover = evt => false;
dropElement.ondragenter = dropElement[`ondrag${'ondragexit' in dropElement ? 'exit' : 'leave'}`] = function(evt) {
	if (evt.currentTarget.contains(evt.relatedTarget)) return;
	overlay.style.opacity = evt.type == 'dragenter' ? 0.5 : 0;
	overlay.style.cursor = evt.type == 'dragenter' ? 'copy' : 'auto';
};
dropElement.ondrop = function(evt) {
	function validateURL(url) {
		if (url) try { if (new URL(url)) return true } catch(reason) { console.log('Invalid URL:', url, reason) }
		return false;
	}

	overlay.style.opacity = 0;
	const url = evt.dataTransfer.getData('text/plain');
	if (url && validateURL(url)) GlobalXHR.get(url, { responseType: null, anonymous: true }).then(({finalUrl}) => finalUrl).then(function(url) {
		function typeIdFromUrl(url) {
			if (!url) throw 'Invalid argument'; else try { url = new URL(url) } catch(e) {
				console.warn('Not valid URL:', url, '(' + e + ')');
				return -Infinity;
			}
			if (url.pathname.includes('search') && url.search) return -1; // search link
			if (urlLinkTypes[entity] instanceof Object && urlLinkTypes[entity].image > 0 && [
				'jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'jfif', 'tif', 'tiff',
			].some(ext => url.pathname.toLowerCase().endsWith('.' + ext))) return urlLinkTypes[entity].image;
			Object.defineProperties(url, {
				matches: { value: function(param) {
					switch (typeof param) {
						case 'string': return (param = param.toLowerCase().split('.')).join('.')
							== url.hostname.toLowerCase().split('.').slice(-param.length).join('.');
						case 'function': return param(this);
						default: return false;
					}
				}, enumerable: true },
				matchesAnyTLD: { value: function(domainPart) {
					return (domainPart = domainPart.toLowerCase().split('.').filter(Boolean)).length > 0
						&& this.hostname.toLowerCase().split('.').every((name, index, names) =>
							index < domainPart.length ? name == domainPart[index] : XHR.TLDs.includes(name));
				}, enumerable: true },
			});
			const linkCategories = {
				'ignored': ['musicbrainz.org', 'myspace.com', 'secondlife.com', 'plus.google.com'],
				'wikipedia': ['wikipedia.org'], 'wikidata': ['wikidata.org'], 'discogs': ['discogs.com'],
				'allmusic': ['allmusic.com'], 'bandsintown': ['bandsintown.com'], 'BookBrainz': ['bookbrainz.org'],
				'CPDL': ['cpdl.org'], 'IMDb': ['imdb.com'], 'IMSLP': ['imslp.org'], 'last.fm': ['last.fm'],
				'secondhandsongs': ['secondhandsongs.com'], 'setlistfm': ['setlist.fm'], 'songkick': ['songkick.com'],
				'vgmdb': ['vgmdb.net', 'vgmdb.com'], 'VIAF': ['viaf.org'], 'geonames': ['geonames.org'],
				'other databases': [
					'45cat.com', '45worlds.com', 'adp.library.ucsb.edu', 'anidb.net', 'animenewsnetwork.com',
					'anison.info', 'baike.baidu.com', 'bibliotekapiosenki.pl', 'brahms.ircam.fr', 'cancioneros.si',
					'castalbums.org', 'catalogue.bnf.fr', 'cbfiddle.com', 'ccmixter.org', 'dnb.de',
					'ci.nii.ac.jp', 'classicalarchives.com', 'd-nb.info', 'dhhu.dk', 'discografia.dds.it',
					'discosdobrasil.com.br', 'dr.loudness-war.info', 'dramonline.org', 'encyclopedisque.fr',
					'ester.ee', 'finna.fi', 'finnmusic.net', 'folkwiki.se', 'fono.fi', 'generasia.com',
					'goodreads.com', 'ibdb.com', 'id.loc.gov', 'idref.fr', 'imvdb.com', 'irishtune.info',
					'isrc.ncl.edu.tw', 'ndl.go.jp', 'japanesemetal.gooside.com', 'jaxsta.com',
					'jazzmusicarchives.com', 'opac.kbr.be', 'librarything.com', 'livefans.jp', 'lortel.org',
					'mainlynorfolk.info', 'maniadb.com', 'metal-archives.com', 'mobygames.com', 'musicmoz.org',
					'musik-sammler.de', 'muziekweb.eu', 'mvdbase.com', 'ocremix.org', 'offiziellecharts.de',
					'openlibrary.org', 'operabase.com', 'operadis-opera-discography.org.uk', 'overture.doremus.org',
					'pomus.net', 'progarchives.com', 'psydb.net', 'qim.com', 'rateyourmusic.com', 'ra.co',
					'residentadvisor.net', 'rock.com.ar', 'rockensdanmarkskort.dk', 'rockinchina.com',
					'rockipedia.no', 'rolldabeats.com', 'smdb.kb.se', 'snaccooperative.org',
					'soundtrackcollector.com', 'spirit-of-metal.com', 'spirit-of-rock.com', 'stage48.net',
					'tedcrane.com', 'theatricalia.com', 'thedancegypsy.com', 'themoviedb.org', 'thesession.org',
					'touhoudb.com', 'triplejunearthed.com', 'trove.nla.gov.au', 'tunearch.org', 'utaitedb.net',
					'vkdb.jp', 'vndb.org', 'vocadb.net', 'whosampled.com', 'worldcat.org', 'www22.big.or.jp',
					'www5.atwiki.jp', 'ra.co', 'albumoftheyear.org', 'iobdb.com', 'repertoire.bmi.com',
					'sacm.org.mx', 'doo-wop.blogg.org', 'mdmarchive.co.uk', 'data.bnf.fr', 'ark.bnf.fr', 'n2t.net',
					'saisaibatake.ame-zaiku.com', 'generasia.com', 'genius.com', 'jaxsta.com', 'metalmusicarchives.com',
					'musicapopular.cl', 'videogam.in', 'quebecinfomusique.com', 'qim.com', 'rism.online',
					'tobarandualchais.co.uk', 'vk.gy', 'oclc.org', 'ascap.com', 'company-information.service.gov.uk',
					url => url.matches('soundbetter.com') && url.pathname.startsWith('/profiles/'),
					'opencorporates.com', 'astreetnearyou.org',
				],
				'soundcloud': ['soundcloud.com'], 'purevolume': ['purevolume.com'],
				'youtube': [
					url => url.matches('youtube.com') && !url.matches('music.youtube.com'),
					'youtu.be',
				],
				'video channel': [
					'vimeo.com', 'rumble.tv', 'odysee.com', 'tiktok.com', 'twitch.com', 'twitch.tv',
					'dailymotion.com', 'nicovideo.jp',
				],
				'social network': [
					'facebook.com', 'fb.com', 'twitter.com', 'x.com', 'instagram.com', 'linkedin.com', ,'vk.com',
					'snapchat.com', 't.me', 'mixcloud.com', 'reddit.com', 'pinterest.com', 'minds.com',
					'flickr.com', 'discord.com', '4chan.org', 'truthsocial.com', 'icq.com', 'pinterest.co.uk',
					'foursquare.com', 'reverbnation.com', 'threads.net', 'vine.co', 'weibo.com',
				],
				'art gallery': [
					'deviantart.com', 'behance.net', 'artstation.com', 'dribbble.com', 'pixiv.net',
				],
				'blog': [
					'wordpress.com', 'blogger.com', 'tumblr.com',
					'ameblo.jp', 'blog.livedoor.jp', 'jugem.jp', 'exblog.jp',
				],
				'apple music': ['music.apple.com'], 'bandcamp': ['bandcamp.com'],
				'CD Baby': ['cdbaby.com', 'cdbaby.name'],
				'youtube music': ['music.youtube.com'],
				'purchase for download': [
					'7digital.com', 'acousticsounds.com', 'beatport.com', 'beatsource.com', 'bleep.com',
					'boomkat.com', 'e-onkyo.com', 'eclassical.com', 'extrememusic.com',
					'genie.co.kr', 'hdtracks.com', 'highresaudio.com', 'itunes.apple.com', 'joox.com', 'jpc.de',
					'junodownload.com', 'kompakt.fm', 'kugou.com', 'kuwo.cn', 'melon.com', 'mora.jp',
					'music-flo.com', 'music.163.com', 'music.apple.com', 'apple.co', 'books.apple.com',
					'music.youtube.com', 'muziekweb.nl', 'nativedsd.com', 'ototoy.jp', 'qobuz.com',
					'prestomusic.com', 'prostudiomasters.com', 'recochoku.jp', 'shop.spotify.com',
					'supraphonline.cz', 'traxsource.com', 'y.qq.com', 'zdigital.com',
					'audiojelly.com', 'hd-music.info', 'musa24.fi', 'loudr.fm', 'store.tidal.comk',
				],
				'purchase for mail-order': [
					'bigcartel.com', 'ozon.ru', 'target.com', 'tower.jp', 'shop.tsutaya.co.jp', 'yesasia.com',
				],
				'download for free': ['jamendo.com', 'librivox.org'],
				'streaming': [
					url => [
						'com', 'co.uk', 'ae', 'at', 'com.au', 'com.br', 'ca', 'cn', 'de', 'es',
						'fr', 'in', 'it', 'jp', 'com.mx', 'nl', 'pl', 'se', 'sg', 'com.tr',
					].map(tld => 'music.amazon.' + tld).includes(url.hostname),
					'music.bugs.co.kr', 'deezer.com', 'genie.co.kr', 'melon.com', 'napster.com',
					'qobuz.com', 'tidal.com',
				],
				'free streaming': [
					'audiomack.com', 'anghami.com', 'boomplay.com', 'dailymotion.com', 'dogmazic.net',
					'instagram.com', 'jamendo.com', 'music.migu.cn', 'nicovideo.jp', 'spotify.com',
					url => ['com', 'by', 'kz', 'ru', 'uz'].map(tld => 'music.yandex.' + tld).includes(url.hostname),
				],
				'patronage': [
					'buymeacoffee.com', 'changetip.com', 'tip.me', 'd.rip', 'drip.kickstarter.com', 'flattr.com',
					'ko-fi.com', 'patreon.com', 'paypal.me', 'tipeee.com',
				],
				'crowdfunding': ['indiegogo.com', 'kickstarter.com'],
				'discography page': [
					'naxos.com', 'bis.se', 'universal-music.co.jp', 'jvcmusic.co.jp', 'wmg.jp', 'avexnet.jp',
					'kingrecords.co.jp', 'lantis.jp',
				],
				'lyrics': [
					'hoick.jp', 'joysound.com', 'kashinavi.com', 'laboiteauxparoles.com', 'lyric.evesta.jp',
					'directlyrics.com', 'lieder.net', 'utamap.com', 'j-lyric.net', 'muzikum.eu', 'gutenberg.org',
					'mainlynorfolk.info', 'musixmatch.com', 'online-bijbel.nl', 'petitlyrics.com', 'runeberg.org',
					'uta-net.com', 'utaten.com', 'wikisource.org',
				],
				'ticketing': [
					url => /^(?:(?:concerts|www)\.)?livenation\.(?:[a-z]{2,3}?\.)?[a-z]{2,4}$/i.test(url.hostname),
					url => /^(?:www\.)?ticketmaster\.(?:[a-z]{2,3}?\.)?[a-z]{2,4}/i.test(url.hostname),
				],
				'image': ['pixogs.com', 'wikimedia.org'], 'history site': ['web.archive.org'], 'online community': [ ],
			};
			if (linkCategories.ignored.some(url.matches.bind(url))) return -1;
			if (urlLinkTypes[entity] instanceof Object) for (let linkType in urlLinkTypes[entity])
				if (urlLinkTypes[entity][linkType] && linkType in linkCategories && linkCategories[linkType].some(url.matches.bind(url)))
					return urlLinkTypes[entity][linkType];
			if (urlLinkTypes[entity] instanceof Object && urlLinkTypes[entity].blog && (url.pathname.includes('blog')
					|| url.hostname.toLowerCase().split('.').includes('blog'))) return urlLinkTypes[entity].blog;
		}
		function isDomainpart(name, url) {
			if (name && url) try {
				const toASCII = str => str && str.normalize('NFKD').replace(/[\x00-\x1F\u0300-\u036F]/gu, '');
				const cmpNorm = str => str && toASCII(str).replace(/\s+(?:and|et|y|und|a|i|и)\s+/gi, ' & ')
					.replace(/[\s\‐\−\—\–\x00-\x25\x27-\x2F\x3A-\x40\x5B-\x5E\x60\x7B-\x7F\u2019]+/g, '').toLowerCase();
				const _url = new URL(url), normName = cmpNorm(name.trim());
				return ['hostname'/*, 'pathname'*/].some(prop => cmpNorm(_url[prop]).includes(normName));
			} catch(e) { console.warn(e) }
			return false;
		}
		function unknownUrlHandler(url) {
			if (!url) throw 'Invalid argument';
			return GlobalXHR.get(url, { anonymous: true }).then(({document}) => document, reason => null).then(function(document) {
				if (!(document instanceof HTMLDocument)) return;
				const title = document.head.querySelector('title'), patterns = {
					'biography': [/\b(?:bio(?:graphy))\b/i],
					'discography': [/\b(?:discography)\b/i],
					'interview': [/\b(?:interview)\b/i],
				}, getTypes = content => Object.keys(patterns).filter(linkType => linkType in urlLinkTypes[entity]
					&& patterns[linkType].some(rx => rx.test(content)));
				let types = title != null ? getTypes(title.textContent) : [ ];
				if (types.length <= 0) types = getTypes(document.body.textContent);
				if (debugLogging) console.debug('Attempt to get URL type from page content:', url, types, urlLinkTypes[entity]);
				if (types.length <= 0) return Promise.reject('URL type could not be determined from page content');
				if (types.length > 1) return Promise.reject('Ambiguous page content');
				return urlLinkTypes[entity][types[0]] || Promise.reject('Undetermined link type');
			}).catch(function(reason) {
				const linkType = GM_getValue('unknown_url_class', 'unknown');
				if (!(linkType < 0)) return urlLinkTypes[entity][linkType]
					|| Promise.reject(`Undetermined URL link type for ${entity} (${url})`);
			});
		}

		const urlLinkTypes = {
			artist: {
				'ignored': -1, 'unknown': undefined,
				'apple music': 1131, 'bandcamp': 718, 'soundcloud': 291, 'youtube music': 1080,
				'youtube': 193, 'purevolume': 174, 'BookBrainz': 852, 'CPDL': 981, 'discogs': 180,
				'IMDb': 178, 'IMSLP': 754, 'last.fm': 840, 'secondhandsongs': 307, 'setlistfm': 816,
				'songkick': 785, 'vgmdb': 191, 'VIAF': 310, 'wikidata': 352, 'wikipedia': 179,
				'allmusic': 283, 'bandsintown': 862, 'CD Baby': 919, 'official homepage': 183,
				'fanpage': 172, 'biography': 182, 'discography page': 184, 'interview': 707, 'image': 173,
				'lyrics': 197, 'social network': 192,  'video channel': 303, 'online community': 185,
				'art gallery': 1192, 'blog': 199, 'crowdfunding': 902, 'patronage': 897, 'ticketing': 1193,
				'purchase for mail-order': 175, 'purchase for download': 176, 'download for free': 177,
				'free streaming': 194, 'streaming': 978, 'other databases': 188,
			},
			label: {
				'ignored': -1, 'unknown': undefined,
				'official site': 219, 'lyrics': 982, 'blog': 224, 'history site': 211, 'catalog site': 212,
				'logo': 213, 'image': 213, 'fanpage': 214, 'crowdfunding': 903, 'patronage': 899, 'ticketing': 1194,
				'social network': 218, 'soundcloud': 290, 'video channel': 304, 'youtube': 225,
				'purchase for mail-order': 960, 'purchase for download': 959, 'download for free': 958,
				'free streaming': 997, 'streaming': 1005, 'apple music': 1130, 'bandcamp': 719,
				'other databases': 222, 'BookBrainz': 851, 'discogs': 217, 'IMDb': 313, 'last.fm': 838,
				'secondhandsongs': 977, 'vgmdb': 210, 'VIAF': 311, 'wikidata': 354, 'wikipedia': 216,
			},
			place: {
				'ignored': -1, 'unknown': undefined,
				'official homepage': 363, 'blog': 627, 'image': 396, 'social network': 429,
				'soundcloud': 940, 'video channel': 495, 'youtube': 528, 'crowdfunding': 909,
				'fanpage': 1191, 'history site': 984, 'patronage': 900, 'ticketing': 1195,
				'other databases': 561, 'bandsintown': 861, 'discogs': 705, 'geonames': 934, 'IMDb': 706,
				'last.fm': 837, 'setlistfm': 817, 'songkick': 787, 'vgmdb': 1013, 'VIAF': 920,
				'wikidata': 594, 'wikipedia': 595,
			},
			series: {
				'ignored': -1, 'unknown': undefined,
				'official homepage': 745, 'schedule': 1083, 'social network': 784, 'soundcloud': 870,
				'podcast feed': 915, 'video channel': 805, 'youtube': 792, 'crowdfunding': 910,
				'fanpage': 1189, 'patronage': 901, 'ticketing': 1196, 'other databases': 746,
				'BookBrainz': 1167, 'discogs': 747, 'setlistfm': 938, 'VIAF': 1001, 'wikidata': 749,
				'wikipedia': 744,
			},
		};
		let linkTypeId = typeIdFromUrl(url);
		if (!linkTypeId && isDomainpart(name, url)) linkTypeId = urlLinkTypes[entity]['official homepage'];
		if (linkTypeId < 0) return alert('Dropped link is invalid or unrecognized type');
		Promise.all([
			mbApiRequest(entity + '/' + mbid, { inc: 'url-rels' }),
			linkTypeId > 0 ? Promise.resolve(linkTypeId) : unknownUrlHandler(url),
		]).then(function([entry, linkTypeId]) {
			return entry.relations && entry.relations.some(function(relation) {
				if (relation['target-type'] == 'url') try {
					const urls = [url, relation.url.resource].map(url => new URL(url));
					return ['hostname', 'pathname'].every(prop => urls[0][prop] == urls[1][prop]);
				} catch(e) { console.warn(e) }
				return false;
			}) ? Promise.reject('Dropped link is already related') : LocalXHR.post('/ws/js/edit/create', {
				edits: [{
					edit_type: 90,
					linkTypeID: linkTypeId,
					entities: [{ entityType: entity, gid: mbid }, { entityType: 'url', name: url }],
				}],
				//editNote: 'Entry URL added from external source',
				makeVotable: true,
			}, { responseType: 'json', recoverableErrors: [429] }).then(({json}) => json.edits).then(function(edits) {
				console.log('Entity edit result:', edits);
				if (!edits.some(edit => edit.response == 1)) return Promise.reject('Edit unsuccessfull');
				document.location.reload();
			});
		}).catch(alert);
	});
	return false;
};