// ==UserScript==
// @name ISRC Scout
// @namespace https://musicbrainz.org/
// @version 2026.6.24
// @description Scout ISRCs for a MusicBrainz release: reads existing ISRCs, finds missing ones on SoundExchange / Deezer / Spotify / Beatport / Tidal / Volumo / HDtracks, 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
// @connect musicbrainz.org
// @connect beta.musicbrainz.org
// @connect isrc-api.soundexchange.com
// @connect isrc.soundexchange.com
// @connect api.deezer.com
// @connect isrchunt.com
// @connect openapi.tidal.com
// @connect auth.tidal.com
// @connect volumo.com
// @connect hdtracks.azurewebsites.net
// @connect api.beatport.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
}
/* ═══════════════════════════════════════════════════════════════════════
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;
const tracks = results.map((t, i) => {
const mix = t.mix_name && !/^original mix$/i.test(t.mix_name) ? ' (' + t.mix_name + ')' : '';
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 || '',
};
});
try { GM_setValue('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('beatport_close_' + bpId, 0);
if (flag && (Date.now() - flag < 120000)) { GM_deleteValue('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/';
const SCRIPT_VERSION = '2026.6.12';
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',
};
// Brand glyphs for the import-source buttons (fill:currentColor → each inherits
// its button's brand colour). Toggled against text labels via ⚙ Setup.
const SRC_ICON = {
dz: '',
sp: '',
bp: '',
td: '',
vo: '',
// HDtracks: "HD" monogram (clean stand-in — the brand has no public glyph mark)
hd: '',
};
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),
};
})();
// 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('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;
position: relative; padding-right: 74px; } /* reserve room so "Clear" stays pinned top-right and never wraps (#180) */
#ii-clear-pending { position: absolute; top: 8px; right: 16px; }
.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.bp { color: #0a8754; border-color: #9fe0c2; }
.ii-tbtn.td { color: #1f2d3d; border-color: #b5c2d0; }
.ii-tbtn.vo { color: #7c4dff; border-color: #cdbcff; }
.ii-tbtn.hd { color: #e63329; border-color: #f4b8b3; }
/* 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; }
.ii-tbtn.primary { background: #198754; color: #fff; border-color: #198754; }
.ii-tbtn.primary:hover { background: #157347; }
.ii-tbtn.ghost { border-color: transparent; }
/* 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. */
#ii-tools.ii-show-icons .ii-tbtn.ii-mb .ii-bico {
box-shadow: 0 0 0 1.5px currentColor; border-radius: 50%; padding: 2px; }
.ii-tbtn.ii-mb { background: currentColor; }
.ii-tbtn.ii-mb .ii-bico, .ii-tbtn.ii-mb .ii-blabel { filter: none; }
.ii-tbtn.ii-mb .ii-blabel, #ii-tools.ii-show-icons .ii-tbtn.ii-mb .ii-bico svg { color: #fff; }
.ii-tbtn.ii-mb .ii-blabel { color: #fff; }
.ii-tbtn.ii-mb:hover { filter: brightness(1.08); }
/* 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 #ced4da; border-radius: 50%;
background: #fff; color: #6c757d; cursor: pointer; font-size: 16px; line-height: 1; }
.ii-urladd-btn:hover { background: #f1f3f5; border-color: #adb5bd; }
.ii-urladd-btn svg { display: block; }
.ii-urladd-input { display: none; padding: 4px 9px;
border: 1px solid #ced4da; border-radius: 5px; font-size: 12px; }
.ii-urladd-input:focus { outline: none; border-color: #6f42c1; }
.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: #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;
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: #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; 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: #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: 7px; padding: 3px 8px 3px 4px;
border: 1px solid #e0d7f2; background: #faf8fe; 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: #6c757d; background: #eef3fb; border: 1px solid #cfd8e3; border-left-color: #b9c6d8;
border-top-right-radius: 5px; border-bottom-right-radius: 5px; cursor: pointer; }
.ii-sxprov:hover { background: #dde8f7; color: #1b3f6e; }
.ii-prov-menu { display: none; position: absolute; z-index: 60; flex-direction: column; min-width: 178px;
padding: 4px; background: #fff; border: 1px solid #d6c7ee; 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: #f3eefc; }
.ii-prov-item.active { background: #ece4fb; }
.ii-prov-item.active::after { content: '✓'; margin-left: auto; color: #6f42c1; 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: #6f42c1; }
.ii-prov-name { color: #343a40; }
/* 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: #8a7bb0; background: #fff; border: 1px solid #e0d7f2; border-radius: 5px; cursor: pointer; }
.ii-exact-toggle:hover { background: #f3eefc; color: #6f42c1; }
.ii-exact-toggle .ii-exact-car { font-size: 9px; transition: transform .15s; }
.ii-sx-group:not(.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: #6f42c1; border-color: #c9b6ee; background: #f3eefc; }
.ii-exact-toggle.on::after { content: ''; width: 6px; height: 6px; border-radius: 50%; background: #6f42c1; }
.ii-sx-group.collapsed .ii-exact-set { display: none; }
.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; 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 #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; }
/* ──────────────────────────────────────────────────────────────────
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) */
#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 #e9ecef; }
#ii-table tr:not(.ii-medrow) > td { display: block; border: none !important; padding: 0; }
#ii-table .ii-pos { grid-column: 1; grid-row: 1; }
#ii-table tr > td:nth-child(2) { grid-column: 2; grid-row: 1; } /* track */
#ii-table .ii-track-dur { grid-column: 3; grid-row: 1; text-align: right; }
#ii-table .ii-existing { grid-column: 2 / 4; grid-row: 2; width: auto; }
#ii-table tr > td:nth-child(5) { grid-column: 2 / 4; grid-row: 3; } /* New ISRC */
#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: #fff7e8; box-shadow: inset 3px 0 0 #f0ad4e; }
#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 }
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, beatportId = null, tidalId = null, volumoId = null, hdtracksId = 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];
if ((m = u.match(/beatport\.com\/release\/[^/]+\/(\d+)/))) beatportId = m[1];
if ((m = u.match(/(?:listen\.)?tidal\.com\/(?:browse\/)?album\/(\d+)/))) tidalId = m[1];
if ((m = u.match(/volumo\.com\/album\/(\d+)/))) volumoId = m[1]; // id or leading ICPN
// HDtracks: new API form #/album/<24-hex ObjectId> resolves directly; the
// 5009 legacy MB rels carry the UPC in valbum_code, which fetchHDtracks
// resolves via barcode search. The slug-id / artist-page legacy forms have
// no clean id mapping and are skipped (handled by Platform Check by barcode).
if (!hdtracksId && (m = u.match(/hdtracks\.com\/(?:#\/)?album\/([a-f0-9]{24})/i))) hdtracksId = m[1];
if (!hdtracksId && (m = u.match(/hdtracks\.com\/[^?]*[?&]valbum_code=(\d{8,})/i))) hdtracksId = 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, beatportId, tidalId, volumoId, hdtracksId, 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 : null, spotifyId ? 'Spotify ' + spotifyId : null,
beatportId ? 'Beatport ' + beatportId : null, tidalId ? 'Tidal ' + tidalId : null, volumoId ? 'Volumo ' + volumoId : null,
hdtracksId ? 'HDtracks ' + hdtracksId : null].filter(Boolean).join(', '));
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 = /