// ==UserScript== // @name Import FFM releases to MusicBrainz // @description Import ffm.to smart links with Harmony and add their remaining URL relationships to MusicBrainz // @version 2026.08.07.1 // @author Raman Sinclair // @namespace https://github.com/murdos/musicbrainz-userscripts/ // @downloadURL https://raw.githubusercontent.com/murdos/musicbrainz-userscripts/dist/ffm_importer.user.js // @updateURL https://raw.githubusercontent.com/murdos/musicbrainz-userscripts/dist/ffm_importer.user.js // @match https://ffm.to/* // @match https://www.ffm.to/* // @connect * // @grant GM.xmlHttpRequest // @grant GM_xmlhttpRequest // @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 URL_RELATIONSHIP_TYPES = { asin: 77, purchaseForDownload: 74, downloadForFree: 75, 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 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.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 === '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); } /** 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', 'junodownload'].includes(service)) { return URL_RELATIONSHIP_TYPES.purchaseForDownload; } if (['amazon', 'apple', 'itunes', 'pandora', 'qobuz', 'tidal', 'youtubemusic'].includes(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 PANEL_ID = 'ffm-mb-importer'; const STYLE_ID = 'ffm-mb-importer-style'; const CACHE_PREFIX = 'ffm-mb-importer:v1:'; const SERVER_PREFERENCE_KEY = 'ffm-mb-importer:server'; const HYDRATION_SETTLE_MS = 1_000; const MUSICBRAINZ_SERVERS = ['https://musicbrainz.org', 'https://beta.musicbrainz.org']; function pageCacheKey() { return `${CACHE_PREFIX}${window.location.origin}${window.location.pathname.replace(/\/$/, '')}`; } function readPageCache() { try { const parsed = JSON.parse(window.localStorage.getItem(pageCacheKey()) ?? '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(cache) { try { window.localStorage.setItem(pageCacheKey(), 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 collectServiceAnchors() { const counters = new Map(); const anchors = []; for (const element of document.querySelectorAll('a[service][href]')) { const rawService = element.getAttribute('service') ?? ''; const service = normalizeServiceName(rawService); if (!service || !element.href) continue; const count = (counters.get(service) ?? 0) + 1; counters.set(service, count); anchors.push({ cacheKey: count === 1 ? service : `${service}:${count}`, element, service, label: element.querySelector('.service-title')?.textContent.trim() || rawService, action: element.querySelector('.service-text')?.textContent.trim() || '', sourceUrl: element.href }); } return anchors; } function waitForServiceAnchors() { return new Promise(resolve => { let settleTimer; let finished = false; const finish = anchors => { if (finished) return; finished = true; observer.disconnect(); if (settleTimer !== undefined) window.clearTimeout(settleTimer); window.clearTimeout(maximumWaitTimer); resolve(anchors); }; const waitUntilStable = () => { const anchors = collectServiceAnchors(); if (anchors.length === 0) return; if (settleTimer !== undefined) window.clearTimeout(settleTimer); settleTimer = window.setTimeout(() => { const stableAnchors = collectServiceAnchors(); if (stableAnchors.length > 0) finish(stableAnchors);else waitUntilStable(); }, HYDRATION_SETTLE_MS); }; const observer = new MutationObserver(() => { waitUntilStable(); }); const maximumWaitTimer = window.setTimeout(() => { finish(collectServiceAnchors()); }, 20_000); observer.observe(document.documentElement, { childList: true, subtree: true }); waitUntilStable(); }); } async function resolveServiceLinks(anchors, cache) { const resolved = await Promise.all(anchors.map(async anchor => { const cached = cache.links[anchor.cacheKey]; if (cached?.sourceUrl === anchor.sourceUrl) return cached; try { const decoded = decodeFfmDestination(anchor.sourceUrl); if (cached && (!decoded || normalizeServiceUrl(decoded, anchor.service) === cached.url)) { const refreshed = { ...cached, label: anchor.label, action: anchor.action, sourceUrl: anchor.sourceUrl }; cache.links[anchor.cacheKey] = refreshed; return refreshed; } const destination = decoded ?? (await followRedirect(anchor.sourceUrl)); const link = { service: anchor.service, label: anchor.label, action: anchor.action, sourceUrl: anchor.sourceUrl, url: normalizeServiceUrl(destination, anchor.service) }; cache.links[anchor.cacheKey] = link; return link; } catch (error) { console.warn(`FFM importer: could not resolve ${anchor.label}`, error); return undefined; } })); savePageCache(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}`); const resources = extractReleaseUrlResources(await response.json()); return { releaseId: match.releaseId, matchedUrls: findCanonicallyMatchedLinkUrls(links, resources) }; } function addStyles() { if (document.getElementById(STYLE_ID)) return; const style = document.createElement('style'); style.id = STYLE_ID; style.textContent = ` #${PANEL_ID} { 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) { #${PANEL_ID} { top: auto; right: 8px; bottom: 8px; width: min(340px, calc(100vw - 16px)); max-height: 50vh; } } #${PANEL_ID} .ffm-mb-heading, #${PANEL_ID} .ffm-mb-controls, #${PANEL_ID} .ffm-mb-buttons { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } #${PANEL_ID} .ffm-mb-heading { font-weight: bold; margin-bottom: 8px; } #${PANEL_ID} [hidden] { display: none !important; } #${PANEL_ID} .ffm-mb-controls { margin: 8px 0; } #${PANEL_ID} .ffm-mb-status { color: #555; } #${PANEL_ID} .ffm-mb-release { color: #0875bd; font-weight: bold; } #${PANEL_ID} .ffm-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; } #${PANEL_ID} .ffm-mb-button:hover:not(:disabled) { background: #fff; } #${PANEL_ID} .ffm-mb-button:disabled { cursor: default; opacity: 0.55; } #${PANEL_ID} .ffm-mb-button img { flex: none; } a.ffm-mb-present { position: relative; outline: 3px solid #32a852 !important; } a.ffm-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(server) { addStyles(); document.getElementById(PANEL_ID)?.remove(); const root = document.createElement('section'); root.id = PANEL_ID; root.innerHTML = `