// ==UserScript== // @name Import Fanlink releases to MusicBrainz // @description Import fanlink.tv smart links with Harmony and add their remaining URL relationships to MusicBrainz. // @version 2026.08.28.2 // @author Raman Sinclair // @namespace https://github.com/murdos/musicbrainz-userscripts/ // @downloadURL https://raw.githubusercontent.com/murdos/musicbrainz-userscripts/dist/fanlink_importer.user.js // @updateURL https://raw.githubusercontent.com/murdos/musicbrainz-userscripts/dist/fanlink_importer.user.js // @match https://fanlink.tv/* // @match https://*.fanlink.tv/* // @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'; const LEGACY_GM_API_NAMES = { getValue: 'GM_getValue', setValue: 'GM_setValue', xmlHttpRequest: 'GM_xmlhttpRequest' }; function getOptionalGlobal(name) { return Reflect.get(globalThis, name); } function getGmApi(name) { const modernGM = getOptionalGlobal('GM'); const modernApi = modernGM?.[name]; return modernApi ?? getOptionalGlobal(LEGACY_GM_API_NAMES[name]); } 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 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 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 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 === '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 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 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. } } const HYDRATION_SETTLE_MS = 1_000; 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 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 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 = ` `; 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'), 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 mountPanel(config, root) { if (config.mountPanel) config.mountPanel(root);else document.body.appendChild(root); } 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(); } 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; } 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.status.textContent = `No ${config.siteName} provider links were found on this page.`; return; } const links = await resolveServiceLinks(config, elements, cache); panel.status.textContent = `Resolved ${links.length} of ${elements.length} provider links. Checking MusicBrainz…`; configureHarmonyButton(panel, links); let lookupGeneration = 0; const checkMusicBrainz = async selectedServer => { const generation = ++lookupGeneration; panel.status.textContent = `Resolved ${links.length} provider links. Checking MusicBrainz…`; 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.`; 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. Reload the page to try again.'; } }; 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); } function record(value) { return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined; } 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() { 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 { const filename = new URL(imageUrl).pathname.split('/').pop() ?? ''; return normalizeServiceName(filename.replace(/\.[^.]+$/, '')); } catch { return ''; } } function collectServiceElements() { const dataByService = new Map(); for (const data of readServiceData()) { 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 || isIgnoredService(service) || isPhysicalMediaLink(service, action)) continue; const count = (counters.get(service) ?? 0) + 1; counters.set(service, count); elements.push({ cacheKey: count === 1 ? service : `${service}:${count}`, element, service, label: element.querySelector('img[alt]')?.alt || data.label, action, sourceUrl: data.sourceUrl }); } return elements; } void runSmartLinkImporter({ id: 'fanlink', siteName: 'Fanlink', collectServiceElements, resolveDestination: element => followRedirect(element.sourceUrl).catch(() => element.sourceUrl) }); })();