// ==UserScript== // @name Import bfan.link releases to MusicBrainz // @description Import bfan.link smart links with Harmony and add their remaining URL relationships to MusicBrainz. Bfan is Believe Digital's link aggregator service. // @version 2026.08.15.1 // @author Raman Sinclair // @namespace https://github.com/murdos/musicbrainz-userscripts/ // @downloadURL https://raw.githubusercontent.com/murdos/musicbrainz-userscripts/dist/bfan_importer.user.js // @updateURL https://raw.githubusercontent.com/murdos/musicbrainz-userscripts/dist/bfan_importer.user.js // @match https://bfan.link/* // @match https://*.bfan.link/* // @run-at document-idle // @icon https://raw.githubusercontent.com/murdos/musicbrainz-userscripts/master/assets/images/Musicbrainz_import_logo.png // ==/UserScript== (function () { 'use strict'; const HARMONY_SERVICE_PREFERENCE = ['spotify', 'tidal', 'deezer', 'bandcamp', 'apple', 'itunes']; const TRACKING_PARAMETER_NAMES = new Set(['at', 'ct', 'ffm', 'lid', 'ref', 'ref_', 'src', 'tag']); const FREE_STREAMING_SERVICES = new Set(['boomplay', 'deezer', 'spotify', 'youtube']); const STREAMING_SERVICES = new Set(['amazon', 'apple', 'itunes', 'kkbox', 'pandora', 'qobuz', 'soundcloud', 'tidal', 'youtubemusic']); const URL_RELATIONSHIP_TYPES = { asin: 77, purchaseForDownload: 74, downloadForFree: 75, otherDatabases: 82, streamForFree: 85, streaming: 980 }; function normalizeServiceName(service) { const normalized = service.trim().toLowerCase().replaceAll(/[^a-z0-9]/g, ''); if (normalized === 'applemusic') return 'apple'; if (normalized === 'amazonmusic') return 'amazon'; if (normalized === 'ytmusic') return 'youtubemusic'; return normalized; } function removeTrackingParameters(url) { for (const name of [...url.searchParams.keys()]) { if (name.toLowerCase().startsWith('utm_') || TRACKING_PARAMETER_NAMES.has(name.toLowerCase())) { url.searchParams.delete(name); } } } /** Produce stable provider URLs suitable for Harmony and exact MusicBrainz URL lookup. */ function normalizeServiceUrl(rawUrl, rawService) { const service = normalizeServiceName(rawService); const url = new URL(rawUrl); const nestedPandoraUrl = url.searchParams.get('$desktop_url'); if (nestedPandoraUrl) return normalizeServiceUrl(nestedPandoraUrl, 'pandora'); url.protocol = 'https:'; url.hash = ''; if (service === 'apple' || service === 'itunes') { url.hostname = 'music.apple.com'; url.pathname = url.pathname.replace(/\/id(\d+)\/?$/, '/$1'); url.search = ''; } else if (service === 'spotify') { url.hostname = 'open.spotify.com'; url.search = ''; } else if (service === 'tidal') { url.hostname = 'tidal.com'; url.search = ''; } else if (service === 'deezer') { url.hostname = 'www.deezer.com'; url.pathname = url.pathname.replace(/^\/[a-z]{2}\/album\//i, '/album/'); url.search = ''; } else if (service === 'boomplay') { url.hostname = 'www.boomplay.com'; url.search = ''; } else if (service === 'youtube' || service === 'youtubemusic') { const list = url.searchParams.get('list'); const video = url.searchParams.get('v'); url.search = ''; if (list) url.searchParams.set('list', list); if (!list && video) url.searchParams.set('v', video); } else { removeTrackingParameters(url); } return url.toString(); } function pathValueAfter(url, segment) { const parts = url.pathname.split('/').filter(Boolean); const segmentIndex = parts.indexOf(segment); if (segmentIndex < 0) return undefined; return parts.at(-1); } function hostnameMatches(url, domain) { return url.hostname === domain || url.hostname.endsWith(`.${domain}`); } function amazonAsinFromUrl(rawUrl) { try { const url = new URL(rawUrl); if (!/(^|\.)amazon\.[a-z]{2,}(\.[a-z]{2})?$/i.test(url.hostname)) return undefined; const parts = url.pathname.split('/').filter(Boolean); const asinIndex = parts.findIndex((part, index) => /^(dp|product)$/i.test(part) && index < parts.length - 1); const asin = asinIndex >= 0 ? parts[asinIndex + 1] : undefined; return asin && /^[A-Z0-9]{10}$/i.test(asin) ? asin.toUpperCase() : undefined; } catch { return undefined; } } /** Identify the provider entity represented by a URL while ignoring storefront and tracking differences. */ function canonicalServiceUrlKey(rawUrl, rawService) { const service = normalizeServiceName(rawService); try { const url = new URL(rawUrl); let providerId; switch (service) { case 'apple': case 'itunes': if (!hostnameMatches(url, 'apple.com')) return rawUrl; providerId = pathValueAfter(url, 'album'); return providerId ? `apple:album:${providerId}` : normalizeServiceUrl(rawUrl, service); case 'spotify': if (!hostnameMatches(url, 'spotify.com')) return rawUrl; providerId = pathValueAfter(url, 'album'); return providerId ? `${service}:album:${providerId}` : normalizeServiceUrl(rawUrl, service); case 'tidal': if (!hostnameMatches(url, 'tidal.com')) return rawUrl; providerId = pathValueAfter(url, 'album'); return providerId ? `${service}:album:${providerId}` : normalizeServiceUrl(rawUrl, service); case 'deezer': if (!hostnameMatches(url, 'deezer.com')) return rawUrl; providerId = pathValueAfter(url, 'album'); return providerId ? `${service}:album:${providerId}` : normalizeServiceUrl(rawUrl, service); case 'amazon': case 'amazonstore': { const asin = amazonAsinFromUrl(rawUrl); if (asin) return `amazon:asin:${asin}`; if (service === 'amazonstore') return normalizeServiceUrl(rawUrl, service); if (!url.hostname.startsWith('music.amazon.')) return rawUrl; providerId = pathValueAfter(url, 'albums'); return providerId ? `amazon:album:${providerId}` : normalizeServiceUrl(rawUrl, service); } case 'youtube': case 'youtubemusic': if (!hostnameMatches(url, 'youtube.com')) return rawUrl; providerId = url.searchParams.get('list') ?? undefined; return providerId ? `${service}:playlist:${providerId}` : normalizeServiceUrl(rawUrl, service); case 'qobuz': if (!hostnameMatches(url, 'qobuz.com')) return rawUrl; providerId = pathValueAfter(url, 'album'); return providerId ? `qobuz:album:${providerId}` : normalizeServiceUrl(rawUrl, service); default: return normalizeServiceUrl(rawUrl, service); } } catch { return rawUrl; } } function extractReleaseUrlResources(response) { if (!response || typeof response !== 'object') return []; const relations = response['relations']; if (!Array.isArray(relations)) return []; const resources = []; for (const relation of relations) { const resource = relation.url?.resource; if (resource) resources.push(resource); } return resources; } function findCanonicallyMatchedLinkUrls(links, releaseResources) { return links.filter(link => { const linkKey = canonicalServiceUrlKey(link.url, link.service); return releaseResources.some(resource => canonicalServiceUrlKey(resource, link.service) === linkKey); }).map(link => link.url); } function isLegacyBoomplayAlbumUrl(rawUrl) { try { const url = new URL(rawUrl); return hostnameMatches(url, 'boomplay.com') && /^\/albums\/\d+\/?$/.test(url.pathname); } catch { return false; } } /** Add the current destinations of legacy numeric Boomplay album URLs as comparison aliases. */ async function expandLegacyBoomplayResources(resources, resolveUrl) { const aliases = await Promise.all(resources.map(async resource => { if (!isLegacyBoomplayAlbumUrl(resource)) return resource; try { return await resolveUrl(resource); } catch { return resource; } })); return [...new Set([...resources, ...aliases])]; } /** Return every resolved provider link that is not linked to the matched release. */ function findMissingLinks(links, matchedUrls) { return links.filter(link => !matchedUrls.has(link.url)); } function chooseHarmonyLink(links) { for (const preferredService of HARMONY_SERVICE_PREFERENCE) { const match = links.find(link => normalizeServiceName(link.service) === preferredService); if (match) return match; } return undefined; } function relationshipTypeFor(link) { const action = link.action.toLowerCase(); const service = normalizeServiceName(link.service); if (amazonAsinFromUrl(link.url)) return URL_RELATIONSHIP_TYPES.asin; if (action.includes('free') && action.includes('download')) return URL_RELATIONSHIP_TYPES.downloadForFree; if (action.includes('buy') || action.includes('download') || ['amazonstore', 'beatport'].includes(service)) { return URL_RELATIONSHIP_TYPES.purchaseForDownload; } if (FREE_STREAMING_SERVICES.has(service)) { return URL_RELATIONSHIP_TYPES.streamForFree; } if (STREAMING_SERVICES.has(service)) { return URL_RELATIONSHIP_TYPES.streaming; } if (action.includes('play') || action.includes('listen') || action.includes('stream')) { return URL_RELATIONSHIP_TYPES.streamForFree; } return URL_RELATIONSHIP_TYPES.otherDatabases; } function readReleaseIds(relations) { if (!Array.isArray(relations)) return []; const ids = []; for (const relation of relations) { if (!relation || typeof relation !== 'object') continue; const release = relation['release']; if (!release || typeof release !== 'object') continue; const id = release['id']; if (typeof id === 'string') ids.push(id); } return ids; } /** Collect every release matched by any of the queried provider URLs. */ function findReleaseMatches(response) { if (!response || typeof response !== 'object') return []; const record = response; const urlEntries = Array.isArray(record['urls']) ? record['urls'] : [record]; const matches = new Map(); for (const entry of urlEntries) { if (!entry || typeof entry !== 'object') continue; const urlRecord = entry; const resource = urlRecord['resource']; if (typeof resource !== 'string') continue; for (const releaseId of readReleaseIds(urlRecord['relations'])) { const resources = matches.get(releaseId) ?? new Set(); resources.add(resource); matches.set(releaseId, resources); } } return [...matches].map(([releaseId, resources]) => ({ releaseId, matchedUrls: [...resources] })); } const SERVER_PREFERENCE_KEY = 'smartlink-mb-importer:server'; const HYDRATION_SETTLE_MS = 1_000; const MUSICBRAINZ_SERVERS = ['https://musicbrainz.org', 'https://beta.musicbrainz.org']; function pageCacheKey(config) { return `${config.id}-mb-importer:v1:${window.location.origin}${window.location.pathname.replace(/\/$/, '')}`; } function readPageCache(config) { try { const parsed = JSON.parse(window.localStorage.getItem(pageCacheKey(config)) ?? 'null'); if (parsed && typeof parsed === 'object') { const record = parsed; if (record['links'] && typeof record['links'] === 'object') { return { links: record['links'] }; } } } catch { // Ignore unavailable storage and obsolete/corrupt cache entries. } return { links: {} }; } function savePageCache(config, cache) { try { window.localStorage.setItem(pageCacheKey(config), JSON.stringify(cache)); } catch { // The importer still works for this page load when storage is unavailable. } } function readServerPreference() { try { const stored = window.localStorage.getItem(SERVER_PREFERENCE_KEY); if (MUSICBRAINZ_SERVERS.includes(stored)) return stored; } catch { // Fall through to production. } return MUSICBRAINZ_SERVERS[0]; } function saveServerPreference(server) { try { window.localStorage.setItem(SERVER_PREFERENCE_KEY, server); } catch { // Preference persistence is optional. } } function gmRequest() { const userscriptGlobal = globalThis; return userscriptGlobal.GM?.xmlHttpRequest ?? userscriptGlobal.GM_xmlhttpRequest; } function followRedirect(sourceUrl) { const request = gmRequest(); if (!request) return Promise.reject(new Error('No userscript cross-origin request API is available')); return new Promise((resolve, reject) => { const failed = () => { reject(new Error(`Could not resolve ${sourceUrl}`)); }; request({ method: 'GET', url: sourceUrl, timeout: 20_000, onload: response => { const destination = response.finalUrl ?? response.responseURL; if (response.status >= 200 && response.status < 400 && destination) resolve(destination);else failed(); }, onerror: failed, ontimeout: failed }); }); } function waitForServiceElements(config) { return new Promise(resolve => { let settleTimer; let finished = false; const finish = elements => { if (finished) return; finished = true; observer.disconnect(); if (settleTimer !== undefined) window.clearTimeout(settleTimer); window.clearTimeout(maximumWaitTimer); resolve(elements); }; const waitUntilStable = () => { const elements = config.collectServiceElements(); if (elements.length === 0) return; if (settleTimer !== undefined) window.clearTimeout(settleTimer); settleTimer = window.setTimeout(() => { const stableAnchors = config.collectServiceElements(); if (stableAnchors.length > 0) finish(stableAnchors);else waitUntilStable(); }, HYDRATION_SETTLE_MS); }; const observer = new MutationObserver(() => { waitUntilStable(); }); const maximumWaitTimer = window.setTimeout(() => { finish(config.collectServiceElements()); }, 20_000); observer.observe(document.documentElement, { childList: true, subtree: true }); waitUntilStable(); }); } async function resolveServiceLinks(config, elements, cache) { const resolved = await Promise.all(elements.map(async element => { const cached = cache.links[element.cacheKey]; if (cached?.sourceUrl === element.sourceUrl) { const refreshed = { ...cached, label: element.label, action: element.action }; cache.links[element.cacheKey] = refreshed; return refreshed; } try { const destination = await config.resolveDestination(element); const link = { service: element.service, label: element.label, action: element.action, sourceUrl: element.sourceUrl, url: normalizeServiceUrl(destination, element.service) }; cache.links[element.cacheKey] = link; return link; } catch (error) { console.warn(`${config.siteName} importer: could not resolve ${element.label}`, error); return undefined; } })); savePageCache(config, cache); return resolved.filter(link => link !== undefined); } function lookupReleases(links, server) { const resources = [...new Set(links.map(link => link.url))]; if (resources.length === 0) return Promise.resolve([]); const endpoint = new URL('/ws/2/url', server); for (const resource of resources) endpoint.searchParams.append('resource', resource); endpoint.searchParams.set('inc', 'release-rels'); endpoint.searchParams.set('fmt', 'json'); return fetch(endpoint, { headers: { Accept: 'application/json' } }).then(async response => { if (!response.ok) throw new Error(`MusicBrainz URL lookup failed with HTTP ${response.status}`); return findReleaseMatches(await response.json()); }); } async function includeReleaseRelationships(links, server, match) { const endpoint = new URL(`/ws/2/release/${match.releaseId}`, server); endpoint.searchParams.set('inc', 'url-rels'); endpoint.searchParams.set('fmt', 'json'); const response = await fetch(endpoint, { headers: { Accept: 'application/json' } }); if (!response.ok) throw new Error(`MusicBrainz release lookup failed with HTTP ${response.status}`); let resources = extractReleaseUrlResources(await response.json()); if (links.some(link => normalizeServiceName(link.service) === 'boomplay')) { resources = await expandLegacyBoomplayResources(resources, followRedirect); } return { releaseId: match.releaseId, matchedUrls: findCanonicallyMatchedLinkUrls(links, resources) }; } function panelId(config) { return `${config.id}-mb-importer`; } function styleId(config) { return `${panelId(config)}-style`; } function addStyles(config) { const importerPanelId = panelId(config); if (document.getElementById(styleId(config))) return; const style = document.createElement('style'); style.id = styleId(config); style.textContent = ` #${importerPanelId} { position: fixed; top: 16px; right: 16px; z-index: 2147483646; width: min(340px, calc(100vw - 32px)); max-height: calc(100vh - 32px); overflow-y: auto; box-sizing: border-box; padding: 12px; border-radius: 8px; background: rgba(255, 255, 255, 0.96); color: #222; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.22); font: 13px/1.4 Arial, sans-serif; } @media (max-width: 720px) { #${importerPanelId} { top: auto; right: 8px; bottom: 8px; width: min(340px, calc(100vw - 16px)); max-height: 50vh; } } #${importerPanelId} .smartlink-mb-heading, #${importerPanelId} .smartlink-mb-controls, #${importerPanelId} .smartlink-mb-buttons { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } #${importerPanelId} .smartlink-mb-heading { font-weight: bold; margin-bottom: 8px; } #${importerPanelId} [hidden] { display: none !important; } #${importerPanelId} .smartlink-mb-controls { margin: 8px 0; } #${importerPanelId} .smartlink-mb-status { color: #555; } #${importerPanelId} .smartlink-mb-release { color: #0875bd; font-weight: bold; } #${importerPanelId} .smartlink-mb-button { display: inline-flex; align-items: center; gap: 6px; min-height: 30px; padding: 5px 10px; box-sizing: border-box; border: 1px solid #a7a7a7; border-radius: 5px; background: #f4f4f4; color: #222; cursor: pointer; font: bold 12px Arial, sans-serif; text-decoration: none; } #${importerPanelId} .smartlink-mb-button:hover:not(:disabled) { background: #fff; } #${importerPanelId} .smartlink-mb-button:disabled { cursor: default; opacity: 0.55; } #${importerPanelId} .smartlink-mb-button img { flex: none; } .smartlink-mb-present { position: relative; outline: 3px solid #32a852 !important; } .smartlink-mb-present::after { content: '\u2713'; position: absolute; top: -7px; right: -7px; width: 21px; height: 21px; border-radius: 25%; background: #ba478f; color: #fff; font: bold 15px/21px Arial, sans-serif; text-align: center; z-index: 2; } `; document.head.appendChild(style); } function createPanel(config, server) { addStyles(config); document.getElementById(panelId(config))?.remove(); const root = document.createElement('section'); root.id = panelId(config); root.innerHTML = `