// ==UserScript==
// @name ISRC Scout
// @namespace https://musicbrainz.org/
// @version 2026.6.7
// @description Scout ISRCs for a MusicBrainz release: reads existing ISRCs, finds missing ones on SoundExchange / Deezer / Spotify, bulk paste & import/export, submits directly to MB (one-time OAuth, never depends on MagicISRC).
// @author majkinetor
// @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4Ij48cmVjdCB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCIgcng9IjI4IiBmaWxsPSIjZjNlZWZjIi8+PHBhdGggZD0iTTY0IDY0IEw2NCAyNCBBNDAgNDAgMCAwIDEgOTkgODQgWiIgZmlsbD0iI2UzZDhmNyIvPjxnIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzZmNDJjMSIgc3Ryb2tlLXdpZHRoPSI2Ij48Y2lyY2xlIGN4PSI2NCIgY3k9IjY0IiByPSI0MCIvPjxjaXJjbGUgY3g9IjY0IiBjeT0iNjQiIHI9IjI2IiBzdHJva2Utd2lkdGg9IjQiIHN0cm9rZT0iI2I5YTNlOCIvPjxjaXJjbGUgY3g9IjY0IiBjeT0iNjQiIHI9IjEzIiBzdHJva2Utd2lkdGg9IjQiIHN0cm9rZT0iI2I5YTNlOCIvPjwvZz48bGluZSB4MT0iNjQiIHkxPSI2NCIgeDI9IjY0IiB5Mj0iMjQiIHN0cm9rZT0iIzZmNDJjMSIgc3Ryb2tlLXdpZHRoPSI2IiBzdHJva2UtbGluZWNhcD0icm91bmQiLz48Y2lyY2xlIGN4PSI4NiIgY3k9IjUwIiByPSI3IiBmaWxsPSIjNGIyZTgzIi8+PC9zdmc+
// @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*
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @connect musicbrainz.org
// @connect beta.musicbrainz.org
// @connect isrc-api.soundexchange.com
// @connect isrc.soundexchange.com
// @connect api.deezer.com
// @connect isrchunt.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';
/* ═══════════════════════════════════════════════════════════════════════
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('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
}
/* ═══════════════════════════════════════════════════════════════════════
CONSTANTS
═══════════════════════════════════════════════════════════════════════ */
const MB_ROOT = location.origin; // musicbrainz.org or beta
const MB_WS2 = MB_ROOT + '/ws/2/';
const SCRIPT_VERSION = '2026.6.7.3';
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 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',
};
const mbid = location.pathname.match(/\/release\/([a-f0-9-]{36})/)?.[1];
if (!mbid) return;
/* ═══════════════════════════════════════════════════════════════════════
GM STORAGE HELPERS
═══════════════════════════════════════════════════════════════════════ */
const store = {
get: (k, d) => { try { return GM_getValue(k, d); } catch (e) { return d; } },
set: (k, v) => { try { GM_setValue(k, v); } catch (e) {} },
del: (k) => { try { GM_deleteValue(k); } catch (e) {} },
};
/* ═══════════════════════════════════════════════════════════════════════
GENERIC HTTP (GM_xmlhttpRequest promisified)
═══════════════════════════════════════════════════════════════════════ */
const _inflight = new Set(); // live GM requests, so batched SoundExchange work can be aborted (#127)
function http(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);
}
});
}
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) {
return String(s || '').toLowerCase().normalize('NFD')
.replace(/[̀-ͯ]/g, '').replace(/[^a-z0-9 ]/g, ' ')
.replace(/\s+/g, ' ').trim();
}
function normCI(s) { return norm(s); }
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;
}
// 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).
function titleClose(sx, mb) {
const aw = norm(sx).split(' ').filter(Boolean);
const bw = norm(mb).split(' ').filter(Boolean);
if (!aw.length || !bw.length) return null;
const shorter = aw.length <= bw.length ? aw : bw;
const longer = aw.length <= bw.length ? bw : aw;
if (!shorter.every(w => longer.includes(w))) 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) => longer[i] === w));
}
function artistClose(sx, mb) {
if (!sx || !mb) return null;
return wordsMatch(mb, sx) || wordsMatch(sx, 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
});
}
function toast(msg, kind) {
let t = document.getElementById('ii-toast');
if (!t) {
t = document.createElement('div');
t.id = 'ii-toast';
(document.body || document.documentElement).appendChild(t);
}
// auto-hide is driven by a CSS animation (forwards) — restart it on every call
// by removing the class + forcing a reflow, so a toast can never get stuck.
t.classList.remove('ii-toast-show');
void t.offsetWidth;
t.textContent = msg == null ? '' : String(msg);
t.className = 'ii-toast-show ' + (kind || '');
}
/* ═══════════════════════════════════════════════════════════════════════
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; } }
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();
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),
};
})();
const shortUrl = (u) => String(u || '').replace(/^https?:\/\//, '').replace(/[?#].*$/, '').slice(0, 90);
Log.info('ISRC Scout v' + SCRIPT_VERSION + ' — ' + MB_ROOT);
/* ═══════════════════════════════════════════════════════════════════════
STYLES
═══════════════════════════════════════════════════════════════════════ */
const style = document.createElement('style');
style.textContent = `
/* 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: #fff !important;
background: #6f42c1; border: none; border-radius: 4px; cursor: pointer;
vertical-align: middle; white-space: nowrap; transition: background .15s; }
#ii-btn:hover { background: #5a32a3; }
#ii-btn.has-missing { background: #d63384; animation: ii-pulse 1.6s ease-in-out infinite; }
#ii-btn.has-missing:hover { background: #a0225e; }
#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(0,0,0,.42); z-index: 999998; display: none; }
#ii-overlay.open { display: block; }
#ii-modal {
position: fixed; top: 4vh; left: 50%; transform: translateX(-50%);
width: 1080px; max-width: 96vw;
/* 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. !important so MusicBrainz's page CSS can't un-cap it. */
height: 92vh !important; max-height: 92vh !important; background: #fff;
border-radius: 10px; box-shadow: 0 12px 48px rgba(0,0,0,.3); z-index: 999999;
display: none; flex-direction: column; font-family: system-ui, sans-serif;
color: #212529; overflow: hidden !important; }
#ii-modal.open { display: flex; }
#ii-hdr { display: flex; align-items: center; gap: 10px; padding: 11px 16px;
background: #f8f9fa; border-bottom: 1px solid #dee2e6; flex-shrink: 0; }
#ii-hdr h2 { font-size: 15px; font-weight: 700; margin: 0; flex: 1; }
#ii-hdr h2 em { color: #6f42c1; font-style: normal; }
#ii-hdr h2 .ii-logo { vertical-align: -5px; }
#ii-hdr .ii-sub { font-size: 13.5px; color: #6c757d; font-weight: 700; margin-left: 7px; }
#ii-help { display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0;
width: 21px; height: 21px; border-radius: 50%; border: 1px solid #ccc; color: #999;
font-size: 12px; font-weight: 700; text-decoration: none; box-sizing: border-box; }
#ii-help:hover { color: #6f42c1; border-color: #b9a3e8; text-decoration: none; }
#ii-close { background: none; border: none; font-size: 20px; color: #6c757d; cursor: pointer; line-height: 1; }
#ii-close:hover { color: #212529; }
/* toolbar */
#ii-tools { display: flex; align-items: center; flex-wrap: wrap; gap: 6px;
padding: 8px 16px; border-bottom: 1px solid #eee; flex-shrink: 0; background: #fbfbfd; }
.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 #dee2e6; background: #fff; color: #343a40; white-space: nowrap; }
a.ii-tbtn:hover { text-decoration: none; }
.ii-tbtn:hover { background: #f1f3f5; }
.ii-tbtn:disabled { opacity: .5; cursor: default; }
.ii-tbtn.sx { color: #6f42c1; border-color: #d6c7ee; }
.ii-tbtn.dz { color: #ef5466; border-color: #f5c2c8; }
.ii-tbtn.sp { color: #1db954; border-color: #b6e5c6; }
.ii-tbtn.primary { background: #198754; color: #fff; border-color: #198754; }
.ii-tbtn.primary:hover { background: #157347; }
.ii-tbtn.ghost { border-color: transparent; }
.ii-split { display: inline-flex; }
.ii-split .ii-tbtn { border-radius: 0; }
.ii-split .ii-tbtn:first-child { border-top-left-radius: 5px; border-bottom-left-radius: 5px; }
.ii-split .ii-caret { border-top-right-radius: 5px; border-bottom-right-radius: 5px; border-left: none; padding: 4px 7px; font-size: 9px; }
.ii-srcmenu { display: none; position: fixed; z-index: 1000001; background: #fff; border: 1px solid #ced4da;
border-radius: 8px; box-shadow: 0 8px 28px rgba(0,0,0,.2); padding: 11px; width: 520px; max-width: 92vw; box-sizing: border-box; }
.ii-srcmenu.open { display: block; }
.ii-srcmenu-t { font-size: 11.5px; color: #495057; margin-bottom: 7px; }
.ii-srcmenu-t b { color: #212529; }
/* align-items:stretch makes the input match the button's height no matter what
height MusicBrainz forces on the button — no need to fight its CSS. */
.ii-srcmenu-pc { display: block; width: 100%; box-sizing: border-box; margin-bottom: 9px; padding: 7px 10px;
text-align: left; font-size: 12px; color: #0f5132; background: #e8f5ee; border: 1px solid #a3cfbb;
border-radius: 6px; cursor: pointer; }
.ii-srcmenu-pc:hover { background: #d5eddf; border-color: #75b798; }
.ii-srcmenu-pc b { color: #0a3622; }
.ii-srcmenu-pc-url { display: block; margin-top: 2px; font-size: 10.5px; color: #6c757d;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ii-srcmenu-row { display: flex; gap: 6px; align-items: stretch; }
.ii-srcmenu-row input {
flex: 1; min-width: 0; box-sizing: border-box !important; height: auto !important; min-height: 0 !important;
padding: 6px 10px !important; border: 1px solid #ced4da !important; border-radius: 6px !important;
font-size: 12px !important; margin: 0 !important; }
.ii-srcmenu-row input:focus { outline: none; border-color: #6f42c1 !important; }
.ii-srcmenu-row .ii-tbtn { margin: 0 !important; }
.ii-tspacer { flex: 1; }
.ii-prog { font-size: 11px; color: #6c757d; min-width: 0; }
.ii-prog.err { color: #dc3545; font-weight: 700; }
.ii-prog.continue { color: #6f42c1; font-weight: 700; cursor: pointer; text-decoration: underline dotted; }
.ii-prog.continue:hover { color: #5a32a3; }
/* 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;
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: #f1f3f5;
text-align: left; font-size: 11px; font-weight: 700; text-transform: uppercase;
letter-spacing: .3px; color: #6c757d; padding: 7px 10px; border-bottom: 1px solid #dee2e6; }
.ii-medrow td { background: #eef0f3; font-weight: 700; font-size: 11.5px; color: #495057;
padding: 5px 10px; border-top: 1px solid #dee2e6; }
#ii-table td { padding: 6px 10px; border-bottom: 1px solid #f1f3f5; vertical-align: top; }
.ii-pos { color: #adb5bd; 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: #6f42c1; text-decoration: underline; }
.ii-track-artist { color: #6c757d; font-size: 11.5px; }
.ii-track-dur { color: #adb5bd; font-size: 11px; font-family: 'Courier New', monospace; }
.ii-existing { width: 150px; }
.ii-existing samp { display: block; font-size: 11px; font-weight: 700; color: #198754; font-family: 'Courier New', monospace; }
.ii-existing samp.dup { color: #d63384; background: #ffe3ef; border-radius: 3px; padding: 0 3px; }
.ii-existing .none { color: #ced4da; font-style: italic; font-size: 11px; }
/* #159: highlight rows that still have no ISRC (no existing + nothing entered yet) */
.ii-row-missing > td { background: #fff7e8; }
.ii-row-missing > td:first-child { box-shadow: inset 3px 0 0 #f0ad4e; }
.ii-row-missing .ii-existing .none { color: #d39e00; 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: #d63384; }
/* 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: #8a5a00;
background: #fde6c8; border: 1px solid #f1c690; border-radius: 3px; padding: 0 4px; }
.ii-ex-pending samp { color: #8a5a00; text-decoration: line-through; }
.ii-inwrap { display: flex; align-items: center; gap: 5px; }
/* 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 #ced4da; 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: #6f42c1; }
.ii-input.bad { border-color: #dc3545; background: #fff0f1; }
.ii-input.dup { border-color: #fd7e14; background: #fff6ed; }
.ii-input.dupother { border-color: #d63384; background: #ffe3ef; }
.ii-input.ok { border-color: #198754; }
.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: #adb5bd; font-size: 13px;
line-height: 1; cursor: pointer; display: flex; align-items: center; justify-content: center; }
.ii-clear:hover { background: #fdeaec; color: #dc3545; }
.ii-plus { flex-shrink: 0; font-size: 11px; font-weight: 700; padding: 3px 7px; border: 1px solid #dee2e6;
border-radius: 4px; background: #f8f9fa; cursor: pointer; color: #6c757d; font-family: monospace; }
.ii-plus:hover { background: #e9ecef; color: #212529; }
.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 #cfd8e3;
border-radius: 4px; background: #eef3fb; cursor: pointer; color: #2c5d9b; font-family: monospace; }
.ii-sx:hover { background: #dde8f7; color: #1b3f6e; }
.ii-sx:disabled { opacity: .4; cursor: default; }
.ii-sx:disabled:hover { background: #eef3fb; color: #2c5d9b; }
.ii-cands { margin-top: 4px; 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 #dee2e6;
border-radius: 4px; cursor: pointer; font-size: 11px; background: #fff; }
.ii-cand:hover { background: #f0f6ff; border-color: #9ec5fe; }
.ii-cands.collapsed .ii-cand:not(.chosen) { display: none; }
.ii-cand.chosen { box-shadow: inset 3px 0 0 #198754; }
.ii-cand.best { border-color: #6ea8fe; background: #d4e6ff; }
.ii-cand.warn { border-color: #ffe08a; background: #fff3cd; }
.ii-cand.bad { border-color: #f3c6cb; background: #fdf2f3; }
.ii-cand-isrc { font-family: 'Courier New', monospace; font-weight: 700; color: #084298; flex-shrink: 0; padding-top: 1px; }
.ii-cand-meta { flex: 1; min-width: 0; color: #495057; white-space: normal; word-break: break-word; line-height: 1.35; }
.ii-bad { color: #dc3545; font-weight: 600; text-decoration: underline wavy rgba(220,53,69,.55); text-underline-offset: 2px; }
.ii-mbdur { color: #198754; font-weight: 600; }
.ii-cand-src { margin-left: auto; font-size: 9px; text-transform: uppercase; color: #adb5bd; flex-shrink: 0; }
.ii-cand-note { font-size: 11px; color: #adb5bd; 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 #dee2e6; background: #f8f9fa; }
#ii-foot .ii-summary { font-size: 12px; color: #495057; flex: 1; min-width: 0; }
#ii-foot .ii-summary b { color: #212529; }
.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: #0f5132; background: #d1e7dd;
border: 1px solid #a3cfbb; border-radius: 11px; vertical-align: middle; letter-spacing: .3px; }
/* sub-panels (setup / bulk) */
.ii-pane { display: none; padding: 14px 16px; border-bottom: 1px solid #eee; background: #fcfcfe; 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; }
.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: #6c757d; background: #fff; border: 1px solid #dee2e6; border-radius: 4px; cursor: pointer; }
.ii-pane-x:hover { background: #f1f3f5; color: #212529; border-color: #adb5bd; }
.ii-pane label { display: block; font-size: 11.5px; color: #495057; margin: 6px 0 2px; }
.ii-pane input[type=text], .ii-pane textarea {
width: 100%; box-sizing: border-box; padding: 6px 8px; border: 1px solid #ced4da;
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: #6c757d; margin-top: 6px; line-height: 1.5; }
.ii-help a { color: #6f42c1; }
.ii-authstate { font-size: 11.5px; padding: 4px 0; }
.ii-authstate.ok { color: #198754; }
.ii-authstate.no { color: #dc3545; }
/* toast */
#ii-toast { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%) translateY(80px);
background: #212529; color: #fff; padding: 10px 18px; border-radius: 6px; font-size: 13px;
font-family: system-ui, sans-serif; z-index: 1000000; opacity: 0; pointer-events: none; max-width: 80vw; }
#ii-toast.ii-toast-show { animation: ii-toast-life 4.4s ease forwards; }
#ii-toast.err { background: #b02a37; }
#ii-toast.ok { background: #198754; }
@keyframes ii-toast-life {
0% { transform: translateX(-50%) translateY(80px); opacity: 0; }
6% { transform: translateX(-50%) translateY(0); opacity: 1; }
90% { transform: translateX(-50%) translateY(0); opacity: 1; }
100% { transform: translateX(-50%) translateY(80px); opacity: 0; }
}
/* 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;
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: 10px; padding: 3px 10px 3px 4px;
border: 1px solid #e0d7f2; background: #faf8fe; border-radius: 7px; }
.ii-exact-set { display: inline-flex; align-items: center; gap: 9px; font-size: 11px; color: #6c757d; }
.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: #198754; 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: #198754; }
.ii-lookup.warn { color: #b8860b; }
.ii-lookup.err { color: #dc3545; }
.ii-lookup.spin { color: #6c757d; }
.ii-lookup-rel { color: #6c757d; }
.ii-lookup.pending { color: #6c757d; cursor: pointer; text-decoration: underline dotted #adb5bd; text-underline-offset: 2px; }
.ii-lookup.pending:hover { color: #343a40; }
.ii-cand-refine { font-size: 10.5px; color: #6f42c1; cursor: pointer; padding: 2px 7px;
border: 1px dashed #d6c7ee; border-radius: 4px; background: #faf8fe; width: max-content; }
.ii-cand-refine:hover { background: #f0e9fb; text-decoration: underline; }
.ii-cand-pending { font-size: 10.5px; color: #6c757d; cursor: pointer; padding: 3px 8px;
border: 1px dashed #ced4da; border-radius: 4px; background: #f8f9fa; width: max-content; }
.ii-cand-pending:hover { background: #eceef0; color: #343a40; border-color: #adb5bd; }
/* SoundExchange refine panel */
#ii-sxpanel { position: fixed; top: 9vh; right: 4vw; width: 560px; max-width: 92vw; max-height: 78vh;
background: #fff; border: 1px solid #cdb8ee; border-radius: 10px; box-shadow: 0 14px 44px rgba(0,0,0,.32);
z-index: 1000001; 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: #f7f3fe;
border-bottom: 1px solid #e6dcf7; cursor: move; user-select: none; }
.ii-sxp-hdr .t { flex: 1; font-size: 13px; font-weight: 700; color: #4b2e83; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ii-sxp-hdr .t b { color: #6f42c1; }
#ii-sxp-close { background: none; border: none; font-size: 17px; color: #6c757d; cursor: pointer; line-height: 1; }
#ii-sxp-close:hover { color: #212529; }
.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 #ced4da; border-radius: 6px; font-size: 13px; box-sizing: border-box; }
.ii-sxp-inp:focus { outline: none; border-color: #6f42c1; }
.ii-sxp-field.off .ii-sxp-inp { color: #adb5bd; background: #f8f9fa; }
.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: #adb5bd; background: #fff; border: 1px solid #e9ecef;
border-radius: 4px; cursor: pointer; }
.ii-sxp-E:hover { color: #6f42c1; border-color: #d6c7ee; }
.ii-sxp-E.on { color: #212529; 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: 6px; background: #6f42c1; color: #fff; font-size: 13px; font-weight: 700; cursor: pointer; }
#ii-sxp-search:hover { background: #5a32a3; } #ii-sxp-search:disabled { background: #adb5bd; }
.ii-sxp-status { padding: 2px 13px; font-size: 11px; color: #6c757d; min-height: 14px; }
.ii-sxp-status.err { color: #dc3545; }
.ii-sxp-results { flex: 1; overflow-y: auto; overflow-x: hidden; 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 #e9ecef;
border-radius: 6px; cursor: pointer; overflow: hidden; flex-shrink: 0; }
.ii-sxp-row:hover { background: #f0f6ff; border-color: #9ec5fe; }
.ii-sxp-row.best { border-color: #6ea8fe; background: #d4e6ff; }
.ii-sxp-row.warn { border-color: #ffe08a; background: #fff3cd; }
.ii-sxp-row.bad { border-color: #f3c6cb; background: #fdf2f3; }
.ii-sxp-row.cur { border-color: #198754; background: #d1e7dd; }
.ii-sxp-row { align-items: flex-start; }
.ii-sxp-isrc { font-family: 'Courier New', monospace; font-weight: 700; color: #084298; 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: #212529; white-space: normal; word-break: break-word; line-height: 1.35; }
.ii-sxp-meta .b { display: block; font-size: 10.5px; color: #6c757d; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ii-sxp-inmb { font-size: 9px; font-weight: 700; color: #198754; flex-shrink: 0; }
.ii-sxp-foot { padding: 8px 13px; border-top: 1px solid #eee; background: #fbfbfd; flex-shrink: 0; }
.ii-sxp-foot a { font-size: 11.5px; font-weight: 600; color: #6f42c1; text-decoration: none; }
.ii-sxp-foot a:hover { text-decoration: underline; }
`;
// @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 }
function fetchRelease() {
return gmGet(
MB_WS2 + 'release/' + mbid + '?inc=recordings+artist-credits+isrcs+url-rels+release-groups&fmt=json',
{ 'Accept': 'application/json', 'User-Agent': UA }
).then(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(),
pending: '',
});
});
});
const rels = data.relations || [];
let deezerId = null, spotifyId = null;
rels.forEach(rel => {
const u = rel.url && rel.url.resource;
if (!u) return;
let m;
if ((m = u.match(/open\.spotify\.com\/album\/([A-Za-z0-9]+)/))) spotifyId = m[1];
if ((m = u.match(/deezer\.com\/(?:[a-z]{2}\/)?album\/(\d+)/))) deezerId = m[1];
});
// 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']);
// Restore Remove-ISRC edits we previously submitted (still pending in MB's
// queue, so WS2 still lists the ISRC). Keep only ISRCs still on the recording
// — a gone one means the edit was applied, so drop it from storage.
const pend = loadPendingRemovals();
let pendChanged = false;
tracks.forEach(t => {
const stored = pend[t.recId] || [];
const stillThere = stored.filter(i => t.existing.includes(normalizeIsrc(i)));
if (stillThere.length) t.pendingRemoval = stillThere;
if (stillThere.length !== stored.length) { pend[t.recId] = stillThere; pendChanged = true; }
});
Object.keys(pend).forEach(rid => { if (!tracks.some(t => t.recId === rid)) { delete pend[rid]; pendChanged = true; } });
if (pendChanged) savePendingRemovals(pend);
RELEASE = { title: data.title || '', tracks, deezerId, spotifyId, releaseYear, artist };
Log.info('Release "' + RELEASE.title + '"' + (releaseYear ? ' (' + releaseYear + ')' : '') + ': ' + tracks.length + ' track(s), ' +
tracks.filter(t => !t.existing.length).length + ' missing ISRC' +
'; links: ' + (deezerId ? 'Deezer ' + deezerId : 'no Deezer') + ', ' + (spotifyId ? 'Spotify ' + spotifyId : 'no Spotify'));
return RELEASE;
});
}
function acName(ac) {
if (!Array.isArray(ac)) return '';
return ac.map(c => (c.name || (c.artist && c.artist.name) || '') + (c.joinphrase || '')).join('');
}
// Persisted pending Remove-ISRC edits for this release: { recId: [isrcs] }.
function pendKey() { return 'pending_removals_' + mbid; }
function loadPendingRemovals() { try { return JSON.parse(store.get(pendKey(), '') || '{}') || {}; } catch (e) { return {}; } }
function savePendingRemovals(map) {
const has = map && Object.keys(map).some(k => (map[k] || []).length);
if (has) store.set(pendKey(), JSON.stringify(map)); else store.del(pendKey());
}
function recordPendingRemoval(recId, isrcs) {
const map = loadPendingRemovals();
map[recId] = [...new Set((map[recId] || []).concat(isrcs.map(normalizeIsrc)))];
savePendingRemovals(map);
}
/* ═══════════════════════════════════════════════════════════════════════
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() {
['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 = /