const resources =
await searchAppleMusicResourcesByAlbumId(info.id);
if (resources.length) {
releases = await lookupMusicBrainzResources(resources);
}
}
if (!releases.length) {
const barcode =
await __mbToolBoxAppleAlbumBarcode(info);
if (barcode) {
releases =
await searchMusicBrainzReleasesByBarcode(
barcode
);
}
}
storeCachedReleases(info.key, releases);
return releases;
} catch (error) {
console.warn('[MusicBrainz ToolBox] Apple Music -> MusicBrainz lookup failed:', error);
return null;
}
}
function installStyles() {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = `
.${TITLE_ROW_CLASS} {
display: flex !important;
align-items: center !important;
gap: 8px !important;
flex-wrap: wrap;
min-width: 0;
}
.${LINK_CLASS} {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 28px;
height: 28px;
border-radius: 4px;
text-decoration: none !important;
opacity: .92;
transition: transform .12s ease, opacity .12s ease;
}
.${LINK_CLASS}:hover {
opacity: 1;
transform: scale(1.08);
}
.${LINK_CLASS} img,
.${LINK_CLASS} svg {
display: block;
width: 26px;
height: 26px;
border: 0;
}
.${LINK_CLASS}.mb-toolbox-provider-link-missing {
position: relative;
overflow: visible;
}
.${LINK_CLASS} .mb-toolbox-broken-chain-badge {
position: absolute;
right: 0;
bottom: 0;
z-index: 2;
width: 10px;
height: 10px;
overflow: visible;
font-size: 9px;
line-height: 10px;
text-align: center;
pointer-events: none;
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, .9));
}
`;
document.head.appendChild(style);
}
function removeLinks() {
document.querySelectorAll('.' + LINK_CLASS).forEach(node => node.remove());
document.querySelectorAll('.' + TITLE_ROW_CLASS).forEach(node => {
node.classList.remove(TITLE_ROW_CLASS);
});
}
function renderLinks() {
const info = appleAlbumInfoFromLocation();
if (!info || info.key !== currentAlbumKey || !Array.isArray(currentReleases)) {
return false;
}
const title = document.querySelector(
'h1[data-testid="non-editable-product-title"]'
);
if (!title) return false;
installStyles();
title.classList.add(TITLE_ROW_CLASS);
const desired = currentReleases.length
? currentReleases.map(release => {
const unlinked =
release.linkState === 'unlinked';
const releaseLabel = release.disambiguation
? `${release.title || release.id} (${release.disambiguation})`
: (release.title || release.id);
return {
key:
(unlinked ? 'mb-unlinked:' : 'mb:') +
release.id,
mbid: release.id,
href: unlinked
? __mbToolBoxHarmonyReleaseUrl(
info.harmonyResource,
release.id
)
: 'https://musicbrainz.org/release/' +
release.id,
svg: __mbToolBoxMusicBrainzIconSvg,
alt: 'MusicBrainz',
brokenLink: unlinked,
title: unlinked
? `MusicBrainz release found by barcode ${release.barcode || ''}, but this Apple Music release is not linked: ${releaseLabel}`
: `Open MusicBrainz release: ${releaseLabel}`,
};
})
: [{
key: 'harmony',
mbid: '',
href:
HARMONY_URL +
'release?url=' +
encodeURIComponent(info.harmonyResource) +
'>in=®ion=&deezer=&spotify=&tidal=&qobuz=',
svg: HARMONY_LOGO_SVG,
alt: 'Harmony',
title: 'Search this Apple Music release in Harmony',
}];
const wantedKeys = new Set(desired.map(item => item.key));
title.querySelectorAll('.' + LINK_CLASS).forEach(link => {
if (!wantedKeys.has(link.dataset.linkKey || '')) link.remove();
});
for (const item of desired) {
if (title.querySelector(
`.${LINK_CLASS}[data-link-key="${CSS.escape(item.key)}"]`
)) {
continue;
}
const link = document.createElement('a');
link.className =
LINK_CLASS +
(item.brokenLink
? ' mb-toolbox-provider-link-missing'
: '');
link.dataset.linkKey = item.key;
if (item.mbid) link.dataset.mbid = item.mbid;
link.href = item.href;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.title = item.title;
link.setAttribute('aria-label', item.title);
if (item.svg) {
const icon = document.createElement('span');
icon.setAttribute('aria-hidden', 'true');
icon.innerHTML = item.svg;
link.appendChild(icon.firstElementChild);
if (item.brokenLink) {
const badge = document.createElement('span');
badge.className =
'mb-toolbox-broken-chain-badge';
badge.textContent = '⛓️💥';
badge.setAttribute('aria-hidden', 'true');
link.appendChild(badge);
}
} else {
const image = document.createElement('img');
image.src = item.image;
image.alt = item.alt;
image.width = 26;
image.height = 26;
link.appendChild(image);
}
title.appendChild(link);
}
return true;
}
async function refreshCurrentAlbum(force = false) {
const info = appleAlbumInfoFromLocation();
if (!info || info.key !== currentAlbumKey || lookupInFlight) return;
lookupInFlight = true;
const generation = requestGeneration;
try {
const releases = await lookupMusicBrainzReleases(info, force);
const latest = appleAlbumInfoFromLocation();
if (
generation !== requestGeneration ||
!latest ||
latest.key !== currentAlbumKey ||
latest.key !== info.key
) {
return;
}
currentLookupAt = Date.now();
currentReleases = releases;
renderLinks();
} finally {
lookupInFlight = false;
}
}
function handleRouteChange() {
const info = appleAlbumInfoFromLocation();
const albumKey = info?.key || '';
if (albumKey === currentAlbumKey) {
if (Array.isArray(currentReleases)) {
renderLinks();
if (
currentReleases.length === 0 &&
Date.now() - currentLookupAt >= MISSING_TTL
) {
refreshCurrentAlbum(true);
}
}
return;
}
currentAlbumKey = albumKey;
currentReleases = null;
currentLookupAt = 0;
++requestGeneration;
removeLinks();
if (!info) return;
refreshCurrentAlbum(false);
}
function queueScan() {
if (scanQueued) return;
scanQueued = true;
setTimeout(() => {
scanQueued = false;
handleRouteChange();
}, 50);
}
const observer = new MutationObserver(queueScan);
observer.observe(document.documentElement, {
childList: true,
subtree: true,
});
window.addEventListener('focus', () => {
if (
currentAlbumKey &&
Array.isArray(currentReleases) &&
(
currentReleases.length === 0 ||
currentReleases.some(
release =>
release?.linkState === 'unlinked'
)
)
) {
refreshCurrentAlbum(true);
}
});
document.addEventListener('visibilitychange', () => {
if (
document.visibilityState === 'visible' &&
currentAlbumKey &&
Array.isArray(currentReleases) &&
(
currentReleases.length === 0 ||
currentReleases.some(
release =>
release?.linkState === 'unlinked'
)
)
) {
refreshCurrentAlbum(true);
}
});
setInterval(handleRouteChange, 500);
handleRouteChange();
})();
}
// ============================================================================
// Apple Music release-list indicators
// Exact Apple Music album URLs are resolved through MusicBrainz URL relations.
// Found -> MusicBrainz icon. Missing -> Harmony fallback.
// ============================================================================
if (location.hostname === 'music.apple.com') {
(() => {
'use strict';
const INDICATOR_CLASS = 'mb-toolbox-release-list-indicator';
const STYLE_ID = 'mb-toolbox-release-list-indicator-style';
const CACHE_KEY = 'mb-toolbox-release-list-links-v2';
const FOUND_TTL = 30 * 24 * 60 * 60 * 1000;
const MISSING_TTL = 15 * 1000;
const MAX_CACHE_ENTRIES = 1000;
const REQUEST_INTERVAL = 1100;
const HARMONY_URL = 'https://harmony.pulsewidth.org.uk/';
const HARMONY_LOGO_SVG = "";
const pendingLookups = new Map();
const targetLookupGeneration = new WeakMap();
let requestQueue = Promise.resolve();
let lastRequestAt = 0;
let scanQueued = false;
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function queueMusicBrainzRequest(task) {
const run = requestQueue.then(async () => {
const wait = Math.max(
0,
REQUEST_INTERVAL - (Date.now() - lastRequestAt)
);
if (wait) await sleep(wait);
lastRequestAt = Date.now();
return task();
});
requestQueue = run.catch(() => {});
return run;
}
function readCache() {
try {
const value = GM_getValue(CACHE_KEY, {});
return value && typeof value === 'object' ? value : {};
} catch {
return {};
}
}
function writeCache(cache) {
try {
const entries = Object.entries(cache)
.sort(
(a, b) =>
Number(b[1]?.checkedAt || 0) -
Number(a[1]?.checkedAt || 0)
)
.slice(0, MAX_CACHE_ENTRIES);
GM_setValue(CACHE_KEY, Object.fromEntries(entries));
} catch {
// Cache failure must never break Apple Music.
}
}
function cachedResult(resource, force = false) {
if (force) return null;
const item = readCache()[resource];
if (!item?.checkedAt || !Array.isArray(item.releases)) {
return null;
}
const hasUnlinked = item.releases.some(
release => release?.linkState === 'unlinked'
);
const ttl =
item.releases.length && !hasUnlinked
? FOUND_TTL
: MISSING_TTL;
if (Date.now() - Number(item.checkedAt) > ttl) return null;
return item.releases;
}
function storeResult(resource, releases) {
const cache = readCache();
cache[resource] = {
checkedAt: Date.now(),
releases,
};
writeCache(cache);
}
function requestJson(url) {
return new Promise((resolve, reject) => {
__mbToolBoxGmXmlhttpRequest({
method: 'GET',
url,
headers: {Accept: 'application/json'},
timeout: 15000,
onload(response) {
if (response.status === 404) {
resolve(null);
return;
}
if (
response.status < 200 ||
response.status >= 300
) {
reject(
new Error(
'MusicBrainz HTTP ' + response.status
)
);
return;
}
try {
resolve(JSON.parse(response.responseText));
} catch (error) {
reject(error);
}
},
onerror() {
reject(new Error('MusicBrainz request failed'));
},
ontimeout() {
reject(new Error('MusicBrainz request timed out'));
},
});
});
}
function parseReleaseRelations(data) {
const sources = Array.isArray(data?.urls) ? data.urls : [data];
const unique = new Map();
for (const source of sources) {
const relations = Array.isArray(source?.relations)
? source.relations
: [];
for (const relation of relations) {
const release = relation?.release;
const id = String(release?.id || '')
.trim()
.toLowerCase();
if (
!/^[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(
id
)
) {
continue;
}
if (!unique.has(id)) {
unique.set(id, {
id,
title: String(release?.title || '').trim(),
disambiguation: String(
release?.disambiguation || ''
).trim(),
});
}
}
}
return [...unique.values()];
}
function appleAlbumIdFromResource(value) {
let url;
try {
url = new URL(value);
} catch {
return '';
}
if (url.hostname.toLowerCase() !== 'music.apple.com') return '';
const parts = url.pathname.split('/').filter(Boolean);
const albumIndex = parts.findIndex(
part => part.toLowerCase() === 'album'
);
if (albumIndex < 0) return '';
return parts
.slice(albumIndex + 1)
.reverse()
.find(part => /^\d+$/.test(part)) || '';
}
async function lookupResources(resources) {
const uniqueResources = [...new Set(
(resources || []).filter(Boolean)
)];
if (!uniqueResources.length) return [];
const endpoint =
'https://musicbrainz.org/ws/2/url?' +
uniqueResources
.map(resource => 'resource=' + encodeURIComponent(resource))
.join('&') +
'&inc=release-rels&fmt=json';
const data = await queueMusicBrainzRequest(
() => requestJson(endpoint)
);
return parseReleaseRelations(data);
}
async function searchAppleResourcesByAlbumId(albumId) {
const base = 'https\\:\\/\\/music.apple.com\\/';
const searchQueries = [
'url:' + base + '*\\/album\\/*\\/' + albumId +
' AND targettype:release',
'url:' + base + '*\\/album\\/' + albumId +
' AND targettype:release',
];
for (const query of searchQueries) {
const endpoint =
'https://musicbrainz.org/ws/2/url/?query=' +
encodeURIComponent(query) +
'&limit=100&fmt=json';
const data = await queueMusicBrainzRequest(
() => requestJson(endpoint)
);
const resources = [...new Set(
(Array.isArray(data?.urls) ? data.urls : [])
.map(item => String(item?.resource || '').trim())
.filter(resource =>
appleAlbumIdFromResource(resource) === albumId
)
)];
if (resources.length) return resources;
}
return [];
}
async function searchReleasesByBarcode(barcode) {
const variants = __mbToolBoxBarcodeVariants(barcode);
if (!variants.length) return [];
const query = variants
.map(value => 'barcode:' + value)
.join(' OR ');
const endpoint =
'https://musicbrainz.org/ws/2/release/?query=' +
encodeURIComponent(query) +
'&limit=100&fmt=json';
const data = await queueMusicBrainzRequest(
() => requestJson(endpoint)
);
const wanted = __mbToolBoxComparableBarcode(barcode);
const unique = new Map();
for (const release of data?.releases || []) {
if (
__mbToolBoxComparableBarcode(release?.barcode) !==
wanted
) {
continue;
}
const id = String(release?.id || '')
.trim()
.toLowerCase();
if (
!/^[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(
id
)
) {
continue;
}
if (!unique.has(id)) {
unique.set(id, {
id,
title: String(release?.title || '').trim(),
disambiguation: String(
release?.disambiguation || ''
).trim(),
barcode: String(release?.barcode || barcode),
linkState: 'unlinked',
});
}
}
return [...unique.values()];
}
async function lookupResource(info, force = false) {
const cached = cachedResult(info.key, force);
if (cached !== null) return cached;
const pendingKey = info.key + (force ? ':force' : '');
if (pendingLookups.has(pendingKey)) {
return pendingLookups.get(pendingKey);
}
const promise = (async () => {
try {
let releases = await lookupResources([
info.resource,
]);
if (!releases.length) {
const resources =
await searchAppleResourcesByAlbumId(info.id);
if (resources.length) {
releases = await lookupResources(resources);
}
}
if (!releases.length) {
const barcode =
await __mbToolBoxAppleAlbumBarcode(info);
if (barcode) {
releases =
await searchReleasesByBarcode(
barcode
);
}
}
storeResult(info.key, releases);
return releases;
} catch (error) {
console.warn(
'[MusicBrainz ToolBox] Apple Music release-list lookup failed:',
info.key,
error
);
return null;
} finally {
pendingLookups.delete(pendingKey);
}
})();
pendingLookups.set(pendingKey, promise);
return promise;
}
function appleAlbumInfo(value) {
let url;
try {
url = new URL(value, location.href);
} catch {
return null;
}
if (url.hostname !== 'music.apple.com') return null;
const parts = url.pathname.split('/').filter(Boolean);
const albumIndex = parts.findIndex(
part => part.toLowerCase() === 'album'
);
if (albumIndex < 0) return null;
const id = parts
.slice(albumIndex + 1)
.reverse()
.find(part => /^\d+$/.test(part));
if (!id) return null;
const storefront =
albumIndex > 0 &&
/^[a-z]{2}$/i.test(parts[albumIndex - 1])
? parts[albumIndex - 1].toLowerCase()
: 'us';
const harmonyUrl = new URL(url.href);
harmonyUrl.hash = '';
harmonyUrl.search = '';
return {
id,
storefront,
key: 'apple:' + id,
resource:
'https://music.apple.com/' +
storefront +
'/album/' +
id,
harmonyUrl: harmonyUrl.href,
};
}
function installStyles() {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = [
'.' + INDICATOR_CLASS + ' {',
'display: inline-flex !important;',
'align-items: center !important;',
'justify-content: center !important;',
'width: 18px !important;',
'height: 18px !important;',
'margin-left: 6px !important;',
'vertical-align: -3px !important;',
'flex: 0 0 auto !important;',
'text-decoration: none !important;',
'opacity: .94 !important;',
'}',
'.' + INDICATOR_CLASS + ':hover {',
'opacity: 1 !important;',
'transform: scale(1.12) !important;',
'}',
'.' + INDICATOR_CLASS + ' img,',
'.' + INDICATOR_CLASS + ' svg {',
'display: block !important;',
'width: 18px !important;',
'height: 18px !important;',
'max-width: none !important;',
'border: 0 !important;',
'}',
'.' + INDICATOR_CLASS + '.mb-toolbox-provider-link-missing {',
'position: relative !important;',
'overflow: visible !important;',
'}',
'.' + INDICATOR_CLASS + ' .mb-toolbox-broken-chain-badge {',
'position: absolute !important;',
'right: 0 !important;',
'bottom: 0 !important;',
'z-index: 2 !important;',
'width: 8px !important;',
'height: 8px !important;',
'overflow: visible !important;',
'font-size: 7px !important;',
'line-height: 8px !important;',
'text-align: center !important;',
'pointer-events: none !important;',
'filter: drop-shadow(0 1px 1px rgba(0,0,0,.9));',
'}',
].join('\n');
document.head.appendChild(style);
}
function ensureTargetId(target) {
if (!target.dataset.mbtbIndicatorTarget) {
target.dataset.mbtbIndicatorTarget =
Math.random().toString(36).slice(2);
}
return target.dataset.mbtbIndicatorTarget;
}
function indicatorsForTarget(target) {
const parent = target.parentElement;
if (!parent) return [];
const targetId = ensureTargetId(target);
return [...parent.querySelectorAll('.' + INDICATOR_CLASS)]
.filter(
node =>
node.dataset.mbtbTarget === targetId
);
}
function makeMusicBrainzImage() {
const holder = document.createElement('span');
holder.innerHTML = __mbToolBoxMusicBrainzIconSvg;
const icon = holder.firstElementChild;
icon.setAttribute('role', 'img');
icon.setAttribute('aria-label', 'MusicBrainz');
return icon;
}
function createIndicator(info, releases) {
if (releases.length) {
const release = releases[0];
const unlinked =
release.linkState === 'unlinked';
const link = document.createElement('a');
link.className =
INDICATOR_CLASS +
(unlinked
? ' mb-toolbox-provider-link-missing'
: '');
link.dataset.mbtbKey = encodeURIComponent(info.key);
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.href = unlinked
? __mbToolBoxHarmonyReleaseUrl(
info.harmonyUrl,
release.id
)
: 'https://musicbrainz.org/release/' + release.id;
const releaseLabel = release.disambiguation
? (release.title || release.id) +
' (' + release.disambiguation + ')'
: (release.title || release.id);
link.title = unlinked
? 'MusicBrainz release found by barcode ' +
(release.barcode || '') +
', but this Apple Music release is not linked: ' +
releaseLabel
: 'Open MusicBrainz release: ' + releaseLabel;
link.setAttribute('aria-label', link.title);
link.appendChild(makeMusicBrainzImage());
if (unlinked) {
const badge = document.createElement('span');
badge.className =
'mb-toolbox-broken-chain-badge';
badge.textContent = '⛓️💥';
badge.setAttribute('aria-hidden', 'true');
link.appendChild(badge);
}
return link;
}
const link = document.createElement('a');
link.className = INDICATOR_CLASS;
link.dataset.mbtbKey = encodeURIComponent(info.key);
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.href =
HARMONY_URL +
'release?url=' +
encodeURIComponent(info.harmonyUrl) +
'>in=®ion=&deezer=&spotify=&tidal=&qobuz=';
link.title = 'Search this Apple Music release in Harmony';
link.setAttribute('aria-label', link.title);
const holder = document.createElement('span');
holder.setAttribute('aria-hidden', 'true');
holder.innerHTML = HARMONY_LOGO_SVG;
if (holder.firstElementChild) {
link.appendChild(holder.firstElementChild);
}
return link;
}
async function decorateTarget(target, info, force = false) {
if (!target || !info) return;
const key = encodeURIComponent(info.key);
const existing = indicatorsForTarget(target)[0];
if (
existing &&
existing.dataset.mbtbKey === key &&
!force &&
cachedResult(info.key) !== null
) {
return;
}
const generation =
(targetLookupGeneration.get(target) || 0) + 1;
targetLookupGeneration.set(target, generation);
const releases = await lookupResource(
info,
force
);
if (
!Array.isArray(releases) ||
!target.isConnected ||
targetLookupGeneration.get(target) !== generation
) {
return;
}
installStyles();
indicatorsForTarget(target).forEach(
node => node.remove()
);
const indicator = createIndicator(info, releases);
indicator.dataset.mbtbTarget = ensureTargetId(target);
target.insertAdjacentElement('afterend', indicator);
}
function appleTargets() {
const selectors = [
'a[data-testid="product-lockup-title"][href*="/album/"]',
'[data-testid="artist-featured-release-title"] > a[href*="/album/"]',
];
return [...document.querySelectorAll(selectors.join(','))]
.map(target => ({
target,
info: appleAlbumInfo(target.href),
}))
.filter(item => item.info);
}
function scan(forceMissing = false) {
const tasks = appleTargets().map(async item => {
let force = false;
if (forceMissing) {
const cached = cachedResult(item.info.key);
force =
Array.isArray(cached) &&
(
cached.length === 0 ||
cached.some(
release =>
release?.linkState ===
'unlinked'
)
);
}
await decorateTarget(
item.target,
item.info,
force
);
});
return Promise.allSettled(tasks);
}
function queueScan() {
if (scanQueued) return;
scanQueued = true;
setTimeout(() => {
scanQueued = false;
scan(false);
}, 80);
}
const observer = new MutationObserver(queueScan);
observer.observe(document.documentElement, {
childList: true,
subtree: true,
});
window.addEventListener('focus', () => {
scan(true);
});
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
scan(true);
}
});
setInterval(() => scan(false), 5000);
scan(false);
})();
}
// Relationship shortcuts for MusicBrainz entity lists.
if (__mbToolBoxShouldRun([
"https://musicbrainz.org/*",
"https://beta.musicbrainz.org/*"
], [])) {
(() => {
'use strict';
const STYLE_ID = 'mbtb-relationship-shortcuts-style';
const CELL_CLASS = 'mbtb-relationship-shortcuts';
const HEAD_CLASS = 'mbtb-relationship-shortcuts-head';
const LABEL_COMMENT_CLASS = 'mbtb-label-disambiguation';
const LABEL_TOGGLE_CLASS = 'mbtb-label-disambiguation-toggle';
const UUID = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}';
function pageContext() {
if (!/(^|\.)musicbrainz\.org$/i.test(location.hostname)) return null;
let match = location.pathname.match(new RegExp('^/artist/(' + UUID + ')/?$','i'));
if (match) {
return {
parentType: 'artist',
parentMbid: match[1],
childType: 'release-group',
};
}
match = location.pathname.match(new RegExp('^/(release-group|label)/(' + UUID + ')/?$','i'));
if (match) {
return {
parentType: match[1],
parentMbid: match[2],
childType: 'release',
};
}
match = location.pathname.match(new RegExp('^/artist/(' + UUID + ')/(releases|works)/?$','i'));
if (match) {
return {
parentType: 'artist',
parentMbid: match[1],
childType: match[2].replace(/s$/, ''),
};
}
return null;
}
function installStyle() {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = [
'th.' + HEAD_CLASS + ' { white-space: nowrap; }',
'td.' + CELL_CLASS + ' { white-space: normal; min-width: 20px; }',
'td.' + CELL_CLASS + ' a { display: inline-block; margin: 2px; vertical-align: middle; }',
'td.' + CELL_CLASS + ' span.favicon { display: inline-block; width: 16px; height: 16px; vertical-align: middle; }',
'td.' + CELL_CLASS + ' .mbtb-rel-ended { opacity: .28; }',
'td.' + CELL_CLASS + ' .mbtb-rel-generic {',
'display: inline-flex; align-items: center; justify-content: center;',
'width: 16px; height: 16px; box-sizing: border-box;',
'font-size: 12px; line-height: 16px; font-weight: 700;',
'border: 1px solid #999; border-radius: 3px; text-decoration: none;',
'}',
'td.' + CELL_CLASS + ' .mbtb-rel-mb img { width: 16px; height: 16px; display: block; border: 0; }',
'.' + LABEL_TOGGLE_CLASS + ' {',
'appearance: none; -webkit-appearance: none; background: none; border: 0;',
'padding: 0 2px; margin: 0; color: inherit; font: inherit; cursor: pointer;',
'text-decoration: underline; text-decoration-style: dotted;',
'}',
'.' + LABEL_TOGGLE_CLASS + ':hover { text-decoration-style: solid; }',
].join('\n');
document.head.appendChild(style);
}
function relationIconClass(relation, targetUrl) {
const type = String(relation?.type || '').toLowerCase();
if (type === 'official homepage' || type === 'discography entry') return 'home';
if (type === 'allmusic') return 'allmusic';
if (type === 'amazon asin') return 'amazon';
if (type === 'discogs') return 'discogs';
if (type === 'imdb') return 'imdb';
if (type === 'lyrics') return 'lyrics';
if (type === 'secondhandsongs') return 'secondhandsongs';
if (type === 'vgmdb') return 'vgmdb';
if (type === 'wikidata') return 'wikidata';
if (type === 'wikipedia') return 'wikipedia';
let url;
try {
url = new URL(targetUrl);
} catch {
return '';
}
const host = url.hostname.toLowerCase();
const path = url.pathname.toLowerCase();
if (host === 'open.spotify.com') return 'spotify';
if (host === 'music.apple.com') return 'applemusic';
if (host === 'itunes.apple.com') return 'itunes';
if (host === 'www.deezer.com' || host === 'deezer.com') return 'deezer';
if (host === 'tidal.com' || host.endsWith('.tidal.com')) return 'tidal';
if (host === 'soundcloud.com' || host.endsWith('.soundcloud.com')) return 'soundcloud';
if (host === 'bandcamp.com' || host.endsWith('.bandcamp.com')) return 'bandcamp';
if (host === 'www.qobuz.com' || host === 'qobuz.com' || host.endsWith('.qobuz.com')) return 'qobuz';
if (host === 'music.youtube.com') return 'youtubemusic';
if (host === 'youtube.com' || host === 'www.youtube.com' || host === 'youtu.be') return 'youtube';
if (host === 'beatport.com' || host === 'www.beatport.com') return 'beatport';
if (host === 'mora.jp' || host.endsWith('.mora.jp')) return 'mora';
if (host === '7digital.com' || host.endsWith('.7digital.com')) return 'sevendigital';
if (host === 'audiomack.com' || host.endsWith('.audiomack.com')) return 'audiomack';
if (host.startsWith('music.amazon.')) return 'amazonmusic';
if (host.includes('amazon.') && path.includes('/dp/')) return 'amazon';
if (host === 'discogs.com' || host.endsWith('.discogs.com')) return 'discogs';
if (host === 'allmusic.com' || host.endsWith('.allmusic.com')) return 'allmusic';
if (host === 'genius.com' || host.endsWith('.genius.com')) return 'genius';
if (host === 'www.wikidata.org' || host === 'wikidata.org') return 'wikidata';
if (host.endsWith('wikipedia.org')) return 'wikipedia';
if (host === 'rateyourmusic.com' || host.endsWith('.rateyourmusic.com')) return 'rateyourmusic';
if (host === 'www.worldcat.org' || host === 'worldcat.org') return 'worldcat';
if (host === 'archive.org' || host.endsWith('.archive.org')) return 'archive';
if (host === 'store.steampowered.com') return 'steam';
return '';
}
function collapseLabelDisambiguations() {
for (const labelLink of document.querySelectorAll('table.tbl a[href^="/label/"]')) {
if (labelLink.dataset.mbtbLabelCommentHandled) continue;
let comment = null;
let node = labelLink.nextSibling;
while (
node &&
node.nodeType === Node.TEXT_NODE &&
!node.textContent.trim()
) {
node = node.nextSibling;
}
if (
node?.nodeType === Node.ELEMENT_NODE &&
node.matches('.comment')
) {
comment = node;
} else if (node?.nodeType === Node.TEXT_NODE) {
const match = node.data.match(/^\s*(\([^)]*\))/);
if (match) {
comment = document.createElement('span');
comment.className = 'comment';
comment.textContent = match[1];
node.data = node.data.slice(match[0].length);
node.parentNode.insertBefore(comment, node);
}
}
labelLink.dataset.mbtbLabelCommentHandled = '1';
if (!comment || !comment.textContent.trim()) continue;
if (comment.classList.contains(LABEL_COMMENT_CLASS)) continue;
comment.classList.add(LABEL_COMMENT_CLASS);
const fullText = comment.textContent.trim();
comment.hidden = true;
const toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = LABEL_TOGGLE_CLASS;
toggle.textContent = '...';
toggle.title = 'Show label disambiguation: ' + fullText;
toggle.setAttribute('aria-label', toggle.title);
toggle.setAttribute('aria-expanded', 'false');
toggle.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
const expanded = comment.hidden;
comment.hidden = !expanded;
toggle.setAttribute('aria-expanded', String(expanded));
toggle.title =
(expanded ? 'Hide' : 'Show') +
' label disambiguation: ' + fullText;
toggle.setAttribute('aria-label', toggle.title);
});
comment.parentNode.insertBefore(toggle, comment);
}
}
function entityFromRow(row, childType) {
const re = new RegExp('^/' + childType + '/(' + UUID + ')(?:[/?#]|$)', 'i');
const cells = [...row.children];
for (let index = 0; index < cells.length; index++) {
for (const link of cells[index].querySelectorAll('a[href]')) {
let pathname;
try {
pathname = new URL(link.href, location.href).pathname;
} catch {
continue;
}
const match = pathname.match(re);
if (match) {
return {mbid: match[1].toLowerCase(), index};
}
}
}
return null;
}
function prepareTables(childType) {
const cellsByMbid = new Map();
for (const table of document.querySelectorAll('table.tbl')) {
const rows = [...table.querySelectorAll('tr')];
const firstData = rows
.map(row => ({row, entity: entityFromRow(row, childType)}))
.find(item => item.entity);
if (!firstData) continue;
const entityIndex = firstData.entity.index;
const header =
table.querySelector('thead tr:last-child') ||
rows.find(row => row.querySelectorAll(':scope > th').length > entityIndex);
const headerCell = header?.children?.[entityIndex];
if (headerCell && !header.querySelector('.' + HEAD_CLASS)) {
const th = document.createElement('th');
th.className = HEAD_CLASS;
th.textContent = 'Relationships';
headerCell.insertAdjacentElement('afterend', th);
}
for (const row of rows) {
if (row === header) continue;
if (row.classList.contains('subh')) {
const spanning = row.querySelector(':scope > th[colspan], :scope > td[colspan]');
if (spanning && !row.dataset.mbtbRelationshipColspanAdjusted) {
spanning.colSpan += 1;
row.dataset.mbtbRelationshipColspanAdjusted = '1';
}
continue;
}
const tdCells = [...row.querySelectorAll(':scope > td')];
if (!tdCells.length || tdCells.length <= entityIndex) continue;
if (row.querySelector(':scope > td.' + CELL_CLASS)) continue;
const entity = entityFromRow(row, childType);
const td = document.createElement('td');
td.className = CELL_CLASS;
tdCells[entityIndex].insertAdjacentElement('afterend', td);
if (!entity) continue;
if (!cellsByMbid.has(entity.mbid)) cellsByMbid.set(entity.mbid, []);
cellsByMbid.get(entity.mbid).push(td);
}
}
return cellsByMbid;
}
function addUrlRelationship(cell, relation) {
const targetUrl = relation?.url?.resource;
if (!targetUrl) return;
const link = document.createElement('a');
link.href = targetUrl;
link.title =
String(relation.type || 'URL relationship') +
(relation.ended ? ' (ended)' : '') +
': ' + targetUrl;
link.setAttribute('aria-label', link.title);
const iconClass = relationIconClass(relation, targetUrl);
if (iconClass) {
const icon = document.createElement('span');
icon.className =
'favicon ' + iconClass + '-favicon' +
(relation.ended ? ' mbtb-rel-ended' : '');
link.appendChild(icon);
} else {
link.className = 'mbtb-rel-generic' + (relation.ended ? ' mbtb-rel-ended' : '');
link.textContent = '↗';
}
cell.appendChild(link);
}
function addMusicBrainzRelationship(cell, relation) {
const targetType = String(relation?.['target-type'] || '');
const normalizedType = targetType.replace('_', '-');
const allowed =
(targetType === 'release_group' && relation.type === 'single from') ||
(targetType === 'release' && relation.type === 'remaster');
if (!allowed) return;
const target = relation[targetType];
if (!target?.id) return;
const link = document.createElement('a');
link.className = 'mbtb-rel-mb' + (relation.ended ? ' mbtb-rel-ended' : '');
link.href = '/' + normalizedType + '/' + target.id;
link.title =
String(relation.type || 'MusicBrainz relationship') +
(relation.ended ? ' (ended)' : '');
link.setAttribute('aria-label', link.title);
const image = document.createElement('img');
image.src = 'https://musicbrainz.org/favicon.ico';
image.alt = '';
link.appendChild(image);
cell.appendChild(link);
}
function renderEntity(entity, cells) {
for (const cell of cells) {
cell.textContent = '';
for (const relation of entity.relations || []) {
if (relation?.ended) continue;
if (relation?.['target-type'] === 'url') {
// Intentionally do not deduplicate by provider or icon class:
// every attached URL relationship gets its own shortcut icon.
addUrlRelationship(cell, relation);
} else {
addMusicBrainzRelationship(cell, relation);
}
}
}
}
async function init() {
const context = pageContext();
if (!context) return;
installStyle();
collapseLabelDisambiguations();
const cellsByMbid = prepareTables(context.childType);
if (!cellsByMbid.size) return;
const includes = {
'release-group': ['release-group-rels', 'url-rels'],
release: ['release-rels', 'url-rels', 'discids'],
recording: ['work-rels', 'url-rels'],
work: ['url-rels'],
}[context.childType] || ['url-rels'];
const page = Math.max(1, Number(new URL(location.href).searchParams.get('page')) || 1);
const api = new URL('/ws/2/' + context.childType, location.origin);
api.searchParams.set(context.parentType, context.parentMbid);
api.searchParams.set('inc', includes.join(' '));
api.searchParams.set('limit', '100');
api.searchParams.set('offset', String((page - 1) * 100));
api.searchParams.set('fmt', 'json');
try {
const response = await __mbToolBoxFetch(api.toString(), {
headers: {Accept: 'application/json'},
});
if (!response.ok) {
throw new Error('HTTP ' + response.status);
}
const data = await response.json();
const listKey = {
'release-group': 'release-groups',
release: 'releases',
recording: 'recordings',
work: 'works',
}[context.childType];
for (const entity of data[listKey] || []) {
const cells = cellsByMbid.get(String(entity.id || '').toLowerCase());
if (cells?.length) renderEntity(entity, cells);
}
} catch (error) {
console.error('[MusicBrainz ToolBox] Relationship shortcuts failed:', error);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init, {once: true});
} else {
init();
}
})();
}
// ============================================================================
// Credit Hoarder provider import/review system (MIT).
// Copyright: majkinetor and contributors.
// Upstream: https://github.com/majkinetor/musicbrainz-userscripts/tree/main/userscripts/credit_hoarder
// Upstream blob: 3609da1f25cc3ce5c6d4c054c087eafda4dc2134; integrated by Karpuzikov Tools.
// Modifications: single-script integration and global duplicate-Work check.
// ============================================================================
if (new Set(['musicbrainz.org', 'beta.musicbrainz.org',
'tidal.com', 'listen.tidal.com', 'www.metal-archives.com']
).has(location.hostname.toLowerCase())) {
(() => {
// one copy per page: with String Theory and a standalone install both on, the newer one runs (#653)
function mbuClaimVer(v) {
return String(v || '').split('.').map(function (n) { return parseInt(n, 10) || 0; });
}
function mbuClaimCmp(a, b) {
var x = mbuClaimVer(a), y = mbuClaimVer(b);
for (var i = 0; i < Math.max(x.length, y.length); i++) { var d = (x[i] || 0) - (y[i] || 0); if (d) return d < 0 ? -1 : 1; }
return 0;
}
function mbuClaim(key, label) {
var info = (typeof GM_info !== 'undefined' && GM_info && GM_info.script) || {};
var name = String(info.name || label || key), ver = String(info.version || '0');
var mine = (name.slice(-1) === '*' ? 'String Theory' : 'standalone') + ' v' + ver;
var root = document.documentElement, attr = 'data-mbu-run-' + key, ev = 'mbu-claim-' + key, noteKey = 'mbu-newer-' + key;
var log = function (msg) { try { if (typeof mbuLog !== 'undefined' && mbuLog.active) mbuLog.active.info(msg); else if (typeof mbuToast !== 'undefined' && typeof mbuToast.log === 'function') mbuToast.log('info', msg); else console.info('[' + (label || key) + '] ' + msg); } catch (e) { /* no log */ } };
var note = null;
try { note = JSON.parse(localStorage.getItem(noteKey) || 'null'); } catch (e) { /* storage blocked */ }
// A note older than this copy is spent. This copy's own note stays: it is what keeps the
// older copy aside on every later load, not only the next one (#671).
var noteCmp = note ? mbuClaimCmp(note.ver, ver) : 1;
if (noteCmp < 0) { try { localStorage.removeItem(noteKey); } catch (e) { /* storage blocked */ } }
if (noteCmp <= 0) note = null;
var held = root && root.getAttribute(attr);
var off = function (why) {
// tell the running copy (any sandbox hears a DOM event), or the copy that runs after
// this one (it reads the attribute), and stay quiet
try { if (root) root.setAttribute(attr + '-off', JSON.stringify({ mine: mine, why: why })); } catch (e) { /* no attribute */ }
try { document.dispatchEvent(new CustomEvent(ev, { detail: JSON.stringify({ mine: mine, ver: ver, why: why }) })); } catch (e) { /* no event */ }
return false;
};
if (held) {
var heldVer = (root.getAttribute(attr + '-ver') || '0');
if (mbuClaimCmp(ver, heldVer) > 0) {
try { localStorage.setItem(noteKey, JSON.stringify({ ver: ver, mine: mine, at: Date.now() })); } catch (e) { /* storage blocked */ }
return off('newer, from the next page load');
}
return off('older or the same');
}
if (note) {
// a newer copy said it is installed: leave the page to it, unless it never shows up
var watch = function () {
setTimeout(function () {
if (root.getAttribute(attr)) return;
try { localStorage.removeItem(noteKey); } catch (e) { /* storage blocked */ }
try { console.info('[' + (label || key) + '] the newer copy (' + note.mine + ') did not start: this copy runs again from the next page load'); } catch (e) { /* no console */ }
}, 3000);
};
if (document.readyState === 'complete') watch(); else window.addEventListener('load', watch, { once: true });
return off('older: a newer copy runs');
}
if (root) { root.setAttribute(attr, mine); root.setAttribute(attr + '-ver', ver); }
var told = function (o) {
log((label || key) + ' is installed twice: ' + mine + ' runs, ' + (o.mine || 'another copy') + ' is switched off'
+ (o.why === 'newer, from the next page load' ? ' for this page (it is newer and runs from the next page load)' : '') + '.');
};
document.addEventListener(ev, function (e) { var o = {}; try { o = JSON.parse(e.detail); } catch (x) { /* not ours */ } told(o); });
// a copy that stepped aside before this one started (it found a note): log it once the script's log is up
var before = root && root.getAttribute(attr + '-off');
if (before) setTimeout(function () { var o = {}; try { o = JSON.parse(before); } catch (x) { /* not ours */ } told(o); }, 0);
return true;
}
if (!mbuClaim('credit_hoarder', 'Credit Hoarder')) return;
// src/constants.js
var REL_TEMPLATE = {
_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
};
var SELECTORS = {
MediumsInput: ".multiselect-input",
MediumsInputOptions: ".multiselect-input + .menu a",
InstrumentsInput: "#add-relationship-dialog .multiselect.instrument input[aria-autocomplete]",
VocalsTypeInput: "#add-relationship-dialog .multiselect.vocal input[aria-autocomplete]",
AddRelationshipsDialogEntityType: "#add-relationship-dialog .entity-type",
AddRelationshipsDialogRelationshipType: "#add-relationship-dialog input.relationship-type",
AddRelationshipsDialogRelationshipTarget: "#add-relationship-dialog input.relationship-target",
AddRelationshipsDialogEntityCredit: "#add-relationship-dialog input.entity-credit",
AddRelationshipsDialogDoneButton: "#add-relationship-dialog .buttons button.positive",
AddRelationshipsDialogError: "#add-relationship-dialog .error",
AddRelationshipsDialogCancelButton: "#add-relationship-dialog .buttons button.negative",
AddReleaseRelationshipButton: "#release-rels button.add-relationship",
EditNote: "#edit-note-text",
TaskInput: "#add-relationship-dialog .attribute-container.task input"
};
var EQUIVALENCE_SETS = [
["writer", "composer"]
];
var MB = typeof location !== "undefined" && /(^|\.)musicbrainz\.org$/.test(location.hostname) ? "//" + location.hostname : "//musicbrainz.org";
var DISCOGS_CHANNEL = new BroadcastChannel("discogs-importer-artist");
DISCOGS_CHANNEL.unref?.();
var pageWindow = typeof unsafeWindow !== "undefined" ? unsafeWindow : typeof window !== "undefined" ? window : globalThis;
// src/log.js
var _logs = null;
var _warn = 0;
var _err = 0;
var _countListener = null;
function onLogCounts(fn) {
_countListener = fn;
}
function resetLogCounts() {
_warn = 0;
_err = 0;
_notifyCounts();
}
function _notifyCounts() {
if (_countListener) {
try {
_countListener(_warn, _err);
} catch (e) {
}
}
}
function setLogContainer(el) {
_logs = el;
const pending = _pending.splice(0, _pending.length);
pending.forEach((a) => _emit(...a));
}
function getLogContainer() {
return _logs;
}
var _review = null;
function setReviewContainer(el) {
_review = el;
}
function getReviewContainer() {
return _review || _logs;
}
var _pending = [];
var PENDING_MAX = 200;
function _console(plainText, sev) {
try {
const line = "[credit_hoarder] " + String(plainText).replace(/<[^>]*>/g, "").trim();
if (sev === "error") console.error(line);
else if (sev === "warn") console.warn(line);
else console.log(line);
} catch (e) {
}
}
function _emit(html, plainText, sev) {
_console(plainText, sev);
if (!_logs) {
if (_pending.length < PENDING_MAX) _pending.push([html, plainText, sev]);
return;
}
const li = document.createElement("li");
if (sev) li.dataset.sev = sev;
if (sev === "warn") {
_warn++;
_notifyCounts();
} else if (sev === "error") {
_err++;
_notifyCounts();
}
const d = /* @__PURE__ */ new Date();
const pad = (n) => String(n).padStart(2, "0");
const stamp = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
li.innerHTML = `${stamp} ${html}`;
_logs.insertAdjacentElement("beforeend", li);
const bar = document.querySelector(".discogs-bar");
if (bar?._setProgress) {
bar._setProgress(null, plainText.replace(/<[^>]*>/g, "").trim().substring(0, 120));
}
}
var log = {
info: (msg) => _emit(msg, msg),
warn: (msg) => _emit(`WARN ${msg}`, `WARN ${msg}`, "warn"),
error: (msg) => _emit(`ERR ${msg}`, `ERR ${msg}`, "error"),
// Entity skipped because it wasn't matched on MB in the review (#118). Kept
// OUT of the WARN tally — these are surfaced by the separate "N unresolved"
// badge, and the maintainer asked not to lump them with real warnings. Still
// rendered (muted amber) so the log shows exactly which roles were dropped.
skip: (msg) => _emit(`SKIP ${msg}`, `SKIP ${msg}`, "skip")
};
var _debugUl = null;
var _debugStartT = null;
function _ensureDebugUl() {
if (_debugUl) return _debugUl;
if (!_logs) return null;
const details = document.createElement("details");
details.style.cssText = "margin:0.3rem 0;";
const summary = document.createElement("summary");
summary.textContent = "Preflight diagnostics";
summary.style.cssText = "cursor:pointer;font-size:0.8rem;color:var(--mbu-text-weak);user-select:none;";
details.appendChild(summary);
const ul = document.createElement("ul");
ul.style.cssText = "list-style:none;margin:0.3rem 0;padding:0.4rem 0.6rem;background:var(--mbu-bg-raised);border-radius:0.25rem;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:0.72rem;color:var(--mbu-text);max-height:24rem;overflow-y:auto;";
details.appendChild(ul);
const li = document.createElement("li");
li.style.listStyle = "none";
li.appendChild(details);
_logs.appendChild(li);
_debugUl = ul;
_debugStartT = performance.now();
return _debugUl;
}
function logDebug(line) {
const ul = _ensureDebugUl();
if (!ul) return;
const t = Math.round(performance.now() - _debugStartT);
const row = document.createElement("div");
row.textContent = `[+${t}ms] ${line}`;
ul.appendChild(row);
}
// ../../dev/net/mb-gate.mjs
var MBN_GAP = 1e3;
var MBN_BURST = 3;
var MBN_KEY = "mbu:mb-gate";
var MBN_LOCK = "mbu-mb-gate";
var MBN_MAX_HOLD = 6e4;
function mbnGated(url) {
const s = String(url || "");
return /^\/ws\/2\//.test(s) || /^https?:\/\/([a-z0-9-]+\.)*musicbrainz\.org\/ws\/2\//i.test(s);
}
async function mbnState(fn) {
const rw = () => {
let s = null, stored = true;
try {
s = JSON.parse(localStorage.getItem(MBN_KEY) || "null");
} catch (e) {
stored = false;
}
const g = globalThis.__mbnGate || (globalThis.__mbnGate = { tat: 0, cool: 0, hot: 0 });
if (!stored) s = g;
else if (!s || typeof s !== "object") s = { tat: 0, cool: 0, hot: 0 };
const out = fn(s);
Object.assign(g, s);
try {
localStorage.setItem(MBN_KEY, JSON.stringify({ tat: s.tat || 0, cool: s.cool || 0, hot: s.hot || 0 }));
} catch (e) {
}
return out;
};
let timer = 0;
try {
if (typeof navigator !== "undefined" && navigator.locks && navigator.locks.request) {
const ctrl = typeof AbortController === "function" ? new AbortController() : null;
if (ctrl) timer = setTimeout(() => ctrl.abort(), 3e3);
return await navigator.locks.request(MBN_LOCK, ctrl ? { signal: ctrl.signal } : {}, rw);
}
} catch (e) {
} finally {
clearTimeout(timer);
}
return rw();
}
async function mbnSlot(o) {
o = o || {};
const t0 = Date.now(), background = o.background !== false;
const booked = await mbnState((s) => {
const now = Date.now(), cool = s.cool || 0, tat = s.tat || 0;
let at = background ? Math.max(now, tat - (MBN_BURST - 1) * MBN_GAP) : now;
at = Math.max(at, cool);
s.tat = Math.max(tat, at) + MBN_GAP;
return { at, why: cool > now && cool >= at ? "MusicBrainz asked everyone to wait" : "pacing, one request a second" };
});
let told = false;
for (; ; ) {
if (o.cancelled && o.cancelled()) return { ok: false, waited: Date.now() - t0 };
let cool = 0;
try {
cool = (JSON.parse(localStorage.getItem(MBN_KEY) || "null") || {}).cool || 0;
} catch (e) {
cool = (globalThis.__mbnGate || {}).cool || 0;
}
const until = Math.max(booked.at, cool), left = until - Date.now();
if (left <= 0) break;
if (!told && until - t0 > 1e3 && o.log) {
told = true;
o.log("info", "MusicBrainz gate: " + (o.label || "request") + " waits " + Math.round((until - t0) / 100) / 10 + "s (" + (cool > booked.at ? "MusicBrainz asked everyone to wait" : booked.why) + ")");
}
await new Promise((r) => setTimeout(r, Math.min(left, 250)));
}
return { ok: true, waited: Date.now() - t0 };
}
async function mbnAnswer(status, header, o) {
o = o || {};
if (status === 429 || status === 503) {
const raw = header ? header("Retry-After") : null;
const secs = Number(raw), date = raw ? Date.parse(raw) : NaN;
const ra = Number.isFinite(secs) ? secs * 1e3 : Number.isFinite(date) ? Math.max(0, date - Date.now()) : 0;
const hold = await mbnState((s) => {
const now = Date.now();
if (now > (s.cool || 0) + 3e4) s.hot = 0;
if (now >= (s.cool || 0)) s.hot = Math.min((s.hot || 0) + 1, 6);
const ms = Math.min(Math.max(1e3, ra) * Math.max(1, s.hot), MBN_MAX_HOLD);
s.cool = Math.max(s.cool || 0, Date.now() + ms);
return s.cool - Date.now();
});
if (o.log) o.log("warn", "MusicBrainz gate: HTTP " + status + (raw != null ? " (Retry-After: " + raw + ")" : "") + " \u2014 every script holds " + Math.round(hold / 100) / 10 + "s");
return { throttled: true, hold };
}
if (status >= 200 && status < 500) await mbnState((s) => {
if (s.hot) s.hot = Date.now() > (s.cool || 0) + 3e4 ? 0 : s.hot - 1;
});
return { throttled: false, hold: 0 };
}
async function mbnFetch(url, init, o) {
o = o || {};
if (!mbnGated(url)) return fetch(url, init);
const tries = o.tries || 4;
for (let attempt = 1; ; attempt++) {
const slot = await mbnSlot(o);
if (!slot.ok) return null;
const r = await fetch(url, init);
const a = await mbnAnswer(r.status, (n) => r.headers.get(n), o);
if (!a.throttled || attempt >= tries) return r;
}
}
// src/api-mb.js
async function fetchMBEntity(mbid) {
const res = await fetch(`/ws/js/entity/${mbid}`);
if (!res.ok) throw new Error(`/ws/js/entity/${mbid} \u2192 ${res.status}`);
return res.json();
}
var mbThrottle = /* @__PURE__ */ (() => {
const MAX_CONCURRENT = 4;
let _running = 0;
let _pauseUntil = 0;
const _queue = [];
let _totalRequests = 0;
let _rateLimited = 0;
async function _waitForPause() {
let wait = _pauseUntil - Date.now();
if (wait <= 0) return;
logDebug(`throttle: waiting ${wait}ms for shared pause`);
while ((wait = _pauseUntil - Date.now()) > 0) {
await new Promise((r) => setTimeout(r, wait));
}
}
function _drain() {
while (_running < MAX_CONCURRENT && _queue.length > 0) {
_running++;
const item = _queue.shift();
_run(item).finally(() => {
_running--;
_drain();
});
}
}
let _diagReqSeq = 0;
const REQUEST_TIMEOUT_MS = 1e4;
async function _run(item) {
const tag = `req#${++_diagReqSeq}`;
const shortUrl = item.url.replace(MB, "").replace(/^https:/, "");
const gated = mbnGated(item.url);
const gateLog = (lv, m) => lv === "warn" ? log.warn(m) : logDebug(m);
for (let attempt = 0; attempt <= item.retries; attempt++) {
await _waitForPause();
if (gated) await mbnSlot({ label: shortUrl, log: gateLog });
_totalRequests++;
const attemptTag = attempt === 0 ? "" : ` (retry ${attempt})`;
logDebug(`${tag} [running=${_running} queued=${_queue.length}] GET ${shortUrl}${attemptTag}`);
const t0 = Date.now();
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), REQUEST_TIMEOUT_MS);
try {
const res = await fetch(item.url, { signal: ctrl.signal });
const elapsed = Date.now() - t0;
if (gated) {
const a = await mbnAnswer(res.status, (n) => res.headers.get(n), { log: gateLog });
if (a.throttled) {
_rateLimited++;
logDebug(`${tag} <- ${res.status} in ${elapsed}ms; the shared gate holds ${a.hold}ms`);
continue;
}
}
if (res.status === 429 || res.status === 503) {
_rateLimited++;
const ra = parseInt(res.headers.get("Retry-After"), 10);
const waitMs = ra > 0 ? ra * 1e3 : Math.min(1e3 * Math.pow(2, attempt), 3e4);
_pauseUntil = Math.max(_pauseUntil, Date.now() + waitMs);
logDebug(`${tag} <- ${res.status} in ${elapsed}ms; shared pause pushed to +${waitMs}ms`);
continue;
}
if (!res.ok) {
logDebug(`${tag} <- ${res.status} (give up) in ${elapsed}ms`);
item.resolve(item.nf && res.status === 404 ? { notFound: true } : null);
return;
}
const data = item.wantJson ? await res.json() : res;
logDebug(`${tag} <- ${res.status} in ${elapsed}ms`);
item.resolve(data);
return;
} catch (e) {
const elapsed = Date.now() - t0;
const isTimeout = e?.name === "AbortError";
const reason = isTimeout ? `timed out after ${REQUEST_TIMEOUT_MS}ms` : `${e?.message || e}`;
if (isTimeout) {
_rateLimited++;
const waitMs = Math.min(1e3 * Math.pow(2, attempt), 8e3);
_pauseUntil = Math.max(_pauseUntil, Date.now() + waitMs);
logDebug(`${tag} threw in ${elapsed}ms: ${reason}; shared pause pushed to +${waitMs}ms`);
} else {
logDebug(`${tag} threw in ${elapsed}ms: ${reason}`);
}
if (attempt === item.retries) {
item.resolve(null);
return;
}
await new Promise((r) => setTimeout(r, 500));
} finally {
clearTimeout(timer);
}
}
item.resolve(null);
}
function _enqueue(url, retries, wantJson, nf = false) {
return new Promise((resolve) => {
_queue.push({ url, retries, wantJson, nf, resolve });
_drain();
});
}
return {
fetchJson: (url, retries = 3) => _enqueue(url, retries, true),
// Like fetchJson, but a 404 resolves `{ notFound: true }` instead of
// null — so null unambiguously means "lookup FAILED" (#193 chip bug).
fetchJson404: (url, retries = 3) => _enqueue(url, retries, true, true),
fetchRaw: (url, retries = 3) => _enqueue(url, retries, false),
stats: () => ({
total: _totalRequests,
rateLimited: _rateLimited,
inFlight: _running,
queued: _queue.length
})
};
})();
async function fetchWithRetry(url, retries = 4) {
return mbThrottle.fetchJson(url, retries);
}
var _relTypeCache = /* @__PURE__ */ new Map();
async function fetchArtistRelTypes(mbid) {
if (!mbid) return null;
if (_relTypeCache.has(mbid)) return _relTypeCache.get(mbid);
const json = await mbThrottle.fetchJson(
`${MB}/ws/2/artist/${mbid}?inc=recording-rels+release-rels+release-group-rels+work-rels&fmt=json&limit=100`
);
if (!json) return null;
const types = relRoleLabels(json.relations);
_relTypeCache.set(mbid, types);
return types;
}
var MODIFIER_ATTRS = /* @__PURE__ */ new Set(["additional", "guest", "solo", "minor", "bonus"]);
function relRoleLabels(relations) {
const labels = /* @__PURE__ */ new Set();
for (const r of relations || []) {
if (!r.type) continue;
const attrs = (Array.isArray(r.attributes) ? r.attributes : []).filter((a) => a && !MODIFIER_ATTRS.has(String(a).toLowerCase()));
if ((r.type === "instrument" || r.type === "vocal") && attrs.length) {
attrs.forEach((a) => labels.add(a));
} else {
labels.add(r.type);
}
}
return [...labels].sort();
}
async function getSourceUrlsForRelease(mbid) {
const url = `/ws/js/release/${mbid}?fmt=json&inc=rels`;
let json = null;
for (let attempt = 1; ; attempt++) {
let res = null;
try {
res = await fetch(url, { headers: { Accept: "application/json" } });
} catch (e) {
if (attempt >= 4) throw e;
}
if (res && res.ok) {
json = await res.json();
if (attempt > 1) log.info(`Sources: MusicBrainz answered on attempt ${attempt}`);
break;
}
if (res && res.status === 404) {
log.warn(`Sources: MusicBrainz says this release does not exist (404)`);
return {};
}
if (attempt >= 4) throw new Error(`MB /ws/js/release returned ${res ? res.status : "no response"} after ${attempt} attempts`);
const wait = Number(res && res.headers.get("Retry-After")) * 1e3 || 400 * attempt + Math.floor(Math.random() * 300);
log.warn(`Sources: MusicBrainz returned ${res ? res.status : "no response"} \u2014 retrying (${attempt}/3) in ${Math.round(wait)}ms`);
await new Promise((r) => setTimeout(r, Math.min(wait, 8e3)));
}
{
const rels = json.relationships || [];
const urlRels = rels.filter((r) => r.target?.href_url);
log.info(`Sources: MusicBrainz returned ${rels.length} relationship(s) for this release, ${urlRels.length} of them url(s)`);
_lastLinkCount = urlRels.length;
const abs = (u) => u && u.startsWith("//") ? "https:" + u : u;
const href = (pred) => abs(rels.find(pred)?.target?.href_url || null);
return {
discogs: href((rel) => rel.target?.sidebar_name === "Discogs"),
tidal: href((rel) => /(^|\/\/)(www\.|listen\.)?tidal\.com\/(browse\/)?album\/\d+/i.test(rel.target?.href_url || "")),
qobuz: href((rel) => /(^|\/\/)(www\.|play\.|open\.)?qobuz\.com\/([a-z]{2}-[a-z]{2}\/)?album\//i.test(rel.target?.href_url || "")),
deezer: href((rel) => /(^|\/\/)(www\.)?deezer\.com\/([a-z]{2}\/)?album\/\d+/i.test(rel.target?.href_url || "")),
apple: href((rel) => /(^|\/\/)(?:music|itunes)\.apple\.com\/(?:[a-z]{2}\/)?album\/(?:[^/?#]+\/)?(?:id)?\d+/i.test(rel.target?.href_url || "")),
// #435; iTunes URLs #436
metalArchives: href((rel) => /(^|\/\/)(www\.)?metal-archives\.com\/albums\/[^/]+\/[^/]+\/\d+/i.test(rel.target?.href_url || "")),
// #453
ytmusic: href((rel) => /(^|\/\/)((music|www|m)\.)?youtube\.com\/(?:playlist\?(?:[^#]*&)?list=OLAK5uy_|browse\/MPREb_)/i.test(rel.target?.href_url || ""))
// #648; www.youtube.com #679
};
}
}
var _lastLinkCount = 0;
function logSourceProbe(sources, urlCount) {
const found = Object.entries(sources || {}).filter(([, v]) => v);
const n = urlCount == null ? _lastLinkCount : urlCount;
log.info(`Sources: ${found.length} import source(s) from ${n} link(s)` + (found.length ? " \u2014 " + found.map(([k]) => k).join(", ") : " \u2014 none of the links is a supported source"));
found.forEach(([k, v]) => logDebug(` source ${k}: ${v}`));
}
function resolveLinkTypeId(name, type0, type1) {
const lt = pageWindow.MB?.linkedEntities?.link_type;
if (!lt) {
log.error("MB.linkedEntities.link_type not available");
return null;
}
const needle = name.toLowerCase().trim();
const stripAttrs = (s) => (s || "").toLowerCase().replace(/\{[^}]*\}/g, "").replace(/\s+/g, " ").trim();
const candidates = Object.values(lt).filter(
(v) => v.type0 === type0 && v.type1 === type1 && !v.deprecated
);
for (const v of candidates) {
if ((v.name || "").toLowerCase() === needle) return v.id;
}
for (const v of candidates) {
if (stripAttrs(v.link_phrase) === needle) return v.id;
if (stripAttrs(v.reverse_link_phrase) === needle) return v.id;
}
const contains = candidates.filter((v) => {
const blobs = [v.name, stripAttrs(v.link_phrase), stripAttrs(v.reverse_link_phrase)].filter(Boolean);
return blobs.some((b) => b.includes(needle) || needle.includes(b));
});
if (contains.length > 0) {
contains.sort((a, b) => ((a.name || "").length || 999) - ((b.name || "").length || 999));
const best = contains[0];
if ((best.name || "").toLowerCase() !== needle) {
log.info(`Fuzzy match: "${name}" \u2192 "${best.name}" (${type0}\u2192${type1})`);
}
return best.id;
}
const availableNames = candidates.map((v) => v.name).filter(Boolean).sort().join(", ");
const allByName = Object.values(lt).filter(
(v) => (v.name || "").toLowerCase() === needle || stripAttrs(v.link_phrase) === needle || stripAttrs(v.reverse_link_phrase) === needle
);
const deprecatedHit = allByName.find((v) => v.type0 === type0 && v.type1 === type1 && v.deprecated);
const wrongPairHits = allByName.filter((v) => !(v.type0 === type0 && v.type1 === type1));
if (deprecatedHit) {
const altPairs = [...new Set(allByName.filter((v) => !v.deprecated).map((v) => `${v.type0}\u2192${v.type1}`))].join(", ");
log.error(`"${name}" (${type0}\u2192${type1}) is deprecated by MB and would block the commit \u2014 skipping${altPairs ? `. Valid alternative(s): ${altPairs}` : ""}.`);
} else if (wrongPairHits.length > 0) {
const hitDesc = wrongPairHits.map((v) => `${v.name}(${v.type0}\u2192${v.type1})`).join(", ");
log.warn(`No "${name}" link type for (${type0}\u2192${type1}) \u2014 exists for other entity pairs: ${hitDesc} \u2014 skipping`);
} else {
log.warn(`Unknown link type "${name}" (${type0}\u2192${type1}). Available for this pair: ${availableNames || "none"}`);
}
return null;
}
// src/storage.js
var DB_NAME = "mblink";
var DB_VERSION = 2;
var STORE = "entity_cache";
var db = null;
var _request = indexedDB.open(DB_NAME, DB_VERSION);
_request.onerror = function() {
console.error("Why didn't you allow my web app to use IndexedDB?!");
};
_request.onsuccess = function(event) {
db = event.target.result;
};
_request.onupgradeneeded = function(event) {
const upgradeDb = event.target.result;
if (!upgradeDb.objectStoreNames.contains(STORE)) {
upgradeDb.createObjectStore(STORE, { keyPath: "discogs_id" });
}
};
function mbUrlOf(entityType, mbid) {
return `${MB}/${entityType}/${mbid}`;
}
function readIdbRecord(key) {
return new Promise((resolve) => {
if (!key || !db) return resolve(null);
try {
const tx = db.transaction([STORE], "readonly");
const req = tx.objectStore(STORE).get(key);
req.onsuccess = () => resolve(req.result || null);
req.onerror = () => resolve(null);
} catch (e) {
resolve(null);
}
});
}
function writeIdbRecord(key, partial) {
return new Promise((resolve) => {
if (!key || !db) return resolve(null);
try {
const tx = db.transaction([STORE], "readwrite");
const store = tx.objectStore(STORE);
const getReq = store.get(key);
getReq.onsuccess = () => {
const existing = getReq.result || {};
const merged = { ...existing, ...partial, discogs_id: key, resolvedAt: (/* @__PURE__ */ new Date()).toISOString() };
if (merged.mbid && merged.entityType && !partial.mbUrl) {
merged.mbUrl = mbUrlOf(merged.entityType, merged.mbid);
}
const putReq = store.put(merged);
putReq.onsuccess = () => resolve(merged);
putReq.onerror = () => resolve(null);
};
getReq.onerror = () => resolve(null);
} catch (e) {
resolve(null);
}
});
}
function deleteIdbRecord(key) {
return new Promise((resolve) => {
if (!key || !db) return resolve(false);
try {
const tx = db.transaction([STORE], "readwrite");
const store = tx.objectStore(STORE);
const req = store.delete(key);
req.onsuccess = () => resolve(true);
req.onerror = () => resolve(false);
} catch (e) {
resolve(false);
}
});
}
// src/progress-bar.js
var _pInterval = null;
var _pPos = -40;
function _showBar() {
const row1 = document.querySelector(".discogs-bar-row1");
const row2 = document.querySelector(".discogs-bar-row2");
const r1h = row1 ? row1.getBoundingClientRect().height : 42;
let pb = document.getElementById("discogs-pb");
if (!pb) {
pb = document.createElement("div");
pb.id = "discogs-pb";
pb.style.cssText = "position:fixed;left:0;right:0;height:5px;z-index:99999;background:var(--mbu-bg-sunken);overflow:hidden;";
const fill = document.createElement("div");
fill.id = "discogs-pb-fill";
fill.style.cssText = "position:absolute;top:0;height:100%;width:40%;background:var(--mbu-warn);transition:width 0.2s linear;";
pb.appendChild(fill);
document.body.appendChild(pb);
}
pb.style.top = r1h + "px";
pb.style.display = "block";
if (row2) row2.style.marginTop = r1h + 5 + "px";
_startMarquee();
}
function _startMarquee() {
clearInterval(_pInterval);
const fill = document.getElementById("discogs-pb-fill");
if (fill) {
fill.style.width = "40%";
fill.style.left = _pPos + "%";
fill.style.transition = "";
}
_pPos = -40;
_pInterval = setInterval(() => {
_pPos += 1.5;
if (_pPos > 100) _pPos = -40;
const f = document.getElementById("discogs-pb-fill");
if (f) f.style.left = _pPos + "%";
}, 16);
}
function _setProgressPct(pct) {
const p = Math.max(0, Math.min(100, Number(pct) || 0));
clearInterval(_pInterval);
_pInterval = null;
const pctEl = document.getElementById("discogs-progress-pct");
if (pctEl) pctEl.textContent = Math.round(p) + "%";
const fill = document.getElementById("discogs-pb-fill");
if (!fill) return;
fill.style.left = "0";
fill.style.transition = "width 0.2s linear";
fill.style.width = p + "%";
}
function _hideBar() {
clearInterval(_pInterval);
_pInterval = null;
const pb = document.getElementById("discogs-pb");
if (pb) pb.style.display = "none";
const row2 = document.querySelector(".discogs-bar-row2");
if (row2) row2.style.marginTop = "";
}
// src/api-discogs.js
var DISCOGS_URL_RE = /^https?:\/\/(?:www|api)\.discogs\.com\/(?:(?:(?!sell).+|sell.+)\/)?(master|release|artist|label)s?\/(\d+)(?:[^?#]*)(?:\?noanv=1|\?anv=[^=]+)?$/i;
function parseDiscogsUrl(url) {
const m = DISCOGS_URL_RE.exec(url);
if (!m) return null;
const type = m[1];
const id = m[2];
return {
type,
id,
key: `${type}/${id}`,
cleanUrl: `https://www.discogs.com/${type}/${id}`
};
}
var _releaseDataCache = /* @__PURE__ */ new Map();
function stripCompanyNums(json) {
for (const list of [json.companies, json.labels]) {
if (!Array.isArray(list)) continue;
for (const c of list) {
if (c && typeof c.name === "string") c.name = c.name.replace(/\s+\(\d+\)$/, "");
}
}
return json;
}
function getDiscogsReleaseData(url) {
if (_releaseDataCache.has(url)) return Promise.resolve(_releaseDataCache.get(url));
return fetch(
`${url.replace(
"https://www.discogs.com/release/",
"https://api.discogs.com/releases/"
)}?token=gYAnSAmIoXiHezHBmHoqcBCuJRyQLJBYSjurbGTZ`
).then((body) => body.json()).then((json) => {
_releaseDataCache.set(url, stripCompanyNums(json));
return json;
});
}
var _entityDataCache = /* @__PURE__ */ new Map();
function getDiscogsEntityData(resourceUrl) {
if (!resourceUrl) return Promise.resolve(null);
if (_entityDataCache.has(resourceUrl)) return Promise.resolve(_entityDataCache.get(resourceUrl));
return fetch(`${resourceUrl}?token=gYAnSAmIoXiHezHBmHoqcBCuJRyQLJBYSjurbGTZ`).then((r) => r.ok ? r.json() : r.status === 404 ? null : Promise.reject(new Error("HTTP " + r.status))).then((json) => {
if (!json) {
_entityDataCache.set(resourceUrl, null);
return null;
}
const slim = {
profile: json.profile || "",
name: json.name || "",
namevariations: json.namevariations || [],
realname: json.realname || ""
};
_entityDataCache.set(resourceUrl, slim);
return slim;
}).catch(() => null);
}
// src/data/entity-map.js
var ENTITY_TYPE_MAP = {
// Places
"Arranged At": {
entityType: "place",
linkType: "arranged at"
},
"Engineered At": {
entityType: "place",
linkType: "engineered at"
},
"Recorded At": {
entityType: "place",
linkType: "recorded at"
},
"Mixed At": {
entityType: "place",
linkType: "mixed at"
},
"Mastered At": {
entityType: "place",
linkType: "mastered at"
},
"Lacquer Cut At": {
entityType: "place",
linkType: "lacquer cut at"
},
"edited At": {
entityType: "place",
linkType: "edited at"
},
"Remixed At": {
entityType: "place",
linkType: "remixed at"
},
"Produced At": {
entityType: "place",
linkType: "produced at"
},
"Overdubbed At": null,
"manufactured At": {
entityType: "place",
linkType: "manufactured at"
},
"Glass Mastered At": {
entityType: "place",
linkType: "glass mastered at"
},
"Pressed At": {
entityType: "place",
linkType: "pressed at"
},
"Designed At": null,
"Filmed At": null,
"Exclusive Retailer": null,
// labels
"Copyright (c)": {
entityType: "label",
linkType: "copyright"
},
"Phonographic Copyright (p)": {
entityType: "label",
linkType: "phonographic copyright"
},
"Copyright \xA9": {
entityType: "label",
linkType: "copyright"
},
"Phonographic Copyright \u2117": {
entityType: "label",
linkType: "phonographic copyright"
},
"Licensed From": {
entityType: "label",
linkType: "licensor"
},
"Licensed To": {
entityType: "label",
linkType: "licensee"
},
"Licensed Through": null,
"Distributed By": {
entityType: "label",
linkType: "distributed"
},
"Made By": {
entityType: "label",
linkType: "manufactured"
},
"Manufactured By": {
entityType: "label",
linkType: "manufactured"
},
"Glass Mastered By": {
entityType: "label",
linkType: "glass mastered"
},
"Pressed By": {
entityType: "label",
linkType: "pressed"
},
"Marketed By": {
entityType: "label",
linkType: "marketed"
},
"Printed By": {
entityType: "label",
linkType: "printed"
},
"Promoted By": {
entityType: "label",
linkType: "promoted"
},
"Published By": {
entityType: "label",
linkType: "published"
},
"Rights Society": {
entityType: "label",
linkType: "rights society"
},
"Arranged For": {
entityType: "label",
linkType: "arranged for"
},
"Manufactured For": {
entityType: "label",
linkType: "manufactured for"
},
"Mixed For": {
entityType: "label",
linkType: "mixed for"
},
"Produced For": {
entityType: "label",
linkType: "produced for"
},
"Miscellaneous Support": {
entityType: "label",
linkType: "misc"
},
"Exported By": null,
// Artists
Performer: {
entityType: "artist",
linkType: "performer"
},
// Discogs "Ensemble" — a group/ensemble credited as performing (e.g. a
// vocal quartet or jazz ensemble). MB has no dedicated "ensemble" link
// type, so the conventional fit is the generic `performer` relationship
// (same as Discogs "Performer"). Without this it fell through to the
// INSTRUMENTS table (`Ensemble: null`) and was dropped as unmapped.
Ensemble: {
entityType: "artist",
linkType: "performer"
},
// Discogs role "Accompanied By" → MB has no dedicated link type or
// attribute for this. Closest semantic fit is `performer` with the
// `additional` attribute (used elsewhere in MB for non-primary
// contributions). Previously this fell through to the INSTRUMENTS map
// and was dispatched as a bare `instrument` rel with the bogus
// attribute value "accompanied by" — which MB silently drops, leaving
// a junk instrument rel with no instrument named.
"Accompanied By": {
entityType: "artist",
linkType: "performer",
attributes: ["additional"]
},
Instruments: {
entityType: "artist",
linkType: "instrument"
},
Vocals: {
entityType: "artist",
linkType: "vocal"
},
// Discogs "Voice [Humming]" and the like: a vocal (it was dropped, unmapped)
Voice: {
entityType: "artist",
linkType: "vocal"
},
"Backing Vocals": {
entityType: "artist",
linkType: "vocal",
attributes: [{ _type: "vocal", value: "background vocals" }]
},
Choir: {
entityType: "artist",
linkType: "vocal",
attributes: [{ _type: "vocal", value: "choir vocals" }]
},
Chorus: {
entityType: "artist",
linkType: "vocal",
attributes: [{ _type: "vocal", value: "choir vocals" }]
},
"Choir Vocals": {
entityType: "artist",
linkType: "vocal",
attributes: [{ _type: "vocal", value: "choir vocals" }]
},
"Lead Vocals": {
entityType: "artist",
linkType: "vocal",
attributes: [{ _type: "vocal", value: "lead vocals" }]
},
// #427: voice-type vocals — MB carries each of these as a vocal attribute (verified
// against the live link_attribute_type table). Hyphenated ones get both Discogs
// casing variants, since the role lookup is exact-case.
"Soprano Vocals": { entityType: "artist", linkType: "vocal", attributes: [{ _type: "vocal", value: "soprano vocals" }] },
"Mezzo-soprano Vocals": { entityType: "artist", linkType: "vocal", attributes: [{ _type: "vocal", value: "mezzo-soprano vocals" }] },
"Mezzo-Soprano Vocals": { entityType: "artist", linkType: "vocal", attributes: [{ _type: "vocal", value: "mezzo-soprano vocals" }] },
"Alto Vocals": { entityType: "artist", linkType: "vocal", attributes: [{ _type: "vocal", value: "alto vocals" }] },
"Contralto Vocals": { entityType: "artist", linkType: "vocal", attributes: [{ _type: "vocal", value: "contralto vocals" }] },
"Countertenor Vocals": { entityType: "artist", linkType: "vocal", attributes: [{ _type: "vocal", value: "countertenor vocals" }] },
"Tenor Vocals": { entityType: "artist", linkType: "vocal", attributes: [{ _type: "vocal", value: "tenor vocals" }] },
"Baritone Vocals": { entityType: "artist", linkType: "vocal", attributes: [{ _type: "vocal", value: "baritone vocals" }] },
"Bass-baritone Vocals": { entityType: "artist", linkType: "vocal", attributes: [{ _type: "vocal", value: "bass-baritone vocals" }] },
"Bass-Baritone Vocals": { entityType: "artist", linkType: "vocal", attributes: [{ _type: "vocal", value: "bass-baritone vocals" }] },
"Bass Vocals": { entityType: "artist", linkType: "vocal", attributes: [{ _type: "vocal", value: "bass vocals" }] },
"Treble Vocals": { entityType: "artist", linkType: "vocal", attributes: [{ _type: "vocal", value: "treble vocals" }] },
Orchestra: {
entityType: "artist",
linkType: "orchestra"
},
Conductor: {
entityType: "artist",
linkType: "conductor"
},
"Chorus Master": {
entityType: "artist",
linkType: "chorus master"
},
Concertmaster: {
entityType: "artist",
linkType: "concertmaster"
},
Concertmistress: {
entityType: "artist",
linkType: "concertmaster"
},
"Compiled By": {
entityType: "artist",
linkType: "compiler"
},
"DJ Mix": {
entityType: "artist",
linkType: "DJ-mixer"
},
Remix: {
entityType: "artist",
linkType: "remixer"
},
"contains samples by": {
entityType: "artist",
linkType: "contains samples by"
},
"Written-By": {
entityType: "artist",
linkType: "writer"
},
"Written By": {
entityType: "artist",
linkType: "writer"
},
"Composed By": {
entityType: "artist",
linkType: "composer"
},
// #233: author of the source text (e.g. the novel a Hörspiel adapts). MB
// 'writer' is a work-level rel, so this lands on the work CH creates.
Author: {
entityType: "artist",
linkType: "writer"
},
// #233: spoken-word performer (audio play / Hörspiel cast). MB convention is
// the 'vocal' relationship with the 'spoken vocals' attribute; the character
// in the Discogs bracket becomes the credited-as (handled in mappers.js).
"Voice Actor": {
entityType: "artist",
linkType: "vocal",
attributes: [{ _type: "vocal", value: "spoken vocals" }]
},
"Words By": {
entityType: "artist",
linkType: "lyricist"
},
"Lyrics By": {
entityType: "artist",
linkType: "lyricist"
},
"Libretto By": {
entityType: "artist",
linkType: "librettist"
},
"Translated By": {
entityType: "artist",
linkType: "translator"
},
"Arranged By": {
entityType: "artist",
linkType: "arranger"
},
"Instrumentation By": {
entityType: "artist",
linkType: "instruments arranger"
},
"Orchestrated By": {
entityType: "artist",
linkType: "orchestrator"
},
"vocals arranger": {
entityType: "artist",
linkType: "vocals arranger"
},
Producer: {
entityType: "artist",
linkType: "producer"
},
"Co-producer": {
entityType: "artist",
linkType: "producer",
// MB has no `co` attribute. The convention for "Co-X" is `additional`
// on the base role (issue #3). See also the matching remap in
// `getArtistRoles` for the regex /Co /.
attributes: ["additional"]
},
"Executive-Producer": {
entityType: "artist",
linkType: "producer",
attributes: ["executive"]
},
"Post Production": {
entityType: "artist",
linkType: "producer"
},
Engineer: {
entityType: "artist",
linkType: "engineer"
},
"Audio Engineer": {
entityType: "artist",
linkType: "audio engineer"
},
"Mastered By": {
entityType: "artist",
linkType: "mastering"
},
"Remastered By": {
entityType: "artist",
linkType: "mastering",
attributes: ["re"]
},
"Lacquer Cut By": {
entityType: "artist",
linkType: "lacquer cut"
},
"sound engineer": {
entityType: "artist",
linkType: "sound engineer"
},
"Mixed By": {
entityType: "artist",
linkType: "mix"
},
"Recorded By": {
entityType: "artist",
linkType: "recording"
},
"Recording Engineer": {
entityType: "artist",
linkType: "recording"
},
"Programmed By": {
entityType: "artist",
linkType: "programming"
},
Editor: {
entityType: "artist",
linkType: "editor"
},
"Edited By": {
entityType: "artist",
linkType: "editor"
},
"balance engineer": {
entityType: "artist",
linkType: "engineer"
},
"copyrighted by": {
entityType: "artist",
linkType: "copyright"
},
"phonographic copyright by": {
entityType: "artist",
linkType: "phonographic copyright"
},
Legal: {
entityType: "artist",
linkType: "legal representation"
},
Booking: {
entityType: "artist",
linkType: "booking"
},
"Art Direction": {
entityType: "artist",
linkType: "art direction"
},
Artwork: {
entityType: "artist",
linkType: "artwork"
},
"Artwork By": {
entityType: "artist",
linkType: "artwork"
},
Cover: {
entityType: "artist",
linkType: "artwork"
},
Design: {
entityType: "artist",
linkType: "design"
},
"Graphic Design": {
entityType: "artist",
linkType: "graphic design"
},
Illustration: {
entityType: "artist",
linkType: "illustration"
},
"Booklet Editor": {
entityType: "artist",
linkType: "booklet editor"
},
Photography: {
entityType: "artist",
linkType: "photography"
},
"Photography By": {
entityType: "artist",
linkType: "photography"
},
Technician: {
entityType: "artist",
// MB's link type is singular ("instrument technician"). The plural
// form here missed the exact-name match in resolveLinkTypeId, so the
// fuzzy contains-match collapsed "instruments technician" down to the
// bare "instrument" performance link type — turning a piano tuner /
// tech credit into an instrument performer. #155
linkType: "instrument technician"
},
publisher: {
entityType: "artist",
linkType: "published"
},
"Liner Notes": {
entityType: "artist",
linkType: "liner notes"
},
"A&R": {
entityType: "artist",
linkType: "misc"
},
// #291: no dedicated MB rel — the generic "misc" relationship carries the
// role name as its `task` attribute (mappers.js), so this lands as misc + task "research".
Research: {
entityType: "artist",
linkType: "misc"
},
Advisor: {
entityType: "artist",
linkType: "misc"
},
"Concept By": {
entityType: "artist",
linkType: "misc"
},
Contractor: {
entityType: "artist",
linkType: "misc"
},
Coordinator: {
entityType: "artist",
linkType: "misc"
},
Management: {
entityType: "artist",
linkType: "misc"
},
"Musical Assistance": {
entityType: "artist",
linkType: "misc"
},
"Tour Manager": {
entityType: "artist",
linkType: "misc"
},
Other: {
entityType: "artist",
linkType: "misc"
},
"Public Relations": {
entityType: "artist",
linkType: "misc"
},
Promotion: {
entityType: "artist",
linkType: "misc"
},
Crew: {
entityType: "artist",
linkType: "misc"
},
"Supervised By": {
entityType: "artist",
linkType: "misc"
},
"Director Of Photography": {
entityType: "artist",
linkType: "photography",
attributes: [{ _type: "task", value: "director of photography" }]
}
};
// src/data/instruments.js
var INSTRUMENTS = {
Afox\u00E9: "afox\xE9",
Agog\u00F4: "agog\xF4",
Ashiko: "ashiko",
Atabal: null,
Bapang: "bhapang",
"Clarinet": "clarinet",
"Percussion": "percussion",
"Congas": "congas",
"Conga": "congas",
"Conga Drum": "congas",
"Bongos": "bongos",
"Bongo": "bongos",
"Tambourine": "tambourine",
"Cuica": "cu\xEDca",
"Guiro": "g\xFCiro",
"G\xFCiro": "g\xFCiro",
"Udu": "udu",
"La\xFAd": "la\xFAd",
"Laud": "la\xFAd",
"Mbira": "mbira",
"Kalimba": "mbira",
"Slide Guitar": "slide guitar",
"Acoustic Guitar": "acoustic guitar",
"Double Bass": "double bass",
"Goblet Drum": "goblet drum",
"Darbouka": "darbuka",
// #392 goblet drum (Discogs spelling; MB instrument is "darbuka")
"Darbuka": "darbuka",
"Maracas": "maracas",
"Steel Drum": "steelpan",
"Steelpan": "steelpan",
"Electronics": "electronics",
"Electronic": "electronics",
"Synth Bass": "bass synthesizer",
"Vocoder": "vocoder",
"Xylophone": "xylophone",
"Bells": "bells",
"Chimes": "chimes",
// #392 MB has no "wind chimes" instrument; the generic MB name is "chimes"
"Glockenspiel": "glockenspiel",
"Shakers": "shaker",
"Shaker": "shaker",
"Cowbell": "cowbell",
"Claves": "claves",
"Timbales": "timbales",
"Baritone Guitar": "baritone guitar",
"Synth": "synthesizer",
"Synthesizer": "synthesizer",
"Synths": "synthesizer",
"Keyboards": "keyboard",
"Keyboard": "keyboard",
"Organ": "organ",
"Harmonium": "harmonium",
"Piano": "piano",
"Electric Piano": "electric piano",
"Harpsichord": "harpsichord",
"Celesta": "celesta",
"Strings": "string instruments",
"Flute": "flute",
"Saxophone": "saxophone",
"Vibraphone": "vibraphone",
"Trumpet": "trumpet",
"Trombone": "trombone",
"Violin": "violin",
"Cello": "cello",
"Viola": "viola",
"Harp": "harp",
"Banjo": "banjo",
"Mandolin": "mandolin",
"Ukulele": "ukulele",
"Harmonica": "harmonica",
"Accordion": "accordion",
"Oboe": "oboe",
"Bassoon": "bassoon",
"Tuba": "tuba",
"French Horn": "French horn",
"Marimba": "marimba",
"Melodica": "melodica",
"Sitar": "sitar",
"Oud": "oud",
"Kora": "kora",
"Tabla": "tabla",
"Didgeridoo": "didgeridoo",
"Theremin": "theremin",
"Flugelhorn": "flugelhorn",
"Cornet": "cornet",
"Alto Saxophone": "alto saxophone",
"Tenor Saxophone": "tenor saxophone",
"Soprano Saxophone": "soprano saxophone",
"Baritone Saxophone": "baritone saxophone",
"Bass Clarinet": "bass clarinet",
"Bass Flute": "bass flute",
"Piccolo": "piccolo",
"Bass Drum": "bass drum",
Bata: "bat\xE1 drum",
"Bell Tree": "bell tree",
Bendir: "bendir",
Bodhr\u00E1n: "bodhr\xE1n",
"Body Percussion": "body percussion",
Bombo: null,
Bones: "bones",
Buhay: null,
Buk: "buk",
Cabasa: "cabasa",
Caixa: "caixa",
"Caja Vallenata": null,
Caj\u00F3n: "caj\xF3n",
Calabash: "calabash",
Castanets: "castanets",
Caxixi: "caxixi",
"Chak'chas": null,
Chinch\u00EDn: null,
Ching: "ching",
Cymbal: "cymbal",
Daf: "daf",
Davul: "davul",
Dhol: "dhol",
Dholak: "dholak",
Djembe: "djembe",
Doira: null,
Doli: null,
Drum: "drum set",
"Drum Programming": null,
Drums: "drum set",
Dunun: "dunun",
"Electronic Drums": null,
"Finger Cymbals": "finger cymbals",
"Finger Snaps": "finger snaps",
"Frame Drum": "frame drum",
"Friction Drum": "friction drum",
Frottoir: "frottoir",
Ganz\u00E1: "ganz\xE1",
Ghatam: "ghatam",
Ghungroo: null,
Gong: "gong",
Guacharaca: null,
Handbell: "handbell",
Handclaps: "handclaps",
"Hang Drum": "handpan",
Hihat: "hi-hat",
Hosho: null,
Hyoshigi: "hyoshigi",
Idiophone: "idiophone",
Jaggo: null,
Janggu: "janggu",
Jing: "jing",
"K'kwaengwari": null,
Ka: null,
"Kagura Suzu": null,
Kanjira: "kanjira",
Karkabas: null,
Khartal: null,
Khurdak: "duggi",
Kynggari: null,
Lagerphone: "monkey stick",
"Lion's Roar": null,
Madal: "madal",
Mallets: null,
"Monkey stick": "monkey stick",
Mridangam: "mridangam",
Pakhavaj: "pakhawaj",
Pandeiro: null,
Rainstick: "rainstick",
Ratchet: "ratchet",
Rattle: "shaken idiophone",
"Reco-reco": "reco-reco",
Repinique: "repinique",
Rototoms: null,
Scraper: null,
Shakubyoshi: null,
Shekere: "shekere",
Shuitar: null,
"Singing Bowls": null,
Skratjie: null,
Slapstick: "slapstick",
"Slit Drum": "slit drum",
Snare: null,
Spoons: "spoons",
"Stomp Box": null,
Surdo: "surdo",
Surigane: "atarigane",
Taiko: "taiko",
"Talking Drum": "talking drum",
"Tam-tam": "chau gong",
Tambora: null,
Tamboril: "tabor",
Tamborim: "tamborim",
"Tan-Tan": null,
"Tap Dance": "tap dance",
"Tar (Drum)": null,
"Temple Bells": null,
"Temple Block": null,
Thavil: "thavil",
Timpani: "timpani",
"Tom Tom": "tom-tom",
Triangle: "triangle",
T\u00FCng\u00FCr: null,
Vibraslap: "vibraslap",
Washboard: "washboard",
Waterphone: "waterphone",
"Wood Block": "wood block",
Zabumba: "zabumba",
Amadinda: "amadinda",
Angklung: "angklung",
Balafon: "balafon",
Boomwhacker: "boomwhacker",
Carillon: null,
Crotales: "crotales",
Guitaret: "guitaret",
Lamellophone: "lamellaphone",
Marimbula: "mar\xEDmbula",
Metallophone: "metallophone",
"Musical Box": "musical box",
Prempensua: null,
Slagbordun: null,
"Steel Drums": "steelpan",
"Thumb Piano": "mbira",
Tubaphone: null,
"Tubular Bells": "tubular bells",
Tun: null,
Txalaparta: "txalaparta",
"Baby Grand Piano": null,
Chamberlin: "chamberlin",
Claviorgan: "claviorganum",
"Concert Grand Piano": null,
Dulcitone: "dulcitone",
"Electric Harmonium": null,
"Electric Harpsichord": null,
"Electric Organ": "electronic organ",
Fortepiano: null,
"Grand Piano": "grand piano",
Mellotron: "mellotron",
Omnichord: "omnichord",
"Ondes Martenot": "ondes martenot",
"Parlour Grand Piano": null,
Pedalboard: null,
"Player Piano": null,
Regal: "regal",
Stylophone: null,
"Tangent Piano": "tangent piano",
"Toy Piano": "toy piano",
"Upright Piano": "upright piano",
Virginal: "virginal",
"12-String Acoustic Guitar": null,
"12-String Bass": null,
"5-String Banjo": null,
"6-String Banjo": null,
"6-String Bass": null,
"Acoustic Bass": "acoustic bass guitar",
// #209 — Discogs separates this from "Double Bass", which maps to upright
"Arco Bass": null,
Arpa: null,
Autoharp: "autoharp",
Baglama: null,
"Bajo Quinto": null,
"Bajo Sexto": "bajo sexto",
Balalaika: "balalaika",
Bandola: null,
Bandura: "bandura",
Bandurria: "bandurria",
Banhu: "banhu",
Banjolin: "banjolin",
"Baroque Guitar": "baroque guitar",
Baryton: "baryton",
"Bass Guitar": "bass guitar",
Berimbau: "berimbau",
Bhapang: "bhapang",
Biwa: "biwa",
"Blaster Beam": "blaster beam",
Bolon: "bolon",
Bouzouki: "bouzouki",
"Bulbul Tarang": "bulbul tarang",
Byzaanchi: null,
Cavaquinho: "cavaquinho",
"Cello Banjo": null,
Changi: null,
Chanzy: "chanzy",
"Chapman Stick": "chapman stick",
Charango: "charango",
Chitarrone: null,
Chonguri: null,
Chuniri: null,
Cimbalom: "cimbalom",
Citole: "citole",
Cittern: "cittern",
Cl\u00E0rsach: null,
"Classical Guitar": "classical guitar",
Clavichord: "clavichord",
Clavinet: "clavinet",
Cobza: null,
Contrabass: "double bass",
Cuatro: "cuatro",
C\u00FCmb\u00FC\u015F: "c\xFCmb\xFC\u015F",
Cura: null,
Deaejeng: null,
"Diddley Bow": "diddley bow",
Dilruba: "dilruba",
Dobro: "resonator guitar",
Dojo: null,
Dombra: "dombra",
Domra: "domra",
Doshpuluur: "doshpuluur",
Dulcimer: null,
Dutar: "dutar",
"\u0110\xE0n b\u1EA7u": "\u0111\xE0n b\u1EA7u",
Ektare: null,
"Electric Bass": "bass guitar",
// MB's "bass guitar" IS the electric bass (was falling through to generic "instrument")
"Electric Bass Guitar": "electric bass guitar",
// MB's specific instrument (distinct from generic "bass guitar"); metal default, OP #453
"Electric Guitar": "electric guitar",
"Electric Upright Bass": "electric upright bass",
"Electric Violin": "electric violin",
"Epinette des Vosges": null,
Erhu: "erhu",
Esraj: "esraj",
Fiddle: "fiddle",
"Flamenco Guitar": "flamenco guitar",
"Fretless Bass": "fretless bass",
"Fretless Guitar": null,
Gadulka: "gadulka",
Gaohu: "gaohu",
Gayageum: "gayageum",
Geomungo: "geomungo",
Giga: "\u0123\u012Bga",
Gittern: "gittern",
Gottuv\u00E2dyam: null,
Guimbri: "gumbri",
Guitalele: "guitalele",
Guitar: "guitar",
"Guitar Banjo": "banjitar",
"Guitar Synthesizer": "guitar synthesizer",
Guitarr\u00F3n: null,
GuitarViol: null,
Guqin: "guqin",
Gusli: "gusli",
Guzheng: "guzheng",
Haegum: "haegeum",
Halldorophone: null,
Hardingfele: "hardingfele",
"Harp Guitar": "harp guitar",
Hummel: "hummel",
Huqin: "huqin",
"Hurdy Gurdy": "hurdy gurdy",
Igil: "igil",
Jarana: null,
Jinghu: "jinghu",
Jouhikko: "jouhikko",
Kabosy: null,
Kamancha: "kamancheh",
Kankl\u0117s: "kankl\u0117s",
Kantele: "kantele",
Kanun: "kanun",
Kemenche: "kemenche",
Kirar: null,
Kobyz: null,
Kokyu: "kokyu",
Koto: "koto",
Krar: "krar",
Langeleik: "langeleik",
Laouto: "laouto",
"Lap Steel Guitar": "lap steel guitar",
Lavta: "lavta",
"Lead Guitar": "guitar",
// #209 — MB has no lead/rhythm-guitar instrument; both are just "guitar"
Lira: null,
"Lira da Braccio": "lira da braccio",
Lirone: "lirone",
Liuqin: "liuqin",
Lute: "lute",
Lyre: "lyre",
Mandobass: null,
Mandocello: "mandocello",
Mandoguitar: "mandoguitar",
Mandola: "mandola",
"Mandolin Banjo": null,
Mandolincello: null,
Marxophone: "marxophone",
Masinko: null,
Monochord: null,
Morinhoor: null,
"Mountain Dulcimer": null,
"Musical Bow": "musical bow",
Ngoni: "ng\u0254ni",
Nyckelharpa: "nyckelharpa",
"Open-Back Banjo": null,
Outi: "oud",
Panduri: null,
"Pedal Steel Guitar": "pedal steel guitar",
"Piccolo Banjo": null,
Pipa: "pipa",
"Plectrum Banjo": null,
"Portuguese Guitar": "portuguese guitar",
Psalmodicon: null,
Psaltery: "psaltery",
Rabab: "rebab",
Rabeca: "rebec",
Rebab: "rebab",
Rebec: "rebec",
Reikin: null,
"Requinto Guitar": null,
"Resonator Banjo": null,
"Resonator Guitar": "resonator guitar",
"Rhythm Guitar": "guitar",
// #209 — see Lead Guitar
Ronroco: "ronroco",
Ruan: "ruan",
Sanshin: "sanshin",
Santoor: "santoor",
Sanxian: "sanxian",
Sarangi: "sarangi",
Sarod: "sarod",
"Selmer-Maccaferri Guitar": null,
"Semi-Acoustic Guitar": null,
Seperewa: null,
"Shahi Baaja": "bulbul tarang",
Shamisen: "shamisen",
Sintir: "gumbri",
Spinet: "spinet",
"Steel Guitar": "steel guitar",
"Stroh Violin": "stroh violin",
Strumstick: null,
Surbahar: "surbahar",
"Svara Mandala": null,
Swarmandel: null,
Sympitar: null,
SynthAxe: null,
Taish\u014Dgoto: "taishogoto",
Talharpa: "talharpa",
Tambura: "tambura",
Tamburitza: null,
Tapboard: null,
"Tar (lute)": null,
"Tenor Banjo": "tenor banjo",
"Tenor Guitar": "tenor guitar",
Theorbo: "theorbo",
Timple: "tiple",
Tiple: "tiple",
Tipple: "tiple",
Tonkori: "tonkori",
Tres: "tres",
"Tromba Marina": "tromba marina",
"Twelve-String Guitar": null,
Tzouras: "tzoura",
"Ukulele Banjo": "banjo-ukulele",
\u00DCt\u0151gardon: "\xFCt\u0151gardon",
Valiha: "valiha",
Veena: "saraswati veena",
Vielle: "vielle",
Vihuela: "vihuela",
Viol: "viola da gamba",
"Viola Caipira": "viola caipira",
"Viola d'Amore": "viola d'amore",
"Viola da Gamba": "viola da gamba",
"Viola de Cocho": null,
"Viola Kontra": null,
"Viola Nordestina": null,
"Violino Piccolo": "violino piccolo",
Violoncello: "cello",
Violone: "violone",
"Washtub Bass": "washtub bass",
Xalam: "xalam",
"Yang T'Chin": "yangqin",
Yanggeum: null,
Zither: "zither",
Zongora: null,
Algoza: "algozey",
Alphorn: "alphorn",
"Alto Clarinet": "alto clarinet",
"Alto Flute": "alto flute",
"Alto Horn": null,
"Alto Recorder": "treble recorder / alto recorder",
Apito: null,
Bagpipes: "bagpipe",
Bandoneon: "bandone\xF3n",
Bansuri: "bansuri",
"Baritone Horn": "baritone horn",
"Barrel Organ": "barrel organ",
"Bass Harmonica": "bass harmonica",
"Bass Saxophone": "bass saxophone",
"Bass Trombone": "bass trombone",
"Bass Trumpet": "bass trumpet",
"Bass Tuba": null,
"Basset Horn": "basset horn",
Bawu: "bawu",
Bayan: "bayan",
Bellowphone: null,
Beresta: null,
"Blues Harp": "harmonica",
"Bolivian Flute": null,
Bombarde: "bombarde",
Brass: "brass",
"Brass Bass": null,
Bucium: null,
Bugle: null,
Chalumeau: "chalumeau",
Chanter: null,
Charamel: null,
Chirimia: "chirim\xEDa",
Clarion: null,
Claviola: "claviola",
Comb: null,
"Concert Flute": "concert flute",
Concertina: null,
Conch: "conch",
"Contra-Alto Clarinet": null,
"Contrabass Clarinet": "contrabass clarinet",
"Contrabass Saxophone": "contrabass saxophone",
Contrabassoon: "contrabassoon",
"Cor Anglais": "cor anglais",
Cornett: "cornett",
Cromorne: "crumhorn",
Crumhorn: "crumhorn",
Daegeum: "daegeum",
Danso: "danso",
"Dili Tuiduk": null,
Dizi: "dizi",
Drone: null,
Duduk: "duduk",
Dulcian: "dulcian",
Dulzaina: "dulzaina",
"Electronic Valve Instrument": null,
"Electronic Wind Instrument": "wind synthesizer",
"English Horn": "cor anglais",
Euphonium: "euphonium",
Fife: "fife",
Flageolet: "flageolet",
Flugabone: null,
Fluier: null,
Flumpet: "flumpet",
"Flute D'Amour": "fl\xFBte d'amour",
Friscaletto: null,
Fujara: "fujara",
Galoubet: "three-hole pipe",
Gemshorn: "gemshorn",
Gudastviri: null,
Harmet: null,
Heckelphone: "heckelphone",
Helicon: "helicon",
Hichiriki: "hichiriki",
"Highland Pipes": null,
Horagai: null,
Horn: "horn",
Horns: null,
Hotchiku: "hotchiku",
"Hunting Horn": null,
Jug: "jug",
Kagurabue: "kagurabue",
Kaval: "kaval",
Kazoo: "kazoo",
Khene: "khene",
Kortholt: "kortholt",
Launeddas: "launeddas",
Limbe: "limbe",
Liru: null,
"Low Whistle": "low whistle",
Lur: null,
Lyricon: "lyricon",
M\u00E4nkeri: null,
Mellophone: "mellophone",
Melodeon: null,
Mey: null,
Mizmar: null,
Mizwad: "mezwed",
Moce\u00F1o: null,
"Mouth Organ": "mouth organ",
Murli: null,
Musette: null,
Nadaswaram: "nadaswaram",
Ney: "ney",
"Northumbrian Pipes": "northumbrian pipes",
"Nose Flute": "nose flute",
"Oboe d'Amore": "oboe d'amore",
"Oboe Da Caccia": "oboe da caccia",
Ocarina: "ocarina",
Ophicleide: "ophicleide",
"Overtone Flute": null,
Panpipes: "pan flute",
"Piano Accordion": "piano accordion",
"Piccolo Flute": null,
"Piccolo Trumpet": "piccolo trumpet",
Pipe: null,
Piri: "piri",
Pito: "three-hole pipe",
Pixiephone: null,
Quena: "quena",
Quenacho: null,
Quray: null,
Rauschpfeife: "rauschpfeife",
Recorder: "recorder",
Reeds: "reeds",
Rhaita: null,
Rondador: "rondador",
Rozhok: null,
Ryuteki: "ryuteki",
Sackbut: "sackbut",
Salamuri: null,
Sampona: "siku",
Sarrusophone: "sarrusophone",
Saxello: null,
Saxhorn: null,
Schwyzer\u00F6rgeli: "schwyzer\xF6rgeli",
Serpent: "serpent",
Shakuhachi: "shakuhachi",
Shanai: "shehnai",
Shawm: "shawm",
Shenai: "shehnai",
Sheng: "sheng",
Shinobue: "shinobue",
Sho: "sho",
"Shruti Box": "shruti box",
"Slide Whistle": "slide whistle",
Smallpipes: null,
Sodina: null,
Sopilka: "sopilka",
"Sopranino Saxophone": "sopranino saxophone",
"Soprano Clarinet": "soprano clarinet",
"Soprano Cornet": null,
"Soprano Flute": "soprano flute",
"Soprano Trombone": null,
Souna: null,
Sousaphone: "sousaphone",
"Subcontrabass Saxophone": null,
Suling: "suling",
Suona: "suona",
Taepyungso: null,
T\u00E1rogat\u00F3: "taragot",
"Tenor Horn": null,
"Tenor Trombone": "tenor trombone",
"Ti-tse": null,
"Tin Whistle": "tin whistle",
Tonette: "tonette",
Txirula: "three-hole pipe",
Txistu: "three-hole pipe",
"Uilleann Pipes": "uilleann pipes",
"Valve Trombone": "valve trombone",
"Valve Trumpet": null,
"Wagner Tuba": "wagner tuba",
Whistle: "whistle",
"Whistling Water Jar": "jug",
Wind: null,
Woodwind: "woodwind",
Xiao: "xiao",
Yorgaphone: null,
Zhaleika: "zhaleika",
Zukra: null,
Zurna: "zurna",
"Automatic Orchestra": null,
Computer: null,
"Drum Machine": "drum machine",
"Rhythm Box": "drum machine",
// #223 — Discogs "[Rhythm Box]" preset drum unit
Effects: "effects",
Groovebox: null,
Loops: null,
"MIDI Controller": null,
Noises: null,
Sampler: "sampler",
Scratches: "turntable",
Sequencer: null,
"Software Instrument": null,
Talkbox: "talkbox",
// not a Discogs role; a bracket names it ("Soloist [Moog Solo]") and MB has it
Moog: "Moog",
Tannerin: null,
Tape: "tape",
Turntables: "turntable",
// 'Accompanied By' deliberately NOT in INSTRUMENTS — it's a meta role,
// not an instrument. Mapped in ENTITY_TYPE_MAP to performer + additional.
"Audio Generator": null,
"Backing Band": null,
Band: null,
// Discogs "Bass" is generic (could be bass guitar, double bass, …) but MB
// has a generic "bass" instrument for exactly that case. Leaving it null
// dispatched a bare instrument rel with no instrument → "Missing instrument"
// on commit (#133). Map to MB's generic "bass".
Bass: "bass",
"Brass Band": null,
Bullroarer: "bullroarer",
"Concert Band": null,
"E-Bow": "ebow",
Ensemble: null,
Gamelan: "gamelan",
"Glass Harmonica": "glass harmonica",
Guest: null,
Homus: null,
Instruments: null,
"Jew's Harp": "mouth harp",
Morchang: null,
Musician: null,
Orchestra: null,
Performer: null,
"Rhythm Section": null,
Saw: null,
Siren: null,
Soloist: null,
Sounds: null,
Toy: null,
Trautonium: "trautonium",
"Wind Chimes": null,
"Wobble Board": null
};
// ../../dev/match/artist-match.mjs
var MBM_EXACT_LIMIT = 100;
var MBM_SPECIAL_PURPOSE = [
"125ec42a-7229-4250-afc5-e057484327fe",
// [unknown]
"f731ccc4-e22a-43af-a747-64213329e088",
// [anonymous]
"33cf029c-63b0-41a0-9855-be2a3665fb3b",
// [data]
"314e1c25-dde7-4e4d-b2f4-0a7b9f7c56dc",
// [dialogue]
"eec63d3c-3b81-4ad4-b1e4-7c147d4d2b61",
// [no artist]
"9be7f096-97ec-4615-8957-8d40b5dcbc41",
// [traditional]
"89ad4ac3-39f7-470e-963a-56509c546377",
// Various Artists
"7e84f845-ac16-41fe-9ff8-df12eb32af55",
// MusicBrainz Test Artist
"66ea0139-149f-4a0c-8fbf-5ea9ec4a6e49",
// [Disney]
"a0ef7e1d-44ff-4039-9435-7d5fefdeecc9",
// [theatre]
"90068d37-bae7-4292-be4a-704c145bd616",
// [church chimes]
"80a8851f-444c-4539-892b-ad2a49292aa9"
// [language instruction]
];
function mbmFold(s) {
return String(s == null ? "" : s).normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/đ/gi, "d").replace(/[‐‑‒–—―−]/g, "-").toLowerCase().replace(/\s+/g, " ").trim();
}
function mbmFoldKeepCase(s) {
return String(s == null ? "" : s).normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/đ/gi, "d").replace(/[‐‑‒–—―−]/g, "-").replace(/\s+/g, " ").trim();
}
function mbmSameName(a, b) {
return mbmFold(a) === mbmFold(b) && mbmFold(a) !== "";
}
function mbmSameNameCase(a, b) {
return mbmFoldKeepCase(a) === mbmFoldKeepCase(b) && mbmFoldKeepCase(a) !== "";
}
function mbmHolds(entity, name, caseExact) {
if (!entity) return null;
const same = caseExact ? mbmSameNameCase : mbmSameName;
if (same(entity.name, name)) return "name";
if ((entity.aliases || []).some((al) => same(al && (al.name != null ? al.name : al), name))) return "alias";
return null;
}
function mbmIdentityQuery(name, field) {
const q = String(name == null ? "" : name).replace(/["\\]/g, " ").replace(/\s+/g, " ").trim();
return q ? 'alias:"' + q + '" OR ' + (field || "artist") + ':"' + q + '"' : "";
}
function mbmExactIdentity(json, name, opts) {
const o = opts || {};
if (!json || typeof json !== "object") return { status: "failed", exact: [] };
const list = json.artists || json.labels || json.places || [];
let exact = list.filter((e) => mbmHolds(e, name));
if (exact.length > 1) {
const caseExact = exact.filter((e) => mbmHolds(e, name, true));
if (caseExact.length === 1) exact = caseExact;
else if (o.scoreGap) {
const scored = exact.filter((e) => typeof e.score === "number").sort((a, b) => b.score - a.score);
if (scored.length >= 2 && scored[0].score - scored[1].score >= o.scoreGap) exact = [scored[0]];
}
}
const offset = typeof json.offset === "number" ? json.offset : 0;
const complete = typeof json.count === "number" && json.count <= offset + list.length;
if (exact.length === 1 && complete) return { status: "unique", hit: exact[0], via: mbmHolds(exact[0], name) === "name" ? "name" : "alias", exact, complete };
if (exact.length > 1) return { status: "ambiguous", exact, complete };
if (!complete) return { status: "incomplete", exact, complete };
return { status: "none", exact, complete };
}
function mbmRelatedArtists(artistJson) {
if (!artistJson || !artistJson.id) return [];
const out = [{ gid: artistJson.id, name: artistJson.name || "", aliases: (artistJson.aliases || []).map((a) => a && a.name).filter(Boolean), rel: "self" }];
for (const r of artistJson.relations || []) {
const a = r && r.artist;
if (!a || !a.id || out.some((x) => x.gid === a.id)) continue;
out.push({ gid: a.id, name: a.name || "", aliases: [], rel: r.type || "" });
}
return out;
}
function mbmContextHolders(related, name, candidates) {
const cand = new Map((candidates || []).map((c) => [c.id || c.gid, c]));
const out = [];
for (const r of related || []) {
let via = mbmSameName(r.name, name) ? "name" : (r.aliases || []).some((a) => mbmSameName(a, name)) ? "alias" : null;
if (!via) {
const c = cand.get(r.gid);
if (c && mbmHolds(c, name)) via = mbmHolds(c, name);
}
if (via && !out.some((x) => x.gid === r.gid)) out.push({ gid: r.gid, name: r.name, via, rel: r.rel });
}
return out;
}
function mbmCoCreditHits(recordingsJson, ctxGid, name) {
const out = [];
for (const rec of recordingsJson && recordingsJson.recordings || []) {
for (const c of rec["artist-credit"] || []) {
const a = c && c.artist;
if (!a || !a.id || a.id === ctxGid) continue;
if ((mbmSameName(c.name, name) || mbmSameName(a.name, name)) && !out.some((x) => x.gid === a.id)) out.push({ gid: a.id, name: a.name });
}
}
return out;
}
function mbmGuessSortName(name) {
if (!name || !name.trim()) return name;
name = name.trim().replace(/\s+/g, " ");
if (/[^\p{Script=Latin}\p{Script=Common}\p{Script=Inherited}]/u.test(name)) return name;
const words = name.split(" ");
if (words.length === 1) return name;
const article = name.match(/^(the|a|an)\s+(.+)$/i);
if (article) return article[2] + ", " + article[1].charAt(0).toUpperCase() + article[1].slice(1).toLowerCase();
let base = name, suffix = "";
const sfx = name.match(/^(.*?),?\s+(jr\.?|sr\.?|ii|iii|iv|v|esq\.?)$/i);
if (sfx) {
base = sfx[1].trim();
suffix = " " + sfx[2];
}
const parts = base.split(" ");
if (parts.length === 1) return name;
return parts[parts.length - 1] + ", " + parts.slice(0, -1).join(" ") + suffix;
}
// src/mappers.js
var INSTRUMENTS_CI = Object.fromEntries(
Object.entries(INSTRUMENTS).map(([k, v]) => [k.toLowerCase(), v])
);
var BRACKET_REFINES = [
[/^(electronic organ|organ)$/, /\b(b-?3|hammond)\b/i, "Hammond organ"],
[/^electric piano$/, /\brhodes\b/i, "Rhodes piano"],
[/^(guitar|acoustic guitar)$/, /\b(12|twelve)[- ]string\b/i, "12 string guitar"],
[/^(guitar|acoustic guitar)$/, /\bnylon\b/i, "classical guitar"],
[/^lute$/, /\b(tanpura|tambura)\b/i, "tambura"],
[/^synthesizer$/, /\bmoog\b/i, "Moog"]
];
var isBareInstrument = (r) => r.linkType === "instrument" && !(r.attributes || []).some((a) => a && a._type === "instrument");
function flattenTracklist(tracklist) {
if (!Array.isArray(tracklist)) return [];
return tracklist.flatMap((t) => {
if (t?.type_ === "index" && Array.isArray(t.sub_tracks)) {
const parentExtra = Array.isArray(t.extraartists) ? t.extraartists : [];
if (!parentExtra.length) return t.sub_tracks;
return t.sub_tracks.map((st) => Object.assign({}, st, {
extraartists: [...Array.isArray(st.extraartists) ? st.extraartists : [], ...parentExtra]
}));
}
return [t];
});
}
function getAllArtistTracks(tracklist, artistTracks) {
tracklist = flattenTracklist(tracklist);
return artistTracks.split(",").reduce((trackArray, trackNumber) => {
if (/ to /.test(trackNumber)) {
const parts = trackNumber.split(" to ");
const startTrack = parts[0].trim().replace(".", "-");
const lastTrack = parts[1].trim().replace(".", "-");
let hasFoundStart = false, hasFoundEnd = false;
tracklist.forEach((track) => {
const resolvedTrackPosition = track.position.replace(".", "-");
if (!hasFoundStart && resolvedTrackPosition === startTrack) {
hasFoundStart = true;
trackArray.push(track);
} else if (hasFoundStart && !hasFoundEnd) {
if (resolvedTrackPosition === lastTrack) {
hasFoundEnd = true;
trackArray.push(track);
} else if (track.position === "") {
hasFoundEnd = true;
} else {
trackArray.push(track);
}
}
});
} else {
const track = tracklist.find((track2) => {
return track2.position === trackNumber.trim();
});
if (track) {
trackArray.push(track);
}
}
return trackArray;
}, []);
}
function convertPotentialDJMixers(json) {
const all = flattenTracklist(json.tracklist || []).filter((t) => t.type_ === "track");
const djmixers = (json.extraartists || []).filter((artist) => artist.role === "DJ Mix" && artist.tracks).map((artist) => {
const covered = new Set(getAllArtistTracks(json.tracklist, artist.tracks).map((t) => t.position));
if (!all.length || !all.every((t) => covered.has(t.position))) return null;
json.extraartists = json.extraartists.filter((a) => a !== artist);
return Object.assign({}, ENTITY_TYPE_MAP["DJ Mix"], { artist });
}).filter((role) => role !== null);
return djmixers;
}
function getArtistRoles(artist) {
const roleStr = artist.role;
const rawRoles = roleStr.split(",");
if (/\([0-9]+\)/.test(artist.anv)) {
artist.anv = artist.anv.replace(/\([0-9]+\)/, "").trim();
}
if (/\([0-9]+\)/.test(artist.name)) {
artist.name = artist.name.replace(/\([0-9]+\)/, "").trim();
}
return rawRoles.map((role) => {
let additionalAttributes = [];
let creditedAs = null;
let rolePart = role.trim().split("[");
const actualRole = rolePart[0].trim();
if (/Recording Engineer/.test(rolePart[1]) && actualRole === "Engineer") {
return Object.assign({}, ENTITY_TYPE_MAP["Recording Engineer"], {
artist
});
}
if (/Mastering Engineer/.test(rolePart[1]) && actualRole === "Engineer") {
return Object.assign({}, ENTITY_TYPE_MAP["Mastered By"], {
artist
});
}
if (/Cover Design/.test(rolePart[1]) && actualRole === "Artwork") {
return Object.assign({}, ENTITY_TYPE_MAP["Design"], {
artist
});
}
if (/Design/.test(rolePart[1]) && actualRole === "Cover") {
return Object.assign({}, ENTITY_TYPE_MAP["Design"], {
artist
});
}
if (/Art/.test(rolePart[1]) && actualRole === "Cover") {
return Object.assign({}, ENTITY_TYPE_MAP["Artwork"], {
artist
});
}
if (/Additional/.test(rolePart[1])) {
additionalAttributes.push("additional");
}
if (/Assistant/.test(rolePart[1])) {
additionalAttributes.push("assistant");
}
if (/Co /.test(rolePart[1])) {
additionalAttributes.push("additional");
}
if (/Executive/.test(rolePart[1])) {
additionalAttributes.push("executive");
}
if (/Associate/.test(rolePart[1])) {
additionalAttributes.push("associate");
}
if (/Guest/.test(rolePart[1])) {
additionalAttributes.push("guest");
}
if (/Solo/.test(rolePart[1])) {
additionalAttributes.push("solo");
}
const mapping = ENTITY_TYPE_MAP[actualRole];
if (mapping && mapping.linkType == "misc") {
const taskValue = rolePart[1] ? rolePart[1].replace("]", "").trim().toLowerCase() : actualRole.trim().toLowerCase();
additionalAttributes.push({ _type: "task", value: taskValue });
}
if (mapping && mapping.linkType == "engineer" && rolePart[1]) {
additionalAttributes.push({ _type: "task", value: rolePart[1].replace("]", "").trim().toLowerCase() });
}
if (mapping && mapping.linkType == "mix" && rolePart[1]) {
additionalAttributes.push({ _type: "task", value: rolePart[1].replace("]", "").trim().toLowerCase() });
}
if (mapping && mapping.linkType == "photography" && rolePart[1]) {
additionalAttributes.push({ _type: "task", value: rolePart[1].replace("]", "").trim().toLowerCase() });
}
if (mapping && mapping.linkType == "artwork" && rolePart[1]) {
additionalAttributes.push({ _type: "task", value: rolePart[1].replace("]", "").trim().toLowerCase() });
}
if (mapping && mapping.linkType == "vocal" && rolePart[1]) {
creditedAs = rolePart[1].replace(/]/g, "").trim() || null;
}
const actualRoleLc = actualRole.toLowerCase();
if (!mapping && Object.prototype.hasOwnProperty.call(INSTRUMENTS_CI, actualRoleLc)) {
let instrumentName = INSTRUMENTS_CI[actualRoleLc];
let role2 = ENTITY_TYPE_MAP.Instruments;
if (actualRoleLc === "drum programming") {
role2 = ENTITY_TYPE_MAP["Programmed By"];
instrumentName = INSTRUMENTS_CI["drum machine"];
}
const bracket = rolePart[1] ? rolePart[1].replace(/]/g, "").trim() : "";
if (instrumentName && bracket) {
const ref = BRACKET_REFINES.find(([base, re]) => base.test(instrumentName.toLowerCase()) && re.test(bracket));
if (ref) instrumentName = ref[2];
}
if (!instrumentName && bracket) {
const noSolo = bracket.replace(/\s*\bsolos?\b\s*/ig, " ").trim();
for (const candidate of [bracket, bracket.split(",")[0].trim(), noSolo]) {
const lc = candidate.toLowerCase();
if (lc && Object.prototype.hasOwnProperty.call(INSTRUMENTS_CI, lc) && INSTRUMENTS_CI[lc]) {
instrumentName = INSTRUMENTS_CI[lc];
break;
}
}
}
const extra = additionalAttributes.filter((a) => a === "solo" || a === "guest" || a === "additional");
return Object.assign({}, role2, {
artist,
attributes: (instrumentName ? [{ _type: "instrument", value: instrumentName.toLowerCase() }] : []).concat(extra)
});
}
if (!mapping) {
return null;
}
if (Array.isArray(mapping.attributes)) {
let mapped = mapping.attributes;
if (creditedAs && mapping.linkType === "vocal") {
mapped = mapped.map((a) => a && a._type === "vocal" ? Object.assign({}, a, { creditedAs }) : a);
}
additionalAttributes = additionalAttributes.concat(mapped);
}
return Object.assign({}, mapping, {
artist,
attributes: additionalAttributes
});
}).filter((resolvedRole) => {
return !!resolvedRole;
}).filter((r, i, all) => !isBareInstrument(r) || !all.some((o) => o.linkType === "vocal" || o.linkType === "instrument" && !isBareInstrument(o)));
}
var ARTWORK_LINK_TYPES = /* @__PURE__ */ new Set(["artwork", "design", "photography", "illustration", "graphic design"]);
function hoistFullSpanArtworkRels(artistRoles, tracklistRels, tracklist) {
const allPos = new Set((tracklist || []).map((t) => String(t && t.position != null ? t.position : "")).filter(Boolean));
if (!allPos.size) return { artistRoles, tracklistRels, hoisted: [] };
const keyOf = (r) => [
r.artist && (r.artist.resource_url || r.artist.name) || "",
r.linkType,
JSON.stringify((r.attributes || []).map((a) => a && typeof a === "object" && a._type ? a._type + ":" + a.value : String(a)).sort())
].join("|");
const groups = /* @__PURE__ */ new Map();
for (const r of tracklistRels) {
if (!ARTWORK_LINK_TYPES.has(r.linkType) || !r.artist) continue;
const k = keyOf(r);
if (!groups.has(k)) groups.set(k, { rels: [], pos: /* @__PURE__ */ new Set() });
const g = groups.get(k);
g.rels.push(r);
if (r.track && r.track.position != null) g.pos.add(String(r.track.position));
}
const drop = /* @__PURE__ */ new Set(), hoisted = [];
for (const g of groups.values()) {
if (g.pos.size !== allPos.size || ![...allPos].every((p) => g.pos.has(p))) continue;
g.rels.forEach((r) => drop.add(r));
const rel = Object.assign({}, g.rels[0]);
delete rel.track;
hoisted.push(rel);
}
if (!hoisted.length) return { artistRoles, tracklistRels, hoisted };
return { artistRoles: artistRoles.concat(hoisted), tracklistRels: tracklistRels.filter((r) => !drop.has(r)), hoisted };
}
function rolesFromDiscogsArtists(artists) {
return artists?.reduce((rolesArr, artist) => {
const roles = getArtistRoles(artist);
if (Array.isArray(roles) && roles.length > 0) {
return rolesArr.concat(roles);
}
return rolesArr;
}, []) || [];
}
// src/sources/split-names.js
function splitCombinedNames(name) {
if (!name || !/[,&]/.test(name)) return [name];
const parts = name.split(/\s*,\s*|\s*&\s*/).map((s) => s.trim()).filter(Boolean);
if (parts.length < 2 || !parts.every((p) => /\s/.test(p))) return [name];
return parts;
}
// src/util.js
function assignVolumePositions(items, getNum) {
const nums = items.map((it) => String(getNum(it) || "").trim());
const multiVolume = nums.some((n, i) => n && nums.indexOf(n) < i);
let vol = 1;
const seenInVol = /* @__PURE__ */ new Set();
const positions = nums.map((num) => {
if (multiVolume && num && seenInVol.has(num)) {
vol++;
seenInVol.clear();
}
if (num) seenInVol.add(num);
return multiVolume ? `${vol}-${num}` : num;
});
return { positions, multiVolume };
}
function noPasswordManagers(el) {
if (!el) return el;
el.autocomplete = "off";
el.setAttribute("data-lpignore", "true");
el.setAttribute("data-1p-ignore", "true");
el.setAttribute("data-bwignore", "true");
el.setAttribute("data-form-type", "other");
if (el.tagName === "INPUT" && (!el.type || el.type === "text")) el.type = "search";
el.classList.add("ch-nopw");
return el;
}
function hashKey(str) {
let h = 2166136261;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return (h >>> 0).toString(16).padStart(8, "0");
}
// src/sources/tidal.js
var TIDAL_PERTRACK_BRIDGE = {
"Vocal": "Vocals",
"Background Vocal": "Backing Vocals",
"Performance Arranger": "Arranged By"
};
var TIDAL_PERTRACK_SKIP = /* @__PURE__ */ new Set(["Mastering Engineer", "Associated Performer", "Studio Personnel"]);
var TIDAL_ROLE_MAP = {
"Producer": { target: "recording", rel: "producer" },
"Remixer": { target: "recording", rel: "remixer" },
// #257 artist→recording remixer (was unrecognized → dropped)
"Mixing Engineer": { target: "recording", rel: "mix" },
"Recording Engineer": { target: "recording", rel: "recording" },
"Sound Engineer": { target: "recording", rel: "sound engineer" },
"Lead Vocalist": { target: "recording", rel: "vocal", attributes: [{ _type: "vocal", value: "lead vocals" }] },
// #257
// #266 Tidal's per-track background-vocal roles → recording vocal[background vocals]
// (same MB attribute as the release-level "Backing Vocals", see entity-map.js).
"Group Background Vocalists": { target: "recording", rel: "vocal", attributes: [{ _type: "vocal", value: "background vocals" }] },
"Background Vocalist": { target: "recording", rel: "vocal", attributes: [{ _type: "vocal", value: "background vocals" }] },
"Background Vocalists": { target: "recording", rel: "vocal", attributes: [{ _type: "vocal", value: "background vocals" }] },
"Composer": { target: "work", rel: "composer" },
"Lyricist": { target: "work", rel: "lyricist" },
"Writer": { target: "work", rel: "writer" },
"Orchestrator": { target: "work", rel: "orchestrator" },
"Music Publisher": { target: "work", rel: "publisher" },
"Publisher": { target: "work", rel: "publisher" }
// Not mapped (reported, not imported): Mastering Engineer (artist→recording mastering
// is deprecated in MB — it's release-level), Sound Editor, the Assistant * Engineer
// variants (need an MB "assistant" attribute), and Studio Personnel (too generic).
};
var TIDAL_COPYRIGHT_CONTROL_ID = "15780";
var isCopyrightControl = (n) => n?.tidalId === TIDAL_COPYRIGHT_CONTROL_ID || /^copyright control$/i.test(n?.name || "");
var TIDAL_RELEASE_ROLE_MAP = {
"Mixing": "Mixed By",
"Mixing Engineer": "Mixed By",
"Recording": "Recorded By",
"Recording Engineer": "Recorded By",
"Sound Engineer": "sound engineer",
"Mastering": "Mastered By",
"Mastering Engineer": "Mastered By",
"Vocals (Background)": "Backing Vocals",
"Background Vocals": "Backing Vocals",
"Composer": "Composed By",
"Lyricist": "Lyrics By",
"Writer": "Written-By",
"Orchestrator": "Orchestrated By",
// #325 instrument-name variants Tidal uses that the INSTRUMENTS table doesn't
// match verbatim (plurals / qualified forms) → canonical Discogs instrument names.
"Guitars": "Guitar",
"Bass Instrument": "Bass",
"Bass Guitars": "Bass Guitar",
"Sax (Alto)": "Alto Saxophone",
"Sax (Tenor)": "Tenor Saxophone",
"Sax (Baritone)": "Baritone Saxophone",
"Sax (Soprano)": "Soprano Saxophone",
"Keyboard": "Keyboards"
};
var TIDAL_RELEASE_COMPANY_MAP = {
"Current Distributor": "Distributed By",
"Distributor": "Distributed By"
};
var TIDAL_RELEASE_ROLE_SKIP = /* @__PURE__ */ new Set([
"Primary Artist",
"Featured Artist",
"Main Artist",
"Artist",
"Record Label",
"Manufacturer",
"Copyright",
"Phonographic Copyright"
]);
var TIDAL_ALBUM_RE = /^(?:https?:)?\/\/(?:www\.|listen\.)?tidal\.com\/(?:browse\/)?album\/(\d+)/i;
function parseTidalAlbumUrl(url) {
const m = TIDAL_ALBUM_RE.exec(url || "");
if (!m) return null;
return { id: m[1], creditsUrl: `https://tidal.com/album/${m[1]}/credits` };
}
function extractTidalCreditsDom(doc) {
const out = [];
for (const item of doc.querySelectorAll('[data-test="album-info-item"]')) {
const num = item.querySelector('[class*="_trackNumber"]')?.textContent?.trim() || "";
const titleEl = item.querySelector('[class*="_title_"]');
const credits = [];
for (const cell of item.querySelectorAll('[class*="_creditsCell"]')) {
const role = cell.querySelector("[data-uppercase]")?.textContent?.trim();
if (!role) continue;
const names = [...cell.querySelectorAll('[data-test="grid-item-detail-text-title-artist"]')].map((el) => ({
name: el.getAttribute("title") || el.textContent.trim(),
tidalId: (el.getAttribute("href") || "").match(/\/artist\/(\d+)/)?.[1] || null
})).filter((n) => n.name);
credits.push({ role, names });
}
out.push({
num,
title: titleEl?.getAttribute("title") || titleEl?.textContent?.trim() || "",
tidalTrackId: titleEl?.getAttribute("data-test-id") || null,
credits
});
}
return out;
}
function extractTidalReleaseCreditsDom(doc) {
const out = [];
for (const cell of doc.querySelectorAll('[class*="_creditsCell"]')) {
if (cell.closest('[data-test="album-info-item"]')) continue;
const role = cell.querySelector("[data-uppercase]")?.textContent?.trim();
if (!role) continue;
const names = [];
const seen = /* @__PURE__ */ new Set();
const textBox = cell.querySelector('[class*="_creditsCellText"]') || cell;
for (const el of textBox.querySelectorAll("a, [title]")) {
const name = (el.getAttribute("title") || el.textContent || "").trim();
if (!name || seen.has(name)) continue;
seen.add(name);
names.push({ name, tidalId: (el.getAttribute("href") || "").match(/\/artist\/(\d+)/)?.[1] || null });
}
if (!names.length) {
(textBox.textContent || "").split(/\s*,\s*/).map((s) => s.trim()).filter(Boolean).forEach((name) => {
if (!seen.has(name)) {
seen.add(name);
names.push({ name, tidalId: null });
}
});
}
if (names.length) out.push({ role, names });
}
return out;
}
var ASSISTANT_RE = /^Assistant\s+(.+)$/i;
function tidalRoleBase(role) {
const m = ASSISTANT_RE.exec(role || "");
return m ? m[1] : role;
}
function filterTidalCredits(tracks) {
return tracks.map((t) => ({
...t,
credits: t.credits.map((c) => ({
...c,
names: c.names.filter((n) => !(/^(?:Music )?Publisher$/.test(c.role) && isCopyrightControl(n)))
})).filter((c) => c.names.length)
}));
}
var TIDAL_ARTIST_RE = /^(?:https?:)?\/\/(?:www\.|listen\.)?tidal\.com\/(?:browse\/)?artist\/(\d+)/i;
function parseTidalArtistUrl(url) {
const m = TIDAL_ARTIST_RE.exec(url || "");
if (!m) return null;
return { id: m[1], key: `tidal-artist/${m[1]}`, cleanUrl: `https://tidal.com/artist/${m[1]}` };
}
function tidalPublisherRole(name, track) {
const role = {
linkType: "publishing",
entityType: "label",
attributes: [],
artist: { name, anv: "", entityType: "label", resource_url: "https://tidal.com/_publisher/" + encodeURIComponent(name) }
};
if (track) role.track = track;
return role;
}
function tidalCompany(name, entityTypeName) {
return {
entity_type_name: entityTypeName,
name,
resource_url: "https://tidal.com/_company/" + encodeURIComponent(entityTypeName) + "/" + encodeURIComponent(name)
};
}
function tidalToEngine(tracks) {
const tracklistRels = [];
const tracklist = [];
const skipped = [];
const filtered = filterTidalCredits(tracks);
const { positions, multiVolume } = assignVolumePositions(filtered, (t) => t.num);
filtered.forEach((t, i) => {
const track = { position: positions[i], title: t.title || "", type_: "track" };
tracklist.push(track);
for (const c of t.credits) {
const base = tidalRoleBase(c.role);
const assistant = base !== c.role;
if (base === "Music Publisher" || base === "Publisher") {
for (const n of c.names) {
if (isCopyrightControl(n)) continue;
tracklistRels.push(tidalPublisherRole(n.name, track));
}
continue;
}
const names = c.names.flatMap((n) => {
const parts = splitCombinedNames(n.name);
return parts.length > 1 ? parts.map((nm) => ({ name: nm, tidalId: null })) : [n];
});
const mapping = TIDAL_ROLE_MAP[base];
if (mapping) {
for (const n of names) {
tracklistRels.push({
linkType: mapping.rel,
entityType: "artist",
attributes: [...mapping.attributes || [], ...assistant ? ["assistant"] : []],
artist: {
id: n.tidalId ? `tidal-${n.tidalId}` : void 0,
name: n.name,
anv: "",
resource_url: n.tidalId ? `https://tidal.com/artist/${n.tidalId}` : ""
},
track
});
}
continue;
}
if (TIDAL_PERTRACK_SKIP.has(base)) {
for (const n of names) skipped.push(`track ${track.position} "${t.title}": ${c.role} \u2014 ${n.name}`);
continue;
}
const discogsRole = TIDAL_PERTRACK_BRIDGE[base] || base;
for (const n of names) {
const artist = {
id: n.tidalId ? `tidal-${n.tidalId}` : void 0,
name: n.name,
anv: "",
role: discogsRole,
resource_url: n.tidalId ? `https://tidal.com/artist/${n.tidalId}` : ""
};
const rels = getArtistRoles(artist);
if (!rels.length) {
skipped.push(`track ${track.position} "${t.title}": ${c.role} \u2014 ${n.name}`);
continue;
}
for (const r of rels) {
tracklistRels.push({
linkType: r.linkType,
entityType: "artist",
attributes: [...r.attributes || [], ...assistant ? ["assistant"] : []],
artist: r.artist,
track
});
}
}
}
});
return { tracklistRels, tracklist, skipped, multiVolume };
}
function tidalReleaseArtists(releaseCredits) {
const artists = [];
const publishers = [];
const companies = [];
const skipped = [];
for (const c of releaseCredits || []) {
const baseRole = tidalRoleBase(c.role);
if (baseRole === "Music Publisher" || baseRole === "Publisher") {
for (const n of c.names || []) {
if (isCopyrightControl(n)) continue;
publishers.push(tidalPublisherRole(n.name));
}
continue;
}
if (TIDAL_RELEASE_COMPANY_MAP[c.role]) {
for (const n of c.names || []) companies.push(tidalCompany(n.name, TIDAL_RELEASE_COMPANY_MAP[c.role]));
continue;
}
if (TIDAL_RELEASE_ROLE_SKIP.has(c.role)) {
(c.names || []).forEach((n) => skipped.push(`release: ${c.role} \u2014 ${n.name}`));
continue;
}
const base = tidalRoleBase(c.role);
const assistant = base !== c.role;
const discogsRole = TIDAL_RELEASE_ROLE_MAP[base] || base;
const relNames = (c.names || []).flatMap((n) => {
const parts = splitCombinedNames(n.name);
return parts.length > 1 ? parts.map((nm) => ({ name: nm, tidalId: null })) : [n];
});
for (const n of relNames) {
artists.push({
id: n.tidalId ? `tidal-${n.tidalId}` : void 0,
name: n.name,
anv: "",
role: discogsRole,
assistant,
tidalRole: c.role,
// for "not imported" reporting at the call site
resource_url: n.tidalId ? `https://tidal.com/artist/${n.tidalId}` : ""
});
}
}
return { artists, publishers, companies, skipped };
}
var HARVEST_KEY = (reqId) => `ch-tidal-result:${reqId}`;
var HARVEST_TIMEOUT_MS = 45e3;
function runTidalHarvestPage() {
const m = location.hash.match(/ch-req=([a-z0-9.-]+)/i);
if (!m) return;
const reqId = m[1];
const albumId = (location.pathname.match(/\/album\/(\d+)/) || [])[1] || null;
const post = (payload) => {
try {
GM_setValue(HARVEST_KEY(reqId), { albumId, ts: Date.now(), ...payload });
} catch (e) {
}
};
const started = Date.now();
let lastCount = -1, stableSince = 0;
const timer = setInterval(() => {
const items = document.querySelectorAll('[data-test="album-info-item"]');
if (items.length > 0) {
if (items.length !== lastCount) {
lastCount = items.length;
stableSince = Date.now();
} else if (Date.now() - stableSince > 1200) {
clearInterval(timer);
const tracks = extractTidalCreditsDom(document);
harvestReleaseThenPost(tracks, post);
return;
}
}
if (Date.now() - started > HARVEST_TIMEOUT_MS - 5e3) {
clearInterval(timer);
post({ ok: false, error: lastCount > 0 ? "render never stabilised" : "credits never rendered (login wall? geo block?)" });
setTimeout(() => window.close(), 250);
}
}, 300);
}
function harvestReleaseThenPost(tracks, post) {
const finish = (releaseCredits) => {
post({ ok: true, tracks, releaseCredits });
setTimeout(() => window.close(), 250);
};
const infoTab = document.querySelector('[data-test="album-info-tab-info"]');
if (!infoTab) {
finish([]);
return;
}
try {
infoTab.click();
} catch (e) {
finish([]);
return;
}
const start = Date.now();
const t = setInterval(() => {
const cells = [...document.querySelectorAll('[class*="_creditsCell"]')].filter((c) => !c.closest('[data-test="album-info-item"]'));
if (cells.length > 0) {
clearInterval(t);
setTimeout(() => finish(extractTidalReleaseCreditsDom(document)), 600);
} else if (Date.now() - start > 6e3) {
clearInterval(t);
finish([]);
}
}, 250);
}
function harvestTidalAlbum(albumUrl) {
const parsed = parseTidalAlbumUrl(albumUrl);
if (!parsed) return Promise.reject(new Error(`Not a Tidal album URL: ${albumUrl}`));
const reqId = `${parsed.id}.${Date.now().toString(36)}`;
const key = HARVEST_KEY(reqId);
const harvestUrl = `${parsed.creditsUrl}#ch-req=${reqId}`;
if (typeof GM_openInTab === "function") {
GM_openInTab(harvestUrl, { active: false, insert: true, setParent: true });
} else {
const tab = window.open(harvestUrl, "_blank");
if (!tab) return Promise.reject(new Error("Popup blocked \u2014 allow popups for musicbrainz.org and retry"));
}
return new Promise((resolve, reject) => {
let listenerId = null;
let pollTimer = null;
const done = (fn, arg) => {
if (pollTimer) clearInterval(pollTimer);
clearTimeout(deadline);
try {
if (listenerId !== null && typeof GM_removeValueChangeListener === "function") GM_removeValueChangeListener(listenerId);
} catch (e) {
}
try {
GM_deleteValue(key);
} catch (e) {
}
fn(arg);
};
const check = (value) => {
if (value && typeof value === "object") done(resolve, value);
};
if (typeof GM_addValueChangeListener === "function") {
listenerId = GM_addValueChangeListener(key, (_n, _o, value) => check(value));
}
pollTimer = setInterval(() => {
try {
check(GM_getValue(key));
} catch (e) {
}
}, 700);
const deadline = setTimeout(() => done(reject, new Error("Tidal harvest timed out \u2014 is the credits tab open and loading?")), HARVEST_TIMEOUT_MS);
});
}
// src/sources/qobuz.js
var QOBUZ_ROLE_MAP = {
"Composer": { target: "work", rel: "composer" },
"Lyricist": { target: "work", rel: "lyricist" },
"Author": { target: "work", rel: "lyricist" },
"ComposerLyricist": { target: "work", rel: "writer" },
"Writer": { target: "work", rel: "writer" },
"Arranger": { target: "work", rel: "arranger" },
"Performance Arranger": { target: "recording", rel: "arranger" },
"Producer": { target: "recording", rel: "producer" },
"Co-Producer": { target: "recording", rel: "producer" },
"Assistant Producer": { target: "recording", rel: "producer", attributes: ["assistant"] },
"Mixer": { target: "recording", rel: "mix" },
"MixingEngineer": { target: "recording", rel: "mix" },
"Mixing Engineer": { target: "recording", rel: "mix" },
"Engineer": { target: "recording", rel: "engineer" },
"Assistant Engineer": { target: "recording", rel: "engineer", attributes: ["assistant"] },
"RecordingEngineer": { target: "recording", rel: "recording" },
"Recording Engineer": { target: "recording", rel: "recording" },
"MasteringEngineer": { target: "release", rel: "mastering" },
"Mastering Engineer": { target: "release", rel: "mastering" },
"Editor": { target: "recording", rel: "editor" },
"Remixer": { target: "recording", rel: "remixer" },
"Conductor": { target: "recording", rel: "conductor" },
"Vocals": { target: "recording", rel: "vocal" },
"Vocal": { target: "recording", rel: "vocal" },
"Background Vocal": { target: "recording", rel: "vocal", attributes: [{ _type: "vocal", value: "background vocals" }] },
"Background Vocals": { target: "recording", rel: "vocal", attributes: [{ _type: "vocal", value: "background vocals" }] },
"MusicPublisher": { target: "work", rel: "publisher" },
"Music Publisher": { target: "work", rel: "publisher" },
"MainArtist": null,
"Main Artist": null,
"FeaturedArtist": null,
"Featured Artist": null,
"AssociatedPerformer": null,
"Associated Performer": null,
"StudioPersonnel": null,
"Studio Personnel": null
};
var QOBUZ_INSTRUMENTS_CI = new Set(Object.keys(INSTRUMENTS).map((k) => k.toLowerCase()));
function isQobuzRole(token) {
return Object.prototype.hasOwnProperty.call(QOBUZ_ROLE_MAP, token) || QOBUZ_INSTRUMENTS_CI.has(token.toLowerCase());
}
var QOBUZ_ALBUM_RE = /^(?:https?:)?\/\/(?:www\.|play\.|open\.)?qobuz\.com\/(?:[a-z]{2}-[a-z]{2}\/)?album\/(?:[^/]+\/)?([a-z0-9]+)\/?(?:[?#]|$)/i;
function parseQobuzAlbumUrl(url) {
const m = QOBUZ_ALBUM_RE.exec(url || "");
if (!m) return null;
const original = String(url).replace(/^\/\//, "https://");
const isStore = /^https?:\/\/(www\.)?qobuz\.com\/[a-z]{2}-[a-z]{2}\/album\//i.test(original);
return { id: m[1], pageUrl: isStore ? original.split(/[?#]/)[0] : `https://www.qobuz.com/us-en/album/x/${m[1]}` };
}
function decodeEntities(s) {
return String(s).replace(/(\d+);/g, (_, n) => String.fromCodePoint(+n)).replace(/([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16))).replace(/&/g, "&").replace(/"/g, '"').replace(/</g, "<").replace(/>/g, ">").replace(/ /g, " ");
}
var QOBUZ_NOTICE_RE = /^\s*[(℗©]|\bcopyright\b/i;
function parseQobuzCreditLine(line) {
const out = [];
for (const seg of String(line).split(" - ")) {
const raw = seg.trim();
if (!raw || QOBUZ_NOTICE_RE.test(raw)) continue;
const tokens = raw.split(",").map((t) => t.trim()).filter(Boolean);
const firstRole = tokens.findIndex(isQobuzRole);
if (firstRole === -1) {
out.push({ name: tokens.join(", "), roles: [] });
continue;
}
const name = tokens.slice(0, firstRole).join(", ");
const roles = tokens.slice(firstRole).filter(isQobuzRole);
if (name) out.push({ name, roles });
else if (out.length) out[out.length - 1].roles.push(...roles);
}
return out;
}
function extractQobuzCredits(html) {
const out = [];
const re = /id="popinAddToCartBtnPlayerTrack(\d+)"|]*class="[^"]*\btrack__info\b[^"]*"[^>]*>([\s\S]*?)<\/p>/gi;
let m, current = 0, capturedFor = null;
while ((m = re.exec(html)) !== null) {
if (m[1] !== void 0) {
current = parseInt(m[1], 10);
capturedFor = null;
continue;
}
const text = decodeEntities((m[2] || "").replace(/<[^>]+>/g, "").trim());
if (!text || !current || capturedFor === current) continue;
out.push({ index: current, credits: parseQobuzCreditLine(text) });
capturedFor = current;
}
return out;
}
function extractQobuzAlbumInfo(html) {
const og = html.match(/ t.index);
parsedTracks.forEach((t, i) => {
const track = { position: positions[i], title: "", type_: "track" };
tracklist.push(track);
for (const credit of t.credits) {
if (!credit.roles.length) {
if (credit.name && !/^copyright control$/i.test(credit.name)) skipped.push(`track ${track.position}: (no role) \u2014 ${credit.name}`);
continue;
}
const names = splitCombinedNames(credit.name);
for (const role of credit.roles) {
if (role === "MusicPublisher" || role === "Music Publisher") {
if (!/^copyright control$/i.test(credit.name)) skipped.push(`track ${track.position}: Music Publisher \u2014 ${credit.name}`);
continue;
}
for (const nm of names) {
const url = names.length > 1 ? "" : credit.resource_url || "";
if (Object.prototype.hasOwnProperty.call(QOBUZ_ROLE_MAP, role)) {
const plan = QOBUZ_ROLE_MAP[role];
if (!plan) continue;
const rel = {
linkType: plan.rel,
entityType: "artist",
attributes: [...plan.attributes || []],
artist: { name: nm, anv: "", resource_url: url }
// #353 Qobuz artist id (composer/performer) → exact link
};
if (plan.target === "release") artistRoles.push(rel);
else tracklistRels.push({ ...rel, track });
continue;
}
const rels = getArtistRoles({ name: nm, anv: "", role, resource_url: url });
if (!rels.length) {
skipped.push(`track ${track.position}: ${role} \u2014 ${nm}`);
continue;
}
for (const r of rels) {
tracklistRels.push({
linkType: r.linkType,
entityType: "artist",
attributes: r.attributes || [],
artist: r.artist,
track
});
}
}
}
}
});
return { tracklistRels, artistRoles, tracklist, skipped, multiVolume };
}
function fetchQobuzAlbumPage(pageUrl) {
return new Promise((resolve, reject) => {
if (typeof GM_xmlhttpRequest !== "function") {
reject(new Error("GM_xmlhttpRequest unavailable"));
return;
}
GM_xmlhttpRequest({
method: "GET",
url: pageUrl,
headers: { "Accept": "text/html,application/xhtml+xml", "Accept-Language": "en-US,en;q=0.8" },
timeout: 2e4,
onload: (r) => r.status >= 200 && r.status < 400 && r.responseText ? resolve(r.responseText) : reject(new Error(`Qobuz page returned ${r.status}`)),
onerror: () => reject(new Error("Qobuz page fetch failed (network)")),
ontimeout: () => reject(new Error("Qobuz page fetch timed out"))
});
});
}
var QOBUZ_API = "https://www.qobuz.com/api.json/0.2";
var QOBUZ_APP_ID = "712109809";
var QOBUZ_LS_KEY = "mbtools:qobuz";
function qobuzToken() {
try {
const t = JSON.parse(localStorage.getItem(QOBUZ_LS_KEY) || "null");
return t && t.token || null;
} catch (e) {
return null;
}
}
function qobuzArtistUrl(id) {
return `https://open.qobuz.com/artist/${id}`;
}
var QOBUZ_ARTIST_RE = /(?:https?:)?\/\/(?:www\.|play\.|open\.)?qobuz\.com\/(?:[a-z]{2}-[a-z]{2}\/)?(?:interpreter\/[^/]+\/|artist\/)(\d+)/i;
function parseQobuzArtistUrl(url) {
const m = QOBUZ_ARTIST_RE.exec(url || "");
return m ? { key: `qobuz-artist/${m[1]}`, cleanUrl: qobuzArtistUrl(m[1]), id: m[1] } : null;
}
function parseQobuzApiTracks(json) {
const items = json && json.tracks && json.tracks.items || [];
return items.map((t, i) => {
const credits = t.performers ? parseQobuzCreditLine(decodeEntities(t.performers)) : [];
const idByName = {};
if (t.composer && t.composer.id) idByName[t.composer.name] = t.composer.id;
if (t.performer && t.performer.id) idByName[t.performer.name] = t.performer.id;
for (const c of credits) {
const id = idByName[c.name];
if (id) c.resource_url = qobuzArtistUrl(id);
}
return { index: t.track_number || i + 1, credits };
}).filter((t) => t.credits.length);
}
function qobuzApiAlbumInfo(json) {
if (!json) return "";
return [json.title, json.artist && json.artist.name].filter(Boolean).join(", ");
}
function fetchQobuzApiAlbum(albumId, token) {
return new Promise((resolve, reject) => {
if (typeof GM_xmlhttpRequest !== "function") {
reject(new Error("GM_xmlhttpRequest unavailable"));
return;
}
GM_xmlhttpRequest({
method: "GET",
url: `${QOBUZ_API}/album/get?album_id=${encodeURIComponent(albumId)}&app_id=${QOBUZ_APP_ID}`,
headers: { "X-App-Id": QOBUZ_APP_ID, "X-User-Auth-Token": token, "Accept": "application/json" },
timeout: 2e4,
onload: (r) => {
if (r.status === 401) {
reject(new Error("Qobuz session expired \u2014 re-login in Platform Check"));
return;
}
if (r.status < 200 || r.status >= 300) {
reject(new Error(`Qobuz album/get returned ${r.status}`));
return;
}
try {
resolve(JSON.parse(r.responseText));
} catch (e) {
reject(new Error("Qobuz album/get: malformed JSON"));
}
},
onerror: () => reject(new Error("Qobuz album/get failed (network)")),
ontimeout: () => reject(new Error("Qobuz album/get timed out"))
});
});
}
// src/sources/metal_archives.js
var MA_ARTIST_RE = /^https?:\/\/(?:www\.)?metal-archives\.com\/artists\/([^/?#]+)\/(\d+)/i;
function parseMetalArchivesArtistUrl(url) {
const m = MA_ARTIST_RE.exec(url || "");
if (!m) return null;
return { id: m[2], key: `metal-archives-artist/${m[2]}`, cleanUrl: `https://www.metal-archives.com/artists/${m[1]}/${m[2]}` };
}
var MA_ALBUM_RE = /^https?:\/\/(?:www\.)?metal-archives\.com\/albums\/([^/?#]+)\/([^/?#]+)\/(\d+)/i;
function parseMetalArchivesAlbumUrl(url) {
const m = MA_ALBUM_RE.exec(url || "");
if (!m) return null;
return { id: m[3], albumUrl: `https://www.metal-archives.com/albums/${m[1]}/${m[2]}/${m[3]}` };
}
var MA_INSTRUMENT_MAP = {
// Guitars / Bass are special-cased in bridgeToken (electric by default in metal, #453).
"Drums": "Drums",
"Drum programming": "Drum Programming",
"Drum Programming": "Drum Programming",
"Keyboards": "Keyboard",
"Keyboard": "Keyboard",
"Synthesizers": "Synthesizer",
"Synthesizer": "Synthesizer",
"Synth": "Synthesizer",
"Synths": "Synthesizer",
"Piano": "Piano",
"Percussion": "Percussion",
"Classical Percussion": "Percussion",
"Bagpipes": "Bagpipe",
"Bells": "Bell",
"Round Bells": "Bell",
"Kettledrums": "Kettledrum",
"Contrabass": "Double Bass",
"Citern": "Cittern",
"Jew's Harp": "Mouth Harp",
"Oak Stick": "Rhythm Sticks",
"Western Concert Flute": "Concert Flute",
"Woodchimes": "Chimes",
"Wind instruments": "Wind Instruments",
"Saxophone (alto)": "Alto Saxophone",
"Saxophone (baritone)": "Baritone Saxophone",
"Saxophone (tenor)": "Tenor Saxophone"
// pass-through-friendly names (already in INSTRUMENTS): Accordion, Banjo, Bassoon, Bouzouki, Cello,
// Clarinet, Concertina, Cowbell, Crumhorn, Domra, Fiddle, Flute, French Horn, Harp, Harpsichord,
// Hurdy Gurdy, Mandolin, Oboe, Ocarina, Organ, Pan Flute, Saxophone, Shakuhachi, Sopilka, Strings,
// Tambourine, Timpani, Tin Whistle, Trombone, Trumpet, Viola, Violin, Xylophone, Sitar, Ebow, Samples.
};
var MA_VOCAL_SUBTYPE = {
"lead": "Lead Vocals",
"backing": "Backing Vocals",
"back": "Backing Vocals",
"additional": "Backing Vocals",
"baritone": "Baritone Vocals",
"soprano": "Soprano Vocals",
"tenor": "Tenor Vocals",
"alto": "Alto Vocals",
"choirs": "Choir Vocals",
"choir": "Choir Vocals",
"spoken": "Spoken Vocals",
"spoken word": "Spoken Vocals"
};
var MA_STAFF_MAP = {
"Songwriting": "Composed By",
"Composition": "Composed By",
"Lyrics": "Lyrics By",
"Arrangements": "Arranged By",
"Arrangement": "Arranged By",
"Recording": "Recording Engineer",
"Engineering": "Engineer",
"Mixing": "Mixed By",
"Remixing": "Remixer",
"Mastering": "Mastered By",
"Remastering": "Remastered By",
"Producer": "Producer",
"Executive Producer": "Executive-Producer",
"Co-producer": "Co-producer",
"Editing": "Edited By",
"Technician": "Instruments",
"Conductor": "Conductor",
"Choirmaster": "Chorus Master",
"Artwork": "Artwork By",
"Illustrations": "Illustration",
"Illustration": "Illustration",
"Cover Art": "Artwork By",
"Interior art": "Artwork By",
"Art Direction": "Art Direction",
"Photography": "Photography By",
"Design": "Graphic Design",
"Liner Notes": "Liner Notes",
"Director": "Director"
// design-with-a-task (#453): base rel + a `task` attribute (getArtistRoles doesn't add
// it for the graphic-design link type, so bridgeToken attaches it explicitly).
};
var MA_DESIGN_TASK = { "Layout": "layout", "Logo": "logo", "Photo manipulation": "photo manipulation" };
var MA_SKIP = /* @__PURE__ */ new Set([
"All Instruments",
"Everything",
"Unknown",
"Production assistance",
"Producer (pre-production)",
"Authoring",
"Orchestra leader",
"Menu"
]);
var MA_SPECIAL_SKIP = /* @__PURE__ */ new Set(["Ambience"]);
function splitTopLevelCommas(str) {
const out = [];
let depth = 0, cur = "";
for (const ch of String(str || "")) {
if (ch === "(") depth++;
else if (ch === ")") depth = Math.max(0, depth - 1);
if (ch === "," && depth === 0) {
out.push(cur.trim());
cur = "";
} else cur += ch;
}
if (cur.trim()) out.push(cur.trim());
return out;
}
function parseTrackGroup(inner) {
if (!/^tracks?\b/i.test(inner)) return null;
const nums = [];
inner.replace(/^tracks?\s*/i, "").split(",").forEach((part) => {
const range = part.trim().match(/^(\d+)\s*[-–]\s*(\d+)$/);
if (range) {
for (let n = +range[1]; n <= +range[2]; n++) nums.push(n);
} else {
const one = part.trim().match(/^\d+$/);
if (one) nums.push(+part.trim());
}
});
return nums.length ? nums : null;
}
function parseMaRoleCell(cell) {
return splitTopLevelCommas(cell).map((token) => {
const details = [];
let tracks = null;
const base = token.replace(/\(([^()]*)\)/g, (_, inner) => {
const tg = parseTrackGroup(inner.trim());
if (tg) tracks = (tracks || []).concat(tg);
else if (inner.trim()) details.push(inner.trim());
return "";
}).replace(/\s+/g, " ").trim();
return { base, details, tracks };
}).filter((t) => t.base);
}
function bridgeToken(tok) {
const base = tok.base;
if (MA_SKIP.has(base) || MA_SPECIAL_SKIP.has(base)) return null;
const detail = tok.details.map((d) => d.toLowerCase()).join(" ");
if (/^guitars?$/i.test(base)) {
const role = /\belectric\b/.test(detail) ? "Electric Guitar" : /\bacoustic\b/.test(detail) ? "Acoustic Guitar" : /\b(classical|nylon)\b/.test(detail) ? "Classical Guitar" : "Electric Guitar";
return { role, attrs: [] };
}
if (/^bass(\s*guitar)?$/i.test(base)) {
const role = /\bacoustic\b/.test(detail) ? "Acoustic Bass" : /\b(double|upright|contrabass)\b/.test(detail) ? "Double Bass" : /\bfretless\b/.test(detail) ? "Fretless Bass" : "Electric Bass Guitar";
return { role, attrs: [] };
}
if (/^vocals?$/i.test(base) || /^voice$/i.test(base) || /^narration$/i.test(base)) {
if (/^narration$/i.test(base)) return { role: "Spoken Vocals", attrs: [] };
const sub = tok.details.map((d) => d.toLowerCase()).find((d) => MA_VOCAL_SUBTYPE[d]);
return { role: sub ? MA_VOCAL_SUBTYPE[sub] : "Vocals", attrs: [] };
}
if (MA_DESIGN_TASK[base]) return { role: "Graphic Design", attrs: [{ _type: "task", value: MA_DESIGN_TASK[base] }] };
return { role: MA_INSTRUMENT_MAP[base] || MA_STAFF_MAP[base] || base, attrs: [] };
}
function extractLineupTable(doc, id) {
const table = doc.getElementById(id);
const rows = [];
if (!table) return rows;
let band = null;
for (const tr of table.querySelectorAll("tr")) {
const a = tr.querySelector('a[href*="/artists/"]');
const tds = [...tr.querySelectorAll("td")];
if (!a) {
const label = tds.map((td) => td.textContent.trim()).join(" ").replace(/^\[|\]$/g, "").trim();
if (label) band = label;
continue;
}
const url = a.href;
const nameText = a.textContent.trim();
const roleTd = tds.find((td) => !td.querySelector('a[href*="/artists/"]'));
const roleCell = roleTd ? roleTd.textContent.trim().replace(/\s+/g, " ") : "";
rows.push({ name: nameText, url, roleCell, band });
}
return rows;
}
function extractTracklist(doc) {
const table = doc.querySelector("#album_tabs_tracklist table.table_lyrics, .album_tabs_tracklist table, table.table_lyrics");
const tracks = [];
let disc = 0;
if (!table) return { tracks, multiDisc: false };
const multiDisc = /\b(?:Disc|CD)\s*\d/i.test(table.textContent);
for (const tr of table.querySelectorAll("tr")) {
const tds = [...tr.querySelectorAll("td")];
if (/^(disc|cd|side)\s*\w+/i.test(tr.textContent.trim()) && !tr.querySelector("td.wrapWords")) {
disc++;
continue;
}
const m = tds[0] && tds[0].textContent.trim().match(/^(\d+)\.?$/);
if (!m) continue;
const n = +m[1];
const titleCell = tr.querySelector("td.wrapWords") || tds[1];
const title = titleCell ? titleCell.textContent.trim().replace(/\s+/g, " ") : "";
tracks.push({ position: multiDisc ? `${disc || 1}-${n}` : String(n), title, type_: "track" });
}
return { tracks, multiDisc };
}
function extractMaLineupDom(doc) {
const typeEl = [...doc.querySelectorAll("#album_info dt, dl.float_left dt")].find((d) => /^type\b/i.test(d.textContent.trim()));
const type = typeEl && typeEl.nextElementSibling ? typeEl.nextElementSibling.textContent.trim() : "";
const band = extractLineupTable(doc, "album_members_lineup");
const guest = extractLineupTable(doc, "album_members_guest");
const misc = extractLineupTable(doc, "album_members_misc");
const { tracks, multiDisc } = extractTracklist(doc);
const multiBand = /split|collaboration/i.test(type) || [...band, ...guest].some((r) => r.band);
if (/split/i.test(type)) {
const bandNames = [...new Set([...band, ...guest, ...misc].map((r) => r.band).filter(Boolean))];
for (const t of tracks) {
const b = bandNames.find((bn) => new RegExp("^" + bn.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "\\s*[-\u2013]\\s*", "i").test(t.title));
if (b) {
t.band = b;
t.title = t.title.replace(new RegExp("^" + b.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "\\s*[-\u2013]\\s*", "i"), "");
}
}
}
return { type, multiBand, multiDisc, tracks, band, guest, misc };
}
var HARVEST_KEY2 = (reqId) => `ch-ma-result:${reqId}`;
var HARVEST_TIMEOUT_MS2 = 45e3;
function runMetalArchivesHarvestPage() {
const m = location.hash.match(/ch-req=([a-z0-9.-]+)/i);
if (!m) return;
const reqId = m[1];
const albumId = (location.pathname.match(/\/albums\/[^/]+\/[^/]+\/(\d+)/) || [])[1] || null;
const post = (payload) => {
try {
GM_setValue(HARVEST_KEY2(reqId), { albumId, ts: Date.now(), ...payload });
} catch (e) {
}
};
const started = Date.now();
const timer = setInterval(() => {
const challenged = /just a moment|attention required|checking your browser/i.test(document.title);
const ready = document.querySelector("#album_info, .album_name, #album_tabs_tracklist, table.table_lyrics");
if (ready && !challenged) {
clearInterval(timer);
try {
post({ ok: true, ...extractMaLineupDom(document) });
} catch (e) {
post({ ok: false, error: "extract failed: " + (e && e.message) });
}
setTimeout(() => window.close(), 250);
return;
}
if (Date.now() - started > HARVEST_TIMEOUT_MS2 - 3e3) {
clearInterval(timer);
post({ ok: false, error: challenged ? "Cloudflare challenge did not clear in the tab" : "album page never rendered" });
setTimeout(() => window.close(), 250);
}
}, 300);
}
function harvestMetalArchivesAlbum(albumUrl) {
const parsed = parseMetalArchivesAlbumUrl(albumUrl);
if (!parsed) return Promise.reject(new Error(`Not a Metal Archives album URL: ${albumUrl}`));
const reqId = `${parsed.id}.${Date.now().toString(36)}`;
const key = HARVEST_KEY2(reqId);
const harvestUrl = `${parsed.albumUrl}#ch-req=${reqId}`;
if (typeof GM_openInTab === "function") {
GM_openInTab(harvestUrl, { active: false, insert: true, setParent: true });
} else {
const tab = window.open(harvestUrl, "_blank");
if (!tab) return Promise.reject(new Error("Popup blocked \u2014 allow popups for musicbrainz.org and retry"));
}
return new Promise((resolve, reject) => {
let listenerId = null, pollTimer = null;
const done = (fn, arg) => {
if (pollTimer) clearInterval(pollTimer);
clearTimeout(deadline);
try {
if (listenerId !== null && typeof GM_removeValueChangeListener === "function") GM_removeValueChangeListener(listenerId);
} catch (e) {
}
try {
GM_deleteValue(key);
} catch (e) {
}
fn(arg);
};
const check = (value) => {
if (value && typeof value === "object") done(resolve, value);
};
if (typeof GM_addValueChangeListener === "function") listenerId = GM_addValueChangeListener(key, (_n, _o, value) => check(value));
pollTimer = setInterval(() => {
try {
check(GM_getValue(key));
} catch (e) {
}
}, 700);
const deadline = setTimeout(() => done(reject, new Error("Metal Archives harvest timed out \u2014 is the album tab open and loading?")), HARVEST_TIMEOUT_MS2);
});
}
function maArtist(row, discogsRole) {
const parsed = parseMetalArchivesArtistUrl(row.url);
return {
id: parsed ? `metal-archives-${parsed.id}` : void 0,
name: row.name,
anv: "",
role: discogsRole,
resource_url: parsed ? parsed.cleanUrl : ""
};
}
function rowToTrackRels(row, allTracks, byPosition, guest, skipped, sectionLabel, onlyQualified) {
const rels = [];
for (const tok of parseMaRoleCell(row.roleCell)) {
if (onlyQualified && !tok.tracks) continue;
const bridged = bridgeToken(tok);
if (!bridged) {
skipped.push(`${sectionLabel}: ${tok.base} \u2014 ${row.name}`);
continue;
}
let resolved = getArtistRoles(maArtist(row, bridged.role));
if (!resolved.length && /s$/i.test(bridged.role)) resolved = getArtistRoles(maArtist(row, bridged.role.replace(/s$/i, "")));
if (!resolved.length) {
skipped.push(`${sectionLabel}: ${tok.base} \u2014 ${row.name}`);
continue;
}
const targets = tok.tracks ? tok.tracks.map((n) => byPosition.get(String(n))).filter(Boolean) : allTracks;
for (const track of targets) {
for (const r of resolved) {
rels.push({
linkType: r.linkType,
entityType: "artist",
attributes: [...r.attributes || [], ...bridged.attrs, ...guest ? ["guest"] : []],
artist: r.artist,
track
});
}
}
}
return rels;
}
function metalArchivesToEngine(harvest) {
const tracklist = (harvest.tracks || []).map((t) => ({ position: t.position, title: t.title, type_: "track", band: t.band }));
const byPosition = new Map(tracklist.map((t) => [String(t.position).replace(/^\d+-/, ""), t]));
tracklist.forEach((t) => byPosition.set(String(t.position), t));
const skipped = [];
const tracklistRels = [];
const isSplit = /split/i.test(harvest.type || "");
for (const [rows, guest, label] of [[harvest.band || [], false, "band"], [harvest.guest || [], true, "guest"]]) {
for (const row of rows) {
let scope = tracklist;
if (isSplit && row.band) {
scope = tracklist.filter((t) => t.band === row.band);
if (!scope.length) {
skipped.push(`${label} (split \u2014 no tracks matched band "${row.band}"): ${row.roleCell} \u2014 ${row.name}`);
continue;
}
}
tracklistRels.push(...rowToTrackRels(row, scope, byPosition, guest, skipped, label));
}
}
for (const row of harvest.misc || []) {
tracklistRels.push(...rowToTrackRels(row, tracklist, byPosition, false, skipped, "misc", true));
}
return { tracklistRels, tracklist, skipped, multiVolume: !!harvest.multiDisc };
}
function metalArchivesReleaseArtists(harvest) {
const artists = [], skipped = [];
for (const row of harvest.misc || []) {
for (const tok of parseMaRoleCell(row.roleCell)) {
if (tok.tracks) continue;
const bridged = bridgeToken(tok);
if (!bridged) {
skipped.push(`release: ${tok.base} \u2014 ${row.name}`);
continue;
}
const a = maArtist(row, bridged.role);
a.maRole = tok.base;
a.maAttrs = bridged.attrs;
artists.push(a);
}
}
return { artists, publishers: [], companies: [], skipped };
}
// src/sources/registry.js
function parseSourceEntityUrl(url) {
if (!url) return null;
return parseDiscogsUrl(url) || parseTidalArtistUrl(url) || parseQobuzArtistUrl(url) || parseMetalArchivesArtistUrl(url);
}
function idbKeyForEntity(entity) {
if (!entity) return null;
return parseSourceEntityUrl(entity.resource_url)?.key || entity._cacheKey || null;
}
function sourceNameForUrl(url) {
if (/tidal\.com\//i.test(url || "")) return "Tidal";
if (/qobuz\.com\//i.test(url || "")) return "Qobuz";
if (/deezer\.com\//i.test(url || "")) return "Deezer";
if (/(?:music|itunes)\.apple\.com\//i.test(url || "")) return "Apple";
if (/metal-archives\.com\//i.test(url || "")) return "Metal Archives";
if (/music\.youtube\.com\/|youtube\.com\/playlist\?(?:[^#]*&)?list=OLAK5uy_/i.test(url || "")) return "YouTube Music";
return "Discogs";
}
var isSyntheticProviderUrl = (url) => /tidal\.com\/_(?:publisher|company)\//i.test(String(url || ""));
function sourceUrlLinkTypeId(url, entityType) {
if (isSyntheticProviderUrl(url)) return null;
const src = sourceNameForUrl(url);
if (src === "Tidal") return entityType === "artist" ? "978" : null;
if (src === "Qobuz") return entityType === "artist" ? "978" : null;
if (src === "Metal Archives") return entityType === "artist" ? "188" : null;
return entityType === "label" ? "217" : entityType === "place" ? "705" : "180";
}
// src/preflight.js
var KIND_TABLE = {
artist: { searchLimit: 100, resultKey: "artists", incRels: "artist-rels" },
label: { searchLimit: 8, resultKey: "labels", incRels: "label-rels" },
// Places also accept label-rels because MB editors often file a
// facility as a label rather than a place (issue we've worked around
// since the original company resolver).
place: { searchLimit: 8, resultKey: "places", incRels: "place-rels+label-rels" }
};
var toCandidate = (a) => ({
id: a.id,
name: a.name,
disambiguation: a.disambiguation || a["disambiguation-comment"] || "",
score: a.score || 0,
aliases: (a.aliases || []).map((al) => al && al.name).filter(Boolean)
});
function contextHit(context, name, candidates) {
if (!context || !context.related || !context.related.length) return null;
const holders = mbmContextHolders(context.related, name, (candidates || []).map((c) => ({ id: c.id, name: c.name, aliases: (c.aliases || []).map((n) => ({ name: n })) })));
if (holders.length === 1) return holders[0];
if (holders.length > 1) logDebug(`context: "${name}" is carried by ${holders.length} related artists (${holders.map((h) => h.name).join(", ")}) \u2014 left to review`);
return null;
}
async function coCreditHit(context, name) {
if (!context || !context.coCredit || !context.seeds || !context.seeds.length) return null;
const tally = /* @__PURE__ */ new Map();
for (const seed of context.seeds.slice(0, 4)) {
const q = `arid:${seed} AND artistname:"${String(name).replace(/["\\]/g, " ")}"`;
const json = await mbThrottle.fetchJson(`${MB}/ws/2/recording?query=${encodeURIComponent(q)}&inc=artist-credits&limit=25&fmt=json`);
if (!json) continue;
for (const h of mbmCoCreditHits(json, seed, name)) {
const t = tally.get(h.gid) || { n: 0, name: h.name };
t.n++;
tally.set(h.gid, t);
}
}
const ranked = [...tally.entries()].sort((a, b) => b[1].n - a[1].n);
if (ranked.length === 1 || ranked.length > 1 && ranked[0][1].n > ranked[1][1].n) return { gid: ranked[0][0], name: ranked[0][1].name };
if (ranked.length) logDebug(`co-credit: "${name}" \u2192 ${ranked.length} tied candidates \u2014 left to review`);
return null;
}
async function resolveEntity(entity, kind, opts) {
const { bypassIdb } = opts;
const context = kind === "artist" ? opts.context || null : null;
const { searchLimit, resultKey, incRels } = KIND_TABLE[kind];
const parsed = parseSourceEntityUrl(entity.resource_url);
const key = parsed?.key || entity._cacheKey || null;
const searchName = entity.name;
const displayName = kind === "artist" ? entity.anv && entity.anv.trim() || entity.name : entity.name;
const discogsHref = entity.resource_url.replace(/https:\/\/api\.discogs\.com\/(\w+?)s\/(\d+)/, "https://www.discogs.com/$1/$2");
function buildResolved(mbUrl, mbName, mbDisambig, via2, actualKind = kind, fromCache = false, urlLinkedIds2, creditOverride) {
return {
type: "resolved",
entityType: actualKind,
entity,
displayName,
discogsHref,
mbUrl,
mbName,
mbDisambig,
// User's saved "Credited as" override from a prior session
// (IDB `creditOverride` field). Review-table reads this in
// `pickPrefill` to populate the field. Undefined when no
// prior override exists. #105.
creditOverride,
// `urlLinkedIds` — MBIDs that have a relation to this Discogs URL,
// harvested from the URL lookup done during
// preflight. The review-table uses this to render
// the "Add Discogs link" / "already linked" / "linked
// to different MB " badge without issuing
// another `/ws/2/url?…` query per row. `undefined`
// means "preflight didn't ask MB" (IDB hit on a
// legacy record that predates this field), in which
// case review-table falls back to its own per-row
// fetch. `[]` means "asked MB, got no relations" —
// no fallback needed.
urlLinkedIds: urlLinkedIds2,
// `via` — the resolution mechanism (`name` / `url` / `both` / `user`,
// or `cache` only when a legacy IDB record predates the
// `resolvedVia` field and we genuinely can't recover it).
// `fromCache`— whether THIS resolution came from IDB rather than a fresh
// MB lookup. The two are orthogonal: a name-resolved entity
// loaded from cache is `via='name'` + `fromCache=true`, and
// the UI surfaces both as `name (cache)`.
logEntry: { displayName, discogsHref, mbUrl, mbName, mbDisambig, via: via2, fromCache }
};
}
function buildAttention(nameMatches2, nameSearchFailed2, ambiguityReason, urlLinkedIds2, creditOverride) {
return {
type: "attention",
entityType: kind,
entity,
displayName,
discogsHref,
nameMatches: nameMatches2 || [],
// Saved "Credited as" override — see buildResolved. #105.
creditOverride,
// Same `urlLinkedIds` contract as on the resolved shape — review-table
// uses it to skip the per-row URL fetch even for attention rows once
// the user picks an MBID from the candidate list.
urlLinkedIds: urlLinkedIds2,
// Only artists track this — used by the review table to badge
// entries that failed because of a rate-limited name search vs
// entries that genuinely don't exist in MB.
rateLimited: kind === "artist" && nameSearchFailed2 && !nameMatches2?.length,
ambiguityReason: ambiguityReason || null
};
}
async function fetchMbEntityInfo(et, mbid) {
const json = await mbThrottle.fetchJson(`${MB}/ws/2/${et}/${mbid}?fmt=json`);
return json ? { name: json.name || null, disambiguation: json.disambiguation || "" } : { name: null, disambiguation: "" };
}
if (bypassIdb && key) {
await deleteIdbRecord(key);
}
if (!bypassIdb && key) {
const cachedRec = await readIdbRecord(key);
if (cachedRec?.mbid && cachedRec?.entityType) {
const via2 = cachedRec.resolvedVia || "cache";
let cachedLinkedIds = cachedRec.urlLinkedIds;
if (cachedLinkedIds === void 0 && (via2 === "url" || via2 === "both" || via2 === "both-alias")) {
cachedLinkedIds = [cachedRec.mbid];
}
if (Array.isArray(cachedLinkedIds) && cachedLinkedIds.length === 0) cachedLinkedIds = void 0;
if (cachedRec.name) {
return buildResolved(
cachedRec.mbUrl,
cachedRec.name,
cachedRec.disambiguation || "",
via2,
cachedRec.entityType,
true,
cachedLinkedIds,
cachedRec.creditOverride
);
}
const info = await fetchMbEntityInfo(cachedRec.entityType, cachedRec.mbid);
if (info.name) {
await writeIdbRecord(key, {
name: info.name,
disambiguation: info.disambiguation
});
}
return buildResolved(
cachedRec.mbUrl,
info.name,
info.disambiguation,
via2,
cachedRec.entityType,
true,
cachedLinkedIds,
cachedRec.creditOverride
);
}
if (cachedRec && Array.isArray(cachedRec.nameMatches)) {
const attnLinkedIds = Array.isArray(cachedRec.urlLinkedIds) && cachedRec.urlLinkedIds.length === 0 ? void 0 : cachedRec.urlLinkedIds;
const ctx = contextHit(context, searchName, cachedRec.nameMatches);
if (ctx) {
log.info(`Match: ${displayName} \u2192 ${ctx.name} \u2014 via release context (${ctx.rel || "related"}, ${ctx.via})`);
const mbUrl = `${MB}/artist/${ctx.gid}`;
await writeIdbRecord(key, { mbid: ctx.gid, entityType: "artist", name: ctx.name, disambiguation: "", resolvedVia: "ctx", nameMatches: null, mbUrl });
return buildResolved(mbUrl, ctx.name, "", "ctx", "artist", false, attnLinkedIds, cachedRec.creditOverride);
}
return buildAttention(cachedRec.nameMatches, false, null, attnLinkedIds, cachedRec.creditOverride);
}
}
const [nameJson, urlJson] = await Promise.all([
mbThrottle.fetchJson(
`${MB}/ws/2/${kind}?query=${encodeURIComponent(searchName)}&fmt=json&limit=${searchLimit}`
),
parsed ? mbThrottle.fetchJson404(
`${MB}/ws/2/url?resource=${encodeURIComponent(parsed.cleanUrl)}&inc=${incRels}&fmt=json`
) : Promise.resolve({ notFound: true })
]);
const nameSearchFailed = nameJson === null;
const normalized = searchName.toLowerCase().trim();
const isArtist = kind === "artist";
const holdsName = (a) => isArtist ? !!mbmHolds(a, searchName) : a.name.toLowerCase().trim() === normalized;
const found = nameJson?.[resultKey] || [];
const nameMatches = [
...found.filter((a) => holdsName(a)),
...found.filter((a) => !holdsName(a) && a.score != null && a.score >= 70).slice(0, 10)
].map(toCandidate);
const exactNameMatches = nameMatches.filter(holdsName);
const nameHit = exactNameMatches.length === 1 ? {
kind,
mbid: exactNameMatches[0].id,
name: exactNameMatches[0].name,
disambiguation: exactNameMatches[0].disambiguation || "",
via: isArtist && !(exactNameMatches[0].name.toLowerCase().trim() === normalized) ? "alias" : "name"
} : null;
let urlHit = null;
const urlLinkedIds = urlJson === null ? void 0 : (urlJson.relations || []).map((r) => kind === "place" ? r.place?.id || r.label?.id || null : r[kind]?.id || null).filter(Boolean);
if (urlJson?.relations?.length > 0) {
const rel = kind === "place" ? urlJson.relations.find((r) => r.place || r.label) : urlJson.relations.find((r) => r[kind]);
if (rel) {
const actualKind = rel[kind] ? kind : rel.label ? "label" : "place";
const a = rel[actualKind];
urlHit = {
kind: actualKind,
mbid: a.id,
name: a.name || null,
disambiguation: a.disambiguation || ""
};
}
}
async function cacheAttention(matches) {
if (key && !nameSearchFailed) {
await writeIdbRecord(key, {
mbid: null,
entityType: null,
name: null,
mbUrl: null,
disambiguation: "",
resolvedVia: null,
nameMatches: matches,
// Omit when unknown (lookup failed) — never persist a guess.
...urlLinkedIds !== void 0 && { urlLinkedIds }
});
}
}
let resolved = null;
let via = null;
const heldBy = (a) => isArtist ? mbmHolds(a, displayName) || mbmHolds(a, searchName) : holdsName(a) ? "name" : null;
const urlHolder = urlHit && urlHit.kind === kind && !(nameHit && nameHit.mbid === urlHit.mbid) ? (nameJson?.[resultKey] || []).find((a) => a.id === urlHit.mbid && heldBy(a)) : null;
if (urlHolder) {
resolved = urlHit;
via = heldBy(urlHolder) === "alias" ? "both-alias" : "both";
logDebug(`Match: ${displayName} \u2192 ${urlHolder.name} \u2014 the URL's artist holds the name${via === "both-alias" ? " as an alias" : ""} (${exactNameMatches.length} exact holder(s) in all)`);
} else if (nameHit && urlHit) {
if (nameHit.mbid === urlHit.mbid && nameHit.kind === urlHit.kind) {
resolved = urlHit;
via = nameHit.via === "alias" ? "both-alias" : "both";
} else {
await cacheAttention(nameMatches);
return buildAttention(
nameMatches,
false,
`name \u2192 ${nameHit.kind}/${nameHit.mbid}, URL \u2192 ${urlHit.kind}/${urlHit.mbid}`,
urlLinkedIds
);
}
} else if (urlHit) {
resolved = urlHit;
via = "url";
} else if (isArtist) {
let reviewReason = null;
const ctx = contextHit(context, searchName, nameMatches);
if (ctx) {
resolved = { kind: "artist", mbid: ctx.gid, name: ctx.name, disambiguation: "" };
via = "ctx";
log.info(`Match: ${displayName} \u2192 ${ctx.name} \u2014 via release context (${ctx.rel || "related"}, ${ctx.via})`);
} else if (nameHit) {
const idJson = await mbThrottle.fetchJson(`${MB}/ws/2/artist?query=${encodeURIComponent(mbmIdentityQuery(searchName, "artist"))}&fmt=json&limit=${MBM_EXACT_LIMIT}`);
const idn = mbmExactIdentity(idJson, searchName);
logDebug(`exact identity "${searchName}": ${idn.status} (${idJson ? (idJson.artists || []).length + " of " + idJson.count : "no response"})`);
if (idn.status === "unique" && idn.hit.id === nameHit.mbid) {
resolved = nameHit;
via = idn.via;
} else if (idn.status === "failed") {
return buildAttention(nameMatches, true, null, urlLinkedIds);
} else {
reviewReason = idn.status === "incomplete" ? `not provably unique (${idJson.count} artists match)` : idn.status === "ambiguous" ? `${idn.exact.length} artists carry the name` : "the exact holder did not verify";
logDebug(`"${searchName}" not resolved by name \u2014 ${reviewReason}`);
}
} else if (exactNameMatches.length > 1) {
reviewReason = `${exactNameMatches.length} artists carry the name`;
logDebug(`"${searchName}" not resolved by name \u2014 ${reviewReason}`);
}
if (!resolved) {
const cc = await coCreditHit(context, searchName);
if (cc) {
resolved = { kind: "artist", mbid: cc.gid, name: cc.name, disambiguation: "" };
via = "cred";
log.info(`Match: ${displayName} \u2192 ${cc.name} \u2014 via existing artist credits (co-credit search)`);
}
}
if (!resolved && reviewReason) {
logDebug(`"${searchName}" left to review \u2014 ${reviewReason}`);
await cacheAttention(nameMatches);
return buildAttention(nameMatches, false, reviewReason, urlLinkedIds);
}
} else if (nameHit) {
resolved = nameHit;
via = "name";
}
if (resolved) {
const mbUrl = `${MB}/${resolved.kind}/${resolved.mbid}`;
let finalName = resolved.name;
let finalDisam = resolved.disambiguation;
let liveAliases = null;
if (via === "url" && resolved.kind === "artist" && isArtist) {
const live = await mbThrottle.fetchJson(`${MB}/ws/2/artist/${resolved.mbid}?inc=aliases&fmt=json`);
if (live && live.id === resolved.mbid) {
liveAliases = (live.aliases || []).map((al) => al && al.name).filter(Boolean);
if (!finalName) {
finalName = live.name || null;
finalDisam = live.disambiguation || "";
}
const held = heldBy({ name: live.name, aliases: live.aliases || [] });
if (held) via = held === "alias" ? "both-alias" : "both";
logDebug(`"${displayName}" \u2192 ${live.name}: found by URL; the artist ${held ? `holds the name as its ${held} \u2192 ${via}` : `doesn't carry the name (${liveAliases.length} alias(es))`}`);
} else logDebug(`"${displayName}": the URL's artist ${resolved.mbid} couldn't be read \u2014 stays "url"`);
}
if (!finalName) {
const info = await fetchMbEntityInfo(resolved.kind, resolved.mbid);
finalName = info.name || null;
finalDisam = info.disambiguation || "";
}
if (key) {
await writeIdbRecord(key, {
mbid: resolved.mbid,
entityType: resolved.kind,
name: finalName,
disambiguation: finalDisam || "",
resolvedVia: via,
// Omit when unknown (lookup failed) — never persist a guess.
...urlLinkedIds !== void 0 && { urlLinkedIds }
});
}
const out = buildResolved(mbUrl, finalName, finalDisam || "", via, resolved.kind, false, urlLinkedIds);
if (resolved.kind === "artist") {
const cand = nameMatches.find((c) => c.id === resolved.mbid);
const self = !cand && via === "ctx" && context && context.related ? context.related.find((x) => x.gid === resolved.mbid && x.rel === "self") : null;
if (cand && Array.isArray(cand.aliases)) out.mbAliases = cand.aliases;
else if (liveAliases) out.mbAliases = liveAliases;
else if (self && Array.isArray(self.aliases)) out.mbAliases = self.aliases;
logDebug(`"${displayName}" \u2192 ${finalName}: aliases ${out.mbAliases ? `known (${out.mbAliases.length})` : "unknown"} for the "+ alias" check`);
}
return out;
}
await cacheAttention(nameMatches);
return buildAttention(nameMatches, nameSearchFailed, null, urlLinkedIds);
}
async function resolveAll(entities, opts) {
const { kindOf, progressLi, bypassIdb, progressLabel, context } = opts;
const CONCURRENCY = 5;
const MIN_GAP_MS = 50;
let done = 0;
const inFlightNames = /* @__PURE__ */ new Set();
function setProgress() {
if (entities.length > 0) {
try {
_setProgressPct(done / entities.length * 100);
} catch (_) {
}
}
if (!progressLi) return;
const remaining = entities.length - done;
const checking = inFlightNames.size ? ` \u2014 checking ${[...inFlightNames].join(", ")}` : "";
progressLi.innerHTML = `${progressLabel}\u2026 ${done}/${entities.length} done${checking}` + (remaining === 0 ? " \u2714" : ` (${remaining} remaining)`);
try {
const plain = `${progressLabel}\u2026 ${done}/${entities.length} done` + (inFlightNames.size ? ` \u2014 checking ${[...inFlightNames].join(", ")}` : "") + (remaining === 0 ? " \u2714" : ` (${remaining} remaining)`);
document.querySelector(".discogs-bar")?._setProgress?.(null, plain);
} catch (_) {
}
}
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
const queue = entities.map((e, i) => ({ entity: e, index: i }));
const results = new Array(entities.length);
setProgress();
async function worker(slotIndex) {
const tag = `worker#${slotIndex}`;
logDebug(`${tag} starting (stagger ${slotIndex * MIN_GAP_MS}ms)`);
await delay(slotIndex * MIN_GAP_MS);
let processed = 0;
while (queue.length > 0) {
const { entity, index } = queue.shift();
const kind = kindOf(entity);
if (!kind) {
logDebug(`${tag} skip "${entity?.name || "?"}" \u2014 no resolvable kind`);
done++;
setProgress();
continue;
}
const displayName = kind === "artist" ? entity.anv && entity.anv.trim() || entity.name : entity.name;
inFlightNames.add(displayName);
setProgress();
const t0 = Date.now();
logDebug(`${tag} resolving "${displayName}" (${kind})`);
results[index] = await resolveEntity(entity, kind, { bypassIdb, context });
const elapsed = Date.now() - t0;
const r = results[index];
const outcome = r?.type === "resolved" ? `resolved via ${r.logEntry?.via || "?"}${r.logEntry?.fromCache ? " (cache)" : ""}` : r?.type === "attention" ? `unresolved (${r.nameMatches?.length || 0} candidates)` : "skipped";
logDebug(`${tag} "${displayName}" -> ${outcome} in ${elapsed}ms`);
inFlightNames.delete(displayName);
done++;
processed++;
setProgress();
}
logDebug(`${tag} finished (${processed} entit${processed === 1 ? "y" : "ies"})`);
}
const slots = Math.min(CONCURRENCY, entities.length);
logDebug(`resolveAll: ${entities.length} entit${entities.length === 1 ? "y" : "ies"}, ${slots} worker slot(s)`);
if (slots > 0) await Promise.all(Array.from({ length: slots }, (_, i) => worker(i)));
logDebug(`resolveAll: done`);
return { allResults: results.filter(Boolean) };
}
var ARTIST_KIND = () => "artist";
var ENTITY_KIND = (e) => e?.entityType || "artist";
var COMPANY_KIND = (c) => ENTITY_TYPE_MAP[c.entity_type_name]?.entityType ?? null;
// src/derive/remix.js
var STRONG = /* @__PURE__ */ new Set([
"extended",
"original",
"radio",
"instrumental",
"acapella",
"acappella",
"acoustic",
"album",
"single",
"main",
"long",
"short",
"full",
"special",
"bonus",
"alternative",
"alternate",
"vip",
"rmx",
"redux",
"dancefloor",
"unplugged",
"demo",
"remastered",
// remix-family words, in case a lead carries a second one
// ("Extended Remix Edit"): strip them off the trailing edge too.
"dub",
"edit",
"mix",
"remix",
"rework",
"remodel",
"reshuffle",
"reprise",
"version",
"re-edit",
"reedit"
]);
var WEAK = /* @__PURE__ */ new Set([
"club",
"deep",
"tech",
"soulful",
"disco",
"electro",
"house",
"techno",
"progressive",
"tribal",
"vocal",
"classic",
"clean",
"dirty",
"censored",
"uncensored",
"studio",
"live",
"the",
"a",
"an"
]);
var SEP_RE = /\s*(?:&|\+|,|\/|\bfeat\.?\b|\bft\.?\b|\bfeaturing\b)\s*/i;
var isVinyl = (t) => /^\d+(?:"|''|”|inch|in)?$/.test(t);
var norm = (t) => t.toLowerCase().replace(/[.''`]+$/, "");
var isStrong = (t) => {
const l = norm(t);
return STRONG.has(l) || isVinyl(l);
};
var isDecorator = (t) => {
const l = norm(t);
return STRONG.has(l) || WEAK.has(l) || isVinyl(l);
};
var TRAILING_RE = /^(.+?)\s+((?:re[-_ ]?)?(?:remix|rework|remodel|reshuffle|reprise|edit|dub|mix))(?:es|ed|s|d)?$/i;
var BY_RE = /^(?:re[-_ ]?)?(?:remix|rework|remodel|reshuffle|rerub)(?:es|ed|s|d)?\s+by\s+(.+)$/i;
function cleanName(raw) {
let s = String(raw || "").replace(/\s*[([].*$/, "").replace(/[)\]]+\s*$/, "");
let tokens = s.trim().split(/\s+/).filter(Boolean);
const pIdx = tokens.findIndex((t) => /['']s$/i.test(t));
if (pIdx !== -1) {
tokens = tokens.slice(0, pIdx + 1);
tokens[pIdx] = tokens[pIdx].replace(/['']s$/i, "");
}
while (tokens.length && isStrong(tokens[tokens.length - 1])) tokens.pop();
if (!tokens.length) return null;
if (tokens.every(isDecorator)) return null;
const name = tokens.join(" ").replace(/['']s$/i, "").trim();
if (!/[A-Za-z0-9]/.test(name)) return null;
return name;
}
function parseRemixTitle(title) {
const result = { base: title || "", remixers: [], kind: null };
if (!title || typeof title !== "string") return result;
const groups = title.match(/[([][^)\]]*[)\]]/g);
if (!groups) return result;
for (const g of groups) {
const inner = g.slice(1, -1).trim();
let captured = null, kind = null;
let m = BY_RE.exec(inner);
if (m) {
captured = m[1];
kind = "remix";
} else {
m = TRAILING_RE.exec(inner);
if (m) {
captured = m[1];
kind = /mix$/i.test(m[2]) && !/remix$/i.test(m[2]) ? "mix" : /edit$/i.test(m[2]) ? "edit" : /dub$/i.test(m[2]) ? "dub" : "remix";
}
}
if (!captured) continue;
const names = captured.split(SEP_RE).map(cleanName).filter(Boolean);
if (!names.length) continue;
result.kind = result.kind || kind;
for (const n of names) if (!result.remixers.includes(n)) result.remixers.push(n);
result.base = result.base.replace(g, "").replace(/\s{2,}/g, " ").trim();
}
return result;
}
function deriveRemixRoles(tracklist, releaseMbid) {
if (!Array.isArray(tracklist)) return [];
const roles = [];
for (const track of tracklist) {
if (!track || track.type_ && track.type_ !== "track") continue;
if (!track.title) continue;
const { remixers } = parseRemixTitle(track.title);
for (const name of remixers) {
const artist = { name, anv: "", resource_url: "", _derived: true };
if (releaseMbid) artist._cacheKey = `titles-remix/${releaseMbid}/${name.toLowerCase().trim()}`;
roles.push({
linkType: "remixer",
artist,
track,
creditedAs: name,
attributes: [],
entityType: "artist"
});
}
}
return roles;
}
// src/data/special-purpose.js
var SPECIAL_PURPOSE_ARTISTS = /* @__PURE__ */ new Set([
"125ec42a-7229-4250-afc5-e057484327fe",
// [unknown]
"f731ccc4-e22a-43af-a747-64213329e088",
// [anonymous]
"33cf029c-63b0-41a0-9855-be2a3665fb3b",
// [data]
"314e1c25-dde7-4e4d-b2f4-0a7b9f7c56dc",
// [dialogue]
"eec63d3c-3b81-4ad4-b1e4-7c147d4d2b61",
// [no artist]
"9be7f096-97ec-4615-8957-8d40b5dcbc41",
// [traditional]
"89ad4ac3-39f7-470e-963a-56509c546377",
// Various Artists
"7e84f845-ac16-41fe-9ff8-df12eb32af55",
// MusicBrainz Test Artist
"66ea0139-149f-4a0c-8fbf-5ea9ec4a6e49",
// [Disney]
"a0ef7e1d-44ff-4039-9435-7d5fefdeecc9",
// [theatre]
"90068d37-bae7-4292-be4a-704c145bd616",
// [church chimes]
"80a8851f-444c-4539-892b-ad2a49292aa9"
// [language instruction]
]);
// src/edit-note.js
function buildEditNote(sourceUrl, opts, extraLines, sourceLabel) {
const s = GM_info.script;
const mbUrl = location.href.split(/[?#]/)[0].replace(/\/edit-relationships$/, "");
const homepage = s.homepageURL || s.homepage || "https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/credit_hoarder/README.md";
const header = s.name + " v" + s.version + " by " + s.author + " - " + homepage;
const raw = String(sourceUrl || "");
const sourceName = sourceNameForUrl(raw);
const cleanSource = sourceName === "YouTube Music" ? raw.split("#")[0] : raw.split(/[?#]/)[0];
const lines = [
header,
"",
"Release URL: " + mbUrl,
// #408: a consolidated "Import all" run passes an explicit source label (it has no single
// URL). Otherwise: the source URL's provider, or the title-derived "Titles" source (#271).
sourceLabel ? "Source: " + sourceLabel : cleanSource ? sourceName + " URL: " + cleanSource : "Source: track titles"
];
if (opts) lines.push("Options: " + opts);
if (extraLines) lines.push(...Array.isArray(extraLines) ? extraLines : [extraLines]);
return lines.join("\n");
}
function buildCreateNote(action = "Created the entity") {
const s = GM_info.script;
const homepage = s.homepageURL || s.homepage || "https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/credit_hoarder/README.md";
const header = s.name + " v" + s.version + " by " + s.author + " - " + homepage;
const mbUrl = location.href.split(/[?#]/)[0].replace(/\/edit(-relationships)?$/, "");
return header + "\n\n" + action + " while importing credits onto " + mbUrl;
}
function splitOurNote(note) {
const headerPrefix = GM_info.script.name + " v";
const lines = String(note || "").split("\n");
const idx = lines.findIndex((l) => l.startsWith(headerPrefix));
if (idx === -1) return null;
const pre = lines.slice(0, idx).join("\n").replace(/\s+$/, "");
const our = lines.slice(idx);
const relIdx = our.findIndex((l) => /^Release URL:/i.test(l));
return {
pre,
header: our[0],
releaseLine: relIdx !== -1 ? our[relIdx] : "",
body: (relIdx !== -1 ? our.slice(relIdx + 1) : our.slice(1)).join("\n").trim()
};
}
function blockSourceKey(block) {
const first = (String(block).split("\n")[0] || "").trim();
const m = first.match(/^([A-Za-z][A-Za-z ]*?)\s+URL:/);
if (m) return m[1].trim().toLowerCase();
if (/^Source:\s*track titles/i.test(first)) return "titles";
return first.toLowerCase();
}
function combineEditNote(existingNote, ourNote) {
const fresh = splitOurNote(ourNote);
if (!fresh) return ourNote;
const newKey = blockSourceKey(fresh.body);
const prev = splitOurNote(existingNote);
const keptBlocks = prev ? prev.body.split(/\n\n+/).map((b) => b.trim()).filter(Boolean).filter((b) => blockSourceKey(b) !== newKey) : [];
const stacked = [fresh.body, ...keptBlocks].join("\n\n");
const ourBlock = `${fresh.header}
${fresh.releaseLine}
${stacked}`;
const pre = prev ? prev.pre : String(existingNote || "").replace(/\s+$/, "");
return pre ? `${pre}
${ourBlock}` : ourBlock;
}
// src/alias-add.js
var SPECIAL = new Set(MBM_SPECIAL_PURPOSE);
function aliasHeldBy(artist, credit) {
if (!artist || !credit) return true;
const aliases = (artist.aliases || []).map((a) => typeof a === "string" ? { name: a } : a).filter(Boolean);
return !!mbmHolds({ name: artist.name, aliases }, credit);
}
function wantsAliasButton(entityType, artist, credit) {
if (entityType !== "artist" || !artist || !artist.id || SPECIAL.has(artist.id)) return false;
if (!Array.isArray(artist.aliases)) return !aliasHeldBy({ name: artist.name, aliases: [] }, credit);
return !aliasHeldBy(artist, credit);
}
var aliasFormUrl = (mbid) => `${location.origin}/artist/${mbid}/add-alias`;
async function liveHolds(mbid, name) {
const t0 = Date.now();
logDebug(`+ alias: checking ${mbid}'s live aliases for "${name}"`);
let why = "";
const live = await Promise.race([
mbnFetch(`${location.origin}/ws/2/artist/${mbid}?inc=aliases&fmt=json`, { headers: { Accept: "application/json" } }, { background: false }).then((r) => {
if (!r) {
why = "cancelled";
return null;
}
if (!r.ok) {
why = "HTTP " + r.status;
return null;
}
return r.json();
}).catch((e) => {
why = e && e.message || String(e);
return null;
}),
new Promise((res) => setTimeout(() => {
why = `no answer in ${LIVE_TIMEOUT / 1e3}s`;
res(null);
}, LIVE_TIMEOUT))
]);
const out = live && live.id ? { held: aliasHeldBy({ name: live.name, aliases: live.aliases || [] }, name), artistName: live.name } : null;
if (out) logDebug(`+ alias: ${out.artistName} ${out.held ? "already carries" : "doesn't carry"} "${name}" (${Date.now() - t0} ms)`);
else log.warn(`+ alias: couldn't read ${mbid}'s live aliases (${why || "no artist in the answer"}, ${Date.now() - t0} ms) \u2014 going ahead without the check`);
return out;
}
var LIVE_TIMEOUT = 8e3;
async function aliasNowHeld(mbid, name) {
const live = await liveHolds(mbid, name);
return live ? live.held : null;
}
async function openAddAliasForm(mbid, name, note) {
const q = new URLSearchParams({ "edit-alias.name": name, "edit-alias.sort_name": name });
if (note) q.set("edit-alias.edit_note", note);
const url = aliasFormUrl(mbid) + "?" + q.toString();
const tab = window.open("about:blank", "_blank");
const live = await liveHolds(mbid, name);
if (live && live.held) {
try {
if (tab) tab.close();
} catch (e) {
}
log.info(`+ alias: "${name}" is already a name/alias of ${live.artistName} \u2014 form not opened`);
return { already: true };
}
log.info(`+ alias: opening MusicBrainz's add-alias form for "${name}" \u2014 ${url.split("?")[0]}`);
let how = "the blank tab";
try {
if (!tab || tab.closed) throw new Error(tab ? "the blank tab was closed" : "no tab handle (popup blocked?)");
tab.location.href = url;
} catch (e) {
how = "a new tab \u2014 " + (e && e.message || e);
try {
if (tab) tab.close();
} catch (e2) {
}
window.open(url, "_blank");
}
logDebug(`+ alias: form opened in ${how}`);
return { already: false };
}
async function submitAliasBackground(mbid, name, note) {
const url = aliasFormUrl(mbid);
const live = await liveHolds(mbid, name);
if (live && live.held) {
log.info(`+ alias: "${name}" is already a name/alias of ${live.artistName} \u2014 nothing submitted`);
return { already: true };
}
const html = await fetch(url, { credentials: "same-origin" }).then((r) => {
if (!r.ok) throw new Error(`GET add-alias HTTP ${r.status}`);
return r.text();
});
const form = new DOMParser().parseFromString(html, "text/html").querySelector("form.edit-alias");
if (!form) throw new Error("MusicBrainz did not serve an alias form (still logged in?)");
const p = new URLSearchParams();
form.querySelectorAll("input, select, textarea").forEach((el) => {
if (!el.name) return;
if (el.type === "checkbox" || el.type === "radio") {
if (el.checked) p.set(el.name, el.value || "1");
return;
}
p.set(el.name, el.value || "");
});
p.set("edit-alias.name", name);
p.set("edit-alias.sort_name", name);
p.set("edit-alias.type_id", "");
p.set("edit-alias.locale", "");
p.delete("edit-alias.primary_for_locale");
if (note) p.set("edit-alias.edit_note", note);
log.info(`+ alias: submitting "${name}" as an alias of ${url.split("/artist/")[1].split("/")[0]} in the background (no type)`);
const res = await fetch(url, { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: p.toString() });
if (!res.ok) throw new Error(`add-alias submit HTTP ${res.status}`);
if (!/\/add-alias\b/.test(res.url)) return res.url;
const doc = new DOMParser().parseFromString(await res.text(), "text/html");
const msg = [...doc.querySelectorAll(".error, .errors li, p.error")].map((n) => (n.textContent || "").trim()).filter(Boolean)[0];
throw new Error(msg || "MusicBrainz rejected the alias without saying why");
}
// src/split-credit.js
var SEP_RE2 = /\s*(\bfeat\.?|\bft\.?|\bfeaturing|&|\band\b|\bvs\.?|\bwith\b|×|・|,|;)\s*/gi;
var stripDiscogsNum = (s) => String(s || "").replace(/\s+\(\d+\)$/, "");
function splitCreditName(name) {
const parts = stripDiscogsNum(name).split(SEP_RE2).filter((_, i) => i % 2 === 0).map((p) => p.trim()).filter(Boolean);
if (parts.length < 2) return [];
const words = (p) => p.split(/\s+/);
const last = words(parts[parts.length - 1]);
if (last.length > 1 && parts.slice(0, -1).every((p) => words(p).length === 1)) {
const surname = last[last.length - 1];
return parts.map((p, i) => i === parts.length - 1 ? p : `${p} ${surname}`);
}
return parts;
}
var splitKey = (origKey, i) => `${origKey}#split${i}`;
function expandSplitRoles(roles, splits, keyOfEntity) {
if (!splits || !splits.size || !roles) return roles;
const out = [];
for (const role of roles) {
const parts = role?.artist ? splits.get(keyOfEntity(role.artist)) : null;
if (!parts) {
out.push(role);
continue;
}
for (const p of parts) {
out.push({ ...role, creditedAs: "", artist: { name: p.name, anv: "", _syntheticKey: p.key, _splitOf: keyOfEntity(role.artist) } });
}
}
return out;
}
// src/review-table.js
var _urlCheckSessionCache = /* @__PURE__ */ new Map();
function ensureCreatingStyle() {
if (document.getElementById("ch-creating-style")) return;
const st = document.createElement("style");
st.id = "ch-creating-style";
st.textContent = "@keyframes ch-creating-spin { to { transform: rotate(360deg); } }";
document.head.appendChild(st);
}
async function showReviewTable(allResults, rolesMap, companiesRolesMap, opts) {
rolesMap = rolesMap || /* @__PURE__ */ new Map();
companiesRolesMap = companiesRolesMap || /* @__PURE__ */ new Map();
const onRefresh = opts?.onRefresh || null;
const headerSlot = opts?.headerSlot || null;
const importSourceName = opts?.sourceName || "Discogs";
const sourceIcon = opts?.sourceIcon || "";
const entitySources = opts?.entitySources || null;
const sourceBadgeIcon = opts?.sourceBadgeIcon || (() => "");
const _preloadedNames = /* @__PURE__ */ new Map();
const _nullNames = allResults.filter((r) => r.type === "resolved" && r.mbUrl && !r.mbName);
for (const r of _nullNames) {
const rUrl = r.entity?.resource_url;
try {
const idbKey = idbKeyForEntity(r.entity);
const rec = await readIdbRecord(idbKey);
if (rec?.name) {
_preloadedNames.set(rUrl, { name: rec.name, dis: rec.disambiguation || "" });
continue;
}
const mbid = (r.mbUrl || "").split("/").pop().replace(/[^a-f0-9-]/g, "").substring(0, 36);
if (!mbid) continue;
const et = r.entityType || "artist";
const data = await mbThrottle.fetchJson(`https:${MB}/ws/2/${et}/${mbid}?fmt=json`);
if (data?.name) {
_preloadedNames.set(rUrl, { name: data.name, dis: data.disambiguation || "" });
if (idbKey) {
await writeIdbRecord(idbKey, {
mbid,
entityType: et,
name: data.name,
disambiguation: data.disambiguation || ""
// No resolvedVia change — this is just a name-display
// populate; whatever set the cached mbid stays the
// source of truth for `resolvedVia`.
});
}
}
} catch (e) {
}
}
return new Promise((resolve) => {
opts?.registerAbort?.(() => resolve(null));
const rowState = /* @__PURE__ */ new Map();
const rowSearchInputs = /* @__PURE__ */ new Map();
const linkState = /* @__PURE__ */ new Map();
const rowLinkChips = /* @__PURE__ */ new Map();
let linksNote = null;
const splits = /* @__PURE__ */ new Map();
function updateLinksBadge() {
if (!linksNote) return;
const n = [...linkState.values()].filter((v) => v === "none").length;
linksNote.textContent = n ? `\u{1F517} ${n} link${n === 1 ? "" : "s"}` : "";
linksNote.style.display = n ? "" : "none";
linksNote.classList.toggle("clickable", n > 0);
}
const keyOf = (r) => r.entity?.resource_url || r.entity?._syntheticKey || `_nourl_${r.entity?.name || r.displayName}`;
let _linkJumpIdx = -1;
function jumpNextLink() {
const n = allResults.length;
let found = -1;
for (let step = 1; step <= n; step++) {
const i = (_linkJumpIdx + step) % n;
if (linkState.get(keyOf(allResults[i])) === "none") {
found = i;
break;
}
}
if (found === -1) return;
_linkJumpIdx = found;
const key = keyOf(allResults[found]);
const chip = rowLinkChips.get(key);
const target = chip || rowSearchInputs.get(key);
if (!target) return;
target.scrollIntoView({ behavior: "smooth", block: "center" });
if (chip) {
const o = chip.style.boxShadow;
chip.style.boxShadow = "0 0 0 3px rgba(232,119,29,0.6)";
setTimeout(() => {
chip.style.boxShadow = o;
}, 1200);
}
}
const attentionCount = allResults.filter((r) => r.type === "attention").length;
const mismatchCount = allResults.filter((r) => {
if (r.type !== "resolved") return false;
const e = r.logEntry;
return e && e.mbName && e.displayName && e.mbName.toLowerCase().trim() !== e.displayName.toLowerCase().trim();
}).length;
const URL_CHECK_CONCURRENCY = 5;
const urlCheckPending = [];
let urlCheckRunning = 0;
let urlCheckStarted = false;
function queuedUrlCheck(fn) {
return new Promise((resolve2, reject) => {
urlCheckPending.push({ fn, resolve: resolve2, reject });
if (urlCheckRunning < URL_CHECK_CONCURRENCY) {
runUrlCheckWorker();
}
});
}
async function runUrlCheckWorker() {
urlCheckRunning++;
while (urlCheckPending.length > 0) {
const { fn, resolve: resolve2, reject } = urlCheckPending.shift();
try {
resolve2(await fn());
} catch (e) {
reject(e);
}
}
urlCheckRunning--;
}
const VIA_STYLES = {
both: { text: "name+url", color: "var(--mbu-ok)" },
// high confidence
"both-alias": { text: "alias+url", color: "var(--mbu-ok)" },
// #613 URL and an exact ALIAS agree
url: { text: "url", color: "var(--mbu-accent-text)" },
name: { text: "name", color: "var(--mbu-accent-text)" },
alias: { text: "alias", color: "var(--mbu-accent-text)" },
// #613 exact alias of the MB artist (provably unique)
ctx: { text: "context", color: "var(--mbu-ok)" },
// #612 a related artist of the release artist
cred: { text: "co-credit", color: "var(--mbu-accent-text)" },
// #613 co-credit search (option)
user: { text: "user", color: "var(--mbu-text-dim)" },
cache: { text: "cache", color: "var(--mbu-text-dim)" }
// legacy: original mechanism unknown
};
function viaCfg(via, fromCache) {
const base = VIA_STYLES[via];
if (!base) return null;
if (fromCache && via !== "cache") {
return { text: `${base.text} (cache)`, color: base.color };
}
return base;
}
function makeViaBadge(via, fromCache) {
const cfg = viaCfg(via, fromCache);
if (!cfg) return null;
const span = document.createElement("span");
span.textContent = cfg.text;
span.title = fromCache && via !== "cache" ? `Resolved via ${via}, served from cache` : `Resolved via ${via}`;
span.style.cssText = `font-size:0.68rem;background:var(--mbu-bg-raised);color:${cfg.color};padding:0 0.35rem;border-radius:8px;border:1px solid var(--mbu-border);flex-shrink:0;`;
return span;
}
const creditOverrides = /* @__PURE__ */ new Map();
const existingCreditByMbid = computeExistingCreditByMbid();
function computeExistingCreditByMbid() {
const counts = /* @__PURE__ */ new Map();
const MB2 = pageWindow?.MB;
const iterate = MB2?.tree?.iterate;
if (!iterate) return counts;
const valueOf = (yielded) => Array.isArray(yielded) ? yielded[1] : yielded;
const isTree = (x) => x && typeof x === "object" && x.size != null && (x.left !== void 0 || x.right !== void 0 || x.value !== void 0);
function tally(rel) {
if (!rel || rel._status === 2) return;
for (const side of [1, 0]) {
const entity = rel[`entity${side}`];
const tgt = entity?.gid;
if (!tgt) continue;
const credit = rel[`entity${side}_credit`] || entity.name;
if (!credit) continue;
if (!counts.has(tgt)) counts.set(tgt, /* @__PURE__ */ new Map());
const m = counts.get(tgt);
m.set(credit, (m.get(credit) || 0) + 1);
}
}
function walkRels(rels) {
if (!isTree(rels)) return;
for (const e of iterate(rels)) tally(valueOf(e));
}
function walkPhraseGroups(phraseGroups) {
if (!isTree(phraseGroups)) return;
for (const e of iterate(phraseGroups)) {
const pg = valueOf(e);
if (pg?.relationships) walkRels(pg.relationships);
}
}
function walkTypeGroups(byTypeId) {
if (!isTree(byTypeId)) return;
for (const e of iterate(byTypeId)) {
const tg = valueOf(e);
if (tg?.phraseGroups) walkPhraseGroups(tg.phraseGroups);
}
}
function walkPerSource(perSource) {
if (!isTree(perSource)) return;
for (const e of iterate(perSource)) {
walkTypeGroups(valueOf(e));
}
}
function walkSource(root) {
if (!isTree(root)) return;
for (const e of iterate(root)) {
walkPerSource(valueOf(e));
}
}
try {
walkSource(MB2.relationshipEditor?.state?.existingRelationshipsBySource);
} catch (e) {
}
try {
walkSource(MB2.relationshipEditor?.state?.relationshipsBySource);
} catch (e) {
}
const out = /* @__PURE__ */ new Map();
for (const [mbid, m] of counts) {
let best = null, bestN = 0;
for (const [credit, n] of m) {
if (n > bestN) {
best = credit;
bestN = n;
}
}
if (best) out.set(mbid, best);
}
return out;
}
const panel = document.createElement("div");
panel.style.cssText = "border:2px solid var(--mbu-warn);border-radius:0.5rem;background:var(--mbu-bg);padding:1rem 1.5rem;margin:0.5rem 0;";
{
const _pb = document.getElementById("discogs-progress-bar");
if (_pb) _pb.style.display = "none";
}
const _bar = document.querySelector(".discogs-bar");
if (_bar) {
_hideBar();
const _r2 = _bar.querySelector(".discogs-bar-row2");
if (_r2) _r2.style.marginTop = "";
}
const heading = document.createElement("div");
heading.style.cssText = "display:flex;align-items:center;gap:0.6rem;margin:0 0 0.5rem;padding:0.4rem 0.6rem;border-radius:0.3rem;background:var(--mbu-warn-bg);border:1px solid var(--mbu-warn);";
if (onRefresh) {
const refreshBtn = document.createElement("button");
refreshBtn.textContent = "\u{1F504} Refresh from MB";
refreshBtn.title = "Re-resolve every entity via MusicBrainz API, ignoring the local IDB cache";
refreshBtn.style.cssText = "font-size:0.8rem;cursor:pointer;padding:0.2rem 0.5rem;border:1px solid var(--mbu-warn);border-radius:3px;background:var(--mbu-bg);color:var(--mbu-warn);flex-shrink:0;";
refreshBtn.addEventListener("click", () => {
refreshBtn.disabled = true;
refreshBtn.textContent = "\u{1F504} Refreshing\u2026";
(panelLi || panel).remove();
if (headerSlot) headerSlot.replaceChildren();
onRefresh().then((freshResults) => {
showReviewTable(freshResults, rolesMap, companiesRolesMap, opts).then((confirmedMap) => resolve(confirmedMap));
});
});
heading.appendChild(refreshBtn);
}
const headingText = document.createElement("span");
headingText.style.cssText = "font-weight:bold;font-size:1rem;color:var(--mbu-warn);flex:1;";
headingText.textContent = `Review \u2014 ${allResults.length} entit${allResults.length === 1 ? "y" : "ies"}`;
heading.appendChild(headingText);
panel.appendChild(heading);
const intro = document.createElement("p");
intro.style.cssText = "margin:0 0 0.75rem;font-size:0.85rem;color:var(--mbu-text-dim);";
intro.innerHTML = 'Review all artist matches before importing. Red rows need attention. Yellow rows have a name mismatch \u2014 verify. Green rows are confirmed. Use the search or create buttons to resolve outstanding issues.';
panel.appendChild(intro);
const table = document.createElement("table");
table.style.cssText = "border-collapse:collapse;width:100%;font-size:0.85rem;";
const thead = document.createElement("thead");
const hr = document.createElement("tr");
hr.style.background = "var(--mbu-warn-bg)";
[...entitySources ? ["Source"] : [], importSourceName + " entity", "MB match / search"].forEach((col) => {
const th = document.createElement("th");
th.style.cssText = "text-align:left;padding:0.3rem 0.5rem;border:1px solid var(--mbu-warn);white-space:nowrap;color:var(--mbu-text);";
th.textContent = col;
hr.appendChild(th);
});
thead.appendChild(hr);
table.appendChild(thead);
const tbody = document.createElement("tbody");
async function splitRow(r, tr, parts, btn) {
const origKey = keyOf(r);
if (allResults.indexOf(r) < 0) return;
btn.disabled = true;
btn.textContent = "\u2026";
log.info(`#605 split "${r.displayName}" \u2192 ${parts.join(" \xB7 ")}`);
const norm2 = (s) => String(s || "").toLowerCase().trim();
const subs = [];
for (let i = 0; i < parts.length; i++) {
const name = parts[i];
const entity = { name, anv: "", _syntheticKey: splitKey(origKey, i), _splitOf: origKey, _splitFrom: r.displayName };
let pool = (r.nameMatches || []).filter((a) => norm2(a.name) === norm2(name));
let via = "candidates";
if (!pool.length) {
via = "search";
try {
const json = await mbThrottle.fetchJson(`${MB}/ws/2/artist?query=${encodeURIComponent(name)}&fmt=json&limit=8`);
const all = json?.artists || [];
const exact2 = all.filter((a) => norm2(a.name) === norm2(name));
pool = exact2.length ? exact2 : all;
} catch (e) {
log.warn(`#605 split: search for "${name}" failed \u2014 ${e.message}`);
pool = [];
}
}
const exact = pool.filter((a) => norm2(a.name) === norm2(name));
log.info(`#605 split part "${name}": ${pool.length} candidate(s) via ${via}, ${exact.length} exact`);
const base = { entityType: "artist", entity, displayName: name, discogsHref: "", _roles: r._roles };
if (exact.length === 1) {
const a = exact[0], mbUrl = `${MB}/artist/${a.id}`;
subs.push({
...base,
type: "resolved",
mbUrl,
mbName: a.name,
mbDisambig: a.disambiguation || "",
logEntry: { displayName: name, discogsHref: "", mbUrl, mbName: a.name, mbDisambig: a.disambiguation || "", via: "name", fromCache: false }
});
} else {
subs.push({ ...base, type: "attention", nameMatches: pool });
}
}
const idx = allResults.indexOf(r);
if (idx < 0 || !tr.isConnected) return;
if (r._credInput?._activeMbUrl) creditOverrides.delete(r._credInput._activeMbUrl);
rowState.delete(origKey);
rowSearchInputs.delete(origKey);
linkState.delete(origKey);
if (entitySources?.has(origKey)) subs.forEach((s) => entitySources.set(keyOf(s), entitySources.get(origKey)));
allResults.splice(idx, 1, ...subs);
subs.forEach((s) => buildRow(s, tr));
tr.remove();
splits.set(origKey, subs.map((s) => ({ key: keyOf(s), name: s.displayName })));
headingText.textContent = `Review \u2014 ${allResults.length} entit${allResults.length === 1 ? "y" : "ies"}`;
updateLinksBadge();
updateImportBtn();
}
allResults.forEach((r) => buildRow(r));
function buildRow(r, beforeEl) {
const entityType = r.entityType || "artist";
const displayName = r.displayName || r.entity?.name || "";
const discogsHref = r.discogsHref || "";
const srcName = discogsHref ? sourceNameForUrl(discogsHref) : importSourceName;
const e = r.logEntry || null;
const artist = r.entity;
const isResolved = r.type === "resolved";
const initMbUrl = isResolved ? r.mbUrl : null;
const _entityKey = r.entity?.resource_url || r.entity?._syntheticKey || `_nourl_${r.entity?.name || r.displayName}`;
const _pl = _preloadedNames.get(_entityKey) || _preloadedNames.get(r.entity?.resource_url);
const initMbName = e && e.mbName ? e.mbName : _pl?.name || (isResolved ? r.mbName : null) || null;
const initMbDisam = e && e.mbDisambig ? e.mbDisambig : _pl?.dis || r.mbDisambig || "";
const nameMismatch = isResolved && initMbName && initMbName.toLowerCase().trim() !== displayName.toLowerCase().trim();
const needsAttention = r.type === "attention";
const rowBg = needsAttention ? "var(--mbu-error-bg)" : nameMismatch ? "var(--mbu-warn-bg)" : "var(--mbu-bg)";
const borderColor = needsAttention ? "#cc6666" : "#d4d4d4";
const tr = document.createElement("tr");
tr.style.cssText = `vertical-align:top;background:${rowBg};`;
tr.dataset.entityKey = _entityKey;
rowState.set(_entityKey, {
mbUrl: initMbUrl,
mbName: initMbName,
mbDisambig: initMbDisam,
confirmed: isResolved && !needsAttention,
via: isResolved ? r.logEntry?.via || null : null,
fromCache: isResolved ? r.logEntry?.fromCache || false : false
});
if (entitySources) {
const tdSrc = document.createElement("td");
tdSrc.style.cssText = `padding:0.3rem 0.5rem;border:1px solid ${borderColor};white-space:nowrap;text-align:center;`;
const names = entitySources.get(_entityKey) || [];
const srcUrls = (r._mergeUrls || (discogsHref ? [discogsHref] : [])).filter((u) => !isSyntheticProviderUrl(u));
if (!names.length) {
tdSrc.innerHTML = '\u2014';
} else names.forEach((nm) => {
const url = srcUrls.find((u) => sourceNameForUrl(u) === nm) || null;
const span = document.createElement("span");
span.className = "discogs-src-badge";
span.style.cssText = "display:inline-flex;vertical-align:middle;margin:0 2px;" + (url ? "cursor:pointer;" : "filter:grayscale(1);opacity:0.45;");
span.title = url ? `${nm} \u2014 click to open ${url}` : `${nm} \u2014 name-only credit (no link)`;
span.innerHTML = sourceBadgeIcon(nm);
if (url) span.addEventListener("click", () => window.open(url, "_blank", "noopener,noreferrer"));
tdSrc.appendChild(span);
});
tr.appendChild(tdSrc);
}
const tdDiscogs = document.createElement("td");
tdDiscogs.style.cssText = `padding:0.3rem 0.5rem;border:1px solid ${borderColor};white-space:nowrap;`;
const nameRow = document.createElement("div");
nameRow.style.cssText = "display:flex;align-items:center;justify-content:space-between;gap:0.6rem;";
const nameWrap = document.createElement("span");
nameWrap.style.cssText = "min-width:0;overflow:hidden;text-overflow:ellipsis;";
if (entityType !== "artist") {
const badge = document.createElement("span");
badge.textContent = entityType;
badge.style.cssText = "font-size:0.7rem;background:var(--mbu-bg-sunken);border-radius:3px;padding:0 0.3rem;margin-right:0.3rem;color:var(--mbu-text-dim);vertical-align:middle;";
nameWrap.appendChild(badge);
}
const placeholderUrl = /\/_company\//.test(discogsHref) || /\/_company\//.test(r.entity?.resource_url || "");
const hasDiscogsUrl = !!r.entity?.resource_url && !placeholderUrl;
const dlA = document.createElement(hasDiscogsUrl ? "a" : "span");
dlA.href = discogsHref;
dlA.target = "_blank";
dlA.rel = "noopener noreferrer nofollow";
dlA.textContent = displayName;
if (!hasDiscogsUrl) {
dlA.className = "discogs-entity-name";
dlA.style.color = "var(--mbu-text)";
}
const _srcTitles = [...new Set((r._roles || []).map((x) => x.trackTitle).filter(Boolean))];
if (_srcTitles.length) dlA.title = _srcTitles.join("\n");
nameWrap.appendChild(dlA);
const splitParts = entityType === "artist" && !r.entity?._splitOf ? splitCreditName(displayName || r.entity?.name) : [];
if (splitParts.length) {
const sp = document.createElement("button");
sp.type = "button";
sp.className = "discogs-split-btn";
sp.textContent = "\u22D4";
sp.title = `Split into separate artists: ${splitParts.join(" \xB7 ")}
Each gets this row's roles.`;
sp.style.cssText = "margin-left:0.35rem;padding:0 0.35rem;min-width:1.4rem;cursor:pointer;border:1px solid var(--mbu-accent);border-radius:3px;background:var(--mbu-bg-raised);color:var(--mbu-accent-text);font-size:16px;font-weight:bold;line-height:1.2;vertical-align:middle;";
sp.addEventListener("click", () => splitRow(r, tr, splitParts, sp));
nameWrap.appendChild(sp);
}
if (r.entity?._splitOf) {
const sb = document.createElement("span");
sb.textContent = "split";
sb.title = `Split from "${r.entity._splitFrom}" \u2014 gets that credit's roles`;
sb.style.cssText = "display:inline-flex;align-items:center;margin-left:0.35rem;padding:0.05rem 0.4rem;font-size:0.65rem;font-weight:600;border-radius:0.7rem;line-height:1.4;cursor:help;background:var(--mbu-bg-sunken);color:var(--mbu-text-dim);border:1px solid var(--mbu-border);";
nameWrap.appendChild(sb);
}
const BADGE_BASE = "display:inline-flex;align-items:center;margin-left:0.35rem;padding:0.05rem 0.4rem;font-size:0.65rem;font-weight:600;border-radius:0.7rem;letter-spacing:0.01em;cursor:help;text-transform:lowercase;line-height:1.4;";
if (!hasDiscogsUrl && !placeholderUrl && srcName !== "Titles" && !r.entity?._splitOf) {
const noUrl = document.createElement("span");
noUrl.textContent = "no profile";
noUrl.title = `No ${srcName} artist page \u2014 name lookup unavailable, search MB manually`;
noUrl.style.cssText = BADGE_BASE + "background:var(--mbu-error-bg);color:var(--mbu-error);border:1px solid var(--mbu-error);";
nameWrap.appendChild(noUrl);
}
if (nameMismatch) {
const w = document.createElement("span");
w.textContent = "name differs";
w.title = "MB entity name differs from the Discogs display name \u2014 double-check this is the right match";
w.style.cssText = BADGE_BASE + "background:var(--mbu-warn-bg);color:var(--mbu-warn);border:1px solid var(--mbu-warn);";
nameWrap.appendChild(w);
}
nameRow.appendChild(nameWrap);
const actionsLine = document.createElement("span");
actionsLine.style.cssText = "display:inline-flex;align-items:center;gap:0.3rem;flex-shrink:0;";
nameRow.appendChild(actionsLine);
tdDiscogs.appendChild(nameRow);
tr.appendChild(tdDiscogs);
const rolesList = r._roles || [];
if (rolesList.length > 0) {
const byRole = /* @__PURE__ */ new Map();
rolesList.forEach(({ displayLabel, linkType, trackPos }) => {
const key = displayLabel || linkType;
if (!key) return;
if (!byRole.has(key)) byRole.set(key, /* @__PURE__ */ new Set());
if (trackPos) byRole.get(key).add(String(trackPos));
});
const compressPositions = (posSet) => {
const all = [...posSet];
if (!all.length) return "";
if (!all.every((p) => /^\d+$/.test(p))) return "[" + all.join(",") + "]";
const nums = all.map(Number).sort((a, b) => a - b);
const parts = [];
for (let i = 0; i < nums.length; ) {
let j = i;
while (j + 1 < nums.length && nums[j + 1] === nums[j] + 1) j++;
parts.push(j > i ? nums[i] + "-" + nums[j] : String(nums[i]));
i = j + 1;
}
return "[" + parts.join(",") + "]";
};
const chips = [...byRole.entries()].map(([roleKey, posSet]) => {
const pos = compressPositions(posSet);
return { roleKey, displayText: roleKey + (pos ? " " + pos : "") };
});
const rolesLine = document.createElement("div");
rolesLine.style.cssText = "font-size:0.75rem;color:var(--mbu-text-weak);margin-top:0.15rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:240px;";
rolesLine.title = chips.map((c) => c.displayText).join(", ");
chips.forEach((chip, i) => {
if (i > 0) rolesLine.appendChild(document.createTextNode(", "));
const span = document.createElement("span");
span.className = "discogs-role-chip";
span.dataset.roleKey = chip.roleKey;
span.textContent = chip.displayText;
rolesLine.appendChild(span);
});
tdDiscogs.appendChild(rolesLine);
}
const credLine = document.createElement("div");
credLine.style.cssText = "display:flex;align-items:center;gap:0.3rem;margin-top:1rem;padding-top:0.25rem;max-width:280px;";
const credLabel = document.createElement("label");
credLabel.textContent = "Credited as:";
credLabel.style.cssText = "font-size:0.72rem;color:var(--mbu-text-weak);flex-shrink:0;";
const credInput = noPasswordManagers(document.createElement("input"));
const CRED_BG_SAME = "var(--mbu-bg-sunken)";
const CRED_BG_DIFFERENT = "var(--mbu-warn-bg)";
credInput.style.cssText = "flex:1;padding:0.15rem 0.35rem;font-size:0.78rem;border:1px solid var(--mbu-border);border-radius:3px;background:" + CRED_BG_SAME + ";";
credInput.placeholder = displayName;
credInput.title = `Override the credited name dispatched with every rel for this entity.
Leave empty to use the default (${srcName} name, or MB's most-frequent existing credit when known).`;
function refreshCredBg() {
const value = (credInput.value || "").trim();
const same = value === "" || value === displayName;
credInput.style.background = same ? CRED_BG_SAME : CRED_BG_DIFFERENT;
}
function pickPrefill(mbUrl) {
if (r.creditOverride !== void 0 && r.creditOverride !== null && r.creditOverride !== "") {
return r.creditOverride;
}
if (mbUrl) {
const mbid = (String(mbUrl).split("/").pop() || "").replace(/[^a-f0-9-]/gi, "").slice(0, 36);
if (mbid && existingCreditByMbid.has(mbid)) return existingCreditByMbid.get(mbid);
}
if (srcName === "Titles") {
const mbName = rowState.get(_entityKey)?.mbName || r.mbName;
if (mbName) return mbName;
}
return displayName;
}
credInput.value = pickPrefill(r.mbUrl);
credInput._userTouched = false;
refreshCredBg();
let _credSaveTimer;
credInput.addEventListener("input", () => {
credInput._userTouched = true;
const url = credInput._activeMbUrl;
if (url) creditOverrides.set(url, credInput.value);
refreshCredBg();
clearTimeout(_credSaveTimer);
_credSaveTimer = setTimeout(() => {
const idbKey = idbKeyForEntity(r.entity);
if (idbKey) writeIdbRecord(idbKey, { creditOverride: credInput.value });
}, 500);
});
credInput._activeMbUrl = r.mbUrl;
if (r.mbUrl) creditOverrides.set(r.mbUrl, credInput.value);
const CRED_BTN_STYLE = "flex-shrink:0;padding:0.05rem 0.35rem;font-size:0.7rem;line-height:1;cursor:pointer;border:1px solid var(--mbu-warn);border-radius:3px;background:var(--mbu-bg-raised);color:var(--mbu-warn);";
const mbBtn = document.createElement("button");
mbBtn.type = "button";
mbBtn.textContent = "MB";
mbBtn.title = "Set Credited as to the MB entity name";
mbBtn.style.cssText = CRED_BTN_STYLE;
const dBtn = document.createElement("button");
dBtn.type = "button";
dBtn.textContent = srcName.charAt(0);
dBtn.title = `Set Credited as to the ${srcName} name`;
dBtn.style.cssText = CRED_BTN_STYLE;
function currentMbName() {
return rowState.get(_entityKey)?.mbName || r.mbName || null;
}
function refreshCredBtns() {
const val = credInput.value;
const mbName = currentMbName();
mbBtn.style.display = !mbName || val === mbName ? "none" : "";
dBtn.style.display = val === displayName ? "none" : "";
}
function setCredViaButton(value) {
credInput.value = value;
credInput._userTouched = true;
credInput.dispatchEvent(new Event("input", { bubbles: true }));
}
mbBtn.addEventListener("click", () => {
const mbName = currentMbName();
if (mbName) setCredViaButton(mbName);
});
dBtn.addEventListener("click", () => setCredViaButton(displayName));
credInput.addEventListener("input", refreshCredBtns);
refreshCredBtns();
credLine.appendChild(credLabel);
credLine.appendChild(credInput);
credLine.appendChild(mbBtn);
credLine.appendChild(dBtn);
tdDiscogs.appendChild(credLine);
r._credInput = credInput;
r._refreshCredBtns = refreshCredBtns;
const tdMb = document.createElement("td");
tdMb.style.cssText = `padding:0.3rem 0.5rem;border:1px solid ${borderColor};min-width:240px;`;
const candidateList = document.createElement("div");
candidateList.style.cssText = "display:flex;flex-direction:column;gap:0.2rem;margin-bottom:0.3rem;";
const searchRow = document.createElement("div");
searchRow.style.cssText = "display:flex;gap:0.3rem;";
const searchInput = noPasswordManagers(document.createElement("input"));
searchInput.value = displayName;
searchInput.style.cssText = "flex:1;padding:0.15rem 0.35rem;font-size:0.82rem;border:1px solid var(--mbu-border);border-radius:3px;";
rowSearchInputs.set(_entityKey, searchInput);
const searchBtn = document.createElement("button");
searchBtn.type = "button";
searchBtn.innerHTML = '';
searchBtn.title = "Search MusicBrainz";
searchBtn.style.cssText = "display:inline-flex;align-items:center;justify-content:center;padding:0 0.45rem;cursor:pointer;color:var(--mbu-text-dim);background:var(--mbu-bg);border:1px solid var(--mbu-border);border-radius:3px;";
searchRow.appendChild(searchBtn);
searchRow.appendChild(searchInput);
tdMb.appendChild(candidateList);
tdMb.appendChild(searchRow);
tr.appendChild(tdMb);
const tdAction = actionsLine;
tbody.insertBefore(tr, beforeEl || null);
function buildMbRolesEl(explicitMbid) {
if (entityType !== "artist") return null;
const wrap = document.createElement("span");
wrap.style.cssText = "display:inline-flex;align-items:center;gap:0.25rem;margin-left:0.5rem;min-width:0;overflow:hidden;font-size:0.72rem;";
const trigger = document.createElement("a");
trigger.href = "#";
trigger.textContent = "MB roles \u25BE";
trigger.style.cssText = "color:var(--mbu-info);text-decoration:none;cursor:pointer;white-space:nowrap;";
trigger.title = "Fetch this artist's existing MB relationship types to compare with the Discogs role";
trigger.addEventListener("click", async (ev) => {
ev.preventDefault();
let mbid = (String(explicitMbid || "").match(/[a-f0-9-]{36}/i) || [])[0];
if (!mbid) {
const st = rowState.get(_entityKey);
const curUrl = st?.mbUrl || r.mbUrl;
mbid = (String(curUrl || "").split("/").pop() || "").replace(/[^a-f0-9-]/gi, "").slice(0, 36);
}
if (!mbid) {
trigger.textContent = "MB roles: (none selected)";
return;
}
trigger.textContent = "MB roles\u2026";
const types = await fetchArtistRelTypes(mbid);
wrap.innerHTML = "";
const label = document.createElement("span");
label.style.color = "var(--mbu-text-dim)";
if (!types) {
label.textContent = "MB roles: fetch failed";
label.style.color = "var(--mbu-error)";
wrap.appendChild(label);
return;
}
if (!types.length) {
label.textContent = "MB roles: none";
wrap.appendChild(label);
return;
}
label.textContent = "MB roles: ";
label.style.whiteSpace = "nowrap";
label.style.flex = "0 0 auto";
wrap.style.alignItems = "flex-start";
wrap.style.overflow = "visible";
wrap.appendChild(label);
wrap.title = types.join(", ");
const chipsBox = document.createElement("span");
chipsBox.style.cssText = "display:flex;flex-wrap:wrap;gap:0.25rem;min-width:0;";
wrap.appendChild(chipsBox);
types.forEach((t) => {
const c = document.createElement("span");
c.textContent = t;
c.style.cssText = "background:var(--mbu-info-bg);border:1px solid var(--mbu-border);border-radius:0.7rem;padding:0 0.4rem;color:var(--mbu-info);white-space:nowrap;";
chipsBox.appendChild(c);
});
});
wrap.appendChild(trigger);
return wrap;
}
let _creatingEl = null;
let _creatingTimer = null;
let _creatingCancel = null;
function setRowCreating(name, onCancel) {
ensureCreatingStyle();
if (_creatingTimer) {
clearTimeout(_creatingTimer);
_creatingTimer = null;
}
if (_creatingEl) _creatingEl.remove();
_creatingCancel = onCancel || null;
candidateList.style.display = "none";
tdAction.innerHTML = "";
searchInput.disabled = true;
searchBtn.disabled = true;
const ph = document.createElement("div");
ph.className = "ch-creating";
ph.style.cssText = "padding:0.15rem 0.4rem;border:1px dashed var(--mbu-info);border-radius:3px;background:var(--mbu-bg);display:flex;align-items:center;gap:0.4rem;font-size:0.85rem;color:var(--mbu-info);font-style:italic;margin-bottom:0.3rem;";
const spin = document.createElement("span");
spin.textContent = "\u27F3";
spin.style.cssText = "display:inline-block;animation:ch-creating-spin 0.9s linear infinite;";
ph.appendChild(spin);
const txt = document.createElement("span");
txt.textContent = `Creating ${name} in the background\u2026`;
ph.appendChild(txt);
const x = document.createElement("button");
x.textContent = "\u2715";
x.title = "Cancel \u2014 stop waiting and restore the row";
x.style.cssText = "margin-left:auto;font-size:0.75rem;line-height:1;cursor:pointer;border:none;background:none;color:var(--mbu-info);padding:0 0.2rem;";
x.addEventListener("click", () => cancelCreating());
ph.appendChild(x);
tdMb.insertBefore(ph, candidateList);
_creatingEl = ph;
tr.style.background = "var(--mbu-info-bg)";
_creatingTimer = setTimeout(() => {
_creatingTimer = null;
cancelCreating();
}, 9e4);
}
function cancelCreating() {
if (_creatingCancel) {
try {
_creatingCancel();
} catch (e2) {
}
}
clearRowCreating(true);
}
function clearRowCreating(restore) {
if (_creatingTimer) {
clearTimeout(_creatingTimer);
_creatingTimer = null;
}
_creatingCancel = null;
if (_creatingEl) {
_creatingEl.remove();
_creatingEl = null;
}
candidateList.style.display = "";
if (restore) {
searchInput.disabled = false;
searchBtn.disabled = false;
tr.style.background = "";
renderActions(null);
}
}
let aliasPick = null;
let rowViaBadge = null, rowMatchId = null;
function aliasLanded(a) {
if (r.logEntry?.via !== "url" || !rowViaBadge || rowMatchId !== a.id) return;
const nb = makeViaBadge("both-alias", false);
if (nb) {
rowViaBadge.replaceWith(nb);
rowViaBadge = nb;
}
r.logEntry.via = "both-alias";
r.logEntry.fromCache = false;
const st = rowState.get(_entityKey);
if (st) {
st.via = "both-alias";
st.fromCache = false;
}
const k = idbKeyForEntity(r.entity);
if (k) writeIdbRecord(k, { resolvedVia: "both-alias" });
log.info(`+ alias: "${displayName}" \u2192 ${a.name} is now an alias+url match (was url)${k ? ", cached" : ""}`);
}
function makeAddAliasBtn(a) {
if (!wantsAliasButton(entityType, a, displayName)) return null;
const ab = document.createElement("button");
ab.type = "button";
ab.className = "discogs-add-alias";
ab.textContent = "+ alias";
ab.title = `Add "${displayName}" as an alias of ${a.name}
\u2022 click: open MusicBrainz's add-alias form, pre-filled (you submit it)
\u2022 right-click: submit it in the background (no alias type)`;
ab.style.cssText = "font-size:0.72rem;cursor:pointer;padding:0 0.4rem;border:1px solid var(--mbu-accent);border-radius:3px;background:var(--mbu-bg);color:var(--mbu-accent-text);white-space:nowrap;flex:0 0 auto;";
const note = buildCreateNote(`Added "${displayName}" as an alias \u2014 the ${srcName} credit${discogsHref ? " (" + discogsHref + ")" : ""} \u2014`);
ab.addEventListener("click", async (ev) => {
ev.preventDefault();
logDebug(`+ alias: click on "${displayName}" \u2192 ${a.name} (${a.id})`);
let res;
try {
res = await openAddAliasForm(a.id, displayName, note);
} catch (e2) {
log.warn(`+ alias: opening the form for "${displayName}" failed \u2014 ${e2 && e2.message || e2}`);
return;
}
if (res && res.already) {
ab.textContent = "\u2713 has alias";
ab.disabled = true;
ab.title = `${a.name} already carries "${displayName}" \u2014 nothing to add`;
ab.style.color = "var(--mbu-ok)";
ab.style.borderColor = "var(--mbu-ok)";
aliasLanded(a);
return;
}
if (ab._aliasWatch) return;
let checking = false;
const onReturn = async () => {
if (document.visibilityState !== "visible" || checking || ab.disabled) return;
checking = true;
const held = await aliasNowHeld(a.id, displayName);
checking = false;
if (held !== true) {
log.info(`+ alias: "${displayName}" isn't on ${a.name} yet${held === null ? " (lookup failed)" : ""} \u2014 checked on return to this tab`);
return;
}
document.removeEventListener("visibilitychange", onReturn);
window.removeEventListener("focus", onReturn);
ab._aliasWatch = null;
ab.textContent = "\u2713 alias";
ab.disabled = true;
ab.title = `"${displayName}" is now an alias of ${a.name}`;
ab.style.color = "var(--mbu-ok)";
ab.style.borderColor = "var(--mbu-ok)";
a.aliases = [...a.aliases || [], displayName];
log.info(`+ alias: "${displayName}" is now an alias of ${a.name} (added through the form)`);
aliasLanded(a);
};
ab._aliasWatch = onReturn;
setTimeout(() => {
document.addEventListener("visibilitychange", onReturn);
window.addEventListener("focus", onReturn);
}, 600);
});
ab.addEventListener("contextmenu", async (ev) => {
ev.preventDefault();
logDebug(`+ alias: right-click on "${displayName}" \u2192 ${a.name} (${a.id})${ab.disabled ? " \u2014 ignored, the button is busy or done" : ""}`);
if (ab.disabled) return;
ab.disabled = true;
ab.textContent = "\u23F3 alias";
try {
const res = await submitAliasBackground(a.id, displayName, note);
ab.textContent = res && res.already ? "\u2713 has alias" : "\u2713 alias";
ab.title = res && res.already ? `${a.name} already carries "${displayName}" \u2014 nothing submitted` : `"${displayName}" submitted as an alias of ${a.name}`;
ab.style.color = "var(--mbu-ok)";
ab.style.borderColor = "var(--mbu-ok)";
a.aliases = [...a.aliases || [], displayName];
aliasLanded(a);
if (!(res && res.already)) log.info(`+ alias: "${displayName}" submitted as an alias of ${a.name}`);
} catch (e2) {
ab.disabled = false;
ab.textContent = "\u2717 alias";
ab.title = `Adding the alias failed: ${e2.message} \u2014 right-click to retry, click to open the form`;
ab.style.color = "var(--mbu-error)";
ab.style.borderColor = "var(--mbu-error)";
log.warn(`+ alias: "${displayName}" \u2192 ${a.name} failed \u2014 ${e2.message}`);
}
});
return ab;
}
function setRowResolved(a) {
aliasPick = a;
clearRowCreating();
const mbUrl = `${MB}/${entityType}/${a.id}`;
rowState.set(_entityKey, { mbUrl, mbName: a.name, mbDisambig: a.disambiguation || "", confirmed: true, via: "user", fromCache: false });
if (r._credInput) {
const oldUrl = r._credInput._activeMbUrl;
if (oldUrl && oldUrl !== mbUrl) creditOverrides.delete(oldUrl);
r._credInput._activeMbUrl = mbUrl;
if (!r._credInput._userTouched) {
const fresh = pickPrefill(mbUrl);
r._credInput.value = fresh;
}
creditOverrides.set(mbUrl, r._credInput.value);
refreshCredBg();
if (r._refreshCredBtns) r._refreshCredBtns();
}
const _idbKey = idbKeyForEntity(r.entity);
if (_idbKey) {
writeIdbRecord(_idbKey, {
mbid: a.id,
entityType,
name: a.name,
disambiguation: a.disambiguation || "",
resolvedVia: "user"
// user picked this in the review table
});
}
tr.style.background = "var(--mbu-ok-bg)";
searchInput.disabled = true;
searchBtn.disabled = true;
candidateList.innerHTML = "";
const selRow = document.createElement("div");
selRow.style.cssText = "padding:0.15rem 0.4rem;border:1px solid var(--mbu-ok);border-radius:3px;background:var(--mbu-ok-bg);display:flex;flex-wrap:wrap;align-items:center;gap:0.4rem;font-size:0.85rem;";
const selA = document.createElement("a");
selA.href = "https:" + mbUrl;
selA.target = "_blank";
selA.rel = "noopener noreferrer nofollow";
selA.textContent = "\u2713 " + a.name + (a.disambiguation ? ` (${a.disambiguation})` : "");
selA.style.fontWeight = "bold";
selA.style.whiteSpace = "nowrap";
selA.style.flex = "0 0 auto";
const undoBtn = document.createElement("button");
undoBtn.textContent = "\u2715";
undoBtn.title = "Clear selection";
undoBtn.style.cssText = "font-size:0.75rem;cursor:pointer;padding:0 0.3rem;margin-left:auto;";
undoBtn.addEventListener("click", () => setRowUnresolved());
selRow.appendChild(selA);
const viaBadge = makeViaBadge("user", false);
if (viaBadge) selRow.appendChild(viaBadge);
const mbRolesEl = buildMbRolesEl();
if (mbRolesEl) selRow.appendChild(mbRolesEl);
selRow.appendChild(undoBtn);
candidateList.appendChild(selRow);
renderActions(a);
updateImportBtn();
}
function setRowUnresolved() {
aliasPick = null;
clearRowCreating();
rowState.set(_entityKey, { mbUrl: null, mbName: null, mbDisambig: "", confirmed: false, via: null, fromCache: false });
if (r._credInput && r._credInput._activeMbUrl) {
creditOverrides.delete(r._credInput._activeMbUrl);
r._credInput._activeMbUrl = null;
}
tr.style.background = "var(--mbu-error-bg)";
searchInput.disabled = false;
searchBtn.disabled = false;
candidateList.innerHTML = "";
const none = document.createElement("div");
none.style.cssText = "font-size:0.82rem;color:var(--mbu-text-weak);";
none.textContent = "No selection \u2014 search or create";
candidateList.appendChild(none);
renderActions(null);
updateImportBtn();
}
const ACTION_CHIP_STYLE = "display:inline-flex;align-items:center;justify-content:center;min-width:1.6rem;height:1.6rem;padding:0 0.35rem;font-size:0.95rem;line-height:1;cursor:pointer;border:1px solid var(--mbu-border);border-radius:0.3rem;background:var(--mbu-bg-raised);";
function renderActions(selected) {
tdAction.innerHTML = "";
if (!selected) {
linkState.delete(_entityKey);
rowLinkChips.delete(_entityKey);
updateLinksBadge();
}
const noLinkUi = selected && (isSyntheticProviderUrl(discogsHref) || SPECIAL_PURPOSE_ARTISTS.has(selected.id));
if (noLinkUi) {
linkState.delete(_entityKey);
rowLinkChips.delete(_entityKey);
updateLinksBadge();
}
if (selected && !noLinkUi) {
let recheckUrlBypassCache = function() {
_urlCheckSessionCache.delete(urlCheckCacheKey);
try {
localStorage.removeItem(urlCheckLsKey);
} catch (e2) {
}
queuedUrlCheck(
() => fetchWithRetry(`${MB}/ws/2/url?resource=${encodeURIComponent(discogsHref)}&inc=${entityType}-rels&fmt=json`).then((json) => {
const linkedIds = (json.relations || []).filter((r2) => r2[entityType]).map((r2) => r2[entityType].id);
const result = linkedIds.includes(selected.id) ? "linked" : linkedIds.length > 0 ? "other" : "none";
_urlCheckSessionCache.set(urlCheckCacheKey, result);
try {
localStorage.setItem(urlCheckLsKey, JSON.stringify({ date: urlCheckToday, result }));
} catch (e2) {
}
const healKey = parseSourceEntityUrl(r.entity?.resource_url)?.key;
if (healKey) writeIdbRecord(healKey, { urlLinkedIds: linkedIds });
applyUrlCheckResult(result);
}).catch(() => applyUrlCheckResult("none"))
);
}, applyUrlCheckResult = function(result) {
linkState.set(_entityKey, result);
if (result !== "none") rowLinkChips.delete(_entityKey);
updateLinksBadge();
if (result === "linked") {
linkSlot.textContent = "\u2713";
linkSlot.title = srcName + " URL already linked to this MB " + entityType;
linkSlot.style.color = "var(--mbu-ok)";
linkSlot.style.fontWeight = "bold";
} else if (result === "other") {
linkSlot.textContent = "\u26A0\uFE0F";
linkSlot.title = `${srcName} URL is linked to a DIFFERENT MB ${entityType}`;
linkSlot.style.color = "var(--mbu-warn)";
} else {
linkSlot.textContent = "";
linkSlot.style.color = "";
const addLinkBtn = document.createElement("button");
const _addCount = r._mergeUrls && r._mergeUrls.length ? r._mergeUrls.filter((u) => sourceUrlLinkTypeId(u, entityType)).length : 0;
addLinkBtn.textContent = "\u{1F517}" + (_addCount > 1 ? " " + _addCount : "");
addLinkBtn.title = (_addCount > 1 ? `Add ${_addCount} source links to MB ${entityType}` : `Add ${srcName} link to MB ${entityType}`) + ` \xB7 right-click: add ${_addCount > 1 ? "them" : "it"} silently in the background`;
addLinkBtn.style.cssText = ACTION_CHIP_STYLE + "color:var(--mbu-warn);";
const openLinkEdit = (background) => {
const urls = r._mergeUrls && r._mergeUrls.length ? r._mergeUrls : [discogsHref];
const p = new URLSearchParams();
let n = 0;
for (const u of urls) {
const lt = sourceUrlLinkTypeId(u, entityType);
if (!lt) continue;
p.set(`edit-${entityType}.url.${n}.text`, u);
p.set(`edit-${entityType}.url.${n}.link_type_id`, lt);
n++;
}
if (!n) return;
p.set(`edit-${entityType}.edit_note`, buildCreateNote(n > 1 ? `Added ${n} source links` : `Added ${srcName} link`));
const mbid = selected.id.replace(/.*\//, "").replace(/[^a-f0-9-]/gi, "").substring(0, 36);
const editUrl = `https:${MB}/${entityType}/${mbid}/edit?${p}`;
if (background && typeof GM_openInTab === "function") {
const editTab = GM_openInTab(`${editUrl}#ch-autocommit`, { active: false, insert: true });
const onCommitted = (evt) => {
if (evt.data?.type !== "edit-committed" || evt.data.id !== mbid) return;
DISCOGS_CHANNEL.removeEventListener("message", onCommitted);
try {
if (editTab && typeof editTab.close === "function") editTab.close();
} catch (e2) {
}
recheckUrlBypassCache();
};
DISCOGS_CHANNEL.addEventListener("message", onCommitted);
linkSlot.innerHTML = "";
linkSlot.textContent = "\u2026";
linkSlot.title = `Adding ${srcName} link in the background\u2026`;
linkSlot.style.color = "var(--mbu-text-dim)";
linkSlot.style.fontStyle = "italic";
return;
}
const linkTab = window.open(editUrl, "_blank");
if (linkTab) {
const trySet = () => {
try {
linkTab.sessionStorage.setItem("discogs-importer-close-after-edit", "1");
} catch (e2) {
setTimeout(trySet, 50);
}
};
trySet();
}
linkSlot.innerHTML = "";
linkSlot.textContent = "\u2026";
linkSlot.title = `Verifying ${srcName} link on return to this tab\u2026`;
linkSlot.style.color = "var(--mbu-text-dim)";
linkSlot.style.fontStyle = "italic";
const onReturn = () => {
if (document.visibilityState !== "visible") return;
document.removeEventListener("visibilitychange", onReturn);
window.removeEventListener("focus", onReturn);
recheckUrlBypassCache();
};
document.addEventListener("visibilitychange", onReturn);
window.addEventListener("focus", onReturn);
};
addLinkBtn.addEventListener("click", () => openLinkEdit(false));
addLinkBtn.addEventListener("contextmenu", (e2) => {
e2.preventDefault();
openLinkEdit(true);
});
linkSlot.appendChild(addLinkBtn);
rowLinkChips.set(_entityKey, addLinkBtn);
}
};
const linkSlot = document.createElement("span");
linkSlot.style.cssText = "display:inline-flex;align-items:center;font-size:0.8rem;color:var(--mbu-text-weak);";
linkSlot.textContent = "\u2026";
linkSlot.title = `Checking whether MB already has this ${srcName} URL linked`;
tdAction.appendChild(linkSlot);
const urlCheckCacheKey = `${selected.id}|${discogsHref}`;
const urlCheckLsKey = `discogs-urlcheck-${selected.id}-${hashKey(discogsHref)}`;
const urlCheckToday = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
const urlCheckExpiry = /* @__PURE__ */ new Date();
urlCheckExpiry.setDate(urlCheckExpiry.getDate() - 7);
const urlCheckExpiryStr = urlCheckExpiry.toISOString().slice(0, 10);
let urlCheckCached = _urlCheckSessionCache.get(urlCheckCacheKey) ?? null;
if (urlCheckCached === null) {
try {
const s = JSON.parse(localStorage.getItem(urlCheckLsKey) || "null");
if (s?.date >= urlCheckExpiryStr) urlCheckCached = s.result;
} catch (e2) {
}
if (urlCheckCached !== null) _urlCheckSessionCache.set(urlCheckCacheKey, urlCheckCached);
}
if (!discogsHref || placeholderUrl) {
linkState.set(_entityKey, "na");
rowLinkChips.delete(_entityKey);
updateLinksBadge();
if (srcName === "Titles" || placeholderUrl || r.entity?._splitOf) {
linkSlot.remove();
} else {
linkSlot.textContent = `\u26A0 No ${srcName} page`;
linkSlot.style.color = "var(--mbu-warn)";
}
} else if (urlCheckCached !== null) {
applyUrlCheckResult(urlCheckCached);
} else if (Array.isArray(r.urlLinkedIds)) {
const result = r.urlLinkedIds.includes(selected.id) ? "linked" : r.urlLinkedIds.length > 0 ? "other" : "none";
_urlCheckSessionCache.set(urlCheckCacheKey, result);
try {
localStorage.setItem(urlCheckLsKey, JSON.stringify({ date: urlCheckToday, result }));
} catch (e2) {
}
applyUrlCheckResult(result);
} else {
queuedUrlCheck(
() => fetchWithRetry(`${MB}/ws/2/url?resource=${encodeURIComponent(discogsHref)}&inc=${entityType}-rels&fmt=json`).then((json) => {
const linkedIds = (json.relations || []).filter((r2) => r2[entityType]).map((r2) => r2[entityType].id);
const result = linkedIds.includes(selected.id) ? "linked" : linkedIds.length > 0 ? "other" : "none";
_urlCheckSessionCache.set(urlCheckCacheKey, result);
try {
localStorage.setItem(urlCheckLsKey, JSON.stringify({ date: urlCheckToday, result }));
} catch (e2) {
}
const healKey = parseSourceEntityUrl(r.entity?.resource_url)?.key;
if (healKey) writeIdbRecord(healKey, { urlLinkedIds: linkedIds });
applyUrlCheckResult(result);
}).catch(() => applyUrlCheckResult("none"))
);
}
}
function openCreateTab({ name, disambiguation, background } = {}) {
const finalName = (name || displayName).trim();
const seedUrls = (params, et) => {
const urls = r._mergeUrls && r._mergeUrls.length ? r._mergeUrls : [discogsHref];
let n = 0;
for (const u of urls) {
if (!u) continue;
const lt = sourceUrlLinkTypeId(u, et);
if (!lt) continue;
params[`edit-${et}.url.${n}.text`] = u;
params[`edit-${et}.url.${n}.link_type_id`] = lt;
n++;
}
};
let createUrl;
let createParams;
if (entityType === "artist") {
createParams = {
"edit-artist.name": finalName,
"edit-artist.sort_name": mbmGuessSortName(finalName),
"edit-artist.type_id": "1"
};
seedUrls(createParams, "artist");
if (disambiguation) createParams["edit-artist.comment"] = disambiguation;
createParams["edit-artist.edit_note"] = buildCreateNote();
createUrl = `https:${MB}/artist/create`;
} else {
createParams = {
[`edit-${entityType}.name`]: finalName
};
seedUrls(createParams, entityType);
if (disambiguation) createParams[`edit-${entityType}.comment`] = disambiguation;
createParams[`edit-${entityType}.edit_note`] = buildCreateNote();
createUrl = `https:${MB}/${entityType}/create`;
}
const p = new URLSearchParams(createParams);
const pendingKey = r.entity?.resource_url || r.entity?._syntheticKey || `_nourl_${r.entity?.name || displayName}`;
let bgTab = null;
if (background && typeof GM_openInTab === "function") {
const url = `${createUrl}?${p}#ch-autocommit=${encodeURIComponent(pendingKey)}`;
bgTab = GM_openInTab(url, { active: false, insert: true });
} else {
const newTab = window.open(`${createUrl}?${p}`, "_blank");
if (newTab) {
const trySet = () => {
try {
newTab.sessionStorage.setItem("discogs-importer-pending-artist", pendingKey);
} catch (e2) {
setTimeout(trySet, 50);
}
};
trySet();
}
}
const onCreated = (evt) => {
if (evt.data?.type !== "artist-created") return;
if (evt.data.resourceUrl !== pendingKey) return;
DISCOGS_CHANNEL.removeEventListener("message", onCreated);
try {
if (bgTab && typeof bgTab.close === "function") bgTab.close();
} catch (e2) {
}
_urlCheckSessionCache.set(`${evt.data.id}|${discogsHref}`, "linked");
if (evt.data.name) {
setRowResolved({ id: evt.data.id, name: evt.data.name, disambiguation: evt.data.disambiguation });
} else {
setRowResolved({ id: evt.data.id, name: finalName || displayName || "", disambiguation: "" });
fetchWithRetry(`${MB}/ws/2/${entityType}/${evt.data.id}?fmt=json`).then((json) => {
if (json && json.name) setRowResolved({ id: evt.data.id, name: json.name, disambiguation: json.disambiguation || "" });
}).catch(() => {
});
}
};
DISCOGS_CHANNEL.addEventListener("message", onCreated);
if (background && typeof GM_openInTab === "function") {
setRowCreating(finalName, () => {
DISCOGS_CHANNEL.removeEventListener("message", onCreated);
try {
if (bgTab && typeof bgTab.close === "function") bgTab.close();
} catch (e2) {
}
});
}
}
const createBtn = document.createElement("button");
createBtn.textContent = "+";
createBtn.title = (discogsHref ? `Create in MB with default ${srcName} name + URL` : "Create in MB with the credited name") + " \xB7 right-click: create silently in a background tab (auto-submitted)";
createBtn.style.cssText = ACTION_CHIP_STYLE + "color:var(--mbu-ok);font-size:1.15rem;font-weight:600;";
createBtn.addEventListener("click", () => openCreateTab());
createBtn.addEventListener("contextmenu", (e2) => {
e2.preventDefault();
openCreateTab({ background: true });
});
const createAdvBtn = document.createElement("button");
createAdvBtn.textContent = "\u25BE";
createAdvBtn.title = "Create in MB with editable name + disambiguation" + (srcName === "Discogs" && discogsHref ? ", pre-filled from the Discogs profile" : "");
createAdvBtn.style.cssText = ACTION_CHIP_STYLE + "color:var(--mbu-text-dim);";
createAdvBtn.addEventListener("click", () => openAdvancedCreatePopup());
tdAction.appendChild(createBtn);
tdAction.appendChild(createAdvBtn);
async function openAdvancedCreatePopup() {
const distinctRoles = [];
const seen = /* @__PURE__ */ new Set();
for (const role of r._roles || []) {
const label = (role.displayLabel || role.linkType || "").trim();
if (!label || seen.has(label)) continue;
seen.add(label);
distinctRoles.push(label);
if (distinctRoles.length === 3) break;
}
const defaultDis = distinctRoles.join(", ");
const overlay = document.createElement("div");
overlay.className = "mbu-ui";
overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,0.45);z-index:10000;display:flex;align-items:center;justify-content:center;";
const modal = document.createElement("div");
modal.style.cssText = "background:var(--mbu-bg);border-radius:0.5rem;padding:1.1rem 1.35rem 1rem;max-width:600px;width:92%;max-height:82vh;display:flex;flex-direction:column;gap:0.55rem;box-shadow:0 12px 32px rgba(0,0,0,0.32);font-family:inherit;";
const heading2 = document.createElement("div");
heading2.style.cssText = "font-weight:bold;font-size:1.02rem;color:var(--mbu-text);margin-bottom:0.15rem;";
heading2.textContent = `Create ${entityType} in MusicBrainz`;
modal.appendChild(heading2);
const FIELD_LABEL = "font-size:0.78rem;color:var(--mbu-text-dim);font-weight:600;letter-spacing:0.02em;text-transform:uppercase;margin-top:0.25rem;";
const FIELD_INPUT = "padding:0.45rem 0.55rem;border:1px solid var(--mbu-border);border-radius:0.3rem;font-size:0.93rem;font-family:inherit;";
const nameLabel = document.createElement("label");
nameLabel.style.cssText = FIELD_LABEL;
nameLabel.textContent = "Name";
modal.appendChild(nameLabel);
const nameInput = noPasswordManagers(document.createElement("input"));
nameInput.value = displayName;
nameInput.style.cssText = FIELD_INPUT;
modal.appendChild(nameInput);
let nameUserTouched = false;
nameInput.addEventListener("input", () => {
nameUserTouched = true;
});
const disLabel = document.createElement("label");
disLabel.style.cssText = FIELD_LABEL;
disLabel.textContent = "Disambiguation";
modal.appendChild(disLabel);
const disInput = noPasswordManagers(document.createElement("input"));
disInput.value = defaultDis;
disInput.style.cssText = FIELD_INPUT;
modal.appendChild(disInput);
let disUserTouched = false;
disInput.addEventListener("input", () => {
disUserTouched = true;
});
const showProfile = srcName === "Discogs" && !!discogsHref;
let profileBox = null;
if (showProfile) {
const profileLabel = document.createElement("div");
profileLabel.style.cssText = "font-size:0.78rem;color:var(--mbu-text-weak);margin-top:0.55rem;";
profileLabel.textContent = "Discogs profile \u2014 select text to copy into Disambiguation";
modal.appendChild(profileLabel);
profileBox = document.createElement("div");
profileBox.style.cssText = "border:1px solid var(--mbu-border);border-radius:0.3rem;padding:0.5rem 0.6rem;background:var(--mbu-bg-raised);font-size:0.85rem;line-height:1.5;white-space:pre-wrap;overflow:auto;min-height:5rem;max-height:18rem;flex:1;color:var(--mbu-text);";
profileBox.textContent = "Loading profile from Discogs\u2026";
modal.appendChild(profileBox);
const captureSelection = () => {
const sel = window.getSelection();
if (!sel || sel.isCollapsed) return;
if (!profileBox.contains(sel.anchorNode)) return;
const text = sel.toString().trim();
if (!text) return;
disInput.value = text;
disUserTouched = true;
};
profileBox.addEventListener("mouseup", captureSelection);
profileBox.addEventListener("keyup", captureSelection);
}
const btnRow2 = document.createElement("div");
btnRow2.style.cssText = "display:flex;gap:0.5rem;justify-content:flex-end;margin-top:0.55rem;";
const cancelBtn = document.createElement("button");
cancelBtn.textContent = "Cancel";
cancelBtn.style.cssText = "padding:0.4rem 1rem;cursor:pointer;border:1px solid var(--mbu-border);border-radius:0.25rem;background:var(--mbu-bg-raised);color:var(--mbu-text);font-size:0.88rem;";
const submitBtn = document.createElement("button");
submitBtn.textContent = "Create \u2197";
submitBtn.title = "Open the create page, for you to review \xB7 right-click: create silently in a background tab (auto-submitted)";
submitBtn.style.cssText = "padding:0.4rem 1.1rem;cursor:pointer;font-weight:bold;background:var(--mbu-ok);color:var(--mbu-text-on-accent);border:none;border-radius:0.25rem;font-size:0.9rem;";
btnRow2.appendChild(cancelBtn);
btnRow2.appendChild(submitBtn);
modal.appendChild(btnRow2);
overlay.appendChild(modal);
document.body.appendChild(overlay);
const close = () => {
document.removeEventListener("keydown", onKey);
overlay.remove();
};
const submit = (background) => {
const name = nameInput.value.trim();
const dis = disInput.value.trim();
close();
openCreateTab({ name: name || displayName, disambiguation: dis || null, background: !!background });
};
const onKey = (ev) => {
if (ev.key === "Escape") {
close();
} else if (ev.key === "Enter" && (ev.target === disInput || ev.target === nameInput)) submit(false);
};
document.addEventListener("keydown", onKey);
overlay.addEventListener("click", (ev) => {
if (ev.target === overlay) close();
});
cancelBtn.addEventListener("click", close);
submitBtn.addEventListener("click", () => submit(false));
submitBtn.addEventListener("contextmenu", (ev) => {
ev.preventDefault();
submit(true);
});
disInput.focus();
disInput.select();
if (showProfile) try {
const data = await getDiscogsEntityData(r.entity?.resource_url);
if (data?.realname && !nameUserTouched && data.realname.trim() !== displayName.trim()) {
nameInput.value = data.realname.trim();
}
const lines = [];
if (data?.namevariations?.length) lines.push(`Also known as: ${data.namevariations.slice(0, 6).join(", ")}`);
if (data?.profile) {
if (lines.length) lines.push("");
lines.push(data.profile);
}
profileBox.textContent = lines.length ? lines.join("\n") : "(no Discogs profile)";
} catch (e2) {
profileBox.textContent = "(failed to load Discogs profile)";
}
}
if (selected && aliasPick && aliasPick.id === selected.id) {
const ab = makeAddAliasBtn(aliasPick);
if (ab) tdAction.insertBefore(ab, tdAction.firstChild);
}
}
function makeCandidateRow(a) {
const row = document.createElement("div");
row.style.cssText = "display:flex;align-items:center;gap:0.35rem;padding:0.2rem 0.35rem;border:1px solid var(--mbu-border);border-radius:3px;background:var(--mbu-bg);font-size:0.82rem;";
const selBtn = document.createElement("button");
selBtn.textContent = "\u2713";
selBtn.title = "Select this candidate as the MB match";
selBtn.style.cssText = "font-size:0.95rem;line-height:1;cursor:pointer;padding:0.1rem 0.45rem;white-space:nowrap;border:1px solid var(--mbu-border);border-radius:0.25rem;background:var(--mbu-ok-bg);color:var(--mbu-ok);font-weight:600;flex-shrink:0;";
selBtn.addEventListener("click", () => setRowResolved(a));
row.appendChild(selBtn);
const info = document.createElement("span");
info.style.flex = "1";
const nameA = document.createElement("a");
nameA.href = `https:${MB}/${entityType}/${a.id}`;
nameA.target = "_blank";
nameA.rel = "noopener noreferrer nofollow";
nameA.style.fontWeight = "bold";
nameA.textContent = a.name;
info.appendChild(nameA);
if (a.disambiguation) {
const d = document.createElement("span");
d.style.cssText = "color:var(--mbu-text-dim);margin-left:0.25rem;";
d.textContent = `(${a.disambiguation})`;
info.appendChild(d);
}
row.appendChild(info);
const rolesEl = buildMbRolesEl(a.id);
if (rolesEl) {
rolesEl.style.marginLeft = "auto";
row.appendChild(rolesEl);
}
return row;
}
function extractMbid(q) {
const m = q.match(/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/i);
return m ? m[0] : null;
}
function doSearch(q) {
if (!q) return;
const mbid = extractMbid(q);
if (mbid) {
candidateList.innerHTML = 'Looking up MBID\u2026
';
mbThrottle.fetchJson(`${MB}/ws/2/${entityType}/${mbid}?inc=aliases&fmt=json`).then((json) => {
if (!json) return;
candidateList.innerHTML = "";
if (json.id) {
candidateList.appendChild(makeCandidateRow({
id: json.id,
name: json.name,
disambiguation: json.disambiguation || "",
aliases: json.aliases || []
}));
} else {
candidateList.innerHTML = 'Not found
';
}
}).catch(() => {
candidateList.innerHTML = `MBID not found or wrong entity type
`;
});
return;
}
candidateList.innerHTML = 'Searching\u2026
';
mbThrottle.fetchJson(`${MB}/ws/2/${entityType}?query=${encodeURIComponent(q)}&fmt=json&limit=8`).then((json) => {
if (!json) {
candidateList.innerHTML = 'Search failed \u2014 MB unavailable
';
return;
}
candidateList.innerHTML = "";
const resultKey = entityType === "label" ? "labels" : entityType === "place" ? "places" : "artists";
if (!json[resultKey] || json[resultKey].length === 0) {
const none = document.createElement("div");
none.style.cssText = "font-size:0.82rem;color:var(--mbu-text-weak);";
none.textContent = "No results";
candidateList.appendChild(none);
} else {
json[resultKey].forEach((a) => candidateList.appendChild(makeCandidateRow(a)));
}
}).catch(() => {
candidateList.innerHTML = 'Search failed
';
});
}
let searchTimer;
searchInput.addEventListener("input", () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => doSearch(searchInput.value.trim()), 300);
});
searchInput.addEventListener("keydown", (ev) => {
if (ev.key === "Enter") {
ev.preventDefault();
doSearch(searchInput.value.trim());
}
});
searchBtn.addEventListener("click", () => doSearch(searchInput.value.trim()));
if (isResolved && initMbUrl) {
const mbid = initMbUrl.replace(/.*\//, "").replace(/[^a-f0-9-]/gi, "").substring(0, 36);
const correctedMbUrl = `${MB}/${entityType}/${mbid}`;
const displayName2 = initMbName || mbid;
if (!initMbName) {
rowState.set(_entityKey, { mbUrl: initMbUrl, mbName: null, mbDisambig: "", confirmed: true, via: r.logEntry?.via || null, fromCache: r.logEntry?.fromCache || false });
tr.style.background = "var(--mbu-warn-bg)";
}
const fakeA = { id: mbid, name: displayName2, disambiguation: initMbDisam };
if (entityType === "artist" && initMbName && Array.isArray(r.mbAliases)) {
fakeA.aliases = r.mbAliases;
aliasPick = fakeA;
if (wantsAliasButton(entityType, fakeA, displayName)) logDebug(`+ alias: offered on the automatic match "${displayName}" \u2192 ${initMbName} (not among its ${r.mbAliases.length} alias(es))`);
}
candidateList.innerHTML = "";
const selRow = document.createElement("div");
selRow.style.cssText = "padding:0.15rem 0.4rem;border:1px solid var(--mbu-ok);border-radius:3px;background:var(--mbu-ok-bg);display:flex;flex-wrap:wrap;align-items:center;gap:0.4rem;font-size:0.85rem;";
const selA = document.createElement("a");
selA.href = "https:" + correctedMbUrl;
selA.target = "_blank";
selA.rel = "noopener noreferrer nofollow";
selA.textContent = "\u2713 " + displayName2 + (initMbDisam ? ` (${initMbDisam})` : "") + (!initMbName ? " \u26A0 name unknown" : "");
selA.style.fontWeight = "bold";
selA.style.whiteSpace = "nowrap";
selA.style.flex = "0 0 auto";
const undoBtn = document.createElement("button");
undoBtn.textContent = "\u2715";
undoBtn.title = "Clear selection";
undoBtn.style.cssText = "font-size:0.75rem;cursor:pointer;padding:0 0.3rem;margin-left:auto;";
undoBtn.addEventListener("click", () => setRowUnresolved());
selRow.appendChild(selA);
const viaBadge = makeViaBadge(r.logEntry?.via, r.logEntry?.fromCache);
if (viaBadge) selRow.appendChild(viaBadge);
rowViaBadge = viaBadge;
rowMatchId = mbid;
const mbRolesEl = buildMbRolesEl();
if (mbRolesEl) selRow.appendChild(mbRolesEl);
selRow.appendChild(undoBtn);
candidateList.appendChild(selRow);
renderActions(fakeA);
} else if (r.nameMatches && r.nameMatches.length > 0) {
r.nameMatches.forEach((a) => candidateList.appendChild(makeCandidateRow(a)));
renderActions(null);
} else {
const none = document.createElement("div");
none.style.cssText = "font-size:0.82rem;color:var(--mbu-text-weak);";
none.textContent = needsAttention ? "No suggestions \u2014 search or create" : "";
if (needsAttention) candidateList.appendChild(none);
renderActions(null);
}
}
table.appendChild(tbody);
panel.appendChild(table);
const btnRow = document.createElement("div");
btnRow.style.cssText = "display:flex;gap:0.75rem;align-items:center;margin-top:0.75rem;flex-wrap:wrap;";
const importBtn = document.createElement("button");
importBtn.style.cssText = "border:none;padding:0.4rem 1.1rem;border-radius:0.3rem;cursor:pointer;font-weight:bold;font-size:0.95rem;display:inline-flex;align-items:center;gap:5px;";
const issueNote = document.createElement("span");
issueNote.className = "discogs-issue-note";
issueNote.style.cssText = "font-size:0.85rem;color:var(--mbu-warn);";
linksNote = document.createElement("span");
linksNote.className = "discogs-issue-note discogs-links-note";
linksNote.style.cssText = "font-size:0.85rem;color:var(--mbu-warn);display:none;";
linksNote.title = `Confirmed matches whose ${importSourceName} URL isn't linked in MB yet \u2014 click to jump to the next one; use its \u{1F517} chip to add the link`;
linksNote.addEventListener("click", jumpNextLink);
updateLinksBadge();
let _jumpIdx = -1;
function jumpNextUnresolved() {
const n = allResults.length;
let found = -1;
for (let step = 1; step <= n; step++) {
const i = (_jumpIdx + step) % n;
if (!rowState.get(keyOf(allResults[i]))?.confirmed) {
found = i;
break;
}
}
if (found === -1) return;
_jumpIdx = found;
const input = rowSearchInputs.get(keyOf(allResults[found]));
if (!input) return;
input.scrollIntoView({ behavior: "smooth", block: "center" });
try {
input.focus({ preventScroll: true });
} catch {
input.focus();
}
input.select?.();
}
issueNote.addEventListener("click", jumpNextUnresolved);
function recSelectionCounts() {
try {
let total = 0, checked = 0;
for (const tr of document.querySelectorAll("tr")) {
if (!tr.querySelector('a[href*="/recording/"]')) continue;
const cb = tr.querySelector('input[type="checkbox"]');
if (!cb) continue;
total++;
if (cb.checked) checked++;
}
return total ? { checked, total } : null;
} catch (e) {
return null;
}
}
function updateImportBtn() {
const unresolved = [...rowState.values()].filter((s) => !s.confirmed).length;
const sel = recSelectionCounts();
const selLabel = sel && sel.checked > 0 && sel.checked < sel.total ? ` (${sel.checked}/${sel.total})` : "";
if (unresolved === 0) {
importBtn.innerHTML = `Start import${selLabel} \u2192`;
importBtn.style.background = "var(--mbu-ok)";
importBtn.style.color = "var(--mbu-text-on-accent)";
issueNote.textContent = "";
issueNote.classList.remove("clickable");
issueNote.removeAttribute("title");
} else {
importBtn.innerHTML = `Start import anyway${selLabel} \u2192`;
importBtn.style.background = "var(--mbu-warn)";
importBtn.style.color = "var(--mbu-text-on-accent)";
issueNote.textContent = `\u26A0 ${unresolved} unresolved`;
issueNote.classList.add("clickable");
issueNote.title = "Jump to the next unresolved entity \u2014 click again to cycle through them; these will be skipped on import";
}
}
updateImportBtn();
let _lastSelKey = "";
const _recSelPoll = setInterval(() => {
if (!importBtn.isConnected) {
clearInterval(_recSelPoll);
return;
}
const s = recSelectionCounts();
const k = s ? `${s.checked}/${s.total}` : "";
if (k !== _lastSelKey) {
_lastSelKey = k;
updateImportBtn();
}
}, 1e3);
function buildStaticTableLi() {
const tbl = document.createElement("table");
tbl.style.cssText = "border-collapse:collapse;width:100%;font-size:0.78rem;margin:0.4rem 0;";
const thRow = document.createElement("tr");
thRow.style.background = "var(--mbu-bg-raised)";
[importSourceName + " entity", "Roles / Tracks", "MB match", "MBID", "Resolved via"].forEach((h) => {
const th = document.createElement("th");
th.style.cssText = "text-align:left;padding:0.2rem 0.4rem;border:1px solid var(--mbu-border);white-space:nowrap;";
th.textContent = h;
thRow.appendChild(th);
});
tbl.appendChild(thRow);
allResults.forEach((r) => {
const _rKey = r.entity?.resource_url || r.entity?._syntheticKey || `_nourl_${r.entity?.name || r.displayName}`;
const state = rowState.get(_rKey) || {};
const tr2 = document.createElement("tr");
const url = r.entity?.resource_url || r.entity?._syntheticKey || "";
const rolesList2 = url ? rolesMap.get(url) || companiesRolesMap.get(url) || [] : [];
const grouped2 = /* @__PURE__ */ new Map();
rolesList2.forEach(({ displayLabel, linkType, trackPos }) => {
const key = displayLabel || linkType;
if (!grouped2.has(key)) grouped2.set(key, /* @__PURE__ */ new Set());
if (trackPos) grouped2.get(key).add(trackPos);
});
const rolesText = [...grouped2.entries()].map(([label, tr]) => label + (tr.size ? " [" + [...tr].join(",") + "]" : "")).join("; ");
const mbid = state.mbUrl ? state.mbUrl.replace(/.*\//, "").replace(/[^a-f0-9-]/gi, "").substring(0, 36) : "";
const matchText = state.mbName || (state.mbUrl ? mbid : "");
const vCfg = state.via ? viaCfg(state.via, state.fromCache) : null;
const viaText = vCfg ? vCfg.text : state.mbUrl ? "\u2014" : "";
[r.displayName || r.entity?.name, rolesText, matchText, mbid, viaText].forEach((val, ci) => {
const td = document.createElement("td");
td.style.cssText = "padding:0.15rem 0.4rem;border:1px solid var(--mbu-border);" + (ci === 2 && !val ? "color:var(--mbu-text-weak);" : ci === 2 ? "color:var(--mbu-ok);" : ci === 4 && vCfg ? `color:${vCfg.color};` : "");
if (ci === 2 && mbid) {
const a = document.createElement("a");
a.href = "https:" + state.mbUrl;
a.target = "_blank";
a.rel = "noopener noreferrer nofollow";
a.textContent = val || mbid;
td.appendChild(a);
} else {
td.textContent = val || (ci === 1 ? "" : ci === 2 ? "\u2014" : "");
}
tr2.appendChild(td);
});
tbl.appendChild(tr2);
});
const tblLi = document.createElement("li");
tblLi.style.cssText = "list-style:none;margin:0;padding:0;";
tblLi.appendChild(tbl);
return tblLi;
}
importBtn.addEventListener("click", () => {
clearInterval(_recSelPoll);
const confirmedMap = /* @__PURE__ */ new Map();
rowState.forEach((s, key) => {
if (s.mbUrl) confirmedMap.set(key, s.mbUrl);
});
getLogContainer().appendChild(buildStaticTableLi());
const unresolvedCount = allResults.filter((r) => {
const _k = r.entity?.resource_url || r.entity?._syntheticKey || `_nourl_${r.entity?.name || r.displayName}`;
return !rowState.get(_k)?.confirmed;
}).length;
if (unresolvedCount > 0) {
const unresolvedLi = document.createElement("li");
unresolvedLi.style.cssText = "list-style:none;margin:0.2rem 0;font-size:0.82rem;color:var(--mbu-warn);";
unresolvedLi.textContent = `\u26A0 ${unresolvedCount} entity/entities unresolved \u2014 will be skipped`;
getLogContainer().appendChild(unresolvedLi);
}
confirmedMap.unresolvedCount = unresolvedCount;
confirmedMap.totalEntities = allResults.length;
confirmedMap.creditOverrides = creditOverrides;
confirmedMap.splits = splits;
(panelLi || panel).remove();
if (headerSlot) headerSlot.replaceChildren();
resolve(confirmedMap);
});
if (headerSlot) {
headerSlot.replaceChildren(importBtn, issueNote, linksNote);
} else {
btnRow.appendChild(importBtn);
btnRow.appendChild(issueNote);
btnRow.appendChild(linksNote);
panel.appendChild(btnRow);
}
updateLinksBadge();
const panelLi = document.createElement("li");
panelLi.style.cssText = "list-style:none;margin:0;padding:0;";
panelLi.classList.add("discogs-review-panel-li");
panelLi._buildStaticTableLi = buildStaticTableLi;
panelLi.appendChild(panel);
getReviewContainer().appendChild(panelLi);
panelLi.scrollIntoView({ behavior: "smooth", block: "nearest" });
_hideBar();
});
}
// src/match-context.js
var SPECIAL2 = new Set(MBM_SPECIAL_PURPOSE);
var _relatedCache = /* @__PURE__ */ new Map();
function releaseArtistMbids() {
try {
const names = pageWindow.MB?.relationshipEditor?.state?.entity?.artistCredit?.names || [];
return [...new Set(names.map((n) => n?.artist?.gid).filter(Boolean))];
} catch (e) {
return [];
}
}
async function buildReleaseContext({ coCredit = false } = {}) {
const all = releaseArtistMbids();
const seeds = all.filter((g) => !SPECIAL2.has(g));
if (all.length && !seeds.length) log.info("Matching context: the release artist is special-purpose (e.g. Various Artists) \u2014 no context lookup (#612)");
const related = [];
for (const gid of seeds.slice(0, 4)) {
let list = _relatedCache.get(gid);
if (!list) {
const json = await mbThrottle.fetchJson(`${MB}/ws/2/artist/${gid}?inc=aliases+artist-rels&fmt=json`);
if (!json) {
log.warn(`Matching context: could not load release artist ${gid} \u2014 continuing without it`);
continue;
}
list = mbmRelatedArtists(json);
_relatedCache.set(gid, list);
}
for (const r of list) if (!related.some((x) => x.gid === r.gid)) related.push(r);
}
if (seeds.length) {
const kinds = related.filter((r) => r.rel !== "self").reduce((m, r) => (m[r.rel] = (m[r.rel] || 0) + 1, m), {});
log.info(`Matching context: ${seeds.length} release artist(s) \u2192 ${related.length - seeds.length} related artist(s)` + (Object.keys(kinds).length ? ` (${Object.entries(kinds).map(([k, n]) => `${n} ${k}`).join(", ")})` : "") + (coCredit ? " \xB7 co-credit search on" : ""));
logDebug(`context related: ${related.map((r) => `${r.name}[${r.rel}]`).join(", ")}`);
}
return { seeds, related, coCredit: !!coCredit };
}
// src/editor-state.js
async function waitForMBEditor(timeoutMs = 15e3) {
log.info("Waiting for MB relationship editor\u2026");
let waited = 0;
while (waited < timeoutMs) {
const MB2 = pageWindow.MB;
const re = MB2?.relationshipEditor;
const st = re?.state;
if (st?.entity) {
log.info(`Editor ready (${waited}ms). Release: "${st.entity.name}"`);
return re;
}
if (waited % 2e3 === 0 && waited > 0) {
const mbKeys = MB2 ? Object.keys(MB2).join(", ") : "undefined";
const reKeys = re ? Object.keys(re).join(", ") : "undefined";
const stKeys = st ? Object.keys(st).join(", ") : "undefined";
log.info(`[${waited}ms] MB={${mbKeys}} re={${reKeys}} state={${stKeys}}`);
}
await new Promise((r) => setTimeout(r, 200));
waited += 200;
}
log.error("MB editor not ready after 15s \u2014 aborting");
return null;
}
function dispatchRelationship(re, sourceEntity, targetEntity, linkTypeID, credit, attributes, trackPos) {
if (credit && credit === (targetEntity.name || "")) credit = "";
const swapped = sourceEntity.entityType > targetEntity.entityType;
const e0 = swapped ? targetEntity : sourceEntity;
const e1 = swapped ? sourceEntity : targetEntity;
const ltEntry = pageWindow.MB?.linkedEntities?.link_type?.[linkTypeID];
const ltName = ltEntry ? ltEntry.name : linkTypeID;
let attrDesc = "";
if (attributes) {
try {
const parts = [];
for (const a of pageWindow.MB.tree.iterate(attributes)) {
const n = a.type?.name || a.typeID;
const v = a.text_value ? `=${a.text_value}` : "";
if (n) parts.push(n + v);
}
if (parts.length) attrDesc = ` [${parts.join(", ")}]`;
} catch (e) {
}
}
const posLabel = trackPos != null && trackPos !== "" ? ` #${trackPos}` : "";
log.info(`\u2192 ${ltName}${attrDesc}${posLabel}: ${sourceEntity.name || sourceEntity.gid} \u2194 ${targetEntity.name || targetEntity.gid}${credit && credit !== (targetEntity.name || targetEntity.gid) ? ` (credited: ${credit})` : ""}`);
re.dispatch({
type: "update-relationship-state",
sourceEntity,
batchSelectionCount: null,
creditsToChangeForSource: "",
creditsToChangeForTarget: "",
oldRelationshipState: null,
newRelationshipState: {
...REL_TEMPLATE,
entity0: e0,
entity0_credit: swapped ? credit || "" : "",
entity1: e1,
entity1_credit: swapped ? "" : credit || "",
id: re.getRelationshipStateId(),
linkTypeID,
attributes: attributes || null
}
});
}
function buildAttributes(rawAttributes, linkTypeID) {
if (!rawAttributes || rawAttributes.length === 0) return null;
const MB2 = pageWindow.MB;
const tree = MB2?.tree;
const lat = MB2?.linkedEntities?.link_attribute_type;
if (!tree || !lat) return null;
const linkType = linkTypeID != null ? MB2?.linkedEntities?.link_type?.[linkTypeID] : null;
const supportedRoots = linkType && linkType.attributes ? new Set(Object.keys(linkType.attributes)) : null;
const attrSupported = (found) => {
if (!supportedRoots) return true;
const rootId = found.root_id != null ? found.root_id : found.id;
return supportedRoots.has(String(rootId));
};
function findAttrByName(name) {
const lower = name.toLowerCase().trim();
for (const v of Object.values(lat)) {
if (v.name?.toLowerCase() === lower) return v;
}
if (lower.length >= 4) {
for (const v of Object.values(lat)) {
const vl = v.name?.toLowerCase() || "";
if (vl.length < 4) continue;
if (vl.includes(lower) || lower.includes(vl)) return v;
}
}
log.warn(`Attribute "${name}" not found in MB \u2014 dropping attribute but keeping the rel`);
return null;
}
const attrObjs = [];
const seen = /* @__PURE__ */ new Set();
for (const attr of rawAttributes) {
let attrName = null;
let textValue = "";
let creditedAs = "";
if (typeof attr === "string") {
attrName = attr;
} else if (attr && typeof attr === "object" && attr._type) {
if (attr._type === "task") {
attrName = "task";
textValue = attr.value;
} else {
attrName = attr.value;
}
if (attr.creditedAs) creditedAs = attr.creditedAs;
}
if (!attrName) continue;
const found = findAttrByName(attrName);
if (!found || seen.has(found.id)) continue;
if (!attrSupported(found)) {
log.warn(`Attribute "${found.name}" isn't supported by link type "${linkType.name || linkTypeID}" \u2014 dropping it (keeping the rel)`);
continue;
}
seen.add(found.id);
attrObjs.push({ type: found, typeID: found.id, credited_as: creditedAs, text_value: textValue });
}
if (attrObjs.length === 0) return null;
attrObjs.sort((a, b) => a.typeID - b.typeID);
try {
return tree.fromDistinctAscArray(attrObjs);
} catch (e) {
log.warn(`Attribute tree build failed (${e.message}) \u2014 importing without attributes`);
return null;
}
}
// src/data/work-only-rels.js
var WORK_ONLY_ARTIST_RELS = [
"writer",
"composer",
"lyricist",
"librettist",
"revised by",
// NOT 'translator' — a translator credit (liner notes / lyrics / libretto) is a
// release-wide credit, so it dispatches at RELEASE level (artist↔release
// "translator"), not duplicated onto every work. (MB has the artist-release rel.)
"reconstructed by",
// 'arranger',
// 'instruments arranger',
"orchestrator",
// 'vocals arranger',
"previously attributed to",
"miscellaneous support",
"dedicated to",
"premiered by",
"was commissioned by",
"publisher",
// MB's actual link-type name for the music-publisher rel (label→work).
// Tidal/Qobuz "Music Publisher" credits resolve to a label and attach here.
"publishing",
"inspired the name of"
];
// src/dispatch.js
var stripDiscogsNum2 = (s) => String(s || "").replace(/\s+\(\d+\)$/, "");
function makeIdentifyingClassifier(lat) {
const identifyingRoots = /* @__PURE__ */ new Set();
if (lat) {
for (const v of Object.values(lat)) {
const isRoot = v.parent_id == null || v.parent_id === v.id;
if (isRoot && /^(instrument|vocal)$/i.test(v.name || "")) identifyingRoots.add(v.id);
}
}
const rootCache = /* @__PURE__ */ new Map();
const rootIdOf = (typeID) => {
if (rootCache.has(typeID)) return rootCache.get(typeID);
let node = lat ? lat[typeID] : null;
let guard = 0;
while (node && node.parent_id != null && node.parent_id !== node.id && lat[node.parent_id] && guard++ < 64) {
node = lat[node.parent_id];
}
const rootId = node ? node.id : typeID;
rootCache.set(typeID, rootId);
return rootId;
};
return (typeID) => identifyingRoots.has(rootIdOf(typeID));
}
async function dispatchAllRelationships(companies, artistRoles, tracklistRels, applyToTracks, createWorksMode, discogsTracklist, processTracklist, resolvedEntityTypes, confirmedMap, discogsUrl, dedupOpts) {
resolvedEntityTypes = resolvedEntityTypes || /* @__PURE__ */ new Map();
confirmedMap = confirmedMap || /* @__PURE__ */ new Map();
dedupOpts = dedupOpts || {};
if (confirmedMap.splits?.size) {
const keyOfEntity = (e) => e.resource_url || e._syntheticKey || `_nourl_${e.name}`;
const before = (artistRoles?.length || 0) + (tracklistRels?.length || 0);
artistRoles = expandSplitRoles(artistRoles, confirmedMap.splits, keyOfEntity);
tracklistRels = expandSplitRoles(tracklistRels, confirmedMap.splits, keyOfEntity);
const after = (artistRoles?.length || 0) + (tracklistRels?.length || 0);
confirmedMap.splits.forEach((parts, k) => log.info(`#605 split ${k} \u2192 ${parts.map((p) => `${p.name} [${confirmedMap.get(p.key) || "unresolved"}]`).join(" \xB7 ")}`));
log.info(`#605 split: ${before} role(s) \u2192 ${after} after fan-out`);
}
const dedupeEquivalenceSets = dedupOpts.dedupeEquivalenceSets !== false;
const dedupeDuplicateRoles = dedupOpts.dedupeDuplicateRoles !== false;
const creditOverrides = dedupOpts.creditOverrides || /* @__PURE__ */ new Map();
const re = await waitForMBEditor();
if (!re) return;
const MB2 = pageWindow.MB;
const isIdentifyingAttr = makeIdentifyingClassifier(MB2?.linkedEntities?.link_attribute_type);
const equivalenceLookup = (() => {
const m = /* @__PURE__ */ new Map();
if (!dedupeEquivalenceSets || !MB2?.linkedEntities?.link_type) return m;
for (const set of EQUIVALENCE_SETS) {
const byPair = /* @__PURE__ */ new Map();
for (const [id, lt] of Object.entries(MB2.linkedEntities.link_type)) {
if (!lt?.name) continue;
if (!set.includes(String(lt.name).toLowerCase())) continue;
const key = `${lt.type0}|${lt.type1}`;
if (!byPair.has(key)) byPair.set(key, []);
byPair.get(key).push(Number(id));
}
for (const ids of byPair.values()) {
if (ids.length < 2) continue;
const sibSet = new Set(ids);
for (const id of ids) m.set(id, sibSet);
}
}
return m;
})();
const releaseEntity = re.state.entity;
let added = 0, existedInMb = 0, existedStaged = 0, dedupedThisSession = 0, skipped = 0, failed = 0;
const dispatchedThisSession = /* @__PURE__ */ new Set();
const RECORDING_LINK_TYPES = /* @__PURE__ */ new Set([
"performer",
"instrument",
"vocal",
"vocals",
"orchestra",
"conductor",
"concertmaster",
"chorus master",
"producer",
"engineer",
"mix",
"recording",
"remixer",
"DJ-mixer",
"additional",
"guest",
"programming"
// NOT 'mastering' — MB deprecated artist→recording mastering (link type 136).
]);
const isExecProducer = (role) => role.linkType === "producer" && (role.attributes || []).some((a) => a === "executive" || a && a.value === "executive");
log.info(`Starting instant fill: ${companies.length} companies, ${artistRoles.length} release artist roles, ${tracklistRels.length} tracklist roles`);
try {
_showBar();
} catch (_) {
}
const bar = document.querySelector(".discogs-bar");
function tickProgress() {
const done = added + skipped + failed;
const est = Math.max(done + 1, companies.length + artistRoles.length + tracklistRels.length);
const pct = Math.min(Math.round(done / est * 99), 99);
try {
_setProgressPct(pct);
} catch (_) {
}
}
const recordingByGid = /* @__PURE__ */ new Map();
const recordingByPosition = /* @__PURE__ */ new Map();
const editorWorkByRecGid = /* @__PURE__ */ new Map();
const positionByGid = /* @__PURE__ */ new Map();
let trackCount = 0;
try {
let mediumIndex = 0;
for (const [mediumKey, medium] of MB2.tree.iterate(re.state.mediums)) {
mediumIndex++;
const tracks = medium?.tracks ?? medium;
let trackIndex = 0;
for (const rawTrack of MB2.tree.iterate(tracks)) {
const trackObj = Array.isArray(rawTrack) ? rawTrack[1] : rawTrack;
const trackKey = Array.isArray(rawTrack) ? rawTrack[0] : null;
const rec = trackObj?.recording ?? trackObj;
if (!rec) continue;
trackCount++;
if (rec.gid) {
recordingByGid.set(rec.gid, rec);
positionByGid.set(rec.gid, `${mediumIndex}-${trackIndex + 1}`);
const rw = trackObj?.relatedWorks;
if (rw && rw.size > 0) {
try {
for (const entry of MB2.tree.iterate(rw)) {
const raw = Array.isArray(entry) ? entry[1] : entry;
const work = raw?.work ?? raw;
if (work?.gid || work?.id) {
editorWorkByRecGid.set(rec.gid, work);
break;
}
}
} catch (e) {
}
}
}
const positions = new Set([
trackObj?.position,
trackObj?.number,
rec?.position,
rec?.number,
trackKey,
trackIndex + 1,
// Compound keys: "mediumIndex-trackPosition"
`${mediumIndex}-${trackIndex + 1}`,
trackObj?.position != null ? `${mediumIndex}-${trackObj.position}` : null,
trackObj?.number != null ? `${mediumIndex}-${trackObj.number}` : null
].filter((x) => x != null).map(String));
for (const p of positions) recordingByPosition.set(p, rec);
trackIndex++;
}
}
log.info(`Found ${trackCount} track(s) in editor state (${recordingByGid.size} with GID, ${recordingByPosition.size} position entries: ${[...recordingByPosition.keys()].join(",")}). relatedWorks: ${editorWorkByRecGid.size} pre-linked`);
} catch (e) {
log.warn(`Iterating MB state: ${e.message}`);
}
const checkedRecGids = /* @__PURE__ */ new Set();
try {
for (const raw of MB2.tree.iterate(re.state.selectedRecordings)) {
const rec = Array.isArray(raw) ? raw[1] : raw;
if (rec?.gid) checkedRecGids.add(rec.gid);
}
} catch (e) {
}
const recSelectionActive = checkedRecGids.size > 0 && checkedRecGids.size < recordingByGid.size;
const applyToRec = (gid) => !recSelectionActive || checkedRecGids.has(gid);
if (recSelectionActive) log.info(`Recording selection: applying only to ${checkedRecGids.size}/${recordingByGid.size} checked recording(s).`);
const positionToGid = /* @__PURE__ */ new Map();
try {
const relMbid = releaseEntity.gid;
log.info(`WS2: fetching recordings for release ${relMbid}\u2026`);
const wsJson = await fetchWithRetry(`/ws/2/release/${relMbid}?inc=recordings&fmt=json`);
log.info(`WS2: response received`);
if (wsJson) {
const mediaCount = wsJson.media?.length ?? 0;
log.info(`WS2: ${mediaCount} medium/media in response`);
const mediaArr = wsJson.media || [];
const isMultiMedium2 = mediaArr.length > 1;
for (const medium of mediaArr) {
const medPos = medium.position;
for (const track of medium.tracks || []) {
const gid = track.recording?.id;
if (!gid) continue;
if (medPos != null && track.position != null) {
positionToGid.set(`${medPos}-${track.position}`, gid);
}
if (medPos != null && track.number != null) {
positionToGid.set(`${medPos}-${track.number}`, gid);
}
if (!isMultiMedium2) {
if (track.position != null) positionToGid.set(String(track.position), gid);
if (track.number != null) positionToGid.set(String(track.number), gid);
}
}
}
log.info(`WS2 position map: ${positionToGid.size} entries (${[...positionToGid.keys()].sort().join(", ")})`);
}
} catch (e) {
log.warn(`WS2 recording fetch failed: ${e.message} \u2014 using editor state positions only`);
}
const isMultiMedium = positionToGid.size > 0 && [...positionToGid.keys()].some((k) => /^[2-9]-/.test(k));
function inferDiscFromVinylSide(pos) {
const m = String(pos || "").match(/^([A-Z])\d+$/i);
if (!m) return null;
return Math.floor((m[1].toUpperCase().charCodeAt(0) - 65) / 2) + 1;
}
function getRecordingEntity(track) {
const stripPad = (s) => String(s).replace(/-0+(\d)/g, "-$1");
const pos = track.position != null ? String(track.position) : "";
const num = track.number != null ? String(track.number) : "";
const compounds = /* @__PURE__ */ new Set();
const plain = /* @__PURE__ */ new Set();
if (/^\d+-/.test(pos)) {
compounds.add(pos);
const unpadded = stripPad(pos);
if (unpadded !== pos) compounds.add(unpadded);
} else if (pos) {
plain.add(pos);
const inferredDisc = inferDiscFromVinylSide(pos);
if (inferredDisc != null) compounds.add(`${inferredDisc}-${pos}`);
for (let m = 1; m <= 10; m++) compounds.add(`${m}-${pos}`);
}
if (num && num !== pos) {
plain.add(num);
for (let m = 1; m <= 10; m++) compounds.add(`${m}-${num}`);
}
const tryKeys = isMultiMedium ? [...compounds] : [...plain, ...compounds];
for (const c of tryKeys) {
const gid = positionToGid.get(c);
if (gid) {
const rec = recordingByGid.get(gid);
if (rec) return rec;
log.warn(`Recording ${gid} for track ${track.position} not in editor state`);
return null;
}
}
for (const c of tryKeys) {
const rec = recordingByPosition.get(c);
if (rec) return rec;
}
if (trackCount > 0) {
const ws2Keys = positionToGid.size ? [...positionToGid.keys()].join(", ") : "(empty)";
const stateKeys = recordingByPosition.size ? [...recordingByPosition.keys()].join(", ") : "(empty)";
log.warn(`No recording for track ${track.position} "${track.title}". WS2 keys: ${ws2Keys} | State keys: ${stateKeys}`);
}
return null;
}
function confirmedMbUrl(entity) {
if (!entity) return null;
const direct = confirmedMap.get(entity.resource_url) || confirmedMap.get(entity._syntheticKey) || null;
if (direct) return direct;
if (entity.name) {
return confirmedMap.get(`_nourl_${entity.name}`) || null;
}
return null;
}
function relAlreadyExists(sourceEntity, linkTypeID, targetGid, attrTree) {
const rels = sourceEntity?.relationships;
if (!Array.isArray(rels) || rels.length === 0) return null;
const acceptableLinkTypes = equivalenceLookup.get(linkTypeID) || /* @__PURE__ */ new Set([linkTypeID]);
const sigOf = (attrs) => attrs.map((a) => `${a.typeID}:${a.text_value || ""}:${a.credited_as || ""}`).sort().join(",");
const idSigOf = (attrs) => attrs.filter((a) => isIdentifyingAttr(a.typeID)).map((a) => `${a.typeID}:${a.text_value || ""}:${a.credited_as || ""}`).sort().join(",");
const candAttrs = (() => {
if (!attrTree) return [];
try {
return [...pageWindow.MB.tree.iterate(attrTree)].map((a) => ({ typeID: a.typeID, text_value: a.text_value || "", credited_as: a.credited_as || "" }));
} catch (e) {
return [];
}
})();
const candSig = sigOf(candAttrs);
const candIdSig = idSigOf(candAttrs);
const lookupName = (id) => {
try {
return pageWindow.MB.linkedEntities.link_type[id]?.name || `#${id}`;
} catch (e) {
return `#${id}`;
}
};
let dupMatch = null;
for (const r of rels) {
if (!acceptableLinkTypes.has(r.linkTypeID)) continue;
const tgt = r.target?.gid || r.entity0?.gid || r.entity1?.gid;
if (tgt !== targetGid) continue;
const isEquivalent = r.linkTypeID !== linkTypeID;
const existingAttrs = (r.attributes || []).map((a) => ({ typeID: a.typeID, text_value: a.text_value || "", credited_as: a.credited_as || "" }));
const exactMatch = sigOf(existingAttrs) === candSig;
if (exactMatch) {
return { kind: isEquivalent ? "equivalence" : "exact", existingLinkName: lookupName(r.linkTypeID), status: r._status };
}
if (dedupeDuplicateRoles && !dupMatch && idSigOf(existingAttrs) === candIdSig) {
dupMatch = { kind: isEquivalent ? "equivalence" : "duplicate-role", existingLinkName: lookupName(r.linkTypeID), status: r._status };
}
}
return dupMatch;
}
async function processOne(sourceEntity, entityType0, entityType1, linkTypeName, mbUrl, rawAttributes, credit, trackPos) {
const overrideCredit = creditOverrides.get(mbUrl);
if (overrideCredit && String(overrideCredit).trim()) {
credit = String(overrideCredit).trim();
}
const mbid = mbUrl.replace(/.*\//, "").replace(/[^a-f0-9-]/gi, "").substring(0, 36);
if (!mbid) {
log.error(`Bad MBID URL: ${mbUrl}`);
failed++;
return;
}
const linkTypeID = resolveLinkTypeId(linkTypeName, entityType0, entityType1);
if (!linkTypeID) {
failed++;
return;
}
const attrTree = buildAttributes(rawAttributes, linkTypeID);
const attrSig = attrTree ? (() => {
try {
return [...pageWindow.MB.tree.iterate(attrTree)].map((a) => (a.typeID || "") + (a.credited_as ? "~" + a.credited_as : "")).join(",");
} catch (e) {
return "";
}
})() : "";
const sessionKey = `${sourceEntity.gid}|${linkTypeID}|${mbid}|${attrSig}`;
if (dispatchedThisSession.has(sessionKey)) {
log.info(`Skipped duplicate dispatch of ${linkTypeName}: ${sourceEntity.name} \u2194 ${credit || ""} \u2014 already queued earlier this run`);
dedupedThisSession++;
return;
}
dispatchedThisSession.add(sessionKey);
let targetEntity;
try {
targetEntity = await fetchMBEntity(mbid);
} catch (e) {
log.error(`Entity fetch failed for ${mbid}: ${e.message}`);
failed++;
return;
}
const PLACE_TO_LABEL_LINK = {
"glass mastered at": "glass mastered",
"mastered at": "mastering",
"pressed at": "pressed",
"manufactured at": "manufactured",
"recorded at": "engineer",
"mixed at": "mix"
};
const LABEL_TO_PLACE_LINK = Object.fromEntries(
Object.entries(PLACE_TO_LABEL_LINK).map(([k, v]) => [v, k])
);
let resolvedLinkTypeID = linkTypeID;
if (targetEntity.entityType !== entityType1 && targetEntity.entityType !== entityType0) {
const at = targetEntity.entityType;
const [rt0, rt1] = at < sourceEntity.entityType ? [at, sourceEntity.entityType] : [sourceEntity.entityType, at];
let reResolved = resolveLinkTypeId(linkTypeName, rt0, rt1);
if (!reResolved) {
const altName = at === "label" ? PLACE_TO_LABEL_LINK[linkTypeName] : LABEL_TO_PLACE_LINK[linkTypeName];
if (altName) reResolved = resolveLinkTypeId(altName, rt0, rt1);
}
if (reResolved) {
resolvedLinkTypeID = reResolved;
} else {
log.warn(`Entity "${targetEntity.name}" is a ${targetEntity.entityType} but expected ${entityType0}/${entityType1} \u2014 link type "${linkTypeName}" may not apply`);
}
}
const dedupHit = relAlreadyExists(sourceEntity, resolvedLinkTypeID, targetEntity.gid, attrTree);
if (dedupHit) {
const pair = `${sourceEntity.name} \u2194 ${targetEntity.name}${credit && credit !== targetEntity.name ? ` (credited: ${credit})` : ""}`;
const existing = dedupHit.existingLinkName;
const staged = dedupHit.status === 1;
const where = staged ? "already added this session" : "already in MB";
if (dedupHit.kind === "equivalence") {
log.info(`Deduplication (equivalence sets): ${linkTypeName} not added \u2014 equivalent ${existing} ${where} on ${pair}`);
} else if (dedupHit.kind === "duplicate-role") {
log.info(`Deduplication (duplicate roles): ${linkTypeName} not added \u2014 same role ${where} with different attributes on ${pair}`);
} else {
log.info(`${staged ? "Already added this session" : "Already in MB"}: ${linkTypeName}: ${pair}`);
}
if (staged) existedStaged++;
else existedInMb++;
return;
}
dispatchRelationship(re, sourceEntity, targetEntity, resolvedLinkTypeID, credit, attrTree, trackPos);
added++;
}
async function dispatchCompanies() {
for (const company of companies) {
const details = ENTITY_TYPE_MAP[company.entity_type_name];
if (!details) continue;
const resolvedEt = resolvedEntityTypes.get(company.resource_url) || details.entityType;
if (resolvedEt !== details.entityType) {
if (details.entityType === "place" && resolvedEt === "label") {
log.warn(`Skipped ${company.name}: MB has no "${details.linkType}" relationship for labels (only places). Add manually if needed.`);
skipped++;
tickProgress();
continue;
}
}
const mbUrl = confirmedMbUrl(company);
if (!mbUrl) {
log.skip(`Skipped ${company.name} \u2014 not resolved in review`);
skipped++;
tickProgress();
continue;
}
const et = resolvedEt;
const [t0, t1] = et <= "release" ? [et, "release"] : ["release", et];
await processOne(releaseEntity, t0, t1, details.linkType, mbUrl, [], "");
tickProgress();
}
}
async function dispatchReleaseArtists() {
for (const role of artistRoles) {
if (applyToTracks && RECORDING_LINK_TYPES.has(role.linkType) && !isExecProducer(role)) continue;
if (WORK_ONLY_ARTIST_RELS.includes(role.linkType)) continue;
const mbUrl = confirmedMbUrl(role.artist);
if (!mbUrl) {
log.skip(`Skipped ${role.artist.name} (${role.linkType}) \u2014 not resolved in review`);
skipped++;
tickProgress();
continue;
}
const credit = role.creditedAs || stripDiscogsNum2(role.artist.anv?.trim() || role.artist.name);
await processOne(releaseEntity, "artist", "release", role.linkType, mbUrl, role.attributes || [], credit);
tickProgress();
}
}
async function dispatchTracklist() {
if (applyToTracks && recordingByGid.size > 0) {
const applicable = artistRoles.filter((role) => RECORDING_LINK_TYPES.has(role.linkType) && !WORK_ONLY_ARTIST_RELS.includes(role.linkType) && !isExecProducer(role));
if (applicable.length > 0) {
log.info(`Applying ${applicable.length} release credit(s) to ${recordingByGid.size} recording(s)\u2026`);
for (const role of applicable) {
const mbUrl = confirmedMbUrl(role.artist);
if (!mbUrl) {
log.skip(`Skipped ${role.artist.name} (${role.linkType}) in applyToTracks \u2014 not resolved in review`);
continue;
}
const credit = role.creditedAs || stripDiscogsNum2(role.artist.anv?.trim() || role.artist.name);
for (const recEntity of recordingByGid.values()) {
if (!applyToRec(recEntity.gid)) continue;
await processOne(recEntity, "artist", "recording", role.linkType, mbUrl, role.attributes || [], credit, positionByGid.get(recEntity.gid) || "*");
}
}
}
}
}
async function dispatchWorks() {
// Toolbox policy: a new Work may only be staged after a full and
// successful title search. Other recordings may already use that Work.
let toolboxWorkRequestAt = 0;
const toolboxStagedTitles = __mbToolBoxStagedWorkTitles;
const toolboxWorkQueryCache = new Map();
const toolboxNormalizeTitle = x => String(x || "").normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "").toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, " ").trim();
const toolboxLucene = x => String(x).replace(/([+\-!(){}\[\]^"~*?:\\/]|&&|\|\|)/g, "\\$1");
async function toolboxWorkJson(url) {
const waitMs = Math.max(0, 1700 - (Date.now() - toolboxWorkRequestAt));
if (waitMs) await __mbToolBoxSleep(waitMs);
toolboxWorkRequestAt = Date.now();
const response = await __mbToolBoxFetch(url, {
credentials: "same-origin", headers: {Accept: "application/json"}
});
if (!response.ok) throw Error("MusicBrainz Work search HTTP " + response.status);
return response.json();
}
async function toolboxSearchAllWorks(query, label) {
const matches = [];
const limit = 100;
let offset = 0;
for (;;) {
const url = "/ws/2/work?query=" + encodeURIComponent(query) +
"&fmt=json&limit=" + limit + "&offset=" + offset;
const json = await toolboxWorkJson(url);
const count = Number(json?.["work-count"]);
if (!Number.isSafeInteger(count) || count < 0 || !Array.isArray(json.works)) {
throw Error("Invalid Work search result for " + label);
}
matches.push(...json.works);
offset += json.works.length;
if (offset >= count) return matches;
if (!json.works.length || json.works.length < limit) {
throw Error("Incomplete Work search for " + label + " (" + offset + "/" + count + ")");
}
}
}
async function toolboxResolveWork(workTitle, creditedNames) {
const title = String(workTitle || "").trim();
if (!title) return {status:"review", reason:"Missing Work title"};
const key = toolboxNormalizeTitle(title);
if (toolboxStagedTitles.has(key)) {
return {status:"review", reason:"A Work with this title is already staged"};
}
const names = [...new Map((creditedNames || []).map(x => String(x || "").trim())
.filter(Boolean).map(x => [toolboxNormalizeTitle(x), x])).values()];
try {
let works = toolboxWorkQueryCache.get(key);
if (!works) {
works = (await toolboxSearchAllWorks('work:"' + toolboxLucene(title) + '"', title))
.filter(w => w.id && toolboxNormalizeTitle(w.title) === key);
toolboxWorkQueryCache.set(key,works);
}
if (!works.length) return {status:"new"};
if (!names.length) return {
status:"review",reason: works.length+" same-title Works; no credited author names to verify"
};
let candidates = works;
if (works.length > 8) {
const query = 'work:"' + toolboxLucene(title) + '" AND (' +
names.map(name => 'artist:"' + toolboxLucene(name) + '"').join(" OR ") + ')';
const found = await toolboxSearchAllWorks(query,title+" + authors");
const allowed = new Set(works.map(w => w.id));
const matched = new Set(found.filter(w => allowed.has(w.id) &&
toolboxNormalizeTitle(w.title) === key).map(w => w.id));
candidates = works.filter(w => matched.has(w.id));
}
if (!candidates.length || candidates.length > 8) {
return {status:"review",reason:works.length+" same-title Works; authors ambiguous"};
}
const possible = [];
const known = new Set(names.map(toolboxNormalizeTitle));
for (const candidate of candidates) {
const details = await toolboxWorkJson("/ws/2/work/" + encodeURIComponent(candidate.id) +
"?inc=artist-rels&fmt=json");
const matches = (details.relations || []).some(rel =>
rel.artist && ["writer","composer","lyricist","librettist"].includes(
toolboxNormalizeTitle(rel.type)
) && [rel.artist.name,rel["target-credit"],rel["source-credit"]]
.some(name => name && known.has(toolboxNormalizeTitle(name)))
);
if (matches) possible.push(candidate);
}
if (possible.length === 1) {
return {status:"existing", mbid:possible[0].id, title:possible[0].title};
}
return {status:"review",reason:works.length+
" same-title Works; "+possible.length+" have matching author credits"};
} catch(error) {
return {status:"review",reason:"MusicBrainz Work verification failed: "+error.message};
}
}
if (createWorksMode === "off") {
const workOnly = [...tracklistRels || [], ...artistRoles || []].filter((r) => WORK_ONLY_ARTIST_RELS.includes(r.linkType));
if (workOnly.length) log.skip(`"Use works" is off \u2014 skipped ${workOnly.length} work-level credit(s) (${[...new Set(workOnly.map((r) => r.linkType))].join(", ")})`);
return;
}
const recordingOfLinkTypeId = resolveLinkTypeId("performance", "recording", "work");
const includeOnlyResolved = createWorksMode === "when-needed";
const workOnlyByGid = /* @__PURE__ */ new Map();
for (const role of tracklistRels) {
if (!WORK_ONLY_ARTIST_RELS.includes(role.linkType)) continue;
if (includeOnlyResolved && !confirmedMbUrl(role.artist)) continue;
const recEntity = getRecordingEntity(role.track);
if (!recEntity) {
log.error(`Work-only rel for track ${role.track.position} "${role.track.title}" \u2014 no recording found, skipped`);
failed++;
continue;
}
if (!applyToRec(recEntity.gid)) continue;
if (!workOnlyByGid.has(recEntity.gid)) workOnlyByGid.set(recEntity.gid, []);
workOnlyByGid.get(recEntity.gid).push({ role, recEntity });
}
for (const role of artistRoles) {
if (!WORK_ONLY_ARTIST_RELS.includes(role.linkType)) continue;
if (includeOnlyResolved && !confirmedMbUrl(role.artist)) continue;
for (const recEntity of recordingByGid.values()) {
if (!applyToRec(recEntity.gid)) continue;
const syntheticRole = { ...role, track: { position: "", title: recEntity.name || "" } };
if (!workOnlyByGid.has(recEntity.gid)) workOnlyByGid.set(recEntity.gid, []);
workOnlyByGid.get(recEntity.gid).push({ role: syntheticRole, recEntity });
}
}
if (createWorksMode === "when-missing" && recordingOfLinkTypeId) {
for (const recEntity of recordingByGid.values()) {
if (!applyToRec(recEntity.gid)) continue;
if (!workOnlyByGid.has(recEntity.gid)) {
workOnlyByGid.set(recEntity.gid, []);
}
}
}
if (workOnlyByGid.size === 0) return;
if (!recordingOfLinkTypeId) {
log.error('Could not resolve "performance" link type \u2014 work processing skipped');
return;
}
log.info(`Processing work relationships for ${workOnlyByGid.size} recording(s)\u2026`);
const existingWorkByRecGid = editorWorkByRecGid;
log.info(`Editor state: ${existingWorkByRecGid.size} recording(s) already have a linked work`);
function getWorkFromEditorState(recEntity) {
try {
for (const rel of MB2.tree.iterate(recEntity.relationships)) {
if (rel._status === 1 && rel.linkTypeID === recordingOfLinkTypeId) {
return rel.entity0?.entityType === "work" ? rel.entity0 : rel.entity1;
}
}
} catch (e) {
}
return null;
}
const createdWorkRecGids = /* @__PURE__ */ new Set();
for (const [recGid, entries] of workOnlyByGid) {
const recEntity = entries[0]?.recEntity ?? recordingByGid.get(recGid);
const trackTitle = recEntity?.name || entries[0]?.role.track.title || recGid;
const trackPos = entries[0]?.role.track.position ?? "";
if (!recEntity) continue;
const hasExistingWork = editorWorkByRecGid.has(recGid);
let workEntity = null;
if (hasExistingWork) {
workEntity = editorWorkByRecGid.get(recGid);
const wid = workEntity.gid || workEntity.id;
log.info(`Track ${trackPos} "${trackTitle}": work already linked (${workEntity.name || wid || "existing"}) \u2014 skipping creation`);
if (!workEntity.gid && !workEntity.id) continue;
}
if (!workEntity) workEntity = getWorkFromEditorState(recEntity);
if (!workEntity && createWorksMode === "never") {
for (const { role } of entries) {
log.error(`Track ${trackPos} "${trackTitle}": no work exists for ${role.linkType} (${role.artist.name}) \u2014 "Create works" is set to "never". Add the work manually or change the mode.`);
failed++;
}
continue;
}
if (!workEntity) {
const writerNames = entries.filter(e => ["writer", "composer", "lyricist", "librettist"].some(
type => String(e.role.linkType || "").toLowerCase().includes(type)
)).map(e => e.role.artist?.name).filter(Boolean);
const resolution = await toolboxResolveWork(trackTitle, writerNames);
if (resolution.status === "review") {
log.warn('Track ' + trackPos + ' "' + trackTitle + '": ' +
resolution.reason + '. No duplicate Work staged.');
continue;
}
if (resolution.status === "existing") {
try {
workEntity = await fetchMBEntity(resolution.mbid);
if (workEntity?.entityType !== "work") throw Error("Expected a Work entity");
dispatchRelationship(re, recEntity, workEntity, recordingOfLinkTypeId, "", null, trackPos);
log.info('Track ' + trackPos + ': linked existing Work "' + resolution.title + '".');
} catch(error) {
log.warn('Track ' + trackPos + ': could not stage verified existing Work: ' + error.message);
continue;
}
}
if (!workEntity && createdWorkRecGids.has(recGid)) {
log.error(`Track ${trackPos} "${trackTitle}": a work was already created for this recording in this run \u2014 skipping to avoid a duplicate work`);
failed++;
continue;
}
if (!workEntity) {
const newWorkId = re.getRelationshipStateId();
workEntity = {
_fromBatchCreateWorksDialog: true,
attributes: [],
comment: "",
editsPending: false,
entityType: "work",
gid: null,
id: newWorkId,
iswcs: [],
languages: [],
name: trackTitle,
typeID: 17
};
if (MB2.mergeLinkedEntities) {
MB2.mergeLinkedEntities({ work: { [newWorkId]: workEntity } });
}
re.dispatch({
type: "update-relationship-state",
sourceEntity: recEntity,
batchSelectionCount: null,
creditsToChangeForSource: "",
creditsToChangeForTarget: "",
oldRelationshipState: null,
newRelationshipState: {
_lineage: ["batch-created work"],
_original: null,
_status: 1,
attributes: null,
begin_date: null,
editsPending: false,
end_date: null,
ended: false,
entity0: recEntity,
entity0_credit: "",
entity1: workEntity,
entity1_credit: "",
id: re.getRelationshipStateId(),
linkOrder: 0,
linkTypeID: recordingOfLinkTypeId
}
});
createdWorkRecGids.add(recGid);
log.info(`Track ${trackPos} "${trackTitle}": created new work "${trackTitle}"`);
added++;
tickProgress();
workEntity = getWorkFromEditorState(recEntity) || workEntity;
toolboxStagedTitles.add(toolboxNormalizeTitle(trackTitle));
}
}
for (const { role } of entries) {
const mbUrl = confirmedMbUrl(role.artist);
if (!mbUrl) {
log.skip(`Skipped ${role.artist.name} \u2014 not resolved in review (${role.linkType})`);
continue;
}
const credit = role.creditedAs || stripDiscogsNum2(role.artist.anv?.trim() || role.artist.name);
const srcType = role.entityType || "artist";
const urlType = (mbUrl.match(/musicbrainz\.org\/(artist|label|place)\//i) || [])[1];
if (urlType && urlType !== srcType) {
log.skip(`Skipped ${role.linkType} for "${credit}" \u2014 resolved to an MB ${urlType}, but this relationship needs a ${srcType} (#417)`);
continue;
}
if (workEntity.gid) {
await processOne(workEntity, srcType, "work", role.linkType, mbUrl, role.attributes || [], credit, trackPos || entries[0]?.role?.track?.position);
} else {
const linkTypeID = resolveLinkTypeId(role.linkType, srcType, "work");
if (linkTypeID) {
const mbid = mbUrl.replace(/.*\//, "").replace(/[^a-f0-9-]/gi, "").substring(0, 36);
try {
const artistEntity = await fetchMBEntity(mbid);
dispatchRelationship(re, workEntity, artistEntity, linkTypeID, credit, buildAttributes(role.attributes || [], linkTypeID));
added++;
} catch (e) {
log.error(`Failed to add ${role.linkType} for new work: ${e.message}`);
}
}
}
}
}
}
async function dispatchTracklistArtists() {
const seenTrackRels = /* @__PURE__ */ new Set();
for (const role of tracklistRels) {
if (WORK_ONLY_ARTIST_RELS.includes(role.linkType)) continue;
const mbUrl = confirmedMbUrl(role.artist);
if (!mbUrl) {
log.skip(`Skipped ${role.artist.name} on track ${role.track.position} \u2014 not resolved in review`);
continue;
}
const recEntity = getRecordingEntity(role.track);
if (!recEntity) {
log.warn(`No recording found for track ${role.track.position} "${role.track.title}" \u2014 skipped`);
failed++;
continue;
}
if (!applyToRec(recEntity.gid)) continue;
const credit = role.creditedAs || stripDiscogsNum2(role.artist.anv?.trim() || role.artist.name);
const attrKey = (role.attributes || []).map((a) => typeof a === "string" ? a : a.value || a._type || "").join(",");
const trackRelKey = `${role.track.position}|${role.linkType}|${mbUrl}|${attrKey}`;
if (seenTrackRels.has(trackRelKey)) continue;
seenTrackRels.add(trackRelKey);
log.info(`Track ${role.track.position} "${role.track.title}": adding ${role.linkType} \u2014 ${credit}`);
await processOne(recEntity, "artist", "recording", role.linkType, mbUrl, role.attributes || [], credit, role.track.position);
tickProgress();
}
}
await dispatchCompanies();
await dispatchReleaseArtists();
await dispatchTracklist();
await dispatchWorks();
await dispatchTracklistArtists();
try {
const opts = [
processTracklist !== void 0 ? `per-track:${processTracklist ? "on" : "off"}` : null,
applyToTracks !== void 0 ? `move-to-tracks:${applyToTracks ? "on" : "off"}` : null,
createWorksMode !== void 0 ? `create-works:${createWorksMode}` : null
].filter(Boolean).join(", ");
const trackCount2 = Array.isArray(discogsTracklist) ? discogsTracklist.length : 0;
const inputStats = `Input: ${companies?.length || 0} companies, ${artistRoles?.length || 0} release credits, ${tracklistRels?.length || 0} tracklist credits on ${trackCount2} track${trackCount2 === 1 ? "" : "s"}`;
const unresolvedCount = confirmedMap?.unresolvedCount || 0;
const totalEntities = confirmedMap?.totalEntities || 0;
const unresolvedLine = unresolvedCount > 0 ? `Unresolved: ${unresolvedCount} of ${totalEntities} entit${totalEntities === 1 ? "y" : "ies"} skipped in review` : null;
const editNoteDedupPart = dedupedThisSession > 0 ? `, ${dedupedThisSession} dispatch duplicate${dedupedThisSession === 1 ? "" : "s"}` : "";
const editNoteStagedPart = existedStaged > 0 ? `, ${existedStaged} already added this session` : "";
const resultStats = `Result: ${added} added, ${existedInMb} already in MB${editNoteStagedPart}${editNoteDedupPart}, ${skipped} skipped, ${failed} failed${recSelectionActive ? ` (applied to ${checkedRecGids.size} of ${recordingByGid.size} selected recordings)` : ""}`;
const ourNote = buildEditNote(discogsUrl, opts, [inputStats, unresolvedLine, resultStats].filter(Boolean), dedupOpts.sourceLabel);
const existingNote = document.querySelector(SELECTORS.EditNote)?.value || "";
const note = combineEditNote(existingNote, ourNote);
re.dispatch({ type: "update-edit-note", editNote: note });
} catch (e) {
}
const dedupPart = dedupedThisSession > 0 ? `, ${dedupedThisSession} dispatch duplicate${dedupedThisSession === 1 ? "" : "s"}` : "";
const stagedPart = existedStaged > 0 ? `, ${existedStaged} already added this session` : "";
const selNote = recSelectionActive ? ` \u2014 applied to ${checkedRecGids.size} of ${recordingByGid.size} selected recording(s)` : "";
log.info(`Done: ${added} added, ${existedInMb} already in MB${stagedPart}${dedupPart}, ${skipped} skipped, ${failed} failed${selNote}`);
}
// src/sources/apple.js
var APPLE_AMP = "https://amp-api.music.apple.com/v1/catalog";
var APPLE_ALBUM_RE = /(?:music|itunes)\.apple\.com\/(?:([a-z]{2})\/)?album\/(?:[^/?#]+\/)?(?:id)?(\d+)/i;
function parseAppleAlbumUrl(url) {
const m = APPLE_ALBUM_RE.exec(url || "");
return m ? { storefront: (m[1] || "us").toLowerCase(), id: m[2] } : null;
}
var APPLE_ROLE_BRIDGE = {
"Songwriter": "Written-By",
"Writer": "Written-By",
"Composer": "Composed By",
"Lyricist": "Lyrics By",
"Producer": "Producer",
"Mixing Engineer": "Mixed By",
"Mixer": "Mixed By",
"Recording Engineer": "Recording Engineer",
"Engineer": "Engineer",
"Arranger": "Arranged By",
"Vocalist": "Vocals",
"Vocal": "Vocals",
"Background Vocals": "Backing Vocals",
"Background Vocal": "Backing Vocals"
};
var APPLE_SKIP = /* @__PURE__ */ new Set(["Performer", "Mastering Engineer", "Studio Personnel"]);
function appleToEngine(parsedTracks) {
const tracklistRels = [];
const tracklist = [];
const skipped = [];
const tracks = parsedTracks || [];
const { positions, multiVolume } = assignVolumePositions(tracks, (t) => t.index);
tracks.forEach((t, i) => {
const track = { position: positions[i], title: t.title || "", type_: "track" };
tracklist.push(track);
for (const c of t.credits || []) {
const role = String(c.role || "").trim();
const name = String(c.name || "").trim();
if (!role || !name) continue;
if (APPLE_SKIP.has(role)) {
skipped.push(`track ${track.position}: ${role} \u2014 ${name}`);
continue;
}
const discogsRole = APPLE_ROLE_BRIDGE[role] || role;
const rels = getArtistRoles({ name, anv: "", role: discogsRole, resource_url: "" });
if (!rels || !rels.length) {
skipped.push(`track ${track.position}: ${role} \u2014 ${name} (unmapped)`);
continue;
}
for (const rel of rels) tracklistRels.push({ ...rel, artist: rel.artist || { name, anv: "", resource_url: "" }, track });
}
});
return { tracklistRels, tracklist, skipped, multiVolume };
}
function appleGet(url, headers) {
return new Promise((resolve, reject) => {
if (typeof GM_xmlhttpRequest !== "function") {
reject(new Error("GM_xmlhttpRequest unavailable"));
return;
}
GM_xmlhttpRequest({
method: "GET",
url,
headers: headers || {},
timeout: 2e4,
onload: (r) => resolve({ status: r.status, text: r.responseText || "" }),
onerror: () => reject(new Error("Apple request failed (network)")),
ontimeout: () => reject(new Error("Apple request timed out"))
});
});
}
var _appleToken = null;
var APPLE_TOKEN_LS = "ch:apple-token";
async function appleToken() {
if (_appleToken) return _appleToken;
try {
const raw = JSON.parse(localStorage.getItem(APPLE_TOKEN_LS) || "null");
if (raw && raw.t && raw.at && Date.now() - raw.at < 12 * 36e5) {
_appleToken = raw.t;
return _appleToken;
}
} catch (e) {
}
const home = await appleGet("https://music.apple.com/us/browse", { "Accept": "text/html" });
const asset = (home.text.match(/\/assets\/index-legacy~[a-z0-9]+\.js/i) || home.text.match(/\/assets\/index~[a-z0-9]+\.js/i) || [])[0];
if (!asset) throw new Error("Apple: could not locate the web-player JS asset");
const js = await appleGet("https://music.apple.com" + asset, {});
const tok = (js.text.match(/eyJ[A-Za-z0-9._\-]{80,}/) || [])[0];
if (!tok) throw new Error("Apple: no bearer token in the web-player JS");
_appleToken = tok;
try {
localStorage.setItem(APPLE_TOKEN_LS, JSON.stringify({ t: tok, at: Date.now() }));
} catch (e) {
}
return tok;
}
function ampHeaders(tok) {
return { Authorization: "Bearer " + tok, Origin: "https://music.apple.com", Accept: "application/json" };
}
async function fetchAppleCredits(storefront, albumId, onProgress) {
const sf = storefront || "us";
const tok = await appleToken();
const albRes = await appleGet(`${APPLE_AMP}/${sf}/albums/${encodeURIComponent(albumId)}?l=en-US`, ampHeaders(tok));
if (albRes.status !== 200) throw new Error(`Apple album ${albumId} \u2192 HTTP ${albRes.status}`);
let albJson;
try {
albJson = JSON.parse(albRes.text);
} catch (e) {
throw new Error("Apple: malformed album JSON");
}
const album = albJson.data && albJson.data[0];
const albumName = album && album.attributes && album.attributes.name || "";
let songs = album && album.relationships && album.relationships.tracks && album.relationships.tracks.data || [];
let next = album && album.relationships && album.relationships.tracks && album.relationships.tracks.next;
while (next) {
const pg = await appleGet("https://amp-api.music.apple.com" + next + (next.includes("?") ? "&" : "?") + "l=en-US", ampHeaders(tok));
let pj;
try {
pj = JSON.parse(pg.text);
} catch (e) {
break;
}
songs = songs.concat(pj.data || []);
next = pj.next;
}
const tracks = [];
let done = 0;
for (const s of songs) {
const sid = s.id;
const num = s.attributes && s.attributes.trackNumber || done + 1;
const title = s.attributes && s.attributes.name || "";
const credits = [];
try {
const cr = await appleGet(`${APPLE_AMP}/${sf}/songs/${encodeURIComponent(sid)}/credits?l=en-US`, ampHeaders(tok));
if (cr.status === 200) {
let cj;
try {
cj = JSON.parse(cr.text);
} catch (e) {
cj = null;
}
for (const grp of cj && cj.data || []) {
const arts = grp.relationships && grp.relationships["credit-artists"] && grp.relationships["credit-artists"].data || [];
for (const a of arts) {
const name = a.attributes && a.attributes.name;
for (const role of a.attributes && a.attributes.roleNames || []) {
if (name && role) credits.push({ name, role });
}
}
}
}
} catch (e) {
}
if (credits.length) tracks.push({ index: +num, title, credits });
done++;
if (onProgress) {
try {
onProgress(done, songs.length);
} catch (e) {
}
}
}
return { album: albumName, tracks };
}
// src/sources/ytmusic.js
var YTM_API = "https://music.youtube.com/youtubei/v1/";
var YTM_CLIENT = { clientName: "WEB_REMIX", clientVersion: "1.20250101.01.00", hl: "en", gl: "US" };
var YTM_ALBUM_RE = /(?:^|\/\/)(?:(?:music|www|m)\.)?youtube\.com\/(?:playlist\?(?:[^#]*&)?list=(OLAK5uy_[\w-]+)|browse\/(MPREb_[\w-]+))/i;
function parseYtmAlbumUrl(url) {
const m = YTM_ALBUM_RE.exec(url || "");
return m ? m[1] ? { list: m[1] } : { album: m[2] } : null;
}
var YTM_ROLE_BRIDGE = {
"Written by": "Written-By",
"Produced by": "Producer"
};
var YTM_SKIP = {
"Performed by": "the track artist credit",
"Music metadata provided by": "the label or distributor"
};
function ytmPositions(count, mediumSizes) {
const sizes = (mediumSizes || []).filter((n) => n > 0);
const plain = Array.from({ length: count }, (_, i) => String(i + 1));
if (sizes.length <= 1) return { positions: plain, multiMedium: false, mismatch: false };
const total = sizes.reduce((a, b) => a + b, 0);
if (total !== count) return { positions: plain, multiMedium: true, mismatch: true };
const positions = [];
sizes.forEach((n, m) => {
for (let t = 1; t <= n; t++) positions.push(`${m + 1}-${t}`);
});
return { positions, multiMedium: true, mismatch: false };
}
function ytmToEngine(songs, mediumSizes) {
const tracklistRels = [];
const tracklist = [];
const skipped = [];
const list = songs || [];
const { positions, multiMedium, mismatch } = ytmPositions(list.length, mediumSizes);
list.forEach((s, i) => {
const track = { position: positions[i], title: s.title || "", type_: "track" };
tracklist.push(track);
for (const [section, names] of Object.entries(s.sections || {})) {
const named = (names || []).map((n) => String(n || "").trim()).filter(Boolean);
if (!named.length) continue;
if (YTM_SKIP[section]) {
skipped.push(`track ${track.position}: ${section} \u2014 ${named.join(", ")} (${YTM_SKIP[section]})`);
continue;
}
const role = YTM_ROLE_BRIDGE[section];
if (!role) {
skipped.push(`track ${track.position}: ${section} \u2014 ${named.join(", ")} (unmapped section)`);
continue;
}
for (const name of named) {
const rels = getArtistRoles({ name, anv: "", role, resource_url: "" });
if (!rels || !rels.length) {
skipped.push(`track ${track.position}: ${section} \u2014 ${name} (unmapped)`);
continue;
}
for (const rel of rels) tracklistRels.push({ ...rel, artist: rel.artist || { name, anv: "", resource_url: "" }, track });
}
}
});
return { tracklistRels, tracklist, skipped, multiMedium, mismatch };
}
function ytmPost(endpoint, body) {
const what = body.browseId || endpoint;
return new Promise((resolve, reject) => {
if (typeof GM_xmlhttpRequest !== "function") {
reject(new Error("GM_xmlhttpRequest unavailable"));
return;
}
const t0 = Date.now();
GM_xmlhttpRequest({
method: "POST",
url: `${YTM_API}${endpoint}?prettyPrint=false`,
anonymous: true,
timeout: 2e4,
headers: { "Content-Type": "application/json" },
data: JSON.stringify({ context: { client: YTM_CLIENT }, ...body }),
onload: (r) => {
const text = r.responseText || "";
let json = null;
try {
json = JSON.parse(text);
} catch (e) {
}
logDebug(`YouTube Music: ${endpoint} ${what} \u2192 HTTP ${r.status}, ${text.length}b in ${Date.now() - t0}ms`);
if (r.status !== 200 || !json) {
reject(new Error(`YouTube Music ${endpoint} ${what} \u2192 HTTP ${r.status}${r.status === 200 ? ", not JSON" : ""} \u2014 YouTube Music's API may have changed`));
return;
}
resolve(json);
},
onerror: () => reject(new Error(`YouTube Music ${endpoint} ${what}: network error`)),
ontimeout: () => reject(new Error(`YouTube Music ${endpoint} ${what}: timed out`))
});
});
}
var ytmText = (t) => t && t.runs ? t.runs.map((x) => x.text).join("") : t && t.simpleText || "";
function ytmWalk(o, fn) {
if (!o || typeof o !== "object") return;
fn(o);
for (const k in o) ytmWalk(o[k], fn);
}
var videoType = (w) => w && w.watchEndpointMusicSupportedConfigs && w.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig && w.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType;
function ytmRows(json) {
const rows = [];
ytmWalk(json, (o) => {
const r = o.musicResponsiveListItemRenderer;
if (!r) return;
let w = null;
ytmWalk(r, (x) => {
if (!w && x.watchEndpoint && x.watchEndpoint.videoId) w = x.watchEndpoint;
});
const id = r.playlistItemData && r.playlistItemData.videoId || w && w.videoId;
if (!id) return;
const col = r.flexColumns && r.flexColumns[0] && r.flexColumns[0].musicResponsiveListItemFlexColumnRenderer;
rows.push({ title: ytmText(col && col.text), videoId: id, type: videoType(w) || null });
});
return rows;
}
function ytmCreditSections(json) {
const out = {};
ytmWalk(json, (o) => {
const s = o.dismissableDialogContentSectionRenderer;
if (s) out[ytmText(s.title)] = (s.subtitle && s.subtitle.runs || []).map((r) => r.text.trim()).filter(Boolean);
});
return out;
}
async function fetchYtmCredits(url, onProgress) {
const parsed = parseYtmAlbumUrl(url);
if (!parsed) throw new Error(`Not a YouTube Music album link: ${url}`);
let list = parsed.list, album = "";
if (!list) {
const a = await ytmPost("browse", { browseId: parsed.album });
const canon = a.microformat && a.microformat.microformatDataRenderer && a.microformat.microformatDataRenderer.urlCanonical || "";
list = (canon.match(/[?&]list=(OLAK5uy_[\w-]+)/) || JSON.stringify(a).match(/"(OLAK5uy_[\w-]+)"/) || [])[1];
let h = null;
ytmWalk(a, (o) => {
if (!h && o.musicResponsiveHeaderRenderer) h = o.musicResponsiveHeaderRenderer;
});
album = h ? ytmText(h.title) : "";
log.info(`YouTube Music: album ${parsed.album} "${album}" \u2192 playlist ${list || "(none found)"}`);
if (!list) throw new Error(`YouTube Music: album ${parsed.album} names no playlist \u2014 its page may have changed`);
}
const pl = await ytmPost("browse", { browseId: "VL" + list });
if (!album) {
let h = null;
ytmWalk(pl, (o) => {
if (!h && (o.musicResponsiveHeaderRenderer || o.musicDetailHeaderRenderer)) h = o.musicResponsiveHeaderRenderer || o.musicDetailHeaderRenderer;
});
album = h ? ytmText(h.title) : "";
}
if (/"continuations?"|"continuationItemRenderer"/.test(JSON.stringify(pl))) log.warn(`YouTube Music: playlist ${list} has more songs than its first page \u2014 only those are read`);
const rows = ytmRows(pl);
const types = rows.reduce((m, r) => {
const k = (r.type || "?").replace("MUSIC_VIDEO_TYPE_", "");
m[k] = (m[k] || 0) + 1;
return m;
}, {});
log.info(`YouTube Music: playlist ${list} "${album}" \u2014 ${rows.length} row(s) (${Object.entries(types).map(([k, n]) => `${n} ${k}`).join(", ") || "none"})`);
const songs = [];
let done = 0;
for (const r of rows) {
let sections = {};
if (r.type && r.type !== "MUSIC_VIDEO_TYPE_ATV") logDebug(`YouTube Music: row ${done + 1} "${r.title}" is a ${r.type}, not a song \u2014 it has no credits`);
else {
try {
sections = ytmCreditSections(await ytmPost("browse", { browseId: "MPTC" + r.videoId }));
} catch (e) {
log.warn(`YouTube Music: credits of "${r.title}" (${r.videoId}) failed \u2014 ${e.message}`);
}
}
logDebug(`YouTube Music: ${done + 1}. "${r.title}" (${r.videoId}) \u2014 ${Object.keys(sections).map((k) => `${k}: ${sections[k].join(", ")}`).join(" \xB7 ") || "no credits"}`);
songs.push({ title: r.title, videoId: r.videoId, sections });
done++;
if (onProgress) {
try {
onProgress(done, rows.length);
} catch (e) {
}
}
}
return { album, list, songs };
}
// src/sources/deezer.js
function decodeEntities2(s) {
return String(s).replace(/(\d+);/g, (_, n) => String.fromCodePoint(+n)).replace(/([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16))).replace(/&/g, "&").replace(/"/g, '"').replace(/</g, "<").replace(/>/g, ">").replace(/ /g, " ").replace(/'/g, "'").replace(/'/g, "'");
}
var DEEZER_LABEL_MAP = {
"Composers": { rel: "composer" },
"Composer": { rel: "composer" },
"Authors": { rel: "lyricist" },
"Author": { rel: "lyricist" },
"Writers": { rel: "writer" },
"Writer": { rel: "writer" },
"Producers": { rel: "producer" },
"Producer": { rel: "producer" }
};
var DEEZER_ALBUM_RE = /^(?:https?:)?\/\/(?:www\.)?deezer\.com\/(?:[a-z]{2}\/)?album\/(\d+)/i;
function parseDeezerAlbumUrl(url) {
const m = DEEZER_ALBUM_RE.exec(url || "");
return m ? { id: m[1], pageUrl: `https://www.deezer.com/us/album/${m[1]}` } : null;
}
var DEEZER_NO_INFO_RE = /^\s*no\s*info\s*$/i;
function parseDeezerCreditLine(line) {
const text = decodeEntities2(String(line)).trim();
const byName = /* @__PURE__ */ new Map();
for (const group of text.split(" / ")) {
const m = /^([^:]+):\s*(.*)$/.exec(group.trim());
if (!m) continue;
const plan = DEEZER_LABEL_MAP[m[1].trim()];
if (!plan) continue;
for (const raw of m[2].split(/\s*,\s*|\s+-\s+/)) {
const name = raw.trim();
if (!name || DEEZER_NO_INFO_RE.test(name)) continue;
if (!byName.has(name)) byName.set(name, []);
const roles = byName.get(name);
if (!roles.includes(plan.rel)) roles.push(plan.rel);
}
}
return [...byName].map(([name, roles]) => ({ name, roles }));
}
function extractDeezerCredits(html) {
const posByTrack = /* @__PURE__ */ new Map();
const posRe = /itemid="\/[a-z]{2}\/track\/(\d+)"[\s\S]{0,600}?data-target="position">\s*(\d+)/gi;
let pm;
while ((pm = posRe.exec(html)) !== null) {
const id = pm[1];
if (!posByTrack.has(id)) posByTrack.set(id, parseInt(pm[2], 10));
}
const out = [];
const credRe = /naboo_datagrid_contributors_(\d+)"[\s\S]{0,400}?data-target="contributors">([^<]*) t.index);
parsedTracks.forEach((t, i) => {
const track = { position: positions[i], title: "", type_: "track" };
tracklist.push(track);
for (const credit of t.credits) {
for (const rel of credit.roles) {
tracklistRels.push({
linkType: rel,
entityType: "artist",
attributes: [],
artist: { name: credit.name, anv: "", resource_url: "" },
track
});
}
}
});
return { tracklistRels, tracklist, skipped, multiVolume };
}
function fetchDeezerAlbumPage(pageUrl) {
return new Promise((resolve, reject) => {
if (typeof GM_xmlhttpRequest !== "function") {
reject(new Error("GM_xmlhttpRequest unavailable"));
return;
}
GM_xmlhttpRequest({
method: "GET",
url: pageUrl,
headers: { "Accept": "text/html,application/xhtml+xml", "Accept-Language": "en-US,en;q=0.8" },
timeout: 2e4,
onload: (r) => r.status >= 200 && r.status < 400 && r.responseText ? resolve(r.responseText) : reject(new Error(`Deezer page returned ${r.status}`)),
onerror: () => reject(new Error("Deezer page fetch failed (network)")),
ontimeout: () => reject(new Error("Deezer page fetch timed out"))
});
});
}
// src/consolidate.js
var fold = (s) => String(s || "").normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/\s+/g, " ").trim();
var entityKeyOf = (a) => a && a.resource_url ? a.resource_url : `_nourl_${a && (a.name || a.id) || ""}`;
var attrsSig = (rel) => JSON.stringify(
(rel.attributes || []).map((x) => x && typeof x === "object" && x._type ? `${x._type}:${x.value}` : String(x)).sort()
);
var relKeyOf = (rel) => [
entityKeyOf(rel.artist),
rel.linkType || "",
attrsSig(rel),
rel.track ? String(rel.track.position != null ? rel.track.position : "") : ""
].join("\x01");
function mergeHarvests(harvests) {
const mkDedup = () => {
const seen = /* @__PURE__ */ new Map(), order = [];
return {
add(rel, source) {
const k = relKeyOf(rel);
if (!seen.has(k)) {
seen.set(k, { rel, sources: /* @__PURE__ */ new Set() });
order.push(k);
}
seen.get(k).sources.add(source);
},
drain(relSrc2, entitySrc2) {
const arr = [];
for (const k of order) {
const e = seen.get(k);
arr.push(e.rel);
relSrc2.set(k, [...e.sources]);
const ek = entityKeyOf(e.rel.artist);
if (!entitySrc2.has(ek)) entitySrc2.set(ek, /* @__PURE__ */ new Set());
e.sources.forEach((s) => entitySrc2.get(ek).add(s));
}
return arr;
}
};
};
const relSrc = /* @__PURE__ */ new Map(), entitySrc = /* @__PURE__ */ new Map();
const arD = mkDedup(), trD = mkDedup();
const companies = [], seenCo = /* @__PURE__ */ new Set();
const tracklist = [], seenPos = /* @__PURE__ */ new Set();
let processTracklist = false;
for (const h of harvests || []) {
if (!h) continue;
const src = h.sourceName || "Source";
if (h.processTracklist) processTracklist = true;
(h.artistRoles || []).forEach((r) => arD.add(r, src));
(h.tracklistRels || []).forEach((r) => trD.add(r, src));
(h.companies || []).forEach((c) => {
const ck = c.resource_url || `co:${fold(c.entity_type_name)}|${fold(c.name)}`;
if (ck && !seenCo.has(ck)) {
seenCo.add(ck);
companies.push(c);
}
const ek = entityKeyOf(c);
if (!entitySrc.has(ek)) entitySrc.set(ek, /* @__PURE__ */ new Set());
entitySrc.get(ek).add(src);
});
(h.tracklist || []).forEach((t) => {
const pos = String(t && t.position != null ? t.position : "");
const key = pos || `#${tracklist.length}`;
if (!seenPos.has(key)) {
seenPos.add(key);
tracklist.push(t);
}
});
}
const artistRoles = arD.drain(relSrc, entitySrc);
const tracklistRels = trD.drain(relSrc, entitySrc);
const entitySources = /* @__PURE__ */ new Map();
entitySrc.forEach((set, k) => entitySources.set(k, [...set]));
return { companies, artistRoles, tracklistRels, tracklist, processTracklist, relSrc, entitySources };
}
var _resultKey = (r) => r && r.entity && (r.entity.resource_url || r.entity._syntheticKey) || `_nourl_${r && (r.entity && r.entity.name || r.displayName) || ""}`;
var _resultName = (r) => r && (r.entity && r.entity.name || r.displayName) || "";
var _resultKind = (r) => r && (r.entityType || r.entity && r.entity.entityType) || "artist";
var _roleKey = (ro) => [ro.linkType, ro.displayLabel, ro.trackPos, ro.trackTitle].join("");
var boundedLev = (a, b, max) => {
if (Math.abs(a.length - b.length) > max) return -1;
const dp = Array.from({ length: a.length + 1 }, (_, i) => i);
for (let j = 1; j <= b.length; j++) {
let prev = dp[0];
dp[0] = j;
let rowMin = dp[0];
for (let i = 1; i <= a.length; i++) {
const tmp = dp[i];
dp[i] = Math.min(dp[i] + 1, dp[i - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
prev = tmp;
if (dp[i] < rowMin) rowMin = dp[i];
}
if (rowMin > max) return -1;
}
return dp[a.length] <= max ? dp[a.length] : -1;
};
var fuzzyMax = (len) => len <= 6 ? 0 : len <= 12 ? 1 : 2;
var stripInitials = (fn) => fn.split(" ").filter((t) => !/^[a-z]\.?$/.test(t)).join(" ");
function mergeResolvedResults(allResults, entitySources) {
const rows = (allResults || []).filter(Boolean);
const nameMbids = /* @__PURE__ */ new Map();
const nameMbidsStripped = /* @__PURE__ */ new Map();
for (const r of rows) {
if (r.type !== "resolved" || !r.mbUrl) continue;
const fn = fold(_resultName(r));
if (!fn) continue;
const kn = _resultKind(r) + "|" + fn;
if (!nameMbids.has(kn)) nameMbids.set(kn, /* @__PURE__ */ new Set());
nameMbids.get(kn).add(r.mbUrl);
const sn = _resultKind(r) + "|" + stripInitials(fn);
if (!nameMbidsStripped.has(sn)) nameMbidsStripped.set(sn, /* @__PURE__ */ new Set());
nameMbidsStripped.get(sn).add(r.mbUrl);
}
const conflictNames = /* @__PURE__ */ new Set();
nameMbids.forEach((set, kn) => {
if (set.size > 1) conflictNames.add(kn);
});
const uniqResolved = [];
nameMbids.forEach((set, kn) => {
if (set.size === 1) uniqResolved.push([kn, [...set][0]]);
});
const fuzzyResolvedMatch = (kfn) => {
const [kind, fn] = [kfn.slice(0, kfn.indexOf("|")), kfn.slice(kfn.indexOf("|") + 1)];
let hit = null;
for (const [kn, url] of uniqResolved) {
if (!kn.startsWith(kind + "|")) continue;
const nm = kn.slice(kind.length + 1);
if (boundedLev(fn, nm, fuzzyMax(Math.max(fn.length, nm.length))) < 0) continue;
if (hit && hit !== url) return null;
hit = url;
}
return hit;
};
const keyFor = (r) => {
const fn = fold(_resultName(r));
const kn = fn ? _resultKind(r) + "|" + fn : "";
if (kn && conflictNames.has(kn)) return "cf:" + kn;
if (r.type === "resolved" && r.mbUrl) return "mb:" + r.mbUrl;
if (!fn) return null;
const set = nameMbids.get(kn);
if (set && set.size === 1) return "mb:" + [...set][0];
if (!set) {
const stripped = nameMbidsStripped.get(_resultKind(r) + "|" + stripInitials(fn));
if (stripped && stripped.size === 1) return "mb:" + [...stripped][0];
}
const fuzzy = fuzzyResolvedMatch(kn);
if (fuzzy) return "mb:" + fuzzy;
return "nm:" + kn;
};
const byKey = /* @__PURE__ */ new Map(), mergeMap = /* @__PURE__ */ new Map(), out = [];
for (const r of rows) {
const gk = keyFor(r), rk = _resultKey(r);
if (!gk || !byKey.has(gk)) {
if (gk) {
byKey.set(gk, r);
mergeMap.set(rk, [rk]);
if (gk.startsWith("cf:")) r._conflicts = [r.mbUrl ? { mbUrl: r.mbUrl, mbName: r.mbName, mbDisambig: r.mbDisambig } : null];
}
out.push(r);
continue;
}
const rep = byKey.get(gk), repKey = _resultKey(rep);
if (gk.startsWith("cf:")) {
if (r.mbUrl) (rep._conflicts = rep._conflicts || []).push({ mbUrl: r.mbUrl, mbName: r.mbName, mbDisambig: r.mbDisambig });
} else if ((rep.type !== "resolved" || !rep.mbUrl) && r.type === "resolved" && r.mbUrl) {
rep.type = "resolved";
rep.mbUrl = r.mbUrl;
rep.mbName = r.mbName;
rep.mbDisambig = r.mbDisambig;
rep.entityType = r.entityType || rep.entityType;
rep.logEntry = r.logEntry || rep.logEntry;
}
const seen = new Set((rep._roles || []).map(_roleKey));
rep._roles = (rep._roles || []).concat((r._roles || []).filter((ro) => {
const k = _roleKey(ro);
if (seen.has(k)) return false;
seen.add(k);
return true;
}));
if (entitySources) {
const u = new Set(entitySources.get(repKey) || []);
(entitySources.get(rk) || []).forEach((s) => u.add(s));
entitySources.set(repKey, [...u]);
}
mergeMap.get(repKey).push(rk);
}
byKey.forEach((rep, gk) => {
if (!gk.startsWith("cf:")) return;
const cands = [], seenIds = /* @__PURE__ */ new Set();
for (const c of rep._conflicts || []) {
const m = c && c.mbUrl && c.mbUrl.match(/\/(?:artist|label|place)\/([^/?#]+)/i);
if (!m || seenIds.has(m[1])) continue;
seenIds.add(m[1]);
cands.push({ id: m[1], name: c.mbName || _resultName(rep), disambiguation: c.mbDisambig || "" });
}
rep.nameMatches = cands.concat((rep.nameMatches || []).filter((nm) => nm && nm.id && !seenIds.has(nm.id)));
rep.type = "attention";
rep.mbUrl = null;
rep.mbName = null;
rep.mbDisambig = "";
rep.ambiguityReason = "sources link this name to different MB artists";
delete rep._conflicts;
});
for (const rep of out) {
const keys = mergeMap.get(_resultKey(rep));
rep._mergeUrls = [...new Set((keys || []).filter((k) => /^https?:\/\//i.test(k) && !/tidal\.com\/_(?:publisher|company)\//i.test(k)).map((k) => k.replace(/^https?:\/\/api\.discogs\.com\/(\w+?)s\/(\d+).*$/i, "https://www.discogs.com/$1/$2")))];
}
return { results: out, mergeMap };
}
// src/ui-bar.js
var _logs2;
var _summary;
var _discogsJson = null;
var _tidalJson = null;
var _maJson = null;
var _qobuzJson = null;
var _deezerJson = null;
var _appleJson = null;
var _ytmJson = null;
var _consolidatedJson = null;
var ST_ICONS = { "musicbrainz": { "color": "#eb743b", "svg": '' }, "discogs": { "color": "#333333", "svg": '' }, "spotify": { "color": "#1DB954", "svg": '' }, "apple": { "color": "#FA243C", "svg": '' }, "deezer": { "color": "#A238FF", "svg": '' }, "tidal": { "color": "#000000", "svg": '' }, "qobuz": { "color": "#0070ef", "svg": '' }, "beatport": { "color": "#01FF95", "svg": '' }, "bandcamp": { "color": "#629AA9", "svg": '' }, "volumo": { "color": "#7c4dff", "svg": '' }, "hdtracks": { "color": "#e63329", "svg": '' }, "soundcloud": { "color": "#ff5500", "svg": '' }, "audiomack": { "color": "#FFA200", "svg": '' }, "sevendigital": { "color": "#07606E", "svg": '' }, "ytmusic": { "color": "#FF0000", "svg": '' }, "amazonmusic": { "color": "#25D1DA", "svg": '' }, "soundexchange": { "color": "#6f42c1", "svg": '' }, "globe": { "color": "#6f7d75", "svg": '' } };
function stIcon(name, size) {
var i = ST_ICONS[name];
if (!i) return "";
size = size || 16;
return i.svg.replace(/