// ==UserScript==
// @name ISRC Scout
// @namespace https://musicbrainz.org/
// @version 2026.10.4
// @description Scout ISRCs for a MusicBrainz release: reads existing ISRCs, finds missing ones on SoundExchange / Deezer / Spotify / Beatport / Tidal / Volumo / HDtracks / Qobuz, bulk paste & import/export, submits directly to MB (one-time OAuth, never depends on MagicISRC).
// @author majkinetor
// @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4IiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+CiAgPHRpdGxlPklTUkMgU2NvdXQ8L3RpdGxlPgogICAgPHBhdGggZD0iTTY0IDY0IEw2NCAyNCBBNDAgNDAgMCAwIDEgOTkgODQgWiIgZmlsbD0iI2UzZDhmNyIvPgogIDxnIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzZmNDJjMSIgc3Ryb2tlLXdpZHRoPSI2Ij4KICAgIDxjaXJjbGUgY3g9IjY0IiBjeT0iNjQiIHI9IjQwIi8+CiAgICA8Y2lyY2xlIGN4PSI2NCIgY3k9IjY0IiByPSIyNiIgc3Ryb2tlLXdpZHRoPSI0IiBzdHJva2U9IiNiOWEzZTgiLz4KICAgIDxjaXJjbGUgY3g9IjY0IiBjeT0iNjQiIHI9IjEzIiBzdHJva2Utd2lkdGg9IjQiIHN0cm9rZT0iI2I5YTNlOCIvPgogIDwvZz4KICA8bGluZSB4MT0iNjQiIHkxPSI2NCIgeDI9IjY0IiB5Mj0iMjQiIHN0cm9rZT0iIzZmNDJjMSIgc3Ryb2tlLXdpZHRoPSI2IiBzdHJva2UtbGluZWNhcD0icm91bmQiLz4KICA8Y2lyY2xlIGN4PSI4NiIgY3k9IjUwIiByPSI3IiBmaWxsPSIjNGIyZTgzIi8+Cjwvc3ZnPgo=
// @homepageURL https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/isrc_scout/README.md
// @match https://*.musicbrainz.org/release/*
// @match https://*.musicbrainz.org/oauth2/oob*
// @match https://www.beatport.com/release/*
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_openInTab
// @grant unsafeWindow
// @connect musicbrainz.org
// @connect beta.musicbrainz.org
// @connect isrc-api.soundexchange.com
// @connect isrc.soundexchange.com
// @connect api.deezer.com
// @connect isrchunt.com
// @connect isrc.mollamusicgroup.com
// @connect openapi.tidal.com
// @connect auth.tidal.com
// @connect volumo.com
// @connect hdtracks.azurewebsites.net
// @connect www.qobuz.com
// @connect qobuz.com
// @connect api.beatport.com
// @connect bandcamp.com
// @connect music.apple.com
// @connect amp-api.music.apple.com
// @connect soundcloud.com
// @connect api-v2.soundcloud.com
// @connect api.audiomack.com
// @connect api.7digital.com
// @connect us.7digital.com
// @connect js-cdn.7digital.com
// @connect a-v2.sndcdn.com
// @connect open.spotify.com
// @connect music.youtube.com
// @connect music.amazon.com
// @connect na.mesk.skill.music.a2z.com
// @run-at document-start
// ==/UserScript==
/*
* ─────────────────────────────────────────────────────────────────────────
* SETUP (one time, ever)
* ─────────────────────────────────────────────────────────────────────────
* Submitting ISRCs to MusicBrainz requires authentication. This script uses
* OAuth with the `submit_isrc` scope and `access_type=offline`, so you
* authorize EXACTLY ONCE — the refresh token is stored locally and is used
* to silently mint short-lived access tokens forever after.
*
* The OAuth app is baked in, so there's nothing to register:
* open the editor (the "ISRC" button on a release page) → ⚙ Setup → Authorize,
* approve in the MusicBrainz tab, paste the code it shows back. Done forever.
*
* Everything except the final "Submit" runs without any credentials.
* Trouble? Open the editor's "Log" pane — every action is recorded there.
* ─────────────────────────────────────────────────────────────────────────
*/
(function () {
'use strict';
// one copy per page: with String Theory and a standalone install both on, the newer one runs (#653)
if (!mbuClaim('isrc_scout', 'ISRC Scout')) return;
// the shared MusicBrainz request gate (#633) — first, so nothing can call it before its consts exist
// — generated by dev/net/sync-gate.mjs from dev/net/mb-gate.mjs — DO NOT EDIT
const MBN_GAP = 1000;
const MBN_BURST = 3;
const MBN_KEY = "mbu:mb-gate";
const MBN_LOCK = "mbu-mb-gate";
const MBN_MAX_HOLD = 60000;
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; // no storage: this page's copy only
else if (!s || typeof s !== 'object') s = { tat: 0, cool: 0, hot: 0 }; // none yet (or cleared): a fresh schedule
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;
};
// The lock is held only for rw()'s synchronous moment, so a wait of seconds means it is
// stuck (a frozen or sandboxed holder): give up on it and go unlocked rather than hang
// every request behind it.
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(), 3000);
return await navigator.locks.request(MBN_LOCK, ctrl ? { signal: ctrl.signal } : {}, rw);
}
} catch (e) { /* no Web Locks here (an insecure or sandboxed context), or stuck: unlocked, like a lone script */ } 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(); // a hold that appeared while we queued
if (left <= 0) break;
if (!told && until - t0 > 1000 && 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))); // in slices, so a cancel lands promptly
}
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 * 1000 : Number.isFinite(date) ? Math.max(0, date - Date.now()) : 0;
const hold = await mbnState(s => {
const now = Date.now();
// One throttle, one step: the requests already in flight when it began all answer
// 503 too, and each must not stretch the hold again. A throttle long gone doesn't
// count at all: `hot` is shared and persists, and would otherwise slow every later
// page for good.
if (now > (s.cool || 0) + 30000) s.hot = 0;
if (now >= (s.cool || 0)) s.hot = Math.min((s.hot || 0) + 1, 6);
const ms = Math.min(Math.max(1000, 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 + ')' : '') + ' — 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) + 30000 ? 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;
}
}
function mbnHot() {
let s;
try { s = JSON.parse(localStorage.getItem(MBN_KEY) || 'null') || {}; } catch (e) { s = globalThis.__mbnGate || {}; }
return Date.now() > (s.cool || 0) + 30000 ? 0 : (s.hot || 0); // a throttle long gone is over
}
function mbnRawHeader(raw, name) {
const want = String(name).toLowerCase();
for (const line of String(raw || '').split(/\r?\n/)) {
const i = line.indexOf(':');
if (i > 0 && line.slice(0, i).trim().toLowerCase() === want) return line.slice(i + 1).trim();
}
return null;
}
//
/* ═══════════════════════════════════════════════════════════════════════
TIMERS — Firefox + Violentmonkey can throw "called on incompatible object"
when a native timer is invoked with the wrong `this` (sandbox/Xray quirk).
Bind them to the window so every call has the right receiver.
═══════════════════════════════════════════════════════════════════════ */
const _timerHost = (typeof window !== 'undefined' && window) || globalThis;
const _setTimeout = _timerHost.setTimeout.bind(_timerHost);
const _setInterval = _timerHost.setInterval.bind(_timerHost);
/* ═══════════════════════════════════════════════════════════════════════
OAUTH OUT-OF-BAND CODE CATCHER
After you approve, MusicBrainz lands on /oauth2/oob?code=… showing the code.
Grab it, hand it to the editor tab via GM storage, and close this tab — so
you never have to copy/paste the code.
═══════════════════════════════════════════════════════════════════════ */
if (/oauth2\/oob$/.test(location.pathname)) {
const code = new URLSearchParams(location.search).get('code');
if (code) {
try { GM_setValue('ii:oauth_oob_code', { code: code, ts: Date.now() }); } catch (e) {}
const finishOob = () => {
try { window.close(); } catch (e) {}
// Browsers block window.close() on a tab that has navigated (authorize → oob);
// the editor tab also tries to close this popup, but if it's still here, show a
// clear confirmation so it's obvious it worked.
_setTimeout(() => {
try {
window.close();
document.title = '✓ Authorized — you can close this tab';
if (document.body) document.body.innerHTML =
'
' +
'
✓ Authorized
ISRC Scout captured the code. You can close this tab.
';
} catch (e) {}
}, 500);
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', finishOob, { once: true });
else finishOob();
}
return; // never run the editor on the oob page
}
/* ═══════════════════════════════════════════════════════════════════════
BEATPORT HARVESTER (runs ON beatport.com)
Beatport is Cloudflare-walled, so a cross-origin GM_xmlhttpRequest from
MusicBrainz is always challenged. Instead, when the editor opens a release
in a brief background tab, THIS instance (matched on beatport.com) reads
the ISRCs straight out of the page's embedded __NEXT_DATA__ in real page
context — where Cloudflare is already satisfied — stashes them in shared
GM storage for the MB tab to pick up, then closes itself.
═══════════════════════════════════════════════════════════════════════ */
if (/(^|\.)beatport\.com$/i.test(location.hostname)) {
const bpId = (location.pathname.match(/\/release\/[^/]+\/(\d+)/) || [])[1];
if (!bpId) return;
const grab = () => {
const el = document.getElementById('__NEXT_DATA__');
if (!el) return false;
let j; try { j = JSON.parse(el.textContent); } catch (e) { return false; }
const qs = (((j || {}).props || {}).pageProps || {}).dehydratedState;
const queries = (qs && qs.queries) || [];
let results = null;
for (const q of queries) {
const r = q && q.state && q.state.data && q.state.data.results;
if (Array.isArray(r) && r.length && ('isrc' in r[0])) { results = r; break; }
}
if (!results) return false;
// #457: Beatport's release PAGE embeds the tracklist id-DESCENDING (the reverse of album
// order) and carries no `number`, so the index-based position below came out reversed vs the
// authenticated API (which serves the same tracks id-ASCENDING = album order). Sort by id
// ascending first, so the harvest agrees with the API and MB.
const ordered = [...results].sort((a, b) => (Number(a.id) || 0) - (Number(b.id) || 0));
const tracks = ordered.map((t, i) => {
const mix = t.mix_name && !/^original mix$/i.test(t.mix_name) && !String(t.name || '').toLowerCase().includes('(' + String(t.mix_name).toLowerCase() + ')') ? ' (' + t.mix_name + ')' : ''; // #643: not twice
return {
isrc: String(t.isrc || '').toUpperCase().replace(/[\s-]/g, ''),
title: (t.name || '') + mix,
artist: (t.artists || []).map(a => a && a.name).filter(Boolean).join(', '),
disc: 1,
pos: t.number || (i + 1),
dur: t.length || '',
url: (t.slug && t.id) ? 'https://www.beatport.com/track/' + t.slug + '/' + t.id : '', // #387 per-track link
};
});
try { GM_setValue('ii:beatport_harvest_' + bpId, { ts: Date.now(), tracks: tracks }); } catch (e) {}
return true;
};
const t0 = Date.now();
const tick = () => {
if (grab()) {
// Only self-close when the editor's background harvest opened this tab
// (it sets a per-release close flag first). A tab the USER opened — or
// one Platform Check's ↗/link opened — must stay put; we still harvest
// it (populating the cache) but leave it on screen.
try {
const flag = GM_getValue('ii:beatport_close_' + bpId, 0);
if (flag && (Date.now() - flag < 120000)) { GM_deleteValue('ii:beatport_close_' + bpId); window.close(); }
} catch (e) {}
return;
}
if (Date.now() - t0 > 90000) return; // Cloudflare never cleared — give up silently
window.setTimeout(tick, 500);
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', tick, { once: true });
else tick();
return; // never run the MB editor on a Beatport page
}
/* ═══════════════════════════════════════════════════════════════════════
CONSTANTS
═══════════════════════════════════════════════════════════════════════ */
const MB_ROOT = location.origin; // musicbrainz.org or beta
const MB_WS2 = MB_ROOT + '/ws/2/';
// Derive from the installed @version so the banner/CLIENT never drift out of
// sync with the metadata again (the hardcoded constant kept lagging behind).
const SCRIPT_VERSION = (() => {
try { return (typeof GM_info !== 'undefined' && GM_info.script && GM_info.script.version) || '2026.6.27'; } catch (e) { return '2026.6.27'; }
})();
const SCRIPT_URL = 'https://github.com/majkinetor/musicbrainz-userscripts/tree/main/userscripts/isrc_scout';
const CLIENT = 'isrc_scout-' + SCRIPT_VERSION;
const UA = 'MB-ISRC-Scout/1.0';
const SX_API = 'https://isrc-api.soundexchange.com/api/ext/recordings';
const SX_HOME = 'https://isrc.soundexchange.com/';
const BATCH_DELAY = 650;
const TIDAL_TRACK_DELAY = 350; // pace per-track Tidal lookups under its rate limit
const SX_BATCH_LIMIT = 30; // max individual SoundExchange searches per batch (avoid being blocked)
const STREAM_BATCH_LIMIT = 50; // max per-track Deezer fetches per batch (1000-track releases would spam Deezer)
const ISRC_RE = /^[A-Z]{2}[A-Z0-9]{3}[0-9]{7}$/;
// Shared, pre-registered MusicBrainz OAuth app (type: Installed application,
// redirect urn:ietf:wg:oauth:2.0:oob, scope submit_isrc). Baked in so users only
// click "Authorize" once — no per-user app registration. The secret is not truly
// confidential for an installed app (same model as MagicISRC / isrchunt).
const OAUTH = {
clientId: 'axXnet_AiWglKOQEVSiM8xF6EAlKFBzM',
clientSecret: 'gi-S0GuLeKtOgFs5QRZAEEVATD4Lo6l9',
authUrl: MB_ROOT + '/oauth2/authorize',
tokenUrl: MB_ROOT + '/oauth2/token',
redirect: 'urn:ietf:wg:oauth:2.0:oob',
scope: 'submit_isrc',
};
// Baked-in Tidal API app. The client-credentials grant yields an app-level
// token with TIDAL Catalog access (no user login) — same "shared installed
// app" trust model as the MB OAuth app above.
const TIDAL = {
clientId: 'cRhhDJDpYXXBn82U',
clientSecret: 'K7UX40jDOZ5p4y4JMYZgoiwKi7jymTHWcLMb4gkewKs=',
tokenUrl: 'https://auth.tidal.com/v1/oauth2/token',
api: 'https://openapi.tidal.com/v2',
country: 'US',
};
// Shared platform icons (#404) — stIcon(name, size) / stColor(name). Source of truth is
// dev/ui/platform-icons.mjs; the block below is generated by dev/ui/sync-icons.mjs (pre-commit hook).
// — generated by dev/ui/sync-icons.mjs from dev/ui/platform-icons.mjs — DO NOT EDIT
const 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(/
// Import-source buttons are keyed by short code → map each to the shared icon (uniform 16px).
const SRC_ICON = {
dz: stIcon('deezer', 16), sp: stIcon('spotify', 16), bp: stIcon('beatport', 16),
td: stIcon('tidal', 16), vo: stIcon('volumo', 16), hd: stIcon('hdtracks', 16),
qz: stIcon('qobuz', 16), bc: stIcon('bandcamp', 16), am: stIcon('apple', 16),
sc: stIcon('soundcloud', 16), yt: stIcon('ytmusic', 16), az: stIcon('amazonmusic', 16),
ak: stIcon('audiomack', 16), sd: stIcon('sevendigital', 16),
};
// MBIDs are canonically lowercase, but MB serves an upper-/mixed-case URL as-is
// (no redirect), so match case-insensitively and normalise — otherwise a link like
// /release/Eb13342a-… left `mbid` undefined and the whole button never injected.
const mbid = location.pathname.match(/\/release\/([a-f0-9-]{36})/i)?.[1]?.toLowerCase();
if (!mbid) return;
/* ═══════════════════════════════════════════════════════════════════════
GM STORAGE HELPERS
═══════════════════════════════════════════════════════════════════════ */
// #501 follow-up (majkinetor: "tidy up config prefixes, some have them,
// some don't") — every GM-stored key now carries the `ii:` prefix,
// transparently: call sites still pass the bare name (`store.get('col_widths', …)`),
// `store` prepends it. One-time, non-destructive migration folded into
// `get`: if the new prefixed key is empty but the old bare one has a value,
// adopt it (and write it through under the new name); the old key is left
// in place, unused.
const store = {
get: (k, d) => { try { const v = GM_getValue('ii:' + k, undefined); if (v !== undefined) return v; const old = GM_getValue(k, undefined); if (old !== undefined) { GM_setValue('ii:' + k, old); return old; } return d; } catch (e) { return d; } },
set: (k, v) => { try { GM_setValue('ii:' + k, v); } catch (e) {} },
del: (k) => { try { GM_deleteValue('ii:' + k); } catch (e) {} },
};
// majkinetor: "Tokens should go into GM storage, so that we don't have to
// initialize plugins on every place" — auth tokens, INCLUDING the
// short-lived derived ones, stay on `store` (GM) on purpose; localStorage
// below is only for things that are genuinely local, like a per-release
// in-progress draft.
const localStore = {
get: (k, d) => { try { const v = localStorage.getItem('ii:' + k); if (v !== null) return JSON.parse(v); const old = localStorage.getItem(k); if (old !== null) { const parsed = JSON.parse(old); localStorage.setItem('ii:' + k, old); return parsed; } return d; } catch (e) { return d; } },
set: (k, v) => { try { localStorage.setItem('ii:' + k, JSON.stringify(v)); } catch (e) {} },
del: (k) => { try { localStorage.removeItem('ii:' + k); } catch (e) {} },
};
if (mbuTestHooks()) window.__isrcScoutTestStore = { store, localStore }; // test hook only (#501) — no behaviour change
/* ═══════════════════════════════════════════════════════════════════════
GENERIC HTTP (GM_xmlhttpRequest promisified)
═══════════════════════════════════════════════════════════════════════ */
const _inflight = new Set(); // live GM requests, so batched SoundExchange work can be aborted (#127)
function httpRaw(opts) {
const t0 = Date.now();
const tag = (opts.method || 'GET') + ' ' + shortUrl(opts.url);
Log.net('→ ' + tag);
return new Promise((resolve, reject) => {
const entry = { url: opts.url, handle: null };
const done = () => _inflight.delete(entry);
entry.handle = GM_xmlhttpRequest(Object.assign({
timeout: 20000,
onload: r => {
done();
const ms = Date.now() - t0;
if (r.status >= 200 && r.status < 300) Log.net('← ' + r.status + ' ' + tag + ' (' + ms + 'ms)');
else Log.warn('← ' + r.status + ' ' + tag + ' (' + ms + 'ms) ' + String(r.responseText || '').replace(/\s+/g, ' ').slice(0, 160));
resolve(r);
},
onerror: () => { done(); Log.err('✗ network ' + tag); reject(new Error('network error')); },
ontimeout: () => { done(); Log.err('✗ timeout ' + tag); reject(new Error('timeout')); },
onabort: () => { done(); reject(new Error('aborted')); },
}, opts));
_inflight.add(entry);
});
}
// Abort in-flight GM requests whose URL contains `urlSubstr` (cancels batched SoundExchange work). #127
function abortInflight(urlSubstr) {
[..._inflight].forEach(e => {
if (!urlSubstr || (e.url && e.url.indexOf(urlSubstr) !== -1)) {
try { e.handle && e.handle.abort && e.handle.abort(); } catch (x) {}
_inflight.delete(e);
}
});
}
// MusicBrainz's /ws/2 (the release reads, the ISRC submission) goes through the shared request
// gate (#633): its slot first, and its hold after a 429/503, which the next call waits out.
const gateLog = (lv, m) => (lv === 'warn' ? Log.warn : Log.info)(m);
async function http(opts) {
if (!mbnGated(opts && opts.url)) return httpRaw(opts);
await mbnSlot({ label: opts.method + ' ' + '/ws/2/' + String(opts.url).split('/ws/2/').pop(), log: gateLog });
const r = await httpRaw(opts);
await mbnAnswer(r.status, n => mbnRawHeader(r.responseHeaders, n), { log: gateLog });
return r;
}
const gmGet = (url, headers) => http({ method: 'GET', url, headers: headers || {} });
const gmPost = (url, data, headers) => http({ method: 'POST', url, data, headers: headers || {} });
/* ═══════════════════════════════════════════════════════════════════════
SMALL UTILITIES
═══════════════════════════════════════════════════════════════════════ */
function esc(s) {
return String(s ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
}
function msToMmSs(ms) {
if (!ms) return null;
const s = Math.round(ms / 1000);
return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
}
function durToSec(str) {
const m = String(str || '').match(/^(\d+):(\d{2})$/);
return m ? parseInt(m[1]) * 60 + parseInt(m[2]) : null;
}
function norm(s) {
// #623 (sweep, X10): any script — keeping only [a-z0-9] reduced a mixed-script title to
// its Latin fragments ("Love ~夜~" = "Love ~朝~"). Diacritics still fold on Latin, Greek
// and Cyrillic letters; other scripts keep their marks.
return String(s || '').toLowerCase().normalize('NFKD')
.replace(/(?<=[\p{Script=Latin}\p{Script=Greek}\p{Script=Cyrillic}])\p{M}+/gu, '').normalize('NFC')
.replace(/[^\p{L}\p{N}\p{M} ]/gu, ' ')
.replace(/\s+/g, ' ').trim();
}
function wordsMatch(needle, haystack) {
const nw = norm(needle).split(' ').filter(Boolean), hw = norm(haystack);
return nw.length > 0 && nw.every(w => hw.includes(w));
}
function isGoodMatch(aTitle, aArtist, bTitle, bArtist) {
// shares titleClose/artistClose (below) so the row classification and the
// per-field highlighting never disagree
if (titleClose(aTitle, bTitle) !== true) return false;
return !bArtist || artistClose(aArtist, bArtist) === true;
}
// #643: guests don't make another song. A "(feat. …)" clause names them — Tidal titles "Instant
// Crush (feat. Julian Casablancas)", and MusicBrainz may carry the guests in the title where Deezer
// doesn't ("Get Lucky (feat. Pharrell Williams & Nile Rodgers)" vs "Get Lucky") — so it is left out
// of both titles. Guest lists differ between databases too ("Daft Punk feat. Pharrell Williams" on
// MusicBrainz, "Daft Punk, Pharrell Williams, Nile Rodgers" on Deezer), so when the whole credits
// don't match, the main artist is looked for in the other side's credit.
function unfeat(s) {
return String(s || '').replace(/\s*[([](?:feat\.?|ft\.?|featuring)\s[^)\]]*[)\]]/gi, '')
.replace(/\s+(?:feat\.?|ft\.?|featuring)\s.*$/i, '').trim();
}
function mainArtist(s) { return String(s || '').split(/\s+(?:feat\.?|ft\.?|featuring)\s+|\s*[,;]\s*/i)[0].trim(); }
// Per-field comparisons between an SoundExchange result and the MB track,
// used to highlight exactly WHICH attribute disagrees. Each returns
// true (matches) / false (mismatch) / null (can't compare — no data).
// #661: a typo is not another song. Tidal and Apple title it "Les Ecrocs", MusicBrainz "Les
// Escrocs". Two words are the same word one letter apart (a letter added, dropped, changed, or
// two swapped) when both have 5 letters or more and start alike, so "Love"/"Live" and
// "Heart"/"Start" stay different songs.
function wordTypo(a, b) {
if (a === b) return true;
if (Math.min(a.length, b.length) < 5 || Math.abs(a.length - b.length) > 1 || a[0] !== b[0]) return false;
let i = 0;
while (a[i] === b[i]) i++;
const ra = a.slice(i + 1), rb = b.slice(i + 1);
return ra === rb // changed
|| a.slice(i) === rb || ra === b.slice(i) // added or dropped
|| (a[i] === b[i + 1] && a[i + 1] === b[i] && a.slice(i + 2) === b.slice(i + 2)); // swapped
}
// exact: words must be spelled alike; otherwise one typo per word is tolerated (wordTypo)
function titleClose(sx, mb, exact) {
const aw = norm(unfeat(sx)).split(' ').filter(Boolean);
const bw = norm(unfeat(mb)).split(' ').filter(Boolean);
if (!aw.length || !bw.length) return null;
const same = exact ? (x, y) => x === y : wordTypo;
const shorter = aw.length <= bw.length ? aw : bw;
const longer = aw.length <= bw.length ? bw : aw;
if (!shorter.every(w => longer.some(l => same(w, l)))) return false;
const extra = longer.length - shorter.length;
// extra words are only tolerated as a SUFFIX (a version/remaster tag) — a
// leading extra word ("Sacred Motherhood" vs "Motherhood") is a different song
return extra === 0 || (extra <= 2 && shorter.every((w, i) => same(longer[i], w)));
}
function artistClose(sx, mb) {
if (!sx || !mb) return null;
if (wordsMatch(mb, sx) || wordsMatch(sx, mb)) return true;
const a = mainArtist(sx), b = mainArtist(mb); // guests left out (#643)
return !!(a && b) && (wordsMatch(b, sx) || wordsMatch(a, mb));
}
function durClose(sxDur, mbDur) {
const a = durToSec(mbDur), b = durToSec(sxDur);
return (a === null || b === null) ? null : Math.abs(a - b) <= 10;
}
function yearOk(sxYear, mbYear) {
if (!mbYear || !sxYear) return null;
return parseInt(sxYear) <= mbYear + 1;
}
// Build the "Title · Artist · Year · Dur" meta for an SX result `f` vs MB track
// `t`, wrapping each mismatching field in .ii-bad (with a tooltip explaining
// it) so problems are obvious in the chip, the candidate list, and the popup.
function sxMetaHtml(f, t) {
const span = (txt, ok, tip) =>
'' + esc(txt) + '';
const parts = [];
if (f.title) parts.push(span(f.title, titleClose(f.title, t.title), 'Title differs from "' + t.title + '"'));
if (f.artist) parts.push(span(f.artist, artistClose(f.artist, t.artist), 'Artist differs from "' + t.artist + '"'));
if (f.year) parts.push(span(f.year, yearOk(f.year, RELEASE && RELEASE.releaseYear),
'Recording year ' + f.year + ' is after this release (' + (RELEASE && RELEASE.releaseYear) + ')'));
if (f.dur) {
const dOk = durClose(f.dur, t.dur);
parts.push(span(f.dur, dOk, 'Length differs from MB (' + (t.dur || '?') + ')') +
(dOk === false && t.dur ? ' ↔ ' + esc(t.dur) + '' : ''));
}
return parts.join(' · ');
}
function normalizeIsrc(raw) {
return String(raw || '').toUpperCase().replace(/[\s\-]/g, '');
}
function isValidIsrc(s) { return ISRC_RE.test(normalizeIsrc(s)); }
function sleep(ms) {
return new Promise(resolve => {
try { _setTimeout(resolve, ms); }
catch (e) { resolve(); } // never stall a flow if the env's timer misbehaves
});
}
// #563: the shared toast. Its own `kind` argument already matched the standard
// severities, so it passes straight through.
function toast(msg, kind) { return mbuToast(msg == null ? '' : msg, kind ? { kind: kind === 'err' ? 'error' : kind } : undefined); }
/* ═══════════════════════════════════════════════════════════════════════
LOG — console + in-modal pane, for troubleshooting
═══════════════════════════════════════════════════════════════════════ */
const Log = (function () {
const buf = [], MAX = 800;
let paneEl = null;
const stamp = () => { const d = new Date(); return d.toTimeString().slice(0, 8) + '.' + String(d.getMilliseconds()).padStart(3, '0'); };
const fmt = (d) => { if (d === undefined) return ''; try { return ' ' + (typeof d === 'string' ? d : JSON.stringify(d)); } catch (e) { return ' ' + String(d); } };
function render() { if (paneEl) { paneEl.textContent = buf.join('\n'); paneEl.scrollTop = paneEl.scrollHeight; } }
// the open pane is redrawn once per frame, not once per line: a busy lookup logs
// dozens of lines at a time (X12 of #623)
let queued = false;
function add(level, msg, data) {
const line = '[' + stamp() + '] ' + String(level).toUpperCase().padEnd(5) + ' ' + msg + fmt(data);
buf.push(line); if (buf.length > MAX) buf.shift();
if (paneEl && !queued) { queued = true; requestAnimationFrame(() => { queued = false; render(); }); }
}
return {
setPane: el => { paneEl = el; render(); },
text: () => buf.join('\n'),
clear: () => { buf.length = 0; render(); },
info: (m, d) => add('info', m, d),
warn: (m, d) => add('warn', m, d),
err: (m, d) => add('error', m, d),
net: (m, d) => add('net', m, d),
};
})();
// Keep the FULL url — scheme + query — in the NET log so an API call's real
// arguments (album_id, app_id, …) are visible for debugging; only redact
// token/secret/signature values, and cap length. (#201: the query was being
// dropped, so a 404 gave no clue which id/app_id was actually sent.)
const shortUrl = (u) => String(u || '').replace(/([?&](?:[a-z_]*(?:token|secret|sig|password))=)[^]*/gi, '$1…').slice(0, 200);
Log.info(mbuStartupInfo('ISRC Scout') + ' — ' + MB_ROOT);
/* ═══════════════════════════════════════════════════════════════════════
STYLES
═══════════════════════════════════════════════════════════════════════ */
const style = document.createElement('style');
// The shared design tokens (#562). Values live in dev/tokens/design-tokens.mjs and are
// inlined here by dev/tokens/sync-tokens.mjs — edit them THERE, never in this block.
// — generated by dev/tokens/sync-tokens.mjs from dev/tokens/design-tokens.mjs — DO NOT EDIT
const MBU_TOKENS = ':root{--mbu-bg:var(--background, #fff);--mbu-bg-raised:#faf9fe;--mbu-bg-raised:color-mix(in srgb, var(--mbu-bg) 96%, var(--mbu-accent));--mbu-bg-sunken:#f4f2f9;--mbu-bg-sunken:color-mix(in srgb, var(--mbu-bg) 94%, var(--mbu-text));--mbu-bg-hover:#f3eefe;--mbu-bg-hover:color-mix(in srgb, var(--mbu-bg) 91%, var(--mbu-accent));--mbu-text:var(--text, #222);--mbu-text-dim:#555;--mbu-text-dim:color-mix(in srgb, var(--mbu-text) 78%, var(--mbu-bg));--mbu-text-weak:#999;--mbu-text-weak:color-mix(in srgb, var(--mbu-text) 52%, var(--mbu-bg));--mbu-text-on-accent:#fff;--mbu-border:var(--border, #cfc6e6);--mbu-border-soft:#e2dcef;--mbu-border-strong:#9a8ccb;--mbu-border-strong:color-mix(in srgb, var(--mbu-border) 70%, var(--mbu-text));--mbu-divider:#eee;--mbu-divider:color-mix(in srgb, var(--mbu-bg) 92%, var(--mbu-text));--mbu-accent:#5f3ec0;--mbu-accent-hover:#4e329f;--mbu-accent-deep:#3b2c70;--mbu-accent-soft:#ece4ff;--mbu-accent-soft:color-mix(in srgb, var(--mbu-bg) 86%, var(--mbu-accent));--mbu-accent-fg:#fff;--mbu-accent-text:#5f3ec0;--mbu-accent-deep-text:#3b2c70;--mbu-ok:#1f9d6b;--mbu-ok:color-mix(in srgb, #1f9d6b 78%, var(--mbu-text));--mbu-ok-bg:#eef7f1;--mbu-ok-bg:color-mix(in srgb, var(--mbu-bg) 88%, var(--mbu-ok));--mbu-ok-border:#9bd3b6;--mbu-warn:#a05a00;--mbu-warn:color-mix(in srgb, #b4791f 78%, var(--mbu-text));--mbu-warn-bg:#fff7e6;--mbu-warn-bg:color-mix(in srgb, var(--mbu-bg) 88%, var(--mbu-warn));--mbu-warn-border:#f0c877;--mbu-error:#c0392b;--mbu-error:color-mix(in srgb, #d0473a 78%, var(--mbu-text));--mbu-error-bg:#fdecec;--mbu-error-bg:color-mix(in srgb, var(--mbu-bg) 90%, var(--mbu-error));--mbu-error-border:#e2a1a1;--mbu-info:#2f7fbf;--mbu-info:color-mix(in srgb, #3f8fd0 78%, var(--mbu-text));--mbu-info-bg:#eef4fb;--mbu-info-bg:color-mix(in srgb, var(--mbu-bg) 90%, var(--mbu-info));--mbu-info-border:#a9c8e6;--mbu-font:-apple-system,Segoe UI,Roboto,Arial,sans-serif;--mbu-font-mono:ui-monospace,SFMono-Regular,Consolas,Menlo,monospace;--mbu-fs:14px;--mbu-fs-sm:12px;--mbu-fs-xs:11px;--mbu-radius:6px;--mbu-radius-lg:10px;--mbu-shadow:0 1px 5px rgba(60,40,110,.07);--mbu-shadow-lg:0 8px 30px rgba(40,20,80,.3);--mbu-z-panel:30;--mbu-z-pop:99998;--mbu-z-modal:2147483000;--mbu-z-modal-panel:2147483001}:root[data-mbu-theme="dark"]{--mbu-bg:#1e1b24;--mbu-text:#e9e5f2;--mbu-border:#3b3548;--mbu-accent-text:#b9a7f0;--mbu-accent-deep-text:#a493e0}:root[data-mbu-theme="dark"][data-mbu-seed="theme"]{--mbu-bg:var(--background, #1e1b24);--mbu-text:var(--text, #e9e5f2);--mbu-border:var(--border, #3b3548)}';
//
// The shared UI components (#563). Definitions live in dev/ui/ui-components.mjs
// and are inlined here by dev/ui/sync-ui.mjs — edit them THERE, never here.
// — generated by dev/ui/sync-ui.mjs from dev/ui/ui-components.mjs — DO NOT EDIT
const MBU_UI_CSS = '.mbu-help{font-size:12px;color:var(--mbu-accent-text);text-decoration:none;border:1px solid var(--mbu-border);border-radius:var(--mbu-radius);padding:1px 8px;white-space:nowrap;line-height:1.6;background:none}.mbu-help:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent);text-decoration:none}h4>.mbu-help,.mbu-cfg-h>.mbu-help{margin-left:8px;flex:0 0 auto;font-weight:normal}#mbu-toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:var(--mbu-z-pop);background:var(--mbu-accent-deep);color:var(--mbu-text-on-accent);padding:10px 16px;border-radius:9px;font:13px/1.35 var(--mbu-font);box-shadow:var(--mbu-shadow-lg);opacity:0;transition:opacity .2s;pointer-events:none;max-width:80vw;text-align:center;white-space:pre-wrap}#mbu-toast.mbu-toast-on{opacity:1}#mbu-toast.mbu-toast-act{pointer-events:auto}#mbu-toast .mbu-toast-btn{margin-left:10px;padding:2px 9px;border:1px solid currentColor;border-radius:5px;background:transparent;color:inherit;font:inherit;cursor:pointer}#mbu-toast .mbu-toast-btn:hover{background:rgba(255,255,255,.18)}#mbu-toast.mbu-toast-ok{background:var(--mbu-ok)}#mbu-toast.mbu-toast-warn{background:var(--mbu-warn)}#mbu-toast.mbu-toast-error{background:var(--mbu-error)}.mbu-cfg-h{display:flex;align-items:center;gap:8px;margin:0 0 10px;padding:0 0 9px;border-bottom:1px solid var(--mbu-border-soft);font:600 15px/1.3 var(--mbu-font);color:var(--mbu-text)}.mbu-cfg-ic{flex:0 0 auto;display:inline-flex;align-items:center;width:22px;height:22px}.mbu-cfg-ic img,.mbu-cfg-ic svg{width:22px;height:22px;object-fit:contain;display:block}.mbu-cfg-name{flex:0 0 auto;font-weight:700;color:var(--mbu-accent-text)}.mbu-cfg-ver{flex:0 0 auto;font:400 11px var(--mbu-font);color:var(--mbu-text-weak);white-space:nowrap}.mbu-cfg-sp{flex:1 1 auto;min-width:8px}.mbu-cfg-log{flex:0 0 auto;font:400 12px var(--mbu-font);color:var(--mbu-accent-text);cursor:pointer;background:none;border:1px solid transparent;border-radius:var(--mbu-radius);padding:1px 8px;line-height:1.6}.mbu-cfg-log:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-border)}#mbu-logpop{position:fixed;top:74px;left:50%;transform:translateX(-50%);z-index:var(--mbu-z-modal);display:flex;flex-direction:column;width:min(720px,94vw);max-height:72vh;background:var(--mbu-bg);border:1px solid var(--mbu-border);border-radius:11px;box-shadow:var(--mbu-shadow-lg);font:13px var(--mbu-font);color:var(--mbu-text);overflow:hidden}.mbu-logpop-h{display:flex;align-items:center;gap:8px;padding:10px 13px;border-bottom:1px solid var(--mbu-border-soft);color:var(--mbu-accent-text);cursor:move;user-select:none}.mbu-logpop-sp{margin-left:auto}.mbu-logpop-clear,.mbu-logpop-copy,.mbu-logpop-x,.mbu-logpop-min{font-size:12px;color:var(--mbu-accent-text);background:var(--mbu-bg-hover);border:1px solid var(--mbu-border);border-radius:5px;padding:2px 9px;cursor:pointer;font-family:inherit}.mbu-logpop-clear:hover,.mbu-logpop-copy:hover,.mbu-logpop-x:hover,.mbu-logpop-min:hover{background:var(--mbu-accent-soft)}#mbu-logpop.min .mbu-log-list,#mbu-logpop.min .mbu-logpop-clear,#mbu-logpop.min .mbu-logpop-copy,#mbu-logpop.min .mbu-logpop-x{display:none}#mbu-logpop.min{max-height:none;width:auto}#mbu-logpop.min .mbu-logpop-sp{display:none}.mbu-log-badge{color:var(--mbu-border-strong);font-size:11px}.mbu-log-list{flex:1 1 auto;overflow:auto;overscroll-behavior:contain;padding:9px 13px;display:flex;flex-direction:column;gap:3px}.mbu-log-li{display:flex;gap:9px;white-space:pre-wrap;word-break:break-word}.mbu-log-t{color:var(--mbu-text-weak);flex:0 0 auto;font-variant-numeric:tabular-nums}.mbu-log-m{flex:1 1 auto;color:var(--mbu-text-dim)}#mbu-logpop .mbu-log-m a{color:var(--mbu-accent-text)}.mbu-log-ok .mbu-log-m{color:var(--mbu-ok)}.mbu-log-warn .mbu-log-m{color:var(--mbu-warn)}.mbu-log-error .mbu-log-m{color:var(--mbu-error)}.mbu-log-debug{opacity:.85}.mbu-log-debug .mbu-log-m{color:var(--mbu-text-weak)}.mbu-log-empty{color:var(--mbu-text-weak)}.mbu-ov{position:fixed;inset:0;z-index:var(--mbu-z-modal);background:rgba(15,12,28,.45);display:flex;align-items:center;justify-content:center;padding:24px}.mbu-ov-panel{background:var(--mbu-bg);color:var(--mbu-text);border-radius:var(--mbu-radius-lg);box-shadow:var(--mbu-shadow-lg);max-width:94vw;max-height:88vh;display:flex;flex-direction:column;overflow:hidden}.mbu-ov-h{display:flex;align-items:center;gap:10px;padding:12px 16px;border-bottom:1px solid var(--mbu-border-soft);font-weight:700}.mbu-ov-h .mbu-ov-title{flex:1 1 auto;min-width:0}.mbu-ov-x{flex:0 0 auto;width:26px;height:26px;display:inline-flex;align-items:center;justify-content:center;font-size:15px;line-height:1;cursor:pointer;color:var(--mbu-text-dim);background:none;border:none;border-radius:var(--mbu-radius)}.mbu-ov-x:hover{background:var(--mbu-bg-hover);color:var(--mbu-text)}.mbu-ov-body{flex:1 1 auto;overflow:auto;padding:14px 16px}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) ::placeholder{color:var(--mbu-text-weak);opacity:1;font-style:italic}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu){color:var(--mbu-text)}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) :is(table,td,th,div,span,label)[style*=background]{color:var(--mbu-text)}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) input:not(:where([type=checkbox],[type=radio],[type=range],[type=color],[type=file])),:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) textarea,:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) select{background:var(--mbu-bg-sunken);color:var(--mbu-text);border-color:var(--mbu-border)}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) input:focus-visible,:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) textarea:focus-visible,:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) select:focus-visible{outline:2px solid var(--mbu-accent);outline-offset:1px}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) :where(input[type=checkbox],input[type=radio],input[type=range]){accent-color:var(--mbu-accent)}:root[data-mbu-theme=dark] :where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu){color-scheme:dark;--invert-value:none;--invert:none}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) button{background-color:var(--mbu-bg-raised);color:var(--mbu-text);border-color:var(--mbu-border)}.mbu-compact .mbu-bt{display:none}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input){-webkit-appearance:none;-moz-appearance:none;appearance:none;width:18px;height:18px;margin:0;border:none;border-radius:3px;cursor:pointer;background:transparent url("data:image/svg+xml,%3Csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20viewBox=%270%200%2016%2016%27%20fill=%27%23888%27%3E%3Crect%20x=%271%27%20y=%273.5%27%20width=%2710%27%20height=%279%27%20rx=%271.5%27/%3E%3Cpath%20d=%27M11.5%207L15%204.8v6.4L11.5%209z%27/%3E%3C/svg%3E") center/13px no-repeat;opacity:.45;box-shadow:none;flex:0 0 auto;vertical-align:middle}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):hover{opacity:1}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):checked{opacity:1;background-color:var(--mbu-accent);background-image:url("data:image/svg+xml,%3Csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20viewBox=%270%200%2016%2016%27%20fill=%27%23fff%27%3E%3Crect%20x=%271%27%20y=%273.5%27%20width=%2710%27%20height=%279%27%20rx=%271.5%27/%3E%3Cpath%20d=%27M11.5%207L15%204.8v6.4L11.5%209z%27/%3E%3C/svg%3E")}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):focus-visible{outline:1px solid var(--mbu-accent);outline-offset:1px}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):disabled{cursor:default;opacity:.3}';
// Help link markup. Every script's help link is this, pointing at its own README.
// `name` is the userscript folder, e.g. mbuHelpHref('art_station').
function mbuHelpHref(name) {
return 'https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/' + name + '/README.md';
}
function mbuHelpHtml(name, label) {
return '' + (label || '? Help') + '';
}
function mbuHelpEl(name, label) {
var a = document.createElement('a');
a.className = 'mbu-help';
a.href = mbuHelpHref(name);
a.target = '_blank';
a.rel = 'noopener';
a.title = 'open the README in a new tab';
a.textContent = label || '? Help';
return a;
}
// The config (settings) icon every script's settings button shows: the gear in text
// presentation (U+FE0E), so it takes the button's colour instead of an emoji's. #650
var MBU_CFG_ICON = '\u2699\uFE0E';
// HTML for an innerHTML on a page that enforces Trusted Types (YouTube Music, #650): there a
// plain string is refused ("This document requires 'TrustedHTML' assignment"). The policy
// passes the string through; the markup is the script's own. Elsewhere it is the string.
var _mbuTT;
function mbuHtml(s) {
if (_mbuTT === undefined) {
_mbuTT = null;
try {
var tt = (typeof window !== 'undefined' && window.trustedTypes) || null;
if (tt && tt.createPolicy) _mbuTT = tt.createPolicy('mbu-' + Math.random().toString(36).slice(2, 8), { createHTML: function (x) { return x; } });
} catch (e) { /* the page allows no new policy: plain strings, as before */ }
}
return _mbuTT ? _mbuTT.createHTML(String(s)) : String(s);
}
// The first line of every script's log: the script, its version, and what runs it, so a
// pasted log says which manager and browser it came from (#282 started it in Art Station):
// Log.info(mbuStartupInfo('Fusion'));
// -> Fusion v2026.10.1 · Violentmonkey 2.31.0 · firefox 143.0 (win)
// Inside String Theory GM_info describes the bundle, so the line names it:
// -> Fusion (String Theory v2026.10.1) · Tampermonkey 5.3.3 · chrome 140.0 (win)
function mbuStartupInfo(name) {
var g = null;
try { g = (typeof GM_info !== 'undefined' && GM_info) || null; } catch (e) { /* no GM_info */ }
var s = (g && g.script) || {}, p = (g && g.platform) || {};
var host = String(s.name || '').replace(/\*$/, ''), ver = s.version || '?';
var line = !name ? (host || 'Script') + ' v' + ver
: (host && host !== name) ? name + ' (' + host + ' v' + ver + ')'
: name + ' v' + ver;
if (g) line += ' · ' + (g.scriptHandler || 'unknown manager') + (g.version ? ' ' + g.version : '');
if (p.browserName) line += ' · ' + p.browserName + (p.browserVersion ? ' ' + p.browserVersion : '') + (p.os ? ' (' + p.os + ')' : '');
else { try { line += ' · ' + navigator.userAgent; } catch (e) { /* no navigator */ } }
return line;
}
// One copy per page (#653). With String Theory and a standalone install of the same script
// both on, two copies build the same element ids and fight over them: each settings window
// fills in the other's checkboxes, rows flip between two rule sets. So one copy runs, the
// one with the higher version, and the other stays off without a word; only the running
// copy notes it in its log (majkinetor: "disable copy that has lower version without any
// info (except in log of active copy)"):
// if (!mbuClaim('platform_check', 'Platform Check')) return; // first line of the script
// The claim is a data- attribute on , which every copy sees whatever its sandbox.
// It is decided at once, so no script starts late. The copy that starts first takes the
// page. When it is the older one, the newer copy stays off for this page and leaves a note
// in localStorage, and from the next page load the older copy finds the note and steps
// aside. A note whose copy has gone (uninstalled) is cleared by the older copy after the
// page loads, so it runs again from the load after.
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;
}
// Toast. mbuToast(msg) or mbuToast(msg, { ms, kind, at:{x,y}, action:{ label, onClick } }).
//
// An action adds one button to the toast (e.g. "Copy log"): the toast is then clickable,
// stays up longer (12 s unless ms says otherwise), and closes when the button is used.
//
// Severity is inferred from a leading warning/tick glyph when not given — Art
// Station already did that and it is why its toasts reached its log with the
// right level. Set mbuToast.log = function (level, message) {...} once at
// startup and every toast mirrors into that script's own log; leave it unset
// and the toast still shows.
var _mbuToastT = null;
function mbuToast(msg, opts) {
opts = opts || {};
var s = String(msg);
var kind = opts.kind || (/^\s*[⚠✗×]/.test(s) ? 'warn' : /[✓✅]/.test(s) ? 'ok' : 'info');
try {
if (typeof mbuToast.log === 'function') mbuToast.log(kind, s.replace(/^\s*[⚠✗×✓✅]\s*/, ''));
} catch (e) { /* a broken log sink must never swallow the toast */ }
var el = document.getElementById('mbu-toast');
if (!el) {
el = document.createElement('div');
el.id = 'mbu-toast';
(document.body || document.documentElement).appendChild(el);
}
el.className = 'mbu-toast-on' + (kind !== 'info' ? ' mbu-toast-' + kind : '') + (opts.action ? ' mbu-toast-act' : '');
el.textContent = s;
if (opts.action) {
var b = document.createElement('button');
b.type = 'button'; b.className = 'mbu-toast-btn'; b.textContent = opts.action.label || 'OK';
b.onclick = function () {
try { if (opts.action.onClick) opts.action.onClick(b); } catch (e) { /* the toast still closes */ }
clearTimeout(_mbuToastT); _mbuToastT = setTimeout(function () { el.className = ''; }, 900);
};
el.appendChild(b);
}
// Anchor above a click point when asked, clamped into the viewport; otherwise
// fall back to the centred default by clearing the inline placement.
if (opts.at) {
var w = el.offsetWidth, h = el.offsetHeight;
el.style.left = Math.max(6, Math.min(window.innerWidth - w - 6, opts.at.x - w / 2)) + 'px';
el.style.top = Math.max(6, Math.min(window.innerHeight - h - 6, opts.at.y - h - 10)) + 'px';
el.style.bottom = 'auto';
el.style.transform = 'none';
} else {
el.style.left = ''; el.style.top = ''; el.style.bottom = ''; el.style.transform = '';
}
clearTimeout(_mbuToastT);
_mbuToastT = setTimeout(function () { el.className = ''; }, opts.ms || (opts.action ? 12000 : 2600));
return el;
}
// Config-window title bar.
//
// mbuCfgHeader({ script:'art_station', name:'Art Station', version:'2026.9.2',
// icon:'' | '', log:true, logClass:'as-setup-logbtn' })
//
// Returns the markup for the whole bar. 'log' adds the Log button; a script with
// no log window leaves it out rather than shipping a dead control. logClass /
// logId are carried through IN ADDITION to the shared class so a script's
// existing click handler keeps working — adopting the component must not mean
// rewiring every listener at the same time.
function mbuCfgHeader(o) {
o = o || {};
var esc = function (s) {
return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) {
return { '&': '&', '<': '<', '>': '>', '"': '"' }[c];
});
};
var html = '
';
if (o.icon) html += '' + o.icon + '';
html += '' + esc(o.name) + '';
if (o.version) html += 'v' + esc(o.version) + '';
html += '';
if (o.log) {
html += '';
}
html += mbuHelpHtml(o.script);
return html + '
';
}
// Test hooks. A script puts its test hook on window only when the test harness has
// marked the page (dev/test/harness.mjs sets window.__mbuTest before any script runs):
// if (mbuTestHooks()) window.__fooTest = { … };
// On a user's page the hooks are never built. (#623)
function mbuTestHooks() {
try { return typeof window !== 'undefined' && window.__mbuTest === true; } catch (e) { return false; }
}
// Corner slots (#468). Every floating launcher (Apollo Editor, Art Station, Falcon,
// Fusion, Scribe) tags its element with data-mb-corner (which screen corner: 'br',
// 'bl', 'tr', 'tl') and data-mb-corner-order (lower sits closer to the corner), and
// calls mbRestackCorner(corner) right after it shows, hides, creates or removes it.
// That recomputes every element in the corner, whichever script owns it and
// whatever order they loaded in, so two launchers never land on the same pixel.
// Orders in use: Apollo and Art Station 10 (never on the same page), Falcon 20,
// Fusion above Falcon. Scribe is not on the shared block and keeps a copy of this.
function mbRestackCorner(corner) {
var bottom = corner[0] === 'b', right = corner[1] === 'r';
var els = Array.prototype.slice.call(document.querySelectorAll('[data-mb-corner="' + corner + '"]'))
// offsetParent is always null for position:fixed, so it can't tell visibility here
.filter(function (el) { return getComputedStyle(el).display !== 'none'; })
.sort(function (a, b) { return (Number(a.dataset.mbCornerOrder) || 0) - (Number(b.dataset.mbCornerOrder) || 0); });
var pos = 14;
els.forEach(function (el) {
el.style[bottom ? 'bottom' : 'top'] = pos + 'px';
el.style[right ? 'right' : 'left'] = '14px';
pos += el.getBoundingClientRect().height + 8;
});
}
// Activity log: the session's log lines plus the floating window that shows them
// (#283's viewer, shared since X12 of #623). A script makes its log once:
//
// var LOG = mbuLog({ name: 'Fusion', version: VERSION, key: 'fusion.logwin' });
// LOG.info('…'); LOG.warn(…); LOG.err(…) (or .error); LOG.ok(…); LOG.debug(…)
// LOG.open(); LOG.close(); LOG.reopen() // reopen: only if it was left open
// LOG.markdown(); LOG.copy(btn); LOG.clear(); LOG.lines(); LOG.messages(); LOG.counts()
//
// o.name / o.version the Markdown summary's title (version may be a function)
// o.subtitle optional function; its text follows the title (e.g. the release)
// o.header the window's title (default 'Activity log')
// o.key storage key for the window's open/minimised/position state
// o.load / o.save that storage (default GM_getValue / GM_setValue)
// o.before called before the window opens (e.g. to inject the script's CSS)
// o.max lines kept (default 20000: about 4 MB; 2000 dropped a long session's start)
//
// A long run keeps only the last o.max lines, and the Markdown says how many went
// before them; the copies this replaced grew for the whole session. An open
// window appends each new line and drops the oldest row past the cap; the copies
// rebuilt the whole list with innerHTML on every line, which is quadratic over a
// long matching run.
function mbuLog(o) {
o = o || {};
var max = o.max || 20000, buf = [], dropped = 0, warn = 0, error = 0, win = null;
var pad = function (n, w) { return String(n).padStart(w || 2, '0'); };
var ts = function (d) { return pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()) + '.' + pad(d.getMilliseconds(), 3); };
var str = function (v) {
if (typeof v === 'string') return v;
if (v instanceof Error) return v.message || String(v);
if (v && v.nodeType) return '<' + (v.tagName || 'node').toLowerCase() + '>';
try { return typeof v === 'object' ? JSON.stringify(v) : String(v); } catch (e) { return String(v); }
};
var esc = function (s) {
return String(s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; });
};
// escape, then make http(s) URLs clickable, keeping trailing punctuation out of them
var linkify = function (s) {
return esc(s).replace(/(https?:\/\/[^\s<]+)/g, function (m) {
var t = (m.match(/[.,;:!?)\]]+$/) || [''])[0];
var url = m.slice(0, m.length - t.length);
return '' + url + '' + t;
});
};
var load = o.load || function (k) { try { return GM_getValue(k, undefined); } catch (e) { return undefined; } };
var save = o.save || function (k, v) { try { GM_setValue(k, v); } catch (e) { /* no storage: the window just forgets */ } };
var state = function () { try { return JSON.parse(load(o.key) || '{}') || {}; } catch (e) { return {}; } };
var remember = function (patch) { try { save(o.key, JSON.stringify(Object.assign(state(), patch))); } catch (e) { /* see save */ } };
var tally = function (e, d) { if (e.sev === 'warn') warn += d; else if (e.sev === 'error') error += d; };
var PRE = { info: '', ok: 'OK ', warn: 'WARN ', error: 'ERR ', debug: 'DBG ' };
var line = function (e) { return ts(e.t) + ' ' + (PRE[e.sev] || '') + e.msg; };
function add(sev, args) {
var msg = Array.prototype.map.call(args, str).join(' ').replace(/\s+/g, ' ').trim();
if (!msg) return;
var e = { t: new Date(), sev: sev === 'err' ? 'error' : sev, msg: msg };
buf.push(e); tally(e, 1);
// trim in chunks, not one shift per line
if (buf.length > max + Math.ceil(max / 10)) {
var gone = buf.splice(0, buf.length - max);
gone.forEach(function (g) { tally(g, -1); });
dropped += gone.length;
}
if (win) win.append(e);
}
function title() {
var v = typeof o.version === 'function' ? (function () { try { return o.version(); } catch (e) { return ''; } })() : o.version;
var t = (o.name || 'Log') + (v ? ' v' + v : '');
try { var s = o.subtitle && o.subtitle(); if (s) t += ' — ' + s; } catch (e) { /* no subtitle */ }
return t;
}
function markdown() {
var body = buf.length ? buf.map(line).join('\n') : '(no activity logged)';
if (dropped) body = '(' + dropped + ' earlier line' + (dropped === 1 ? '' : 's') + ' not kept)\n' + body;
var n = (warn || error) ? ' (' + warn + ' warning' + (warn === 1 ? '' : 's') + ', ' + error + ' error' + (error === 1 ? '' : 's') + ')' : '';
var fence = String.fromCharCode(96, 96, 96);
return '' + title() + ' — session log' + n + '\n\n' + fence + 'log\n' + body + '\n' + fence + '\n\n';
}
function copy(btn) {
var md = markdown();
var done = function (ok) {
if (!btn) return;
var was = btn.dataset.lbl || btn.textContent; btn.dataset.lbl = was;
btn.textContent = ok ? 'Copied ✓' : 'Copy failed';
setTimeout(function () { btn.textContent = was; }, 1500);
};
var fallback = function () {
var ok = false;
try {
var ta = document.createElement('textarea'); ta.value = md; ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta); ta.select(); ok = document.execCommand('copy'); ta.remove();
} catch (x) { /* nothing left to try */ }
done(ok);
};
try { navigator.clipboard.writeText(md).then(function () { done(true); }, fallback); } catch (e) { fallback(); }
}
function open() {
close(true);
if (typeof o.before === 'function') { try { o.before(); } catch (e) { /* the window still opens */ } }
remember({ open: true });
var st = state();
var pop = document.createElement('div'); pop.id = 'mbu-logpop'; pop.className = 'mbu-logpop';
pop.innerHTML = mbuHtml('
'
+ '');
document.body.appendChild(pop);
if (st.left != null) { pop.style.left = st.left; pop.style.top = st.top; pop.style.right = 'auto'; pop.style.transform = 'none'; }
var restore = { left: pop.style.left, top: pop.style.top, right: pop.style.right, bottom: pop.style.bottom, transform: pop.style.transform };
var list = pop.querySelector('.mbu-log-list'), badge = pop.querySelector('.mbu-log-badge');
var row = function (e) {
var d = document.createElement('div');
d.className = 'mbu-log-li mbu-log-' + e.sev;
d.innerHTML = mbuHtml('' + ts(e.t) + '' + linkify(e.msg) + '');
return d;
};
var showBadge = function () { badge.textContent = '(' + buf.length + ')' + (warn || error ? ' · ' + warn + '⚠ ' + error + '✖' : ''); };
// the rows, once; later lines are appended one by one
var frag = document.createDocumentFragment();
buf.forEach(function (e) { frag.appendChild(row(e)); });
if (buf.length) list.appendChild(frag);
else list.innerHTML = mbuHtml('
No activity yet.
');
showBadge();
list.scrollTop = list.scrollHeight;
// badge and scroll once per frame, however many lines arrive in it
var queued = false, follow = true;
list.addEventListener('scroll', function () { follow = list.scrollHeight - list.scrollTop - list.clientHeight < 40; });
var paint = function () { queued = false; showBadge(); if (follow) list.scrollTop = list.scrollHeight; };
var onKey = function (e) { if (e.key === 'Escape') close(); };
win = {
el: pop,
append: function (e) {
var empty = list.querySelector('.mbu-log-empty'); if (empty) empty.remove();
list.appendChild(row(e));
while (list.childElementCount > buf.length) list.firstElementChild.remove();
if (!queued) { queued = true; requestAnimationFrame(paint); }
},
off: function () { document.removeEventListener('keydown', onKey); },
cleared: function () { list.innerHTML = mbuHtml('
No activity yet.
'); showBadge(); },
};
pop.querySelector('.mbu-logpop-clear').onclick = function () { clear(); };
pop.querySelector('.mbu-logpop-copy').onclick = function () { copy(pop.querySelector('.mbu-logpop-copy')); };
var minBtn = pop.querySelector('.mbu-logpop-min');
var setMin = function (m) {
minBtn.textContent = m ? '▢' : '–'; minBtn.title = m ? 'Restore' : 'Minimize';
if (m) { pop.style.left = '14px'; pop.style.bottom = '14px'; pop.style.top = 'auto'; pop.style.right = 'auto'; pop.style.transform = 'none'; } // dock to the bottom
else Object.assign(pop.style, restore);
};
minBtn.onclick = function () { var m = pop.classList.toggle('min'); setMin(m); remember({ min: m }); };
if (st.min) { pop.classList.add('min'); setMin(true); }
pop.querySelector('.mbu-logpop-x').onclick = function () { close(); };
// floating and non-modal: dragged by its header
pop.querySelector('.mbu-logpop-h').addEventListener('mousedown', function (e) {
if (e.target.closest('button')) return;
e.preventDefault();
var r = pop.getBoundingClientRect();
pop.style.left = r.left + 'px'; pop.style.top = r.top + 'px'; pop.style.right = 'auto'; pop.style.transform = 'none';
var ox = e.clientX - r.left, oy = e.clientY - r.top;
var mv = function (ev) {
pop.style.left = Math.max(0, Math.min(window.innerWidth - pop.offsetWidth, ev.clientX - ox)) + 'px';
pop.style.top = Math.max(0, Math.min(window.innerHeight - 36, ev.clientY - oy)) + 'px';
};
var up = function () {
document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up);
if (!pop.classList.contains('min')) {
restore = { left: pop.style.left, top: pop.style.top, right: 'auto', bottom: '', transform: 'none' };
remember({ left: pop.style.left, top: pop.style.top });
}
};
document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up);
});
document.addEventListener('keydown', onKey);
return pop;
}
// empty the log: the lines, the counts and the "earlier lines not kept" note
function clear() {
buf = []; dropped = 0; warn = 0; error = 0;
if (win) win.cleared();
}
// quiet: closing to reopen, so the remembered "open" stays as it is
function close(quiet) {
var stray = document.getElementById('mbu-logpop');
if (win) { win.off(); win.el.remove(); win = null; if (!quiet) remember({ open: false }); }
if (stray) stray.remove(); // another script's window: one log window at a time
}
var api = {
info: function () { add('info', arguments); },
warn: function () { add('warn', arguments); },
err: function () { add('error', arguments); },
error: function () { add('error', arguments); },
ok: function () { add('ok', arguments); },
debug: function () { add('debug', arguments); },
add: function (sev) { add(sev, Array.prototype.slice.call(arguments, 1)); },
open: open,
close: function () { close(); },
reopen: function () { if (state().open) open(); },
isOpen: function () { return !!win; },
markdown: markdown,
copy: copy,
clear: clear,
lines: function () { return buf.map(line); },
messages: function () { return buf.map(function (e) { return e.msg; }); },
counts: function () { return { warn: warn, error: error }; },
};
mbuLog.active = api; // the script's own log, for helpers that note things in it (mbuClaim)
return api;
}
// Dismiss-on-outside-click, with the trailing click SWALLOWED.
//
// var off = mbuDismissOn(popoverEl, close); // off() to detach early
//
// #305: a popover torn down on mousedown removes what was under the cursor, so
// the click that follows lands on whatever the page reflowed into that spot and
// activates it. Tearing down on click instead just moves the problem. So: close
// on outside mousedown, then eat exactly one click in the capture phase. This is
// the single most repeated interaction bug in these scripts and it belongs in
// one place — it is why #563 says interaction is part of the contract.
//
// Esc closes too, innermost first: the handler is registered in capture and stops
// propagation, so a popover inside a modal does not close the modal as well.
function mbuDismissOn(el, close, opts) {
opts = opts || {};
var closed = false;
var onDown = function (e) {
if (closed || !el || el.contains(e.target)) return;
if (opts.ignore && e.target.closest && e.target.closest(opts.ignore)) return;
finish();
// swallow the click this mousedown will produce, once
var eat = function (ev) { ev.stopPropagation(); ev.preventDefault(); document.removeEventListener('click', eat, true); };
document.addEventListener('click', eat, true);
setTimeout(function () { document.removeEventListener('click', eat, true); }, 400);
};
var onKey = function (e) {
if (closed || e.key !== 'Escape') return;
e.stopPropagation();
finish();
};
function finish() {
if (closed) return;
closed = true;
document.removeEventListener('mousedown', onDown, true);
document.removeEventListener('keydown', onKey, true);
try { close(); } catch (err) { /* a throwing closer must not leave listeners behind */ }
}
document.addEventListener('mousedown', onDown, true);
document.addEventListener('keydown', onKey, true);
return finish;
}
// Collapse a toolbar to icon-only when its buttons would wrap.
//
// mbuFitToolbar(barEl) // call on build, and on resize
//
// Measured by SUMMING child widths rather than reading scrollWidth or comparing
// offsetTop: a bar with flex:1 spacers never overflows its own scroll box, so
// both of those report "fits" right up until it visibly wraps. Art Station
// learned that the hard way (#234) and it is the only reason this is a helper
// rather than one CSS rule.
//
// opts.gap inter-item gap in px (default 11)
// opts.pad horizontal padding to leave (default 24)
// opts.spacer selector for flexible spacers, which must not count (default .mbu-sp)
function mbuFitToolbar(bar, opts) {
if (!bar) return false;
opts = opts || {};
var gap = opts.gap == null ? 11 : opts.gap;
var pad = opts.pad == null ? 24 : opts.pad;
var spacer = opts.spacer || '.mbu-sp';
bar.classList.remove('mbu-compact'); // measure at full labels
var kids = [].slice.call(bar.children);
var need = gap * Math.max(0, kids.length - 1);
for (var i = 0; i < kids.length; i++) {
if (kids[i].matches && kids[i].matches(spacer)) continue;
need += kids[i].offsetWidth;
}
var compact = need > bar.clientWidth - pad;
bar.classList.toggle('mbu-compact', compact);
return compact;
}
// Publish the components on a shared namespace. Three reasons, in order:
//
// 1. it is the cross-userscript contract #563 is about — another script (or a
// future one) gets the standard widgets without copying them, the same way
// Mammoth already exposes its field-memory through a documented convention;
// 2. it makes the components testable from outside, which is the only way to
// assert the *behaviour* half of the contract rather than just the markup;
// 3. it costs nothing when several scripts do it — the definitions are
// byte-identical, so first writer wins and the rest are no-ops.
//
// Guarded per key, never clobbering: a script that loaded first keeps its copy,
// and a page that defines an unrelated window.MBU is left alone.
// Theme recognition. #564: "we don't have to conform to Stylus vars, we could
// probably use them as a recognition signal to enable our own dark theme."
//
// That is the right way round. Reading --background/--text and hoping every
// derived colour lands somewhere readable is guesswork that fails one token at a
// time; knowing WHICH theme we are in lets the token set say so outright, and
// lets us hand the browser the one thing CSS variables cannot express —
// color-scheme, which is what actually paints a checkbox dark instead of leaving
// a white (Firefox: black) box on a dark panel.
//
// The signal is the rendered page, not a particular userstyle's variable names:
// whatever painted the body, we measure its luminance. So this works for Stylus,
// for a browser extension, for MusicBrainz shipping its own dark mode one day,
// and for a user who just set --background by hand.
//
// · an explicit --mbu-theme (light|dark) always wins — the escape hatch;
// · otherwise the page background decides;
// · re-checked when stylesheets arrive, because Stylus often lands after us.
function mbuThemeOf(bg) {
var m = /rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?/.exec(bg || '');
if (!m) return null;
if (m[4] !== undefined && +m[4] < 0.5) return null; // transparent tells us nothing
var f = function (v) { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); };
var L = 0.2126 * f(+m[1]) + 0.7152 * f(+m[2]) + 0.0722 * f(+m[3]);
return L < 0.35 ? 'dark' : 'light';
}
// #569 (chaban-mb) — write only when the value actually changes.
//
// The DOM does not do this for you. classList.add of a token already present,
// classList.toggle to the state it is already in, setAttribute with the value it
// already has: each one re-sets the attribute and dispatches a mutation record.
// Harmless once; these run from 2Hz heartbeats and from observers that react to
// each other, and the measured idle cost on the release editor was 66 records a
// second, of which 93% came from writes that changed nothing (see
// dev/ui/measure-569-idle-mutations.mjs).
//
// Semantically these are exact no-ops: they skip a write ONLY when the value is
// already the one being written, so nothing that reads the DOM afterwards can
// tell the difference. That is the whole reason they are safe to sprinkle around
// a 2Hz loop.
function mbuCls(el, token, on) {
if (!el || !el.classList) return;
if (el.classList.contains(token) !== !!on) el.classList.toggle(token, !!on);
}
function mbuAttr(el, name, value) {
if (!el) return;
if (value === null || value === undefined || value === false) {
if (el.hasAttribute(name)) el.removeAttribute(name);
} else if (el.getAttribute(name) !== String(value)) {
el.setAttribute(name, String(value));
}
}
// For IDL properties (disabled, title, textContent, style.display …). Reading
// them is cheap; writing them is not, and textContent in particular replaces
// every child node.
//
// ⚠ textContent is the one to think twice about: its getter concatenates the
// text of ALL descendants, so on an element with child ELEMENTS the comparison
// can match while the DOM shape is wrong, and the guard then skips a write that
// would have flattened it. Only use it where the target holds text and nothing
// else.
function mbuProp(obj, prop, value) {
if (!obj) return;
if (obj[prop] !== value) obj[prop] = value;
}
// #569: the one element mbuTheme resolves --background through. Looked up by id
// rather than kept in a variable, so the seven scripts of a bundle share ONE
// probe instead of adding seven, and so it heals itself if anything removes it.
// It lives in : a permanent stray node under , outside head and
// body, is the sort of thing another script's document scan trips over.
function mbuProbe() {
var p = document.getElementById('mbu-theme-probe');
if (p) return p;
if (!document.body) return null;
p = document.createElement('span');
p.id = 'mbu-theme-probe';
p.setAttribute('aria-hidden', 'true');
p.style.cssText = 'position:absolute;left:-9999px;top:0;width:1px;height:1px;pointer-events:none;background:var(--background)';
document.body.appendChild(p);
return p;
}
function mbuTheme() {
var root = document.documentElement;
try {
var cs = getComputedStyle(root);
var forced = (cs.getPropertyValue('--mbu-theme') || '').trim();
var t = (forced === 'dark' || forced === 'light') ? forced
: (mbuThemeOf(getComputedStyle(document.body).backgroundColor)
|| mbuThemeOf(cs.backgroundColor)
|| mbuThemeOf(cs.getPropertyValue('--mbu-bg'))
|| 'light');
if (root.getAttribute('data-mbu-theme') !== t) root.setAttribute('data-mbu-theme', t);
// Should we adopt the userstyle's OWN shades, or use our own palette?
// Only if its --background actually agrees with the theme we detected.
// A userstyle can paint the page dark with ordinary rules and still leave
// --background at a light value for its own purposes; taking that on
// trust hands us a light surface under a correct dark theme, which is
// indistinguishable from the bug it looks like. Measured, not assumed.
var seed = null;
var raw = (cs.getPropertyValue('--background') || '').trim();
if (raw) {
// Resolved through a real element, because --background may itself be
// a var(), a named colour, or anything else CSS accepts.
//
// #569 (chaban-mb): this used to CREATE and REMOVE that element on
// every call, as a direct child of . mbuTheme re-runs whenever
// the root or body class changes, Mammoth watches the whole document
// for childList changes and reacts by toggling classes on , and
// those class changes wake mbuTheme again — a self-feeding loop,
// measured at 12 root-node mutations a second on an idle page, which
// is what makes DevTools blink.
//
// One element, created once and left in place, breaks it: the value
// is still resolved LIVE on every call (a cached reading would freeze
// the theme at whatever it was before Stylus injected, which is the
// bug this whole function exists to avoid) but nothing is added to or
// removed from the DOM to read it.
var probe = mbuProbe();
var got = null;
if (probe) {
got = mbuThemeOf(getComputedStyle(probe).backgroundColor);
} else {
// No yet — document-start. Fall back to the transient
// element for these first one or two calls; the idle loop this
// avoids cannot exist before the page has a body anyway.
var tmp = document.createElement('span');
tmp.style.cssText = 'position:absolute;left:-9999px;width:1px;height:1px;background:var(--background)';
document.documentElement.appendChild(tmp);
got = mbuThemeOf(getComputedStyle(tmp).backgroundColor);
tmp.remove();
}
if (got === t) seed = 'theme';
}
// guarded: setAttribute dispatches a mutation record even when the value
// is unchanged, and this runs several times a second
if (seed) { if (root.getAttribute('data-mbu-seed') !== seed) root.setAttribute('data-mbu-seed', seed); }
else if (root.hasAttribute('data-mbu-seed')) root.removeAttribute('data-mbu-seed');
return t;
} catch (e) { return 'light'; }
}
// A document-start script runs before the document is parsed: documentElement can
// still be null, and and don't exist. Observing a null root threw, the
// catch below swallowed it, and nothing (the watches, the re-checks) was ever set up,
// so such a script never read the theme at all (#625). It starts on the parsed page.
function mbuThemeStart() { try {
mbuTheme();
// Stylus and friends inject after us often enough that a one-shot read is
// wrong about half the time. Watch for stylesheets ARRIVING — head childList
// plus the root's own attributes — and never the whole subtree: this runs on
// the release editor, where a subtree observer calling getComputedStyle is a
// layout thrash on every keystroke.
var _mbuThemeT = 0;
var _mbuThemeSoon = function () {
clearTimeout(_mbuThemeT);
_mbuThemeT = setTimeout(mbuTheme, 150);
};
var _mbuThemeObs = new MutationObserver(_mbuThemeSoon);
_mbuThemeObs.observe(document.documentElement, { attributeFilter: ['style', 'class'] });
// ⚠ #569: characterData, not just childList. Until the idle thrash was fixed
// this function ran several times a second whether or not anything had
// changed — Apollo re-added a body class at 2Hz, which woke this observer,
// which is how a theme change was ever noticed. That accidental polling was
// LOAD-BEARING: with the thrash gone and only head-childList watched, a
// userstyle that REWRITES ITSELF (Stylus editing it live, or one switching
// palette) adds and removes no nodes, so nothing woke us and the theme went
// stale. Caught by verify-569-theme-still-tracks.mjs, which passes on the
// pre-fix build and failed on the first version of this one.
if (document.head) _mbuThemeObs.observe(document.head, { childList: true, subtree: true, characterData: true });
if (document.body) _mbuThemeObs.observe(document.body, { attributeFilter: ['style', 'class'] });
// …and the case that produces no DOM mutation at all: the OS flipping to dark
// under a userstyle with a prefers-color-scheme query. Nothing above can see
// that, and nothing did before either — it was simply never noticed while the
// page was re-checking itself several times a second.
try {
var _mbuMq = matchMedia('(prefers-color-scheme: dark)');
if (_mbuMq.addEventListener) _mbuMq.addEventListener('change', _mbuThemeSoon);
else if (_mbuMq.addListener) _mbuMq.addListener(_mbuThemeSoon);
} catch (e) {}
setTimeout(mbuTheme, 400);
setTimeout(mbuTheme, 2000);
} catch (e) { /* no observer, no theme switching — the light defaults still apply */ } }
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mbuThemeStart, { once: true });
else mbuThemeStart();
try {
var _mbuNs = (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window);
if (!_mbuNs.MBU) _mbuNs.MBU = {};
if (!_mbuNs.MBU.theme) _mbuNs.MBU.theme = mbuTheme;
if (!_mbuNs.MBU.helpHref) _mbuNs.MBU.helpHref = mbuHelpHref;
if (!_mbuNs.MBU.helpHtml) _mbuNs.MBU.helpHtml = mbuHelpHtml;
if (!_mbuNs.MBU.helpEl) _mbuNs.MBU.helpEl = mbuHelpEl;
if (!_mbuNs.MBU.toast) _mbuNs.MBU.toast = mbuToast;
if (!_mbuNs.MBU.cfgHeader) _mbuNs.MBU.cfgHeader = mbuCfgHeader;
if (!_mbuNs.MBU.dismissOn) _mbuNs.MBU.dismissOn = mbuDismissOn;
if (!_mbuNs.MBU.fitToolbar) _mbuNs.MBU.fitToolbar = mbuFitToolbar;
} catch (e) { /* a locked-down page must not stop the script loading */ }
//
style.textContent = MBU_TOKENS + MBU_UI_CSS + `
/* button on the release page */
#ii-btn {
display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px;
margin-left: 12px; font-size: 12px; font-weight: 600; color: var(--mbu-text-on-accent) !important;
background: var(--mbu-accent); border: none; border-radius: 4px; cursor: pointer;
vertical-align: middle; white-space: nowrap; transition: background .15s; }
#ii-btn:hover { background: var(--mbu-accent-hover); }
#ii-btn.has-missing { background: #a0225e; animation: ii-pulse 1.6s ease-in-out infinite; }
#ii-btn.has-missing:hover { background: #7d1a4a; }
#ii-btn .ii-status { font-size: 10px; font-weight: 600; opacity: .9; }
@keyframes ii-pulse { 0%,100%{opacity:1} 50%{opacity:.72} }
/* overlay + modal */
#ii-overlay { position: fixed; inset: 0; background: rgba(15,12,28,.45); z-index: var(--mbu-z-modal); display: none; }
#ii-overlay.open { display: block; }
#ii-modal {
position: fixed; top: 4vh; left: 50%; transform: translateX(-50%);
width: 1300px; max-width: 96vw; /* #471: wider — the table now carries both ISRC and Links columns at once */
/* a DEFINITE height (not just max-height) so the modal never grows as rows /
candidates are added — the body scrolls inside a fixed frame and the footer
stays put. #471 ("movable, resizable, maximize button"): dropped !important
from height so the corner resize handle and the maximize toggle can both
change it — nothing else on an MB page has fought for it since. */
height: 92vh; max-height: 92vh; min-width: 480px; min-height: 320px; background: var(--mbu-bg);
border-radius: var(--mbu-radius-lg); box-shadow: var(--mbu-shadow-lg); z-index: var(--mbu-z-modal-panel);
display: none; flex-direction: column; font-family: system-ui, sans-serif;
color: var(--mbu-text); overflow: hidden !important; resize: both; }
#ii-modal.open { display: flex; }
/* header: status text (left) · centered release (Zen) · actions (right).
#471 (majkinetor: "reduce title which takes a lot of space") — the old
ISRCs/Links tabs were removed (both columns are always visible now), so
this row carries less weight than it used to. */
#ii-hdr { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center;
padding: 4px 14px 0; background: var(--mbu-bg); border-bottom: 1px solid var(--mbu-border); flex-shrink: 0;
cursor: move; } /* #471: drag anywhere on the header (not its buttons) to move the window */
#ii-hdr button, #ii-hdr input, #ii-hdr a { cursor: pointer; }
.ii-zen { text-align: center; padding-bottom: 7px; min-width: 0; }
.ii-zen-t { font: 600 12px system-ui; color: var(--mbu-text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ii-zen-s { font: 10.5px system-ui; color: var(--mbu-text-weak); }
.ii-hicons { display: flex; gap: 6px; justify-self: end; padding-bottom: 5px; }
.ii-hico { width: 28px; height: 26px; display: inline-flex; align-items: center; justify-content: center;
border: 1px solid var(--mbu-border); border-radius: var(--mbu-radius); background: var(--mbu-bg); color: var(--mbu-text-dim); font-size: 14px; cursor: pointer; }
.ii-hico:hover { background: var(--mbu-bg-raised); color: var(--mbu-accent-text); border-color: var(--mbu-accent); }
.ii-hico.on { background: var(--mbu-bg-hover); color: var(--mbu-accent-text); border-color: var(--mbu-accent); }
/* #471: plain status text where the ISRCs/Links tabs used to be — no columns
to gate anymore, so nothing is hidden by it; just counts. */
.ii-hdr-status { font: 600 12px system-ui; color: var(--mbu-text-weak); white-space: nowrap; padding-bottom: 7px; }
.ii-hdr-status b { color: var(--mbu-accent-text); }
/* the Clear menu — a small dropdown anchored under its header button */
.ii-clear-wrap { position: relative; }
/* #471 review: Clear is caret-only (no label) and sits rightmost, after Find
links — #ii-links-btn (not the wrap) carries margin-left:auto now, since
it's the first of the two right-pinned toolbar items. #419: a caret still
needs a real hit area, not a bare glyph — same min footprint as .ii-hico. */
.ii-clear-toggle { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 26px;
border: 1px solid var(--mbu-border); border-radius: 5px; background: var(--mbu-bg); color: var(--mbu-text-dim); font-size: 11px; cursor: pointer; }
.ii-clear-toggle:hover, .ii-clear-toggle[aria-expanded="true"] { background: var(--mbu-bg-raised); color: var(--mbu-accent-text); border-color: var(--mbu-accent); }
.ii-clear-menu { display: none; position: absolute; top: 100%; right: 0; z-index: 60; flex-direction: column;
min-width: 128px; margin-top: 4px; background: var(--mbu-bg); border: 1px solid var(--mbu-border); border-radius: var(--mbu-radius);
box-shadow: 0 6px 18px rgba(0,0,0,.14); padding: 4px; }
.ii-clear-menu.open { display: flex; }
.ii-clear-menu button { background: none; border: none; text-align: left; padding: 6px 10px; font-size: 12px;
border-radius: 4px; cursor: pointer; color: var(--mbu-text); }
.ii-clear-menu button:hover { background: var(--mbu-bg-raised); color: var(--mbu-accent-text); }
/* toolbar */
#ii-tools { display: flex; align-items: center; flex-wrap: wrap; gap: 6px;
padding: 8px 16px; border-bottom: 1px solid var(--mbu-divider); flex-shrink: 0; background: var(--mbu-bg); }
#ii-tools #ii-links-btn { margin-left: auto; } /* pins Find links + the Clear caret to the toolbar's right edge */
.ii-tbtn { display: inline-flex; align-items: center; gap: 5px; padding: 4px 11px;
font-size: 12px; font-weight: 600; border-radius: 5px; cursor: pointer; text-decoration: none;
border: 1px solid var(--mbu-border); background: var(--mbu-bg); color: var(--mbu-text); white-space: nowrap; }
a.ii-tbtn:hover { text-decoration: none; }
.ii-tbtn:hover { background: var(--mbu-bg-raised); }
.ii-tbtn:disabled { opacity: .5; cursor: default; }
.ii-tbtn.sx { color: var(--mbu-accent-text); border-color: var(--mbu-accent); }
.ii-tbtn.dz { color: var(--mbu-error); border-color: var(--mbu-error); }
.ii-tbtn.sp { color: var(--mbu-ok); border-color: var(--mbu-ok); }
.ii-tbtn.bp { color: var(--mbu-ok); border-color: var(--mbu-ok); }
.ii-tbtn.td { color: var(--mbu-info); border-color: var(--mbu-border); }
.ii-tbtn.vo { color: var(--mbu-accent-text); border-color: var(--mbu-accent); }
.ii-tbtn.hd { color: var(--mbu-error); border-color: var(--mbu-error); }
.ii-tbtn.qz { color: var(--mbu-info); border-color: var(--mbu-info); }
/* import-source buttons: independently show icon and/or text (⚙ Setup).
Default = icons only (toolbar room); both can be on at once. */
.ii-bico { display: none; line-height: 0; }
.ii-bico svg { display: block; }
.ii-blabel { display: none; }
#ii-tools.ii-show-icons .ii-bico { display: inline-flex; align-items: center; }
#ii-tools.ii-show-text .ii-blabel { display: inline; }
/* #643: All has no logo: its "icon" is the word, at the logos' height (icons get line-height 0,
which collapsed it), and it is said once when icons and labels are both on */
.ii-tbtn.all { color: var(--mbu-text); border-color: var(--mbu-border-strong); }
.ii-tbtn.all .ii-bico { line-height: 16px; height: 16px; font-weight: 700; font-size: 12px; letter-spacing: .02em; }
#ii-tools.ii-show-icons.ii-show-text .ii-tbtn.all .ii-blabel { display: none; }
.ii-tbtn.primary { background: #198754; color: var(--mbu-text-on-accent); border-color: var(--mbu-ok); }
.ii-tbtn.primary:hover { background: #157347; }
/* #406: a collection is running — fade the whole button in/out so it's clearly in progress */
#ii-submit.ii-collecting { animation: ii-btn-pulse 1.1s ease-in-out infinite; }
@keyframes ii-btn-pulse { 0%,100% { opacity: 1; } 50% { opacity: .5; } }
.ii-tbtn.ghost { border-color: transparent; }
/* #302: a small purple dot marks a provider whose link was pulled from another
release in the group (not this release). Same dot on RG-sourced add candidates. */
.ii-tbtn.ii-rg, .ii-tl.ii-rg { position: relative; }
.ii-tbtn.ii-rg::after, .ii-tl.ii-rg::after { content: ''; position: absolute; top: -3px; right: -3px;
width: 7px; height: 7px; border-radius: 50%; background: var(--mbu-accent); border: 1.5px solid var(--mbu-border); }
/* In-MB marker (#180): a provider button gets a ring around its icon + a
brand tint when the release already has that platform's URL in MB; an
un-tinted/un-ringed button means the link was found by Platform Check. */
/* MB-linked: a strong brand ring on the WHITE button (#404) — the shared icons are
now full-colour, so a brand FILL would hide them (e.g. green Spotify on green). */
.ii-tbtn.ii-mb { border-color: currentColor; box-shadow: 0 0 0 1px currentColor; }
.ii-tbtn.ii-mb:hover { background: var(--mbu-bg-raised); }
/* Unified "paste a URL" control (#180), apollo "+"-unroll style: a small
round button that expands to an input on click; auto-detects the platform. */
.ii-urladd { display: inline-flex; align-items: center; gap: 5px; }
.ii-urladd.open { flex: 1 1 auto; min-width: 120px; } /* expanded input fills the row (#180) */
.ii-urladd-btn { display: inline-flex; align-items: center; justify-content: center;
flex: 0 0 auto; width: 26px; height: 26px; padding: 0; border: 1px solid var(--mbu-border); border-radius: 50%;
background: var(--mbu-bg); color: var(--mbu-text-dim); cursor: pointer; font-size: 16px; line-height: 1; }
.ii-urladd-btn:hover { background: var(--mbu-bg-raised); border-color: var(--mbu-border); }
.ii-urladd-btn svg { display: block; }
.ii-urladd-input { display: none; padding: 4px 9px;
border: 1px solid var(--mbu-border); border-radius: 5px; font-size: 12px; }
.ii-urladd-input:focus { outline: none; border-color: var(--mbu-accent); }
.ii-urladd.open .ii-urladd-input { display: inline-block; flex: 1 1 auto; width: auto; min-width: 0; }
.ii-urladd.open .ii-urladd-btn { border-radius: 5px; }
.ii-prog { font-size: 11px; color: var(--mbu-text-dim); min-width: 0; }
.ii-prog.err { color: var(--mbu-error); font-weight: 700; }
.ii-prog.continue { color: var(--mbu-accent-text); font-weight: 700; cursor: pointer; text-decoration: underline dotted; }
.ii-prog.tolog { cursor: pointer; }
.ii-prog.tolog:hover { text-decoration: underline; }
.ii-prog.continue:hover { color: var(--mbu-accent-hover); }
/* table */
/* min-height:0 → the track list scrolls instead of pushing the footer out of
the modal. !important guards against MusicBrainz's page CSS. */
#ii-body { flex: 1 1 auto !important; min-height: 0 !important; overflow: auto !important;
overscroll-behavior: contain; /* scrolling to either end stays in the modal, never scrolls the page behind */
padding: 0 0 56px 0; } /* 56px bottom = room for the absolutely-pinned footer */
#ii-table { width: 100%; border-collapse: collapse; font-size: 13px; table-layout: fixed; }
#ii-table thead th { position: sticky; top: 0; z-index: 2; background: var(--mbu-bg-raised);
text-align: left; font-size: 11px; font-weight: 700; text-transform: uppercase;
letter-spacing: .3px; color: var(--mbu-text-dim); padding: 7px 10px; border-bottom: 1px solid var(--mbu-border); }
/* #471: resizable columns — a thin drag handle on the right edge of each
resizable header cell; dragging changes that column's
width only
(table-layout:fixed), so #ii-body scrolls horizontally past the widened
table rather than squeezing its neighbor. th's own sticky positioning
(position: sticky, above) already gives it a positioning context for
the handle. */
.ii-col-resize { position: absolute; top: 0; right: -4px; width: 8px; height: 100%; cursor: col-resize; z-index: 3; }
/* A persistent line so the handle is discoverable, not just a hover surprise
(#471 review) — except it was drawn in --mbu-bg-sunken, which is a SURFACE
token: 94% of the background mixed with a little ink, i.e. ~#f0f0f0 on a
white header. Invisible, exactly as majkinetor reported ("column
separators are not visible on light theme, one visible is the one I hover
over"). --mbu-border is the token for a line you are meant to see, and is
what this table's own medium rows already use. */
.ii-col-resize::after { content: ''; position: absolute; top: 4px; bottom: 4px; left: 3px; width: 1px; background: var(--mbu-border); }
.ii-col-resize:hover, .ii-col-resize.dragging { background: rgba(111,66,193,.35); }
.ii-col-resize:hover::after, .ii-col-resize.dragging::after { background: var(--mbu-accent); width: 2px; }
.ii-medrow td { background: var(--mbu-bg-raised); font-weight: 700; font-size: 11.5px; color: var(--mbu-text-dim);
padding: 5px 10px; border-top: 1px solid var(--mbu-border); }
#ii-table td { padding: 6px 10px; border-bottom: 1px solid var(--mbu-border); vertical-align: top; }
.ii-pos { color: var(--mbu-text-weak); font-variant-numeric: tabular-nums; width: 34px; white-space: nowrap; }
.ii-track-title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; }
.ii-track-title a { color: inherit; text-decoration: none; }
.ii-track-title a:hover { color: var(--mbu-accent-text); text-decoration: underline; }
.ii-track-artist { color: var(--mbu-text-dim); font-size: 11.5px; }
.ii-track-dur { color: var(--mbu-text-dim); font-size: 11px; font-family: 'Courier New', monospace;
/* the row is two lines (title + artist) and #ii-table td is vertical-align:top,
so this smaller monospace sat ABOVE the title's baseline. baseline lines it
up with the title and the ISRC cell beside it. */
}
/* #ii-table td sets vertical-align:top at id specificity, so the class alone
could not move it — the duration has to be named at the same level. */
#ii-table td.ii-track-dur { vertical-align: baseline; }
/* #219/#301 — provider link icons live in the LINKED / ADD columns. Grey
monochrome = already linked on MB; brand colour = resolved + addable. */
.ii-tl-linked, .ii-tl-add { display: flex; align-items: center; gap: 9px; min-height: 18px; }
.ii-tl-add { flex-wrap: wrap; }
.ii-tl-linked { flex-wrap: wrap; } /* #389 one row when it fits (the wide col #406 gives it); wrap, never overflow into ADD */
.ii-tl-add:empty::before, .ii-tl-linked:empty::before { content: '—'; color: var(--mbu-text-weak); }
.ii-tl { display: inline-flex; align-items: center; line-height: 0; text-decoration: none; }
.ii-tl svg { width: 16px; height: 16px; display: block; }
.ii-tl.linked { color: var(--mbu-text-weak); } /* already on MB → quiet monochrome */
.ii-tl.linked:hover { color: var(--mbu-text-dim); }
.ii-tl.linked.ended { opacity: .4; } /* #341: relationship marked ended → faded */
.ii-tl.linked.ended:hover { opacity: .7; }
.ii-tl.new { cursor: pointer; } /* resolved, not linked → click to add (brand colour set inline) */
.ii-tl.new:hover { filter: brightness(1.12); }
.ii-tl.cand { display: none; } /* not resolved yet → hidden until Find links */
.ii-tl.spin { color: var(--mbu-info); animation: ii-tl-pulse 1s ease-in-out infinite; }
.ii-tl.removing { opacity: .35; animation: ii-tl-pulse 1s ease-in-out infinite; }
.ii-tl.absent { display: none; } /* track not found on provider */
@keyframes ii-tl-pulse { 0%,100% { opacity: .35; } 50% { opacity: .9; } }
.ii-existing { width: 150px; }
.ii-existing samp { display: block; font-size: 11px; font-weight: 700; color: var(--mbu-ok); font-family: 'Courier New', monospace; }
.ii-existing samp.dup { color: var(--mbu-error); background: var(--mbu-error-bg); border-radius: 3px; padding: 0 3px; }
.ii-existing .none { color: var(--mbu-info); font-style: italic; font-size: 11px; }
/* #159: highlight rows that still have no ISRC (no existing + nothing entered yet) */
.ii-row-missing > td { background: var(--mbu-warn-bg); }
.ii-row-missing > td:first-child { box-shadow: inset 3px 0 0 #f0ad4e; }
.ii-row-missing .ii-existing .none { color: var(--mbu-warn); font-style: normal; font-weight: 600; }
.ii-ex-item { display: flex; align-items: center; gap: 5px; cursor: pointer; }
.ii-ex-item input { cursor: pointer; margin: 0; flex-shrink: 0; }
.ii-ex-item.del samp { text-decoration: line-through; color: var(--mbu-error); }
/* pending Remove-ISRC edit — highlighted like MusicBrainz marks entities with
an open edit (orange/peach), with a strike-through to show it's a removal */
.ii-ex-pending { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; color: var(--mbu-warn);
background: var(--mbu-warn-bg); border: 1px solid var(--mbu-warn); border-radius: 3px; padding: 0 4px; }
.ii-ex-pending samp { color: var(--mbu-warn); text-decoration: line-through; }
a.ii-ex-pending { text-decoration: none; }
a.ii-ex-pending:hover { background: var(--mbu-warn); color: var(--mbu-text-on-accent); }
a.ii-ex-pending:hover samp { color: var(--mbu-text-on-accent); }
/* #587: a removal whose POST failed — it is still on the recording and no edit
exists, which must not read the same as a pending one */
.ii-ex-failed { border: 1px solid var(--mbu-error); border-radius: 3px; padding: 0 3px; }
.ii-ex-failed samp { color: var(--mbu-error); }
/* #587: the Delete button is the global activity indicator while a batch runs —
it counts down, so there is one obvious place to look besides the log */
.ii-tbtn.ii-busy { color: var(--mbu-warn); border-color: var(--mbu-warn); cursor: progress; opacity: 1; }
.ii-tbtn.ii-busy[disabled] { opacity: 1; }
.ii-inwrap { display: flex; align-items: center; gap: 5px; }
/* #490: the initial "search SoundExchange by title/artist" entry point used to be a
permanently-visible text link under every row (spammy on a long tracklist) — now a
row-hover-only icon left of the input. Opacity/pointer-events toggle (not display)
so its slot stays reserved and nothing shifts when it fades in. */
.ii-sx-hover { flex-shrink: 0; width: 20px; height: 22px; padding: 0; border: none; background: none;
cursor: pointer; color: var(--mbu-text-dim); opacity: 0; pointer-events: none; transition: opacity .1s;
display: flex; align-items: center; justify-content: center; }
.ii-sx-hover svg { display: block; }
tr:hover .ii-sx-hover { opacity: .55; pointer-events: auto; }
.ii-sx-hover:hover { opacity: 1 !important; color: var(--mbu-text); }
/* the × lives INSIDE the input box (part of the edit), so it doesn't shift the
row layout / SX text alignment */
.ii-input-box { position: relative; flex-shrink: 0; width: 150px; }
.ii-input { width: 100%; box-sizing: border-box; padding: 4px 22px 4px 7px; border: 1px solid var(--mbu-border); border-radius: 4px;
font-family: 'Courier New', monospace; font-size: 12.5px; font-weight: 700; letter-spacing: .5px; text-transform: uppercase; }
.ii-input:focus { outline: none; border-color: var(--mbu-accent); }
.ii-input.bad { border-color: var(--mbu-error); background: var(--mbu-bg-raised); }
.ii-input.dup { border-color: var(--mbu-warn); background: var(--mbu-bg-raised); }
.ii-input.dupother { border-color: var(--mbu-error); background: var(--mbu-error-bg); }
.ii-input.ok { border-color: var(--mbu-ok); }
.ii-clear { position: absolute; right: 3px; top: 50%; transform: translateY(-50%); width: 17px; height: 17px;
padding: 0; border: none; border-radius: 3px; background: transparent; color: var(--mbu-text-weak); font-size: 13px;
line-height: 1; cursor: pointer; display: flex; align-items: center; justify-content: center; }
.ii-clear:hover { background: var(--mbu-bg-hover); color: var(--mbu-error); }
.ii-plus { flex-shrink: 0; font-size: 11px; font-weight: 700; padding: 3px 7px; border: 1px solid var(--mbu-border);
border-radius: 4px; background: var(--mbu-bg); cursor: pointer; color: var(--mbu-text-dim); font-family: monospace; }
.ii-plus:hover { background: var(--mbu-bg-sunken); color: var(--mbu-text); }
.ii-plus-hidden { visibility: hidden; } /* reserve the slot on the first row so SX text still aligns */
/* explicit per-track SoundExchange trigger (#157), sits next to +1 */
.ii-sx { flex-shrink: 0; font-size: 11px; font-weight: 700; padding: 3px 7px; border: 1px solid var(--mbu-border);
border-radius: 4px; background: var(--mbu-bg-raised); cursor: pointer; color: var(--mbu-info); font-family: monospace; }
.ii-sx:hover { background: var(--mbu-info-bg); color: var(--mbu-info); }
.ii-sx:disabled { opacity: .4; cursor: default; }
.ii-sx:disabled:hover { background: var(--mbu-bg-raised); color: var(--mbu-info); }
/* #490: margin-left matches .ii-sx-hover's width (20px) + .ii-inwrap's gap (5px), so the
candidate list — a sibling of .ii-inwrap, not a child — lines up with the input box
instead of starting flush left under the hover icon. */
.ii-cands { margin-top: 4px; margin-left: 25px; display: flex; flex-direction: column; gap: 3px; width: auto; }
.ii-cand { display: flex; align-items: flex-start; gap: 7px; padding: 3px 7px; border: 1px solid var(--mbu-border);
border-radius: 4px; cursor: pointer; font-size: 11px; background: var(--mbu-bg); }
.ii-cand:hover { background: var(--mbu-bg); border-color: var(--mbu-info); }
.ii-cands.ii-collapsed .ii-cand:not(.chosen) { display: none; }
.ii-cand.chosen { box-shadow: inset 3px 0 0 #198754; }
.ii-cand.best { border-color: var(--mbu-info); background: var(--mbu-info-bg); }
.ii-cand.warn { border-color: var(--mbu-warn); background: var(--mbu-warn-bg); }
.ii-cand.bad { border-color: var(--mbu-error); background: var(--mbu-bg-raised); }
.ii-cand-isrc { font-family: 'Courier New', monospace; font-weight: 700; color: var(--mbu-info); flex-shrink: 0; padding-top: 1px; }
.ii-cand-meta { flex: 1; min-width: 0; color: var(--mbu-text-dim); white-space: normal; word-break: break-word; line-height: 1.35; }
.ii-bad { color: var(--mbu-error); font-weight: 600; text-decoration: underline wavy rgba(220,53,69,.55); text-underline-offset: 2px; }
.ii-mbdur { color: var(--mbu-ok); font-weight: 600; }
.ii-cand-src { margin-left: auto; font-size: 9px; text-transform: uppercase; color: var(--mbu-text-weak); flex-shrink: 0; }
.ii-cand-note { font-size: 11px; color: var(--mbu-text-weak); font-style: italic; padding: 2px 7px; }
.ii-row-fill { animation: ii-flash 1s ease-out; }
@keyframes ii-flash { 0%{background:rgba(25,135,84,.18)} 100%{background:transparent} }
/* footer — pinned ABSOLUTELY to the modal's bottom (out of the flex flow) so it
can never be pushed off, whatever the body does. The body reserves 56px of
bottom padding for it. #ii-modal is position:fixed → it's the containing block. */
#ii-foot { position: absolute !important; left: 0; right: 0; bottom: 0; z-index: 2;
display: flex; align-items: center; gap: 10px; padding: 9px 16px; height: 56px; box-sizing: border-box;
border-top: 1px solid var(--mbu-border); background: var(--mbu-bg); }
#ii-foot .ii-summary { font-size: 12px; color: var(--mbu-text-dim); min-width: 0; }
#ii-foot #ii-summary { flex: 1; } /* #471: both summaries show together — only the first grows */
#ii-foot .ii-summary b { color: var(--mbu-text); }
#ii-foot #ii-summary-links:not(:empty)::before { content: ' · '; color: var(--mbu-info); }
.ii-seq-badge { display: inline-flex; align-items: center; gap: 4px; margin-left: 8px; padding: 2px 9px;
font-size: 11px; font-weight: 700; font-family: 'Courier New', monospace; color: var(--mbu-ok); background: var(--mbu-ok-bg);
border: 1px solid var(--mbu-ok); border-radius: 11px; vertical-align: middle; letter-spacing: .3px; }
/* sub-panels (setup / bulk) */
.ii-pane { display: none; padding: 14px 16px; border-bottom: 1px solid var(--mbu-divider); background: var(--mbu-bg); flex-shrink: 0; }
/* an open pane scrolls internally past 45vh so it can never push the footer off */
.ii-pane.open { display: block; max-height: 45vh; overflow-y: auto; overscroll-behavior: contain; }
.ii-pane h3 { margin: 0 0 8px; font-size: 13px; display: flex; align-items: center; gap: 8px; }
.ii-pane-x { flex-shrink: 0; width: 19px; height: 19px; line-height: 1; padding: 0; font-size: 13px;
color: var(--mbu-text-dim); background: var(--mbu-bg); border: 1px solid var(--mbu-border); border-radius: 4px; cursor: pointer; }
.ii-pane-x:hover { background: var(--mbu-bg-raised); color: var(--mbu-text); border-color: var(--mbu-border); }
/* #301 standard config-window header: icon · name · version · Log · ? Help */
.ii-cfg-lnk { font: 600 12px system-ui; color: var(--mbu-accent-text); text-decoration: none; cursor: pointer;
background: none; border: none; padding: 2px 4px; }
.ii-cfg-lnk:hover { text-decoration: underline; }
.ii-cfg-grp { font: 700 10.5px system-ui; text-transform: uppercase; letter-spacing: .3px; color: var(--mbu-text-weak); margin: 4px 0 6px; }
.ii-pane label { display: block; font-size: 11.5px; color: var(--mbu-text-dim); margin: 6px 0 2px; }
.ii-pane input[type=text], .ii-pane textarea {
width: 100%; box-sizing: border-box; padding: 6px 8px; border: 1px solid var(--mbu-border);
border-radius: 4px; font-size: 12px; font-family: 'Courier New', monospace; }
.ii-pane textarea { min-height: 120px; resize: vertical; }
.ii-pane .row { display: flex; gap: 8px; align-items: flex-end; flex-wrap: wrap; }
.ii-pane .row > div { flex: 1; min-width: 200px; }
.ii-help { font-size: 11px; color: var(--mbu-text-dim); margin-top: 6px; line-height: 1.5; }
.ii-help a { color: var(--mbu-accent-text); }
.ii-authstate { font-size: 11.5px; padding: 4px 0; }
.ii-authstate.ok { color: var(--mbu-ok); }
.ii-authstate.no { color: var(--mbu-error); }
/* log pane */
#ii-log-out { font-family: 'Courier New', monospace; font-size: 11px; line-height: 1.45;
white-space: pre-wrap; word-break: break-word; background: #0d1117; color: #c9d1d9; /* the console keeps a FIXED dark ground in both themes, so its text must be fixed too: var(--mbu-info) is dark in the light theme, which put dark text on #0d1117 */
padding: 8px 10px; border-radius: 5px; max-height: 240px; overflow: auto; margin: 0; }
#ii-log-pane h3 { display: flex; align-items: center; gap: 8px; }
.ii-sx-group { display: inline-flex; align-items: center; gap: 7px; padding: 3px 8px 3px 4px;
border: 1px solid var(--mbu-accent); background: var(--mbu-bg-raised); border-radius: 7px; }
/* per-track ISRC-provider selector (#181): a split [icon|▾] on each row's button */
.ii-sxsplit { display: inline-flex; align-items: stretch; flex-shrink: 0; }
.ii-sxsplit .ii-sx { border-top-right-radius: 0; border-bottom-right-radius: 0; }
.ii-sxprov { display: inline-flex; align-items: center; justify-content: center; padding: 0 4px; margin-left: -1px;
font-size: 9px; color: var(--mbu-text-dim); background: var(--mbu-bg-raised); border: 1px solid var(--mbu-border); border-left-color: var(--mbu-border);
border-top-right-radius: 5px; border-bottom-right-radius: 5px; cursor: pointer; }
.ii-sxprov:hover { background: var(--mbu-info-bg); color: var(--mbu-info); }
.ii-prov-menu { display: none; position: absolute; z-index: 60; flex-direction: column; min-width: 178px;
padding: 4px; background: var(--mbu-bg); border: 1px solid var(--mbu-accent); border-radius: 7px; box-shadow: 0 6px 22px rgba(40,20,80,.18); }
.ii-prov-menu.open { display: flex; }
.ii-prov-item { display: flex; align-items: center; gap: 8px; padding: 6px 9px; font-size: 12px; font-weight: 600;
color: inherit; background: none; border: 0; border-radius: 5px; cursor: pointer; text-align: left; }
.ii-prov-item:hover { background: var(--mbu-bg-raised); }
.ii-prov-item.active { background: var(--mbu-bg-hover); }
.ii-prov-item.active::after { content: '✓'; margin-left: auto; color: var(--mbu-accent-text); font-weight: 700; }
.ii-prov-ico { display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; flex-shrink: 0; }
.ii-prov-ico svg { width: 16px; height: 16px; }
.ii-prov-sx { font-size: 10px; font-weight: 800; color: var(--mbu-accent-text); }
.ii-prov-name { color: var(--mbu-text); }
/* per-track button shows the chosen provider's icon */
.ii-sx svg { width: 13px; height: 13px; display: block; }
/* collapsible "exact" toggle — collapsed by default so the toolbar stays compact */
.ii-exact-toggle { display: inline-flex; align-items: center; gap: 3px; padding: 3px 8px; font-size: 11px;
font-weight: 600; color: var(--mbu-accent-text); background: var(--mbu-bg); border: 1px solid var(--mbu-accent); border-radius: 5px; cursor: pointer; }
.ii-exact-toggle:hover { background: var(--mbu-bg-raised); color: var(--mbu-accent-text); }
.ii-exact-toggle .ii-exact-car { font-size: 9px; transition: transform .15s; }
/* NB: the state class is namespaced (ii-collapsed) on purpose — MB's common.css paints a
20px absolute ::after fade overlay on ANY bare .collapsed, which blanketed the toolbar
and swallowed clicks on the bottom half of every button (#414). */
.ii-sx-group:not(.ii-collapsed) .ii-exact-toggle .ii-exact-car { transform: rotate(180deg); }
/* a filled dot on the toggle when any exact option is active while collapsed */
.ii-exact-toggle.on { color: var(--mbu-accent-text); border-color: var(--mbu-accent); background: var(--mbu-bg-raised); }
.ii-exact-toggle.on::after { content: ''; width: 6px; height: 6px; border-radius: 50%; background: var(--mbu-accent); }
.ii-sx-group.ii-collapsed .ii-exact-set { display: none; }
.ii-exact-set { display: inline-flex; align-items: center; gap: 9px; font-size: 11px; color: var(--mbu-text-dim); }
.ii-ex-all-lbl { display: inline-flex; align-items: center; gap: 5px; cursor: pointer; }
.ii-ex-all-lbl input { cursor: pointer; }
.ii-exact-set label { display: inline-flex; align-items: center; gap: 3px; cursor: pointer; margin: 0; }
.ii-exact-set input { cursor: pointer; }
.ii-cand.inmb { opacity: .72; }
.ii-cand-inmb { margin-left: auto; font-size: 9px; font-weight: 700; color: var(--mbu-ok); flex-shrink: 0; }
.ii-lookup { flex: 1; min-width: 0; font-size: 11px; white-space: normal;
word-break: break-word; line-height: 1.35; }
.ii-lookup.ok { color: var(--mbu-ok); }
.ii-lookup.warn { color: var(--mbu-warn); }
.ii-lookup.err { color: var(--mbu-error); }
.ii-lookup.spin { color: var(--mbu-text-dim); }
/* #643: the All verdict chip and its comparison popover */
.ii-lookup.ii-all-chip { flex: 0 0 auto; cursor: pointer; font-weight: 700; padding: 1px 7px; border-radius: 10px; border: 1px solid currentColor; }
.ii-lookup.ii-all-chip.none { color: var(--mbu-text-weak); }
.ii-lookup.ii-all-chip:hover { background: var(--mbu-bg-hover); }
#ii-all-pop { position: absolute; z-index: 20; min-width: 300px; max-width: 620px; background: var(--mbu-bg); color: var(--mbu-text); border: 1px solid var(--mbu-accent); border-radius: 8px; box-shadow: var(--mbu-shadow-lg); padding: 6px 4px; font-size: 12px; user-select: text; }
#ii-all-pop.pinned { box-shadow: 0 0 0 1px var(--mbu-accent), var(--mbu-shadow-lg); }
#ii-all-pop .ii-all-h { display: flex; flex-wrap: wrap; align-items: center; gap: 5px; font-weight: 700; color: var(--mbu-text-dim); padding: 2px 6px 5px 8px; border-bottom: 1px solid var(--mbu-border-soft); margin-bottom: 3px; }
#ii-all-pop .ii-all-sp { flex: 1 1 auto; }
#ii-all-pop .ii-all-pick { font: inherit; font-family: var(--mbu-font-mono); font-weight: 400; font-size: 11px; padding: 1px 6px; border: 1px solid var(--mbu-border); border-radius: 4px; background: var(--mbu-bg-raised); color: var(--mbu-text); cursor: pointer; }
#ii-all-pop .ii-all-pick.other { border-color: var(--mbu-error); }
#ii-all-pop .ii-all-pick.sel { border-color: var(--mbu-accent); background: var(--mbu-bg-hover); }
#ii-all-pop .ii-all-pick .ii-all-n { color: var(--mbu-text-weak); }
#ii-all-pop .ii-all-line { display: grid; grid-template-columns: 14px 18px 96px 1fr auto auto; gap: 6px; align-items: center; padding: 3px 8px; border-radius: 5px; }
#ii-all-pop .ii-all-ico svg { width: 14px; height: 14px; display: block; }
#ii-all-pop .ii-all-isrc { font-family: var(--mbu-font-mono); }
#ii-all-pop .ii-all-note { color: var(--mbu-text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
#ii-all-pop .ii-all-at { cursor: help; }
#ii-all-pop .ii-all-len { font-variant-numeric: tabular-nums; color: var(--mbu-text-dim); text-align: right; }
#ii-all-pop .ii-all-sub { color: var(--mbu-text-weak); }
#ii-all-pop .ii-all-mark { text-align: center; }
#ii-all-pop .ii-all-ok .ii-all-mark { color: var(--mbu-ok); }
#ii-all-pop .ii-all-song .ii-all-mark, #ii-all-pop .ii-all-blocked .ii-all-mark { color: var(--mbu-warn); }
#ii-all-pop .ii-all-differs { background: var(--mbu-error-bg); }
#ii-all-pop .ii-all-differs .ii-all-mark { color: var(--mbu-error); }
#ii-all-pop .ii-all-none, #ii-all-pop .ii-all-pending { color: var(--mbu-text-weak); }
#ii-all-pop .ii-all-use, #ii-all-pop .ii-all-copy { font-size: 11px; padding: 1px 8px; border: 1px solid var(--mbu-border); border-radius: 4px; background: var(--mbu-bg-raised); color: var(--mbu-text); cursor: pointer; }
#ii-all-pop .ii-all-copy.done { border-color: var(--mbu-ok); color: var(--mbu-ok); }
#ii-all-pop .ii-all-sxlink { color: var(--mbu-link, var(--mbu-accent)); text-decoration: none; font-weight: 700; }
#ii-all-pop .ii-all-copy:hover, #ii-all-pop .ii-all-pick:hover,
#ii-all-pop .ii-all-use:hover { border-color: var(--mbu-accent); }
.ii-lookup-rel { color: var(--mbu-text-dim); }
.ii-lookup.pending { color: var(--mbu-text-dim); cursor: pointer; text-decoration: underline dotted #adb5bd; text-underline-offset: 2px; }
/* #431: a position-matched fill whose length/title doesn't fit the MB track */
input.ii-in-suspect { border-color: var(--mbu-warn) !important; background: var(--mbu-bg-raised) !important; box-shadow: 0 0 0 2px rgba(224,137,42,.28); }
#ii-suspect-badge { color: var(--mbu-warn); background: var(--mbu-warn-bg); border: 1px solid var(--mbu-warn); border-radius: 5px;
padding: 2px 8px; font-weight: 700; font-size: 11.5px; cursor: pointer; flex-shrink: 0; white-space: nowrap; }
#ii-suspect-badge:hover { background: var(--mbu-warn-bg); }
.ii-lookup.pending:hover { color: var(--mbu-text); }
.ii-cand-refine { font-size: 10.5px; color: var(--mbu-accent-text); cursor: pointer; padding: 2px 7px;
border: 1px dashed var(--mbu-accent); border-radius: 4px; background: var(--mbu-bg-raised); width: max-content; }
.ii-cand-refine:hover { background: var(--mbu-bg-hover); text-decoration: underline; }
.ii-cand-pending { font-size: 10.5px; color: var(--mbu-text-dim); cursor: pointer; padding: 3px 8px;
border: 1px dashed var(--mbu-border); border-radius: 4px; background: var(--mbu-bg); width: max-content; }
.ii-cand-pending:hover { background: var(--mbu-bg-raised); color: var(--mbu-text); border-color: var(--mbu-border); }
/* SoundExchange refine panel */
#ii-sxpanel { position: fixed; top: 9vh; right: 4vw; width: 560px; max-width: 92vw; max-height: 78vh;
background: var(--mbu-bg); border: 1px solid var(--mbu-accent); border-radius: var(--mbu-radius-lg); box-shadow: 0 14px 44px rgba(0,0,0,.32);
z-index: var(--mbu-z-modal-panel); display: none; flex-direction: column; overflow: hidden; font-family: system-ui, sans-serif; }
#ii-sxpanel.open { display: flex; }
.ii-sxp-hdr { display: flex; align-items: center; gap: 8px; padding: 9px 13px; background: var(--mbu-bg-raised);
border-bottom: 1px solid var(--mbu-accent); cursor: move; user-select: none; }
.ii-sxp-hdr .t { flex: 1; font-size: 13px; font-weight: 700; color: var(--mbu-accent-deep-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ii-sxp-hdr .t b { color: var(--mbu-accent-text); }
#ii-sxp-close { background: none; border: none; font-size: 17px; color: var(--mbu-text-dim); cursor: pointer; line-height: 1; }
#ii-sxp-close:hover { color: var(--mbu-text); }
.ii-sxp-form { display: grid; grid-template-columns: 1fr 1fr auto; gap: 6px; padding: 10px 13px 10px; align-items: start; }
.ii-sxp-field { position: relative; display: flex; align-items: center; }
#ii-sxp-f-title { grid-column: 1; } #ii-sxp-f-artist { grid-column: 2; }
#ii-sxp-f-release { grid-column: 1 / 3; }
.ii-sxp-inp { width: 100%; padding: 6px 31px; border: 1px solid var(--mbu-border); border-radius: var(--mbu-radius); font-size: 13px; box-sizing: border-box; }
.ii-sxp-inp:focus { outline: none; border-color: var(--mbu-accent); }
.ii-sxp-field.off .ii-sxp-inp { color: var(--mbu-text-weak); background: var(--mbu-bg); }
.ii-sxp-en { position: absolute; left: 8px; width: 15px; height: 15px; margin: 0; cursor: pointer; z-index: 1; flex-shrink: 0; }
.ii-sxp-E { position: absolute; right: 5px; width: 23px; height: 23px; padding: 0; display: inline-flex; align-items: center;
justify-content: center; font-size: 12px; font-weight: 700; color: var(--mbu-text-weak); background: var(--mbu-bg); border: 1px solid var(--mbu-border);
border-radius: 4px; cursor: pointer; }
.ii-sxp-E:hover { color: var(--mbu-accent-text); border-color: var(--mbu-accent); }
.ii-sxp-E.on { color: var(--mbu-text); border-color: #212529; }
.ii-sxp-field.off .ii-sxp-E { opacity: .4; pointer-events: none; }
#ii-sxp-search { grid-column: 3; grid-row: 1 / 3; align-self: stretch; padding: 0 18px; border: none;
border-radius: var(--mbu-radius); background: var(--mbu-accent); color: var(--mbu-text-on-accent); font-size: 13px; font-weight: 700; cursor: pointer; }
#ii-sxp-search:hover { background: var(--mbu-accent-hover); } #ii-sxp-search:disabled { background: var(--mbu-bg-sunken); }
.ii-sxp-status { padding: 2px 13px; font-size: 11px; color: var(--mbu-text-dim); min-height: 14px; }
.ii-sxp-status.err { color: var(--mbu-error); }
.ii-sxp-results { flex: 1; overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain; padding: 4px 13px 12px; display: flex; flex-direction: column; gap: 4px; }
.ii-sxp-row { display: flex; align-items: center; gap: 9px; padding: 7px 9px; border: 1px solid var(--mbu-border);
border-radius: var(--mbu-radius); cursor: pointer; overflow: hidden; flex-shrink: 0; }
.ii-sxp-row:hover { background: var(--mbu-bg); border-color: var(--mbu-info); }
.ii-sxp-row.best { border-color: var(--mbu-info); background: var(--mbu-info-bg); }
.ii-sxp-row.warn { border-color: var(--mbu-warn); background: var(--mbu-warn-bg); }
.ii-sxp-row.bad { border-color: var(--mbu-error); background: var(--mbu-bg-raised); }
.ii-sxp-row.cur { border-color: var(--mbu-ok); background: var(--mbu-ok-bg); }
.ii-sxp-row { align-items: flex-start; }
.ii-sxp-isrc { font-family: 'Courier New', monospace; font-weight: 700; color: var(--mbu-info); flex-shrink: 0; font-size: 12px; padding-top: 1px; }
.ii-sxp-meta { flex: 1; min-width: 0; }
.ii-sxp-meta .a { display: block; font-size: 12px; color: var(--mbu-text); white-space: normal; word-break: break-word; line-height: 1.35; }
.ii-sxp-meta .b { display: block; font-size: 10.5px; color: var(--mbu-text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ii-sxp-inmb { font-size: 9px; font-weight: 700; color: var(--mbu-ok); flex-shrink: 0; }
.ii-sxp-foot { padding: 8px 13px; border-top: 1px solid var(--mbu-divider); background: var(--mbu-bg); flex-shrink: 0; }
.ii-sxp-foot a { font-size: 11.5px; font-weight: 600; color: var(--mbu-accent-text); text-decoration: none; }
.ii-sxp-foot a:hover { text-decoration: underline; }
/* ──────────────────────────────────────────────────────────────────
MOBILE / NARROW VIEWPORTS
MusicBrainz serves width=device-width, so phones render the modal at
~96vw of a small viewport. The desktop header (title + tool buttons on
ONE flex row) then squeezes the title into a one-word-wide column, and
the fixed-width table (560px New-ISRC col) overflows horizontally.
Below ~700px we grow the modal, stack the header, turn every track row
into a full-width card, and let the toolbar/footer wrap.
────────────────────────────────────────────────────────────────── */
@media (max-width: 700px) {
/* Keep the modal CENTERED (inherit the base left:50% + translateX). Do
NOT pin it to 0,0 / 100vw: on tablets where MusicBrainz's desktop
layout overflows a narrow layout-viewport, a position:fixed 100vw /
left:0 modal is sized to the layout viewport and lands in the top-left
corner instead of filling the screen. A centered 96vw × 96vh dialog
reads correctly everywhere. */
#ii-modal {
top: 2vh !important;
width: 96vw !important; max-width: 96vw !important;
height: 96vh !important; max-height: 96vh !important; }
@supports (height: 100dvh) {
#ii-modal { height: 96dvh !important; max-height: 96dvh !important; } }
/* header: title gets its own row (subtitle truncates), tool buttons wrap
below it, close pinned to the top-right corner */
#ii-hdr { flex-wrap: wrap; position: relative; padding: 10px 12px 8px; gap: 6px; }
#ii-hdr h2 { flex: 1 1 100%; font-size: 14px; padding-right: 30px; min-width: 0;
display: flex; align-items: center; }
#ii-hdr h2 em { white-space: nowrap; flex-shrink: 0; }
#ii-hdr .ii-sub { flex: 1 1 auto; min-width: 0; margin-left: 6px; font-size: 12px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
#ii-hdr .ii-tbtn { font-size: 11px; padding: 4px 9px; }
#ii-close { position: absolute; top: 5px; right: 9px; font-size: 22px; padding: 2px 6px; }
/* toolbar: tighten, keep "Clear entered" pushed to the right */
#ii-tools { padding: 7px 12px; gap: 5px; }
.ii-exact-set { gap: 7px; }
/* table → one full-width card per track row (CSS-only, no DOM change).
#471: ISRC and Links columns are always both present now (7 cells per row
instead of 5), so every cell gets its own explicit grid-row by nth-child —
a class-based selector on an inner div (the old .ii-existing rule) is NOT
a grid item and silently does nothing; only the
itself, as the grid's
direct child, can be positioned. That dead selector is why the Links-scope
cells fell back to browser auto-placement and their icon lists collapsed
to a near-zero-width column, wrapping one icon per line (#476's screenshot). */
#ii-table { display: block; }
#ii-table thead, #ii-table colgroup { display: none; }
#ii-table tbody { display: block; }
#ii-table tr:not(.ii-medrow) {
display: grid; grid-template-columns: 24px 1fr auto; align-items: start;
column-gap: 8px; row-gap: 4px; padding: 9px 12px; border-bottom: 1px solid var(--mbu-border); }
#ii-table tr:not(.ii-medrow) > td { display: block; border: none !important; padding: 0; }
#ii-table tr:not(.ii-medrow) > td:nth-child(1) { grid-column: 1; grid-row: 1; } /* # */
#ii-table tr:not(.ii-medrow) > td:nth-child(2) { grid-column: 2; grid-row: 1; } /* track */
#ii-table tr:not(.ii-medrow) > td:nth-child(3) { grid-column: 3; grid-row: 1; text-align: right; } /* dur */
#ii-table tr:not(.ii-medrow) > td:nth-child(4) { grid-column: 2 / 4; grid-row: 2; width: auto; } /* existing ISRC */
#ii-table tr:not(.ii-medrow) > td:nth-child(5) { grid-column: 2 / 4; grid-row: 3; } /* new ISRC */
#ii-table tr:not(.ii-medrow) > td:nth-child(6) { grid-column: 2 / 4; grid-row: 4; } /* linked */
#ii-table tr:not(.ii-medrow) > td:nth-child(7) { grid-column: 2 / 4; grid-row: 5; } /* add */
#ii-table tr.ii-medrow { display: block; }
#ii-table tr.ii-medrow td { display: block; }
/* paint the whole card (not just cells, which would leave striped gaps) */
#ii-table tr.ii-row-missing { background: var(--mbu-warn-bg); box-shadow: inset 3px 0 0 var(--mbu-warn); }
#ii-table tr.ii-row-missing > td { background: transparent; box-shadow: none; }
/* let the New-ISRC input grow to the card width; candidates span it */
.ii-input-box { flex: 1 1 auto; width: auto; min-width: 0; }
.ii-inwrap { flex-wrap: wrap; }
.ii-cands { width: 100%; }
/* footer wraps: summary on its own line, action buttons below it */
#ii-foot { height: auto; min-height: 52px; flex-wrap: wrap; padding: 8px 12px; gap: 6px; }
#ii-foot .ii-summary { flex: 1 1 100%; font-size: 11.5px; }
#ii-foot .ii-tbtn { font-size: 11px; padding: 6px 10px; flex: 0 1 auto; }
#ii-body { padding-bottom: 108px; } /* clear the taller wrapped footer */
/* secondary panels: keep them on-screen */
#ii-sxpanel { top: 2vh !important; left: 2vw !important; right: 2vw !important;
width: auto !important; max-width: none !important; max-height: 92vh !important; }
}
`;
// @run-at document-start (needed for the Spotify harvester) can fire before
// / exist; the MB-side editor only needs the DOM, so defer to ready.
function whenDomReady(fn) {
if (document.head || document.body) fn();
else document.addEventListener('DOMContentLoaded', fn, { once: true });
}
whenDomReady(() => (document.head || document.documentElement).appendChild(style));
/* ═══════════════════════════════════════════════════════════════════════
RELEASE MODEL (single WS2 fetch → everything)
═══════════════════════════════════════════════════════════════════════ */
let RELEASE = null; // { title, tracks:[{recId, title, artist, dur, mediumPos, trackPos, existing:[], pending:''}], deezerId, spotifyId }
// Recognise an album/release provider link → { k: RELEASE field, v: id/url }, or
// null. One place so the per-release parse and the release-group scan (#302) agree.
function matchProviderLink(u) {
let m;
// #640: the release's YouTube Music album — its tracklist resolves the per-track links
if ((m = u.match(/music\.youtube\.com\/(?:playlist\?(?:[^#]*&)?list=(OLAK5uy_[\w-]+)|browse\/(MPREb_[\w-]+))/i))) return { k: 'ytmUrl', v: m[1] ? 'https://music.youtube.com/playlist?list=' + m[1] : 'https://music.youtube.com/browse/' + m[2] };
// #644: the release's Amazon Music album — its tracklist resolves the per-track links
if ((m = u.match(/^https?:\/\/music\.amazon\.([a-z.]+)\/albums\/([A-Z0-9]{10})/i))) return { k: 'amzUrl', v: 'https://music.amazon.' + m[1].toLowerCase() + '/albums/' + m[2].toUpperCase() };
if ((m = u.match(/^https?:\/\/[a-z0-9-]+\.bandcamp\.com\/album\/[^?#]+/i))) return { k: 'bandcampUrl', v: m[0] }; // #300: per-track URLs by position
if ((m = u.match(/^(https?:\/\/music\.apple\.com\/[a-z]{2}\/album\/(?:[^/?#]+\/)?\d+)/i))) return { k: 'appleUrl', v: m[1] }; // album page ld+json lists every track URL
// Legacy iTunes links are equivalent to Apple Music — normalise to the canonical
// music.apple.com album URL so the same fetch path handles them (#436).
if ((m = u.match(/itunes\.apple\.com\/(?:([a-z]{2})\/)?album\/(?:[^/?#]+\/)?id(\d+)/i))) return { k: 'appleUrl', v: 'https://music.apple.com/' + (m[1] || 'us').toLowerCase() + '/album/' + m[2] };
if ((m = u.match(/open\.spotify\.com\/album\/([A-Za-z0-9]+)/))) return { k: 'spotifyId', v: m[1] };
if ((m = u.match(/deezer\.com\/(?:[a-z]{2}\/)?album\/(\d+)/))) return { k: 'deezerId', v: m[1] };
if ((m = u.match(/beatport\.com\/release\/[^/]+\/(\d+)/))) return { k: 'beatportId', v: m[1] };
if ((m = u.match(/(?:listen\.)?tidal\.com\/(?:browse\/)?album\/(\d+)/))) return { k: 'tidalId', v: m[1] };
if ((m = u.match(/volumo\.com\/album\/(\d+)/))) return { k: 'volumoId', v: m[1] }; // id or leading ICPN
// Qobuz album id is the last path segment (…/album//, or the slug-less open form …/album/)
if ((m = u.match(/qobuz\.com\/(?:[a-z]{2}-[a-z]{2}\/)?album\/(?:[^/?#]+\/)?([a-z0-9]{8,})/i))) return { k: 'qobuzId', v: m[1] };
// HDtracks: new #/album/<24-hex ObjectId> resolves directly; legacy 5009 rels carry the
// UPC in valbum_code (fetchHDtracks resolves it via barcode search). Slug-id / artist-page
// legacy forms have no clean id mapping and are skipped (Platform Check handles by barcode).
if ((m = u.match(/hdtracks\.com\/(?:#\/)?album\/([a-f0-9]{24})/i))) return { k: 'hdtracksId', v: m[1] };
if ((m = u.match(/hdtracks\.com\/[^?]*[?&]valbum_code=(\d{8,})/i))) return { k: 'hdtracksId', v: m[1] };
// SoundCloud SET (playlist) = album, or a bare TRACK url = a single-track release
// (#439, chaban-mb); the whole permalink URL is the id (api-v2 resolves either). #439
if ((m = u.match(/^(https?:\/\/(?:www\.|m\.)?soundcloud\.com\/[^/?#]+\/(?:sets\/)?[^/?#]+)/i))) return { k: 'scUrl', v: m[1] };
// Audiomack album, or a song = a single-track release (#664); the canonical URL is the id
if ((m = u.match(/^https?:\/\/(?:www\.)?audiomack\.com\/([^/?#]+)\/(album|song)\/([^/?#]+)/i))) return { k: 'akUrl', v: 'https://audiomack.com/' + m[1] + '/' + m[2].toLowerCase() + '/' + m[3] };
// 7digital release (#669): the id ends its slug; the link (with its store) is what's searched with
if ((m = u.match(/^(https?:\/\/(?:[a-z]+\.)?(?:7digital\.com|zdigital\.com\.au)\/artist\/[^/?#]+\/release\/(?:[^/?#]*-)?\d{3,})\/?(?:[?#]|$)/i))) return { k: 'sdUrl', v: m[1] };
return null;
}
const rgProvidersEnabled = () => !!store.get('rg_providers', false);
// #314: by default respect Platform Check's link confidence — don't import from a
// PC link it withheld by a barcode/format mismatch (it can be a wrong release).
// Opt in to the old #211 behaviour (use it anyway) with this toggle.
const ignorePcConfidence = () => !!store.get('ignore_pc_confidence', false);
// #603: which service turns a Spotify album link into ISRCs — molla (default,
// JSON) or ISRC Hunt (the original, HTML scrape; down since #596).
const spotifyIsrcSource = () => store.get('spotify_isrc_source', 'molla') === 'isrchunt' ? 'isrchunt' : 'molla';
// #302: releases in a release group are often split by platform (one has Deezer,
// another Spotify, …). ISRC and track-link edits target recordings, which are
// shared across the RG — so, when the option is on, fill any provider link the
// current release lacks from its sibling releases (one browse, fill-if-empty).
async function augmentProvidersFromRG(rgId) {
if (!rgId) return;
try {
const r = await gmGet(MB_WS2 + 'release?release-group=' + rgId + '&inc=url-rels&limit=100&fmt=json', { 'Accept': 'application/json', 'User-Agent': UA });
if (r.status !== 200) { Log.warn('Release group: sibling scan gave ' + r.status); return; }
const j = JSON.parse(r.responseText || '{}');
const added = [];
RELEASE.rgFrom = RELEASE.rgFrom || {}; // provider field -> { release, title } it was pulled from (#302)
(j.releases || []).forEach(rel => {
if (rel.id === mbid) return;
(rel.relations || []).forEach(rl => {
const u = rl.url && rl.url.resource; if (!u) return;
const hit = matchProviderLink(u);
if (hit && !RELEASE[hit.k]) { RELEASE[hit.k] = hit.v; RELEASE.rgFrom[hit.k] = { release: rel.id, title: rel.title || '' }; added.push(hit.k.replace(/Id|Url$/, '')); }
});
});
if (added.length) Log.info('Release group: pulled provider links from sibling releases — ' + [...new Set(added)].join(', '));
else Log.info('Release group: no extra provider links on sibling releases');
} catch (e) { Log.warn('Release group provider scan failed: ' + errText(e)); }
}
/* #587 (majkinetor: "Dedup it."). The overview page fetches the release once on
load to prime the button's missing-ISRC count, and opening the panel fetched
it AGAIN — `if (!RELEASE)` is still true while the first one is in flight, so
a click during that window started a second identical request. Two calls for
the same data on every open.
Resolved, not re-requested: an already-loaded release is handed back as-is,
and a call made while one is in flight joins it. The in-flight handle is
cleared on settle so a FAILED load can still be retried. */
let _relInFlight = null;
function fetchRelease() {
if (RELEASE) return Promise.resolve(RELEASE);
if (_relInFlight) return _relInFlight;
_relInFlight = fetchReleaseNow();
_relInFlight.catch(() => {}).then(() => { _relInFlight = null; });
// #607: the page button's count follows EVERY successful load, not just the
// page-load one. That one can fail (MB 503s under its rate limit) and leave
// "?" — and opening the dialog later loaded the release without telling it.
_relInFlight.then(() => { try { updateBtnStatus(); } catch (e) {} }, () => {});
return _relInFlight;
}
function fetchReleaseNow() {
return gmGet(
// recording-level-rels folds each recording's URL relationships into this one
// call (same data the overview's "Display credits inline" shows) — so the
// #219 track-link icons know what's already linked with no extra request.
MB_WS2 + 'release/' + mbid + '?inc=recordings+artist-credits+isrcs+url-rels+recording-level-rels+release-groups&fmt=json',
{ 'Accept': 'application/json', 'User-Agent': UA }
).then(async r => {
if (r.status !== 200) throw new Error('MB ' + r.status);
const data = JSON.parse(r.responseText);
const tracks = [];
(data.media || []).forEach(med => {
(med.tracks || []).forEach(trk => {
const rec = trk.recording || {};
tracks.push({
recId: rec.id || '',
title: trk.title || rec.title || '',
artist: acName(trk['artist-credit'] || rec['artist-credit']),
dur: msToMmSs(trk.length || rec.length) || '',
mediumPos: med.position,
mediumTitle: med.title || '',
trackPos: trk.position,
number: trk.number,
existing: (rec.isrcs || []).slice(),
recUrls: (rec.relations || []).map(rel => rel.url && rel.url.resource).filter(Boolean), // #219: existing url rels on the recording
endedUrls: new Set((rec.relations || []).filter(rel => rel.url && rel.url.resource && rel.ended).map(rel => rel.url.resource)), // #341: url rels already marked ended → render faded
pending: '',
});
});
});
const rels = data.relations || [];
const prov = { deezerId: null, spotifyId: null, beatportId: null, tidalId: null, volumoId: null, hdtracksId: null, qobuzId: null, bandcampUrl: null, appleUrl: null, scUrl: null, ytmUrl: null, amzUrl: null };
rels.forEach(rel => {
const u = rel.url && rel.url.resource;
if (!u) return;
const hit = matchProviderLink(u);
if (hit && !prov[hit.k]) prov[hit.k] = hit.v; // first match per provider
});
// THIS release's year — what the header shows AND what the SX "recording newer
// than the release" check uses. Prefer the release's own date; only fall back to
// the release-group's first-release-date when this release has no date. (Using
// the RG-earliest here was wrong for reissues: a 2025 reissue of 2002 material
// would reject legitimate later recordings.)
const rg = data['release-group'] || {};
const rgYear = parseInt((String(rg['first-release-date'] || '').match(/^(\d{4})/) || [])[1]) || null;
const releaseYear = parseInt((String(data.date || '').match(/^(\d{4})/) || [])[1]) || rgYear;
const artist = acName(data['artist-credit']);
RELEASE = Object.assign({ title: data.title || '', tracks, rgId: rg.id || '', releaseYear, artist }, prov);
// #302: when enabled, fill missing provider links from sibling releases in the RG.
if (rgProvidersEnabled() && RELEASE.rgId) await augmentProvidersFromRG(RELEASE.rgId);
const _pf = { bandcamp: 'bandcampUrl', apple: 'appleUrl', soundcloud: 'scUrl', audiomack: 'akUrl', sevendigital: 'sdUrl' };
const linkStr = ['deezer', 'spotify', 'beatport', 'tidal', 'volumo', 'hdtracks', 'bandcamp', 'apple', 'soundcloud', 'audiomack', 'sevendigital']
.map(k => { const f = _pf[k] || k + 'Id'; return RELEASE[f] ? k[0].toUpperCase() + k.slice(1) + ' ' + RELEASE[f] : null; }).filter(Boolean).join(', ');
Log.info('Release "' + RELEASE.title + '"' + (releaseYear ? ' (' + releaseYear + ')' : '') + ': ' + tracks.length + ' track(s), ' +
tracks.filter(t => !t.existing.length).length + ' missing ISRC' + (linkStr ? '; links: ' + linkStr : ''));
return RELEASE;
});
}
function acName(ac) {
if (!Array.isArray(ac)) return '';
return ac.map(c => (c.name || (c.artist && c.artist.name) || '') + (c.joinphrase || '')).join('');
}
/* #587 (majkinetor): "Revert it. If there is no way to get pending status while
getting ISRC, we wont do any more requests to MB and will not have this
feature. … Remembering is not good idea as this is colaborative space."
Both halves are gone, and neither comes back without his say-so:
· the ⏳ used to be persisted in localStorage and replayed on later loads.
It was a LOCAL belief about SERVER state, and this is a shared database —
he cancels the edit, or another editor votes it down, and the marker is
silently wrong for good. That is exactly what he hit.
· verifying it against /recording//open_edits fixed the staleness by
spending one request per remembered recording. Not my call to make: ANY
new request to MusicBrainz is his decision.
WS2 exposes no pending-edit field and no inc= that adds one — an entity's
open edits live only on MB's HTML pages — so the release fetch already being
made cannot carry it, and by his instruction the feature goes rather than the
request count.
What survives is only what we know without asking anyone: within the session
that submitted them, those removals are marked. A reload forgets, because by
then only MusicBrainz knows. */
/* ═══════════════════════════════════════════════════════════════════════
OAUTH (one-time authorize, offline refresh token)
═══════════════════════════════════════════════════════════════════════ */
const Auth = {
// baked-in shared app, with an optional GM-storage override for power users
clientId() { return store.get('oauth_client_id', '') || OAUTH.clientId; },
clientSecret() { return store.get('oauth_client_secret', '') || OAUTH.clientSecret; },
refreshTok() { return store.get('oauth_refresh_token', ''); },
isAuthorized() { return !!this.refreshTok(); },
authorizeUrl() {
const p = new URLSearchParams({
response_type: 'code',
client_id: this.clientId(),
redirect_uri: OAUTH.redirect,
scope: OAUTH.scope,
access_type: 'offline',
});
return OAUTH.authUrl + '?' + p.toString();
},
async exchangeCode(code) {
const body = new URLSearchParams({
grant_type: 'authorization_code',
code: code.trim(),
client_id: this.clientId(),
client_secret: this.clientSecret(),
redirect_uri: OAUTH.redirect,
}).toString();
const r = await gmPost(OAUTH.tokenUrl, body, { 'Content-Type': 'application/x-www-form-urlencoded' });
const j = JSON.parse(r.responseText || '{}');
if (!j.refresh_token) throw new Error(j.error_description || j.error || ('token exchange failed (' + r.status + ')'));
store.set('oauth_refresh_token', j.refresh_token);
store.set('oauth_access_token', j.access_token || '');
store.set('oauth_access_expiry', Date.now() + ((j.expires_in || 3600) * 1000));
},
async accessToken() {
const tok = store.get('oauth_access_token', '');
const exp = store.get('oauth_access_expiry', 0);
if (tok && Date.now() < exp - 60000) return tok;
const refresh = this.refreshTok();
if (!refresh) throw new Error('not authorized — open ⚙ Setup');
const body = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refresh,
client_id: this.clientId(),
client_secret: this.clientSecret(),
}).toString();
const r = await gmPost(OAUTH.tokenUrl, body, { 'Content-Type': 'application/x-www-form-urlencoded' });
const j = JSON.parse(r.responseText || '{}');
if (!j.access_token) throw new Error(j.error_description || j.error || ('token refresh failed (' + r.status + ')'));
store.set('oauth_access_token', j.access_token);
store.set('oauth_access_expiry', Date.now() + ((j.expires_in || 3600) * 1000));
return j.access_token;
},
signOut() {
store.del('oauth_refresh_token');
['oauth_access_token', 'oauth_access_expiry'].forEach(store.del);
},
};
/* ═══════════════════════════════════════════════════════════════════════
WS2 ISRC SUBMISSION
═══════════════════════════════════════════════════════════════════════ */
function buildIsrcXml(map, editNote) {
let x = '\n' +
'\n';
if (editNote) x += ' ' + esc(editNote) + '\n';
x += '\n';
for (const [rid, isrcs] of Object.entries(map)) {
x += ' ';
isrcs.forEach(i => { x += ''; });
x += '\n';
}
x += '\n';
return x;
}
async function submitIsrcs(map, editNote) {
const token = await Auth.accessToken();
const xml = buildIsrcXml(map, editNote);
const url = MB_WS2 + 'recording/?client=' + CLIENT;
const r = await gmPost(url, xml, {
'Content-Type': 'application/xml; charset=utf-8',
'Authorization': 'Bearer ' + token,
'Accept': 'application/xml',
});
if (r.status === 200) return;
throw new Error('submit failed (' + r.status + '): ' +
(r.responseText || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 220));
}
/* ═══════════════════════════════════════════════════════════════════════
SOUNDEXCHANGE (ported from magicisrc_soundexchange, DOM-independent core)
═══════════════════════════════════════════════════════════════════════ */
const SX = (function () {
let _token = 'ff5284e764c4a90c1a2c2940f6a9aa593c63b8e8';
let _tokenFetch = null;
function extractToken(text) {
const pats = [/[Tt]oken ([a-f0-9]{40})/, /["'](Token [a-f0-9]{40})["']/, /([a-f0-9]{40})/];
for (const p of pats) {
const m = text.match(p);
if (m) { const h = (m[1] || m[0]).match(/[a-f0-9]{40}/); if (h) return h[0]; }
}
return null;
}
function refreshToken() {
if (_tokenFetch) return _tokenFetch;
_tokenFetch = gmGet(SX_HOME, { 'Accept': 'text/html', 'User-Agent': 'Mozilla/5.0' })
.then(r1 => {
_tokenFetch = null;
if (r1.status !== 200) throw new Error('SX home ' + r1.status);
const inline = extractToken(r1.responseText);
if (inline) { _token = inline; return _token; }
const urls = [];
const re = /