NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==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;
};