// ==UserScript== // @name MusicBrainz Smartlink importer // @description Import a release from smart links aggregators with Harmony and add their remaining URL relationships to MusicBrainz. // @version 2026.09.16.1 // @author Raman Sinclair // @namespace https://github.com/murdos/musicbrainz-userscripts/ // @downloadURL https://raw.githubusercontent.com/murdos/musicbrainz-userscripts/dist/smartlink_importer.user.js // @updateURL https://raw.githubusercontent.com/murdos/musicbrainz-userscripts/dist/smartlink_importer.user.js // @match https://album.link/* // @match https://*.album.link/* // @match https://band.link/* // @match https://*.band.link/* // @match https://bfan.link/* // @match https://*.bfan.link/* // @match https://fanlink.tv/* // @match https://*.fanlink.tv/* // @match https://ffm.to/* // @match https://*.ffm.to/* // @match https://orcd.co/* // @match https://*.orcd.co/* // @match https://promolinks.me/* // @match https://*.promolinks.me/* // @match https://song.link/* // @match https://*.song.link/* // @connect * // @grant GM.getValue // @grant GM.setValue // @grant GM.xmlHttpRequest // @grant GM_getValue // @grant GM_setValue // @grant GM_xmlhttpRequest // @run-at document-idle // @icon https://metabrainz.org/static/img/projects/musicbrainz.svg // ==/UserScript== (function () { 'use strict'; /** Shared domain logic for the Smartlink importer. */ const HARMONY_SERVICE_PREFERENCE = ['spotify', 'tidal', 'deezer', 'bandcamp', 'apple', 'itunes']; const TRACKING_PARAMETER_NAMES = new Set(['at', 'ct', 'ffm', 'lid', 'ref', 'ref_', 'si', 'src', 'tag']); const IGNORED_SERVICES = new Set(['junodownload']); const PHYSICAL_MEDIA_SERVICES = new Set(['amazoncdvinyl', 'barnesnoble', 'hmvjapan', 'imusic', 'sanity', 'towerrecords']); 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, discographyEntry: 288, otherDatabases: 82, streamForFree: 85, streaming: 980 }; function findStringProperty(value, names) { if (!value || typeof value !== 'object') return undefined; for (const [key, child] of Object.entries(value)) { if (names.has(key.toLowerCase()) && typeof child === 'string') return child; } for (const child of Object.values(value)) { const found = findStringProperty(child, names); if (found) return found; } return undefined; } /** Extract the destination URL carried in an FFM `cd` tracking payload. */ function decodeFfmDestination(sourceUrl) { try { const encoded = new URL(sourceUrl).searchParams.get('cd'); if (!encoded) return undefined; const base64 = encoded.replaceAll('-', '+').replaceAll('_', '/').padEnd(Math.ceil(encoded.length / 4) * 4, '='); const binary = atob(base64); const bytes = Uint8Array.from(binary, character => character.charCodeAt(0)); const payload = JSON.parse(new TextDecoder().decode(bytes)); return findStringProperty(payload, new Set(['desturl', 'destinationurl', 'destination'])); } catch { return undefined; } } 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 isIgnoredService(service) { return IGNORED_SERVICES.has(normalizeServiceName(service)); } /** Avoid attaching retailer pages for a physical edition to a matched digital release. */ function isPhysicalMediaLink(service, action) { return PHYSICAL_MEDIA_SERVICES.has(normalizeServiceName(service)) || /\b(?:cd|vinyl|cassette)\b/i.test(action); } function isSearchFallbackServiceUrl(rawUrl) { try { const url = new URL(rawUrl); const pathParts = url.pathname.toLowerCase().split('/').filter(Boolean); return pathParts.includes('search') || hostnameMatches(url, 'youtube.com') && pathParts.at(-1) === 'results'; } catch { return false; } } /** Identify provider entities that represent a track rather than a release. */ function isTrackOnlyServiceUrl(rawUrl, rawService) { const service = normalizeServiceName(rawService); try { const url = new URL(rawUrl); const pathParts = url.pathname.toLowerCase().split('/').filter(Boolean); const trackSegments = new Set(['episode', 'song', 'songs', 'track', 'tracks']); if (service === 'youtube' || service === 'youtubemusic') { return (hostnameMatches(url, 'youtu.be') || pathParts.at(-1) === 'watch') && !url.searchParams.has('list'); } if (service === 'soundcloud') return pathParts.length >= 2 && !pathParts.includes('sets'); if (service === 'pandora') return /\/(?:TR:|track\/)/i.test(url.pathname); if (['amazon', 'apple', 'bandcamp', 'boomplay', 'deezer', 'itunes', 'kkbox', 'qobuz', 'spotify', 'tidal'].includes(service)) { return pathParts.some(part => trackSegments.has(part)); } } catch { return false; } return false; } /** Explain why a provider link is not applicable to a MusicBrainz release. */ function skipReasonForServiceLink(service, action, sourceUrl) { if (isIgnoredService(service)) return 'Ignored service'; if (isPhysicalMediaLink(service, action)) return 'Physical-media link'; if (isSearchFallbackServiceUrl(sourceUrl)) return 'Search fallback'; if (isTrackOnlyServiceUrl(sourceUrl, service)) return 'Track-only link'; return undefined; } 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 === 'qobuz') { url.search = ''; } else if (service === 'amazon' && url.hostname.startsWith('music.amazon.') && /\/albums\//i.test(url.pathname)) { 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 (service === 'officialsite') return URL_RELATIONSHIP_TYPES.discographyEntry; 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 readMatchedReleases(relations) { if (!Array.isArray(relations)) return []; const releases = []; for (const relation of relations) { if (!relation || typeof relation !== 'object') continue; const release = relation['release']; if (!release || typeof release !== 'object') continue; const record = release; const id = record['id']; if (typeof id !== 'string') continue; releases.push({ id, title: typeof record['title'] === 'string' ? record['title'] : undefined, disambiguation: typeof record['disambiguation'] === 'string' ? record['disambiguation'] : undefined, date: typeof record['date'] === 'string' ? record['date'] : undefined, country: typeof record['country'] === 'string' ? record['country'] : undefined }); } return releases; } /** 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 release of readMatchedReleases(urlRecord['relations'])) { const { id: _id, ...releaseDetails } = release; const match = matches.get(release.id) ?? { releaseId: release.id, ...releaseDetails, matchedUrls: [] }; if (!match.matchedUrls.includes(resource)) match.matchedUrls.push(resource); matches.set(release.id, match); } } return [...matches.values()]; } /** Return an object record when the value is a non-array object. */ function record(value) { return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined; } function nextCacheKey(counters, service) { const count = (counters.get(service) ?? 0) + 1; counters.set(service, count); return count === 1 ? service : `${service}:${count}`; } /** Read the entity type and canonical smart-link URL from album.link’s Next.js payload. */ function extractAlbumLinkPageData(payload) { const root = record(payload); const props = record(root?.['props']); const pageProps = record(props?.['pageProps']); const pageData = record(pageProps?.['pageData']); const entityData = record(pageData?.['entityData']); const entityType = entityData?.['type']; const canonicalUrl = pageData?.['pageUrl']; if (typeof entityType !== 'string' || typeof canonicalUrl !== 'string' || !canonicalUrl) return undefined; return { canonicalUrl, entityType }; } /** Remove tracking parameters and fragments, preferring album.link’s canonical URL. */ function cleanAlbumLinkPageUrl(currentUrl, canonicalUrl) { let url; try { url = new URL(canonicalUrl ?? currentUrl, currentUrl); } catch { url = new URL(currentUrl); } url.search = ''; url.hash = ''; return url.toString(); } /** Extract “Listen” or “Purchase and download” from album.link’s accessible link label. */ function actionFromAriaLabel(ariaLabel) { if (/^purchase and download\b/i.test(ariaLabel)) return 'Purchase and download'; if (/^listen to\b/i.test(ariaLabel)) return 'Listen'; return ''; } /** Extract the provider name without including text embedded in its SVG icon. */ function serviceLabelFromAriaLabel(ariaLabel) { return ariaLabel.match(/\s+on\s+(.+)$/i)?.[1]?.trim() ?? ''; } function collectAlbumLinkServiceElements() { const counters = new Map(); const elements = []; for (const element of document.querySelectorAll('a[data-test-id="link"][href]')) { const ariaLabel = element.getAttribute('aria-label') ?? ''; const label = serviceLabelFromAriaLabel(ariaLabel) || element.textContent.trim(); const service = normalizeServiceName(label); const action = actionFromAriaLabel(ariaLabel); if (!service || !element.href) continue; elements.push({ cacheKey: nextCacheKey(counters, service), element, service, label, action, sourceUrl: element.href, skipReason: skipReasonForServiceLink(service, action, element.href) }); } return elements; } function createAlbumLinkConfig() { const nextData = document.querySelector('script#__NEXT_DATA__')?.textContent; if (!nextData) return undefined; let pageData; try { pageData = extractAlbumLinkPageData(JSON.parse(nextData)); } catch { return undefined; } if (!pageData) return undefined; const cleanUrl = cleanAlbumLinkPageUrl(window.location.href, pageData.canonicalUrl); if (new URL(cleanUrl).origin === window.location.origin && cleanUrl !== window.location.href) { window.history.replaceState(window.history.state, '', cleanUrl); } if (pageData.entityType !== 'album') return undefined; return { id: 'albumlink', siteName: 'album.link', collectServiceElements: collectAlbumLinkServiceElements, resolveDestination: element => element.sourceUrl }; } function collectBandLinkServiceElements() { const counters = new Map(); const elements = []; for (const element of document.querySelectorAll('.mod-music-services a.el-link[href]')) { const label = element.querySelector('.el-link__service-text')?.textContent.trim() || ''; const service = normalizeServiceName(label); const action = element.querySelector('.el-link__action')?.textContent.trim() || ''; if (!service || !element.href) continue; elements.push({ cacheKey: nextCacheKey(counters, service), element, service, label, action, sourceUrl: element.href, skipReason: skipReasonForServiceLink(service, action, element.href) }); } return elements; } function createBandLinkConfig() { return { id: 'bandlink', siteName: 'BandLink', collectServiceElements: collectBandLinkServiceElements, resolveDestination: element => element.sourceUrl }; } /** Read release-provider destinations from bfan.link’s Next.js hydration payload. */ function extractBfanServiceData(payload) { const root = record(payload); const props = record(root?.['props']); const pageProps = record(props?.['pageProps']); const backlink = record(pageProps?.['backlinkStaticData']); const stores = record(backlink?.['stores']); if (!backlink || !stores) return []; const mode = backlink['mode'] === 'prerelease' ? 'prereleaseLandingCTAs' : 'postreleaseLandingCTAs'; const ctas = record(backlink[mode]); const options = record(ctas?.['options']); const displayOrder = Array.isArray(ctas?.['displayOrder']) ? ctas['displayOrder'].filter(value => typeof value === 'string') : Object.keys(stores); const links = []; for (const storeName of displayOrder) { const store = record(stores[storeName]); const urls = record(store?.['urls']); const option = record(options?.[storeName]); const sourceUrl = urls?.['default']; if (typeof sourceUrl !== 'string' || !sourceUrl || option?.['isDisplayed'] === false) continue; const service = normalizeServiceName(storeName); if (!service) continue; links.push({ service, label: typeof store?.['displayName'] === 'string' ? store['displayName'] : storeName, action: typeof option?.['label'] === 'string' ? option['label'] : '', sourceUrl }); } return links; } function readServiceData$2() { const nextData = document.querySelector('script#__NEXT_DATA__')?.textContent; if (!nextData) return []; try { return extractBfanServiceData(JSON.parse(nextData)); } catch { return []; } } function collectBfanServiceElements() { const dataByService = new Map(readServiceData$2().map(data => [data.service, data])); const elements = []; for (const element of document.querySelectorAll('[data-testid="call-to-actions"] > [data-testid]')) { const rawService = element.dataset['testid'] ?? ''; const service = normalizeServiceName(rawService); const data = dataByService.get(service); if (!data) continue; const action = element.querySelector('button')?.textContent.trim() || data.action; elements.push({ cacheKey: service, element, service, label: element.querySelector('img[alt]')?.alt || data.label, action, sourceUrl: data.sourceUrl, skipReason: skipReasonForServiceLink(service, action, data.sourceUrl) }); } return elements; } function createBfanConfig() { return { id: 'bfan', siteName: 'bfan.link', collectServiceElements: collectBfanServiceElements, resolveDestination: element => element.sourceUrl }; } function serviceLabel(serviceName) { return serviceName.split(/[-_\s]+/).filter(Boolean).map(word => `${word.charAt(0).toUpperCase()}${word.slice(1)}`).join(' '); } /** Read active provider destinations from Fanlink’s `window.preloadLink` payload. */ function extractFanlinkServiceData(payload) { const services = record(payload)?.['services']; if (!Array.isArray(services)) return []; const links = []; for (const value of services) { const serviceData = record(value); const rawService = serviceData?.['service_name']; const sourceUrl = serviceData?.['url']; if (typeof rawService !== 'string' || typeof sourceUrl !== 'string' || !sourceUrl || serviceData['active'] === false) continue; const service = normalizeServiceName(rawService); if (!service) continue; links.push({ service, label: serviceLabel(rawService), sourceUrl }); } return links; } /** Parse the preload assignment from Fanlink’s inline page script. */ function extractFanlinkServiceDataFromScript(source) { const serializedPayload = /window\.preloadLink\s*=\s*(\{[\s\S]*?\});\s*window\.preloadCustomDomain\s*=/.exec(source)?.[1]; if (!serializedPayload) return []; try { return extractFanlinkServiceData(JSON.parse(serializedPayload)); } catch { return []; } } function readServiceData$1() { for (const script of document.scripts) { const links = extractFanlinkServiceDataFromScript(script.textContent); if (links.length > 0) return links; } return []; } function serviceFromElement(element) { const imageUrl = element.querySelector('.link-option-row-img')?.src; if (!imageUrl) return ''; try { return normalizeServiceName(new URL(imageUrl).pathname.split('/').pop()?.replace(/\.[^.]+$/, '') ?? ''); } catch { return ''; } } function collectFanlinkServiceElements() { const dataByService = new Map(); for (const data of readServiceData$1()) { const services = dataByService.get(data.service) ?? []; services.push(data); dataByService.set(data.service, services); } const counters = new Map(); const elements = []; for (const element of document.querySelectorAll('.link-options a.link-option-row')) { const service = serviceFromElement(element); const data = dataByService.get(service)?.shift(); const action = element.querySelector('.link-option-row-action')?.textContent.trim() || ''; if (!data) continue; elements.push({ cacheKey: nextCacheKey(counters, service), element, service, label: element.querySelector('img[alt]')?.alt || data.label, action, sourceUrl: data.sourceUrl, skipReason: skipReasonForServiceLink(service, action, data.sourceUrl) }); } return elements; } function getOptionalGlobal(name) { return Reflect.get(globalThis, name); } function getGmApi(name) { // Tampermonkey may only inject granted APIs when the script references them directly. const apis = {}; if (typeof GM !== 'undefined') { apis.getValue = GM.getValue; apis.setValue = GM.setValue; apis.xmlHttpRequest = GM.xmlHttpRequest; } if (!apis.getValue && typeof GM_getValue !== 'undefined') { apis.getValue = GM_getValue; } if (!apis.setValue && typeof GM_setValue !== 'undefined') { apis.setValue = GM_setValue; } if (!apis.xmlHttpRequest && typeof GM_xmlhttpRequest !== 'undefined') { apis.xmlHttpRequest = GM_xmlhttpRequest; } return apis[name]; } /** * Follow a provider link and return its final destination URL. * * This must use the userscript manager’s privileged request API. Provider redirects cross origins and generally do not expose CORS headers, so native `fetch` either rejects the request or returns an opaque response without an accessible final URL. */ function followRedirect(sourceUrl) { const request = getGmApi('xmlHttpRequest'); 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; if (response.status >= 200 && response.status < 400 && destination) resolve(destination);else failed(); }, onerror: failed, ontimeout: failed }); }); } function createFanlinkConfig() { return { id: 'fanlink', siteName: 'Fanlink', collectServiceElements: collectFanlinkServiceElements, resolveDestination: element => followRedirect(element.sourceUrl).catch(() => element.sourceUrl) }; } function collectFfmServiceElements() { const counters = new Map(); const elements = []; for (const element of document.querySelectorAll('a[service][href]')) { const rawService = element.getAttribute('service') ?? ''; const service = normalizeServiceName(rawService); const action = element.querySelector('.service-text, .music-service-cta-text__overflow')?.textContent.trim() || ''; if (!service || !element.href) continue; elements.push({ cacheKey: nextCacheKey(counters, service), element, service, label: element.querySelector('.service-title')?.textContent.trim() || element.querySelector('img[alt]')?.alt || rawService, action, sourceUrl: element.href, skipReason: skipReasonForServiceLink(service, action, element.href) }); } return elements; } function createFfmConfig() { return { id: 'ffm', siteName: window.location.hostname.endsWith('orcd.co') ? 'ORCD' : 'FFM', collectServiceElements: collectFfmServiceElements, resolveDestination: element => decodeFfmDestination(element.sourceUrl) ?? followRedirect(element.sourceUrl), mountPanel: panel => { const musicServices = document.querySelector('.music-services-section'); if (musicServices?.parentElement) musicServices.parentElement.insertBefore(panel, musicServices);else document.body.appendChild(panel); } }; } const PROVIDERS_BY_DOMAIN = { 'amazon.com': ['amazon', 'Amazon Music'], 'apple.com': ['apple', 'Apple Music'], 'bandcamp.com': ['bandcamp', 'Bandcamp'], 'boomplay.com': ['boomplay', 'Boomplay'], 'deezer.com': ['deezer', 'Deezer'], 'kkbox.com': ['kkbox', 'KKBOX'], 'pandora.com': ['pandora', 'Pandora'], 'qobuz.com': ['qobuz', 'Qobuz'], 'soundcloud.com': ['soundcloud', 'SoundCloud'], 'spotify.com': ['spotify', 'Spotify'], 'tidal.com': ['tidal', 'Tidal'], 'youtube.com': ['youtube', 'YouTube'] }; function providerForUrl(rawUrl) { try { const url = new URL(rawUrl); if (url.hostname === 'music.youtube.com') return ['youtubemusic', 'YouTube Music']; for (const [domain, provider] of Object.entries(PROVIDERS_BY_DOMAIN)) { if (url.hostname === domain || url.hostname.endsWith(`.${domain}`)) return provider; } } catch { // Ignore malformed structured data. } return undefined; } function findReleaseMetadata(value) { const object = record(value); if (!object) return undefined; const types = Array.isArray(object['@type']) ? object['@type'] : [object['@type']]; if (types.includes('MusicRelease') || types.includes('MusicAlbum')) return object; const graph = object['@graph']; if (!Array.isArray(graph)) return undefined; for (const node of graph) { const release = findReleaseMetadata(node); if (release) return release; } return undefined; } /** Read exact provider destinations from PromoLinks’ schema.org metadata. */ function extractPromoLinksServiceData(payload) { const sameAs = findReleaseMetadata(payload)?.['sameAs']; if (!Array.isArray(sameAs)) return []; const links = []; for (const sourceUrl of sameAs) { if (typeof sourceUrl !== 'string') continue; const provider = providerForUrl(sourceUrl); if (!provider) continue; const [service, label] = provider; links.push({ service: normalizeServiceName(service), label, sourceUrl }); } return links; } function readServiceData() { for (const script of document.querySelectorAll('script[type="application/ld+json"]')) { try { const links = extractPromoLinksServiceData(JSON.parse(script.textContent)); if (links.length > 0) return links; } catch { // Continue past unrelated or malformed JSON-LD blocks. } } return []; } function normalizedHref(rawUrl) { try { return new URL(rawUrl, window.location.href).href; } catch { return rawUrl; } } function collectPromoLinksServiceElements() { const anchorsByUrl = new Map(); for (const element of document.querySelectorAll('a[href]')) { const href = normalizedHref(element.href); const anchors = anchorsByUrl.get(href) ?? []; anchors.push(element); anchorsByUrl.set(href, anchors); } const counters = new Map(); const elements = []; for (const data of readServiceData()) { const element = anchorsByUrl.get(normalizedHref(data.sourceUrl))?.shift(); if (!element) continue; elements.push({ cacheKey: nextCacheKey(counters, data.service), element, service: data.service, label: data.label, action: '', sourceUrl: data.sourceUrl, skipReason: skipReasonForServiceLink(data.service, '', data.sourceUrl) }); } return elements; } function createPromoLinksConfig() { return { id: 'promolinks', siteName: 'PromoLinks.me', collectServiceElements: collectPromoLinksServiceElements, resolveDestination: element => element.sourceUrl }; } /** Build the parent release URL exposed for Songlink’s source track. */ function extractSonglinkSourceRelease(payload) { const root = record(payload); const props = record(root?.['props']); const pageProps = record(props?.['pageProps']); const pageData = record(pageProps?.['pageData']); const entityData = record(pageData?.['entityData']); if (entityData?.['type'] !== 'song' && entityData?.['type'] !== 'track') return undefined; const rawProvider = entityData['provider']; const rawAlbumId = entityData['albumId']; if (typeof rawProvider !== 'string' || typeof rawAlbumId !== 'string' && typeof rawAlbumId !== 'number') return undefined; const service = normalizeServiceName(rawProvider); const albumId = encodeURIComponent(String(rawAlbumId)); if (service === 'spotify') return { service, url: `https://open.spotify.com/album/${albumId}` }; if (service === 'tidal') return { service, url: `https://tidal.com/album/${albumId}` }; if (service === 'deezer') return { service, url: `https://www.deezer.com/album/${albumId}` }; if (service === 'amazon') return { service, url: `https://music.amazon.com/albums/${albumId}` }; return undefined; } /** Read the CTA and provider name from Songlink’s accessible link label. */ function parseSonglinkAriaLabel(ariaLabel, fallbackLabel = '') { const providerSeparator = ariaLabel.lastIndexOf(' on '); if (providerSeparator < 0) return { label: fallbackLabel.trim(), action: '' }; const description = ariaLabel.slice(0, providerSeparator).trim(); const label = ariaLabel.slice(providerSeparator + 4).trim() || fallbackLabel.trim(); let action = description; if (description.startsWith('Listen to ')) action = 'Listen';else if (description.startsWith('Purchase and download ')) action = 'Purchase and download';else { const titleSeparator = description.indexOf(' to '); if (titleSeparator >= 0) action = description.slice(0, titleSeparator).trim(); } return { label, action }; } function readSourceRelease() { const nextData = document.querySelector('script#__NEXT_DATA__')?.textContent; if (!nextData) return undefined; try { return extractSonglinkSourceRelease(JSON.parse(nextData)); } catch { return undefined; } } function collectSonglinkServiceElements() { const sourceRelease = readSourceRelease(); const counters = new Map(); const elements = []; for (const element of document.querySelectorAll('a[data-test-id="link"][href]')) { const fallbackLabel = element.querySelector('div:last-child')?.textContent.trim() ?? ''; const { label, action } = parseSonglinkAriaLabel(element.getAttribute('aria-label') ?? '', fallbackLabel); const service = normalizeServiceName(label); const sourceUrl = sourceRelease?.service === service ? sourceRelease.url : element.href; if (!service || !sourceUrl) continue; elements.push({ cacheKey: nextCacheKey(counters, service), element, service, label, action, sourceUrl, skipReason: skipReasonForServiceLink(service, action, sourceUrl) }); } return elements; } function createSonglinkConfig() { return { id: 'songlink', siteName: 'Songlink', collectServiceElements: collectSonglinkServiceElements, resolveDestination: element => element.sourceUrl }; } const SERVER_PREFERENCE_KEY = 'smartlink-mb-importer:server'; const MUSICBRAINZ_SERVERS = ['https://musicbrainz.org', 'https://beta.musicbrainz.org', 'https://musicbrainz.eu']; function isMusicBrainzServer(value) { return MUSICBRAINZ_SERVERS.includes(value); } function localServerPreference() { try { const stored = window.localStorage.getItem(SERVER_PREFERENCE_KEY); if (isMusicBrainzServer(stored)) return stored; } catch { // Fall through when page storage is unavailable. } return undefined; } async function readServerPreference() { const getValue = getGmApi('getValue'); if (getValue) { try { const stored = await getValue(SERVER_PREFERENCE_KEY); if (isMusicBrainzServer(stored)) return stored; // Migrate the old origin-scoped preference when the script is upgraded. const legacyPreference = localServerPreference(); if (legacyPreference) { await saveServerPreference(legacyPreference); return legacyPreference; } } catch { // Fall back to page storage when userscript storage is unavailable. } } return localServerPreference() ?? MUSICBRAINZ_SERVERS[0]; } async function saveServerPreference(server) { const setValue = getGmApi('setValue'); try { if (setValue) { await setValue(SERVER_PREFERENCE_KEY, server); return; } } catch { // Fall back to page storage when userscript storage is unavailable. } try { window.localStorage.setItem(SERVER_PREFERENCE_KEY, server); } catch { // Preference persistence is optional. } } 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-row { display: flex; align-items: center; gap: 7px; min-height: 18px; } #${importerPanelId} .smartlink-mb-status { color: #555; } #${importerPanelId} .smartlink-mb-matches { margin: 8px 0; padding: 0; list-style: none; } #${importerPanelId} .smartlink-mb-match + .smartlink-mb-match { margin-top: 8px; } #${importerPanelId} .smartlink-mb-match-release { font-weight: bold; } #${importerPanelId} .smartlink-mb-match-meta { color: #666; } #${importerPanelId} .smartlink-mb-match-links { margin: 2px 0 0; padding-left: 20px; } #${importerPanelId} .smartlink-mb-match-links a { overflow-wrap: anywhere; } #${importerPanelId} .smartlink-mb-progress { flex: none; width: 12px; height: 12px; box-sizing: border-box; border: 2px solid #bbb; border-top-color: #0875bd; border-radius: 50%; animation: smartlink-mb-spin 0.75s linear infinite; } #${importerPanelId} .smartlink-mb-retry { flex: none; width: 24px; height: 24px; padding: 0; border: 1px solid #aaa; border-radius: 50%; background: #f4f4f4; color: #333; cursor: pointer; font: bold 18px/20px Arial, sans-serif; } #${importerPanelId} .smartlink-mb-retry:hover { background: #fff; } #${importerPanelId} .smartlink-mb-retry:focus-visible { outline: 2px solid #0875bd; outline-offset: 2px; } @keyframes smartlink-mb-spin { to { transform: rotate(360deg); } } @media (prefers-reduced-motion: reduce) { #${importerPanelId} .smartlink-mb-progress { animation: none; } } #${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, .smartlink-mb-skipped { position: relative; } .smartlink-mb-present { outline: 3px solid #32a852 !important; } .smartlink-mb-skipped { outline: 3px solid #888 !important; filter: grayscale(1); opacity: 0.65; } .smartlink-mb-present::after, .smartlink-mb-skipped-badge { 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; } .smartlink-mb-present::after { content: '\u2713'; } .smartlink-mb-skipped-badge { background: #777; } `; document.head.appendChild(style); } function mountPanel(config, root) { if (config.mountPanel) config.mountPanel(root);else document.body.appendChild(root); } function createPanel(config, server) { addStyles(config); document.getElementById(panelId(config))?.remove(); const root = document.createElement('section'); root.id = panelId(config); root.setAttribute('aria-busy', 'true'); root.innerHTML = ` `; mountPanel(config, root); const serverSelect = root.querySelector('.smartlink-mb-server'); for (const value of MUSICBRAINZ_SERVERS) { const option = document.createElement('option'); option.value = value; option.textContent = new URL(value).hostname; option.selected = value === server; serverSelect.appendChild(option); } return { root, status: root.querySelector('.smartlink-mb-status'), progress: root.querySelector('.smartlink-mb-progress'), retryButton: root.querySelector('.smartlink-mb-retry'), matches: root.querySelector('.smartlink-mb-matches'), release: root.querySelector('.smartlink-mb-release'), server: serverSelect, harmonyButton: root.querySelector('.smartlink-mb-harmony'), missingLinksButton: root.querySelector('.smartlink-mb-missing'), missingLinksLabel: root.querySelector('.smartlink-mb-missing-label') }; } function keepPanelMounted(config, panel, onRemount) { let remountScheduled = false; const ensureMounted = () => { if (panel.root.isConnected || remountScheduled) return; remountScheduled = true; window.setTimeout(() => { remountScheduled = false; if (panel.root.isConnected) return; mountPanel(config, panel.root); onRemount(); }, 250); }; const observer = new MutationObserver(ensureMounted); observer.observe(document.documentElement, { childList: true, subtree: true }); ensureMounted(); } const HYDRATION_SETTLE_MS = 1_000; function clearSkippedMark(element) { element.classList.remove('smartlink-mb-skipped'); element.querySelector(':scope > .smartlink-mb-skipped-badge')?.remove(); } function markSkipped(element, reason) { clearSkippedMark(element); element.classList.add('smartlink-mb-skipped'); const badge = document.createElement('span'); badge.className = 'smartlink-mb-skipped-badge'; badge.textContent = '\u00d7'; badge.title = `Skipped: ${reason}`; badge.setAttribute('aria-label', badge.title); element.appendChild(badge); } 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 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 => { clearSkippedMark(element.element); if (element.skipReason) { markSkipped(element.element, element.skipReason); delete cache.links[element.cacheKey]; return undefined; } const cached = cache.links[element.cacheKey]; if (cached?.sourceUrl === element.sourceUrl) { const refreshed = { ...cached, label: element.label, action: element.action }; const skipReason = skipReasonForServiceLink(refreshed.service, refreshed.action, refreshed.url); if (skipReason) { markSkipped(element.element, skipReason); delete cache.links[element.cacheKey]; return undefined; } 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) }; const skipReason = skipReasonForServiceLink(link.service, link.action, link.url); if (skipReason) { markSkipped(element.element, skipReason); delete cache.links[element.cacheKey]; return undefined; } 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 markExistingLinks(elements, links, matchedUrls) { for (const element of elements) { const serviceMatches = links.filter(candidate => candidate.service === element.service); const link = serviceMatches.find(candidate => candidate.sourceUrl === element.sourceUrl) ?? serviceMatches[0]; const present = link ? matchedUrls.has(link.url) : false; element.element.classList.toggle('smartlink-mb-present', present); if (present) element.element.title = 'This URL is already linked to the MusicBrainz release'; } } function submitMissingLinks(config, server, releaseId, links) { const form = document.createElement('form'); form.method = 'post'; form.action = `${server}/release/${releaseId}/edit`; form.target = '_blank'; form.acceptCharset = 'UTF-8'; form.hidden = true; const userscriptInfo = getOptionalGlobal('GM_info')?.script; const scriptName = userscriptInfo?.name ?? `${config.siteName} MusicBrainz importer`; const scriptVersion = userscriptInfo?.version ? ` ${userscriptInfo.version}` : ''; const parameters = [['edit_note', `Added URL relationships from ${window.location.href.replace(/[?#].*$/, '')}\n\nUsing '''${scriptName}'''${scriptVersion} from https://github.com/murdos/musicbrainz-userscripts`], ['redirect_uri', `${server}/release/${releaseId}`]]; links.forEach((link, index) => { parameters.push([`urls.${index}.link_type`, String(relationshipTypeFor(link))]); parameters.push([`urls.${index}.url`, link.url]); }); for (const [name, value] of parameters) { const input = document.createElement('input'); input.type = 'hidden'; input.name = name; input.value = value; form.appendChild(input); } document.body.appendChild(form); form.submit(); form.remove(); } function configureHarmonyButton(panel, links) { const harmonyLink = chooseHarmonyLink(links); if (!harmonyLink) return; const harmonyUrl = new URL('https://harmony.pulsewidth.org.uk/release'); harmonyUrl.searchParams.set('category', 'preferred'); harmonyUrl.searchParams.set('url', harmonyLink.url); panel.harmonyButton.href = harmonyUrl.toString(); panel.harmonyButton.title = `Import using ${harmonyLink.label}`; panel.harmonyButton.hidden = false; } function displayUrl(rawUrl) { try { const url = new URL(rawUrl); return `${url.hostname}${url.pathname}${url.search}`; } catch { return rawUrl; } } function showAmbiguousMatches(panel, server, links, matches) { const items = matches.map(match => { const item = document.createElement('li'); item.className = 'smartlink-mb-match'; const release = document.createElement('a'); release.className = 'smartlink-mb-match-release'; release.href = `${server}/release/${match.releaseId}`; release.target = '_blank'; release.textContent = match.title || `Release ${match.releaseId}`; item.appendChild(release); const metadata = [match.disambiguation, match.date, match.country].filter(value => value); if (metadata.length > 0) { const details = document.createElement('span'); details.className = 'smartlink-mb-match-meta'; details.textContent = ` — ${metadata.join(' · ')}`; item.appendChild(details); } const matchedLinks = links.filter(link => findCanonicallyMatchedLinkUrls([link], match.matchedUrls).length > 0); const urls = matchedLinks.length > 0 ? matchedLinks : match.matchedUrls.map(url => ({ label: '', url })); const list = document.createElement('ul'); list.className = 'smartlink-mb-match-links'; for (const link of urls.filter((candidate, index, all) => all.findIndex(other => other.url === candidate.url) === index)) { const listItem = document.createElement('li'); const anchor = document.createElement('a'); anchor.href = link.url; anchor.target = '_blank'; anchor.textContent = link.label ? `${link.label} — ${displayUrl(link.url)}` : displayUrl(link.url); listItem.appendChild(anchor); list.appendChild(listItem); } item.appendChild(list); return item; }); panel.matches.replaceChildren(...items); panel.matches.hidden = false; } async function runSmartLinkImporter(config) { const mbPanelId = panelId(config); if (document.getElementById(mbPanelId)) return; const server = await readServerPreference(); const cache = readPageCache(config); const elements = await waitForServiceElements(config); if (document.getElementById(mbPanelId)) return; const panel = createPanel(config, server); if (elements.length === 0) { panel.progress.hidden = true; panel.root.removeAttribute('aria-busy'); panel.status.textContent = `No ${config.siteName} provider links were found on this page.`; return; } const links = await resolveServiceLinks(config, elements, cache); if (links.length === 0) { panel.progress.hidden = true; panel.root.removeAttribute('aria-busy'); panel.status.textContent = `No applicable ${config.siteName} release links were found on this page.`; return; } panel.status.textContent = `Resolved ${links.length} applicable provider link${links.length === 1 ? '' : 's'}. Checking MusicBrainz…`; configureHarmonyButton(panel, links); let lookupGeneration = 0; const checkMusicBrainz = async selectedServer => { const generation = ++lookupGeneration; panel.root.setAttribute('aria-busy', 'true'); panel.progress.hidden = false; panel.retryButton.hidden = true; panel.status.textContent = `Resolved ${links.length} provider links. Checking MusicBrainz…`; panel.matches.replaceChildren(); panel.matches.hidden = true; panel.release.hidden = true; panel.missingLinksButton.hidden = true; configureHarmonyButton(panel, links); markExistingLinks(config.collectServiceElements(), links, new Set()); try { const discoveredMatches = await lookupReleases(links, selectedServer); if (generation !== lookupGeneration) return; const discoveredMatch = discoveredMatches[0]; if (!discoveredMatch) { panel.status.textContent = 'No existing MusicBrainz release found. Import with Harmony, then reload this page.'; return; } if (discoveredMatches.length > 1) { panel.harmonyButton.hidden = true; panel.status.textContent = `Ambiguous MusicBrainz match: these provider links belong to ${discoveredMatches.length} releases. No links can be added.`; showAmbiguousMatches(panel, selectedServer, links, discoveredMatches); return; } const match = await includeReleaseRelationships(links, selectedServer, discoveredMatch); if (generation !== lookupGeneration) return; const matchedUrls = new Set(match.matchedUrls); markExistingLinks(config.collectServiceElements(), links, matchedUrls); panel.release.href = `${selectedServer}/release/${match.releaseId}`; panel.release.textContent = 'View matched release'; panel.release.hidden = false; const missing = findMissingLinks(links, matchedUrls); panel.status.textContent = `${matchedUrls.size} provider link${matchedUrls.size === 1 ? '' : 's'} already present; ${missing.length} additional link${missing.length === 1 ? '' : 's'} available.`; panel.missingLinksButton.hidden = false; panel.missingLinksButton.disabled = missing.length === 0; panel.missingLinksLabel.textContent = missing.length === 0 ? 'All Links Present' : 'Add Missing Links'; panel.missingLinksButton.onclick = () => { submitMissingLinks(config, selectedServer, match.releaseId, missing); }; } catch (error) { if (generation !== lookupGeneration) return; console.error(`${config.siteName} importer: MusicBrainz lookup failed`, error); panel.status.textContent = 'MusicBrainz lookup failed.'; panel.retryButton.hidden = false; } finally { if (generation === lookupGeneration) { panel.progress.hidden = true; panel.root.removeAttribute('aria-busy'); } } }; panel.retryButton.addEventListener('click', () => { void checkMusicBrainz(panel.server.value); }); panel.server.addEventListener('change', () => { const selectedServer = panel.server.value; void saveServerPreference(selectedServer); void checkMusicBrainz(selectedServer); }); keepPanelMounted(config, panel, () => { void checkMusicBrainz(panel.server.value); }); await checkMusicBrainz(server); } const DOMAINS_BY_SITE = { albumlink: ['album.link'], bandlink: ['band.link'], bfan: ['bfan.link'], fanlink: ['fanlink.tv'], ffm: ['ffm.to', 'orcd.co'], promolinks: ['promolinks.me'], songlink: ['song.link'] }; function smartLinkSiteForHostname(hostname) { const normalized = hostname.toLowerCase().replace(/\.$/, ''); for (const [site, domains] of Object.entries(DOMAINS_BY_SITE)) { if (domains.some(domain => normalized === domain || normalized.endsWith(`.${domain}`))) return site; } return undefined; } const configFactories = { albumlink: createAlbumLinkConfig, bandlink: createBandLinkConfig, bfan: createBfanConfig, fanlink: createFanlinkConfig, ffm: createFfmConfig, promolinks: createPromoLinksConfig, songlink: createSonglinkConfig }; const site = smartLinkSiteForHostname(window.location.hostname); const config = site ? configFactories[site]() : undefined; if (config) void runSmartLinkImporter(config); })();