// ==UserScript== // @name Apple Music works credits -> MusicBrainz // @namespace https://github.com/karpuzikov/userscripts // @version 2.3.23 // @description Resolve the correct Apple Music release and import supported Apple Music credits to the proper MusicBrainz Recording, Work, or Release relationships. // @author karpuzikov // @license MIT // @downloadURL https://raw.githubusercontent.com/karpuzikov/userscripts/main/musicbrainz-tools/apple-music-composition-lyrics-importer/MusicBrainz_Apple_Music_Composition_Lyrics_Importer.user.js // @updateURL https://raw.githubusercontent.com/karpuzikov/userscripts/main/musicbrainz-tools/apple-music-composition-lyrics-importer/MusicBrainz_Apple_Music_Composition_Lyrics_Importer.user.js // @supportURL https://github.com/karpuzikov/userscripts // @match https://musicbrainz.org/release/*/edit-relationships // @match https://beta.musicbrainz.org/release/*/edit-relationships // @connect music.apple.com // @connect itunes.apple.com // @connect geo.itunes.apple.com // @connect amp-api.music.apple.com // @connect *.mzstatic.com // @grant GM_xmlhttpRequest // @grant unsafeWindow // @run-at document-end // ==/UserScript== const __amMbNativeFetch = window.fetch.bind(window); const __amMbNativeGmXmlhttpRequest = GM_xmlhttpRequest; function __amMbSleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } function __amMbIsMusicBrainzUrl(value) { const raw = typeof value === 'string' ? value : (value?.url || String(value || '')); try { const url = new URL(raw, location.href); const host = url.hostname.toLowerCase(); return host === 'musicbrainz.org' || host.endsWith('.musicbrainz.org'); } catch { return false; } } function __amMbRetryAfterMs(value) { const text = String(value || '').trim(); if (!text) return 0; const seconds = Number(text); if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000; const date = Date.parse(text); return Number.isFinite(date) ? Math.max(0, date - Date.now()) : 0; } function __amMbRawHeader(rawHeaders, name) { const wanted = String(name || '').toLowerCase(); for (const line of String(rawHeaders || '').split(/\r?\n/)) { const index = line.indexOf(':'); if (index < 0) continue; if (line.slice(0, index).trim().toLowerCase() !== wanted) continue; return line.slice(index + 1).trim(); } return ''; } async function __amMbFetch(input, init, retryCallbacks = {}) { if (!__amMbIsMusicBrainzUrl(input)) { return __amMbNativeFetch(input, init); } let retryCount = 0; for (;;) { const response = await __amMbNativeFetch(input, init); if (response.status !== 503) { if (retryCount) retryCallbacks.onRecovered?.(retryCount); return response; } // MusicBrainz requires retrying 503 indefinitely. Back off instead of // hammering the server; keep the status panel and diagnostic bridge // informed so the operation never appears frozen. retryCount++; const delay = Math.max( Math.min(60000, 5000 * (2 ** Math.min(retryCount - 1, 4))), __amMbRetryAfterMs(response.headers?.get?.('Retry-After')) ); retryCallbacks.onRetry?.(retryCount, delay); await __amMbSleep(delay); } } function __amMbGmXmlhttpRequest(details) { if (!__amMbIsMusicBrainzUrl(details?.url)) { return __amMbNativeGmXmlhttpRequest(details); } let currentRequest = null; let retryTimer = null; let aborted = false; const issue = () => { if (aborted) return; const originalOnload = details.onload; currentRequest = __amMbNativeGmXmlhttpRequest({ ...details, onload(response) { if (aborted) return; if (response.status === 503) { retryTimer = setTimeout( issue, Math.max( 5000, __amMbRetryAfterMs( __amMbRawHeader( response.responseHeaders, 'Retry-After' ) ) ) ); return; } originalOnload?.(response); }, }); }; issue(); return { abort() { aborted = true; if (retryTimer !== null) clearTimeout(retryTimer); currentRequest?.abort?.(); }, }; } (() => { 'use strict'; const PAGE = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; const SCRIPT_VERSION = '2.3.23'; let MB = PAGE.MB; const APPLE_API_BASE = 'https://amp-api.music.apple.com/v1'; const APPLE_TOKEN_BOOTSTRAP_URL = 'https://music.apple.com/us/browse'; const RECORDING_OF_LINK_TYPE_ID = 278; const WORK_TYPE_SONG_ID = 17; const SCRIPT_URL = 'https://github.com/karpuzikov/userscripts/blob/main/musicbrainz-tools/apple-music-composition-lyrics-importer/MusicBrainz_Apple_Music_Composition_Lyrics_Importer.user.js'; const FALLBACK_STOREFRONTS = ['us', 'gb', 'de', 'fr', 'ca', 'au', 'jp', 'ua']; let appleToken = ''; const MB_WS_MIN_INTERVAL = 1700; const MB_WS_MAX_RETRIES = 5; let mbWsLastRequestAt = 0; let mbWsQueue = Promise.resolve(); const mbWsCache = new Map(); const ROLE_TYPES = { // Work-level authorship. These describe the composition itself. songwriter: { target: 'work', id: 167, label: 'writer' }, writer: { target: 'work', id: 167, label: 'writer' }, composer: { target: 'work', id: 168, label: 'composer' }, lyrics: { target: 'work', id: 165, label: 'lyricist' }, lyricist: { target: 'work', id: 165, label: 'lyricist' }, librettist: { target: 'work', id: 169, label: 'librettist' }, translator: { target: 'work', id: 872, label: 'translator' }, // Recording-level performance / production. programming: { target: 'recording', id: 132, label: 'programming' }, programmer: { target: 'recording', id: 132, label: 'programming' }, producer: { target: 'recording', id: 141, label: 'producer' }, engineer: { target: 'recording', id: 138, label: 'engineer' }, 'audio engineer': { target: 'recording', id: 140, label: 'audio engineer' }, 'sound engineer': { target: 'recording', id: 133, label: 'sound engineer' }, mixer: { target: 'recording', id: 143, label: 'mixer' }, 'mix engineer': { target: 'recording', id: 143, label: 'mixer' }, 'mixing engineer': { target: 'recording', id: 143, label: 'mixer' }, 'recording engineer': { target: 'recording', id: 128, label: 'recording engineer' }, editor: { target: 'recording', id: 144, label: 'editor' }, remixer: { target: 'recording', id: 153, label: 'remixer' }, 'dj mixer': { target: 'recording', id: 155, label: 'DJ-mixer' }, performer: { target: 'recording', id: 156, label: 'performer' }, vocal: { target: 'recording', id: 149, label: 'vocal' }, vocals: { target: 'recording', id: 149, label: 'vocal' }, conductor: { target: 'recording', id: 151, label: 'conductor' }, orchestra: { target: 'recording', id: 150, label: 'performing orchestra' }, arranger: { target: 'recording', id: 297, label: 'arranger' }, 'vocal arranger': { target: 'recording', id: 298, label: 'vocal arranger' }, orchestrator: { target: 'recording', id: 300, label: 'orchestrator' }, 'balance engineer': { target: 'recording', id: 726, label: 'balance engineer' }, 'field recordist': { target: 'recording', id: 1011, label: 'field recordist' }, // MusicBrainz explicitly keeps mastering at release level. mastering: { target: 'release', id: 42, label: 'mastering' }, 'mastering engineer': { target: 'release', id: 42, label: 'mastering' }, }; const REL_DEFAULTS = { _lineage: [], _original: null, _status: 1, attributes: null, begin_date: null, editsPending: false, end_date: null, ended: false, entity0_credit: '', entity1_credit: '', id: null, linkOrder: 0, linkTypeID: null, }; const state = { appleUrl: '', mbBarcode: '', appleBarcode: '', sourceMode: '', appleTracks: [], rows: [], people: new Map(), creditedArtists: [], aliasEdits: 0, applied: false, }; function wait(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } async function ensureMusicBrainzEditorReady(timeout = 30000) { const started = Date.now(); while (Date.now() - started < timeout) { const liveMb = PAGE.MB; if ( liveMb?.relationshipEditor?.state?.entity && typeof liveMb.relationshipEditor.dispatch === 'function' ) { MB = liveMb; return MB; } setStatus('Waiting for MusicBrainz relationship editor...'); await wait(250); } throw new Error( 'MusicBrainz relationship editor did not become ready within 30 seconds. Reload the page and try again.' ); } function musicBrainzRetryDelay(response, attempt) { const retryAfter = response?.headers?.get?.('Retry-After') || ''; if (/^\d+$/.test(retryAfter)) { return Math.max(1000, Number(retryAfter) * 1000); } const retryDate = Date.parse(retryAfter); if (Number.isFinite(retryDate)) { return Math.max(1000, retryDate - Date.now()); } return Math.min(16000, 1500 * (2 ** (attempt - 1))); } async function runMusicBrainzWsRequest(url, label) { let lastError = null; for (let attempt = 1; attempt <= MB_WS_MAX_RETRIES; attempt++) { const spacing = MB_WS_MIN_INTERVAL - (Date.now() - mbWsLastRequestAt); if (spacing > 0) await wait(spacing); let response; try { mbWsLastRequestAt = Date.now(); response = await __amMbFetch(url, { credentials: 'same-origin', headers: { Accept: 'application/json' }, }, { onRetry: (count, delay) => setStatus( `${label}: HTTP 503; retry ${count} in ${Math.ceil(delay / 1000)}s...`, 'warn' ), onRecovered: count => setStatus( `${label}: recovered after ${count} HTTP 503 retry(s).` ), }); } catch (error) { lastError = error; if (attempt === MB_WS_MAX_RETRIES) break; const delay = Math.min(16000, 1500 * (2 ** (attempt - 1))); setStatus(`${label}: network error. Retrying in ${Math.ceil(delay / 1000)}s...`, 'warn'); await wait(delay); continue; } if (response.ok) { try { return await response.json(); } catch { throw new Error(`${label}: MusicBrainz returned invalid JSON.`); } } const retryable = [429, 502, 503, 504].includes(response.status); if (!retryable || attempt === MB_WS_MAX_RETRIES) { throw new Error(`${label} failed: HTTP ${response.status}`); } const delay = musicBrainzRetryDelay(response, attempt); setStatus( `${label}: MusicBrainz returned HTTP ${response.status}. Retrying ${attempt}/${MB_WS_MAX_RETRIES} in ${Math.ceil(delay / 1000)}s...`, 'warn' ); await wait(delay); } throw new Error( `${label} failed after ${MB_WS_MAX_RETRIES} attempts${lastError ? `: ${lastError.message}` : ''}.` ); } function musicBrainzWsJson(url, label, cacheKey = '') { if (cacheKey && mbWsCache.has(cacheKey)) { return Promise.resolve(mbWsCache.get(cacheKey)); } const task = async () => { const json = await runMusicBrainzWsRequest(url, label); if (cacheKey) mbWsCache.set(cacheKey, json); return json; }; const queued = mbWsQueue.then(task, task); mbWsQueue = queued.catch(() => {}); return queued; } function normalizeText(value) { return String(value || '') .normalize('NFKD') .replace(/[\u0300-\u036f]/g, '') .toLowerCase() .replace(/&/g, ' and ') .replace(/[^\p{L}\p{N}]+/gu, ' ') .trim() .replace(/\s+/g, ' '); } function normalizeTrackTitleForMatch(value) { // A featuring credit can precede a remix/version qualifier: // "Gasolina (feat. Myke Towers) [Safari Riot Remix]". // Remove only a self-contained featuring qualifier, not the remix, // version, subtitle or other editorially significant information. const title = String(value || '') .replace(/\s*[([]\s*(?:feat(?:uring)?|ft)\.?\s+[^\])]+[)\]]/gi, '') .trim(); return normalizeText(title); } function verifyTitleMatcher() { const tests = [ ['Before We Had a Label (feat. Elle Vee)', 'Before We Had a Label'], ['Let You Down (feat. Sunset City)', 'Let You Down'], ['We Should (feat. Shadow Aspect)', 'We Should'], ['Like You (ft. Elle Vee)', 'Like You'], ['Song [featuring Artist]', 'Song'], ['Gasolina (feat. Myke Towers) [Safari Riot Remix]', 'Gasolina (Safari Riot remix)'], ["Let's Ride (feat. Ty Dolla $ign, Lambo4oe & Bone Thugs-N-Harmony) [Trailer Anthem]", 'Let’s Ride (Trailer Anthem)'], ['Song (feat. Artist) (Extended Mix)', 'Song (Extended Mix)'], ]; for (const [appleTitle, mbTitle] of tests) { if ( normalizeTrackTitleForMatch(appleTitle) !== normalizeTrackTitleForMatch(mbTitle) ) { throw new Error( `Internal title matcher test failed: "${appleTitle}" vs "${mbTitle}"` ); } } } verifyTitleMatcher(); function escapeHtml(value) { return String(value ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function escapeLucene(value) { return String(value).replace(/([+\-!(){}\[\]^"~*?:\\/]|&&|\|\|)/g, '\\$1'); } function isMbid(value) { return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); } function extractMbid(value) { const match = String(value || '').match(/[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/i); return match ? match[0].toLowerCase() : ''; } function walk(value, callback, seen = new Set()) { if (!value || typeof value !== 'object' || seen.has(value)) return; seen.add(value); callback(value); if (Array.isArray(value)) { for (const item of value) walk(item, callback, seen); } else { for (const item of Object.values(value)) walk(item, callback, seen); } } function parseAppleServerData(html) { const doc = new DOMParser().parseFromString(html, 'text/html'); const node = doc.querySelector('#serialized-server-data'); if (!node) { throw new Error('Apple Music page does not contain serialized-server-data.'); } try { return JSON.parse(node.textContent); } catch (error) { throw new Error(`Apple Music data could not be parsed: ${error.message}`); } } function getAppleTracks(serverData) { const tracks = new Map(); walk(serverData, object => { const id = object?.contentDescriptor?.identifiers?.storeAdamID; if ( object?.contentDescriptor?.kind === 'song' && id && Number.isFinite(object.trackNumber) && object.title ) { const key = String(id); if (!tracks.has(key)) { tracks.set(key, { id: key, title: String(object.title), trackNumber: Number(object.trackNumber), discNumber: Number(object.discNumber || 1), url: object.contentDescriptor.url || '', }); } } }); return [...tracks.values()].sort( (a, b) => a.discNumber - b.discNumber || a.trackNumber - b.trackNumber ); } function getAppleCredits(serverData) { const credits = []; const seen = new Set(); walk(serverData, object => { if (!Array.isArray(object?.items)) return; const section = String(object.title || object.id || '').trim(); for (const item of object.items) { const name = String(item?.name || '').trim(); const roles = Array.isArray(item?.roleNames) ? item.roleNames.map(role => String(role).trim()).filter(Boolean) : []; if (!name || !roles.length) continue; for (const role of roles) { const key = [ normalizeText(name), normalizeText(role), ].join('|'); if (seen.has(key)) continue; seen.add(key); credits.push({ name, role, section, }); } } }); return credits; } function songCreditsUrl(track, fallbackAlbumUrl) { const source = new URL(track.url || fallbackAlbumUrl); const parts = source.pathname.split('/').filter(Boolean); const albumIndex = parts.indexOf('album'); const storefront = parts[0] || 'us'; const slug = albumIndex >= 0 && parts[albumIndex + 1] ? parts[albumIndex + 1] : encodeURIComponent(track.title.toLowerCase().replace(/\s+/g, '-')); return `${source.origin}/${storefront}/song/${slug}/${encodeURIComponent(track.id)}`; } function normalizeBarcode(value) { return String(value || '').replace(/\D+/g, ''); } function barcodeVariants(value) { const barcode = normalizeBarcode(value); const variants = new Set(); if (!barcode) return variants; variants.add(barcode); if (barcode.length === 13 && barcode.startsWith('0')) variants.add(barcode.slice(1)); if (barcode.length === 12) variants.add(`0${barcode}`); return variants; } function barcodesEqual(a, b) { const left = barcodeVariants(a); const right = barcodeVariants(b); for (const value of left) { if (right.has(value)) return true; } return false; } function releaseMbidFromLocation() { const match = location.pathname.match(/^\/release\/([0-9a-f-]{36})\/edit-relationships/i); return match ? match[1].toLowerCase() : ''; } function isAppleReleaseUrl(value) { try { const url = new URL(value); const host = url.hostname.toLowerCase(); const appleHost = host === 'music.apple.com' || host === 'itunes.apple.com' || host === 'geo.itunes.apple.com' || host.endsWith('.itunes.apple.com'); return appleHost && url.pathname.toLowerCase().includes('/album/'); } catch { return false; } } function parseAppleSongUrl(value) { try { const url = new URL(String(value || '').trim()); if (url.protocol !== 'https:' || url.hostname.toLowerCase() !== 'music.apple.com') return null; const match = /^\/([a-z]{2})\/song\/[^/]+\/([0-9]+)\/?$/i.exec(url.pathname); return match ? { url: url.origin + url.pathname, storefront: match[1].toLowerCase(), id: match[2] } : null; } catch { return null; } } function appleStorefrontFromUrl(value) { try { const url = new URL(value); const first = url.pathname.split('/').filter(Boolean)[0] || ''; return /^[a-z]{2}$/i.test(first) ? first.toLowerCase() : ''; } catch { return ''; } } function appleAlbumIdFromUrl(value) { try { const url = new URL(value); const parts = url.pathname.split('/').filter(Boolean); const albumIndex = parts.findIndex(part => part.toLowerCase() === 'album'); if (albumIndex < 0) return ''; for (let index = parts.length - 1; index > albumIndex; index--) { const match = parts[index].match(/^(?:id)?(\d+)$/i); if (match) return match[1]; } return ''; } catch { return ''; } } function canonicalAppleAlbumUrl(value) { try { const url = new URL(value); url.searchParams.delete('i'); return url.href; } catch { return value; } } function getReleaseCountries(release) { const countries = []; for (const event of release?.['release-events'] || []) { const code = String(event?.area?.['iso-3166-1-codes']?.[0] || '').toLowerCase(); if (/^[a-z]{2}$/.test(code) && !countries.includes(code)) countries.push(code); } return countries; } function collectCreditedArtists(release) { const artists = new Map(); const addCredit = (credit, scope) => { for (const entry of credit || []) { const artist = entry?.artist; if (!artist?.id) continue; let item = artists.get(artist.id); if (!item) { item = { id: artist.id, name: artist.name || '', sortName: artist['sort-name'] || '', creditedNames: new Set(), scopes: new Set(), }; artists.set(artist.id, item); } if (entry?.name) item.creditedNames.add(String(entry.name)); if (artist?.name) item.creditedNames.add(String(artist.name)); item.scopes.add(scope); } }; addCredit(release?.['artist-credit'], 'release'); for (const medium of release?.media || []) { for (const track of medium?.tracks || []) { addCredit(track?.['artist-credit'], 'track'); addCredit(track?.recording?.['artist-credit'], 'recording'); } } return [...artists.values()]; } async function getMusicBrainzReleaseSourceData() { const mbid = releaseMbidFromLocation(); if (!mbid) throw new Error('Cannot determine the MusicBrainz release MBID.'); const releaseUrl = `/ws/2/release/${encodeURIComponent(mbid)}?inc=url-rels+artist-credits+recordings&fmt=json`; const release = await musicBrainzWsJson( releaseUrl, 'MusicBrainz release lookup', `release:${mbid}:url-rels+artist-credits+recordings` ); state.creditedArtists = collectCreditedArtists(release); const barcode = normalizeBarcode(release.barcode); const links = [...new Set( (release.relations || []) .map(relation => relation?.url?.resource || '') .filter(isAppleReleaseUrl) )]; return { mbid, release, barcode, links, countries: getReleaseCountries(release), }; } function gmRequest(url, options = {}) { return new Promise((resolve, reject) => { __amMbGmXmlhttpRequest({ method: options.method || 'GET', url, headers: options.headers || {}, timeout: options.timeout || 60000, onload(response) { if (response.status >= 200 && response.status < 400) { resolve(response); } else { const error = new Error(`HTTP ${response.status} for ${url}`); error.status = response.status; reject(error); } }, ontimeout() { reject(new Error(`Timed out while loading ${url}`)); }, onerror() { reject(new Error(`Failed to load ${url}`)); }, }); }); } async function gmGet(url) { const response = await gmRequest(url, { headers: { Accept: 'text/html,application/xhtml+xml' }, }); return response.responseText; } async function getAppleTokenFromHtml(html, pageUrl) { const doc = new DOMParser().parseFromString(html, 'text/html'); const scripts = [...doc.querySelectorAll('script[src][crossorigin], script[src]')] .map(node => { try { return new URL(node.getAttribute('src'), pageUrl).href; } catch { return ''; } }) .filter(Boolean); const prioritized = [ ...scripts.filter(url => /music|index|config|app/i.test(url)), ...scripts.filter(url => !/music|index|config|app/i.test(url)), ]; for (const scriptUrl of [...new Set(prioritized)]) { try { const script = await gmRequest(scriptUrl, { headers: { Accept: '*/*' }, timeout: 30000, }); const match = script.responseText.match(/["'](eyJ[A-Za-z0-9._-]+)["']/) || script.responseText.match(/(["'])(ey[^"']+)\1/); if (match) return match[2] || match[1]; } catch { // Try the next script asset. } } return ''; } async function ensureAppleToken(pageHtml = '', pageUrl = '') { if (appleToken) return appleToken; if (pageHtml && pageUrl) { appleToken = await getAppleTokenFromHtml(pageHtml, pageUrl); if (appleToken) return appleToken; } const bootstrap = await gmRequest(APPLE_TOKEN_BOOTSTRAP_URL, { headers: { Accept: 'text/html,application/xhtml+xml' }, }); const bootstrapUrl = bootstrap.finalUrl || APPLE_TOKEN_BOOTSTRAP_URL; appleToken = await getAppleTokenFromHtml(bootstrap.responseText, bootstrapUrl); if (!appleToken) { throw new Error('Could not obtain the current Apple Music API token.'); } return appleToken; } async function appleApiGet(path, token) { const response = await gmRequest(`${APPLE_API_BASE}${path}`, { headers: { Accept: 'application/json', Authorization: `Bearer ${token}`, Origin: 'https://music.apple.com', }, }); try { return JSON.parse(response.responseText); } catch { throw new Error('Apple Music API returned invalid JSON.'); } } async function getAppleAlbumById(albumId, storefront, token) { const json = await appleApiGet( `/catalog/${encodeURIComponent(storefront)}/albums/${encodeURIComponent(albumId)}`, token ); return json?.data?.find(item => item?.type === 'albums') || null; } async function getAppleSongById(songId, storefront, token) { const json = await appleApiGet(`/catalog/${encodeURIComponent(storefront)}/songs/${encodeURIComponent(songId)}`, token); return json?.data?.find(item => item?.type === 'songs' && String(item.id) === String(songId)) || null; } async function inspectAppleReleaseLink(link) { const page = await gmRequest(link, { headers: { Accept: 'text/html,application/xhtml+xml' }, }); const finalUrl = page.finalUrl || link; const albumId = appleAlbumIdFromUrl(finalUrl) || appleAlbumIdFromUrl(link); const storefront = appleStorefrontFromUrl(finalUrl) || appleStorefrontFromUrl(link) || 'us'; if (!albumId) throw new Error('The Apple/iTunes URL does not identify an album.'); const token = await ensureAppleToken(page.responseText, finalUrl); const album = await getAppleAlbumById(albumId, storefront, token); if (!album) throw new Error('Apple Music album is unavailable.'); const upc = normalizeBarcode(album?.attributes?.upc); const albumUrl = canonicalAppleAlbumUrl(album?.attributes?.url || finalUrl); if (!isAppleReleaseUrl(albumUrl)) { throw new Error('Apple Music API did not return a usable album URL.'); } return { url: albumUrl, upc, storefront, albumId, }; } function storefrontCandidates(sourceData) { const list = []; for (const link of sourceData.links) { const storefront = appleStorefrontFromUrl(link); if (storefront && !list.includes(storefront)) list.push(storefront); } for (const country of sourceData.countries || []) { if (country && !list.includes(country)) list.push(country); } for (const storefront of FALLBACK_STOREFRONTS) { if (!list.includes(storefront)) list.push(storefront); } return list; } async function findAppleReleaseByBarcode(barcode, sourceData) { const token = await ensureAppleToken(); const wanted = normalizeBarcode(barcode); if (!wanted) return null; for (const storefront of storefrontCandidates(sourceData)) { setStatus(`Searching Apple Music by barcode ${wanted} in ${storefront.toUpperCase()}...`); try { const json = await appleApiGet( `/catalog/${encodeURIComponent(storefront)}/albums?filter%5Bupc%5D=${encodeURIComponent(wanted)}&limit=25`, token ); const albums = (json?.data || []).filter(item => item?.type === 'albums'); const match = albums.find(album => barcodesEqual(album?.attributes?.upc, wanted)); if (!match) continue; const url = canonicalAppleAlbumUrl(match?.attributes?.url || ''); if (!isAppleReleaseUrl(url)) continue; return { url, upc: normalizeBarcode(match?.attributes?.upc), storefront, albumId: String(match?.id || ''), }; } catch (error) { console.warn(`[Apple Music UPC lookup] ${storefront}:`, error); } } return null; } async function resolveAppleRelease() { setStatus('Checking MusicBrainz barcode and Apple/iTunes links...'); const sourceData = await getMusicBrainzReleaseSourceData(); state.mbBarcode = sourceData.barcode; if (!sourceData.barcode && !sourceData.links.length) { throw new Error('No barcode and no Apple Music/iTunes link are present on this MusicBrainz release.'); } if (sourceData.links.length) { for (let index = 0; index < sourceData.links.length; index++) { const link = sourceData.links[index]; setStatus(`Checking Apple/iTunes link ${index + 1}/${sourceData.links.length}...`); try { const candidate = await inspectAppleReleaseLink(link); if (!sourceData.barcode || barcodesEqual(sourceData.barcode, candidate.upc)) { state.appleBarcode = candidate.upc; state.sourceMode = sourceData.barcode ? 'verified existing Apple Music/iTunes link' : 'existing Apple Music/iTunes link (MusicBrainz has no barcode)'; return candidate; } console.warn( `[Apple Music source] Barcode mismatch: MusicBrainz ${sourceData.barcode}, Apple ${candidate.upc || '(none)'}` ); } catch (error) { console.warn(`[Apple Music source] Dead/unusable link: ${link}`, error); } } } if (sourceData.barcode) { const found = await findAppleReleaseByBarcode(sourceData.barcode, sourceData); if (found) { state.appleBarcode = found.upc; state.sourceMode = 'barcode search fallback'; return found; } throw new Error(`No Apple Music release was found for MusicBrainz barcode ${sourceData.barcode}.`); } throw new Error('The Apple Music/iTunes link is dead or unusable, and this MusicBrainz release has no barcode for fallback search.'); } async function resolveAppleSongUrl(rawUrl) { const source = parseAppleSongUrl(rawUrl); if (!source) throw new Error('Enter an Apple Music song URL, such as https://music.apple.com/us/song/title/123456.'); setStatus('Loading selected Apple Music song credits...'); const mbSource = await getMusicBrainzReleaseSourceData(); state.mbBarcode = mbSource.barcode; const page = await gmRequest(source.url, { headers: { Accept: 'text/html,application/xhtml+xml' } }); const redirected = parseAppleSongUrl(page.finalUrl || source.url); if (redirected && redirected.id !== source.id) throw new Error('Apple Music redirected to a different song; import blocked.'); const data = parseAppleServerData(page.responseText); let track = getAppleTracks(data).find(item => item.id === source.id); if (!track) { setStatus('Fetching Apple Music metadata by song ID...'); const token = await ensureAppleToken(page.responseText, page.finalUrl || source.url); const song = await getAppleSongById(source.id, source.storefront, token); if (!song?.attributes?.name) throw new Error('Could not verify Apple Music song metadata.'); track = { id: source.id, title: String(song.attributes.name), discNumber: Number(song.attributes.discNumber || 1), trackNumber: Number(song.attributes.trackNumber || 1), url: song.attributes.url || source.url, }; } state.sourceMode = 'direct Apple Music song URL'; return { url: source.url, appleTrack: track, creditsUrl: source.url, credits: getAppleCredits(data) }; } function setSourceInfo(resolved) { const target = document.getElementById('am2mb-source'); if (!target) return; target.innerHTML = ''; const parts = []; if (state.mbBarcode) parts.push(`MusicBrainz barcode: ${state.mbBarcode}`); if (state.appleBarcode) parts.push(`Apple UPC: ${state.appleBarcode}`); if (state.sourceMode) parts.push(`Source: ${state.sourceMode}`); target.append(document.createTextNode(parts.join(' | '))); if (resolved?.url) { target.append(document.createTextNode(' | ')); const link = document.createElement('a'); link.href = resolved.url; link.target = '_blank'; link.rel = 'noopener noreferrer'; link.textContent = 'Apple Music'; target.append(link); } } async function mapPool(items, concurrency, worker) { const results = new Array(items.length); let nextIndex = 0; async function run() { while (true) { const index = nextIndex++; if (index >= items.length) return; try { results[index] = await worker(items[index], index); } catch (error) { results[index] = { error }; } } } await Promise.all( Array.from({ length: Math.min(concurrency, items.length) }, run) ); return results; } function getMbTrack(discNumber, trackNumber) { MB = PAGE.MB || MB; const mediums = MB?.relationshipEditor?.state?.entity?.mediums || []; const medium = mediums.find(item => Number(item.position) === Number(discNumber)) || mediums[Number(discNumber) - 1]; if (!medium) return null; const tracks = medium.tracks || []; return ( tracks.find(item => Number(item.position) === Number(trackNumber)) || tracks[Number(trackNumber) - 1] || null ); } function resolveMbTrackForSong(song) { MB = PAGE.MB || MB; const title = normalizeTrackTitleForMatch(song.title); if (!title) return { mbTrack: null, error: 'Missing Apple Music song title.' }; const matches = []; for (const medium of MB?.relationshipEditor?.state?.entity?.mediums || []) { for (const track of medium.tracks || []) { if (normalizeTrackTitleForMatch(track.name) === title) matches.push({ medium, track }); } } if (matches.length === 1) return { mbTrack: matches[0].track, error: '' }; if (matches.length > 1) { const slot = matches.filter(x => Number(x.medium.position) === Number(song.discNumber) && Number(x.track.position) === Number(song.trackNumber) ); if (slot.length === 1) return { mbTrack: slot[0].track, error: '' }; } return { mbTrack: null, error: matches.length ? `Ambiguous MusicBrainz track: "${song.title}". Credits not imported.` : `No matching MusicBrainz track: "${song.title}". Credits not imported.` }; } function getTrackWorks(track) { const recording = track?.recording; if (!recording) return []; const relationships = recording.relationships || []; const works = []; const seen = new Set(); for (const relationship of relationships) { // MusicBrainz can expose the Work at either relationship endpoint. const target = relationship?.target; const source = relationship?.source; const work = target?.entityType === 'work' ? target : source?.entityType === 'work' ? source : relationship?.target_type === 'work' ? target : null; if (!work) continue; const key = work.gid || work.id || work.name; if (seen.has(key)) continue; seen.add(key); works.push(work); } return works; } function classifyRole(role) { const normalized = normalizeText(role); return ROLE_TYPES[normalized] || null; } function flattenSupportedCredits(credits) { const output = []; for (const credit of credits) { const link = classifyRole(credit.role); if (!link) continue; output.push({ appleName: credit.name, appleRole: credit.role, appleSection: credit.section, target: link.target, linkTypeID: link.id, mbRole: link.label, }); } return output; } function targetEntityForCredit(row, credit) { if (credit.target === 'recording') { return row.mbTrack?.recording || null; } if (credit.target === 'work') { return row.works.length === 1 ? row.works[0] : null; } if (credit.target === 'release') { return MB?.relationshipEditor?.state?.entity || null; } return null; } function existingRelationship(sourceEntity, artist, linkTypeID) { const relationships = sourceEntity?.relationships || []; const artistGid = artist?.gid; return relationships.some(rel => { const target = rel?.target; const source = rel?.source; const sameArtist = target?.gid === artistGid || source?.gid === artistGid || rel?.entity0?.gid === artistGid || rel?.entity1?.gid === artistGid; return sameArtist && Number(rel?.linkTypeID) === Number(linkTypeID); }); } function addRelationship(sourceEntity, artist, linkTypeID, creditedAs) { const backward = sourceEntity.entityType === artist.entityType ? false : sourceEntity.entityType > artist.entityType; const entity0 = backward ? artist : sourceEntity; const entity1 = backward ? sourceEntity : artist; MB.relationshipEditor.dispatch({ type: 'update-relationship-state', sourceEntity, batchSelectionCount: null, creditsToChangeForSource: '', creditsToChangeForTarget: '', newRelationshipState: { ...REL_DEFAULTS, entity0, entity1, entity0_credit: entity0.entityType === 'artist' ? creditedAs : '', entity1_credit: entity1.entityType === 'artist' ? creditedAs : '', id: MB.relationshipEditor.getRelationshipStateId(), linkTypeID, }, oldRelationshipState: null, }); } async function addEditNote() { if (state.applied) return; await ensureMusicBrainzEditorReady(); const editorState = MB?.relationshipEditor?.state; const dispatch = MB?.relationshipEditor?.dispatch; if (!editorState?.editNoteField || typeof dispatch !== 'function') { throw new Error('MusicBrainz relationship editor Edit Note state is unavailable.'); } const sourceLine = `Apple Music credits: ${state.appleUrl}`; const scriptLine = 'Imported with Apple Music works credits -> MusicBrainz; missing Works were searched first, and newly created Works use Work type Song with a user-selected lyrics language.' + `\nScript: ${SCRIPT_URL}`; const current = String(editorState.editNoteField.value || '').trimEnd(); const addition = `${sourceLine}\n${scriptLine}`; const nextValue = current ? `${current}\n\n${addition}` : addition; dispatch({ editNote: nextValue, type: 'update-edit-note', }); const started = Date.now(); while (Date.now() - started < 3000) { MB = PAGE.MB || MB; const stored = String( MB?.relationshipEditor?.state?.editNoteField?.value || '' ); if (stored === nextValue && stored.includes(SCRIPT_URL)) { state.applied = true; return; } await wait(50); } throw new Error( 'MusicBrainz did not store the required Edit Note in relationship-editor state.' ); } async function searchArtists(name) { const query = `artist:"${escapeLucene(name)}" OR alias:"${escapeLucene(name)}"`; const url = `/ws/2/artist/?query=${encodeURIComponent(query)}&fmt=json&limit=10`; const json = await musicBrainzWsJson( url, `MusicBrainz artist search for "${name}"`, `artist-search:${normalizeText(name)}` ); return (json.artists || []).map(artist => ({ mbid: artist.id, name: artist.name || '', disambiguation: artist.disambiguation || '', country: artist.country || '', type: artist.type || '', score: Number(artist.score ?? 0), aliases: (artist.aliases || []).map(alias => alias.name).filter(Boolean), })); } const artistDetailsCache = new Map(); function candidateFromArtistData(artist, contextReason = '') { return { mbid: artist.id || artist.mbid || '', name: artist.name || '', disambiguation: artist.disambiguation || '', country: artist.country || '', type: artist.type || '', score: Number(artist.score ?? 100), aliases: (artist.aliases || []).map(alias => typeof alias === 'string' ? alias : alias?.name ).filter(Boolean), contextReason, }; } function sameArtistName(left, right) { return normalizeText(left) === normalizeText(right); } function artistCanonicalNameMatches(creditName, artist) { return [ artist?.name, artist?.['sort-name'], artist?.sortName, ].filter(Boolean).some(value => sameArtistName(creditName, value)); } function artistAliasMatches(creditName, artist) { return (artist?.aliases || []).some(alias => sameArtistName( creditName, typeof alias === 'string' ? alias : alias?.name ) ); } function artistNameMatches(creditName, artist) { return artistCanonicalNameMatches(creditName, artist) || artistAliasMatches(creditName, artist); } async function getArtistDetails(mbid) { if (artistDetailsCache.has(mbid)) return artistDetailsCache.get(mbid); const url = `/ws/2/artist/${encodeURIComponent(mbid)}?inc=aliases+artist-rels&fmt=json`; const json = await musicBrainzWsJson( url, `MusicBrainz artist context lookup ${mbid}`, `artist-context:${mbid}` ); artistDetailsCache.set(mbid, json); return json; } function mergeCandidates(...groups) { const merged = new Map(); for (const group of groups) { for (const candidate of group || []) { if (!candidate?.mbid) continue; const current = merged.get(candidate.mbid); if (!current || (!current.contextReason && candidate.contextReason)) { merged.set(candidate.mbid, candidate); } } } return [...merged.values()]; } function dedupeContextCandidates(candidates) { return [...new Map( candidates .filter(candidate => candidate?.mbid) .map(candidate => [candidate.mbid, candidate]) ).values()]; } async function relatedArtistsForCreditedArtist(creditedArtist) { const details = await getArtistDetails(creditedArtist.id); const output = []; for (const relation of details.relations || []) { const related = relation?.artist; if (!related?.id) continue; output.push({ root: details, relation, related, }); } return output; } async function findContextArtistCandidates(creditName) { const creditedArtists = state.creditedArtists || []; // Circle 1: artists directly credited on this release/recording/track. const circle1 = []; for (const artist of creditedArtists) { const creditedNameMatch = [...(artist.creditedNames || [])] .some(name => sameArtistName(creditName, name)); if (creditedNameMatch || artistCanonicalNameMatches(creditName, artist)) { const details = await getArtistDetails(artist.id); circle1.push(candidateFromArtistData( details, 'Circle 1: artist directly credited on this release/recording' )); } } if (circle1.length) { return { circle: 1, candidates: dedupeContextCandidates(circle1) }; } // Circle 2: aliases of the directly credited artists. const circle2 = []; for (const artist of creditedArtists) { const details = await getArtistDetails(artist.id); if (artistAliasMatches(creditName, details)) { circle2.push(candidateFromArtistData( details, 'Circle 2: alias of an artist directly credited on this release/recording' )); } } if (circle2.length) { return { circle: 2, candidates: dedupeContextCandidates(circle2) }; } // Build the relationship neighborhood only after direct artists/aliases fail. const relatedContexts = []; for (const artist of creditedArtists) { relatedContexts.push(...await relatedArtistsForCreditedArtist(artist)); } // Circle 3: artists connected through artist-to-artist relationships. // Relationship credits are treated as names for that related artist too. const circle3 = []; for (const context of relatedContexts) { const relationshipCredits = [ context.relation?.['source-credit'], context.relation?.['target-credit'], ].filter(Boolean); const relationCreditMatch = relationshipCredits .some(value => sameArtistName(creditName, value)); if ( artistCanonicalNameMatches(creditName, context.related) || relationCreditMatch ) { const full = await getArtistDetails(context.related.id); circle3.push(candidateFromArtistData( full, `Circle 3: ${context.relation?.type || 'artist relationship'} with ${context.root?.name || 'credited artist'}` )); } } if (circle3.length) { return { circle: 3, candidates: dedupeContextCandidates(circle3) }; } // Circle 4: aliases of artists found through those relationships. const circle4 = []; for (const context of relatedContexts) { const full = await getArtistDetails(context.related.id); if (artistAliasMatches(creditName, full)) { circle4.push(candidateFromArtistData( full, `Circle 4: alias of artist connected by ${context.relation?.type || 'artist relationship'} to ${context.root?.name || 'credited artist'}` )); } } if (circle4.length) { return { circle: 4, candidates: dedupeContextCandidates(circle4) }; } return { circle: 0, candidates: [] }; } function aliasAlreadyPresent(creditName, artist) { return artistNameMatches(creditName, artist); } async function submitArtistAliasEdit(mbid, aliasName, artistName) { const addAliasUrl = `/artist/${encodeURIComponent(mbid)}/add-alias`; const page = await __amMbFetch(addAliasUrl, { credentials: 'same-origin', headers: { Accept: 'text/html,application/xhtml+xml' }, }); if (!page.ok) { throw new Error(`Cannot open MusicBrainz alias editor for "${artistName}": HTTP ${page.status}`); } const doc = new DOMParser().parseFromString(await page.text(), 'text/html'); const form = [...doc.forms].find(item => item.querySelector('[name="edit-alias.name"]') ); if (!form) { throw new Error(`Cannot find the MusicBrainz add-alias form for "${artistName}".`); } const params = new URLSearchParams(); for (const [key, value] of new FormData(form).entries()) { if (typeof value === 'string') params.append(key, value); } params.set('edit-alias.name', aliasName); params.set('edit-alias.sort_name', aliasName); params.set('edit-alias.type_id', '1'); const noteField = form.querySelector( 'textarea[name*="edit_note"], textarea[name*="edit-note"], textarea[name*="editnote"]' ); if (!noteField?.name) { throw new Error(`Required MusicBrainz Edit Note field was not found for alias "${aliasName}".`); } params.set( noteField.name, `Apple Music credits list "${aliasName}" for ${artistName}. Adding the alias so this credit resolves correctly in future imports.\nSource: ${state.appleUrl}\nScript: ${SCRIPT_URL}` ); const action = new URL(form.getAttribute('action') || addAliasUrl, location.origin).href; const response = await __amMbFetch(action, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', }, body: params, }); if (!response.ok) { throw new Error(`Adding alias "${aliasName}" failed: HTTP ${response.status}`); } const finalPath = new URL(response.url).pathname; if (finalPath.endsWith('/add-alias')) { throw new Error(`MusicBrainz did not accept alias "${aliasName}" for ${artistName}.`); } const cached = artistDetailsCache.get(mbid); if (cached) { cached.aliases = [...(cached.aliases || []), { name: aliasName }]; } state.aliasEdits++; } async function ensureCreditAlias(person, mbid) { const artist = await getArtistDetails(mbid); if (aliasAlreadyPresent(person.name, artist)) return false; setStatus(`Adding MusicBrainz alias "${person.name}" to ${artist.name}...`); await submitArtistAliasEdit(mbid, person.name, artist.name || mbid); return true; } function exactCandidateIndexes(name, candidates) { const wanted = normalizeText(name); const indexes = []; candidates.forEach((candidate, index) => { const names = [candidate.name, ...candidate.aliases].map(normalizeText); if (names.includes(wanted)) indexes.push(index); }); return indexes; } async function fetchMbEntity(mbid, fallbackEntityType = 'artist') { const response = await __amMbFetch(`/ws/js/entity/${encodeURIComponent(mbid)}`, { credentials: 'same-origin', }); if (!response.ok) { throw new Error(`Cannot load MusicBrainz ${fallbackEntityType} ${mbid}: HTTP ${response.status}`); } const entity = await response.json(); if (!entity.entityType) entity.entityType = fallbackEntityType; return entity; } const workTitleSearchCache = new Map(); const stagedWorkTitles = new Set(); function isWorkAuthorCredit(credit) { return credit?.target === 'work' && ['writer', 'composer', 'lyricist', 'librettist'].includes(normalizeText(credit.mbRole)); } async function narrowSameTitleWorksByCreditedAuthors(title, matches, authorCredits) { // WorkSearch indexes names of artists linked to each Work. Narrowing // by these names avoids fetching dozens of unrelated Works with // generic names such as "Get It", without first resolving artists. const authorNames = [...new Map(authorCredits .map(credit => String(credit.appleName || '').trim()) .filter(Boolean) .map(name => [normalizeText(name), name])).values()]; if (!authorNames.length) return []; const authorTerms = authorNames.map(name => `artist:"${escapeLucene(name)}"` ); const query = `work:"${escapeLucene(title)}" AND (${authorTerms.join(' OR ')})`; const wantedIds = new Set(matches.map(work => work.id).filter(Boolean)); const narrowedIds = new Set(); const limit = 100; let offset = 0; for (;;) { const url = `/ws/2/work?query=${encodeURIComponent(query)}&fmt=json&limit=${limit}&offset=${offset}`; const json = await musicBrainzWsJson( url, `Checking credited writers among existing Works for "${title}"`, `work-author-index:${normalizeText(title)}:${authorNames.map(normalizeText).join('|')}:${offset}` ); const page = Array.isArray(json.works) ? json.works : []; const count = Number(json['work-count'] ?? (offset + page.length)); if (!Number.isSafeInteger(count) || count < 0) { throw new Error(`Invalid writer-filtered Work result count for "${title}".`); } for (const work of page) { // The earlier unfiltered title search is authoritative here: // a merely similar Work title must not be used for linking. if ( wantedIds.has(work.id) && normalizeText(work.title || work.name) === normalizeText(title) ) { narrowedIds.add(work.id); } } offset += page.length; if (offset >= count) break; if (!page.length || page.length < limit) { throw new Error(`Incomplete writer-filtered Work search for "${title}".`); } } return matches.filter(work => narrowedIds.has(work.id)); } async function findVerifiedExistingWork(title, matches, authorCredits) { const distinct = [...new Map(matches.filter(work => work?.id) .map(work => [work.id, work])).values()]; let candidates = distinct; if (distinct.length > 8) { setStatus(`Filtering ${distinct.length} same-title Works by credited authors for "${title}"...`); candidates = await narrowSameTitleWorksByCreditedAuthors( title, distinct, authorCredits ); } if (candidates.length > 8) { return { work: null, reason: `${distinct.length} same-title Works; ${candidates.length} still match credited names. Manual review required.` }; } if (!candidates.length) { return { work: null, reason: `${distinct.length} Works share this title, but none were confirmed by indexed writer names. Manual review required.` }; } const authorNames = new Set(authorCredits .map(credit => normalizeText(credit.appleName)).filter(Boolean)); const matchesByAuthor = []; for (const candidate of candidates) { const details = await musicBrainzWsJson( `/ws/2/work/${encodeURIComponent(candidate.id)}?inc=artist-rels&fmt=json`, `Checking Work authors: ${title}`, `work-credit-check:${candidate.id}` ); const matching = (details.relations || []).some(relation => relation.artist && ['writer', 'composer', 'lyricist', 'librettist'].includes(normalizeText(relation.type)) && [relation.artist.name, relation['source-credit'], relation['target-credit']] .some(name => name && authorNames.has(normalizeText(name))) ); if (matching) matchesByAuthor.push(candidate); } if (matchesByAuthor.length === 1) return { work: { mbid: matchesByAuthor[0].id, title: matchesByAuthor[0].title }, reason: '' }; return { work: null, reason: matchesByAuthor.length ? `${matchesByAuthor.length} same-title Works have matching writer credits; manual review required.` : `${distinct.length} same-title Works exist, but no writer relationship was verified. Manual review required.` }; } function findTemporaryCreatedWork(title) { let found = null; walk(MB?.relationshipEditor?.state, object => { if ( !found && object?.entityType === 'work' && object?._fromBatchCreateWorksDialog === true && normalizeText(object?.name) === normalizeText(title) ) { found = object; } }); return found; } function selectedRecordingsSnapshot() { // MusicBrainz stores selected recordings in a weight-balanced-tree: // {size, left, value, right}. Walking for entityType on the tree's // root misses leaf values and makes a valid selection look empty. const tree = (PAGE.MB || MB)?.relationshipEditor?.state?.selectedRecordings; if (!tree || !Number.isSafeInteger(tree.size)) { throw new Error('MusicBrainz recording selection is unavailable.'); } const selected = []; const stack = []; let cursor = tree; while ((cursor && cursor.size > 0) || stack.length) { while (cursor && cursor.size > 0) { stack.push(cursor); cursor = cursor.left; } cursor = stack.pop(); if (cursor.value?.entityType === 'recording') { selected.push(cursor.value); } else { throw new Error('MusicBrainz returned an unexpected selected-recording entry.'); } cursor = cursor.right; } if (selected.length !== tree.size) { throw new Error('MusicBrainz recording selection count was inconsistent.'); } return selected; } async function restoreRecordingSelection(recording, previousSelection) { MB.relationshipEditor.dispatch({ isSelected: false, recording, type: 'toggle-select-recording', }); for (const oldRecording of previousSelection) { MB.relationshipEditor.dispatch({ isSelected: true, recording: oldRecording, type: 'toggle-select-recording', }); } await wait(50); } async function createWorkForRecording(recording) { // MusicBrainz supports an optional lyrics language. This soundtrack // includes songs in different languages; assigning one album-wide // language is incorrect. Leave it unspecified until verified per Work. const previousSelection = selectedRecordingsSnapshot(); MB.relationshipEditor.dispatch({ isSelected: false, type: 'toggle-select-all-recordings', }); MB.relationshipEditor.dispatch({ isSelected: true, recording, type: 'toggle-select-recording', }); try { // Wait for the React relationship-editor state to reflect the // selection before validating the native batch action. await wait(50); const selected = selectedRecordingsSnapshot(); if ( selected.length !== 1 || String(selected[0].gid || selected[0].id) !== String(recording.gid || recording.id) ) { throw new Error( `Work creation selection failed for "${recording.name}". No Work was created.` ); } // Native action used by MusicBrainz's Batch-create Works dialog: // the dialog itself is not needed. This stages a Song Work and a // recording-of relationship in the editor, but does NOT submit. // Its reducer accepts an empty lyrics-language list. setStatus(`Staging Work for "${recording.name}" (lyrics language unspecified)...`); MB.relationshipEditor.dispatch({ // The native dialog submits a weight-balanced-tree for link // attributes. Passing null can break relationship updates. attributes: {size: 0, left: null, right: null, value: null}, begin_date: null, end_date: null, ended: false, languages: [], linkType: null, type: 'accept-batch-create-works-dialog', workType: WORK_TYPE_SONG_ID, }); const started = Date.now(); while (Date.now() - started < 5000) { MB = PAGE.MB || MB; const created = findTemporaryCreatedWork(recording.name); if (created) { if (Number(created.typeID) !== WORK_TYPE_SONG_ID) { throw new Error(`Staged Work "${recording.name}" is not type Song.`); } return created; } await wait(100); } throw new Error( `MusicBrainz did not confirm a staged Work for "${recording.name}" within 5 seconds.` ); } finally { await restoreRecordingSelection(recording, previousSelection); } } async function searchExistingWorksByExactTitle(title) { const key = normalizeText(title); if (workTitleSearchCache.has(key)) return workTitleSearchCache.get(key); const query = `work:"${escapeLucene(title)}"`; const limit = 100, matches = []; let offset = 0; for (;;) { const json = await musicBrainzWsJson( `/ws/2/work?query=${encodeURIComponent(query)}&fmt=json&limit=${limit}&offset=${offset}`, `Existing Work title search: ${title}`, `work-title:${key}:${offset}` ); const page = Array.isArray(json.works) ? json.works : []; const count = Number(json['work-count'] ?? (offset + page.length)); if (!Number.isSafeInteger(count) || count < 0) throw Error(`Invalid Work search count: ${title}`); matches.push(...page.filter(work => normalizeText(work.title || work.name) === key)); offset += page.length; if (offset >= count) break; if (!page.length || page.length < limit) { throw Error(`Incomplete Work title search: ${title} (${offset}/${count})`); } } const result = { matches, complete: true }; workTitleSearchCache.set(key, result); return result; } async function linkExistingWorkForRow(row, candidate) { const recording = row.mbTrack?.recording; if (!recording || !candidate?.mbid) throw new Error('Cannot link the selected Work to this recording.'); const work = await fetchMbEntity(candidate.mbid, 'work'); if (work.entityType !== 'work') throw new Error('MusicBrainz returned an unexpected entity instead of a Work.'); if (!existingRelationship(recording, work, RECORDING_OF_LINK_TYPE_ID)) { addRelationship(recording, work, RECORDING_OF_LINK_TYPE_ID, ''); } row.works = [work]; row.workResolution = `Linked existing Work: ${work.name || candidate.title || row.mbTitle}`; } async function ensureWorkForRow(row) { const workCredits = row.supportedCredits.filter(credit => credit.target === 'work'); if (!workCredits.length) return; if (row.works.length === 1) { row.workResolution = 'Existing linked Work'; return; } if (row.works.length > 1) { row.workResolution = `Multiple linked Works (${row.works.length}); manual review required`; return; } const title = row.mbTitle || row.appleTrack.title; const authors = workCredits.filter(isWorkAuthorCredit); if (!authors.length) { row.workResolution = 'No songwriter/composer credits; Work not created'; return; } // Search the title BEFORE resolving credited artists. 503 errors on // the artist endpoint must not prevent staging a genuinely new Work. setStatus(`Looking for existing Work "${title}"...`); const titleCheck = await searchExistingWorksByExactTitle(title); if (!titleCheck.complete) { row.workResolution = 'Work search incomplete; manual review required'; return; } if (titleCheck.matches.length) { setStatus(`Checking existing Work authors for "${title}"...`); const selection = await findVerifiedExistingWork(title, titleCheck.matches, authors); if (selection.work) { await linkExistingWorkForRow(row, selection.work); } else { row.workResolution = selection.reason; } return; } // A different track or a previous run on this editor may have // staged the same title already. Never silently stage a duplicate. const normalizedTitle = normalizeText(title); if (stagedWorkTitles.has(normalizedTitle) || findTemporaryCreatedWork(title)) { row.workResolution = 'A Work with this title is already staged; review its recording link manually.'; return; } setStatus(`No existing Work found for "${title}". Staging new Song Work...`); const work = await createWorkForRecording(row.mbTrack.recording); stagedWorkTitles.add(normalizedTitle); row.works = [work]; row.workResolution = `New Song Work staged: ${work.name || title}. Writer credits are applied separately.`; } function autoCandidateIndex(person) { const preferred = person.preferredMbid ? person.candidates.findIndex(candidate => candidate.mbid === person.preferredMbid) : -1; if (preferred >= 0) return preferred; const exact = exactCandidateIndexes(person.name, person.candidates); return exact.length === 1 ? exact[0] : -1; } function autoMatchedPeopleCount() { return [...state.people.values()].filter(person => autoCandidateIndex(person) >= 0).length; } function selectedMbid(person) { const row = document.querySelector(`[data-person-key="${CSS.escape(person.key)}"]`); if (!row) return ''; const manual = extractMbid(row.querySelector('.am2mb-manual')?.value || ''); if (manual) return manual; return row.querySelector('.am2mb-candidate')?.value || ''; } function setStatus(message, kind = '') { const status = document.getElementById('am2mb-status'); if (!status) return; status.textContent = message; status.dataset.kind = kind; } function renderTracks() { const container = document.getElementById('am2mb-tracks'); if (!container) return; const rows = state.rows.map(row => { const importable = row.supportedCredits.filter(credit => targetEntityForCredit(row, credit)); const unsupported = row.credits.filter(credit => !classifyRole(credit.role)); const blockedWork = row.supportedCredits.filter( credit => credit.target === 'work' && !targetEntityForCredit(row, credit) ); const creditsText = row.supportedCredits.length ? row.supportedCredits .map(item => `${item.appleName} (${item.appleRole} -> ${item.target}: ${item.mbRole})` ) .join(', ') : row.credits.length ? row.credits.map(item => `${item.name} (${item.role})`).join(', ') : 'No Apple Music credits found'; let status = `${importable.length} importable`; let css = importable.length ? 'ok' : 'warn'; if (row.error) { status = row.error; css = 'bad'; } else if (!row.mbTrack) { status = 'No matching MusicBrainz track'; css = 'bad'; } else if (!row.titleMatch) { status = `Title mismatch: MusicBrainz "${row.mbTitle}"`; css = 'bad'; } else { const details = []; if (row.workResolution) { details.push(row.workResolution); } if (blockedWork.length && row.workResolution !== 'Waiting for Work lookup') { details.push( row.works.length === 0 ? `${blockedWork.length} Work credit(s) blocked - no unambiguous Work could be resolved` : `${blockedWork.length} Work credit(s) blocked - multiple Works linked` ); } if (unsupported.length) { const roles = [...new Set(unsupported.map(item => item.role))]; details.push(`unsupported: ${roles.join(', ')}`); } if (details.length) status += `; ${details.join('; ')}`; } return `
| # | Apple Music track | Credits -> MusicBrainz target | Status |
|---|
Artist matching uses four priority circles: credited artists, their aliases, their artist relationships, then aliases of those related artists. Global search is only a fallback. Recording credits go to Recordings, songwriting/composition to Works, and mastering to the Release. Unmatched or ambiguous names are skipped automatically; no artist lookup is required from you. Optional manual selection/MBID remains available. Manually mapped alternative names may create artist aliases.
| Apple Music credit | Role(s) -> target | MusicBrainz candidate | Manual MBID / URL | Match |
|---|
`; for (const row of container.querySelectorAll('tr[data-person-key]')) { const autoIndex = Number(row.querySelector('.am2mb-auto')?.dataset.autoIndex ?? -1); if (autoIndex >= 0) { row.querySelector('.am2mb-candidate').selectedIndex = autoIndex + 1; } } document.getElementById('am2mb-apply').addEventListener('click', applyCredits); } async function loadAppleCredits() { const button = document.getElementById('am2mb-load'); try { await ensureMusicBrainzEditorReady(); state.appleUrl = ''; state.mbBarcode = ''; state.appleBarcode = ''; state.sourceMode = ''; state.appleTracks = []; state.rows = []; state.people.clear(); state.creditedArtists = []; state.aliasEdits = 0; state.applied = false; // A second Load on this page must not trust cached negative // searches from before any MusicBrainz edits were submitted. workTitleSearchCache.clear(); stagedWorkTitles.clear(); mbWsCache.clear(); button.disabled = true; document.getElementById('am2mb-tracks').innerHTML = ''; document.getElementById('am2mb-people').innerHTML = ''; document.getElementById('am2mb-source').innerHTML = ''; const songUrl = document.getElementById('am2mb-song-url')?.value.trim() || ''; const singleSong = Boolean(songUrl); let appleTracks, creditResults; if (singleSong) { const song = await resolveAppleSongUrl(songUrl); state.appleUrl = song.url; setSourceInfo(song); appleTracks = [song.appleTrack]; creditResults = [{ creditsUrl: song.creditsUrl, credits: song.credits }]; } else { const resolved = await resolveAppleRelease(); state.appleUrl = resolved.url; setSourceInfo(resolved); setStatus('Loading resolved Apple Music release...'); const albumData = parseAppleServerData(await gmGet(state.appleUrl)); appleTracks = getAppleTracks(albumData); if (!appleTracks.length) throw new Error('No Apple Music tracks were found on the resolved release page.'); setStatus(`Found ${appleTracks.length} tracks. Loading all Apple Music credits...`); creditResults = await mapPool(appleTracks, 4, async (track, index) => { setStatus(`Loading Apple Music credits ${index + 1}/${appleTracks.length}: ${track.title}`); const creditsUrl = songCreditsUrl(track, state.appleUrl); const data = parseAppleServerData(await gmGet(creditsUrl)); return { creditsUrl, credits: getAppleCredits(data) }; }); } state.appleTracks = appleTracks; state.rows = appleTracks.map((appleTrack, index) => { const result = creditResults[index]; const match = singleSong ? resolveMbTrackForSong(appleTrack) : { mbTrack: getMbTrack(appleTrack.discNumber, appleTrack.trackNumber), error: '' }; const mbTrack = match.mbTrack; const mbTitle = mbTrack?.name || ''; const works = getTrackWorks(mbTrack); const credits = result?.credits || []; const supportedCredits = flattenSupportedCredits(credits); return { appleTrack, creditsUrl: result?.creditsUrl || '', credits, supportedCredits, mbTrack, mbTitle, works, workResolution: '', titleMatch: !!mbTrack && normalizeTrackTitleForMatch(appleTrack.title) === normalizeTrackTitleForMatch(mbTitle), error: result?.error ? result.error.message : match.error, }; }); if (singleSong && !state.rows.some(row => row.mbTrack && row.titleMatch && !row.error)) { renderTracks(); throw new Error(state.rows[0]?.error || 'Song does not match a MusicBrainz track.'); } // Enter the source note before any Work edits are staged. await addEditNote(); // Expose partial results while Work lookups run, including via bridge. for (const row of state.rows) { if (row.mbTrack && row.titleMatch && !row.error && row.supportedCredits.some(credit => credit.target === 'work')) { row.workResolution = 'Waiting for Work lookup'; } } renderTracks(); let workChecks = 0; let worksStaged = 0; let worksReused = 0; for (let index = 0; index < state.rows.length; index++) { const row = state.rows[index]; if (row.error || !row.mbTrack || !row.titleMatch) continue; const hasWorkCredits = row.supportedCredits.some(credit => credit.target === 'work'); if (hasWorkCredits && row.works.length === 0) { setStatus( `Resolving Work ${index + 1}/${state.rows.length}: ${row.appleTrack.title}` ); try { await ensureWorkForRow(row); } catch (error) { row.workResolution = error.message; console.warn('[Apple Music -> MusicBrainz] Work resolution failed:', error); } } else if (hasWorkCredits) { await ensureWorkForRow(row); } if (hasWorkCredits) { workChecks++; if (row.workResolution.startsWith('New Song Work staged:')) worksStaged++; if (row.workResolution.startsWith('Linked existing Work:')) worksReused++; renderTracks(); setStatus( `Works checked ${workChecks}: ${worksStaged} newly staged, ${worksReused} existing linked. ` + 'Artist credits are checked separately.' ); } } for (const row of state.rows) { if (row.error || !row.mbTrack || !row.titleMatch) continue; for (const credit of row.supportedCredits) { if (!targetEntityForCredit(row, credit)) continue; const key = normalizeText(credit.appleName); if (!state.people.has(key)) { state.people.set(key, { key, name: credit.appleName, roles: new Set(), candidates: [], }); } state.people.get(key).roles.add( `${credit.appleRole} -> ${credit.target}` ); } } renderTracks(); if (!state.people.size) { const allRoles = [...new Set( state.rows.flatMap(row => row.credits.map(credit => credit.role)) )]; throw new Error( allRoles.length ? `Apple Music credits were found, but none are currently importable. Check unresolved Work matches or unsupported roles: ${allRoles.join(', ')}` : 'No Apple Music credits were found.' ); } const people = [...state.people.values()]; for (let index = 0; index < people.length; index++) { const person = people[index]; setStatus( `Checking artist context circles ${index + 1}/${people.length}: ${person.name}` ); const context = await findContextArtistCandidates(person.name); const contextCandidates = context.candidates; if (contextCandidates.length) { // Stop at the first circle which has matches. Lower-priority // circles and global search must not override a local match. person.candidates = contextCandidates; if (contextCandidates.length === 1) { person.preferredMbid = contextCandidates[0].mbid; person.preferredReason = contextCandidates[0].contextReason; } else { person.preferredMbid = ''; person.preferredReason = `Circle ${context.circle} has multiple matching artists`; } continue; } setStatus( `Searching all MusicBrainz artists ${index + 1}/${people.length}: ${person.name}` ); person.candidates = await searchArtists(person.name); } renderPeople(); const importableCount = state.rows.reduce( (sum, row) => sum + row.supportedCredits.filter( credit => row.mbTrack && row.titleMatch && targetEntityForCredit(row, credit) ).length, 0 ); setStatus( `Loaded ${appleTracks.length} Apple Music track(s), ${importableCount} eligible credit(s). ` + `${autoMatchedPeopleCount()} artist name(s) auto-matched; ` + `${state.people.size - autoMatchedPeopleCount()} skipped by default. Ready to apply matched credits.`, 'ok' ); } catch (error) { console.error('[Apple Music -> MusicBrainz]', error); setStatus(error.message, 'bad'); } finally { button.disabled = false; } } async function applyCredits() { const button = document.getElementById('am2mb-apply'); if (!button) return; try { button.disabled = true; await ensureMusicBrainzEditorReady(); const entityCache = new Map(); const mapping = new Map(); let skippedArtists = 0; for (const person of state.people.values()) { const personRow = document.querySelector(`[data-person-key="${CSS.escape(person.key)}"]`); const manualValue = String(personRow?.querySelector('.am2mb-manual')?.value || '').trim(); const mbid = selectedMbid(person); if (manualValue && !extractMbid(manualValue)) { throw new Error(`Invalid manual MusicBrainz artist MBID for "${person.name}". Clear it to skip.`); } if (!mbid) { skippedArtists++; continue; } if (!isMbid(mbid)) { throw new Error(`Invalid MusicBrainz artist MBID for "${person.name}".`); } if (!entityCache.has(mbid)) { setStatus(`Loading MusicBrainz artist: ${person.name}`); const entity = await fetchMbEntity(mbid); entityCache.set(mbid, entity); } mapping.set(person.key, entityCache.get(mbid)); } if (!mapping.size) { setStatus( `No credits staged: ${skippedArtists} Apple Music artist name(s) were skipped. ` + 'Optional mappings are available under Artist mapping.', 'warn' ); return; } for (const person of state.people.values()) { const mbid = selectedMbid(person); if (!mbid) continue; await ensureCreditAlias(person, mbid); } // Edit Note is mandatory. Populate MusicBrainz's internal React // relationship-editor state before staging any relationship edits. await addEditNote(); let added = 0; let addedRecording = 0; let addedWork = 0; let addedRelease = 0; let skippedExisting = 0; let skippedUnavailable = 0; let skippedUnmapped = 0; const created = new Set(); for (const row of state.rows) { if (row.error || !row.mbTrack || !row.titleMatch || !row.supportedCredits.length) { continue; } for (const credit of row.supportedCredits) { const sourceEntity = targetEntityForCredit(row, credit); if (!sourceEntity) { skippedUnavailable++; continue; } const artist = mapping.get(normalizeText(credit.appleName)); if (!artist) { skippedUnmapped++; continue; } const sourceId = sourceEntity.gid || sourceEntity.id || sourceEntity.name; const artistId = artist.gid || artist.id || artist.name; const key = [sourceId, artistId, credit.linkTypeID].join('|'); if ( created.has(key) || existingRelationship(sourceEntity, artist, credit.linkTypeID) ) { skippedExisting++; continue; } addRelationship( sourceEntity, artist, credit.linkTypeID, credit.appleName ); created.add(key); added++; if (credit.target === 'recording') addedRecording++; else if (credit.target === 'work') addedWork++; else if (credit.target === 'release') addedRelease++; } } setStatus( `Applied ${added} relationship(s): ${addedRecording} Recording, ${addedWork} Work, ${addedRelease} Release. ` + `${skippedExisting} existing/duplicate(s), ${skippedUnavailable} unavailable target(s); ` + `${skippedUnmapped} credit(s) skipped for ${skippedArtists} unmapped artist name(s). ` + `${state.aliasEdits} artist alias edit(s). Review staged changes, then submit normally.`, 'ok' ); } catch (error) { console.error('[Apple Music -> MusicBrainz]', error); setStatus(error.message, 'bad'); } finally { button.disabled = false; } } function injectUi() { const existingPanel = document.getElementById('am2mb-panel'); if (existingPanel) { if (existingPanel.dataset.scriptVersion === SCRIPT_VERSION) return; // A legacy installation can remain active after the userscript was // renamed. Remove its panel so the current version always wins. existingPanel.remove(); } const form = document.getElementById('relationship-editor-form') || document.querySelector('form[action*="/edit-relationships"]') || document.getElementById('edit-note-text')?.closest('form') || document.querySelector('textarea[name="edit_note"]')?.closest('form'); if (!form) { setTimeout(injectUi, 500); return; } const panel = document.createElement('div'); panel.id = 'am2mb-panel'; panel.dataset.scriptVersion = SCRIPT_VERSION; panel.innerHTML = `
Paste a song URL to import that track only. Leave blank to import the full album using its Apple link/barcode.
Ready. Paste a song URL or use the linked album/barcode.
`; form.insertAdjacentElement('beforebegin', panel); document.getElementById('am2mb-load').addEventListener('click', loadAppleCredits); } // Compatibility with MusicBrainz ToolBox's consolidated Apple importer. // The standalone script is retained as an independent fallback for users // who do not have Toolbox installed; no duplicate Apple controls appear. const ownerAttribute = 'data-karpuzikov-apple-import-owner'; const rootElement = document.documentElement; function toolboxOwnsAppleImport() { return rootElement.getAttribute(ownerAttribute) === 'toolbox'; } function yieldToToolbox() { if (!toolboxOwnsAppleImport()) return false; const panel = document.getElementById('am2mb-panel'); const hasStagedResults = !!document.getElementById('am2mb-tracks')?.textContent?.trim(); if (panel && !document.getElementById('am2mb-load')?.disabled && !hasStagedResults) { panel.remove(); } return true; } // Toolbox executes at document-idle; wait briefly before mounting our // fallback panel to avoid a transient double-button flash. const ownerObserver = new MutationObserver(() => yieldToToolbox()); ownerObserver.observe(rootElement, {attributes: true, attributeFilter: [ownerAttribute]}); setTimeout(() => { if (!yieldToToolbox()) injectUi(); }, 2000); })();