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; };