// ==UserScript== // @name ISRC Scout // @namespace https://musicbrainz.org/ // @version 2026.10.4 // @description Scout ISRCs for a MusicBrainz release: reads existing ISRCs, finds missing ones on SoundExchange / Deezer / Spotify / Beatport / Tidal / Volumo / HDtracks / Qobuz, bulk paste & import/export, submits directly to MB (one-time OAuth, never depends on MagicISRC). // @author majkinetor // @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4IiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+CiAgPHRpdGxlPklTUkMgU2NvdXQ8L3RpdGxlPgogICAgPHBhdGggZD0iTTY0IDY0IEw2NCAyNCBBNDAgNDAgMCAwIDEgOTkgODQgWiIgZmlsbD0iI2UzZDhmNyIvPgogIDxnIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzZmNDJjMSIgc3Ryb2tlLXdpZHRoPSI2Ij4KICAgIDxjaXJjbGUgY3g9IjY0IiBjeT0iNjQiIHI9IjQwIi8+CiAgICA8Y2lyY2xlIGN4PSI2NCIgY3k9IjY0IiByPSIyNiIgc3Ryb2tlLXdpZHRoPSI0IiBzdHJva2U9IiNiOWEzZTgiLz4KICAgIDxjaXJjbGUgY3g9IjY0IiBjeT0iNjQiIHI9IjEzIiBzdHJva2Utd2lkdGg9IjQiIHN0cm9rZT0iI2I5YTNlOCIvPgogIDwvZz4KICA8bGluZSB4MT0iNjQiIHkxPSI2NCIgeDI9IjY0IiB5Mj0iMjQiIHN0cm9rZT0iIzZmNDJjMSIgc3Ryb2tlLXdpZHRoPSI2IiBzdHJva2UtbGluZWNhcD0icm91bmQiLz4KICA8Y2lyY2xlIGN4PSI4NiIgY3k9IjUwIiByPSI3IiBmaWxsPSIjNGIyZTgzIi8+Cjwvc3ZnPgo= // @homepageURL https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/isrc_scout/README.md // @match https://*.musicbrainz.org/release/* // @match https://*.musicbrainz.org/oauth2/oob* // @match https://www.beatport.com/release/* // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @grant GM_openInTab // @grant unsafeWindow // @connect musicbrainz.org // @connect beta.musicbrainz.org // @connect isrc-api.soundexchange.com // @connect isrc.soundexchange.com // @connect api.deezer.com // @connect isrchunt.com // @connect isrc.mollamusicgroup.com // @connect openapi.tidal.com // @connect auth.tidal.com // @connect volumo.com // @connect hdtracks.azurewebsites.net // @connect www.qobuz.com // @connect qobuz.com // @connect api.beatport.com // @connect bandcamp.com // @connect music.apple.com // @connect amp-api.music.apple.com // @connect soundcloud.com // @connect api-v2.soundcloud.com // @connect api.audiomack.com // @connect api.7digital.com // @connect us.7digital.com // @connect js-cdn.7digital.com // @connect a-v2.sndcdn.com // @connect open.spotify.com // @connect music.youtube.com // @connect music.amazon.com // @connect na.mesk.skill.music.a2z.com // @run-at document-start // ==/UserScript== /* * ───────────────────────────────────────────────────────────────────────── * SETUP (one time, ever) * ───────────────────────────────────────────────────────────────────────── * Submitting ISRCs to MusicBrainz requires authentication. This script uses * OAuth with the `submit_isrc` scope and `access_type=offline`, so you * authorize EXACTLY ONCE — the refresh token is stored locally and is used * to silently mint short-lived access tokens forever after. * * The OAuth app is baked in, so there's nothing to register: * open the editor (the "ISRC" button on a release page) → ⚙ Setup → Authorize, * approve in the MusicBrainz tab, paste the code it shows back. Done forever. * * Everything except the final "Submit" runs without any credentials. * Trouble? Open the editor's "Log" pane — every action is recorded there. * ───────────────────────────────────────────────────────────────────────── */ (function () { 'use strict'; // one copy per page: with String Theory and a standalone install both on, the newer one runs (#653) if (!mbuClaim('isrc_scout', 'ISRC Scout')) return; // the shared MusicBrainz request gate (#633) — first, so nothing can call it before its consts exist // — generated by dev/net/sync-gate.mjs from dev/net/mb-gate.mjs — DO NOT EDIT const MBN_GAP = 1000; const MBN_BURST = 3; const MBN_KEY = "mbu:mb-gate"; const MBN_LOCK = "mbu-mb-gate"; const MBN_MAX_HOLD = 60000; function mbnGated(url) { const s = String(url || ''); return /^\/ws\/2\//.test(s) || /^https?:\/\/([a-z0-9-]+\.)*musicbrainz\.org\/ws\/2\//i.test(s); } async function mbnState(fn) { const rw = () => { let s = null, stored = true; try { s = JSON.parse(localStorage.getItem(MBN_KEY) || 'null'); } catch (e) { stored = false; } const g = globalThis.__mbnGate || (globalThis.__mbnGate = { tat: 0, cool: 0, hot: 0 }); if (!stored) s = g; // no storage: this page's copy only else if (!s || typeof s !== 'object') s = { tat: 0, cool: 0, hot: 0 }; // none yet (or cleared): a fresh schedule const out = fn(s); Object.assign(g, s); try { localStorage.setItem(MBN_KEY, JSON.stringify({ tat: s.tat || 0, cool: s.cool || 0, hot: s.hot || 0 })); } catch (e) {} return out; }; // The lock is held only for rw()'s synchronous moment, so a wait of seconds means it is // stuck (a frozen or sandboxed holder): give up on it and go unlocked rather than hang // every request behind it. let timer = 0; try { if (typeof navigator !== 'undefined' && navigator.locks && navigator.locks.request) { const ctrl = typeof AbortController === 'function' ? new AbortController() : null; if (ctrl) timer = setTimeout(() => ctrl.abort(), 3000); return await navigator.locks.request(MBN_LOCK, ctrl ? { signal: ctrl.signal } : {}, rw); } } catch (e) { /* no Web Locks here (an insecure or sandboxed context), or stuck: unlocked, like a lone script */ } finally { clearTimeout(timer); } return rw(); } async function mbnSlot(o) { o = o || {}; const t0 = Date.now(), background = o.background !== false; const booked = await mbnState(s => { const now = Date.now(), cool = s.cool || 0, tat = s.tat || 0; let at = background ? Math.max(now, tat - (MBN_BURST - 1) * MBN_GAP) : now; at = Math.max(at, cool); s.tat = Math.max(tat, at) + MBN_GAP; return { at, why: cool > now && cool >= at ? 'MusicBrainz asked everyone to wait' : 'pacing, one request a second' }; }); let told = false; for (;;) { if (o.cancelled && o.cancelled()) return { ok: false, waited: Date.now() - t0 }; let cool = 0; try { cool = (JSON.parse(localStorage.getItem(MBN_KEY) || 'null') || {}).cool || 0; } catch (e) { cool = (globalThis.__mbnGate || {}).cool || 0; } const until = Math.max(booked.at, cool), left = until - Date.now(); // a hold that appeared while we queued if (left <= 0) break; if (!told && until - t0 > 1000 && o.log) { told = true; o.log('info', 'MusicBrainz gate: ' + (o.label || 'request') + ' waits ' + Math.round((until - t0) / 100) / 10 + 's (' + (cool > booked.at ? 'MusicBrainz asked everyone to wait' : booked.why) + ')'); } await new Promise(r => setTimeout(r, Math.min(left, 250))); // in slices, so a cancel lands promptly } return { ok: true, waited: Date.now() - t0 }; } async function mbnAnswer(status, header, o) { o = o || {}; if (status === 429 || status === 503) { const raw = header ? header('Retry-After') : null; const secs = Number(raw), date = raw ? Date.parse(raw) : NaN; const ra = Number.isFinite(secs) ? secs * 1000 : Number.isFinite(date) ? Math.max(0, date - Date.now()) : 0; const hold = await mbnState(s => { const now = Date.now(); // One throttle, one step: the requests already in flight when it began all answer // 503 too, and each must not stretch the hold again. A throttle long gone doesn't // count at all: `hot` is shared and persists, and would otherwise slow every later // page for good. if (now > (s.cool || 0) + 30000) s.hot = 0; if (now >= (s.cool || 0)) s.hot = Math.min((s.hot || 0) + 1, 6); const ms = Math.min(Math.max(1000, ra) * Math.max(1, s.hot), MBN_MAX_HOLD); s.cool = Math.max(s.cool || 0, Date.now() + ms); return s.cool - Date.now(); }); if (o.log) o.log('warn', 'MusicBrainz gate: HTTP ' + status + (raw != null ? ' (Retry-After: ' + raw + ')' : '') + ' — every script holds ' + Math.round(hold / 100) / 10 + 's'); return { throttled: true, hold }; } if (status >= 200 && status < 500) await mbnState(s => { if (s.hot) s.hot = Date.now() > (s.cool || 0) + 30000 ? 0 : s.hot - 1; }); return { throttled: false, hold: 0 }; } async function mbnFetch(url, init, o) { o = o || {}; if (!mbnGated(url)) return fetch(url, init); const tries = o.tries || 4; for (let attempt = 1; ; attempt++) { const slot = await mbnSlot(o); if (!slot.ok) return null; const r = await fetch(url, init); const a = await mbnAnswer(r.status, n => r.headers.get(n), o); if (!a.throttled || attempt >= tries) return r; } } function mbnHot() { let s; try { s = JSON.parse(localStorage.getItem(MBN_KEY) || 'null') || {}; } catch (e) { s = globalThis.__mbnGate || {}; } return Date.now() > (s.cool || 0) + 30000 ? 0 : (s.hot || 0); // a throttle long gone is over } function mbnRawHeader(raw, name) { const want = String(name).toLowerCase(); for (const line of String(raw || '').split(/\r?\n/)) { const i = line.indexOf(':'); if (i > 0 && line.slice(0, i).trim().toLowerCase() === want) return line.slice(i + 1).trim(); } return null; } // /* ═══════════════════════════════════════════════════════════════════════ TIMERS — Firefox + Violentmonkey can throw "called on incompatible object" when a native timer is invoked with the wrong `this` (sandbox/Xray quirk). Bind them to the window so every call has the right receiver. ═══════════════════════════════════════════════════════════════════════ */ const _timerHost = (typeof window !== 'undefined' && window) || globalThis; const _setTimeout = _timerHost.setTimeout.bind(_timerHost); const _setInterval = _timerHost.setInterval.bind(_timerHost); /* ═══════════════════════════════════════════════════════════════════════ OAUTH OUT-OF-BAND CODE CATCHER After you approve, MusicBrainz lands on /oauth2/oob?code=… showing the code. Grab it, hand it to the editor tab via GM storage, and close this tab — so you never have to copy/paste the code. ═══════════════════════════════════════════════════════════════════════ */ if (/oauth2\/oob$/.test(location.pathname)) { const code = new URLSearchParams(location.search).get('code'); if (code) { try { GM_setValue('ii:oauth_oob_code', { code: code, ts: Date.now() }); } catch (e) {} const finishOob = () => { try { window.close(); } catch (e) {} // Browsers block window.close() on a tab that has navigated (authorize → oob); // the editor tab also tries to close this popup, but if it's still here, show a // clear confirmation so it's obvious it worked. _setTimeout(() => { try { window.close(); document.title = '✓ Authorized — you can close this tab'; if (document.body) document.body.innerHTML = '
' + '

✓ Authorized

ISRC Scout captured the code. You can close this tab.

'; } catch (e) {} }, 500); }; if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', finishOob, { once: true }); else finishOob(); } return; // never run the editor on the oob page } /* ═══════════════════════════════════════════════════════════════════════ BEATPORT HARVESTER (runs ON beatport.com) Beatport is Cloudflare-walled, so a cross-origin GM_xmlhttpRequest from MusicBrainz is always challenged. Instead, when the editor opens a release in a brief background tab, THIS instance (matched on beatport.com) reads the ISRCs straight out of the page's embedded __NEXT_DATA__ in real page context — where Cloudflare is already satisfied — stashes them in shared GM storage for the MB tab to pick up, then closes itself. ═══════════════════════════════════════════════════════════════════════ */ if (/(^|\.)beatport\.com$/i.test(location.hostname)) { const bpId = (location.pathname.match(/\/release\/[^/]+\/(\d+)/) || [])[1]; if (!bpId) return; const grab = () => { const el = document.getElementById('__NEXT_DATA__'); if (!el) return false; let j; try { j = JSON.parse(el.textContent); } catch (e) { return false; } const qs = (((j || {}).props || {}).pageProps || {}).dehydratedState; const queries = (qs && qs.queries) || []; let results = null; for (const q of queries) { const r = q && q.state && q.state.data && q.state.data.results; if (Array.isArray(r) && r.length && ('isrc' in r[0])) { results = r; break; } } if (!results) return false; // #457: Beatport's release PAGE embeds the tracklist id-DESCENDING (the reverse of album // order) and carries no `number`, so the index-based position below came out reversed vs the // authenticated API (which serves the same tracks id-ASCENDING = album order). Sort by id // ascending first, so the harvest agrees with the API and MB. const ordered = [...results].sort((a, b) => (Number(a.id) || 0) - (Number(b.id) || 0)); const tracks = ordered.map((t, i) => { const mix = t.mix_name && !/^original mix$/i.test(t.mix_name) && !String(t.name || '').toLowerCase().includes('(' + String(t.mix_name).toLowerCase() + ')') ? ' (' + t.mix_name + ')' : ''; // #643: not twice return { isrc: String(t.isrc || '').toUpperCase().replace(/[\s-]/g, ''), title: (t.name || '') + mix, artist: (t.artists || []).map(a => a && a.name).filter(Boolean).join(', '), disc: 1, pos: t.number || (i + 1), dur: t.length || '', url: (t.slug && t.id) ? 'https://www.beatport.com/track/' + t.slug + '/' + t.id : '', // #387 per-track link }; }); try { GM_setValue('ii:beatport_harvest_' + bpId, { ts: Date.now(), tracks: tracks }); } catch (e) {} return true; }; const t0 = Date.now(); const tick = () => { if (grab()) { // Only self-close when the editor's background harvest opened this tab // (it sets a per-release close flag first). A tab the USER opened — or // one Platform Check's ↗/link opened — must stay put; we still harvest // it (populating the cache) but leave it on screen. try { const flag = GM_getValue('ii:beatport_close_' + bpId, 0); if (flag && (Date.now() - flag < 120000)) { GM_deleteValue('ii:beatport_close_' + bpId); window.close(); } } catch (e) {} return; } if (Date.now() - t0 > 90000) return; // Cloudflare never cleared — give up silently window.setTimeout(tick, 500); }; if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', tick, { once: true }); else tick(); return; // never run the MB editor on a Beatport page } /* ═══════════════════════════════════════════════════════════════════════ CONSTANTS ═══════════════════════════════════════════════════════════════════════ */ const MB_ROOT = location.origin; // musicbrainz.org or beta const MB_WS2 = MB_ROOT + '/ws/2/'; // Derive from the installed @version so the banner/CLIENT never drift out of // sync with the metadata again (the hardcoded constant kept lagging behind). const SCRIPT_VERSION = (() => { try { return (typeof GM_info !== 'undefined' && GM_info.script && GM_info.script.version) || '2026.6.27'; } catch (e) { return '2026.6.27'; } })(); const SCRIPT_URL = 'https://github.com/majkinetor/musicbrainz-userscripts/tree/main/userscripts/isrc_scout'; const CLIENT = 'isrc_scout-' + SCRIPT_VERSION; const UA = 'MB-ISRC-Scout/1.0'; const SX_API = 'https://isrc-api.soundexchange.com/api/ext/recordings'; const SX_HOME = 'https://isrc.soundexchange.com/'; const BATCH_DELAY = 650; const TIDAL_TRACK_DELAY = 350; // pace per-track Tidal lookups under its rate limit const SX_BATCH_LIMIT = 30; // max individual SoundExchange searches per batch (avoid being blocked) const STREAM_BATCH_LIMIT = 50; // max per-track Deezer fetches per batch (1000-track releases would spam Deezer) const ISRC_RE = /^[A-Z]{2}[A-Z0-9]{3}[0-9]{7}$/; // Shared, pre-registered MusicBrainz OAuth app (type: Installed application, // redirect urn:ietf:wg:oauth:2.0:oob, scope submit_isrc). Baked in so users only // click "Authorize" once — no per-user app registration. The secret is not truly // confidential for an installed app (same model as MagicISRC / isrchunt). const OAUTH = { clientId: 'axXnet_AiWglKOQEVSiM8xF6EAlKFBzM', clientSecret: 'gi-S0GuLeKtOgFs5QRZAEEVATD4Lo6l9', authUrl: MB_ROOT + '/oauth2/authorize', tokenUrl: MB_ROOT + '/oauth2/token', redirect: 'urn:ietf:wg:oauth:2.0:oob', scope: 'submit_isrc', }; // Baked-in Tidal API app. The client-credentials grant yields an app-level // token with TIDAL Catalog access (no user login) — same "shared installed // app" trust model as the MB OAuth app above. const TIDAL = { clientId: 'cRhhDJDpYXXBn82U', clientSecret: 'K7UX40jDOZ5p4y4JMYZgoiwKi7jymTHWcLMb4gkewKs=', tokenUrl: 'https://auth.tidal.com/v1/oauth2/token', api: 'https://openapi.tidal.com/v2', country: 'US', }; // Shared platform icons (#404) — stIcon(name, size) / stColor(name). Source of truth is // dev/ui/platform-icons.mjs; the block below is generated by dev/ui/sync-icons.mjs (pre-commit hook). // — generated by dev/ui/sync-icons.mjs from dev/ui/platform-icons.mjs — DO NOT EDIT const ST_ICONS = {"musicbrainz":{"color":"#eb743b","svg":""},"discogs":{"color":"#333333","svg":""},"spotify":{"color":"#1DB954","svg":""},"apple":{"color":"#FA243C","svg":""},"deezer":{"color":"#A238FF","svg":""},"tidal":{"color":"#000000","svg":""},"qobuz":{"color":"#0070ef","svg":""},"beatport":{"color":"#01FF95","svg":""},"bandcamp":{"color":"#629AA9","svg":""},"volumo":{"color":"#7c4dff","svg":""},"hdtracks":{"color":"#e63329","svg":""},"soundcloud":{"color":"#ff5500","svg":""},"audiomack":{"color":"#FFA200","svg":""},"sevendigital":{"color":"#07606E","svg":""},"ytmusic":{"color":"#FF0000","svg":""},"amazonmusic":{"color":"#25D1DA","svg":""},"soundexchange":{"color":"#6f42c1","svg":""},"globe":{"color":"#6f7d75","svg":""}}; function stIcon(name, size) { var i = ST_ICONS[name]; if (!i) return ''; size = size || 16; return i.svg.replace(/]*)>/, function (m, a) { a = a.replace(/\s(?:width|height)="[^"]*"/g, ''); var ns = /\bxmlns=/.test(a) ? '' : ' xmlns="http://www.w3.org/2000/svg"'; return ''; }); } function stColor(name) { return (ST_ICONS[name] && ST_ICONS[name].color) || ''; } // // Import-source buttons are keyed by short code → map each to the shared icon (uniform 16px). const SRC_ICON = { dz: stIcon('deezer', 16), sp: stIcon('spotify', 16), bp: stIcon('beatport', 16), td: stIcon('tidal', 16), vo: stIcon('volumo', 16), hd: stIcon('hdtracks', 16), qz: stIcon('qobuz', 16), bc: stIcon('bandcamp', 16), am: stIcon('apple', 16), sc: stIcon('soundcloud', 16), yt: stIcon('ytmusic', 16), az: stIcon('amazonmusic', 16), ak: stIcon('audiomack', 16), sd: stIcon('sevendigital', 16), }; // MBIDs are canonically lowercase, but MB serves an upper-/mixed-case URL as-is // (no redirect), so match case-insensitively and normalise — otherwise a link like // /release/Eb13342a-… left `mbid` undefined and the whole button never injected. const mbid = location.pathname.match(/\/release\/([a-f0-9-]{36})/i)?.[1]?.toLowerCase(); if (!mbid) return; /* ═══════════════════════════════════════════════════════════════════════ GM STORAGE HELPERS ═══════════════════════════════════════════════════════════════════════ */ // #501 follow-up (majkinetor: "tidy up config prefixes, some have them, // some don't") — every GM-stored key now carries the `ii:` prefix, // transparently: call sites still pass the bare name (`store.get('col_widths', …)`), // `store` prepends it. One-time, non-destructive migration folded into // `get`: if the new prefixed key is empty but the old bare one has a value, // adopt it (and write it through under the new name); the old key is left // in place, unused. const store = { get: (k, d) => { try { const v = GM_getValue('ii:' + k, undefined); if (v !== undefined) return v; const old = GM_getValue(k, undefined); if (old !== undefined) { GM_setValue('ii:' + k, old); return old; } return d; } catch (e) { return d; } }, set: (k, v) => { try { GM_setValue('ii:' + k, v); } catch (e) {} }, del: (k) => { try { GM_deleteValue('ii:' + k); } catch (e) {} }, }; // majkinetor: "Tokens should go into GM storage, so that we don't have to // initialize plugins on every place" — auth tokens, INCLUDING the // short-lived derived ones, stay on `store` (GM) on purpose; localStorage // below is only for things that are genuinely local, like a per-release // in-progress draft. const localStore = { get: (k, d) => { try { const v = localStorage.getItem('ii:' + k); if (v !== null) return JSON.parse(v); const old = localStorage.getItem(k); if (old !== null) { const parsed = JSON.parse(old); localStorage.setItem('ii:' + k, old); return parsed; } return d; } catch (e) { return d; } }, set: (k, v) => { try { localStorage.setItem('ii:' + k, JSON.stringify(v)); } catch (e) {} }, del: (k) => { try { localStorage.removeItem('ii:' + k); } catch (e) {} }, }; if (mbuTestHooks()) window.__isrcScoutTestStore = { store, localStore }; // test hook only (#501) — no behaviour change /* ═══════════════════════════════════════════════════════════════════════ GENERIC HTTP (GM_xmlhttpRequest promisified) ═══════════════════════════════════════════════════════════════════════ */ const _inflight = new Set(); // live GM requests, so batched SoundExchange work can be aborted (#127) function httpRaw(opts) { const t0 = Date.now(); const tag = (opts.method || 'GET') + ' ' + shortUrl(opts.url); Log.net('→ ' + tag); return new Promise((resolve, reject) => { const entry = { url: opts.url, handle: null }; const done = () => _inflight.delete(entry); entry.handle = GM_xmlhttpRequest(Object.assign({ timeout: 20000, onload: r => { done(); const ms = Date.now() - t0; if (r.status >= 200 && r.status < 300) Log.net('← ' + r.status + ' ' + tag + ' (' + ms + 'ms)'); else Log.warn('← ' + r.status + ' ' + tag + ' (' + ms + 'ms) ' + String(r.responseText || '').replace(/\s+/g, ' ').slice(0, 160)); resolve(r); }, onerror: () => { done(); Log.err('✗ network ' + tag); reject(new Error('network error')); }, ontimeout: () => { done(); Log.err('✗ timeout ' + tag); reject(new Error('timeout')); }, onabort: () => { done(); reject(new Error('aborted')); }, }, opts)); _inflight.add(entry); }); } // Abort in-flight GM requests whose URL contains `urlSubstr` (cancels batched SoundExchange work). #127 function abortInflight(urlSubstr) { [..._inflight].forEach(e => { if (!urlSubstr || (e.url && e.url.indexOf(urlSubstr) !== -1)) { try { e.handle && e.handle.abort && e.handle.abort(); } catch (x) {} _inflight.delete(e); } }); } // MusicBrainz's /ws/2 (the release reads, the ISRC submission) goes through the shared request // gate (#633): its slot first, and its hold after a 429/503, which the next call waits out. const gateLog = (lv, m) => (lv === 'warn' ? Log.warn : Log.info)(m); async function http(opts) { if (!mbnGated(opts && opts.url)) return httpRaw(opts); await mbnSlot({ label: opts.method + ' ' + '/ws/2/' + String(opts.url).split('/ws/2/').pop(), log: gateLog }); const r = await httpRaw(opts); await mbnAnswer(r.status, n => mbnRawHeader(r.responseHeaders, n), { log: gateLog }); return r; } const gmGet = (url, headers) => http({ method: 'GET', url, headers: headers || {} }); const gmPost = (url, data, headers) => http({ method: 'POST', url, data, headers: headers || {} }); /* ═══════════════════════════════════════════════════════════════════════ SMALL UTILITIES ═══════════════════════════════════════════════════════════════════════ */ function esc(s) { return String(s ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function msToMmSs(ms) { if (!ms) return null; const s = Math.round(ms / 1000); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); } function durToSec(str) { const m = String(str || '').match(/^(\d+):(\d{2})$/); return m ? parseInt(m[1]) * 60 + parseInt(m[2]) : null; } function norm(s) { // #623 (sweep, X10): any script — keeping only [a-z0-9] reduced a mixed-script title to // its Latin fragments ("Love ~夜~" = "Love ~朝~"). Diacritics still fold on Latin, Greek // and Cyrillic letters; other scripts keep their marks. return String(s || '').toLowerCase().normalize('NFKD') .replace(/(?<=[\p{Script=Latin}\p{Script=Greek}\p{Script=Cyrillic}])\p{M}+/gu, '').normalize('NFC') .replace(/[^\p{L}\p{N}\p{M} ]/gu, ' ') .replace(/\s+/g, ' ').trim(); } function wordsMatch(needle, haystack) { const nw = norm(needle).split(' ').filter(Boolean), hw = norm(haystack); return nw.length > 0 && nw.every(w => hw.includes(w)); } function isGoodMatch(aTitle, aArtist, bTitle, bArtist) { // shares titleClose/artistClose (below) so the row classification and the // per-field highlighting never disagree if (titleClose(aTitle, bTitle) !== true) return false; return !bArtist || artistClose(aArtist, bArtist) === true; } // #643: guests don't make another song. A "(feat. …)" clause names them — Tidal titles "Instant // Crush (feat. Julian Casablancas)", and MusicBrainz may carry the guests in the title where Deezer // doesn't ("Get Lucky (feat. Pharrell Williams & Nile Rodgers)" vs "Get Lucky") — so it is left out // of both titles. Guest lists differ between databases too ("Daft Punk feat. Pharrell Williams" on // MusicBrainz, "Daft Punk, Pharrell Williams, Nile Rodgers" on Deezer), so when the whole credits // don't match, the main artist is looked for in the other side's credit. function unfeat(s) { return String(s || '').replace(/\s*[([](?:feat\.?|ft\.?|featuring)\s[^)\]]*[)\]]/gi, '') .replace(/\s+(?:feat\.?|ft\.?|featuring)\s.*$/i, '').trim(); } function mainArtist(s) { return String(s || '').split(/\s+(?:feat\.?|ft\.?|featuring)\s+|\s*[,;]\s*/i)[0].trim(); } // Per-field comparisons between an SoundExchange result and the MB track, // used to highlight exactly WHICH attribute disagrees. Each returns // true (matches) / false (mismatch) / null (can't compare — no data). // #661: a typo is not another song. Tidal and Apple title it "Les Ecrocs", MusicBrainz "Les // Escrocs". Two words are the same word one letter apart (a letter added, dropped, changed, or // two swapped) when both have 5 letters or more and start alike, so "Love"/"Live" and // "Heart"/"Start" stay different songs. function wordTypo(a, b) { if (a === b) return true; if (Math.min(a.length, b.length) < 5 || Math.abs(a.length - b.length) > 1 || a[0] !== b[0]) return false; let i = 0; while (a[i] === b[i]) i++; const ra = a.slice(i + 1), rb = b.slice(i + 1); return ra === rb // changed || a.slice(i) === rb || ra === b.slice(i) // added or dropped || (a[i] === b[i + 1] && a[i + 1] === b[i] && a.slice(i + 2) === b.slice(i + 2)); // swapped } // exact: words must be spelled alike; otherwise one typo per word is tolerated (wordTypo) function titleClose(sx, mb, exact) { const aw = norm(unfeat(sx)).split(' ').filter(Boolean); const bw = norm(unfeat(mb)).split(' ').filter(Boolean); if (!aw.length || !bw.length) return null; const same = exact ? (x, y) => x === y : wordTypo; const shorter = aw.length <= bw.length ? aw : bw; const longer = aw.length <= bw.length ? bw : aw; if (!shorter.every(w => longer.some(l => same(w, l)))) return false; const extra = longer.length - shorter.length; // extra words are only tolerated as a SUFFIX (a version/remaster tag) — a // leading extra word ("Sacred Motherhood" vs "Motherhood") is a different song return extra === 0 || (extra <= 2 && shorter.every((w, i) => same(longer[i], w))); } function artistClose(sx, mb) { if (!sx || !mb) return null; if (wordsMatch(mb, sx) || wordsMatch(sx, mb)) return true; const a = mainArtist(sx), b = mainArtist(mb); // guests left out (#643) return !!(a && b) && (wordsMatch(b, sx) || wordsMatch(a, mb)); } function durClose(sxDur, mbDur) { const a = durToSec(mbDur), b = durToSec(sxDur); return (a === null || b === null) ? null : Math.abs(a - b) <= 10; } function yearOk(sxYear, mbYear) { if (!mbYear || !sxYear) return null; return parseInt(sxYear) <= mbYear + 1; } // Build the "Title · Artist · Year · Dur" meta for an SX result `f` vs MB track // `t`, wrapping each mismatching field in .ii-bad (with a tooltip explaining // it) so problems are obvious in the chip, the candidate list, and the popup. function sxMetaHtml(f, t) { const span = (txt, ok, tip) => '' + esc(txt) + ''; const parts = []; if (f.title) parts.push(span(f.title, titleClose(f.title, t.title), 'Title differs from "' + t.title + '"')); if (f.artist) parts.push(span(f.artist, artistClose(f.artist, t.artist), 'Artist differs from "' + t.artist + '"')); if (f.year) parts.push(span(f.year, yearOk(f.year, RELEASE && RELEASE.releaseYear), 'Recording year ' + f.year + ' is after this release (' + (RELEASE && RELEASE.releaseYear) + ')')); if (f.dur) { const dOk = durClose(f.dur, t.dur); parts.push(span(f.dur, dOk, 'Length differs from MB (' + (t.dur || '?') + ')') + (dOk === false && t.dur ? ' ↔ ' + esc(t.dur) + '' : '')); } return parts.join(' · '); } function normalizeIsrc(raw) { return String(raw || '').toUpperCase().replace(/[\s\-]/g, ''); } function isValidIsrc(s) { return ISRC_RE.test(normalizeIsrc(s)); } function sleep(ms) { return new Promise(resolve => { try { _setTimeout(resolve, ms); } catch (e) { resolve(); } // never stall a flow if the env's timer misbehaves }); } // #563: the shared toast. Its own `kind` argument already matched the standard // severities, so it passes straight through. function toast(msg, kind) { return mbuToast(msg == null ? '' : msg, kind ? { kind: kind === 'err' ? 'error' : kind } : undefined); } /* ═══════════════════════════════════════════════════════════════════════ LOG — console + in-modal pane, for troubleshooting ═══════════════════════════════════════════════════════════════════════ */ const Log = (function () { const buf = [], MAX = 800; let paneEl = null; const stamp = () => { const d = new Date(); return d.toTimeString().slice(0, 8) + '.' + String(d.getMilliseconds()).padStart(3, '0'); }; const fmt = (d) => { if (d === undefined) return ''; try { return ' ' + (typeof d === 'string' ? d : JSON.stringify(d)); } catch (e) { return ' ' + String(d); } }; function render() { if (paneEl) { paneEl.textContent = buf.join('\n'); paneEl.scrollTop = paneEl.scrollHeight; } } // the open pane is redrawn once per frame, not once per line: a busy lookup logs // dozens of lines at a time (X12 of #623) let queued = false; function add(level, msg, data) { const line = '[' + stamp() + '] ' + String(level).toUpperCase().padEnd(5) + ' ' + msg + fmt(data); buf.push(line); if (buf.length > MAX) buf.shift(); if (paneEl && !queued) { queued = true; requestAnimationFrame(() => { queued = false; render(); }); } } return { setPane: el => { paneEl = el; render(); }, text: () => buf.join('\n'), clear: () => { buf.length = 0; render(); }, info: (m, d) => add('info', m, d), warn: (m, d) => add('warn', m, d), err: (m, d) => add('error', m, d), net: (m, d) => add('net', m, d), }; })(); // Keep the FULL url — scheme + query — in the NET log so an API call's real // arguments (album_id, app_id, …) are visible for debugging; only redact // token/secret/signature values, and cap length. (#201: the query was being // dropped, so a 404 gave no clue which id/app_id was actually sent.) const shortUrl = (u) => String(u || '').replace(/([?&](?:[a-z_]*(?:token|secret|sig|password))=)[^&#]*/gi, '$1…').slice(0, 200); Log.info(mbuStartupInfo('ISRC Scout') + ' — ' + MB_ROOT); /* ═══════════════════════════════════════════════════════════════════════ STYLES ═══════════════════════════════════════════════════════════════════════ */ const style = document.createElement('style'); // The shared design tokens (#562). Values live in dev/tokens/design-tokens.mjs and are // inlined here by dev/tokens/sync-tokens.mjs — edit them THERE, never in this block. // — generated by dev/tokens/sync-tokens.mjs from dev/tokens/design-tokens.mjs — DO NOT EDIT const MBU_TOKENS = ':root{--mbu-bg:var(--background, #fff);--mbu-bg-raised:#faf9fe;--mbu-bg-raised:color-mix(in srgb, var(--mbu-bg) 96%, var(--mbu-accent));--mbu-bg-sunken:#f4f2f9;--mbu-bg-sunken:color-mix(in srgb, var(--mbu-bg) 94%, var(--mbu-text));--mbu-bg-hover:#f3eefe;--mbu-bg-hover:color-mix(in srgb, var(--mbu-bg) 91%, var(--mbu-accent));--mbu-text:var(--text, #222);--mbu-text-dim:#555;--mbu-text-dim:color-mix(in srgb, var(--mbu-text) 78%, var(--mbu-bg));--mbu-text-weak:#999;--mbu-text-weak:color-mix(in srgb, var(--mbu-text) 52%, var(--mbu-bg));--mbu-text-on-accent:#fff;--mbu-border:var(--border, #cfc6e6);--mbu-border-soft:#e2dcef;--mbu-border-strong:#9a8ccb;--mbu-border-strong:color-mix(in srgb, var(--mbu-border) 70%, var(--mbu-text));--mbu-divider:#eee;--mbu-divider:color-mix(in srgb, var(--mbu-bg) 92%, var(--mbu-text));--mbu-accent:#5f3ec0;--mbu-accent-hover:#4e329f;--mbu-accent-deep:#3b2c70;--mbu-accent-soft:#ece4ff;--mbu-accent-soft:color-mix(in srgb, var(--mbu-bg) 86%, var(--mbu-accent));--mbu-accent-fg:#fff;--mbu-accent-text:#5f3ec0;--mbu-accent-deep-text:#3b2c70;--mbu-ok:#1f9d6b;--mbu-ok:color-mix(in srgb, #1f9d6b 78%, var(--mbu-text));--mbu-ok-bg:#eef7f1;--mbu-ok-bg:color-mix(in srgb, var(--mbu-bg) 88%, var(--mbu-ok));--mbu-ok-border:#9bd3b6;--mbu-warn:#a05a00;--mbu-warn:color-mix(in srgb, #b4791f 78%, var(--mbu-text));--mbu-warn-bg:#fff7e6;--mbu-warn-bg:color-mix(in srgb, var(--mbu-bg) 88%, var(--mbu-warn));--mbu-warn-border:#f0c877;--mbu-error:#c0392b;--mbu-error:color-mix(in srgb, #d0473a 78%, var(--mbu-text));--mbu-error-bg:#fdecec;--mbu-error-bg:color-mix(in srgb, var(--mbu-bg) 90%, var(--mbu-error));--mbu-error-border:#e2a1a1;--mbu-info:#2f7fbf;--mbu-info:color-mix(in srgb, #3f8fd0 78%, var(--mbu-text));--mbu-info-bg:#eef4fb;--mbu-info-bg:color-mix(in srgb, var(--mbu-bg) 90%, var(--mbu-info));--mbu-info-border:#a9c8e6;--mbu-font:-apple-system,Segoe UI,Roboto,Arial,sans-serif;--mbu-font-mono:ui-monospace,SFMono-Regular,Consolas,Menlo,monospace;--mbu-fs:14px;--mbu-fs-sm:12px;--mbu-fs-xs:11px;--mbu-radius:6px;--mbu-radius-lg:10px;--mbu-shadow:0 1px 5px rgba(60,40,110,.07);--mbu-shadow-lg:0 8px 30px rgba(40,20,80,.3);--mbu-z-panel:30;--mbu-z-pop:99998;--mbu-z-modal:2147483000;--mbu-z-modal-panel:2147483001}:root[data-mbu-theme="dark"]{--mbu-bg:#1e1b24;--mbu-text:#e9e5f2;--mbu-border:#3b3548;--mbu-accent-text:#b9a7f0;--mbu-accent-deep-text:#a493e0}:root[data-mbu-theme="dark"][data-mbu-seed="theme"]{--mbu-bg:var(--background, #1e1b24);--mbu-text:var(--text, #e9e5f2);--mbu-border:var(--border, #3b3548)}'; // // The shared UI components (#563). Definitions live in dev/ui/ui-components.mjs // and are inlined here by dev/ui/sync-ui.mjs — edit them THERE, never here. // — generated by dev/ui/sync-ui.mjs from dev/ui/ui-components.mjs — DO NOT EDIT const MBU_UI_CSS = '.mbu-help{font-size:12px;color:var(--mbu-accent-text);text-decoration:none;border:1px solid var(--mbu-border);border-radius:var(--mbu-radius);padding:1px 8px;white-space:nowrap;line-height:1.6;background:none}.mbu-help:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent);text-decoration:none}h4>.mbu-help,.mbu-cfg-h>.mbu-help{margin-left:8px;flex:0 0 auto;font-weight:normal}#mbu-toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:var(--mbu-z-pop);background:var(--mbu-accent-deep);color:var(--mbu-text-on-accent);padding:10px 16px;border-radius:9px;font:13px/1.35 var(--mbu-font);box-shadow:var(--mbu-shadow-lg);opacity:0;transition:opacity .2s;pointer-events:none;max-width:80vw;text-align:center;white-space:pre-wrap}#mbu-toast.mbu-toast-on{opacity:1}#mbu-toast.mbu-toast-act{pointer-events:auto}#mbu-toast .mbu-toast-btn{margin-left:10px;padding:2px 9px;border:1px solid currentColor;border-radius:5px;background:transparent;color:inherit;font:inherit;cursor:pointer}#mbu-toast .mbu-toast-btn:hover{background:rgba(255,255,255,.18)}#mbu-toast.mbu-toast-ok{background:var(--mbu-ok)}#mbu-toast.mbu-toast-warn{background:var(--mbu-warn)}#mbu-toast.mbu-toast-error{background:var(--mbu-error)}.mbu-cfg-h{display:flex;align-items:center;gap:8px;margin:0 0 10px;padding:0 0 9px;border-bottom:1px solid var(--mbu-border-soft);font:600 15px/1.3 var(--mbu-font);color:var(--mbu-text)}.mbu-cfg-ic{flex:0 0 auto;display:inline-flex;align-items:center;width:22px;height:22px}.mbu-cfg-ic img,.mbu-cfg-ic svg{width:22px;height:22px;object-fit:contain;display:block}.mbu-cfg-name{flex:0 0 auto;font-weight:700;color:var(--mbu-accent-text)}.mbu-cfg-ver{flex:0 0 auto;font:400 11px var(--mbu-font);color:var(--mbu-text-weak);white-space:nowrap}.mbu-cfg-sp{flex:1 1 auto;min-width:8px}.mbu-cfg-log{flex:0 0 auto;font:400 12px var(--mbu-font);color:var(--mbu-accent-text);cursor:pointer;background:none;border:1px solid transparent;border-radius:var(--mbu-radius);padding:1px 8px;line-height:1.6}.mbu-cfg-log:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-border)}#mbu-logpop{position:fixed;top:74px;left:50%;transform:translateX(-50%);z-index:var(--mbu-z-modal);display:flex;flex-direction:column;width:min(720px,94vw);max-height:72vh;background:var(--mbu-bg);border:1px solid var(--mbu-border);border-radius:11px;box-shadow:var(--mbu-shadow-lg);font:13px var(--mbu-font);color:var(--mbu-text);overflow:hidden}.mbu-logpop-h{display:flex;align-items:center;gap:8px;padding:10px 13px;border-bottom:1px solid var(--mbu-border-soft);color:var(--mbu-accent-text);cursor:move;user-select:none}.mbu-logpop-sp{margin-left:auto}.mbu-logpop-clear,.mbu-logpop-copy,.mbu-logpop-x,.mbu-logpop-min{font-size:12px;color:var(--mbu-accent-text);background:var(--mbu-bg-hover);border:1px solid var(--mbu-border);border-radius:5px;padding:2px 9px;cursor:pointer;font-family:inherit}.mbu-logpop-clear:hover,.mbu-logpop-copy:hover,.mbu-logpop-x:hover,.mbu-logpop-min:hover{background:var(--mbu-accent-soft)}#mbu-logpop.min .mbu-log-list,#mbu-logpop.min .mbu-logpop-clear,#mbu-logpop.min .mbu-logpop-copy,#mbu-logpop.min .mbu-logpop-x{display:none}#mbu-logpop.min{max-height:none;width:auto}#mbu-logpop.min .mbu-logpop-sp{display:none}.mbu-log-badge{color:var(--mbu-border-strong);font-size:11px}.mbu-log-list{flex:1 1 auto;overflow:auto;overscroll-behavior:contain;padding:9px 13px;display:flex;flex-direction:column;gap:3px}.mbu-log-li{display:flex;gap:9px;white-space:pre-wrap;word-break:break-word}.mbu-log-t{color:var(--mbu-text-weak);flex:0 0 auto;font-variant-numeric:tabular-nums}.mbu-log-m{flex:1 1 auto;color:var(--mbu-text-dim)}#mbu-logpop .mbu-log-m a{color:var(--mbu-accent-text)}.mbu-log-ok .mbu-log-m{color:var(--mbu-ok)}.mbu-log-warn .mbu-log-m{color:var(--mbu-warn)}.mbu-log-error .mbu-log-m{color:var(--mbu-error)}.mbu-log-debug{opacity:.85}.mbu-log-debug .mbu-log-m{color:var(--mbu-text-weak)}.mbu-log-empty{color:var(--mbu-text-weak)}.mbu-ov{position:fixed;inset:0;z-index:var(--mbu-z-modal);background:rgba(15,12,28,.45);display:flex;align-items:center;justify-content:center;padding:24px}.mbu-ov-panel{background:var(--mbu-bg);color:var(--mbu-text);border-radius:var(--mbu-radius-lg);box-shadow:var(--mbu-shadow-lg);max-width:94vw;max-height:88vh;display:flex;flex-direction:column;overflow:hidden}.mbu-ov-h{display:flex;align-items:center;gap:10px;padding:12px 16px;border-bottom:1px solid var(--mbu-border-soft);font-weight:700}.mbu-ov-h .mbu-ov-title{flex:1 1 auto;min-width:0}.mbu-ov-x{flex:0 0 auto;width:26px;height:26px;display:inline-flex;align-items:center;justify-content:center;font-size:15px;line-height:1;cursor:pointer;color:var(--mbu-text-dim);background:none;border:none;border-radius:var(--mbu-radius)}.mbu-ov-x:hover{background:var(--mbu-bg-hover);color:var(--mbu-text)}.mbu-ov-body{flex:1 1 auto;overflow:auto;padding:14px 16px}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) ::placeholder{color:var(--mbu-text-weak);opacity:1;font-style:italic}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu){color:var(--mbu-text)}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) :is(table,td,th,div,span,label)[style*=background]{color:var(--mbu-text)}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) input:not(:where([type=checkbox],[type=radio],[type=range],[type=color],[type=file])),:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) textarea,:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) select{background:var(--mbu-bg-sunken);color:var(--mbu-text);border-color:var(--mbu-border)}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) input:focus-visible,:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) textarea:focus-visible,:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) select:focus-visible{outline:2px solid var(--mbu-accent);outline-offset:1px}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) :where(input[type=checkbox],input[type=radio],input[type=range]){accent-color:var(--mbu-accent)}:root[data-mbu-theme=dark] :where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu){color-scheme:dark;--invert-value:none;--invert:none}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) button{background-color:var(--mbu-bg-raised);color:var(--mbu-text);border-color:var(--mbu-border)}.mbu-compact .mbu-bt{display:none}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input){-webkit-appearance:none;-moz-appearance:none;appearance:none;width:18px;height:18px;margin:0;border:none;border-radius:3px;cursor:pointer;background:transparent url("data:image/svg+xml,%3Csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20viewBox=%270%200%2016%2016%27%20fill=%27%23888%27%3E%3Crect%20x=%271%27%20y=%273.5%27%20width=%2710%27%20height=%279%27%20rx=%271.5%27/%3E%3Cpath%20d=%27M11.5%207L15%204.8v6.4L11.5%209z%27/%3E%3C/svg%3E") center/13px no-repeat;opacity:.45;box-shadow:none;flex:0 0 auto;vertical-align:middle}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):hover{opacity:1}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):checked{opacity:1;background-color:var(--mbu-accent);background-image:url("data:image/svg+xml,%3Csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20viewBox=%270%200%2016%2016%27%20fill=%27%23fff%27%3E%3Crect%20x=%271%27%20y=%273.5%27%20width=%2710%27%20height=%279%27%20rx=%271.5%27/%3E%3Cpath%20d=%27M11.5%207L15%204.8v6.4L11.5%209z%27/%3E%3C/svg%3E")}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):focus-visible{outline:1px solid var(--mbu-accent);outline-offset:1px}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):disabled{cursor:default;opacity:.3}'; // Help link markup. Every script's help link is this, pointing at its own README. // `name` is the userscript folder, e.g. mbuHelpHref('art_station'). function mbuHelpHref(name) { return 'https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/' + name + '/README.md'; } function mbuHelpHtml(name, label) { return '' + (label || '? Help') + ''; } function mbuHelpEl(name, label) { var a = document.createElement('a'); a.className = 'mbu-help'; a.href = mbuHelpHref(name); a.target = '_blank'; a.rel = 'noopener'; a.title = 'open the README in a new tab'; a.textContent = label || '? Help'; return a; } // The config (settings) icon every script's settings button shows: the gear in text // presentation (U+FE0E), so it takes the button's colour instead of an emoji's. #650 var MBU_CFG_ICON = '\u2699\uFE0E'; // HTML for an innerHTML on a page that enforces Trusted Types (YouTube Music, #650): there a // plain string is refused ("This document requires 'TrustedHTML' assignment"). The policy // passes the string through; the markup is the script's own. Elsewhere it is the string. var _mbuTT; function mbuHtml(s) { if (_mbuTT === undefined) { _mbuTT = null; try { var tt = (typeof window !== 'undefined' && window.trustedTypes) || null; if (tt && tt.createPolicy) _mbuTT = tt.createPolicy('mbu-' + Math.random().toString(36).slice(2, 8), { createHTML: function (x) { return x; } }); } catch (e) { /* the page allows no new policy: plain strings, as before */ } } return _mbuTT ? _mbuTT.createHTML(String(s)) : String(s); } // The first line of every script's log: the script, its version, and what runs it, so a // pasted log says which manager and browser it came from (#282 started it in Art Station): // Log.info(mbuStartupInfo('Fusion')); // -> Fusion v2026.10.1 · Violentmonkey 2.31.0 · firefox 143.0 (win) // Inside String Theory GM_info describes the bundle, so the line names it: // -> Fusion (String Theory v2026.10.1) · Tampermonkey 5.3.3 · chrome 140.0 (win) function mbuStartupInfo(name) { var g = null; try { g = (typeof GM_info !== 'undefined' && GM_info) || null; } catch (e) { /* no GM_info */ } var s = (g && g.script) || {}, p = (g && g.platform) || {}; var host = String(s.name || '').replace(/\*$/, ''), ver = s.version || '?'; var line = !name ? (host || 'Script') + ' v' + ver : (host && host !== name) ? name + ' (' + host + ' v' + ver + ')' : name + ' v' + ver; if (g) line += ' · ' + (g.scriptHandler || 'unknown manager') + (g.version ? ' ' + g.version : ''); if (p.browserName) line += ' · ' + p.browserName + (p.browserVersion ? ' ' + p.browserVersion : '') + (p.os ? ' (' + p.os + ')' : ''); else { try { line += ' · ' + navigator.userAgent; } catch (e) { /* no navigator */ } } return line; } // One copy per page (#653). With String Theory and a standalone install of the same script // both on, two copies build the same element ids and fight over them: each settings window // fills in the other's checkboxes, rows flip between two rule sets. So one copy runs, the // one with the higher version, and the other stays off without a word; only the running // copy notes it in its log (majkinetor: "disable copy that has lower version without any // info (except in log of active copy)"): // if (!mbuClaim('platform_check', 'Platform Check')) return; // first line of the script // The claim is a data- attribute on , which every copy sees whatever its sandbox. // It is decided at once, so no script starts late. The copy that starts first takes the // page. When it is the older one, the newer copy stays off for this page and leaves a note // in localStorage, and from the next page load the older copy finds the note and steps // aside. A note whose copy has gone (uninstalled) is cleared by the older copy after the // page loads, so it runs again from the load after. function mbuClaimVer(v) { return String(v || '').split('.').map(function (n) { return parseInt(n, 10) || 0; }); } function mbuClaimCmp(a, b) { var x = mbuClaimVer(a), y = mbuClaimVer(b); for (var i = 0; i < Math.max(x.length, y.length); i++) { var d = (x[i] || 0) - (y[i] || 0); if (d) return d < 0 ? -1 : 1; } return 0; } function mbuClaim(key, label) { var info = (typeof GM_info !== 'undefined' && GM_info && GM_info.script) || {}; var name = String(info.name || label || key), ver = String(info.version || '0'); var mine = (name.slice(-1) === '*' ? 'String Theory' : 'standalone') + ' v' + ver; var root = document.documentElement, attr = 'data-mbu-run-' + key, ev = 'mbu-claim-' + key, noteKey = 'mbu-newer-' + key; var log = function (msg) { try { if (typeof mbuLog !== 'undefined' && mbuLog.active) mbuLog.active.info(msg); else if (typeof mbuToast !== 'undefined' && typeof mbuToast.log === 'function') mbuToast.log('info', msg); else console.info('[' + (label || key) + '] ' + msg); } catch (e) { /* no log */ } }; var note = null; try { note = JSON.parse(localStorage.getItem(noteKey) || 'null'); } catch (e) { /* storage blocked */ } // A note older than this copy is spent. This copy's own note stays: it is what keeps the // older copy aside on every later load, not only the next one (#671). var noteCmp = note ? mbuClaimCmp(note.ver, ver) : 1; if (noteCmp < 0) { try { localStorage.removeItem(noteKey); } catch (e) { /* storage blocked */ } } if (noteCmp <= 0) note = null; var held = root && root.getAttribute(attr); var off = function (why) { // tell the running copy (any sandbox hears a DOM event), or the copy that runs after // this one (it reads the attribute), and stay quiet try { if (root) root.setAttribute(attr + '-off', JSON.stringify({ mine: mine, why: why })); } catch (e) { /* no attribute */ } try { document.dispatchEvent(new CustomEvent(ev, { detail: JSON.stringify({ mine: mine, ver: ver, why: why }) })); } catch (e) { /* no event */ } return false; }; if (held) { var heldVer = (root.getAttribute(attr + '-ver') || '0'); if (mbuClaimCmp(ver, heldVer) > 0) { try { localStorage.setItem(noteKey, JSON.stringify({ ver: ver, mine: mine, at: Date.now() })); } catch (e) { /* storage blocked */ } return off('newer, from the next page load'); } return off('older or the same'); } if (note) { // a newer copy said it is installed: leave the page to it, unless it never shows up var watch = function () { setTimeout(function () { if (root.getAttribute(attr)) return; try { localStorage.removeItem(noteKey); } catch (e) { /* storage blocked */ } try { console.info('[' + (label || key) + '] the newer copy (' + note.mine + ') did not start: this copy runs again from the next page load'); } catch (e) { /* no console */ } }, 3000); }; if (document.readyState === 'complete') watch(); else window.addEventListener('load', watch, { once: true }); return off('older: a newer copy runs'); } if (root) { root.setAttribute(attr, mine); root.setAttribute(attr + '-ver', ver); } var told = function (o) { log((label || key) + ' is installed twice: ' + mine + ' runs, ' + (o.mine || 'another copy') + ' is switched off' + (o.why === 'newer, from the next page load' ? ' for this page (it is newer and runs from the next page load)' : '') + '.'); }; document.addEventListener(ev, function (e) { var o = {}; try { o = JSON.parse(e.detail); } catch (x) { /* not ours */ } told(o); }); // a copy that stepped aside before this one started (it found a note): log it once the script's log is up var before = root && root.getAttribute(attr + '-off'); if (before) setTimeout(function () { var o = {}; try { o = JSON.parse(before); } catch (x) { /* not ours */ } told(o); }, 0); return true; } // Toast. mbuToast(msg) or mbuToast(msg, { ms, kind, at:{x,y}, action:{ label, onClick } }). // // An action adds one button to the toast (e.g. "Copy log"): the toast is then clickable, // stays up longer (12 s unless ms says otherwise), and closes when the button is used. // // Severity is inferred from a leading warning/tick glyph when not given — Art // Station already did that and it is why its toasts reached its log with the // right level. Set mbuToast.log = function (level, message) {...} once at // startup and every toast mirrors into that script's own log; leave it unset // and the toast still shows. var _mbuToastT = null; function mbuToast(msg, opts) { opts = opts || {}; var s = String(msg); var kind = opts.kind || (/^\s*[⚠✗×]/.test(s) ? 'warn' : /[✓✅]/.test(s) ? 'ok' : 'info'); try { if (typeof mbuToast.log === 'function') mbuToast.log(kind, s.replace(/^\s*[⚠✗×✓✅]\s*/, '')); } catch (e) { /* a broken log sink must never swallow the toast */ } var el = document.getElementById('mbu-toast'); if (!el) { el = document.createElement('div'); el.id = 'mbu-toast'; (document.body || document.documentElement).appendChild(el); } el.className = 'mbu-toast-on' + (kind !== 'info' ? ' mbu-toast-' + kind : '') + (opts.action ? ' mbu-toast-act' : ''); el.textContent = s; if (opts.action) { var b = document.createElement('button'); b.type = 'button'; b.className = 'mbu-toast-btn'; b.textContent = opts.action.label || 'OK'; b.onclick = function () { try { if (opts.action.onClick) opts.action.onClick(b); } catch (e) { /* the toast still closes */ } clearTimeout(_mbuToastT); _mbuToastT = setTimeout(function () { el.className = ''; }, 900); }; el.appendChild(b); } // Anchor above a click point when asked, clamped into the viewport; otherwise // fall back to the centred default by clearing the inline placement. if (opts.at) { var w = el.offsetWidth, h = el.offsetHeight; el.style.left = Math.max(6, Math.min(window.innerWidth - w - 6, opts.at.x - w / 2)) + 'px'; el.style.top = Math.max(6, Math.min(window.innerHeight - h - 6, opts.at.y - h - 10)) + 'px'; el.style.bottom = 'auto'; el.style.transform = 'none'; } else { el.style.left = ''; el.style.top = ''; el.style.bottom = ''; el.style.transform = ''; } clearTimeout(_mbuToastT); _mbuToastT = setTimeout(function () { el.className = ''; }, opts.ms || (opts.action ? 12000 : 2600)); return el; } // Config-window title bar. // // mbuCfgHeader({ script:'art_station', name:'Art Station', version:'2026.9.2', // icon:'' | '', log:true, logClass:'as-setup-logbtn' }) // // Returns the markup for the whole bar. 'log' adds the Log button; a script with // no log window leaves it out rather than shipping a dead control. logClass / // logId are carried through IN ADDITION to the shared class so a script's // existing click handler keeps working — adopting the component must not mean // rewiring every listener at the same time. function mbuCfgHeader(o) { o = o || {}; var esc = function (s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); }; var html = '
'; if (o.icon) html += '' + o.icon + ''; html += '' + esc(o.name) + ''; if (o.version) html += 'v' + esc(o.version) + ''; html += ''; if (o.log) { html += ''; } html += mbuHelpHtml(o.script); return html + '
'; } // Test hooks. A script puts its test hook on window only when the test harness has // marked the page (dev/test/harness.mjs sets window.__mbuTest before any script runs): // if (mbuTestHooks()) window.__fooTest = { … }; // On a user's page the hooks are never built. (#623) function mbuTestHooks() { try { return typeof window !== 'undefined' && window.__mbuTest === true; } catch (e) { return false; } } // Corner slots (#468). Every floating launcher (Apollo Editor, Art Station, Falcon, // Fusion, Scribe) tags its element with data-mb-corner (which screen corner: 'br', // 'bl', 'tr', 'tl') and data-mb-corner-order (lower sits closer to the corner), and // calls mbRestackCorner(corner) right after it shows, hides, creates or removes it. // That recomputes every element in the corner, whichever script owns it and // whatever order they loaded in, so two launchers never land on the same pixel. // Orders in use: Apollo and Art Station 10 (never on the same page), Falcon 20, // Fusion above Falcon. Scribe is not on the shared block and keeps a copy of this. function mbRestackCorner(corner) { var bottom = corner[0] === 'b', right = corner[1] === 'r'; var els = Array.prototype.slice.call(document.querySelectorAll('[data-mb-corner="' + corner + '"]')) // offsetParent is always null for position:fixed, so it can't tell visibility here .filter(function (el) { return getComputedStyle(el).display !== 'none'; }) .sort(function (a, b) { return (Number(a.dataset.mbCornerOrder) || 0) - (Number(b.dataset.mbCornerOrder) || 0); }); var pos = 14; els.forEach(function (el) { el.style[bottom ? 'bottom' : 'top'] = pos + 'px'; el.style[right ? 'right' : 'left'] = '14px'; pos += el.getBoundingClientRect().height + 8; }); } // Activity log: the session's log lines plus the floating window that shows them // (#283's viewer, shared since X12 of #623). A script makes its log once: // // var LOG = mbuLog({ name: 'Fusion', version: VERSION, key: 'fusion.logwin' }); // LOG.info('…'); LOG.warn(…); LOG.err(…) (or .error); LOG.ok(…); LOG.debug(…) // LOG.open(); LOG.close(); LOG.reopen() // reopen: only if it was left open // LOG.markdown(); LOG.copy(btn); LOG.clear(); LOG.lines(); LOG.messages(); LOG.counts() // // o.name / o.version the Markdown summary's title (version may be a function) // o.subtitle optional function; its text follows the title (e.g. the release) // o.header the window's title (default 'Activity log') // o.key storage key for the window's open/minimised/position state // o.load / o.save that storage (default GM_getValue / GM_setValue) // o.before called before the window opens (e.g. to inject the script's CSS) // o.max lines kept (default 20000: about 4 MB; 2000 dropped a long session's start) // // A long run keeps only the last o.max lines, and the Markdown says how many went // before them; the copies this replaced grew for the whole session. An open // window appends each new line and drops the oldest row past the cap; the copies // rebuilt the whole list with innerHTML on every line, which is quadratic over a // long matching run. function mbuLog(o) { o = o || {}; var max = o.max || 20000, buf = [], dropped = 0, warn = 0, error = 0, win = null; var pad = function (n, w) { return String(n).padStart(w || 2, '0'); }; var ts = function (d) { return pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()) + '.' + pad(d.getMilliseconds(), 3); }; var str = function (v) { if (typeof v === 'string') return v; if (v instanceof Error) return v.message || String(v); if (v && v.nodeType) return '<' + (v.tagName || 'node').toLowerCase() + '>'; try { return typeof v === 'object' ? JSON.stringify(v) : String(v); } catch (e) { return String(v); } }; var esc = function (s) { return String(s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); }; // escape, then make http(s) URLs clickable, keeping trailing punctuation out of them var linkify = function (s) { return esc(s).replace(/(https?:\/\/[^\s<]+)/g, function (m) { var t = (m.match(/[.,;:!?)\]]+$/) || [''])[0]; var url = m.slice(0, m.length - t.length); return '' + url + '' + t; }); }; var load = o.load || function (k) { try { return GM_getValue(k, undefined); } catch (e) { return undefined; } }; var save = o.save || function (k, v) { try { GM_setValue(k, v); } catch (e) { /* no storage: the window just forgets */ } }; var state = function () { try { return JSON.parse(load(o.key) || '{}') || {}; } catch (e) { return {}; } }; var remember = function (patch) { try { save(o.key, JSON.stringify(Object.assign(state(), patch))); } catch (e) { /* see save */ } }; var tally = function (e, d) { if (e.sev === 'warn') warn += d; else if (e.sev === 'error') error += d; }; var PRE = { info: '', ok: 'OK ', warn: 'WARN ', error: 'ERR ', debug: 'DBG ' }; var line = function (e) { return ts(e.t) + ' ' + (PRE[e.sev] || '') + e.msg; }; function add(sev, args) { var msg = Array.prototype.map.call(args, str).join(' ').replace(/\s+/g, ' ').trim(); if (!msg) return; var e = { t: new Date(), sev: sev === 'err' ? 'error' : sev, msg: msg }; buf.push(e); tally(e, 1); // trim in chunks, not one shift per line if (buf.length > max + Math.ceil(max / 10)) { var gone = buf.splice(0, buf.length - max); gone.forEach(function (g) { tally(g, -1); }); dropped += gone.length; } if (win) win.append(e); } function title() { var v = typeof o.version === 'function' ? (function () { try { return o.version(); } catch (e) { return ''; } })() : o.version; var t = (o.name || 'Log') + (v ? ' v' + v : ''); try { var s = o.subtitle && o.subtitle(); if (s) t += ' — ' + s; } catch (e) { /* no subtitle */ } return t; } function markdown() { var body = buf.length ? buf.map(line).join('\n') : '(no activity logged)'; if (dropped) body = '(' + dropped + ' earlier line' + (dropped === 1 ? '' : 's') + ' not kept)\n' + body; var n = (warn || error) ? ' (' + warn + ' warning' + (warn === 1 ? '' : 's') + ', ' + error + ' error' + (error === 1 ? '' : 's') + ')' : ''; var fence = String.fromCharCode(96, 96, 96); return '
' + title() + ' — session log' + n + '\n\n' + fence + 'log\n' + body + '\n' + fence + '\n\n
'; } function copy(btn) { var md = markdown(); var done = function (ok) { if (!btn) return; var was = btn.dataset.lbl || btn.textContent; btn.dataset.lbl = was; btn.textContent = ok ? 'Copied ✓' : 'Copy failed'; setTimeout(function () { btn.textContent = was; }, 1500); }; var fallback = function () { var ok = false; try { var ta = document.createElement('textarea'); ta.value = md; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); ok = document.execCommand('copy'); ta.remove(); } catch (x) { /* nothing left to try */ } done(ok); }; try { navigator.clipboard.writeText(md).then(function () { done(true); }, fallback); } catch (e) { fallback(); } } function open() { close(true); if (typeof o.before === 'function') { try { o.before(); } catch (e) { /* the window still opens */ } } remember({ open: true }); var st = state(); var pop = document.createElement('div'); pop.id = 'mbu-logpop'; pop.className = 'mbu-logpop'; pop.innerHTML = mbuHtml('
' + esc(o.header || 'Activity log') + ' ' + '' + '' + '' + '
' + '
'); document.body.appendChild(pop); if (st.left != null) { pop.style.left = st.left; pop.style.top = st.top; pop.style.right = 'auto'; pop.style.transform = 'none'; } var restore = { left: pop.style.left, top: pop.style.top, right: pop.style.right, bottom: pop.style.bottom, transform: pop.style.transform }; var list = pop.querySelector('.mbu-log-list'), badge = pop.querySelector('.mbu-log-badge'); var row = function (e) { var d = document.createElement('div'); d.className = 'mbu-log-li mbu-log-' + e.sev; d.innerHTML = mbuHtml('' + ts(e.t) + '' + linkify(e.msg) + ''); return d; }; var showBadge = function () { badge.textContent = '(' + buf.length + ')' + (warn || error ? ' · ' + warn + '⚠ ' + error + '✖' : ''); }; // the rows, once; later lines are appended one by one var frag = document.createDocumentFragment(); buf.forEach(function (e) { frag.appendChild(row(e)); }); if (buf.length) list.appendChild(frag); else list.innerHTML = mbuHtml('
No activity yet.
'); showBadge(); list.scrollTop = list.scrollHeight; // badge and scroll once per frame, however many lines arrive in it var queued = false, follow = true; list.addEventListener('scroll', function () { follow = list.scrollHeight - list.scrollTop - list.clientHeight < 40; }); var paint = function () { queued = false; showBadge(); if (follow) list.scrollTop = list.scrollHeight; }; var onKey = function (e) { if (e.key === 'Escape') close(); }; win = { el: pop, append: function (e) { var empty = list.querySelector('.mbu-log-empty'); if (empty) empty.remove(); list.appendChild(row(e)); while (list.childElementCount > buf.length) list.firstElementChild.remove(); if (!queued) { queued = true; requestAnimationFrame(paint); } }, off: function () { document.removeEventListener('keydown', onKey); }, cleared: function () { list.innerHTML = mbuHtml('
No activity yet.
'); showBadge(); }, }; pop.querySelector('.mbu-logpop-clear').onclick = function () { clear(); }; pop.querySelector('.mbu-logpop-copy').onclick = function () { copy(pop.querySelector('.mbu-logpop-copy')); }; var minBtn = pop.querySelector('.mbu-logpop-min'); var setMin = function (m) { minBtn.textContent = m ? '▢' : '–'; minBtn.title = m ? 'Restore' : 'Minimize'; if (m) { pop.style.left = '14px'; pop.style.bottom = '14px'; pop.style.top = 'auto'; pop.style.right = 'auto'; pop.style.transform = 'none'; } // dock to the bottom else Object.assign(pop.style, restore); }; minBtn.onclick = function () { var m = pop.classList.toggle('min'); setMin(m); remember({ min: m }); }; if (st.min) { pop.classList.add('min'); setMin(true); } pop.querySelector('.mbu-logpop-x').onclick = function () { close(); }; // floating and non-modal: dragged by its header pop.querySelector('.mbu-logpop-h').addEventListener('mousedown', function (e) { if (e.target.closest('button')) return; e.preventDefault(); var r = pop.getBoundingClientRect(); pop.style.left = r.left + 'px'; pop.style.top = r.top + 'px'; pop.style.right = 'auto'; pop.style.transform = 'none'; var ox = e.clientX - r.left, oy = e.clientY - r.top; var mv = function (ev) { pop.style.left = Math.max(0, Math.min(window.innerWidth - pop.offsetWidth, ev.clientX - ox)) + 'px'; pop.style.top = Math.max(0, Math.min(window.innerHeight - 36, ev.clientY - oy)) + 'px'; }; var up = function () { document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up); if (!pop.classList.contains('min')) { restore = { left: pop.style.left, top: pop.style.top, right: 'auto', bottom: '', transform: 'none' }; remember({ left: pop.style.left, top: pop.style.top }); } }; document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up); }); document.addEventListener('keydown', onKey); return pop; } // empty the log: the lines, the counts and the "earlier lines not kept" note function clear() { buf = []; dropped = 0; warn = 0; error = 0; if (win) win.cleared(); } // quiet: closing to reopen, so the remembered "open" stays as it is function close(quiet) { var stray = document.getElementById('mbu-logpop'); if (win) { win.off(); win.el.remove(); win = null; if (!quiet) remember({ open: false }); } if (stray) stray.remove(); // another script's window: one log window at a time } var api = { info: function () { add('info', arguments); }, warn: function () { add('warn', arguments); }, err: function () { add('error', arguments); }, error: function () { add('error', arguments); }, ok: function () { add('ok', arguments); }, debug: function () { add('debug', arguments); }, add: function (sev) { add(sev, Array.prototype.slice.call(arguments, 1)); }, open: open, close: function () { close(); }, reopen: function () { if (state().open) open(); }, isOpen: function () { return !!win; }, markdown: markdown, copy: copy, clear: clear, lines: function () { return buf.map(line); }, messages: function () { return buf.map(function (e) { return e.msg; }); }, counts: function () { return { warn: warn, error: error }; }, }; mbuLog.active = api; // the script's own log, for helpers that note things in it (mbuClaim) return api; } // Dismiss-on-outside-click, with the trailing click SWALLOWED. // // var off = mbuDismissOn(popoverEl, close); // off() to detach early // // #305: a popover torn down on mousedown removes what was under the cursor, so // the click that follows lands on whatever the page reflowed into that spot and // activates it. Tearing down on click instead just moves the problem. So: close // on outside mousedown, then eat exactly one click in the capture phase. This is // the single most repeated interaction bug in these scripts and it belongs in // one place — it is why #563 says interaction is part of the contract. // // Esc closes too, innermost first: the handler is registered in capture and stops // propagation, so a popover inside a modal does not close the modal as well. function mbuDismissOn(el, close, opts) { opts = opts || {}; var closed = false; var onDown = function (e) { if (closed || !el || el.contains(e.target)) return; if (opts.ignore && e.target.closest && e.target.closest(opts.ignore)) return; finish(); // swallow the click this mousedown will produce, once var eat = function (ev) { ev.stopPropagation(); ev.preventDefault(); document.removeEventListener('click', eat, true); }; document.addEventListener('click', eat, true); setTimeout(function () { document.removeEventListener('click', eat, true); }, 400); }; var onKey = function (e) { if (closed || e.key !== 'Escape') return; e.stopPropagation(); finish(); }; function finish() { if (closed) return; closed = true; document.removeEventListener('mousedown', onDown, true); document.removeEventListener('keydown', onKey, true); try { close(); } catch (err) { /* a throwing closer must not leave listeners behind */ } } document.addEventListener('mousedown', onDown, true); document.addEventListener('keydown', onKey, true); return finish; } // Collapse a toolbar to icon-only when its buttons would wrap. // // mbuFitToolbar(barEl) // call on build, and on resize // // Measured by SUMMING child widths rather than reading scrollWidth or comparing // offsetTop: a bar with flex:1 spacers never overflows its own scroll box, so // both of those report "fits" right up until it visibly wraps. Art Station // learned that the hard way (#234) and it is the only reason this is a helper // rather than one CSS rule. // // opts.gap inter-item gap in px (default 11) // opts.pad horizontal padding to leave (default 24) // opts.spacer selector for flexible spacers, which must not count (default .mbu-sp) function mbuFitToolbar(bar, opts) { if (!bar) return false; opts = opts || {}; var gap = opts.gap == null ? 11 : opts.gap; var pad = opts.pad == null ? 24 : opts.pad; var spacer = opts.spacer || '.mbu-sp'; bar.classList.remove('mbu-compact'); // measure at full labels var kids = [].slice.call(bar.children); var need = gap * Math.max(0, kids.length - 1); for (var i = 0; i < kids.length; i++) { if (kids[i].matches && kids[i].matches(spacer)) continue; need += kids[i].offsetWidth; } var compact = need > bar.clientWidth - pad; bar.classList.toggle('mbu-compact', compact); return compact; } // Publish the components on a shared namespace. Three reasons, in order: // // 1. it is the cross-userscript contract #563 is about — another script (or a // future one) gets the standard widgets without copying them, the same way // Mammoth already exposes its field-memory through a documented convention; // 2. it makes the components testable from outside, which is the only way to // assert the *behaviour* half of the contract rather than just the markup; // 3. it costs nothing when several scripts do it — the definitions are // byte-identical, so first writer wins and the rest are no-ops. // // Guarded per key, never clobbering: a script that loaded first keeps its copy, // and a page that defines an unrelated window.MBU is left alone. // Theme recognition. #564: "we don't have to conform to Stylus vars, we could // probably use them as a recognition signal to enable our own dark theme." // // That is the right way round. Reading --background/--text and hoping every // derived colour lands somewhere readable is guesswork that fails one token at a // time; knowing WHICH theme we are in lets the token set say so outright, and // lets us hand the browser the one thing CSS variables cannot express — // color-scheme, which is what actually paints a checkbox dark instead of leaving // a white (Firefox: black) box on a dark panel. // // The signal is the rendered page, not a particular userstyle's variable names: // whatever painted the body, we measure its luminance. So this works for Stylus, // for a browser extension, for MusicBrainz shipping its own dark mode one day, // and for a user who just set --background by hand. // // · an explicit --mbu-theme (light|dark) always wins — the escape hatch; // · otherwise the page background decides; // · re-checked when stylesheets arrive, because Stylus often lands after us. function mbuThemeOf(bg) { var m = /rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?/.exec(bg || ''); if (!m) return null; if (m[4] !== undefined && +m[4] < 0.5) return null; // transparent tells us nothing var f = function (v) { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; var L = 0.2126 * f(+m[1]) + 0.7152 * f(+m[2]) + 0.0722 * f(+m[3]); return L < 0.35 ? 'dark' : 'light'; } // #569 (chaban-mb) — write only when the value actually changes. // // The DOM does not do this for you. classList.add of a token already present, // classList.toggle to the state it is already in, setAttribute with the value it // already has: each one re-sets the attribute and dispatches a mutation record. // Harmless once; these run from 2Hz heartbeats and from observers that react to // each other, and the measured idle cost on the release editor was 66 records a // second, of which 93% came from writes that changed nothing (see // dev/ui/measure-569-idle-mutations.mjs). // // Semantically these are exact no-ops: they skip a write ONLY when the value is // already the one being written, so nothing that reads the DOM afterwards can // tell the difference. That is the whole reason they are safe to sprinkle around // a 2Hz loop. function mbuCls(el, token, on) { if (!el || !el.classList) return; if (el.classList.contains(token) !== !!on) el.classList.toggle(token, !!on); } function mbuAttr(el, name, value) { if (!el) return; if (value === null || value === undefined || value === false) { if (el.hasAttribute(name)) el.removeAttribute(name); } else if (el.getAttribute(name) !== String(value)) { el.setAttribute(name, String(value)); } } // For IDL properties (disabled, title, textContent, style.display …). Reading // them is cheap; writing them is not, and textContent in particular replaces // every child node. // // ⚠ textContent is the one to think twice about: its getter concatenates the // text of ALL descendants, so on an element with child ELEMENTS the comparison // can match while the DOM shape is wrong, and the guard then skips a write that // would have flattened it. Only use it where the target holds text and nothing // else. function mbuProp(obj, prop, value) { if (!obj) return; if (obj[prop] !== value) obj[prop] = value; } // #569: the one element mbuTheme resolves --background through. Looked up by id // rather than kept in a variable, so the seven scripts of a bundle share ONE // probe instead of adding seven, and so it heals itself if anything removes it. // It lives in : a permanent stray node under , outside head and // body, is the sort of thing another script's document scan trips over. function mbuProbe() { var p = document.getElementById('mbu-theme-probe'); if (p) return p; if (!document.body) return null; p = document.createElement('span'); p.id = 'mbu-theme-probe'; p.setAttribute('aria-hidden', 'true'); p.style.cssText = 'position:absolute;left:-9999px;top:0;width:1px;height:1px;pointer-events:none;background:var(--background)'; document.body.appendChild(p); return p; } function mbuTheme() { var root = document.documentElement; try { var cs = getComputedStyle(root); var forced = (cs.getPropertyValue('--mbu-theme') || '').trim(); var t = (forced === 'dark' || forced === 'light') ? forced : (mbuThemeOf(getComputedStyle(document.body).backgroundColor) || mbuThemeOf(cs.backgroundColor) || mbuThemeOf(cs.getPropertyValue('--mbu-bg')) || 'light'); if (root.getAttribute('data-mbu-theme') !== t) root.setAttribute('data-mbu-theme', t); // Should we adopt the userstyle's OWN shades, or use our own palette? // Only if its --background actually agrees with the theme we detected. // A userstyle can paint the page dark with ordinary rules and still leave // --background at a light value for its own purposes; taking that on // trust hands us a light surface under a correct dark theme, which is // indistinguishable from the bug it looks like. Measured, not assumed. var seed = null; var raw = (cs.getPropertyValue('--background') || '').trim(); if (raw) { // Resolved through a real element, because --background may itself be // a var(), a named colour, or anything else CSS accepts. // // #569 (chaban-mb): this used to CREATE and REMOVE that element on // every call, as a direct child of . mbuTheme re-runs whenever // the root or body class changes, Mammoth watches the whole document // for childList changes and reacts by toggling classes on , and // those class changes wake mbuTheme again — a self-feeding loop, // measured at 12 root-node mutations a second on an idle page, which // is what makes DevTools blink. // // One element, created once and left in place, breaks it: the value // is still resolved LIVE on every call (a cached reading would freeze // the theme at whatever it was before Stylus injected, which is the // bug this whole function exists to avoid) but nothing is added to or // removed from the DOM to read it. var probe = mbuProbe(); var got = null; if (probe) { got = mbuThemeOf(getComputedStyle(probe).backgroundColor); } else { // No yet — document-start. Fall back to the transient // element for these first one or two calls; the idle loop this // avoids cannot exist before the page has a body anyway. var tmp = document.createElement('span'); tmp.style.cssText = 'position:absolute;left:-9999px;width:1px;height:1px;background:var(--background)'; document.documentElement.appendChild(tmp); got = mbuThemeOf(getComputedStyle(tmp).backgroundColor); tmp.remove(); } if (got === t) seed = 'theme'; } // guarded: setAttribute dispatches a mutation record even when the value // is unchanged, and this runs several times a second if (seed) { if (root.getAttribute('data-mbu-seed') !== seed) root.setAttribute('data-mbu-seed', seed); } else if (root.hasAttribute('data-mbu-seed')) root.removeAttribute('data-mbu-seed'); return t; } catch (e) { return 'light'; } } // A document-start script runs before the document is parsed: documentElement can // still be null, and and don't exist. Observing a null root threw, the // catch below swallowed it, and nothing (the watches, the re-checks) was ever set up, // so such a script never read the theme at all (#625). It starts on the parsed page. function mbuThemeStart() { try { mbuTheme(); // Stylus and friends inject after us often enough that a one-shot read is // wrong about half the time. Watch for stylesheets ARRIVING — head childList // plus the root's own attributes — and never the whole subtree: this runs on // the release editor, where a subtree observer calling getComputedStyle is a // layout thrash on every keystroke. var _mbuThemeT = 0; var _mbuThemeSoon = function () { clearTimeout(_mbuThemeT); _mbuThemeT = setTimeout(mbuTheme, 150); }; var _mbuThemeObs = new MutationObserver(_mbuThemeSoon); _mbuThemeObs.observe(document.documentElement, { attributeFilter: ['style', 'class'] }); // ⚠ #569: characterData, not just childList. Until the idle thrash was fixed // this function ran several times a second whether or not anything had // changed — Apollo re-added a body class at 2Hz, which woke this observer, // which is how a theme change was ever noticed. That accidental polling was // LOAD-BEARING: with the thrash gone and only head-childList watched, a // userstyle that REWRITES ITSELF (Stylus editing it live, or one switching // palette) adds and removes no nodes, so nothing woke us and the theme went // stale. Caught by verify-569-theme-still-tracks.mjs, which passes on the // pre-fix build and failed on the first version of this one. if (document.head) _mbuThemeObs.observe(document.head, { childList: true, subtree: true, characterData: true }); if (document.body) _mbuThemeObs.observe(document.body, { attributeFilter: ['style', 'class'] }); // …and the case that produces no DOM mutation at all: the OS flipping to dark // under a userstyle with a prefers-color-scheme query. Nothing above can see // that, and nothing did before either — it was simply never noticed while the // page was re-checking itself several times a second. try { var _mbuMq = matchMedia('(prefers-color-scheme: dark)'); if (_mbuMq.addEventListener) _mbuMq.addEventListener('change', _mbuThemeSoon); else if (_mbuMq.addListener) _mbuMq.addListener(_mbuThemeSoon); } catch (e) {} setTimeout(mbuTheme, 400); setTimeout(mbuTheme, 2000); } catch (e) { /* no observer, no theme switching — the light defaults still apply */ } } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mbuThemeStart, { once: true }); else mbuThemeStart(); try { var _mbuNs = (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window); if (!_mbuNs.MBU) _mbuNs.MBU = {}; if (!_mbuNs.MBU.theme) _mbuNs.MBU.theme = mbuTheme; if (!_mbuNs.MBU.helpHref) _mbuNs.MBU.helpHref = mbuHelpHref; if (!_mbuNs.MBU.helpHtml) _mbuNs.MBU.helpHtml = mbuHelpHtml; if (!_mbuNs.MBU.helpEl) _mbuNs.MBU.helpEl = mbuHelpEl; if (!_mbuNs.MBU.toast) _mbuNs.MBU.toast = mbuToast; if (!_mbuNs.MBU.cfgHeader) _mbuNs.MBU.cfgHeader = mbuCfgHeader; if (!_mbuNs.MBU.dismissOn) _mbuNs.MBU.dismissOn = mbuDismissOn; if (!_mbuNs.MBU.fitToolbar) _mbuNs.MBU.fitToolbar = mbuFitToolbar; } catch (e) { /* a locked-down page must not stop the script loading */ } //
style.textContent = MBU_TOKENS + MBU_UI_CSS + ` /* button on the release page */ #ii-btn { display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px; margin-left: 12px; font-size: 12px; font-weight: 600; color: var(--mbu-text-on-accent) !important; background: var(--mbu-accent); border: none; border-radius: 4px; cursor: pointer; vertical-align: middle; white-space: nowrap; transition: background .15s; } #ii-btn:hover { background: var(--mbu-accent-hover); } #ii-btn.has-missing { background: #a0225e; animation: ii-pulse 1.6s ease-in-out infinite; } #ii-btn.has-missing:hover { background: #7d1a4a; } #ii-btn .ii-status { font-size: 10px; font-weight: 600; opacity: .9; } @keyframes ii-pulse { 0%,100%{opacity:1} 50%{opacity:.72} } /* overlay + modal */ #ii-overlay { position: fixed; inset: 0; background: rgba(15,12,28,.45); z-index: var(--mbu-z-modal); display: none; } #ii-overlay.open { display: block; } #ii-modal { position: fixed; top: 4vh; left: 50%; transform: translateX(-50%); width: 1300px; max-width: 96vw; /* #471: wider — the table now carries both ISRC and Links columns at once */ /* a DEFINITE height (not just max-height) so the modal never grows as rows / candidates are added — the body scrolls inside a fixed frame and the footer stays put. #471 ("movable, resizable, maximize button"): dropped !important from height so the corner resize handle and the maximize toggle can both change it — nothing else on an MB page has fought for it since. */ height: 92vh; max-height: 92vh; min-width: 480px; min-height: 320px; background: var(--mbu-bg); border-radius: var(--mbu-radius-lg); box-shadow: var(--mbu-shadow-lg); z-index: var(--mbu-z-modal-panel); display: none; flex-direction: column; font-family: system-ui, sans-serif; color: var(--mbu-text); overflow: hidden !important; resize: both; } #ii-modal.open { display: flex; } /* header: status text (left) · centered release (Zen) · actions (right). #471 (majkinetor: "reduce title which takes a lot of space") — the old ISRCs/Links tabs were removed (both columns are always visible now), so this row carries less weight than it used to. */ #ii-hdr { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; padding: 4px 14px 0; background: var(--mbu-bg); border-bottom: 1px solid var(--mbu-border); flex-shrink: 0; cursor: move; } /* #471: drag anywhere on the header (not its buttons) to move the window */ #ii-hdr button, #ii-hdr input, #ii-hdr a { cursor: pointer; } .ii-zen { text-align: center; padding-bottom: 7px; min-width: 0; } .ii-zen-t { font: 600 12px system-ui; color: var(--mbu-text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .ii-zen-s { font: 10.5px system-ui; color: var(--mbu-text-weak); } .ii-hicons { display: flex; gap: 6px; justify-self: end; padding-bottom: 5px; } .ii-hico { width: 28px; height: 26px; display: inline-flex; align-items: center; justify-content: center; border: 1px solid var(--mbu-border); border-radius: var(--mbu-radius); background: var(--mbu-bg); color: var(--mbu-text-dim); font-size: 14px; cursor: pointer; } .ii-hico:hover { background: var(--mbu-bg-raised); color: var(--mbu-accent-text); border-color: var(--mbu-accent); } .ii-hico.on { background: var(--mbu-bg-hover); color: var(--mbu-accent-text); border-color: var(--mbu-accent); } /* #471: plain status text where the ISRCs/Links tabs used to be — no columns to gate anymore, so nothing is hidden by it; just counts. */ .ii-hdr-status { font: 600 12px system-ui; color: var(--mbu-text-weak); white-space: nowrap; padding-bottom: 7px; } .ii-hdr-status b { color: var(--mbu-accent-text); } /* the Clear menu — a small dropdown anchored under its header button */ .ii-clear-wrap { position: relative; } /* #471 review: Clear is caret-only (no label) and sits rightmost, after Find links — #ii-links-btn (not the wrap) carries margin-left:auto now, since it's the first of the two right-pinned toolbar items. #419: a caret still needs a real hit area, not a bare glyph — same min footprint as .ii-hico. */ .ii-clear-toggle { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 26px; border: 1px solid var(--mbu-border); border-radius: 5px; background: var(--mbu-bg); color: var(--mbu-text-dim); font-size: 11px; cursor: pointer; } .ii-clear-toggle:hover, .ii-clear-toggle[aria-expanded="true"] { background: var(--mbu-bg-raised); color: var(--mbu-accent-text); border-color: var(--mbu-accent); } .ii-clear-menu { display: none; position: absolute; top: 100%; right: 0; z-index: 60; flex-direction: column; min-width: 128px; margin-top: 4px; background: var(--mbu-bg); border: 1px solid var(--mbu-border); border-radius: var(--mbu-radius); box-shadow: 0 6px 18px rgba(0,0,0,.14); padding: 4px; } .ii-clear-menu.open { display: flex; } .ii-clear-menu button { background: none; border: none; text-align: left; padding: 6px 10px; font-size: 12px; border-radius: 4px; cursor: pointer; color: var(--mbu-text); } .ii-clear-menu button:hover { background: var(--mbu-bg-raised); color: var(--mbu-accent-text); } /* toolbar */ #ii-tools { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; padding: 8px 16px; border-bottom: 1px solid var(--mbu-divider); flex-shrink: 0; background: var(--mbu-bg); } #ii-tools #ii-links-btn { margin-left: auto; } /* pins Find links + the Clear caret to the toolbar's right edge */ .ii-tbtn { display: inline-flex; align-items: center; gap: 5px; padding: 4px 11px; font-size: 12px; font-weight: 600; border-radius: 5px; cursor: pointer; text-decoration: none; border: 1px solid var(--mbu-border); background: var(--mbu-bg); color: var(--mbu-text); white-space: nowrap; } a.ii-tbtn:hover { text-decoration: none; } .ii-tbtn:hover { background: var(--mbu-bg-raised); } .ii-tbtn:disabled { opacity: .5; cursor: default; } .ii-tbtn.sx { color: var(--mbu-accent-text); border-color: var(--mbu-accent); } .ii-tbtn.dz { color: var(--mbu-error); border-color: var(--mbu-error); } .ii-tbtn.sp { color: var(--mbu-ok); border-color: var(--mbu-ok); } .ii-tbtn.bp { color: var(--mbu-ok); border-color: var(--mbu-ok); } .ii-tbtn.td { color: var(--mbu-info); border-color: var(--mbu-border); } .ii-tbtn.vo { color: var(--mbu-accent-text); border-color: var(--mbu-accent); } .ii-tbtn.hd { color: var(--mbu-error); border-color: var(--mbu-error); } .ii-tbtn.qz { color: var(--mbu-info); border-color: var(--mbu-info); } /* import-source buttons: independently show icon and/or text (⚙ Setup). Default = icons only (toolbar room); both can be on at once. */ .ii-bico { display: none; line-height: 0; } .ii-bico svg { display: block; } .ii-blabel { display: none; } #ii-tools.ii-show-icons .ii-bico { display: inline-flex; align-items: center; } #ii-tools.ii-show-text .ii-blabel { display: inline; } /* #643: All has no logo: its "icon" is the word, at the logos' height (icons get line-height 0, which collapsed it), and it is said once when icons and labels are both on */ .ii-tbtn.all { color: var(--mbu-text); border-color: var(--mbu-border-strong); } .ii-tbtn.all .ii-bico { line-height: 16px; height: 16px; font-weight: 700; font-size: 12px; letter-spacing: .02em; } #ii-tools.ii-show-icons.ii-show-text .ii-tbtn.all .ii-blabel { display: none; } .ii-tbtn.primary { background: #198754; color: var(--mbu-text-on-accent); border-color: var(--mbu-ok); } .ii-tbtn.primary:hover { background: #157347; } /* #406: a collection is running — fade the whole button in/out so it's clearly in progress */ #ii-submit.ii-collecting { animation: ii-btn-pulse 1.1s ease-in-out infinite; } @keyframes ii-btn-pulse { 0%,100% { opacity: 1; } 50% { opacity: .5; } } .ii-tbtn.ghost { border-color: transparent; } /* #302: a small purple dot marks a provider whose link was pulled from another release in the group (not this release). Same dot on RG-sourced add candidates. */ .ii-tbtn.ii-rg, .ii-tl.ii-rg { position: relative; } .ii-tbtn.ii-rg::after, .ii-tl.ii-rg::after { content: ''; position: absolute; top: -3px; right: -3px; width: 7px; height: 7px; border-radius: 50%; background: var(--mbu-accent); border: 1.5px solid var(--mbu-border); } /* In-MB marker (#180): a provider button gets a ring around its icon + a brand tint when the release already has that platform's URL in MB; an un-tinted/un-ringed button means the link was found by Platform Check. */ /* MB-linked: a strong brand ring on the WHITE button (#404) — the shared icons are now full-colour, so a brand FILL would hide them (e.g. green Spotify on green). */ .ii-tbtn.ii-mb { border-color: currentColor; box-shadow: 0 0 0 1px currentColor; } .ii-tbtn.ii-mb:hover { background: var(--mbu-bg-raised); } /* Unified "paste a URL" control (#180), apollo "+"-unroll style: a small round button that expands to an input on click; auto-detects the platform. */ .ii-urladd { display: inline-flex; align-items: center; gap: 5px; } .ii-urladd.open { flex: 1 1 auto; min-width: 120px; } /* expanded input fills the row (#180) */ .ii-urladd-btn { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; width: 26px; height: 26px; padding: 0; border: 1px solid var(--mbu-border); border-radius: 50%; background: var(--mbu-bg); color: var(--mbu-text-dim); cursor: pointer; font-size: 16px; line-height: 1; } .ii-urladd-btn:hover { background: var(--mbu-bg-raised); border-color: var(--mbu-border); } .ii-urladd-btn svg { display: block; } .ii-urladd-input { display: none; padding: 4px 9px; border: 1px solid var(--mbu-border); border-radius: 5px; font-size: 12px; } .ii-urladd-input:focus { outline: none; border-color: var(--mbu-accent); } .ii-urladd.open .ii-urladd-input { display: inline-block; flex: 1 1 auto; width: auto; min-width: 0; } .ii-urladd.open .ii-urladd-btn { border-radius: 5px; } .ii-prog { font-size: 11px; color: var(--mbu-text-dim); min-width: 0; } .ii-prog.err { color: var(--mbu-error); font-weight: 700; } .ii-prog.continue { color: var(--mbu-accent-text); font-weight: 700; cursor: pointer; text-decoration: underline dotted; } .ii-prog.tolog { cursor: pointer; } .ii-prog.tolog:hover { text-decoration: underline; } .ii-prog.continue:hover { color: var(--mbu-accent-hover); } /* table */ /* min-height:0 → the track list scrolls instead of pushing the footer out of the modal. !important guards against MusicBrainz's page CSS. */ #ii-body { flex: 1 1 auto !important; min-height: 0 !important; overflow: auto !important; overscroll-behavior: contain; /* scrolling to either end stays in the modal, never scrolls the page behind */ padding: 0 0 56px 0; } /* 56px bottom = room for the absolutely-pinned footer */ #ii-table { width: 100%; border-collapse: collapse; font-size: 13px; table-layout: fixed; } #ii-table thead th { position: sticky; top: 0; z-index: 2; background: var(--mbu-bg-raised); text-align: left; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .3px; color: var(--mbu-text-dim); padding: 7px 10px; border-bottom: 1px solid var(--mbu-border); } /* #471: resizable columns — a thin drag handle on the right edge of each resizable header cell; dragging changes that column's width only (table-layout:fixed), so #ii-body scrolls horizontally past the widened table rather than squeezing its neighbor. th's own sticky positioning (position: sticky, above) already gives it a positioning context for the handle. */ .ii-col-resize { position: absolute; top: 0; right: -4px; width: 8px; height: 100%; cursor: col-resize; z-index: 3; } /* A persistent line so the handle is discoverable, not just a hover surprise (#471 review) — except it was drawn in --mbu-bg-sunken, which is a SURFACE token: 94% of the background mixed with a little ink, i.e. ~#f0f0f0 on a white header. Invisible, exactly as majkinetor reported ("column separators are not visible on light theme, one visible is the one I hover over"). --mbu-border is the token for a line you are meant to see, and is what this table's own medium rows already use. */ .ii-col-resize::after { content: ''; position: absolute; top: 4px; bottom: 4px; left: 3px; width: 1px; background: var(--mbu-border); } .ii-col-resize:hover, .ii-col-resize.dragging { background: rgba(111,66,193,.35); } .ii-col-resize:hover::after, .ii-col-resize.dragging::after { background: var(--mbu-accent); width: 2px; } .ii-medrow td { background: var(--mbu-bg-raised); font-weight: 700; font-size: 11.5px; color: var(--mbu-text-dim); padding: 5px 10px; border-top: 1px solid var(--mbu-border); } #ii-table td { padding: 6px 10px; border-bottom: 1px solid var(--mbu-border); vertical-align: top; } .ii-pos { color: var(--mbu-text-weak); font-variant-numeric: tabular-nums; width: 34px; white-space: nowrap; } .ii-track-title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; } .ii-track-title a { color: inherit; text-decoration: none; } .ii-track-title a:hover { color: var(--mbu-accent-text); text-decoration: underline; } .ii-track-artist { color: var(--mbu-text-dim); font-size: 11.5px; } .ii-track-dur { color: var(--mbu-text-dim); font-size: 11px; font-family: 'Courier New', monospace; /* the row is two lines (title + artist) and #ii-table td is vertical-align:top, so this smaller monospace sat ABOVE the title's baseline. baseline lines it up with the title and the ISRC cell beside it. */ } /* #ii-table td sets vertical-align:top at id specificity, so the class alone could not move it — the duration has to be named at the same level. */ #ii-table td.ii-track-dur { vertical-align: baseline; } /* #219/#301 — provider link icons live in the LINKED / ADD columns. Grey monochrome = already linked on MB; brand colour = resolved + addable. */ .ii-tl-linked, .ii-tl-add { display: flex; align-items: center; gap: 9px; min-height: 18px; } .ii-tl-add { flex-wrap: wrap; } .ii-tl-linked { flex-wrap: wrap; } /* #389 one row when it fits (the wide col #406 gives it); wrap, never overflow into ADD */ .ii-tl-add:empty::before, .ii-tl-linked:empty::before { content: '—'; color: var(--mbu-text-weak); } .ii-tl { display: inline-flex; align-items: center; line-height: 0; text-decoration: none; } .ii-tl svg { width: 16px; height: 16px; display: block; } .ii-tl.linked { color: var(--mbu-text-weak); } /* already on MB → quiet monochrome */ .ii-tl.linked:hover { color: var(--mbu-text-dim); } .ii-tl.linked.ended { opacity: .4; } /* #341: relationship marked ended → faded */ .ii-tl.linked.ended:hover { opacity: .7; } .ii-tl.new { cursor: pointer; } /* resolved, not linked → click to add (brand colour set inline) */ .ii-tl.new:hover { filter: brightness(1.12); } .ii-tl.cand { display: none; } /* not resolved yet → hidden until Find links */ .ii-tl.spin { color: var(--mbu-info); animation: ii-tl-pulse 1s ease-in-out infinite; } .ii-tl.removing { opacity: .35; animation: ii-tl-pulse 1s ease-in-out infinite; } .ii-tl.absent { display: none; } /* track not found on provider */ @keyframes ii-tl-pulse { 0%,100% { opacity: .35; } 50% { opacity: .9; } } .ii-existing { width: 150px; } .ii-existing samp { display: block; font-size: 11px; font-weight: 700; color: var(--mbu-ok); font-family: 'Courier New', monospace; } .ii-existing samp.dup { color: var(--mbu-error); background: var(--mbu-error-bg); border-radius: 3px; padding: 0 3px; } .ii-existing .none { color: var(--mbu-info); font-style: italic; font-size: 11px; } /* #159: highlight rows that still have no ISRC (no existing + nothing entered yet) */ .ii-row-missing > td { background: var(--mbu-warn-bg); } .ii-row-missing > td:first-child { box-shadow: inset 3px 0 0 #f0ad4e; } .ii-row-missing .ii-existing .none { color: var(--mbu-warn); font-style: normal; font-weight: 600; } .ii-ex-item { display: flex; align-items: center; gap: 5px; cursor: pointer; } .ii-ex-item input { cursor: pointer; margin: 0; flex-shrink: 0; } .ii-ex-item.del samp { text-decoration: line-through; color: var(--mbu-error); } /* pending Remove-ISRC edit — highlighted like MusicBrainz marks entities with an open edit (orange/peach), with a strike-through to show it's a removal */ .ii-ex-pending { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; color: var(--mbu-warn); background: var(--mbu-warn-bg); border: 1px solid var(--mbu-warn); border-radius: 3px; padding: 0 4px; } .ii-ex-pending samp { color: var(--mbu-warn); text-decoration: line-through; } a.ii-ex-pending { text-decoration: none; } a.ii-ex-pending:hover { background: var(--mbu-warn); color: var(--mbu-text-on-accent); } a.ii-ex-pending:hover samp { color: var(--mbu-text-on-accent); } /* #587: a removal whose POST failed — it is still on the recording and no edit exists, which must not read the same as a pending one */ .ii-ex-failed { border: 1px solid var(--mbu-error); border-radius: 3px; padding: 0 3px; } .ii-ex-failed samp { color: var(--mbu-error); } /* #587: the Delete button is the global activity indicator while a batch runs — it counts down, so there is one obvious place to look besides the log */ .ii-tbtn.ii-busy { color: var(--mbu-warn); border-color: var(--mbu-warn); cursor: progress; opacity: 1; } .ii-tbtn.ii-busy[disabled] { opacity: 1; } .ii-inwrap { display: flex; align-items: center; gap: 5px; } /* #490: the initial "search SoundExchange by title/artist" entry point used to be a permanently-visible text link under every row (spammy on a long tracklist) — now a row-hover-only icon left of the input. Opacity/pointer-events toggle (not display) so its slot stays reserved and nothing shifts when it fades in. */ .ii-sx-hover { flex-shrink: 0; width: 20px; height: 22px; padding: 0; border: none; background: none; cursor: pointer; color: var(--mbu-text-dim); opacity: 0; pointer-events: none; transition: opacity .1s; display: flex; align-items: center; justify-content: center; } .ii-sx-hover svg { display: block; } tr:hover .ii-sx-hover { opacity: .55; pointer-events: auto; } .ii-sx-hover:hover { opacity: 1 !important; color: var(--mbu-text); } /* the × lives INSIDE the input box (part of the edit), so it doesn't shift the row layout / SX text alignment */ .ii-input-box { position: relative; flex-shrink: 0; width: 150px; } .ii-input { width: 100%; box-sizing: border-box; padding: 4px 22px 4px 7px; border: 1px solid var(--mbu-border); border-radius: 4px; font-family: 'Courier New', monospace; font-size: 12.5px; font-weight: 700; letter-spacing: .5px; text-transform: uppercase; } .ii-input:focus { outline: none; border-color: var(--mbu-accent); } .ii-input.bad { border-color: var(--mbu-error); background: var(--mbu-bg-raised); } .ii-input.dup { border-color: var(--mbu-warn); background: var(--mbu-bg-raised); } .ii-input.dupother { border-color: var(--mbu-error); background: var(--mbu-error-bg); } .ii-input.ok { border-color: var(--mbu-ok); } .ii-clear { position: absolute; right: 3px; top: 50%; transform: translateY(-50%); width: 17px; height: 17px; padding: 0; border: none; border-radius: 3px; background: transparent; color: var(--mbu-text-weak); font-size: 13px; line-height: 1; cursor: pointer; display: flex; align-items: center; justify-content: center; } .ii-clear:hover { background: var(--mbu-bg-hover); color: var(--mbu-error); } .ii-plus { flex-shrink: 0; font-size: 11px; font-weight: 700; padding: 3px 7px; border: 1px solid var(--mbu-border); border-radius: 4px; background: var(--mbu-bg); cursor: pointer; color: var(--mbu-text-dim); font-family: monospace; } .ii-plus:hover { background: var(--mbu-bg-sunken); color: var(--mbu-text); } .ii-plus-hidden { visibility: hidden; } /* reserve the slot on the first row so SX text still aligns */ /* explicit per-track SoundExchange trigger (#157), sits next to +1 */ .ii-sx { flex-shrink: 0; font-size: 11px; font-weight: 700; padding: 3px 7px; border: 1px solid var(--mbu-border); border-radius: 4px; background: var(--mbu-bg-raised); cursor: pointer; color: var(--mbu-info); font-family: monospace; } .ii-sx:hover { background: var(--mbu-info-bg); color: var(--mbu-info); } .ii-sx:disabled { opacity: .4; cursor: default; } .ii-sx:disabled:hover { background: var(--mbu-bg-raised); color: var(--mbu-info); } /* #490: margin-left matches .ii-sx-hover's width (20px) + .ii-inwrap's gap (5px), so the candidate list — a sibling of .ii-inwrap, not a child — lines up with the input box instead of starting flush left under the hover icon. */ .ii-cands { margin-top: 4px; margin-left: 25px; display: flex; flex-direction: column; gap: 3px; width: auto; } .ii-cand { display: flex; align-items: flex-start; gap: 7px; padding: 3px 7px; border: 1px solid var(--mbu-border); border-radius: 4px; cursor: pointer; font-size: 11px; background: var(--mbu-bg); } .ii-cand:hover { background: var(--mbu-bg); border-color: var(--mbu-info); } .ii-cands.ii-collapsed .ii-cand:not(.chosen) { display: none; } .ii-cand.chosen { box-shadow: inset 3px 0 0 #198754; } .ii-cand.best { border-color: var(--mbu-info); background: var(--mbu-info-bg); } .ii-cand.warn { border-color: var(--mbu-warn); background: var(--mbu-warn-bg); } .ii-cand.bad { border-color: var(--mbu-error); background: var(--mbu-bg-raised); } .ii-cand-isrc { font-family: 'Courier New', monospace; font-weight: 700; color: var(--mbu-info); flex-shrink: 0; padding-top: 1px; } .ii-cand-meta { flex: 1; min-width: 0; color: var(--mbu-text-dim); white-space: normal; word-break: break-word; line-height: 1.35; } .ii-bad { color: var(--mbu-error); font-weight: 600; text-decoration: underline wavy rgba(220,53,69,.55); text-underline-offset: 2px; } .ii-mbdur { color: var(--mbu-ok); font-weight: 600; } .ii-cand-src { margin-left: auto; font-size: 9px; text-transform: uppercase; color: var(--mbu-text-weak); flex-shrink: 0; } .ii-cand-note { font-size: 11px; color: var(--mbu-text-weak); font-style: italic; padding: 2px 7px; } .ii-row-fill { animation: ii-flash 1s ease-out; } @keyframes ii-flash { 0%{background:rgba(25,135,84,.18)} 100%{background:transparent} } /* footer — pinned ABSOLUTELY to the modal's bottom (out of the flex flow) so it can never be pushed off, whatever the body does. The body reserves 56px of bottom padding for it. #ii-modal is position:fixed → it's the containing block. */ #ii-foot { position: absolute !important; left: 0; right: 0; bottom: 0; z-index: 2; display: flex; align-items: center; gap: 10px; padding: 9px 16px; height: 56px; box-sizing: border-box; border-top: 1px solid var(--mbu-border); background: var(--mbu-bg); } #ii-foot .ii-summary { font-size: 12px; color: var(--mbu-text-dim); min-width: 0; } #ii-foot #ii-summary { flex: 1; } /* #471: both summaries show together — only the first grows */ #ii-foot .ii-summary b { color: var(--mbu-text); } #ii-foot #ii-summary-links:not(:empty)::before { content: ' · '; color: var(--mbu-info); } .ii-seq-badge { display: inline-flex; align-items: center; gap: 4px; margin-left: 8px; padding: 2px 9px; font-size: 11px; font-weight: 700; font-family: 'Courier New', monospace; color: var(--mbu-ok); background: var(--mbu-ok-bg); border: 1px solid var(--mbu-ok); border-radius: 11px; vertical-align: middle; letter-spacing: .3px; } /* sub-panels (setup / bulk) */ .ii-pane { display: none; padding: 14px 16px; border-bottom: 1px solid var(--mbu-divider); background: var(--mbu-bg); flex-shrink: 0; } /* an open pane scrolls internally past 45vh so it can never push the footer off */ .ii-pane.open { display: block; max-height: 45vh; overflow-y: auto; overscroll-behavior: contain; } .ii-pane h3 { margin: 0 0 8px; font-size: 13px; display: flex; align-items: center; gap: 8px; } .ii-pane-x { flex-shrink: 0; width: 19px; height: 19px; line-height: 1; padding: 0; font-size: 13px; color: var(--mbu-text-dim); background: var(--mbu-bg); border: 1px solid var(--mbu-border); border-radius: 4px; cursor: pointer; } .ii-pane-x:hover { background: var(--mbu-bg-raised); color: var(--mbu-text); border-color: var(--mbu-border); } /* #301 standard config-window header: icon · name · version · Log · ? Help */ .ii-cfg-lnk { font: 600 12px system-ui; color: var(--mbu-accent-text); text-decoration: none; cursor: pointer; background: none; border: none; padding: 2px 4px; } .ii-cfg-lnk:hover { text-decoration: underline; } .ii-cfg-grp { font: 700 10.5px system-ui; text-transform: uppercase; letter-spacing: .3px; color: var(--mbu-text-weak); margin: 4px 0 6px; } .ii-pane label { display: block; font-size: 11.5px; color: var(--mbu-text-dim); margin: 6px 0 2px; } .ii-pane input[type=text], .ii-pane textarea { width: 100%; box-sizing: border-box; padding: 6px 8px; border: 1px solid var(--mbu-border); border-radius: 4px; font-size: 12px; font-family: 'Courier New', monospace; } .ii-pane textarea { min-height: 120px; resize: vertical; } .ii-pane .row { display: flex; gap: 8px; align-items: flex-end; flex-wrap: wrap; } .ii-pane .row > div { flex: 1; min-width: 200px; } .ii-help { font-size: 11px; color: var(--mbu-text-dim); margin-top: 6px; line-height: 1.5; } .ii-help a { color: var(--mbu-accent-text); } .ii-authstate { font-size: 11.5px; padding: 4px 0; } .ii-authstate.ok { color: var(--mbu-ok); } .ii-authstate.no { color: var(--mbu-error); } /* log pane */ #ii-log-out { font-family: 'Courier New', monospace; font-size: 11px; line-height: 1.45; white-space: pre-wrap; word-break: break-word; background: #0d1117; color: #c9d1d9; /* the console keeps a FIXED dark ground in both themes, so its text must be fixed too: var(--mbu-info) is dark in the light theme, which put dark text on #0d1117 */ padding: 8px 10px; border-radius: 5px; max-height: 240px; overflow: auto; margin: 0; } #ii-log-pane h3 { display: flex; align-items: center; gap: 8px; } .ii-sx-group { display: inline-flex; align-items: center; gap: 7px; padding: 3px 8px 3px 4px; border: 1px solid var(--mbu-accent); background: var(--mbu-bg-raised); border-radius: 7px; } /* per-track ISRC-provider selector (#181): a split [icon|▾] on each row's button */ .ii-sxsplit { display: inline-flex; align-items: stretch; flex-shrink: 0; } .ii-sxsplit .ii-sx { border-top-right-radius: 0; border-bottom-right-radius: 0; } .ii-sxprov { display: inline-flex; align-items: center; justify-content: center; padding: 0 4px; margin-left: -1px; font-size: 9px; color: var(--mbu-text-dim); background: var(--mbu-bg-raised); border: 1px solid var(--mbu-border); border-left-color: var(--mbu-border); border-top-right-radius: 5px; border-bottom-right-radius: 5px; cursor: pointer; } .ii-sxprov:hover { background: var(--mbu-info-bg); color: var(--mbu-info); } .ii-prov-menu { display: none; position: absolute; z-index: 60; flex-direction: column; min-width: 178px; padding: 4px; background: var(--mbu-bg); border: 1px solid var(--mbu-accent); border-radius: 7px; box-shadow: 0 6px 22px rgba(40,20,80,.18); } .ii-prov-menu.open { display: flex; } .ii-prov-item { display: flex; align-items: center; gap: 8px; padding: 6px 9px; font-size: 12px; font-weight: 600; color: inherit; background: none; border: 0; border-radius: 5px; cursor: pointer; text-align: left; } .ii-prov-item:hover { background: var(--mbu-bg-raised); } .ii-prov-item.active { background: var(--mbu-bg-hover); } .ii-prov-item.active::after { content: '✓'; margin-left: auto; color: var(--mbu-accent-text); font-weight: 700; } .ii-prov-ico { display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; flex-shrink: 0; } .ii-prov-ico svg { width: 16px; height: 16px; } .ii-prov-sx { font-size: 10px; font-weight: 800; color: var(--mbu-accent-text); } .ii-prov-name { color: var(--mbu-text); } /* per-track button shows the chosen provider's icon */ .ii-sx svg { width: 13px; height: 13px; display: block; } /* collapsible "exact" toggle — collapsed by default so the toolbar stays compact */ .ii-exact-toggle { display: inline-flex; align-items: center; gap: 3px; padding: 3px 8px; font-size: 11px; font-weight: 600; color: var(--mbu-accent-text); background: var(--mbu-bg); border: 1px solid var(--mbu-accent); border-radius: 5px; cursor: pointer; } .ii-exact-toggle:hover { background: var(--mbu-bg-raised); color: var(--mbu-accent-text); } .ii-exact-toggle .ii-exact-car { font-size: 9px; transition: transform .15s; } /* NB: the state class is namespaced (ii-collapsed) on purpose — MB's common.css paints a 20px absolute ::after fade overlay on ANY bare .collapsed, which blanketed the toolbar and swallowed clicks on the bottom half of every button (#414). */ .ii-sx-group:not(.ii-collapsed) .ii-exact-toggle .ii-exact-car { transform: rotate(180deg); } /* a filled dot on the toggle when any exact option is active while collapsed */ .ii-exact-toggle.on { color: var(--mbu-accent-text); border-color: var(--mbu-accent); background: var(--mbu-bg-raised); } .ii-exact-toggle.on::after { content: ''; width: 6px; height: 6px; border-radius: 50%; background: var(--mbu-accent); } .ii-sx-group.ii-collapsed .ii-exact-set { display: none; } .ii-exact-set { display: inline-flex; align-items: center; gap: 9px; font-size: 11px; color: var(--mbu-text-dim); } .ii-ex-all-lbl { display: inline-flex; align-items: center; gap: 5px; cursor: pointer; } .ii-ex-all-lbl input { cursor: pointer; } .ii-exact-set label { display: inline-flex; align-items: center; gap: 3px; cursor: pointer; margin: 0; } .ii-exact-set input { cursor: pointer; } .ii-cand.inmb { opacity: .72; } .ii-cand-inmb { margin-left: auto; font-size: 9px; font-weight: 700; color: var(--mbu-ok); flex-shrink: 0; } .ii-lookup { flex: 1; min-width: 0; font-size: 11px; white-space: normal; word-break: break-word; line-height: 1.35; } .ii-lookup.ok { color: var(--mbu-ok); } .ii-lookup.warn { color: var(--mbu-warn); } .ii-lookup.err { color: var(--mbu-error); } .ii-lookup.spin { color: var(--mbu-text-dim); } /* #643: the All verdict chip and its comparison popover */ .ii-lookup.ii-all-chip { flex: 0 0 auto; cursor: pointer; font-weight: 700; padding: 1px 7px; border-radius: 10px; border: 1px solid currentColor; } .ii-lookup.ii-all-chip.none { color: var(--mbu-text-weak); } .ii-lookup.ii-all-chip:hover { background: var(--mbu-bg-hover); } #ii-all-pop { position: absolute; z-index: 20; min-width: 300px; max-width: 620px; background: var(--mbu-bg); color: var(--mbu-text); border: 1px solid var(--mbu-accent); border-radius: 8px; box-shadow: var(--mbu-shadow-lg); padding: 6px 4px; font-size: 12px; user-select: text; } #ii-all-pop.pinned { box-shadow: 0 0 0 1px var(--mbu-accent), var(--mbu-shadow-lg); } #ii-all-pop .ii-all-h { display: flex; flex-wrap: wrap; align-items: center; gap: 5px; font-weight: 700; color: var(--mbu-text-dim); padding: 2px 6px 5px 8px; border-bottom: 1px solid var(--mbu-border-soft); margin-bottom: 3px; } #ii-all-pop .ii-all-sp { flex: 1 1 auto; } #ii-all-pop .ii-all-pick { font: inherit; font-family: var(--mbu-font-mono); font-weight: 400; font-size: 11px; padding: 1px 6px; border: 1px solid var(--mbu-border); border-radius: 4px; background: var(--mbu-bg-raised); color: var(--mbu-text); cursor: pointer; } #ii-all-pop .ii-all-pick.other { border-color: var(--mbu-error); } #ii-all-pop .ii-all-pick.sel { border-color: var(--mbu-accent); background: var(--mbu-bg-hover); } #ii-all-pop .ii-all-pick .ii-all-n { color: var(--mbu-text-weak); } #ii-all-pop .ii-all-line { display: grid; grid-template-columns: 14px 18px 96px 1fr auto auto; gap: 6px; align-items: center; padding: 3px 8px; border-radius: 5px; } #ii-all-pop .ii-all-ico svg { width: 14px; height: 14px; display: block; } #ii-all-pop .ii-all-isrc { font-family: var(--mbu-font-mono); } #ii-all-pop .ii-all-note { color: var(--mbu-text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } #ii-all-pop .ii-all-at { cursor: help; } #ii-all-pop .ii-all-len { font-variant-numeric: tabular-nums; color: var(--mbu-text-dim); text-align: right; } #ii-all-pop .ii-all-sub { color: var(--mbu-text-weak); } #ii-all-pop .ii-all-mark { text-align: center; } #ii-all-pop .ii-all-ok .ii-all-mark { color: var(--mbu-ok); } #ii-all-pop .ii-all-song .ii-all-mark, #ii-all-pop .ii-all-blocked .ii-all-mark { color: var(--mbu-warn); } #ii-all-pop .ii-all-differs { background: var(--mbu-error-bg); } #ii-all-pop .ii-all-differs .ii-all-mark { color: var(--mbu-error); } #ii-all-pop .ii-all-none, #ii-all-pop .ii-all-pending { color: var(--mbu-text-weak); } #ii-all-pop .ii-all-use, #ii-all-pop .ii-all-copy { font-size: 11px; padding: 1px 8px; border: 1px solid var(--mbu-border); border-radius: 4px; background: var(--mbu-bg-raised); color: var(--mbu-text); cursor: pointer; } #ii-all-pop .ii-all-copy.done { border-color: var(--mbu-ok); color: var(--mbu-ok); } #ii-all-pop .ii-all-sxlink { color: var(--mbu-link, var(--mbu-accent)); text-decoration: none; font-weight: 700; } #ii-all-pop .ii-all-copy:hover, #ii-all-pop .ii-all-pick:hover, #ii-all-pop .ii-all-use:hover { border-color: var(--mbu-accent); } .ii-lookup-rel { color: var(--mbu-text-dim); } .ii-lookup.pending { color: var(--mbu-text-dim); cursor: pointer; text-decoration: underline dotted #adb5bd; text-underline-offset: 2px; } /* #431: a position-matched fill whose length/title doesn't fit the MB track */ input.ii-in-suspect { border-color: var(--mbu-warn) !important; background: var(--mbu-bg-raised) !important; box-shadow: 0 0 0 2px rgba(224,137,42,.28); } #ii-suspect-badge { color: var(--mbu-warn); background: var(--mbu-warn-bg); border: 1px solid var(--mbu-warn); border-radius: 5px; padding: 2px 8px; font-weight: 700; font-size: 11.5px; cursor: pointer; flex-shrink: 0; white-space: nowrap; } #ii-suspect-badge:hover { background: var(--mbu-warn-bg); } .ii-lookup.pending:hover { color: var(--mbu-text); } .ii-cand-refine { font-size: 10.5px; color: var(--mbu-accent-text); cursor: pointer; padding: 2px 7px; border: 1px dashed var(--mbu-accent); border-radius: 4px; background: var(--mbu-bg-raised); width: max-content; } .ii-cand-refine:hover { background: var(--mbu-bg-hover); text-decoration: underline; } .ii-cand-pending { font-size: 10.5px; color: var(--mbu-text-dim); cursor: pointer; padding: 3px 8px; border: 1px dashed var(--mbu-border); border-radius: 4px; background: var(--mbu-bg); width: max-content; } .ii-cand-pending:hover { background: var(--mbu-bg-raised); color: var(--mbu-text); border-color: var(--mbu-border); } /* SoundExchange refine panel */ #ii-sxpanel { position: fixed; top: 9vh; right: 4vw; width: 560px; max-width: 92vw; max-height: 78vh; background: var(--mbu-bg); border: 1px solid var(--mbu-accent); border-radius: var(--mbu-radius-lg); box-shadow: 0 14px 44px rgba(0,0,0,.32); z-index: var(--mbu-z-modal-panel); display: none; flex-direction: column; overflow: hidden; font-family: system-ui, sans-serif; } #ii-sxpanel.open { display: flex; } .ii-sxp-hdr { display: flex; align-items: center; gap: 8px; padding: 9px 13px; background: var(--mbu-bg-raised); border-bottom: 1px solid var(--mbu-accent); cursor: move; user-select: none; } .ii-sxp-hdr .t { flex: 1; font-size: 13px; font-weight: 700; color: var(--mbu-accent-deep-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .ii-sxp-hdr .t b { color: var(--mbu-accent-text); } #ii-sxp-close { background: none; border: none; font-size: 17px; color: var(--mbu-text-dim); cursor: pointer; line-height: 1; } #ii-sxp-close:hover { color: var(--mbu-text); } .ii-sxp-form { display: grid; grid-template-columns: 1fr 1fr auto; gap: 6px; padding: 10px 13px 10px; align-items: start; } .ii-sxp-field { position: relative; display: flex; align-items: center; } #ii-sxp-f-title { grid-column: 1; } #ii-sxp-f-artist { grid-column: 2; } #ii-sxp-f-release { grid-column: 1 / 3; } .ii-sxp-inp { width: 100%; padding: 6px 31px; border: 1px solid var(--mbu-border); border-radius: var(--mbu-radius); font-size: 13px; box-sizing: border-box; } .ii-sxp-inp:focus { outline: none; border-color: var(--mbu-accent); } .ii-sxp-field.off .ii-sxp-inp { color: var(--mbu-text-weak); background: var(--mbu-bg); } .ii-sxp-en { position: absolute; left: 8px; width: 15px; height: 15px; margin: 0; cursor: pointer; z-index: 1; flex-shrink: 0; } .ii-sxp-E { position: absolute; right: 5px; width: 23px; height: 23px; padding: 0; display: inline-flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; color: var(--mbu-text-weak); background: var(--mbu-bg); border: 1px solid var(--mbu-border); border-radius: 4px; cursor: pointer; } .ii-sxp-E:hover { color: var(--mbu-accent-text); border-color: var(--mbu-accent); } .ii-sxp-E.on { color: var(--mbu-text); border-color: #212529; } .ii-sxp-field.off .ii-sxp-E { opacity: .4; pointer-events: none; } #ii-sxp-search { grid-column: 3; grid-row: 1 / 3; align-self: stretch; padding: 0 18px; border: none; border-radius: var(--mbu-radius); background: var(--mbu-accent); color: var(--mbu-text-on-accent); font-size: 13px; font-weight: 700; cursor: pointer; } #ii-sxp-search:hover { background: var(--mbu-accent-hover); } #ii-sxp-search:disabled { background: var(--mbu-bg-sunken); } .ii-sxp-status { padding: 2px 13px; font-size: 11px; color: var(--mbu-text-dim); min-height: 14px; } .ii-sxp-status.err { color: var(--mbu-error); } .ii-sxp-results { flex: 1; overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain; padding: 4px 13px 12px; display: flex; flex-direction: column; gap: 4px; } .ii-sxp-row { display: flex; align-items: center; gap: 9px; padding: 7px 9px; border: 1px solid var(--mbu-border); border-radius: var(--mbu-radius); cursor: pointer; overflow: hidden; flex-shrink: 0; } .ii-sxp-row:hover { background: var(--mbu-bg); border-color: var(--mbu-info); } .ii-sxp-row.best { border-color: var(--mbu-info); background: var(--mbu-info-bg); } .ii-sxp-row.warn { border-color: var(--mbu-warn); background: var(--mbu-warn-bg); } .ii-sxp-row.bad { border-color: var(--mbu-error); background: var(--mbu-bg-raised); } .ii-sxp-row.cur { border-color: var(--mbu-ok); background: var(--mbu-ok-bg); } .ii-sxp-row { align-items: flex-start; } .ii-sxp-isrc { font-family: 'Courier New', monospace; font-weight: 700; color: var(--mbu-info); flex-shrink: 0; font-size: 12px; padding-top: 1px; } .ii-sxp-meta { flex: 1; min-width: 0; } .ii-sxp-meta .a { display: block; font-size: 12px; color: var(--mbu-text); white-space: normal; word-break: break-word; line-height: 1.35; } .ii-sxp-meta .b { display: block; font-size: 10.5px; color: var(--mbu-text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .ii-sxp-inmb { font-size: 9px; font-weight: 700; color: var(--mbu-ok); flex-shrink: 0; } .ii-sxp-foot { padding: 8px 13px; border-top: 1px solid var(--mbu-divider); background: var(--mbu-bg); flex-shrink: 0; } .ii-sxp-foot a { font-size: 11.5px; font-weight: 600; color: var(--mbu-accent-text); text-decoration: none; } .ii-sxp-foot a:hover { text-decoration: underline; } /* ────────────────────────────────────────────────────────────────── MOBILE / NARROW VIEWPORTS MusicBrainz serves width=device-width, so phones render the modal at ~96vw of a small viewport. The desktop header (title + tool buttons on ONE flex row) then squeezes the title into a one-word-wide column, and the fixed-width table (560px New-ISRC col) overflows horizontally. Below ~700px we grow the modal, stack the header, turn every track row into a full-width card, and let the toolbar/footer wrap. ────────────────────────────────────────────────────────────────── */ @media (max-width: 700px) { /* Keep the modal CENTERED (inherit the base left:50% + translateX). Do NOT pin it to 0,0 / 100vw: on tablets where MusicBrainz's desktop layout overflows a narrow layout-viewport, a position:fixed 100vw / left:0 modal is sized to the layout viewport and lands in the top-left corner instead of filling the screen. A centered 96vw × 96vh dialog reads correctly everywhere. */ #ii-modal { top: 2vh !important; width: 96vw !important; max-width: 96vw !important; height: 96vh !important; max-height: 96vh !important; } @supports (height: 100dvh) { #ii-modal { height: 96dvh !important; max-height: 96dvh !important; } } /* header: title gets its own row (subtitle truncates), tool buttons wrap below it, close pinned to the top-right corner */ #ii-hdr { flex-wrap: wrap; position: relative; padding: 10px 12px 8px; gap: 6px; } #ii-hdr h2 { flex: 1 1 100%; font-size: 14px; padding-right: 30px; min-width: 0; display: flex; align-items: center; } #ii-hdr h2 em { white-space: nowrap; flex-shrink: 0; } #ii-hdr .ii-sub { flex: 1 1 auto; min-width: 0; margin-left: 6px; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } #ii-hdr .ii-tbtn { font-size: 11px; padding: 4px 9px; } #ii-close { position: absolute; top: 5px; right: 9px; font-size: 22px; padding: 2px 6px; } /* toolbar: tighten, keep "Clear entered" pushed to the right */ #ii-tools { padding: 7px 12px; gap: 5px; } .ii-exact-set { gap: 7px; } /* table → one full-width card per track row (CSS-only, no DOM change). #471: ISRC and Links columns are always both present now (7 cells per row instead of 5), so every cell gets its own explicit grid-row by nth-child — a class-based selector on an inner div (the old .ii-existing rule) is NOT a grid item and silently does nothing; only the itself, as the grid's direct child, can be positioned. That dead selector is why the Links-scope cells fell back to browser auto-placement and their icon lists collapsed to a near-zero-width column, wrapping one icon per line (#476's screenshot). */ #ii-table { display: block; } #ii-table thead, #ii-table colgroup { display: none; } #ii-table tbody { display: block; } #ii-table tr:not(.ii-medrow) { display: grid; grid-template-columns: 24px 1fr auto; align-items: start; column-gap: 8px; row-gap: 4px; padding: 9px 12px; border-bottom: 1px solid var(--mbu-border); } #ii-table tr:not(.ii-medrow) > td { display: block; border: none !important; padding: 0; } #ii-table tr:not(.ii-medrow) > td:nth-child(1) { grid-column: 1; grid-row: 1; } /* # */ #ii-table tr:not(.ii-medrow) > td:nth-child(2) { grid-column: 2; grid-row: 1; } /* track */ #ii-table tr:not(.ii-medrow) > td:nth-child(3) { grid-column: 3; grid-row: 1; text-align: right; } /* dur */ #ii-table tr:not(.ii-medrow) > td:nth-child(4) { grid-column: 2 / 4; grid-row: 2; width: auto; } /* existing ISRC */ #ii-table tr:not(.ii-medrow) > td:nth-child(5) { grid-column: 2 / 4; grid-row: 3; } /* new ISRC */ #ii-table tr:not(.ii-medrow) > td:nth-child(6) { grid-column: 2 / 4; grid-row: 4; } /* linked */ #ii-table tr:not(.ii-medrow) > td:nth-child(7) { grid-column: 2 / 4; grid-row: 5; } /* add */ #ii-table tr.ii-medrow { display: block; } #ii-table tr.ii-medrow td { display: block; } /* paint the whole card (not just cells, which would leave striped gaps) */ #ii-table tr.ii-row-missing { background: var(--mbu-warn-bg); box-shadow: inset 3px 0 0 var(--mbu-warn); } #ii-table tr.ii-row-missing > td { background: transparent; box-shadow: none; } /* let the New-ISRC input grow to the card width; candidates span it */ .ii-input-box { flex: 1 1 auto; width: auto; min-width: 0; } .ii-inwrap { flex-wrap: wrap; } .ii-cands { width: 100%; } /* footer wraps: summary on its own line, action buttons below it */ #ii-foot { height: auto; min-height: 52px; flex-wrap: wrap; padding: 8px 12px; gap: 6px; } #ii-foot .ii-summary { flex: 1 1 100%; font-size: 11.5px; } #ii-foot .ii-tbtn { font-size: 11px; padding: 6px 10px; flex: 0 1 auto; } #ii-body { padding-bottom: 108px; } /* clear the taller wrapped footer */ /* secondary panels: keep them on-screen */ #ii-sxpanel { top: 2vh !important; left: 2vw !important; right: 2vw !important; width: auto !important; max-width: none !important; max-height: 92vh !important; } } `; // @run-at document-start (needed for the Spotify harvester) can fire before // / exist; the MB-side editor only needs the DOM, so defer to ready. function whenDomReady(fn) { if (document.head || document.body) fn(); else document.addEventListener('DOMContentLoaded', fn, { once: true }); } whenDomReady(() => (document.head || document.documentElement).appendChild(style)); /* ═══════════════════════════════════════════════════════════════════════ RELEASE MODEL (single WS2 fetch → everything) ═══════════════════════════════════════════════════════════════════════ */ let RELEASE = null; // { title, tracks:[{recId, title, artist, dur, mediumPos, trackPos, existing:[], pending:''}], deezerId, spotifyId } // Recognise an album/release provider link → { k: RELEASE field, v: id/url }, or // null. One place so the per-release parse and the release-group scan (#302) agree. function matchProviderLink(u) { let m; // #640: the release's YouTube Music album — its tracklist resolves the per-track links if ((m = u.match(/music\.youtube\.com\/(?:playlist\?(?:[^#]*&)?list=(OLAK5uy_[\w-]+)|browse\/(MPREb_[\w-]+))/i))) return { k: 'ytmUrl', v: m[1] ? 'https://music.youtube.com/playlist?list=' + m[1] : 'https://music.youtube.com/browse/' + m[2] }; // #644: the release's Amazon Music album — its tracklist resolves the per-track links if ((m = u.match(/^https?:\/\/music\.amazon\.([a-z.]+)\/albums\/([A-Z0-9]{10})/i))) return { k: 'amzUrl', v: 'https://music.amazon.' + m[1].toLowerCase() + '/albums/' + m[2].toUpperCase() }; if ((m = u.match(/^https?:\/\/[a-z0-9-]+\.bandcamp\.com\/album\/[^?#]+/i))) return { k: 'bandcampUrl', v: m[0] }; // #300: per-track URLs by position if ((m = u.match(/^(https?:\/\/music\.apple\.com\/[a-z]{2}\/album\/(?:[^/?#]+\/)?\d+)/i))) return { k: 'appleUrl', v: m[1] }; // album page ld+json lists every track URL // Legacy iTunes links are equivalent to Apple Music — normalise to the canonical // music.apple.com album URL so the same fetch path handles them (#436). if ((m = u.match(/itunes\.apple\.com\/(?:([a-z]{2})\/)?album\/(?:[^/?#]+\/)?id(\d+)/i))) return { k: 'appleUrl', v: 'https://music.apple.com/' + (m[1] || 'us').toLowerCase() + '/album/' + m[2] }; if ((m = u.match(/open\.spotify\.com\/album\/([A-Za-z0-9]+)/))) return { k: 'spotifyId', v: m[1] }; if ((m = u.match(/deezer\.com\/(?:[a-z]{2}\/)?album\/(\d+)/))) return { k: 'deezerId', v: m[1] }; if ((m = u.match(/beatport\.com\/release\/[^/]+\/(\d+)/))) return { k: 'beatportId', v: m[1] }; if ((m = u.match(/(?:listen\.)?tidal\.com\/(?:browse\/)?album\/(\d+)/))) return { k: 'tidalId', v: m[1] }; if ((m = u.match(/volumo\.com\/album\/(\d+)/))) return { k: 'volumoId', v: m[1] }; // id or leading ICPN // Qobuz album id is the last path segment (…/album//, or the slug-less open form …/album/) if ((m = u.match(/qobuz\.com\/(?:[a-z]{2}-[a-z]{2}\/)?album\/(?:[^/?#]+\/)?([a-z0-9]{8,})/i))) return { k: 'qobuzId', v: m[1] }; // HDtracks: new #/album/<24-hex ObjectId> resolves directly; legacy 5009 rels carry the // UPC in valbum_code (fetchHDtracks resolves it via barcode search). Slug-id / artist-page // legacy forms have no clean id mapping and are skipped (Platform Check handles by barcode). if ((m = u.match(/hdtracks\.com\/(?:#\/)?album\/([a-f0-9]{24})/i))) return { k: 'hdtracksId', v: m[1] }; if ((m = u.match(/hdtracks\.com\/[^?]*[?&]valbum_code=(\d{8,})/i))) return { k: 'hdtracksId', v: m[1] }; // SoundCloud SET (playlist) = album, or a bare TRACK url = a single-track release // (#439, chaban-mb); the whole permalink URL is the id (api-v2 resolves either). #439 if ((m = u.match(/^(https?:\/\/(?:www\.|m\.)?soundcloud\.com\/[^/?#]+\/(?:sets\/)?[^/?#]+)/i))) return { k: 'scUrl', v: m[1] }; // Audiomack album, or a song = a single-track release (#664); the canonical URL is the id if ((m = u.match(/^https?:\/\/(?:www\.)?audiomack\.com\/([^/?#]+)\/(album|song)\/([^/?#]+)/i))) return { k: 'akUrl', v: 'https://audiomack.com/' + m[1] + '/' + m[2].toLowerCase() + '/' + m[3] }; // 7digital release (#669): the id ends its slug; the link (with its store) is what's searched with if ((m = u.match(/^(https?:\/\/(?:[a-z]+\.)?(?:7digital\.com|zdigital\.com\.au)\/artist\/[^/?#]+\/release\/(?:[^/?#]*-)?\d{3,})\/?(?:[?#]|$)/i))) return { k: 'sdUrl', v: m[1] }; return null; } const rgProvidersEnabled = () => !!store.get('rg_providers', false); // #314: by default respect Platform Check's link confidence — don't import from a // PC link it withheld by a barcode/format mismatch (it can be a wrong release). // Opt in to the old #211 behaviour (use it anyway) with this toggle. const ignorePcConfidence = () => !!store.get('ignore_pc_confidence', false); // #603: which service turns a Spotify album link into ISRCs — molla (default, // JSON) or ISRC Hunt (the original, HTML scrape; down since #596). const spotifyIsrcSource = () => store.get('spotify_isrc_source', 'molla') === 'isrchunt' ? 'isrchunt' : 'molla'; // #302: releases in a release group are often split by platform (one has Deezer, // another Spotify, …). ISRC and track-link edits target recordings, which are // shared across the RG — so, when the option is on, fill any provider link the // current release lacks from its sibling releases (one browse, fill-if-empty). async function augmentProvidersFromRG(rgId) { if (!rgId) return; try { const r = await gmGet(MB_WS2 + 'release?release-group=' + rgId + '&inc=url-rels&limit=100&fmt=json', { 'Accept': 'application/json', 'User-Agent': UA }); if (r.status !== 200) { Log.warn('Release group: sibling scan gave ' + r.status); return; } const j = JSON.parse(r.responseText || '{}'); const added = []; RELEASE.rgFrom = RELEASE.rgFrom || {}; // provider field -> { release, title } it was pulled from (#302) (j.releases || []).forEach(rel => { if (rel.id === mbid) return; (rel.relations || []).forEach(rl => { const u = rl.url && rl.url.resource; if (!u) return; const hit = matchProviderLink(u); if (hit && !RELEASE[hit.k]) { RELEASE[hit.k] = hit.v; RELEASE.rgFrom[hit.k] = { release: rel.id, title: rel.title || '' }; added.push(hit.k.replace(/Id|Url$/, '')); } }); }); if (added.length) Log.info('Release group: pulled provider links from sibling releases — ' + [...new Set(added)].join(', ')); else Log.info('Release group: no extra provider links on sibling releases'); } catch (e) { Log.warn('Release group provider scan failed: ' + errText(e)); } } /* #587 (majkinetor: "Dedup it."). The overview page fetches the release once on load to prime the button's missing-ISRC count, and opening the panel fetched it AGAIN — `if (!RELEASE)` is still true while the first one is in flight, so a click during that window started a second identical request. Two calls for the same data on every open. Resolved, not re-requested: an already-loaded release is handed back as-is, and a call made while one is in flight joins it. The in-flight handle is cleared on settle so a FAILED load can still be retried. */ let _relInFlight = null; function fetchRelease() { if (RELEASE) return Promise.resolve(RELEASE); if (_relInFlight) return _relInFlight; _relInFlight = fetchReleaseNow(); _relInFlight.catch(() => {}).then(() => { _relInFlight = null; }); // #607: the page button's count follows EVERY successful load, not just the // page-load one. That one can fail (MB 503s under its rate limit) and leave // "?" — and opening the dialog later loaded the release without telling it. _relInFlight.then(() => { try { updateBtnStatus(); } catch (e) {} }, () => {}); return _relInFlight; } function fetchReleaseNow() { return gmGet( // recording-level-rels folds each recording's URL relationships into this one // call (same data the overview's "Display credits inline" shows) — so the // #219 track-link icons know what's already linked with no extra request. MB_WS2 + 'release/' + mbid + '?inc=recordings+artist-credits+isrcs+url-rels+recording-level-rels+release-groups&fmt=json', { 'Accept': 'application/json', 'User-Agent': UA } ).then(async r => { if (r.status !== 200) throw new Error('MB ' + r.status); const data = JSON.parse(r.responseText); const tracks = []; (data.media || []).forEach(med => { (med.tracks || []).forEach(trk => { const rec = trk.recording || {}; tracks.push({ recId: rec.id || '', title: trk.title || rec.title || '', artist: acName(trk['artist-credit'] || rec['artist-credit']), dur: msToMmSs(trk.length || rec.length) || '', mediumPos: med.position, mediumTitle: med.title || '', trackPos: trk.position, number: trk.number, existing: (rec.isrcs || []).slice(), recUrls: (rec.relations || []).map(rel => rel.url && rel.url.resource).filter(Boolean), // #219: existing url rels on the recording endedUrls: new Set((rec.relations || []).filter(rel => rel.url && rel.url.resource && rel.ended).map(rel => rel.url.resource)), // #341: url rels already marked ended → render faded pending: '', }); }); }); const rels = data.relations || []; const prov = { deezerId: null, spotifyId: null, beatportId: null, tidalId: null, volumoId: null, hdtracksId: null, qobuzId: null, bandcampUrl: null, appleUrl: null, scUrl: null, ytmUrl: null, amzUrl: null }; rels.forEach(rel => { const u = rel.url && rel.url.resource; if (!u) return; const hit = matchProviderLink(u); if (hit && !prov[hit.k]) prov[hit.k] = hit.v; // first match per provider }); // THIS release's year — what the header shows AND what the SX "recording newer // than the release" check uses. Prefer the release's own date; only fall back to // the release-group's first-release-date when this release has no date. (Using // the RG-earliest here was wrong for reissues: a 2025 reissue of 2002 material // would reject legitimate later recordings.) const rg = data['release-group'] || {}; const rgYear = parseInt((String(rg['first-release-date'] || '').match(/^(\d{4})/) || [])[1]) || null; const releaseYear = parseInt((String(data.date || '').match(/^(\d{4})/) || [])[1]) || rgYear; const artist = acName(data['artist-credit']); RELEASE = Object.assign({ title: data.title || '', tracks, rgId: rg.id || '', releaseYear, artist }, prov); // #302: when enabled, fill missing provider links from sibling releases in the RG. if (rgProvidersEnabled() && RELEASE.rgId) await augmentProvidersFromRG(RELEASE.rgId); const _pf = { bandcamp: 'bandcampUrl', apple: 'appleUrl', soundcloud: 'scUrl', audiomack: 'akUrl', sevendigital: 'sdUrl' }; const linkStr = ['deezer', 'spotify', 'beatport', 'tidal', 'volumo', 'hdtracks', 'bandcamp', 'apple', 'soundcloud', 'audiomack', 'sevendigital'] .map(k => { const f = _pf[k] || k + 'Id'; return RELEASE[f] ? k[0].toUpperCase() + k.slice(1) + ' ' + RELEASE[f] : null; }).filter(Boolean).join(', '); Log.info('Release "' + RELEASE.title + '"' + (releaseYear ? ' (' + releaseYear + ')' : '') + ': ' + tracks.length + ' track(s), ' + tracks.filter(t => !t.existing.length).length + ' missing ISRC' + (linkStr ? '; links: ' + linkStr : '')); return RELEASE; }); } function acName(ac) { if (!Array.isArray(ac)) return ''; return ac.map(c => (c.name || (c.artist && c.artist.name) || '') + (c.joinphrase || '')).join(''); } /* #587 (majkinetor): "Revert it. If there is no way to get pending status while getting ISRC, we wont do any more requests to MB and will not have this feature. … Remembering is not good idea as this is colaborative space." Both halves are gone, and neither comes back without his say-so: · the ⏳ used to be persisted in localStorage and replayed on later loads. It was a LOCAL belief about SERVER state, and this is a shared database — he cancels the edit, or another editor votes it down, and the marker is silently wrong for good. That is exactly what he hit. · verifying it against /recording//open_edits fixed the staleness by spending one request per remembered recording. Not my call to make: ANY new request to MusicBrainz is his decision. WS2 exposes no pending-edit field and no inc= that adds one — an entity's open edits live only on MB's HTML pages — so the release fetch already being made cannot carry it, and by his instruction the feature goes rather than the request count. What survives is only what we know without asking anyone: within the session that submitted them, those removals are marked. A reload forgets, because by then only MusicBrainz knows. */ /* ═══════════════════════════════════════════════════════════════════════ OAUTH (one-time authorize, offline refresh token) ═══════════════════════════════════════════════════════════════════════ */ const Auth = { // baked-in shared app, with an optional GM-storage override for power users clientId() { return store.get('oauth_client_id', '') || OAUTH.clientId; }, clientSecret() { return store.get('oauth_client_secret', '') || OAUTH.clientSecret; }, refreshTok() { return store.get('oauth_refresh_token', ''); }, isAuthorized() { return !!this.refreshTok(); }, authorizeUrl() { const p = new URLSearchParams({ response_type: 'code', client_id: this.clientId(), redirect_uri: OAUTH.redirect, scope: OAUTH.scope, access_type: 'offline', }); return OAUTH.authUrl + '?' + p.toString(); }, async exchangeCode(code) { const body = new URLSearchParams({ grant_type: 'authorization_code', code: code.trim(), client_id: this.clientId(), client_secret: this.clientSecret(), redirect_uri: OAUTH.redirect, }).toString(); const r = await gmPost(OAUTH.tokenUrl, body, { 'Content-Type': 'application/x-www-form-urlencoded' }); const j = JSON.parse(r.responseText || '{}'); if (!j.refresh_token) throw new Error(j.error_description || j.error || ('token exchange failed (' + r.status + ')')); store.set('oauth_refresh_token', j.refresh_token); store.set('oauth_access_token', j.access_token || ''); store.set('oauth_access_expiry', Date.now() + ((j.expires_in || 3600) * 1000)); }, async accessToken() { const tok = store.get('oauth_access_token', ''); const exp = store.get('oauth_access_expiry', 0); if (tok && Date.now() < exp - 60000) return tok; const refresh = this.refreshTok(); if (!refresh) throw new Error('not authorized — open ⚙ Setup'); const body = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refresh, client_id: this.clientId(), client_secret: this.clientSecret(), }).toString(); const r = await gmPost(OAUTH.tokenUrl, body, { 'Content-Type': 'application/x-www-form-urlencoded' }); const j = JSON.parse(r.responseText || '{}'); if (!j.access_token) throw new Error(j.error_description || j.error || ('token refresh failed (' + r.status + ')')); store.set('oauth_access_token', j.access_token); store.set('oauth_access_expiry', Date.now() + ((j.expires_in || 3600) * 1000)); return j.access_token; }, signOut() { store.del('oauth_refresh_token'); ['oauth_access_token', 'oauth_access_expiry'].forEach(store.del); }, }; /* ═══════════════════════════════════════════════════════════════════════ WS2 ISRC SUBMISSION ═══════════════════════════════════════════════════════════════════════ */ function buildIsrcXml(map, editNote) { let x = '\n' + '\n'; if (editNote) x += ' ' + esc(editNote) + '\n'; x += '\n'; for (const [rid, isrcs] of Object.entries(map)) { x += ' '; isrcs.forEach(i => { x += ''; }); x += '\n'; } x += '\n'; return x; } async function submitIsrcs(map, editNote) { const token = await Auth.accessToken(); const xml = buildIsrcXml(map, editNote); const url = MB_WS2 + 'recording/?client=' + CLIENT; const r = await gmPost(url, xml, { 'Content-Type': 'application/xml; charset=utf-8', 'Authorization': 'Bearer ' + token, 'Accept': 'application/xml', }); if (r.status === 200) return; throw new Error('submit failed (' + r.status + '): ' + (r.responseText || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 220)); } /* ═══════════════════════════════════════════════════════════════════════ SOUNDEXCHANGE (ported from magicisrc_soundexchange, DOM-independent core) ═══════════════════════════════════════════════════════════════════════ */ const SX = (function () { let _token = 'ff5284e764c4a90c1a2c2940f6a9aa593c63b8e8'; let _tokenFetch = null; function extractToken(text) { const pats = [/[Tt]oken ([a-f0-9]{40})/, /["'](Token [a-f0-9]{40})["']/, /([a-f0-9]{40})/]; for (const p of pats) { const m = text.match(p); if (m) { const h = (m[1] || m[0]).match(/[a-f0-9]{40}/); if (h) return h[0]; } } return null; } function refreshToken() { if (_tokenFetch) return _tokenFetch; _tokenFetch = gmGet(SX_HOME, { 'Accept': 'text/html', 'User-Agent': 'Mozilla/5.0' }) .then(r1 => { _tokenFetch = null; if (r1.status !== 200) throw new Error('SX home ' + r1.status); const inline = extractToken(r1.responseText); if (inline) { _token = inline; return _token; } const urls = []; const re = /]+src=["']([^"']+\.js[^"']*)["']/gi; let m; while ((m = re.exec(r1.responseText)) !== null) { urls.push(m[1].startsWith('http') ? m[1] : SX_HOME.replace(/\/$/, '') + m[1]); } urls.sort((a, b) => (/entry|main/i.test(b) ? 1 : 0) - (/entry|main/i.test(a) ? 1 : 0)); return (async () => { for (const url of urls.slice(0, 15)) { try { const r2 = await gmGet(url, { 'Accept': '*/*', 'Referer': SX_HOME }); if (r2.status !== 200) continue; const tok = extractToken(r2.responseText); if (tok) { _token = tok; return _token; } } catch (e) {} } throw new Error('SX token not found'); })(); }).catch(e => { _tokenFetch = null; throw e; }); return _tokenFetch; } function fields(item) { return { isrc: item.isrc || '', title: item.recordingTitle || '', artist: item.recordingArtistName || '', version: item.recordingVersion || '', year: item.recordingYear || '', dur: item.duration || '', relTitle: item.releaseName || '', relLabel: item.releaseLabel || '', relDate: (item.releaseDate || '').slice(0, 7), }; } // #486: MB often encodes the edit/version in a trailing "(...)" on the title (e.g. // "Sing (radio edit)"), but SoundExchange keeps that in a separate `version` field // and returns the SAME bare title ("Sing") for every edit — so title/artist alone // (and often duration too — that's the whole reason this is hard) can't tell two // edits of the same recording apart, and the first-ranked "best" result silently // won for every edit that shares a title. Only downgrades when SX actually GIVES us // version text that clearly names a different edit — missing/messy SX version data // (common per chaban) is left alone rather than penalized on absent information. function versionHint(mbTitle) { const m = unfeat(mbTitle).match(/\(([^)]+)\)\s*$/); return m ? norm(m[1]) : ''; } function versionConflicts(f, mbTitle) { const hint = versionHint(mbTitle); if (!hint || !f.version) return false; const v = norm(f.version); return !(v.includes(hint) || hint.includes(v) || wordsMatch(hint, v) || wordsMatch(v, hint)); } function classify(f, mbTitle, mbArtist, mbDurStr, mbYear) { if (!isGoodMatch(f.title, f.artist, mbTitle, mbArtist)) return 'other'; // a recording released after MB's release year (with 1y tolerance) can't be // the source of this release's ISRC — treat as a non-match if (mbYear && f.year && parseInt(f.year) > mbYear + 1) return 'other'; if (versionConflicts(f, mbTitle)) return 'warn'; const a = durToSec(mbDurStr), b = durToSec(f.dur); if (a !== null && b !== null && Math.abs(a - b) > 10) return 'warn'; // #661: the same song with a typo in a title (one or the other) agrees, but never as a sure // match: nothing is picked on a typo alone if (titleClose(f.title, mbTitle, true) !== true) return 'warn'; return 'best'; } const applyExact = (v, exact) => (v && exact) ? '"' + String(v).replace(/"/g, '') + '"' : (v || ''); function dedupe(raw) { const seen = new Map(); raw.forEach(item => { const key = item.isrc || item.id; if (!seen.has(key)) seen.set(key, Object.assign({}, item, { _rels: [] })); if (item.releaseName) seen.get(key)._rels.push(item); }); const rows = [...seen.values()]; rows.forEach(it => { if (it._rels.length > 1) it._rels.sort((a, b) => (a.releaseDate || '9999').localeCompare(b.releaseDate || '9999')); const e = it._rels[0]; if (e) { it.releaseName = e.releaseName; it.releaseLabel = e.releaseLabel; it.releaseDate = e.releaseDate; } }); return rows; } function post(body) { const doReq = (token) => gmPost(SX_API, body, { 'Content-Type': 'application/json', 'Accept': 'application/json', 'Authorization': 'Token ' + token, 'Origin': SX_HOME, 'Referer': SX_HOME, }).then(r => { if (r.status === 0) throw new Error('blocked (SX returned 0 — connection refused?)'); if (r.status === 401 || r.status === 403) { Log.warn('SX ' + r.status + ' — refreshing token'); return refreshToken().then(t => doReq(t)); } // 429 = rate limited. The body isn't JSON, so without this it fell through to the // generic "SX parse error" below — masking the real cause. Surface a typed error so // callers can stop the batch and show the right message. #126 if (r.status === 429) { const e = new Error('SoundExchange rate limit (HTTP 429)'); e.rateLimited = true; throw e; } let p; try { p = JSON.parse(r.responseText); } catch (e) { throw new Error('SX parse error'); } // After too many requests SoundExchange serves a captcha: HTTP 202 with // body {"searchCaptcha": true}. It's NOT an empty result — caching it as // "not found" left rows permanently stuck. Surface a typed error so // callers pause (like a rate limit) and prompt the user to solve it. #157 if (p && p.searchCaptcha) { const e = new Error('SoundExchange captcha'); e.captcha = true; throw e; } return dedupe(p.recordings || p.results || p.data || (Array.isArray(p) ? p : [])); }); return doReq(_token); } // exact = { title, artist, release }; opts release string optional function apiSearch(title, artist, start, count, exact, release) { exact = exact || {}; return post(JSON.stringify({ searchFields: { recordingArtistName: { value: applyExact(artist, exact.artist) }, recordingTitle: { value: applyExact(title, exact.title) }, releaseName: { value: applyExact(release || '', exact.release) }, }, start: start || 0, number: count || 20, showReleases: true, })); } function apiSearchByIsrc(isrc) { return post(JSON.stringify({ searchFields: { isrc: isrc }, start: 0, number: 10, showReleases: true })); } return { refreshToken, apiSearch, apiSearchByIsrc, fields, classify }; })(); if (mbuTestHooks()) window.__isrcScoutTestSX = SX; // test hook only (#486) — no behaviour change /* ═══════════════════════════════════════════════════════════════════════ TRACK ISRC PROVIDER (#181) — each per-track [SX] button is a by-ISRC lookup: it takes the row's ISRC (entered or existing) and looks it up on the selected provider, showing that track's metadata next to the row. It ONLY searches — it never fills the field. The choice is GLOBAL for the release and NOT remembered (resets to SoundExchange on each load). The ▾ on each button opens the provider menu; the bulk "⟳ SoundExchange" button is left untouched. • SoundExchange + Deezer + Tidal: global by-ISRC endpoints — offered on any release (no album link needed). • Beatport / Volumo / HDtracks: read the release's album ONCE (cached) and match by ISRC, so they need the album's link (MB rel or Platform Check). ═══════════════════════════════════════════════════════════════════════ */ // Album-based providers — same fetchers the import buttons use. `idField` is the // RELEASE property holding the in-MB album id; availability also honours a // Platform-Check-found URL (via providerAlbumId). // Spotify is intentionally absent: its only by-ISRC route is the Web API // `isrc:` search, which needs an app token (no free anonymous one), so it can't // be a per-track ISRC provider. (Spotify stays a bulk import button via molla / ISRC Hunt.) const ALBUM_PROVIDERS = { deezer: { source: 'Deezer', idField: 'deezerId', fetcher: fetchDeezer, code: 'dz' }, beatport: { source: 'Beatport', idField: 'beatportId', fetcher: fetchBeatport, code: 'bp' }, tidal: { source: 'Tidal', idField: 'tidalId', fetcher: fetchTidal, code: 'td' }, volumo: { source: 'Volumo', idField: 'volumoId', fetcher: fetchVolumo, code: 'vo' }, hdtracks: { source: 'HDtracks', idField: 'hdtracksId', fetcher: fetchHDtracks, code: 'hd' }, qobuz: { source: 'Qobuz', idField: 'qobuzId', fetcher: fetchQobuz, code: 'qz' }, apple: { source: 'Apple', idField: 'appleUrl', fetcher: fetchApple, code: 'am' }, // #435 anonymous amp-api soundcloud:{ source: 'SoundCloud', idField: 'scUrl', fetcher: fetchSoundcloud, code: 'sc' }, // #439 set → publisher_metadata.isrc audiomack: { source: 'Audiomack', idField: 'akUrl', fetcher: fetchAudiomack, code: 'ak' }, // #664 signed API, isrc per track sevendigital: { source: '7digital', idField: 'sdUrl', fetcher: fetchSevendigital, code: 'sd' }, // #669 track/search per track, isrc per hit }; const _PROV_COLOR = { sx: stColor('soundexchange'), deezer: stColor('deezer'), spotify: stColor('spotify'), beatport: stColor('beatport'), tidal: stColor('tidal'), volumo: stColor('volumo'), hdtracks: stColor('hdtracks'), qobuz: stColor('qobuz'), bandcamp: stColor('bandcamp'), apple: stColor('apple'), soundcloud: stColor('soundcloud'), audiomack: stColor('audiomack'), sevendigital: stColor('sevendigital'), ytmusic: stColor('ytmusic'), amazonmusic: stColor('amazonmusic') }; // #404: colours from the shared registry const TRACK_PROV = { sx: { name: 'SoundExchange', short: 'SX', code: 'sx', color: _PROV_COLOR.sx, kind: 'search' } }; Object.keys(ALBUM_PROVIDERS).forEach(k => { const p = ALBUM_PROVIDERS[k]; // `global` providers have a by-ISRC endpoint that doesn't need the album link // (Deezer: /track/isrc:; Tidal: /tracks?filter[isrc]), so they're offered // on any release. The rest scan the release's album, so they need its link. TRACK_PROV[k] = { name: p.source, short: p.source, code: p.code, color: _PROV_COLOR[k] || '#444', kind: 'album', global: k === 'deezer' || k === 'tidal' }; }); // #643: 'all' — every provider at once. Not SoundExchange: it serves a captcha on nearly every // run, so majkinetor took it out ("I was wrong about SX, it constantly does captcha"). TRACK_PROV.all = { name: 'All', short: 'All', code: 'all', color: '', kind: 'all' }; const TRACK_PROV_ORDER = ['all', 'sx', 'deezer', 'tidal', 'beatport', 'volumo', 'hdtracks', 'qobuz', 'apple', 'soundcloud', 'audiomack', 'sevendigital']; let trackProv = 'sx'; // NOT persisted (#181) const TPM = () => TRACK_PROV[trackProv]; // a provider is offered only when it can resolve a source for THIS release // (SoundExchange always; an album provider needs an MB link or a PC-found URL). function trackProvAvailable(key) { if (key === 'sx' || key === 'all') return true; if (TRACK_PROV[key] && TRACK_PROV[key].global) return true; // global by-ISRC — no link needed const p = ALBUM_PROVIDERS[key]; return !!(p && RELEASE && providerAlbumId(p.source, RELEASE[p.idField])); } // The per-track button looks up an ISRC, so it needs one: enabled when the row // has a valid entered ISRC or an existing one (uniform across all providers). function trackBtnDisabled(t, inputVal) { return !(isValidIsrc(normalizeIsrc(inputVal)) || (t && t.existing && t.existing.length)); } // Fetch the selected album provider's whole tracklist ONCE (all batches), keyed // per provider for the session. Returns the collected per-track ISRC entries. const _provAlbumEntries = {}; const _provAlbumPromise = {}; function ensureProvAlbum(key) { if (_provAlbumEntries[key]) return Promise.resolve(_provAlbumEntries[key]); if (_provAlbumPromise[key]) return _provAlbumPromise[key]; const p = ALBUM_PROVIDERS[key]; const id = providerAlbumId(p.source, RELEASE[p.idField]); if (!id) return Promise.reject(new Error('no ' + p.source + ' link on this release')); const entries = []; _provAlbumPromise[key] = (async () => { let cursor = 0, guard = 0; while (guard++ < 50) { const res = await p.fetcher(id, () => {}, s => { if (s && s.isrc) entries.push(s); }, cursor); if (!res || res.next == null) break; cursor = res.next; } _provAlbumEntries[key] = entries; delete _provAlbumPromise[key]; return entries; })().catch(e => { delete _provAlbumPromise[key]; throw e; }); return _provAlbumPromise[key]; } // Single-track by-ISRC lookup on a provider → a fields-shaped object // {isrc,title,artist,year,dur,relTitle} carrying all the meta the provider // exposes, or null. Deezer has a global by-ISRC endpoint (no album needed); the // album-only providers scan the release's album for a track whose ISRC matches // (still keyed off the ISRC, never the position). SoundExchange uses lookupIsrc. async function providerLookupByIsrc(key, isrc) { if (key === 'deezer') { const r = await gmGet('https://api.deezer.com/track/isrc:' + encodeURIComponent(isrc), { 'Accept': 'application/json' }); if (r.status !== 200) return null; let j; try { j = JSON.parse(r.responseText || 'null'); } catch (e) { return null; } if (!j || j.error || !normalizeIsrc(j.isrc || '')) return null; const arts = [(j.artist && j.artist.name)].concat((j.contributors || []).map(c => c && c.name)).filter(Boolean); return { isrc: normalizeIsrc(j.isrc), title: j.title_short || j.title || '', artist: [...new Set(arts)].join(', '), year: '', dur: j.duration ? msToMmSs(j.duration * 1000) : '', relTitle: (j.album && j.album.title) || '' }; } if (key === 'tidal') return await tidalLookupByIsrc(isrc); // album-scoped providers (HDtracks / Volumo / Beatport): // fetch the release's album once and match the entry by ISRC. const entries = await ensureProvAlbum(key); const e = entries.find(s => normalizeIsrc(s.isrc) === isrc); return e ? { isrc, title: e.title || '', artist: e.artist || '', year: '', dur: e.dur || '', relTitle: '' } : null; } // Per-track ISRC lookup on the selected album provider — shows the provider's // meta for the clicked track's ISRC, rendered like a SoundExchange result. It // looks up ONLY that ISRC and NEVER fills the field. async function lookupRowOnProvider(idx, isrc) { const t = RELEASE.tracks[idx], m = TPM(), el = rowLookup(idx); if (el) { el.onclick = null; el.className = 'ii-lookup spin'; el.textContent = '⏳ ' + m.name + '…'; } try { const f = await providerLookupByIsrc(trackProv, isrc); if (!f) { if (el) { el.className = 'ii-lookup err'; el.textContent = '✗ ' + isrc + ' not found on ' + m.name; } Log.info(m.name + ' ' + isrc + ' (#' + (t.number || t.trackPos) + '): not found'); return; } const cls = SX.classify(f, t.title, t.artist, t.dur, RELEASE.releaseYear); const good = cls === 'best'; const rel = [f.relTitle, f.relLabel, f.relDate].filter(Boolean).join(' · '); if (el) { el.className = 'ii-lookup ' + (good ? 'ok' : 'warn'); el.innerHTML = (good ? '✓ ' : '⚠ ') + sxMetaHtml(f, t) + (rel ? '
' + esc(rel) + '' : ''); el.title = [f.title, f.artist, f.year, f.dur].filter(Boolean).join(' · ') + (rel ? ' | ' + rel : ''); } Log.info(m.name + ' ' + isrc + ' (#' + (t.number || t.trackPos) + '): "' + f.title + '"' + (f.artist ? ' — ' + f.artist : '')); } catch (err) { if (err && err.rateLimited) { // 429 that didn't clear after back-off — NOT "not found" if (el) { el.className = 'ii-lookup warn'; el.textContent = '⚠ ' + m.name + ' rate-limited — retry'; } Log.warn(m.name + ' ' + isrc + ' (#' + (t.number || t.trackPos) + '): rate-limited (HTTP 429)'); } else { if (el) { el.className = 'ii-lookup err'; el.textContent = '✗ ' + m.name + ' failed'; } Log.err(m.name + ' lookup failed: ' + errText(err)); } } } // #643: "All" — the row's ISRC checked on every provider available for the release at once // (SoundExchange is not one: it serves captchas), summed up in a verdict chip in the row's result slot. Hovering the chip // (or the row's All button) shows the comparison beside the chip; a click pins it. // What each provider can say about an ISRC: // · Deezer, Tidal (a lookup by ISRC): ✓ their song for it is this track · // ⚠ it is another song (title, artist or length differ; guests don't count) · – they don't know it // · the album providers (the release's album on them, read once): ✓ the album has this ISRC at // the track's place (📍) or, with nothing there, elsewhere (↪ track n) · ✗ it has ANOTHER ISRC // at the track's place — offered with [use] · – nothing there const _allRes = {}; // idx → { isrc, lines: [{ key, name, state, isrc?, at?, song?, note? }] } let _allEpoch = 0; // bumped by closing the dialog / Clear: a running All stops const ALL_OK = 'ok', ALL_SONG = 'song', ALL_DIFF = 'differs', ALL_NONE = 'none', ALL_FAIL = 'fail', ALL_WAIT = 'pending', ALL_BLOCK = 'blocked'; const ALL_MARK = { ok: '✓', song: '⚠', differs: '✗', none: '–', fail: '!', pending: '⏳', blocked: '⛔' }; function allProviders() { return TRACK_PROV_ORDER.filter(k => k !== 'all' && k !== 'sx' && trackProvAvailable(k)); } // the album entry at this track's place: disc + position, as an album import maps it (mapOneToTrack) function albumEntryAt(entries, t) { const discs = new Set(RELEASE.tracks.map(x => +x.mediumPos)); return entries.find(s => +s.pos === +t.trackPos && (+s.disc === +t.mediumPos || !discs.has(+s.disc))) || null; } // the number of another track of this release that has the ISRC (on MB, or entered), or '' function isrcOfOtherTrack(idx, isrc) { const i = RELEASE.tracks.findIndex((x, j) => j !== idx && ((x.existing || []).includes(isrc) || normalizeIsrc(x.pending || '') === isrc)); return i < 0 ? '' : String(RELEASE.tracks[i].number || RELEASE.tracks[i].trackPos); } // an album entry's title reads as this track's (the artist isn't compared: album entries carry none) const albumTitleFits = (x, t) => !!x && SX.classify({ title: x.title || '', artist: t.artist, dur: x.dur || '' }, t.title, t.artist, t.dur) !== 'other'; // asked: every ISRC All asks about on this row (an album entry with one of them isn't "another ISRC") async function allOnProvider(key, t, isrc, idx, asked) { const name = TRACK_PROV[key].name; try { if (TRACK_PROV[key].global) { const f = await providerLookupByIsrc(key, isrc); if (!f) return { key, name, of: isrc, state: ALL_NONE, note: 'doesn’t know ' + isrc }; return Object.assign(allSongLine(key, name, f, t, isrc), { of: isrc }); } // The album has the ISRC → it agrees, at this track's position (📍) or another (↪ track n: // the album orders its tracks differently). Only when the ISRC is nowhere on the album is // the ISRC at this track's position offered instead (✗ + use), and never when that one is // another track's of this release or this row's own (#643: a Qobuz album with the mixes in // another order offered track 4's ISRC for track 2). const entries = await ensureProvAlbum(key); const e = albumEntryAt(entries, t), eIsrc = e ? normalizeIsrc(e.isrc) : ''; const other = entries.find(x => normalizeIsrc(x.isrc) === isrc); const where = x => 'track ' + (x.disc && RELEASE.tracks.some(y => +y.mediumPos !== 1) ? x.disc + '.' : '') + x.pos; const song = x => ({ title: x.title || '', artist: '', dur: x.dur || '' }); const line = { key, name, of: isrc }; if (eIsrc === isrc) return Object.assign(line, { state: ALL_OK, isrc, at: 'here', song: song(e) }); // the ISRC elsewhere, and another at this position: by title, which one is this track // (when neither or both read as it, the album having the ISRC wins) if (other && !(eIsrc && albumTitleFits(e, t) && !albumTitleFits(other, t))) return Object.assign(line, { state: ALL_OK, isrc, at: where(other), song: song(other), note: eIsrc ? '' : 'nothing at this track’s place' }); if (eIsrc && !asked.includes(eIsrc)) { const owner = isrcOfOtherTrack(idx, eIsrc); if (owner) return Object.assign(line, { state: ALL_NONE, note: (other ? isrc + ' is its ' + where(other) + '; ' : 'not on the album; ') + 'at this position it has track ' + owner + '’s ISRC (' + eIsrc + ')' }); return Object.assign(line, { state: ALL_DIFF, isrc: eIsrc, at: 'here', song: song(e), note: other ? isrc + ' is its ' + where(other) : '' }); } // left: at this position the album has nothing or this row's other ISRC if (other) return Object.assign(line, { state: ALL_SONG, isrc, at: where(other), song: song(other), note: 'another track' + (eIsrc ? '; at this position it has ' + eIsrc + ', this row’s other ISRC' : '') }); return Object.assign(line, { state: ALL_NONE, note: 'not on the album' + (eIsrc ? ' (at this position: ' + eIsrc + ', this row’s other ISRC)' : '') }); } catch (err) { return { key, name, of: isrc, state: ALL_FAIL, note: err && err.rateLimited ? 'rate-limited — try again' : 'failed: ' + errText(err) }; } } // A provider's song for the ISRC, against the track: the same song (classify 'best', or 'warn' — // the version or length reads differently, which is noted) agrees; another song ('other') doesn't. // Guests are left out of the comparison, as in every lookup (unfeat, mainArtist). function allSongLine(key, name, f, t, isrc) { const cls = SX.classify(f, t.title, t.artist, t.dur, RELEASE.releaseYear); const song = { title: f.title || '', artist: cls === 'other' ? f.artist || '' : '', dur: f.dur || '' }; if (cls === 'other') return { key, name, state: ALL_SONG, isrc, song, note: 'another song' }; const typo = cls === 'warn' && titleClose(f.title, t.title, true) !== true; // #661 return { key, name, state: ALL_OK, isrc, song, note: typo ? 'its title is spelled differently' : cls === 'warn' ? 'its version or length reads differently' : '' }; } // Where an album provider has the ISRC, said plainly (the album providers read the release's album // there, so they can say at which position it sits; Deezer/Tidal/SoundExchange can't) function allAtText(l) { if (l.at === 'here') return l.state === ALL_DIFF ? 'the album’s ISRC at this position' : 'same position on the album'; return l.at ? 'on the album at ' + l.at + ', not this position' : ''; } // a line as text (the log) and as the panel shows it function allLineText(l, isrc) { return [allAtText(l), l.isrc && l.isrc !== isrc ? l.isrc : '', l.song && l.song.title ? '"' + l.song.title + '"' + (l.song.artist ? ' — ' + l.song.artist : '') + (l.song.dur ? ' ' + l.song.dur : '') : '', l.note || ''].filter(Boolean).join(' · '); } function allLineHtml(l, isrc) { const p = []; if (l.at === 'here') p.push('📍'); else if (l.at) p.push('↪ ' + esc(l.at) + ''); if (l.isrc && l.isrc !== isrc) p.push('' + esc(l.isrc) + ''); if (l.song && l.song.title) p.push(esc('"' + l.song.title + '"' + (l.song.artist ? ' — ' + l.song.artist : ''))); // the length has its own column if (l.note) p.push('' + esc(l.note) + ''); return p.join(' '); } // #643: the ISRCs All asks about on a row: the given one (entered, or the first existing) and // every other ISRC the recording already has function allRowIsrcs(idx, isrc) { const t = RELEASE.tracks[idx]; return [...new Set([isrc].concat(t.existing || []).map(normalizeIsrc).filter(isValidIsrc))]; } // One track: every provider at once. const _allInflight = {}; function allLookupRow(idx, isrc, opts) { const k = idx + ' ' + isrc; if (!_allInflight[k]) _allInflight[k] = allLookupRowNow(idx, isrc, opts).finally(() => { delete _allInflight[k]; }); return _allInflight[k]; } async function allLookupRowNow(idx, isrc) { const t = RELEASE.tracks[idx], keys = allProviders(), asked = allRowIsrcs(idx, isrc), num = t.number || t.trackPos; // res.isrc: the row's ISRC (entered, or the first existing), shown first; res.asked: every one asked about const res = _allRes[idx] = { isrc, asked, lines: [].concat(...asked.map(i => keys.map(k => ({ key: k, name: TRACK_PROV[k].name, of: i, state: ALL_WAIT, note: 'asking…' })))) }; renderAllChip(idx); Log.info('All #' + num + ' ' + asked.join(', ') + ': asking ' + keys.map(k => TRACK_PROV[k].name).join(', ')); res.lines = [].concat(...await Promise.all(asked.map(i => Promise.all(keys.map(k => allOnProvider(k, t, i, idx, asked)))))); res.lines.forEach(l => Log.info('All #' + num + ' ' + l.of + ' ' + l.name + ': ' + l.state + ' — ' + allLineText(l, l.of))); renderAllChip(idx); return res; } // The verdict chip: ✓ n/n when every provider that knows the ISRC agrees, ⚠ k/n when some don't, // – 0/m when none knows it, ⏳ while asking, ⛔ when SoundExchange was blocked. Hover shows the // comparison, a click pins it (a click on the pinned one's chip unpins it). function renderAllChip(idx) { const el = rowLookup(idx), res = _allRes[idx]; if (!el || !res) return; const lines = res.lines; const known = lines.filter(l => l.state === ALL_OK || l.state === ALL_SONG || l.state === ALL_DIFF); const ok = known.filter(l => l.state === ALL_OK).length; const waiting = lines.some(l => l.state === ALL_WAIT && !/queued/.test(l.note)); let cls, text; if (waiting && !known.length) { cls = 'spin'; text = '⏳ All…'; } else if (!known.length) { cls = 'none'; text = '– 0/' + lines.length; } else if (ok === known.length) { cls = 'ok'; text = '✓ ' + ok + '/' + known.length; } else { cls = 'warn'; text = '⚠ ' + ok + '/' + known.length; } if (lines.some(l => l.state === ALL_BLOCK)) text += ' ⛔'; el.className = 'ii-lookup ii-all-chip ' + cls; el.textContent = text; el.title = ''; // the comparison is the tooltip el.onclick = (e) => { e.stopPropagation(); const p = modal.querySelector('#ii-all-pop'); if (p && p._pinned && p._idx === idx) closeAllPop(); else openAllPop(idx, { pin: true }); }; el.onmouseenter = () => openAllPop(idx); el.onmouseleave = () => hideAllPopSoon(); const p = modal && modal.querySelector('#ii-all-pop'); if (p && p._idx === idx) renderAllPop(p); // live while it is asking } // The comparison, beside the chip or above its row (placeAllPop) so it covers neither the All // buttons nor the chips below — moving down either column walks the tracks. The header lists // every ISRC the providers named (a click shows only the providers with that one) and [copy] // (the comparison as Markdown); a line per provider: verdict, icon, name, what it has. let _allHideT = 0; function openAllPop(idx, { pin = false } = {}) { if (!modal || !_allRes[idx]) return; clearTimeout(_allHideT); let pop = modal.querySelector('#ii-all-pop'); if (pop && pop._pinned && pop._idx !== idx && !pin) return; // a pinned comparison stays until it is closed if (!pop || pop._idx !== idx) { closeAllPop(); pop = document.createElement('div'); pop.id = 'ii-all-pop'; pop._idx = idx; pop._sel = ''; modal.appendChild(pop); pop.addEventListener('mouseenter', () => clearTimeout(_allHideT)); pop.addEventListener('mouseleave', () => hideAllPopSoon()); pop.addEventListener('click', (e) => allPopClick(pop, e)); renderAllPop(pop); } if (pin && !pop._pinned) allPin(pop); } function hideAllPopSoon() { clearTimeout(_allHideT); _allHideT = setTimeout(() => { const p = modal && modal.querySelector('#ii-all-pop'); if (p && !p._pinned) closeAllPop(); }, 200); } // pinned: it stays when the mouse leaves (text can be selected); Escape or a click elsewhere // closes it, and that click is swallowed unless it lands on another track's chip (which pins that one) function allPin(pop) { pop._pinned = true; pop.classList.add('pinned'); const chip = () => rowLookup(pop._idx); const onDown = (e) => { if (pop.contains(e.target) || e.target === chip()) return; closeAllPop(); if (!(e.target.closest && e.target.closest('.ii-all-chip'))) { const swallow = (ev) => { ev.stopPropagation(); ev.preventDefault(); }; document.addEventListener('click', swallow, { capture: true, once: true }); setTimeout(() => document.removeEventListener('click', swallow, { capture: true }), 400); } }; const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); closeAllPop(); } }; document.addEventListener('mousedown', onDown, true); // at once: an Escape pressed right away must not reach the dialog document.addEventListener('keydown', onKey, true); pop._off = () => { document.removeEventListener('mousedown', onDown, true); document.removeEventListener('keydown', onKey, true); }; } // the asked ISRC whose comparison the panel shows ('' while it shows a named ISRC's providers), // and whether a line is in it function allShownIsrc(pop, res) { const asked = res.asked || [res.isrc]; return !pop._sel ? res.isrc : asked.includes(pop._sel) ? pop._sel : ''; } function allLineShown(l, pop, res) { const shown = allShownIsrc(pop, res); return shown ? (l.of || res.isrc) === shown : l.isrc === pop._sel; } function renderAllPop(pop, keepPlace) { const idx = pop._idx, res = _allRes[idx]; if (!res) return; const num = RELEASE.tracks[idx].number || RELEASE.tracks[idx].trackPos; const icon = k => k === 'sx' ? 'SX' : (SRC_ICON[TRACK_PROV[k] && TRACK_PROV[k].code] || ''); // #643: the header has every ISRC on the row (each asked about) and then any other a provider // named; the comparison shows one asked ISRC at a time (the row's first), and a named one shows // the providers that name it const asked = res.asked || [res.isrc]; const isrcs = [...new Set(asked.concat(res.lines.map(l => l.isrc).filter(Boolean)))]; if (!isrcs.includes(pop._sel)) pop._sel = ''; const shown = allShownIsrc(pop, res); const count = i => res.lines.filter(l => l.isrc === i && (l.state === ALL_OK || l.state === ALL_DIFF)).length; pop.innerHTML = '
Track ' + esc(num) + '' + isrcs.map(i => '').join('') + '
' + res.lines.map((l, i) => !allLineShown(l, pop, res) ? '' : '
' + ALL_MARK[l.state] + '' + icon(l.key) + '' + '' + esc(l.name) + '' + allLineHtml(l, l.of || res.isrc) + '' + '' + esc((l.song && l.song.dur) || '') + '' + (l.state === ALL_DIFF ? '' : '') + '
').join(''); if (!keepPlace) placeAllPop(pop); } // Where it goes: right of the chip, level with the row, when it fits there; otherwise above the row // (right-aligned to the chip) — either way the rows below, their All buttons and chips, stay free. // Only with no room above either (the first rows) does it squeeze in beside the chip. function placeAllPop(pop) { const chip = rowLookup(pop._idx); if (!chip) return; const a = chip.getBoundingClientRect(), m = modal.getBoundingClientRect(); const tr = chip.closest('tr'), r = tr ? tr.getBoundingClientRect() : a; pop.style.maxWidth = '620px'; pop.style.left = '0px'; pop.style.top = '0px'; const w = pop.offsetWidth, h = pop.offsetHeight, right = a.right - m.left + 10; const levelTop = Math.max(8, Math.min(a.top - m.top - 8, m.height - h - 8)); if (right + w + 8 <= m.width) { pop.style.left = right + 'px'; pop.style.top = levelTop + 'px'; pop._at = 'right'; return; } const above = r.top - m.top - h - 4; if (above >= 8) { pop.style.left = Math.max(8, Math.min(a.right - m.left - w, m.width - w - 8)) + 'px'; pop.style.top = above + 'px'; pop._at = 'above'; return; } pop.style.maxWidth = Math.max(240, m.width - right - 8) + 'px'; pop.style.left = Math.max(8, Math.min(right, m.width - pop.offsetWidth - 8)) + 'px'; pop.style.top = levelTop + 'px'; pop._at = 'beside'; } function allPopClick(pop, e) { const res = _allRes[pop._idx], idx = pop._idx, num = RELEASE.tracks[idx].number || RELEASE.tracks[idx].trackPos; const pick = e.target.closest('.ii-all-pick'), use = e.target.closest('.ii-all-use'), copy = e.target.closest('.ii-all-copy'); if (pick) { const i = pick.dataset.isrc, asked = res.asked || [res.isrc]; // an ISRC of the row shows its comparison (the row's first: no selection); another one named // by a provider toggles a filter to the providers naming it pop._sel = asked.includes(i) ? (i === res.isrc ? '' : i) : (pop._sel === i ? '' : i); // #643: the panel stays where it is — it used to be placed anew for the other ISRC's lines, // moved out from under the pointer and closed. Above the row it keeps its bottom edge (it // grows upward); elsewhere its top. And a picked tab pins it. const top0 = parseFloat(pop.style.top) || 0, h0 = pop.offsetHeight; renderAllPop(pop, true); if (pop._at === 'above') pop.style.top = Math.max(8, top0 + h0 - pop.offsetHeight) + 'px'; if (!pop._pinned) allPin(pop); return; } if (use) { const l = res.lines[+use.dataset.i]; setPending(idx, l.isrc, true, 'All · ' + l.name); updateSummary(); // #643: the Add ISRCs button counts it Log.info('All #' + num + ': using ' + l.name + '\'s ' + l.isrc + ' in place of ' + res.isrc); closeAllPop(); return; } if (copy) { const cell = x => String(x).replace(/\|/g, '\\|'); const t = RELEASE.tracks[idx]; // what the comparison shows: one asked ISRC's lines, or the providers naming another const md = '**Track ' + num + '** · ' + cell(t.title || '') + ' · `' + (allShownIsrc(pop, res) || pop._sel) + '`\n\n| | Provider | Track | Length | Note |\n|:-:|---|---|---|---|\n' + // no ISRC column: the header's is every line's, except where an album has another one — the note names that res.lines.filter(l => allLineShown(l, pop, res)).map(l => '| ' + ALL_MARK[l.state] + ' | ' + cell(l.name) + ' | ' + cell(l.song && l.song.title ? l.song.title + (l.song.artist ? ' — ' + l.song.artist : '') : '') + ' | ' + cell((l.song && l.song.dur) || '') + ' | ' + cell([l.isrc && l.isrc !== (l.of || res.isrc) ? '`' + l.isrc + '`' : '', allAtText(l), l.note || ''].filter(Boolean).join(' · ')) + ' |').join('\n') + '\n'; const said = (ok) => { copy.textContent = ok ? 'copied ✓' : 'copy failed'; copy.classList.toggle('done', ok); setTimeout(() => { copy.textContent = 'copy'; copy.classList.remove('done'); }, 1500); }; try { navigator.clipboard.writeText(md).then(() => said(true), () => said(false)); } catch (err) { said(false); } Log.info('All #' + num + ': comparison copied as Markdown'); } } function closeAllPop() { clearTimeout(_allHideT); const p = modal && modal.querySelector('#ii-all-pop'); if (p) { if (p._off) p._off(); p.remove(); } } // Right-click on All: every track, on every provider (Tidal paced per track). async function runAllTracks() { const myEpoch = _allEpoch; const todo = []; RELEASE.tracks.forEach((t, idx) => { const isrc = rowIsrc(idx); if (isrc) todo.push({ idx, isrc }); }); Log.info('All: ' + todo.length + ' track(s) with an ISRC, on ' + allProviders().map(k => TRACK_PROV[k].name).join(', ')); for (let k = 0; k < todo.length; k++) { if (myEpoch !== _allEpoch) { Log.info('All: cancelled'); return; } await allLookupRow(todo[k].idx, todo[k].isrc); if (allProviders().includes('tidal') && k < todo.length - 1) await sleep(TIDAL_TRACK_DELAY); } Log.info('All: done'); } // the ISRC a per-track button acts on: the entered value if valid, else the // first existing ISRC on the recording. function rowIsrc(idx) { const t = RELEASE.tracks[idx], input = rowInput(idx); const v = normalizeIsrc(input ? input.value : ''); return (v && isValidIsrc(v)) ? v : ((t && t.existing && t.existing[0]) || ''); } // The per-track button looks up the clicked track's ISRC on the current // provider and shows the result next to the row — it never fills the field. function runTrackSingle(idx) { const isrc = rowIsrc(idx); if (!isrc) return; // nothing to look up (button is disabled) if (trackProv === 'sx') { lookupIsrc(idx, isrc).catch(e => { if (e && (e.rateLimited || e.captcha)) sxBlocked(e); }); return; } if (trackProv === 'all') { allLookupRow(idx, isrc).catch(e => Log.err('All: ' + errText(e))); return; } // #643 lookupRowOnProvider(idx, isrc); } // Right-click → look up EVERY track's ISRC on the current provider, shown next // to each row. SoundExchange routes through its rate-limit/captcha-aware path // (serialized + paced); album providers reuse the album fetched on the first. async function runTrackAll() { if (trackProv === 'all') return runAllTracks(); // #643 const m = TPM(); const myEpoch = _sxEpoch; // closing the popup or hitting Clear bumps this (via abortSxWork) → bail the bulk run so it doesn't keep fetching (e.g. Tidal) in the background const todo = []; RELEASE.tracks.forEach((t, idx) => { const isrc = rowIsrc(idx); if (isrc) todo.push({ idx, isrc }); }); Log.info(m.name + ': looking up ' + todo.length + ' track(s) with an ISRC'); for (let k = 0; k < todo.length; k++) { if (myEpoch !== _sxEpoch) { Log.info(m.name + ': bulk lookup cancelled'); return; } // popup closed / Clear → stop issuing requests const { idx, isrc } = todo[k]; if (trackProv === 'sx') { const cached = !!_isrcLookupCache[isrc]; try { await lookupIsrc(idx, isrc); } catch (e) { if (e && (e.rateLimited || e.captcha)) { sxBlocked(e); return; } } if (!cached && k < todo.length - 1) await sleep(BATCH_DELAY); // pace only real requests } else { try { await lookupRowOnProvider(idx, isrc); } catch (e) { Log.err(m.name + ': ' + errText(e)); } // Tidal makes a real request per track, so pace them (album providers reuse one // cached fetch and don't need it). Combined with tidalGet's back-off, this keeps // a bulk pass under the rate limit instead of 429-ing the tail. #tidal-429 if (trackProv === 'tidal' && k < todo.length - 1) await sleep(TIDAL_TRACK_DELAY); } } } // Re-skin EVERY per-track button to the chosen provider (global, not persisted). // The bulk "⟳ SoundExchange" toolbar button is intentionally left untouched. function setTrackProvider(key, { quiet = false } = {}) { if (!TRACK_PROV[key] || !trackProvAvailable(key)) return; trackProv = key; const m = TPM(); const provGlyph = (m.code !== 'sx' && SRC_ICON[m.code]) ? SRC_ICON[m.code] : null; modal.querySelectorAll('.ii-sx').forEach(b => { b.dataset.prov = key; b.innerHTML = provGlyph || m.short; b.title = (m.kind === 'all' ? 'Check this track’s ISRC on every provider at once — a verdict, and a comparison on click' : m.kind === 'album' ? ('Look up this track’s ISRC on ' + m.name) : 'Look up this track’s ISRC on SoundExchange — verify the entered ISRC, or (if empty) search by title/artist') + ' · right-click: do all tracks'; b.style.color = m.kind === 'album' ? m.color : ''; }); RELEASE.tracks.forEach((t, i) => { const b = tbody.querySelector('tr[data-idx="' + i + '"] .ii-sx'); if (b) { const inp = rowInput(i); b.disabled = trackBtnDisabled(t, inp ? inp.value : ''); } }); if (!quiet) Log.info('Track ISRC provider → ' + m.name); } // Build the provider dropdown (only the providers available for this release). function buildProvMenu() { const menu = modal.querySelector('#ii-prov-menu'); if (!menu) return; menu.innerHTML = ''; TRACK_PROV_ORDER.filter(trackProvAvailable).forEach(key => { const m = TRACK_PROV[key]; const glyph = key === 'all' ? 'ALL' : (m.code !== 'sx' && SRC_ICON[m.code]) ? SRC_ICON[m.code] : 'SX'; const it = document.createElement('button'); it.type = 'button'; it.className = 'ii-prov-item' + (key === trackProv ? ' active' : ''); it.style.color = m.color; it.innerHTML = '' + glyph + '' + m.name + ''; it.addEventListener('click', () => { setTrackProvider(key); closeProvMenu(); }); menu.appendChild(it); }); } // Open the shared menu anchored beneath the clicked per-track ▾ caret. function openProvMenu(anchor) { buildProvMenu(); const menu = modal.querySelector('#ii-prov-menu'); if (!menu) return; menu.classList.add('open'); if (anchor && menu.offsetParent) { const a = anchor.getBoundingClientRect(); const p = menu.offsetParent.getBoundingClientRect(); let left = a.left - p.left; left = Math.min(left, menu.offsetParent.clientWidth - menu.offsetWidth - 8); menu.style.left = Math.max(8, left) + 'px'; menu.style.top = (a.bottom - p.top + 4) + 'px'; } } function closeProvMenu() { const menu = modal.querySelector('#ii-prov-menu'); if (menu) menu.classList.remove('open'); } // SX exact-match toggles (persisted) const sxExact = { title: !!store.get('sx_exact_title', false), artist: !!store.get('sx_exact_artist', false), release: !!store.get('sx_exact_release', false), }; function saveSxExact() { store.set('sx_exact_title', sxExact.title); store.set('sx_exact_artist', sxExact.artist); store.set('sx_exact_release', sxExact.release); } // Refine-panel "use this term" toggles. title/artist reset to ON every time the // panel opens; the release toggle (default OFF) is remembered across invocations. let sxRelEnabled = !!store.get('sx_rel_enabled', false); /* ═══════════════════════════════════════════════════════════════════════ DEEZER (free public API, no auth) ═══════════════════════════════════════════════════════════════════════ */ let _dzListCache = null; // { albumId, list } — tracklist cached across batches // Fetches ONE batch (STREAM_BATCH_LIMIT tracks) starting at `start`. Deezer's // album endpoint lacks ISRCs, so each track needs its own request — a 1000-track // release would be 1000 calls, hence the batching. Returns { total, next }. async function fetchDeezer(albumId, onProgress, onIsrc, start) { start = start || 0; // (re)fetch the tracklist on a fresh import (start 0) or album change; reuse it on continue if (start === 0 || !_dzListCache || _dzListCache.albumId !== albumId) { const r = await gmGet('https://api.deezer.com/album/' + albumId, { 'Accept': 'application/json' }); const data = JSON.parse(r.responseText || '{}'); if (data.error) throw new Error((data.error.message || data.error.type || 'Deezer error') + ' (album ' + albumId + ')'); const list = (data.tracks && data.tracks.data) || []; Log.info('Deezer album "' + (data.title || albumId) + '": ' + list.length + ' track(s)' + (list.length > STREAM_BATCH_LIMIT ? ' — fetching ' + STREAM_BATCH_LIMIT + ' at a time' : '')); _dzListCache = { albumId, list }; } const list = _dzListCache.list; const end = Math.min(start + STREAM_BATCH_LIMIT, list.length); let failed = 0; for (let i = start; i < end; i++) { const t = list[i]; try { const tr = await gmGet('https://api.deezer.com/track/' + t.id, { 'Accept': 'application/json' }); const td = JSON.parse(tr.responseText || '{}'); if (td.error) { failed++; Log.warn('Deezer track ' + t.id + ': ' + (td.error.message || td.error.type)); } const entry = { isrc: normalizeIsrc(td.isrc || ''), title: td.title || t.title || '', artist: (td.artist && td.artist.name) || '', disc: td.disk_number || t.disk_number || 1, pos: td.track_position || t.track_position || (i + 1), dur: td.duration ? msToMmSs(td.duration * 1000) : '', }; if (onIsrc && isValidIsrc(entry.isrc)) onIsrc(entry); // fill this track's input now } catch (e) { failed++; Log.warn('Deezer track ' + t.id + ' fetch failed: ' + errText(e)); } try { if (onProgress) onProgress(i + 1, list.length); } catch (e) { Log.warn('Deezer progress update hiccup: ' + errText(e)); } await sleep(120); } if (failed) Log.warn('Deezer: ' + failed + ' track fetch(es) failed in this batch'); return { total: list.length, next: end < list.length ? end : null }; } /* ═══════════════════════════════════════════════════════════════════════ SPOTIFY (via molla or ISRC Hunt — chosen in ⚙, #603) Spotify's anti-bot makes a direct userscript token-harvest unreliable, and its ISRC API needs a paid developer app. Both services do the Spotify lookup server-side with their own credentials: molla returns JSON, ISRC Hunt renders the ISRCs into a plain HTML table that we scrape (no token, no login). ═══════════════════════════════════════════════════════════════════════ */ function parseIsrchunt(html) { const doc = new DOMParser().parseFromString(html, 'text/html'); const tables = [...doc.querySelectorAll('table')]; // results table columns: Track number · Track name · Length · ISRC · … const table = tables.find(t => /\bISRC\b/i.test(t.textContent) && /track/i.test(t.textContent)) || tables[0]; const out = []; if (!table) return out; // #446: ISRC Hunt restarts the track number at 1 for each Spotify disc and gives NO // disc column, so a multi-disc album reads as 1..13, 1..12 with everything looking like // "disc 1" — the disc-2 rows then collided with medium 1 (only the first medium filled). // Track the running position and bump the disc whenever the number resets (<= the // previous one), so mediums map correctly. Sequential single-disc tables are unaffected. let disc = 1, prevPos = 0; [...table.querySelectorAll('tr')].forEach(tr => { const td = [...tr.querySelectorAll('td')]; if (td.length < 4) return; const lenMs = parseInt(td[2].textContent.trim(), 10); const isrc = normalizeIsrc(td[3].textContent.trim()); if (!isValidIsrc(isrc)) return; const pos = parseInt(td[0].textContent.trim(), 10) || (prevPos + 1); if (pos <= prevPos) disc++; // track number reset → next disc / medium prevPos = pos; out.push({ isrc, title: td[1].textContent.trim(), artist: '', pos, disc, dur: lenMs ? msToMmSs(lenMs) : '' }); }); return out; } async function fetchSpotify(albumId, onProgress, onIsrc) { const src = spotifyIsrcSource(); Log.info('Spotify ISRC source: ' + (src === 'isrchunt' ? 'ISRC Hunt' : 'molla') + ' (change in ⚙)'); return src === 'isrchunt' ? fetchSpotifyIsrchunt(albumId, onProgress, onIsrc) : fetchSpotifyMolla(albumId, onProgress, onIsrc); } // #603: molla (isrc.mollamusicgroup.com) takes the album link and returns every // track's ISRC in one JSON request. It gives album order only — no track or disc // numbers. When its track count equals this release's, map by flattened position // (MB media in order ↔ Spotify's (disc, track) order, which is molla's order). // Otherwise send no position, so only title/artist matching can place an ISRC and // anything unmatched stays empty rather than landing on the wrong track. // Its Spotify quota is shared by every molla user; running out comes back as // HTTP 500 with "429 Too Many Requests" in the body and no Retry-After. async function fetchSpotifyMolla(albumId, onProgress, onIsrc) { if (onProgress) onProgress(0, 0); const input = 'spotify:album:' + albumId; Log.info('Spotify via molla: ' + input); const r = await gmPost('https://isrc.mollamusicgroup.com/api/spotify', JSON.stringify({ input }), { 'Content-Type': 'application/json' }); let j = null; try { j = JSON.parse(r.responseText || 'null'); } catch (e) { Log.warn('molla: response is not JSON (' + String(r.responseText || '').slice(0, 80) + ')'); } if (r.status !== 200) { const msg = (j && j.error) || ('HTTP ' + r.status); if (/\b429\b|too many requests/i.test(msg)) throw new Error('molla is rate-limited by Spotify (its quota is shared by all molla users) — try again in a minute, or switch to ISRC Hunt in ⚙'); throw new Error('molla returned ' + r.status + ': ' + msg); } const tracks = (j && Array.isArray(j.tracks)) ? j.tracks : []; const n = tracks.length, mb = RELEASE.tracks.length; const withIsrc = tracks.filter(t => isValidIsrc(t.isrc || '')).length; Log.info('molla: "' + ((j && j.name) || '?') + '" — ' + n + ' track(s), ' + withIsrc + ' with an ISRC' + (j && j.upc ? ', UPC ' + j.upc : '')); if (!withIsrc) throw new Error('molla found no ISRCs for this album'); const byPos = n === mb; if (byPos) Log.info('molla: track count matches this release (' + n + ') — mapping by position'); else Log.warn('molla: the album has ' + n + ' track(s) but this release has ' + mb + ' — molla gives no track numbers, so ISRCs are placed by title/artist match only'); tracks.forEach((t, i) => { const isrc = normalizeIsrc(t.isrc || ''); const title = t.track_name || ''; // Spotify puts featured artists in the title ("Get Lucky (feat. Pharrell Williams // and Nile Rodgers)"); MB keeps them in the artist credit. molla has no durations, // so the title is the only plausibility check — without this every feat. track // on a correct import was flagged implausible. const matchTitle = title.replace(/\s*[([](?:feat\.?|ft\.?|featuring|with)\s[^)\]]*[)\]]/gi, '').trim() || title; // Match on the main (first) artist only: Spotify lists every featured artist, and // MB's credit often names a different set ("Get Lucky": Spotify adds Nile Rodgers, // MB has "Daft Punk feat. Pharrell Williams"), so neither list contains the other. const matchArtist = (t.artists || [])[0] || ''; if (isValidIsrc(isrc)) { const mt = byPos ? RELEASE.tracks[i] : null; // pos/disc -1 never matches a real track, which forces mapOneToTrack onto title matching const e = { isrc, title: matchTitle, artist: matchArtist, pos: mt ? +mt.trackPos : -1, disc: mt ? +mt.mediumPos : -1, dur: '' }; Log.info('molla #' + (i + 1) + ' ' + isrc + ' "' + title + '"' + (mt ? ' → medium ' + mt.mediumPos + ' track ' + mt.trackPos : ' → by title')); try { if (onIsrc) onIsrc(e); } catch (err) { Log.warn('Spotify map hiccup for ' + isrc + ': ' + errText(err)); } } else { Log.warn('molla #' + (i + 1) + ' "' + title + '": no valid ISRC (' + (t.isrc || 'none') + ')'); } try { if (onProgress) onProgress(i + 1, n); } catch (err) {} }); return { total: n, next: null }; // molla returns everything in one request — never batched } async function fetchSpotifyIsrchunt(albumId, onProgress, onIsrc) { if (onProgress) onProgress(0, 0); const albumUrl = 'https://open.spotify.com/album/' + albumId; const url = 'https://isrchunt.com/spotify/importisrc?releaseId=' + encodeURIComponent(albumUrl); Log.info('Spotify via ISRC Hunt: ' + shortUrl(url)); const r = await gmGet(url, { 'Accept': 'text/html' }); if (r.status !== 200) throw new Error('ISRC Hunt returned ' + r.status); const rows = parseIsrchunt(r.responseText); Log.info('ISRC Hunt: ' + rows.length + ' track(s) with an ISRC'); if (!rows.length) throw new Error('ISRC Hunt found no ISRCs for this album'); rows.forEach((e, i) => { try { if (onIsrc) onIsrc(e); } catch (err) { Log.warn('Spotify map hiccup for ' + e.isrc + ': ' + errText(err)); } try { if (onProgress) onProgress(i + 1, rows.length); } catch (err) {} }); return { total: rows.length, next: null }; // ISRC Hunt returns everything in one request — never batched } /* ═══════════════════════════════════════════════════════════════════════ BEATPORT (Cloudflare-walled → harvested in a brief tab; the harvester at the top of the script fills GM storage, which we read back here.) ═══════════════════════════════════════════════════════════════════════ */ function readBeatportHarvest(id) { try { const h = store.get('beatport_harvest_' + id, ''); const o = h ? (typeof h === 'string' ? JSON.parse(h) : h) : null; return (o && Array.isArray(o.tracks)) ? o : null; } catch (e) { return null; } } // Beatport official API — used only when the user has logged in via Platform Check // (the token is shared through a musicbrainz.org localStorage key). When present it's // a clean one-call fetch with no tab; otherwise we fall back to the tab-harvest below. const BEATPORT = { clientId: '0GIvkCltVIuPkkwSJHp6NDb3s0potTjLBQr388Dd', api: 'https://api.beatport.com/v4', lsKey: 'mbtools:beatport' }; const bpRead = () => { try { return JSON.parse(localStorage.getItem(BEATPORT.lsKey) || 'null'); } catch (e) { return null; } }; const bpWrite = t => { try { t ? localStorage.setItem(BEATPORT.lsKey, JSON.stringify(t)) : localStorage.removeItem(BEATPORT.lsKey); } catch (e) {} }; async function beatportToken() { const t = bpRead(); if (!t || !t.refresh_token) return null; if (t.access_token && Date.now() < t.exp - 60000) return t.access_token; const r = await gmPost(BEATPORT.api + '/auth/o/token/', new URLSearchParams({ grant_type: 'refresh_token', refresh_token: t.refresh_token, client_id: BEATPORT.clientId }).toString(), { 'Content-Type': 'application/x-www-form-urlencoded' }); let j; try { j = JSON.parse(r.responseText || '{}'); } catch (e) { j = {}; } if (!j.access_token) { Log.warn('Beatport: token refresh failed — re-login in Platform Check ⚙'); return null; } bpWrite({ access_token: j.access_token, refresh_token: j.refresh_token || t.refresh_token, exp: Date.now() + ((j.expires_in || 36000) * 1000) }); return j.access_token; } // Returns track entries from the API, or null if not logged in / no usable data. // ISRCs live on the /tracks/ sub-endpoint — the release-detail's embedded `tracks` // array omits them (that's why an earlier build saw 0 ISRCs and fell back to the tab). async function fetchBeatportApi(releaseId, onProgress, onIsrc) { const tok = await beatportToken(); if (!tok) return null; Log.info('Beatport: logged in — fetching release ' + releaseId + ' via the API (no tab)'); const headers = { 'Authorization': 'Bearer ' + tok, 'Accept': 'application/json' }; const list = []; let url = BEATPORT.api + '/catalog/releases/' + releaseId + '/tracks/?per_page=100'; let guard = 0; while (url && guard++ < 20) { const r = await gmGet(url, headers); if (r.status !== 200) { Log.warn('Beatport API ' + r.status + ' for release ' + releaseId + ' — falling back to tab harvest'); return null; } let d; try { d = JSON.parse(r.responseText || '{}'); } catch (e) { return null; } (d.results || d.tracks || []).forEach(t => list.push(t)); url = d.next || null; // DRF pagination returns an absolute next URL } if (!list.length) { Log.warn('Beatport API: release had no tracks — falling back to tab harvest'); return null; } let withIsrc = 0; list.forEach((t, i) => { const mix = t.mix_name && !/^original mix$/i.test(t.mix_name) && !String(t.name || '').toLowerCase().includes('(' + String(t.mix_name).toLowerCase() + ')') ? ' (' + t.mix_name + ')' : ''; // #643: not twice const e = { isrc: normalizeIsrc(t.isrc || ''), 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 || (t.length_ms ? msToMmSs(t.length_ms) : ''), url: (t.slug && t.id) ? 'https://www.beatport.com/track/' + t.slug + '/' + t.id : '', // #387 per-track link }; try { if (onIsrc && isValidIsrc(e.isrc)) { onIsrc(e); withIsrc++; } } catch (err) { Log.warn('Beatport map hiccup for ' + e.isrc + ': ' + errText(err)); } try { if (onProgress) onProgress(i + 1, list.length); } catch (err) {} }); Log.info('Beatport API: ' + withIsrc + '/' + list.length + ' track(s) carried an ISRC'); if (!withIsrc) return null; // nothing usable → let the tab-harvest try return { total: list.length, next: null }; } async function fetchBeatport(releaseId, onProgress, onIsrc) { if (onProgress) onProgress(0, 0); const viaApi = await fetchBeatportApi(releaseId, onProgress, onIsrc); // logged-in fast path (no tab) if (viaApi) return viaApi; let h = readBeatportHarvest(releaseId); if (h) { Log.info('Beatport: using harvested data for release ' + releaseId + ' (' + h.tracks.length + ' track(s))'); } else { Log.info('Beatport: opening release ' + releaseId + ' in a background tab to harvest ISRCs (Cloudflare blocks a direct fetch)'); store.del('beatport_harvest_' + releaseId); // Tell the harvester (running in the tab we're about to open) that THIS // tab is ours to close once it's done — so the harvester never closes a // Beatport tab the user opened themselves. store.set('beatport_close_' + releaseId, Date.now()); const url = 'https://www.beatport.com/release/-/' + releaseId; let tab = null; try { tab = (typeof GM_openInTab === 'function') ? GM_openInTab(url, { active: false, insert: true, setParent: true }) : window.open(url, '_blank'); } catch (e) { tab = window.open(url, '_blank'); } const t0 = Date.now(); while (Date.now() - t0 < 60000) { await sleep(500); h = readBeatportHarvest(releaseId); if (h) break; } try { if (tab && typeof tab.close === 'function') tab.close(); } catch (e) {} store.del('beatport_close_' + releaseId); // clear the flag whether we succeeded or timed out if (!h) throw new Error('Beatport harvest timed out — the tab may have hit a Cloudflare check. Open the release on beatport.com once, then retry.'); } const rows = h.tracks; rows.forEach((e, i) => { try { if (onIsrc && isValidIsrc(e.isrc)) onIsrc(e); } catch (err) { Log.warn('Beatport map hiccup for ' + e.isrc + ': ' + errText(err)); } try { if (onProgress) onProgress(i + 1, rows.length); } catch (err) {} }); const withIsrc = rows.filter(e => isValidIsrc(e.isrc)).length; Log.info('Beatport: ' + withIsrc + '/' + rows.length + ' track(s) carried an ISRC'); if (!withIsrc) throw new Error('Beatport release exposed no ISRCs'); return { total: rows.length, next: null }; } /* ═══════════════════════════════════════════════════════════════════════ TIDAL (official API; app token via client-credentials — no user login) ═══════════════════════════════════════════════════════════════════════ */ async function tidalToken() { const tok = store.get('tidal_token', ''), exp = store.get('tidal_token_exp', 0); if (tok && Date.now() < exp - 60000) return tok; const basic = btoa(TIDAL.clientId + ':' + TIDAL.clientSecret); const r = await gmPost(TIDAL.tokenUrl, 'grant_type=client_credentials', { 'Authorization': 'Basic ' + basic, 'Content-Type': 'application/x-www-form-urlencoded' }); const j = JSON.parse(r.responseText || '{}'); if (!j.access_token) throw new Error('Tidal auth failed (' + r.status + ')' + (j.error ? ': ' + j.error : '')); store.set('tidal_token', j.access_token); store.set('tidal_token_exp', Date.now() + ((j.expires_in || 14400) * 1000)); return j.access_token; } function isoDurToMmSs(iso) { const m = String(iso || '').match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/); if (!m) return ''; const sec = (parseInt(m[1] || 0, 10) * 3600) + (parseInt(m[2] || 0, 10) * 60) + parseInt(m[3] || 0, 10); return sec ? msToMmSs(sec * 1000) : ''; } // Tidal's openapi is aggressively rate-limited (HTTP 429). On a 429, back off — // honouring a Retry-After header when present, else exponential — and retry, so a // throttled request RECOVERS instead of looking like "not found". Only throws a // typed `rateLimited` error when it never clears, so the caller can say so. const TIDAL_MAX_RETRY = 4; function retryAfterMs(r, fallback) { const m = /retry-after:\s*([0-9.]+)/i.exec((r && r.responseHeaders) || ''); const s = m ? parseFloat(m[1]) : NaN; return (s > 0) ? Math.min(s * 1000, 30000) : fallback; } async function tidalGet(url, headers) { for (let attempt = 0; ; attempt++) { const r = await gmGet(url, headers); if (r.status !== 429) return r; if (attempt >= TIDAL_MAX_RETRY) { const e = new Error('Tidal rate limit (HTTP 429)'); e.rateLimited = true; throw e; } const wait = retryAfterMs(r, Math.min(800 * Math.pow(2, attempt), 8000)); Log.warn('Tidal 429 — backing off ' + wait + 'ms (retry ' + (attempt + 1) + '/' + TIDAL_MAX_RETRY + ')'); await sleep(wait); } } async function fetchTidal(albumId, onProgress, onIsrc) { if (onProgress) onProgress(0, 0); const token = await tidalToken(); const headers = { 'Authorization': 'Bearer ' + token, 'Accept': 'application/vnd.api+json' }; let path = '/albums/' + albumId + '/relationships/items?countryCode=' + TIDAL.country + '&include=items'; const rows = []; let guard = 0; while (path && guard++ < 50) { const r = await tidalGet(TIDAL.api + path, headers); if (r.status !== 200) throw new Error('Tidal ' + r.status + ' for album ' + albumId + (r.status === 404 ? ' (not found / region-locked)' : '')); const j = JSON.parse(r.responseText || '{}'); const inc = {}; (j.included || []).forEach(x => { if (x.type === 'tracks') inc[x.id] = x.attributes || {}; }); (j.data || []).forEach(ref => { const a = inc[ref.id] || {}; const meta = ref.meta || {}; const ver = a.version ? ' (' + a.version + ')' : ''; rows.push({ isrc: normalizeIsrc(a.isrc || ''), title: (a.title || '') + ver, artist: '', // track artists need a second include; pos+disc mapping is enough for a full ordered tracklist disc: meta.volumeNumber || 1, pos: meta.trackNumber || (rows.length + 1), dur: isoDurToMmSs(a.duration), }); }); const next = j.links && j.links.next; path = next ? (next.charAt(0) === '/' ? next : '/' + next.replace(/^.*\/v2\//, '')) : null; } Log.info('Tidal album ' + albumId + ': ' + rows.length + ' track(s)'); let n = 0; rows.forEach(e => { try { if (onIsrc && isValidIsrc(e.isrc)) onIsrc(e); } catch (err) { Log.warn('Tidal map hiccup for ' + e.isrc + ': ' + errText(err)); } try { if (onProgress) onProgress(++n, rows.length); } catch (err) {} }); const withIsrc = rows.filter(e => isValidIsrc(e.isrc)).length; if (!withIsrc) throw new Error('Tidal album exposed no ISRCs'); return { total: rows.length, next: null }; } // Global by-ISRC lookup (no album link needed) — Tidal v2 /tracks?filter[isrc], // pulling the artist names via include=artists. Returns a fields-shaped object. async function tidalLookupByIsrc(isrc) { const token = await tidalToken(); const headers = { 'Authorization': 'Bearer ' + token, 'Accept': 'application/vnd.api+json' }; const r = await tidalGet(TIDAL.api + '/tracks?countryCode=' + TIDAL.country + '&filter%5Bisrc%5D=' + encodeURIComponent(isrc) + '&include=artists', headers); if (r.status !== 200) return null; let j; try { j = JSON.parse(r.responseText || '{}'); } catch (e) { return null; } const t = (j.data || [])[0]; if (!t) return null; const a = t.attributes || {}; const names = {}; (j.included || []).forEach(x => { if (x.type === 'artists') names[x.id] = (x.attributes || {}).name; }); const artist = ((((t.relationships || {}).artists || {}).data) || []).map(d => names[d.id]).filter(Boolean).join(', '); const ver = a.version ? ' (' + a.version + ')' : ''; return { isrc: normalizeIsrc(a.isrc || isrc), title: (a.title || '') + ver, artist, year: '', dur: isoDurToMmSs(a.duration), relTitle: '' }; } /* ═══════════════════════════════════════════════════════════════════════ VOLUMO (clean unauthenticated JSON API — no Cloudflare/token; one call per album returns every track's ISRC. Resolves from a Volumo URL only — either an MB rel or the one Platform Check found; Scout never deals with barcodes itself, that's Platform Check's job.) ═══════════════════════════════════════════════════════════════════════ */ /* ═══════════════════════════════════════════════════════════════════════ QOBUZ (#353, #418) — per-track ISRCs come from album/get, which works ANONYMOUSLY (app_id only) when the request comes from a country Qobuz serves: the anonymous API resolves catalogue visibility by request IP and answers 404 "No result matching given argument" everywhere else. (That geo dimension is why the original #353 investigation, run from a non-Qobuz country, concluded a login was required.) So: Platform Check's shared user_auth_token is PREFERRED when present (one request, works from any country — the login's real contribution is the ACCOUNT's region, not auth; mbtools:qobuz localStorage key, same channel as the Beatport token), anonymous otherwise or when the session has gone stale. Only when both paths fail do we point at the PC login. ═══════════════════════════════════════════════════════════════════════ */ const QOBUZ = { appId: '712109809', api: 'https://www.qobuz.com/api.json/0.2', lsKey: 'mbtools:qobuz' }; const qbToken = () => { try { const t = JSON.parse(localStorage.getItem(QOBUZ.lsKey) || 'null'); return (t && t.token) || null; } catch (e) { return null; } }; const qbHeaders = tok => { const h = { 'X-App-Id': QOBUZ.appId, 'Accept': 'application/json' }; if (tok) h['X-User-Auth-Token'] = tok; return h; }; // One Qobuz API GET (#418): the Platform Check session is preferred when present (one // request, works from any country — the token carries the account's region), falling back // to an anonymous call when there is no token or the token has expired. Anonymous works // fine from countries Qobuz serves; elsewhere it answers 404, hence the tailored error. // `pathAndQuery` must already carry its `?`; app_id is appended here. async function qbGet(pathAndQuery, what) { const url = QOBUZ.api + pathAndQuery + '&app_id=' + QOBUZ.appId; const tok = qbToken(); if (tok) { const r = await gmGet(url, qbHeaders(tok)); if (r.status === 200) return r; // expired/broken session — the anonymous path may still work (served country) Log.warn('Qobuz: session ' + what + ' answered ' + r.status + (r.status === 401 ? ' (expired? re-login in Platform Check ⚙)' : '') + ' — trying anonymously'); } const anon = await gmGet(url, qbHeaders(null)); if (anon.status === 200) return anon; throw new Error('Qobuz ' + anon.status + ' for ' + what + (tok ? ' — session and anonymous both failed (re-login in Platform Check ⚙ Setup → Auth?)' : ' — the anonymous API only answers from countries Qobuz serves; sign in to Qobuz in Platform Check ⚙ Setup → Auth to use your account’s region instead')); } async function fetchQobuz(albumId, onProgress, onIsrc) { if (onProgress) onProgress(0, 0); let id = String(albumId).trim(); // a bare barcode/UPC → resolve to the album id via search (Qobuz stores the 13-digit EAN, so try zero-padded too, #354) if (/^\d+$/.test(id)) { const want = id.replace(/^0+/, ''); let hit = null, lastErr = null; for (const q of [id, id.length < 13 ? id.padStart(13, '0') : null].filter(Boolean)) { let sr; try { sr = await qbGet('/album/search?query=' + encodeURIComponent(q), 'barcode search ' + q); } catch (e) { lastErr = e; continue; } let sj; try { sj = JSON.parse(sr.responseText || 'null'); } catch (e) { sj = null; } const items = (sj && sj.albums && sj.albums.items) || []; hit = items.find(a => String(a.upc || '').replace(/^0+/, '') === want) || items[0]; if (hit) break; } if (!hit || !hit.id) throw (lastErr || new Error('Qobuz: no album for barcode ' + id)); id = hit.id; } const r = await qbGet('/album/get?album_id=' + encodeURIComponent(id), 'album ' + id); let j; try { j = JSON.parse(r.responseText || 'null'); } catch (e) { throw new Error('Qobuz: malformed JSON'); } const list = (j && j.tracks && j.tracks.items) || []; Log.info('Qobuz album "' + ((j && j.title) || id) + '": ' + list.length + ' track(s)'); let n = 0; list.forEach((t, i) => { const e = { isrc: normalizeIsrc(t.isrc || ''), title: t.title || '', artist: (t.performer && t.performer.name) || (j.artist && j.artist.name) || '', disc: t.media_number || 1, pos: t.track_number || (i + 1), dur: t.duration ? msToMmSs(t.duration * 1000) : '', // Qobuz duration is in SECONDS url: t.id ? 'https://open.qobuz.com/track/' + t.id : '', // #387 per-track link — id-only open form is what MB stores }; try { if (onIsrc && isValidIsrc(e.isrc)) onIsrc(e); } catch (err) { Log.warn('Qobuz map hiccup for ' + e.isrc + ': ' + errText(err)); } try { if (onProgress) onProgress(++n, list.length); } catch (err) {} }); const withIsrc = list.filter(t => isValidIsrc(normalizeIsrc(t.isrc || ''))).length; if (!withIsrc) throw new Error('Qobuz album exposed no ISRCs (session may lack catalogue access)'); return { total: list.length, next: null }; } /* ═══════════════════════════════════════════════════════════════════════ APPLE MUSIC (#435) — per-track ISRCs from the amp-api, ANONYMOUSLY. The web player's bearer token lives in its public JS bundle (no login); one `catalog//albums/` call returns every track's `attributes.isrc`. The storefront comes from the MB link (…/us/…); credits aren't needed here (that's Credit Hoarder's job), just the album's tracklist. ═══════════════════════════════════════════════════════════════════════ */ const APPLE = { amp: 'https://amp-api.music.apple.com/v1/catalog', lsKey: 'mbtools:apple-token' }; let _appleTok = null; // fresh: skip the cached token — it was refused. The cache is shared with Platform Check, // and a token there can be stale or rotated; without this, ISRC Scout kept presenting a // refused one for up to 12 h (Platform Check has recovered this way since #627). async function appleToken(fresh) { if (fresh) { _appleTok = null; try { localStorage.removeItem(APPLE.lsKey); } catch (e) {} } if (_appleTok) return _appleTok; try { const c = JSON.parse(localStorage.getItem(APPLE.lsKey) || 'null'); if (c && c.t && c.at && (Date.now() - c.at) < 12 * 3600e3) { _appleTok = c.t; return _appleTok; } } catch (e) {} const home = await gmGet('https://music.apple.com/us/browse', { 'Accept': 'text/html' }); const asset = (home.responseText.match(/\/assets\/index-legacy~[a-z0-9]+\.js/i) || home.responseText.match(/\/assets\/index~[a-z0-9]+\.js/i) || [])[0]; if (!asset) throw new Error('Apple: web-player JS asset not found'); const js = await gmGet('https://music.apple.com' + asset, {}); const tok = (js.responseText.match(/eyJ[A-Za-z0-9._\-]{80,}/) || [])[0]; if (!tok) throw new Error('Apple: no bearer token in the web-player JS'); _appleTok = tok; try { localStorage.setItem(APPLE.lsKey, JSON.stringify({ t: tok, at: Date.now() })); } catch (e) {} return tok; } // albumId is "/" (from parseStreamingId) or a full Apple album URL. async function fetchApple(albumId, onProgress, onIsrc) { if (onProgress) onProgress(0, 0); let sf = 'us', id = String(albumId).trim(); let m = id.match(/music\.apple\.com\/([a-z]{2})\/album\/(?:[^/?#]+\/)?(\d+)/i) || id.match(/^([a-z]{2})\/(\d+)$/i); if (m) { sf = m[1].toLowerCase(); id = m[2]; } let h, r; for (let attempt = 0; attempt < 2; attempt++) { h = { Authorization: 'Bearer ' + await appleToken(attempt > 0), Origin: 'https://music.apple.com', Accept: 'application/json' }; r = await gmGet(APPLE.amp + '/' + sf + '/albums/' + encodeURIComponent(id) + '?l=en-US', h); if (r.status !== 401 || attempt > 0) break; Log.warn('Apple: the cached token was refused (401) — fetching a new one'); } if (r.status !== 200) throw new Error('Apple ' + r.status + ' for album ' + id); let j; try { j = JSON.parse(r.responseText || 'null'); } catch (e) { throw new Error('Apple: malformed JSON'); } const alb = j && j.data && j.data[0]; let list = (alb && alb.relationships && alb.relationships.tracks && alb.relationships.tracks.data) || []; let next = alb && alb.relationships && alb.relationships.tracks && alb.relationships.tracks.next; while (next) { const pg = await gmGet('https://amp-api.music.apple.com' + next + (next.includes('?') ? '&' : '?') + 'l=en-US', h); let pj; try { pj = JSON.parse(pg.responseText || 'null'); } catch (e) { break; } list = list.concat((pj && pj.data) || []); next = pj && pj.next; } Log.info('Apple album "' + ((alb && alb.attributes && alb.attributes.name) || id) + '": ' + list.length + ' track(s)'); let n = 0; list.forEach((t, i) => { const a = t.attributes || {}; const e = { isrc: normalizeIsrc(a.isrc || ''), title: a.name || '', artist: a.artistName || '', disc: a.discNumber || 1, pos: a.trackNumber || (i + 1), dur: a.durationInMillis ? msToMmSs(a.durationInMillis) : '', url: a.url || '', }; try { if (onIsrc && isValidIsrc(e.isrc)) onIsrc(e); } catch (err) { Log.warn('Apple map hiccup for ' + e.isrc + ': ' + errText(err)); } try { if (onProgress) onProgress(++n, list.length); } catch (err) {} }); const withIsrc = list.filter(t => isValidIsrc(normalizeIsrc((t.attributes || {}).isrc || ''))).length; if (!withIsrc) throw new Error('Apple album exposed no ISRCs'); return { total: list.length, next: null }; } /* ═══════════════════════════════════════════════════════════════════════ SOUNDCLOUD (#439) — per-track ISRCs from a set (playlist), ANONYMOUSLY. Each track carries `publisher_metadata.isrc` in SoundCloud's public api-v2. The public `client_id` lives in the web player's JS bundle (no login), the same trick the Apple source uses for its bearer token. Flow: resolve the set URL → playlist { tracks:[{id}, …] } /tracks?ids= (batched ≤50) → each track's publisher_metadata.isrc A set is an ordered playlist, so ISRCs map to the tracklist BY POSITION (title + duration ride along so the #431 plausibility guard can flag a mismatched edition). Distributed sets also carry the release barcode per track (publisher_metadata.upc_or_ean) — logged here, but not written (setting a release barcode is Platform Check's job). #439 ═══════════════════════════════════════════════════════════════════════ */ const SC = { api: 'https://api-v2.soundcloud.com', lsKey: 'mbtools:soundcloud-clientid' }; let _scClientId = null; async function soundcloudClientId() { if (_scClientId) return _scClientId; try { const c = JSON.parse(localStorage.getItem(SC.lsKey) || 'null'); if (c && c.id && c.at && (Date.now() - c.at) < 12 * 3600e3) { _scClientId = c.id; return _scClientId; } } catch (e) {} // The player page references its JS asset bundles on a-v2.sndcdn.com; one of // them defines `client_id:"…"`. Scan them newest-first (the id lives in a late // chunk) and cache the first hit for the session. const home = await gmGet('https://soundcloud.com/discover', { 'Accept': 'text/html' }); const assets = [...(home.responseText || '').matchAll(/https:\/\/a-v2\.sndcdn\.com\/assets\/[^"']+\.js/g)].map(m => m[0]); for (const a of assets.reverse()) { let js; try { js = await gmGet(a, {}); } catch (e) { continue; } const m = (js.responseText || '').match(/client_id\s*[:=]\s*"([a-zA-Z0-9]{20,40})"/); if (m) { _scClientId = m[1]; try { localStorage.setItem(SC.lsKey, JSON.stringify({ id: _scClientId, at: Date.now() })); } catch (e) {} return _scClientId; } } throw new Error('SoundCloud: no client_id in the web-player JS'); } // albumId is a SoundCloud set (playlist) URL — soundcloud.com//sets/. async function fetchSoundcloud(albumId, onProgress, onIsrc) { if (onProgress) onProgress(0, 0); const setUrl = String(albumId).trim(); const cid = await soundcloudClientId(); const rj = async (url, what) => { const r = await gmGet(url, { 'Accept': 'application/json' }); if (r.status !== 200) throw new Error('SoundCloud ' + r.status + ' for ' + what); try { return JSON.parse(r.responseText || 'null'); } catch (e) { throw new Error('SoundCloud: malformed JSON (' + what + ')'); } }; const pl = await rj(SC.api + '/resolve?url=' + encodeURIComponent(setUrl) + '&client_id=' + cid, 'set'); let ordered; if (pl && pl.kind === 'track') { // a bare track URL — a single-track release (#439, chaban-mb): treat it as a 1-track set ordered = [pl]; Log.info('SoundCloud track "' + (pl.title || setUrl) + '": 1 track'); } else if (pl && pl.kind === 'playlist') { const stubs = (pl.tracks || []).filter(t => t && t.id); Log.info('SoundCloud set "' + (pl.title || setUrl) + '": ' + stubs.length + ' track(s)'); // /resolve hydrates only the first few tracks; batch-hydrate the rest by id // (≤50 per call), preserving the set order so positions stay 1:1 with the release. const byId = new Map(); (pl.tracks || []).forEach(t => { if (t && t.title) byId.set(t.id, t); }); const missing = stubs.filter(t => !byId.has(t.id)).map(t => t.id); for (let i = 0; i < missing.length; i += 50) { const ids = missing.slice(i, i + 50).join(','); let batch; try { batch = await rj(SC.api + '/tracks?ids=' + encodeURIComponent(ids) + '&client_id=' + cid, 'tracks'); } catch (e) { Log.warn('SoundCloud batch failed: ' + errText(e)); continue; } (batch || []).forEach(t => { if (t && t.id != null) byId.set(t.id, t); }); } ordered = stubs.map(s => byId.get(s.id)).filter(Boolean); } else { throw new Error('SoundCloud: not a set or track URL'); } let n = 0; ordered.forEach((t, i) => { const pm = t.publisher_metadata || {}; const e = { isrc: normalizeIsrc(pm.isrc || ''), title: t.title || '', artist: pm.artist || (t.user && t.user.username) || '', disc: 1, pos: i + 1, // a set is an ordered playlist — position IS the track order dur: (t.duration || t.full_duration) ? msToMmSs(t.duration || t.full_duration) : '', url: t.permalink_url || '', }; try { if (onIsrc && isValidIsrc(e.isrc)) onIsrc(e); } catch (err) { Log.warn('SoundCloud map hiccup for ' + e.isrc + ': ' + errText(err)); } try { if (onProgress) onProgress(++n, ordered.length); } catch (err) {} }); // SoundCloud stores the release barcode per track as publisher_metadata.upc_or_ean // (label-supplied — present on distributed sets, absent on self-uploads). ISRC Scout // doesn't set the barcode (that's a release edit — Platform Check's job), but surface // it in the log when the whole set agrees on one value, so it's not lost. #439 const upcs = [...new Set(ordered.map(t => String((t.publisher_metadata || {}).upc_or_ean || '').trim()).filter(Boolean))]; if (upcs.length === 1) Log.info('SoundCloud set barcode (UPC/EAN): ' + upcs[0]); else if (upcs.length > 1) Log.info('SoundCloud set exposes ' + upcs.length + ' different track UPCs (compilation?): ' + upcs.join(', ')); const withIsrc = ordered.filter(t => isValidIsrc(normalizeIsrc((t.publisher_metadata || {}).isrc || ''))).length; if (!withIsrc) throw new Error('SoundCloud set exposed no ISRCs'); return { total: ordered.length, next: null }; } /* ═══════════════════════════════════════════════════════════════════════ AUDIOMACK (#664) — per-track ISRCs from an album (or a song, a single-track release). api.audiomack.com wants every call OAuth 1.0a-signed (HMAC-SHA1), but with the web player's own public consumer key + secret, so no login. music/album// carries the ordered tracks, each with its isrc, duration and url_slug; music/song// is one such track. Mapped BY POSITION (title + duration ride along for the #431 guard). The album's barcode (upc) is logged, not written — that's Platform Check's job. ═══════════════════════════════════════════════════════════════════════ */ const AK = { api: 'https://api.audiomack.com/v1/', key: 'audiomack-web', secret: 'bd8a07e9f23fbe9d808646b730f89b8e' }; const akEnc = s => encodeURIComponent(s).replace(/[!'()*]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase()); async function audiomackGet(endpoint, params) { const url = AK.api + endpoint; const p = Object.assign({}, params || {}, { oauth_consumer_key: AK.key, oauth_nonce: Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2), oauth_signature_method: 'HMAC-SHA1', oauth_timestamp: String(Math.floor(Date.now() / 1000)), oauth_version: '1.0' }); const qs = Object.keys(p).sort().map(k => akEnc(k) + '=' + akEnc(p[k])).join('&'); const enc = new TextEncoder(); const key = await crypto.subtle.importKey('raw', enc.encode(akEnc(AK.secret) + '&'), { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']); const sig = btoa(String.fromCharCode(...new Uint8Array(await crypto.subtle.sign('HMAC', key, enc.encode(['GET', akEnc(url), akEnc(qs)].join('&')))))); const r = await gmGet(url + '?' + qs + '&oauth_signature=' + akEnc(sig), { 'Accept': 'application/json' }); if (r.status !== 200) throw new Error('Audiomack ' + r.status + ' for ' + endpoint); let j; try { j = JSON.parse(r.responseText || 'null'); } catch (e) { throw new Error('Audiomack: malformed JSON (' + endpoint + ')'); } if (!j || !j.results) throw new Error('Audiomack: no results for ' + endpoint); return j.results; } // → { title, upc, tracks: [{ title, artist, isrc, dur, url }] } in album order async function audiomackRelease(albumUrl) { const m = String(albumUrl || '').match(/audiomack\.com\/([^/?#]+)\/(album|song)\/([^/?#]+)/i); if (!m) throw new Error('Audiomack: not an album or song URL'); const kind = m[2].toLowerCase(); const it = await audiomackGet('music/' + kind + '/' + m[1] + '/' + m[3]); const list = kind === 'album' ? (it.tracks || []) : [it]; return { title: it.title || '', upc: String(it.upc || '').replace(/\D/g, ''), tracks: list.map(t => ({ title: t.title || '', artist: t.artist || '', isrc: normalizeIsrc(t.isrc || ''), dur: Number(t.duration) > 0 ? msToMmSs(Number(t.duration) * 1000) : '', url: t.url_slug ? 'https://audiomack.com/' + (t.uploader_url_slug || (t.uploader && t.uploader.url_slug) || m[1]) + '/song/' + t.url_slug : '', })), }; } async function fetchAudiomack(albumId, onProgress, onIsrc) { if (onProgress) onProgress(0, 0); const rel = await audiomackRelease(albumId); Log.info('Audiomack "' + (rel.title || albumId) + '": ' + rel.tracks.length + ' track(s)' + (rel.upc ? ', barcode ' + rel.upc : '')); let n = 0; rel.tracks.forEach((t, i) => { const e = { isrc: t.isrc, title: t.title, artist: t.artist, disc: 1, pos: i + 1, dur: t.dur, url: t.url }; try { if (onIsrc && isValidIsrc(e.isrc)) onIsrc(e); } catch (err) { Log.warn('Audiomack map hiccup for ' + e.isrc + ': ' + errText(err)); } try { if (onProgress) onProgress(++n, rel.tracks.length); } catch (err) {} }); const withIsrc = rel.tracks.filter(t => isValidIsrc(t.isrc)).length; Log.info('Audiomack: ' + withIsrc + ' of ' + rel.tracks.length + ' track(s) carry an ISRC'); if (!withIsrc) throw new Error('Audiomack album exposed no ISRCs'); return { total: rel.tracks.length, next: null }; } /* ═══════════════════════════════════════════════════════════════════════ 7DIGITAL (#669) — per-track ISRCs from a 7digital release, through its catalogue API (api.7digital.com/1.2) with the key its own web store ships. That key may search but not read a release by id, so each MB track is looked up in track/search (with the album's title, after its artist, then alone), and the hits on the linked release are kept: each carries its ISRC, disc and per-disc position (`number`; `trackNumber` runs on across discs). A search for the album's title is no use: it matches track titles. The key in hand is replaced by the store's current one when the API refuses it, and kept for every MB tab (Platform Check reads the same). ═══════════════════════════════════════════════════════════════════════ */ const SD = { api: 'https://api.7digital.com/1.2/', lsKey: 'mbtools:7digital-key', key: '7drfpc993qp5', countries: ['GB', 'US'] }; let _sdKey = null, _sdKeyP = null; function sdKey(fresh) { if (_sdKey && !fresh) return Promise.resolve(_sdKey); if (!_sdKeyP) _sdKeyP = sdKeyFetch(fresh).finally(() => { _sdKeyP = null; }); return _sdKeyP; } async function sdKeyFetch(fresh) { if (!fresh) { let k = null; try { k = localStorage.getItem(SD.lsKey); } catch (e) {} return (_sdKey = k || SD.key); } Log.info('7digital: fetching the key from the store\'s JS'); const home = await gmGet('https://us.7digital.com/', { 'Accept': 'text/html' }); const js = ((home.responseText || '').match(/\/\/js-cdn\.7digital\.com\/static\/build\/javascript\/app\.[a-z0-9]+\.js/i) || [])[0]; if (!js) throw new Error('7digital: the store\'s JS was not found (home page HTTP ' + home.status + ')'); const r = await gmGet('https:' + js); const k = ((r.responseText || '').match(/oauth_consumer_key="([A-Za-z0-9]+)"/) || [])[1]; if (!k) throw new Error('7digital: no key in the store\'s JS'); try { localStorage.setItem(SD.lsKey, k); } catch (e) {} return (_sdKey = k); } // One API read: its JSON, or throws. A refused key (HTTP 401, error 4001) is replaced, once. async function sdApi(path, params) { for (let attempt = 0; attempt < 2; attempt++) { const key = await sdKey(attempt > 0); const qs = new URLSearchParams(Object.assign({}, params, { usageTypes: 'download', oauth_consumer_key: key })); const r = await gmGet(SD.api + path + '?' + qs, { 'Accept': 'application/json' }); let j = null; try { j = JSON.parse(r.responseText || 'null'); } catch (e) { /* "OAuth authentication error: …" is plain text */ } const code = j && j.error ? String(j.error.code) : ''; if ((r.status === 401 || code === '4001') && attempt === 0) { Log.warn('7digital: the key was refused (HTTP ' + r.status + (code ? ', error ' + code : '') + ') — fetching the store\'s current one'); continue; } if (r.status !== 200 || !j || j.status !== 'ok') throw new Error('7digital ' + path + ': HTTP ' + r.status + (j && j.error ? ' — ' + j.error.message : '')); return j; } throw new Error('7digital: the store\'s current key was refused too'); } // A 7digital release link → { id, country } (uk.7digital.com is GB's store; www or none leaves it open) function sdRef(u) { const m = String(u || '').match(/^https?:\/\/(?:([a-z]{2})\.|www\.)?(7digital\.com|zdigital\.com\.au)\/artist\/[^/?#]+\/release\/(?:[^/?#]*-)?(\d{3,})\/?(?:[?#]|$)/i); if (!m) return null; const sub = (m[1] || '').toLowerCase(); return { id: m[3], country: /zdigital/i.test(m[2]) ? 'AU' : sub === 'uk' ? 'GB' : sub ? sub.toUpperCase() : null }; } // The release's tracks that searching for `wanted` ([{ title, artist }], this release's tracks) finds, // `album` being this release's title: // → { id, tracks: [{ id, title, artist, isrc, disc, pos, dur }] } in disc/position order async function sevendigitalRelease(releaseUrl, wanted, onProgress, album) { const ref = sdRef(releaseUrl); if (!ref) throw new Error('7digital: not a release URL (…/artist//release/-)'); const found = new Map(); // a track already found by another track's search isn't searched for (the same title, exactly: "Hero (Intro)" is not "Hero") const has = t => [...found.values()].some(x => norm(unfeat(x.title)) === norm(unfeat(t.title))); const search = async (q, country) => { const j = await sdApi('track/search', { q, country, pageSize: '50' }); ((j.searchResults || {}).searchResult || []).map(x => x.track) .filter(t => t && t.release && String(t.release.id) === ref.id) .forEach(t => found.set(String(t.id), { id: String(t.id), title: t.title || '', artist: (t.artist || {}).name || '', isrc: normalizeIsrc(t.isrc || ''), disc: t.discNumber || 1, pos: t.number || t.trackNumber, dur: t.duration > 0 ? msToMmSs(t.duration * 1000) : '', })); }; const countries = [...new Set([ref.country].concat(SD.countries).filter(Boolean))]; for (const country of countries) { let done = 0; const queue = wanted.slice(); const worker = async () => { for (let t; (t = queue.shift());) { // with the album's title (often the whole album comes back for one); after its artist; alone // (a track artist 7digital writes differently) for (const q of [album ? t.title + ' ' + album : null, t.artist ? t.artist + ' ' + t.title : null, t.title]) { if (has(t)) break; if (q) await search(q, country); } if (onProgress) onProgress(++done, wanted.length); } }; await Promise.all(Array.from({ length: 6 }, worker)); // a search takes 1–3 s, so six at a time Log.info('7digital release ' + ref.id + ' (' + country + '): ' + found.size + ' track(s) found for ' + wanted.length + ' searched'); if (found.size) break; // the release is in this country's catalogue } return { id: ref.id, tracks: [...found.values()].sort((a, b) => a.disc - b.disc || a.pos - b.pos) }; } async function fetchSevendigital(releaseUrl, onProgress, onIsrc) { if (onProgress) onProgress(0, 0); const rel = await sevendigitalRelease(releaseUrl, RELEASE.tracks.map(t => ({ title: t.title, artist: t.artist })), onProgress, RELEASE.title); rel.tracks.forEach(t => { const e = { isrc: t.isrc, title: t.title, artist: t.artist, disc: t.disc, pos: t.pos, dur: t.dur }; try { if (onIsrc && isValidIsrc(e.isrc)) onIsrc(e); } catch (err) { Log.warn('7digital map hiccup for ' + e.isrc + ': ' + errText(err)); } }); const withIsrc = rel.tracks.filter(t => isValidIsrc(t.isrc)).length; Log.info('7digital: ' + withIsrc + ' of ' + rel.tracks.length + ' track(s) found carry an ISRC'); if (!rel.tracks.length) throw new Error('7digital: none of this release\'s tracks were found on release ' + rel.id); return { total: rel.tracks.length, next: null }; } async function fetchVolumo(albumId, onProgress, onIsrc) { if (onProgress) onProgress(0, 0); const idStr = String(albumId); // a 12–14-digit token is the ICPN (barcode) embedded in the canonical URL; a // shorter one is the internal album id. Both endpoints return the same shape. const path = idStr.length >= 12 ? '/album_by_icpn/' + idStr : '/albums/' + idStr; const r = await gmGet('https://volumo.com/api/v1' + path, { 'Accept': 'application/json' }); if (r.status !== 200) throw new Error('Volumo ' + r.status + ' for album ' + albumId); let j; try { j = JSON.parse(r.responseText || 'null'); } catch (e) { throw new Error('Volumo: malformed JSON'); } const a = Array.isArray(j) ? j[0] : (j && (j.album || j)); const list = (a && a.tracks) || []; Log.info('Volumo album "' + ((a && a.title) || albumId) + '": ' + list.length + ' track(s)'); let n = 0; list.forEach((t, i) => { const mix = t.version && !/^original mix$/i.test(t.version) ? ' (' + t.version + ')' : ''; // #387 per-track URL is /track/{id} — the slug is NOT part of MB's canonical form (chaban-mb), so // don't append it; the bare id 308-redirects to the slug URL in a browser anyway. const e = { isrc: normalizeIsrc(t.isrc || ''), title: (t.title || '') + mix, artist: ((t.artists || []).concat(t.featured_artists || [])).map(x => x && x.name).filter(Boolean).join(', '), disc: t.disc_number || 1, pos: t.number || t.track_number || (i + 1), dur: t.duration ? msToMmSs(t.duration) : '', url: t.id ? 'https://volumo.com/track/' + t.id : '', }; try { if (onIsrc && isValidIsrc(e.isrc)) onIsrc(e); } catch (err) { Log.warn('Volumo map hiccup for ' + e.isrc + ': ' + errText(err)); } try { if (onProgress) onProgress(++n, list.length); } catch (err) {} }); const withIsrc = list.filter(t => isValidIsrc(normalizeIsrc(t.isrc || ''))).length; if (!withIsrc) throw new Error('Volumo album exposed no ISRCs'); return { total: list.length, next: null }; } /* ═══════════════════════════════════════════════════════════════════════ HDTRACKS (#176 — clean unauthenticated, CORS-open JSON API; one call per album returns every track's ISRC inline, no per-track fan-out. The album id is a 24-char hex ObjectId; a numeric token (UPC, from a legacy valbum_code rel or from Platform Check) is resolved to that id via the search endpoint first. Bad ids / unknown barcodes return HTTP 200, so presence is read from the JSON body, never the status code.) ═══════════════════════════════════════════════════════════════════════ */ const HD_API = 'https://hdtracks.azurewebsites.net/api/v1'; async function fetchHDtracks(albumId, onProgress, onIsrc) { if (onProgress) onProgress(0, 0); let id = String(albumId).trim(); if (!/^[a-f0-9]{24}$/i.test(id)) { // a barcode/UPC — resolve to the ObjectId via search before fetching tracks const sr = await gmGet(HD_API + '/albums/search?q=' + encodeURIComponent(id), { 'Accept': 'application/json' }); if (sr.status !== 200) throw new Error('HDtracks search ' + sr.status + ' for ' + id); let sj; try { sj = JSON.parse(sr.responseText || 'null'); } catch (e) { throw new Error('HDtracks: malformed search JSON'); } const hit = (sj && Array.isArray(sj.albums) && sj.albums[0]) || null; if (!hit || !hit.id) throw new Error('HDtracks: no album for ' + id); id = hit.id; } const r = await gmGet(HD_API + '/album/' + id, { 'Accept': 'application/json' }); if (r.status !== 200) throw new Error('HDtracks ' + r.status + ' for album ' + id); let j; try { j = JSON.parse(r.responseText || 'null'); } catch (e) { throw new Error('HDtracks: malformed JSON'); } if (!j || !j.id) throw new Error('HDtracks: album ' + id + ' not found'); const list = (j && j.tracks) || []; Log.info('HDtracks album "' + (j.name || id) + '": ' + list.length + ' track(s)'); let n = 0; list.forEach((t, i) => { const e = { isrc: normalizeIsrc(t.isrc || ''), title: t.name || '', artist: t.mainArtist || '', // discIndex is present on some albums; album-wide `index` is the only // reliable ordering. Disc/position are display-only here — matching is by ISRC. disc: t.discIndex || 1, pos: t.index || (i + 1), dur: t.duration ? msToMmSs(t.duration * 1000) : '', // duration is seconds (float) }; try { if (onIsrc && isValidIsrc(e.isrc)) onIsrc(e); } catch (err) { Log.warn('HDtracks map hiccup for ' + e.isrc + ': ' + errText(err)); } try { if (onProgress) onProgress(++n, list.length); } catch (err) {} }); const withIsrc = list.filter(t => isValidIsrc(normalizeIsrc(t.isrc || ''))).length; if (!withIsrc) throw new Error('HDtracks album exposed no ISRCs'); return { total: list.length, next: null }; } /* ═══════════════════════════════════════════════════════════════════════ EDITOR MODAL — DOM ═══════════════════════════════════════════════════════════════════════ */ let overlay, modal, tbody, summaryEl, progEl, submitBtn; let _removalBusy = false; // #587: a Remove-ISRC batch is in flight let built = false; let noteEdited = false; // has the user hand-edited the edit note? const _isrcLookupCache = {}; // isrc -> SX rows (single-ISRC lookup cache) // Header line: script name, version, author, homepage — resolved from GM_info // (Violentmonkey/Greasemonkey: homepageURL; Tampermonkey: homepage) with // hard-coded fallbacks, so "undefined" never leaks into an edit note. function noteHeader() { let s = {}; try { if (typeof GM_info !== 'undefined' && GM_info.script) s = GM_info.script; } catch (e) {} const name = s.name || 'ISRC Scout'; const version = s.version || SCRIPT_VERSION; const author = s.author || 'majkinetor'; const homepage = s.homepageURL || s.homepage || SCRIPT_URL; return name + ' v' + version + ' by ' + author + ' - ' + homepage; } function defaultNote() { const subs = RELEASE ? RELEASE.tracks.filter(t => { const v = normalizeIsrc(t.pending); return v && isValidIsrc(v) && !t.existing.includes(v); }) : []; // per-source breakdown, e.g. "SoundExchange (2), Spotify (1), manual (1)" const counts = {}; subs.forEach(t => { const src = t.source || 'manual'; counts[src] = (counts[src] || 0) + 1; }); const breakdown = Object.keys(counts).sort().map(src => src + ' (' + counts[src] + ')').join(', '); const lines = [ noteHeader(), '', 'Release: ' + MB_ROOT + '/release/' + mbid, 'Added ' + subs.length + ' ISRC' + (subs.length === 1 ? '' : 's') + (breakdown ? ': ' + breakdown : ''), ]; return lines.join('\n'); } function ensureNote(force) { const ta = modal.querySelector('#ii-note-text'); if (force || (!noteEdited && !ta.value.trim())) { ta.value = defaultNote(); noteEdited = false; } } function getEditNote() { const ta = modal.querySelector('#ii-note-text'); return (noteEdited && ta.value.trim()) ? ta.value.trim() : defaultNote(); } // a "Remove ISRC" edit must NOT say "Added 0 ISRCs" — build a deletion-appropriate note function defaultRemovalNote(recs, total) { const isrcs = recs.flatMap(([, v]) => v.isrcs); return [ noteHeader(), '', 'Release: ' + MB_ROOT + '/release/' + mbid, 'Removed ' + total + ' ISRC' + (total === 1 ? '' : 's') + ' from ' + recs.length + ' recording' + (recs.length === 1 ? '' : 's') + (isrcs.length ? ': ' + isrcs.join(', ') : ''), ].join('\n'); } function getRemovalNote(recs, total) { const ta = modal.querySelector('#ii-note-text'); return (noteEdited && ta.value.trim()) ? ta.value.trim() : defaultRemovalNote(recs, total); // respect a hand-edited note } function refreshDeleteBtn() { const n = tbody.querySelectorAll('.ii-ex-del:checked').length; const btn = modal.querySelector('#ii-delete'); // #587: while a batch runs the button IS the progress counter — the per-row // repaint calls this to keep the checked count honest, and must not overwrite it. if (_removalBusy) return; btn.disabled = n === 0; btn.textContent = n ? '🗑 Delete ' + n + ' ISRC' + (n === 1 ? '' : 's') : '🗑 Delete checked'; } function buildModal() { if (built) return; built = true; overlay = document.createElement('div'); overlay.id = 'ii-overlay'; // #285: click the backdrop (or Esc) to close — safe now, since closeModal only // HIDES the window; everything entered stays in the DOM and is restored on reopen. overlay.addEventListener('click', () => closeModal()); modal = document.createElement('div'); modal.id = 'ii-modal'; modal.addEventListener('click', e => e.stopPropagation()); // clicks inside don't reach the backdrop modal.innerHTML = `
${mbuCfgHeader({ script: 'isrc_scout', name: 'ISRC Scout', version: SCRIPT_VERSION, icon: '', log: true, logId: 'ii-log-link' })}
MusicBrainz
Click Authorize → approve in the MusicBrainz tab → it captures the code and closes itself. If the tab can't close on its own, paste the code it shows into the box above (Enter to submit).
Settings
Spotify's own ISRC API needs a paid developer account, so the lookup goes through one of these services. If one is down or rate-limited, switch to the other (#603).
More
🕓 My ISRC edits on MusicBrainz

Bulk / Export

ISRCs
Paste one ISRC per line, in track order (blank line = skip a track). Lines like 3=USABC1234567 or USABC1234567 | 1.3 target a specific track number. Or paste JSON exported below.
Links
Export this release's recording streaming links — what's already linked plus what 🔗 Find links resolved (Deezer / Tidal / Bandcamp / Apple Music). Copied to the clipboard.

Edit note

Attached to every ISRC add/remove you submit. Auto-filled with the script name + counts; edit freely (your text is kept until you Reset).

Activity log


      
#Track ISRC: new Links: linked Links: new
`; document.body.appendChild(overlay); document.body.appendChild(modal); buildSxPanel(); tbody = modal.querySelector('#ii-tbody'); summaryEl = modal.querySelector('#ii-summary'); progEl = modal.querySelector('#ii-prog'); submitBtn = modal.querySelector('#ii-submit'); // #341: right-click a LINKED link toggles its "ended" flag — Ctrl = whole track, Alt = that // provider everywhere; right-clicking an already-ended (faded) link reverts it. MIDDLE-click // (with the same modifiers) does the actual removal (#301). Left-click just opens the link. tbody.addEventListener('contextmenu', e => { const a = e.target.closest('.ii-tl-linked .ii-tl.linked'); if (!a) return; e.preventDefault(); const tr = a.closest('tr[data-idx]'); if (!tr) return; const idx = +tr.dataset.idx; if (e.ctrlKey || e.metaKey) TrackLinks.endTrack(idx, a.dataset.code); else if (e.altKey) TrackLinks.endProvider(a.dataset.code, idx); else TrackLinks.endOne(idx, a.dataset.code); }); tbody.addEventListener('auxclick', e => { if (e.button !== 1) return; // middle button const a = e.target.closest('.ii-tl-linked .ii-tl.linked'); if (!a) return; e.preventDefault(); const tr = a.closest('tr[data-idx]'); if (!tr) return; const idx = +tr.dataset.idx; if (e.ctrlKey || e.metaKey) TrackLinks.removeTrack(idx); else if (e.altKey) TrackLinks.removeProvider(a.dataset.code); else TrackLinks.removeOne(idx, a.dataset.code); }); // stop the browser opening the link in a new tab on middle-click (fires on mousedown in some browsers) tbody.addEventListener('mousedown', e => { if (e.button === 1 && e.target.closest('.ii-tl-linked .ii-tl.linked')) e.preventDefault(); }); // Esc closes the modal (no ✕ in the header) — but first let an open pane close, // and ignore it while typing in a field. document.addEventListener('keydown', e => { if (e.key !== 'Escape' || !modal.classList.contains('open')) return; if (modal.querySelector('#ii-all-pop')) { closeAllPop(); return; } // #643: the All comparison first const openPane = modal.querySelector('.ii-pane.open'); if (openPane) { openPane.classList.remove('open'); return; } const a = document.activeElement; if (a && /^(INPUT|TEXTAREA)$/.test(a.tagName)) return; closeModal(); }); modal.querySelector('#ii-setup-toggle').addEventListener('click', () => togglePane('ii-setup-pane')); modal.querySelector('#ii-bulk-toggle').addEventListener('click', () => togglePane('ii-bulk-pane')); modal.querySelector('#ii-maximize-toggle').addEventListener('click', toggleMaximize); modal.querySelector('#ii-links-btn').addEventListener('click', () => TrackLinks.resolve()); // #219: resolve candidates // #406: no separate "Add links" button — the single Submit button below adds every // resolved link together with any pending ISRCs (right-click a candidate still adds one). modal.querySelector('#ii-sx-all').addEventListener('click', runSxAll); // bulk SoundExchange — unchanged (#181) // #471: "Clear" is now a small menu (Clear Links / Clear ISRCs / Clear All) — // it used to only clear entered ISRCs, but now that resolved-but-unadded // link candidates are equally visible, clearing needs to say which. const clearToggle = modal.querySelector('#ii-clear-toggle'), clearMenu = modal.querySelector('#ii-clear-menu'); clearToggle.addEventListener('click', () => { const open = clearMenu.classList.toggle('open'); clearToggle.setAttribute('aria-expanded', open ? 'true' : 'false'); }); modal.querySelector('#ii-clear-links').addEventListener('click', () => { clearMenu.classList.remove('open'); TrackLinks.clearResolved(); toast('Cleared resolved links'); }); modal.querySelector('#ii-clear-isrcs').addEventListener('click', () => { clearMenu.classList.remove('open'); clearPending(); }); modal.querySelector('#ii-clear-all').addEventListener('click', () => { clearMenu.classList.remove('open'); TrackLinks.clearResolved(); clearPending(); toast('Cleared entered ISRCs and resolved links'); }); document.addEventListener('mousedown', e => { if (!clearMenu.classList.contains('open')) return; if (e.target.closest('.ii-clear-wrap')) return; clearMenu.classList.remove('open'); clearToggle.setAttribute('aria-expanded', 'false'); }); applyColWidths(); wireColumnResize(); wireModalDragResize(); // Track-ISRC-provider menu (#181): the per-track [SX] buttons carry a ▾ that // opens this shared menu of providers available for THIS release; picking one // re-skins EVERY per-track button (global, not persisted). The bulk // SoundExchange button above is intentionally left alone. document.addEventListener('mousedown', e => { const menu = modal.querySelector('#ii-prov-menu'); if (!menu || !menu.classList.contains('open')) return; if (e.target.closest('#ii-prov-menu') || e.target.closest('.ii-sxprov')) return; closeProvMenu(); }); // log pane — opened from the config window's "Log" link (#301) Log.setPane(modal.querySelector('#ii-log-out')); modal.querySelector('#ii-log-link').addEventListener('click', () => togglePane('ii-log-pane')); modal.querySelector('#ii-log-copy').addEventListener('click', () => { // Wrap in a collapsed
+ fenced block so it pastes into a GitHub // issue/comment as a tidy, foldable log rather than a wall of text. try { navigator.clipboard.writeText('
ISRC Scout Log\n\n```\n' + Log.text().trim() + '\n```\n\n
\n'); toast('Log copied'); } catch (e) { toast('Copy failed', 'err'); } }); modal.querySelector('#ii-log-clear').addEventListener('click', () => Log.clear()); // SX exact toggles + their collapsible container (collapsed state persisted) const sxGroup = modal.querySelector('#ii-sx-group'); const exactToggle = modal.querySelector('#ii-exact-toggle'); // reflect "any exact option active" on the toggle, so it's visible even when collapsed const refreshExactToggle = () => { const anyOn = sxExact.title || sxExact.artist || sxExact.release; exactToggle.classList.toggle('on', !!anyOn); exactToggle.title = anyOn ? 'Exact-match active: ' + ['title', 'artist', 'release'].filter(k => sxExact[k]).join(', ') : 'Exact-match options'; }; const applyExactCollapsed = (collapsed) => { sxGroup.classList.toggle('ii-collapsed', collapsed); exactToggle.setAttribute('aria-expanded', String(!collapsed)); }; applyExactCollapsed(store.get('sx_exact_collapsed', true)); // collapsed by default to keep the toolbar compact exactToggle.addEventListener('click', () => { const collapsed = !sxGroup.classList.contains('ii-collapsed'); applyExactCollapsed(collapsed); store.set('sx_exact_collapsed', collapsed); }); [['ii-ex-title', 'title'], ['ii-ex-artist', 'artist'], ['ii-ex-release', 'release']].forEach(([id, key]) => { const cb = modal.querySelector('#' + id); cb.checked = sxExact[key]; cb.addEventListener('change', () => { sxExact[key] = cb.checked; saveSxExact(); refreshExactToggle(); Log.info('SX exact ' + key + ' = ' + cb.checked); }); }); refreshExactToggle(); // Import-source buttons: independently show icons and/or text labels // (persisted; default icons-only). Never both off — re-check the last one. const tools = modal.querySelector('#ii-tools'); const cbIcons = modal.querySelector('#ii-show-icons'); const cbText = modal.querySelector('#ii-show-text'); const applySrcDisp = () => { tools.classList.toggle('ii-show-icons', cbIcons.checked); tools.classList.toggle('ii-show-text', cbText.checked); }; cbIcons.checked = store.get('src_show_icons', true); cbText.checked = store.get('src_show_text', false); applySrcDisp(); const onSrcDispChange = changed => { if (!cbIcons.checked && !cbText.checked) { changed.checked = true; } // keep at least one visible store.set('src_show_icons', cbIcons.checked); store.set('src_show_text', cbText.checked); applySrcDisp(); Log.info('Source buttons: icons=' + cbIcons.checked + ' text=' + cbText.checked); }; cbIcons.addEventListener('change', () => onSrcDispChange(cbIcons)); cbText.addEventListener('change', () => onSrcDispChange(cbText)); // #302: pull provider links from the whole release group (opt-in) const cbRg = modal.querySelector('#ii-rg-providers'); if (cbRg) { cbRg.checked = rgProvidersEnabled(); cbRg.addEventListener('change', () => { store.set('rg_providers', cbRg.checked); Log.info('Release-group providers: ' + (cbRg.checked ? 'on' : 'off') + (cbRg.checked ? ' — reopen / reload to rescan' : '')); }); } // #314: respect / ignore Platform Check's barcode-mismatch link confidence const cbPc = modal.querySelector('#ii-ignore-pc'); if (cbPc) { cbPc.checked = ignorePcConfidence(); cbPc.addEventListener('change', () => { store.set('ignore_pc_confidence', cbPc.checked); Log.info('Ignore Platform Check link confidence: ' + (cbPc.checked ? 'on — barcode/format-mismatched PC links will be used' : 'off — PC-withheld links are skipped')); }); } // #603: which service resolves Spotify ISRCs const selSp = modal.querySelector('#ii-sp-source'); if (selSp) { selSp.value = spotifyIsrcSource(); selSp.addEventListener('change', () => { store.set('spotify_isrc_source', selSp.value); Log.info('Spotify ISRC source: ' + (selSp.value === 'isrchunt' ? 'ISRC Hunt' : 'molla')); }); } modal.querySelector('#ii-dz-all').addEventListener('click', runDeezer); modal.querySelector('#ii-sp-all').addEventListener('click', runSpotify); modal.querySelector('#ii-bp-all').addEventListener('click', runBeatport); modal.querySelector('#ii-td-all').addEventListener('click', runTidal); modal.querySelector('#ii-vo-all').addEventListener('click', runVolumo); modal.querySelector('#ii-hd-all').addEventListener('click', runHDtracks); modal.querySelector('#ii-qz-all').addEventListener('click', runQobuz); modal.querySelector('#ii-am-all').addEventListener('click', runApple); modal.querySelector('#ii-sc-all').addEventListener('click', runSoundcloud); modal.querySelector('#ii-ak-all').addEventListener('click', runAudiomack); modal.querySelector('#ii-sd-all').addEventListener('click', runSevendigital); // #643: All from the toolbar — every track on every provider, as a right-click on a row's All modal.querySelector('#ii-all-all').addEventListener('click', () => { Log.info('All: run from the toolbar'); if (trackProvAvailable('all')) setTrackProvider('all', { quiet: true }); runAllTracks(); }); // Unified "paste a URL" control (#180) — apollo-style unroll. Click the + // to reveal the input; paste any streaming album URL; on Enter the platform // is auto-detected and imported. Replaces the per-provider ▾ submenus. const urlWrap = modal.querySelector('#ii-urladd'); const urlBtn = modal.querySelector('#ii-url-btn'); const urlInput = modal.querySelector('#ii-url-input'); const openUrlAdd = () => { urlWrap.classList.add('open'); _setTimeout(() => urlInput.focus(), 0); }; const closeUrlAdd = () => { urlWrap.classList.remove('open'); urlInput.value = ''; reflectDetectedSource(''); }; urlBtn.addEventListener('click', () => urlWrap.classList.contains('open') ? closeUrlAdd() : openUrlAdd()); urlInput.addEventListener('input', () => reflectDetectedSource(urlInput.value)); urlInput.addEventListener('keydown', e => { if (e.key === 'Enter') { submitUrlAdd(urlInput.value); closeUrlAdd(); } else if (e.key === 'Escape') closeUrlAdd(); }); // Auto-import on paste — no Enter needed when a recognized album URL is pasted // (#300). Unrecognized text pastes normally so it can still be edited + Entered. urlInput.addEventListener('paste', e => { const v = ((e.clipboardData && e.clipboardData.getData('text')) || '').trim(); if (v && detectSource(v)) { e.preventDefault(); reflectDetectedSource(v); submitUrlAdd(v); closeUrlAdd(); } }); // collapse on click-outside (only when empty, so a half-typed URL isn't lost) document.addEventListener('mousedown', e => { if (!urlWrap.classList.contains('open')) return; if (urlWrap.contains(e.target)) return; if (!urlInput.value.trim()) closeUrlAdd(); }); submitBtn.addEventListener('click', doSubmitAll); // delete-existing wiring (checkboxes are delegated) modal.querySelector('#ii-delete').addEventListener('click', doDelete); tbody.addEventListener('change', e => { if (!e.target.classList.contains('ii-ex-del')) return; e.target.closest('.ii-ex-item').classList.toggle('del', e.target.checked); refreshDeleteBtn(); }); modal.querySelector('#ii-ex-all').addEventListener('change', e => { const on = e.target.checked; tbody.querySelectorAll('.ii-ex-del').forEach(cb => { cb.checked = on; cb.closest('.ii-ex-item').classList.toggle('del', on); }); refreshDeleteBtn(); }); // edit-note pane wiring modal.querySelector('#ii-note-toggle').addEventListener('click', () => { ensureNote(); togglePane('ii-note-pane'); }); modal.querySelector('#ii-note-reset').addEventListener('click', () => { noteEdited = false; ensureNote(true); }); modal.querySelector('#ii-note-text').addEventListener('input', () => { noteEdited = true; }); // setup pane wiring modal.querySelector('#ii-authorize').addEventListener('click', onAuthorize); modal.querySelector('#ii-signout').addEventListener('click', () => { Auth.signOut(); refreshAuthState(); toast('Signed out'); }); const codeInput = modal.querySelector('#ii-oauth-code'); const tryCode = () => { const c = codeInput.value.trim(); if (c) { codeInput.value = ''; exchangeAndFinish(c, 'pasted'); } }; codeInput.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); tryCode(); } }); codeInput.addEventListener('paste', () => _setTimeout(tryCode, 50)); // bulk pane wiring modal.querySelector('#ii-bulk-apply').addEventListener('click', () => applyBulk(false)); modal.querySelector('#ii-bulk-apply-all').addEventListener('click', () => applyBulk(true)); modal.querySelector('#ii-export-text').addEventListener('click', exportText); modal.querySelector('#ii-export-json').addEventListener('click', exportJson); // #301: link export (Links scope) modal.querySelector('#ii-link-export-csv').addEventListener('click', () => exportLinks('csv')); modal.querySelector('#ii-link-export-json').addEventListener('click', () => exportLinks('json')); modal.querySelector('#ii-link-copy-urls').addEventListener('click', () => exportLinks('urls')); // each sub-pane closes via its own ✕ (next to the title) modal.querySelectorAll('.ii-pane-x').forEach(b => b.addEventListener('click', () => b.closest('.ii-pane').classList.remove('open'))); } function togglePane(id) { modal.querySelectorAll('.ii-pane').forEach(p => { if (p.id === id) p.classList.toggle('open'); else p.classList.remove('open'); }); } // #564-adjacent (majkinetor): "lets also add click to 'see log' message like in // Fusion, so it opens a log window". Opening is not toggling — a status text // that says "see Log" must SHOW the log, and clicking it a second time closing // the thing it just offered would be its own small bug. function showPane(id) { modal.querySelectorAll('.ii-pane').forEach(p => p.classList.toggle('open', p.id === id)); const p = modal.querySelector('#' + id); if (p) try { p.scrollIntoView({ block: 'nearest' }); } catch (e) {} } // #471: ISRCs and Links used to be exclusive tabs, each hiding the other's table // columns — "I often have to switch back to ISRC tab before finding links". Both // sets of columns, both toolbars, and both Bulk/Export sections are now always // visible at once (see #ii-colgroup, #ii-tools and #ii-bulk-pane above) — there's // no separate "scope" left to track. // #471 ("make table columns resizable") — a drag handle on the right edge of // every header cell except the first (#, 32px, not worth resizing) and the // last (Links: new — nothing to its right within the table). Dragging changes // ONLY that column's width (table-layout:fixed), so #ii-body scrolls // horizontally past a widened table instead of squeezing a neighbor — the // simplest model, and consistent with how a spreadsheet resizes a column. // Widths persist per-browser via GM storage, keyed by column index. const COL_MIN = 28; function loadColWidths() { try { return JSON.parse(store.get('col_widths', '') || '{}') || {}; } catch (e) { return {}; } } function saveColWidths(w) { store.set('col_widths', JSON.stringify(w)); } function applyColWidths() { const w = loadColWidths(); const cols = modal.querySelectorAll('#ii-colgroup col'); Object.keys(w).forEach(i => { if (cols[i]) cols[i].style.width = w[i] + 'px'; }); } function wireColumnResize() { const ths = [...modal.querySelectorAll('#ii-table thead th')]; const cols = [...modal.querySelectorAll('#ii-colgroup col')]; ths.forEach((th, i) => { if (i === 0 || i === ths.length - 1 || th.querySelector('.ii-col-resize')) return; const handle = document.createElement('span'); handle.className = 'ii-col-resize'; th.appendChild(handle); let dragging = false, startX = 0, startW = 0; handle.addEventListener('mousedown', e => { dragging = true; handle.classList.add('dragging'); startX = e.clientX; startW = cols[i].getBoundingClientRect().width; e.preventDefault(); e.stopPropagation(); }); window.addEventListener('mousemove', e => { if (!dragging) return; cols[i].style.width = Math.max(COL_MIN, Math.round(startW + (e.clientX - startX))) + 'px'; }); window.addEventListener('mouseup', () => { if (!dragging) return; dragging = false; handle.classList.remove('dragging'); const w = loadColWidths(); w[i] = parseInt(cols[i].style.width, 10); saveColWidths(w); }); }); } // #471 ("Lets make window movable, resizable and with maximize button") — same // pattern Falcon's panel already uses: drag the header to move, CSS `resize` // for free corner-resize, and a maximize toggle that detaches from centering // and restores the exact prior box on toggle-back. function wireModalDragResize() { const hdr = modal.querySelector('#ii-hdr'); let dragging = false, dx = 0, dy = 0; hdr.addEventListener('mousedown', e => { if (e.target.closest('button, a, input')) return; dragging = true; const r = modal.getBoundingClientRect(); modal.style.transform = 'none'; modal.style.left = r.left + 'px'; modal.style.top = r.top + 'px'; dx = e.clientX - r.left; dy = e.clientY - r.top; e.preventDefault(); }); window.addEventListener('mousemove', e => { if (!dragging) return; modal.style.left = Math.max(0, Math.min(window.innerWidth - 80, e.clientX - dx)) + 'px'; modal.style.top = Math.max(0, Math.min(window.innerHeight - 40, e.clientY - dy)) + 'px'; }); window.addEventListener('mouseup', () => { dragging = false; }); } let _maxed = false, _prevBox = null; // #484: pulled out of toggleMaximize() so openModal() can reapply it — pinModalToViewport() // (the mobile-viewport handling below) clears left/top/width/height/max-width/max-height/ // transform on every open on a DESKTOP viewport (clearModalViewportPin(), so the mobile pin // never lingers once the window widens back out) — the exact same properties this sets. That // silently wiped the maximized size on every reopen while _maxed stayed true, so the toggle // button kept showing "Restore" for a window that had quietly gone back to its normal size. function applyMaximizedStyle() { modal.style.left = '2vw'; modal.style.top = '2vh'; modal.style.transform = 'none'; modal.style.width = '96vw'; modal.style.maxWidth = '96vw'; modal.style.height = '96vh'; modal.style.maxHeight = '96vh'; } function toggleMaximize() { const btn = modal.querySelector('#ii-maximize-toggle'); if (!_maxed) { _prevBox = { left: modal.style.left, top: modal.style.top, width: modal.style.width, height: modal.style.height, maxWidth: modal.style.maxWidth, maxHeight: modal.style.maxHeight, transform: modal.style.transform }; applyMaximizedStyle(); _maxed = true; if (btn) { btn.textContent = '❐'; btn.title = 'Restore'; } } else { if (_prevBox) Object.assign(modal.style, _prevBox); _maxed = false; if (btn) { btn.textContent = '⛶'; btn.title = 'Maximize'; } } } // Some tablets render MusicBrainz's desktop layout wider than the browser's // layout viewport, so the page overflows and the browser zooms-to-fit. A // position:fixed modal is then sized/anchored to the (narrow) layout viewport // and lands in a corner at ~64% width. Pin the modal + overlay to the VISUAL // viewport instead — it reflects what's actually on screen (zoom scale + // offset). CSS handles the normal case; this only engages on narrow viewports // (where the mobile layout is active) and is a no-op without VisualViewport. let _vvSync = null; function pinModalToViewport() { const vv = window.visualViewport; if (!vv || !window.matchMedia('(max-width: 700px)').matches) { clearModalViewportPin(); return; } const w = Math.min(vv.width * 0.96, 1080), h = vv.height * 0.96; const set = (el, props) => { for (const k in props) el.style.setProperty(k, props[k], 'important'); }; set(overlay, { left: vv.offsetLeft + 'px', top: vv.offsetTop + 'px', width: vv.width + 'px', height: vv.height + 'px' }); set(modal, { left: (vv.offsetLeft + (vv.width - w) / 2) + 'px', top: (vv.offsetTop + (vv.height - h) / 2) + 'px', width: w + 'px', 'max-width': 'none', height: h + 'px', 'max-height': 'none', transform: 'none', }); } function clearModalViewportPin() { ['left', 'top', 'width', 'max-width', 'height', 'max-height', 'transform'].forEach(p => modal.style.removeProperty(p)); ['left', 'top', 'width', 'height'].forEach(p => overlay.style.removeProperty(p)); } function openModal() { buildModal(); overlay.classList.add('open'); modal.classList.add('open'); pinModalToViewport(); if (_maxed) applyMaximizedStyle(); // #484: pinModalToViewport() just cleared it on a desktop viewport if (window.visualViewport && !_vvSync) { _vvSync = () => { pinModalToViewport(); if (_maxed) applyMaximizedStyle(); }; // #484: same wipe risk on resize/scroll while maximized window.visualViewport.addEventListener('resize', _vvSync); window.visualViewport.addEventListener('scroll', _vvSync); } refreshAuthState(); if (!RELEASE) { tbody.innerHTML = 'Loading release…'; fetchRelease() .then(renderTracks) // existing track links ride along on the release fetch (recording-level-rels) .catch(err => { tbody.innerHTML = 'Failed to load release: ' + esc(err.message) + ''; }); } else { renderTracks(); } } function closeModal() { // #587 — a removal batch is still POSTing; closing hides the only progress // there is, so make it a decision rather than an accident. if (_removalBusy && !confirm('Remove-ISRC edits are still being submitted.\n\nClose anyway? The submissions already sent stay submitted; the rest will not be sent.')) return; abortSxWork('window closed'); // don't leave batched SX requests running in the background (#127) if (_vvSync && window.visualViewport) { window.visualViewport.removeEventListener('resize', _vvSync); window.visualViewport.removeEventListener('scroll', _vvSync); _vvSync = null; } clearModalViewportPin(); overlay.classList.remove('open'); modal.classList.remove('open'); } function refreshAuthState() { const el = modal.querySelector('#ii-auth-state'); const pane = modal.querySelector('#ii-setup-pane'); const authed = Auth.isAuthorized(); // when authorized show Sign out; when not, show the code field in its place const code = modal.querySelector('#ii-oauth-code'), out = modal.querySelector('#ii-signout'); if (code) code.style.display = authed ? 'none' : ''; if (out) out.style.display = authed ? '' : 'none'; if (authed && code) code.value = ''; if (authed) { el.className = 'ii-authstate ok'; el.textContent = '✓ Authorized — submit is ready.'; } else { el.className = 'ii-authstate no'; el.textContent = '• Not authorized yet. Click Authorize (one time).'; pane.classList.add('open'); // nudge first-time users } } /* ═══════════════════════════════════════════════════════════════════════ TRACK LINKS (#219 PoC) — per-track provider link icons under the artist line. Two streaming providers that expose a stable, public per-track URL resolvable by ISRC (and already wired in Scout): Deezer and Tidal. • faded monochrome → the recording already links to that provider in MB (read once from a recording browse with url-rels) • full brand colour → the track resolves on the provider by ISRC but isn't linked yet — an "add" candidate The actual background add is the next spike: WS2 only submits ISRCs, so adding URL relationships needs MB's website edit API, not submitIsrcs(). ═══════════════════════════════════════════════════════════════════════ */ const TrackLinks = (function () { // #481 (majkinetor): a link we resolve ourselves reads cosmetically // different from the one Harmony seeds for the SAME track — Tidal's API // hands back a `/browse/track/…` path, Apple's ld+json/amp-api keep the // descriptive title slug — even though MB treats both forms as the same // relationship. MB's own client-side URLCleanup.js is the authority on // the canonical shape (github: metabrainz/musicbrainz-server, root/ // static/scripts/edit/URLCleanup.js); mirrored here for just these two // rules so our "add" candidates already look like Harmony's: // tidal: strips any locale/browse/store prefix → https://tidal.com/track/ // apple: strips the descriptive slug → https://music.apple.com//song/ function normalizeProviderUrl(code, url) { if (!url) return url; if (code === 'td') return url.replace(/^https?:\/\/(?:[^/]+\.)?tidal\.com\/(?:[a-z]{2}\/)?(?:browse\/|store\/)?([a-z]+)\/(\d+).*$/i, 'https://tidal.com/$1/$2'); if (code === 'am') { // MB applies this pair in order: an album link carrying a track's ?i= // query param becomes a song link FIRST, then the descriptive slug // (on any of artist/album/…/song) is stripped. url = url.replace(/^(https:\/\/(?:classical\.)?music\.apple\.com\/[a-z]{2})\/album\/[^?#/]+\/[0-9]+\?i=([0-9]+)$/i, '$1/song/$2'); return url.replace(/^(https:\/\/(?:classical\.)?music\.apple\.com\/[a-z]{2})\/(artist|album|author|label|music-video|song)\/(?:[^?#/]+\/)?(?:id)?([0-9]+)(?:\?.*)?$/i, '$1/$2/$3'); } return url; } // linkTypeID: recording↔url relationship type (confirmed live against MB's // editor): 268 = "free streaming" (Deezer/Spotify free tier), 979 = // "streaming" / streaming page (Tidal, subscription). const PROV = [ { code: 'dz', name: 'Deezer', color: _PROV_COLOR.deezer, icon: SRC_ICON.dz, linkTypeID: 268, conc: 3, gap: 60, // #307: a few in flight; a quota hit RECOVERS below (back off + retry) instead of false-negativing // #406: accept deezer.com with OR without www. The old `(?:^|\.)` anchor required a // leading dot (i.e. www.), so a bare https://deezer.com/track/… link went unrecognised — // it showed as a generic globe in LINKED *and* got re-offered as an addable Deezer // candidate in ADD (Submit would then add a duplicate www link). Use the [./] form the // other providers already use. test: u => /(?:^|[./])deezer\.com\/(?:[a-z]{2}\/)?track\/\d+/i.test(u), async resolve(isrc) { // Deezer signals a rate limit as HTTP 429 or an error body with code 4 (quota) / 700 (busy); // a genuine miss is code 800 ("no data"). Retry only the rate-limit cases, so throttling // never looks like "not found". #307 for (let attempt = 0; ; attempt++) { const r = await gmGet('https://api.deezer.com/track/isrc:' + encodeURIComponent(isrc), { 'Accept': 'application/json' }); let j = null; try { j = JSON.parse(r.responseText || 'null'); } catch (e) {} const throttled = r.status === 429 || (j && j.error && (j.error.code === 4 || j.error.code === 700)); if (throttled && attempt < 4) { await sleep(Math.min(700 * Math.pow(2, attempt), 6000)); continue; } if (r.status !== 200 || !j || j.error || !j.id) return null; // Don't offer a DEAD track: Deezer keeps the ISRC mapping after pulling the // audio, returning a track that streams in zero countries (readable:false, // available_countries:[]). That's a broken link, so treat it as not found. if (j.readable === false && Array.isArray(j.available_countries) && j.available_countries.length === 0) return null; return j.link || ('https://www.deezer.com/track/' + j.id); } } }, { code: 'td', name: 'Tidal', color: _PROV_COLOR.tidal, icon: SRC_ICON.td, linkTypeID: 979, conc: 3, gap: 0, // #307: tidalGet() self-recovers from 429 (backoff+retry), so a burst is safe — it never false-negatives test: u => /tidal\.com\/(?:browse\/)?track\/\d+/i.test(u), async resolve(isrc) { const token = await tidalToken(); const headers = { 'Authorization': 'Bearer ' + token, 'Accept': 'application/vnd.api+json' }; const r = await tidalGet(TIDAL.api + '/tracks?countryCode=' + TIDAL.country + '&filter%5Bisrc%5D=' + encodeURIComponent(isrc), headers); if (r.status !== 200) return null; let j; try { j = JSON.parse(r.responseText || '{}'); } catch (e) { return null; } const t = (j.data || [])[0]; return t && t.id ? ('https://tidal.com/browse/track/' + t.id) : null; } }, // YouTube Music (#640): it shows no ISRCs, but its search (the "songs" filter) finds most // songs by one. A miss comes back as unrelated songs rather than nothing, so ytResolve // takes a hit only when it is official audio, its title matches and its length is within // 3 s of the track's. The free tier plays everything → "free streaming" (268). { code: 'yt', name: 'YouTube Music', color: _PROV_COLOR.ytmusic, icon: SRC_ICON.yt, linkTypeID: 268, conc: 2, gap: 150, // one search per ISRC; an internal API with no published limit, so gently test: u => /music\.youtube\.com\/watch\?(?:[^#]*&)?v=[\w-]{11}/i.test(u), resolve: (isrc, t, idx) => ytResolve(isrc, t, idx) }, // Bandcamp (#300): no ISRC — resolve by ALBUM. The release's Bandcamp album // page lists every track's URL in order, so we match by position (with a // title sanity-check). Only offered when the release has a Bandcamp album link. { code: 'bc', name: 'Bandcamp', color: _PROV_COLOR.bandcamp, icon: SRC_ICON.bc, linkTypeID: 268, album: true, urlKey: 'bandcampUrl', conc: 99, gap: 0, // #307: one album fetch (cached), the rest is local test: u => /\.bandcamp\.com\/track\//i.test(u), resolve: (isrc, t, idx) => bcResolve(t, idx) }, // Amazon Music (#644): no ISRCs — by the release's album, like Bandcamp: position, title and // length checked (azResolve). Subscription streaming → "streaming page" (979), as MusicBrainz's // editor types a music.amazon.com/tracks link. Only offered when the release has an Amazon Music album link. { code: 'az', name: 'Amazon Music', color: _PROV_COLOR.amazonmusic, icon: SRC_ICON.az, linkTypeID: 979, album: true, urlKey: 'amzUrl', conc: 99, gap: 0, // one album fetch (cached), the rest is local test: u => /music\.amazon\.[a-z.]+\/tracks\/[A-Z0-9]{10}/i.test(u), resolve: (isrc, t, idx) => azResolve(t, idx) }, // Apple Music (#like Bandcamp): the album page's ld+json lists every track URL, // matched by position. Subscription streaming → linkType 979. { code: 'am', name: 'Apple Music', color: _PROV_COLOR.apple, icon: SRC_ICON.am, linkTypeID: 979, album: true, urlKey: 'appleUrl', conc: 99, gap: 0, // #307: one album fetch (cached), the rest is local test: u => /music\.apple\.com\/[a-z]{2}\/(?:song\/|album\/[^"\s]*[?&]i=)/i.test(u), resolve: (isrc, t, idx) => amResolve(t, idx) }, // Volumo (#387): a download store — recording↔url "purchase for download" (254). No global by-ISRC // endpoint, but the release's Volumo album JSON (fetched once, cached) carries every track's ISRC and // id, so we match by ISRC and hand back the per-track URL. Only offered when the release has a Volumo link. { code: 'vo', name: 'Volumo', color: _PROV_COLOR.volumo, icon: SRC_ICON.vo, linkTypeID: 254, album: true, urlKey: 'volumoId', conc: 99, gap: 0, test: u => /(?:^|[./])volumo\.com\/track\//i.test(u), // note the char class incl. '/': the URL is https://volumo.com/… (no www) resolve: (isrc) => provAlbumUrl('volumo', isrc) }, // Beatport (#387): same album-scoped, ISRC-matched shape as Volumo. Its tracklist (with track // id+slug) comes from the API when logged in via Platform Check, else from the Cloudflare tab harvest. { code: 'bp', name: 'Beatport', color: _PROV_COLOR.beatport, icon: SRC_ICON.bp, linkTypeID: 254, album: true, urlKey: 'beatportId', conc: 99, gap: 0, test: u => /(?:^|[./])beatport\.com\/track\//i.test(u), resolve: (isrc) => provAlbumUrl('beatport', isrc) }, // Qobuz (#353/#387): album-scoped, ISRC-matched like Volumo/Beatport. album/get (with the shared // Platform Check login token) carries every track's ISRC + id; the per-track link is the id-only // open.qobuz.com/track/ — recording↔url "purchase for download" (254). Needs the Qobuz login. { code: 'qz', name: 'Qobuz', color: _PROV_COLOR.qobuz, icon: SRC_ICON.qz, linkTypeID: 254, album: true, urlKey: 'qobuzId', conc: 99, gap: 0, test: u => /qobuz\.com\/(?:[a-z]{2}-[a-z]{2}\/)?track\//i.test(u), resolve: (isrc) => provAlbumUrl('qobuz', isrc) }, // SoundCloud (#439): the set (fetched once, cached) carries every track's ISRC + // permalink_url, so match by ISRC and hand back the per-track URL. Free streaming → // recording↔url "free streaming" (268). The per-track link is a plain user/slug // permalink — NOT a /sets/ URL (that's the album). { code: 'sc', name: 'SoundCloud', color: _PROV_COLOR.soundcloud, icon: SRC_ICON.sc, linkTypeID: 268, album: true, urlKey: 'scUrl', conc: 99, gap: 0, test: u => /soundcloud\.com\/[^/]+\/(?!sets\/)[^/?#]+/i.test(u), resolve: (isrc, t, idx) => scResolve(t, idx) }, // Audiomack (#664): the album (fetched once, cached) lists every song's page, in order; // matched BY POSITION with a title guard. Free streaming → recording↔url "free streaming" (268). { code: 'ak', name: 'Audiomack', color: _PROV_COLOR.audiomack, icon: SRC_ICON.ak, linkTypeID: 268, album: true, urlKey: 'akUrl', conc: 99, gap: 0, test: u => /audiomack\.com\/[^/]+\/song\//i.test(u), resolve: (isrc, t, idx) => akResolve(t, idx) }, // Spotify (#458): the token-free /embed/album/ page ships the ordered tracklist, // matched per track BY POSITION with a title guard (like Bandcamp/Apple). Free tier → // recording↔url "free streaming" (268). Only offered when the release has a Spotify album link. { code: 'sp', name: 'Spotify', color: _PROV_COLOR.spotify, icon: SRC_ICON.sp, linkTypeID: 268, album: true, urlKey: 'spotifyId', conc: 99, gap: 0, test: u => /open\.spotify\.com\/(?:intl-[a-z-]+\/)?track\//i.test(u), resolve: (isrc, t, idx) => spResolve(t, idx) }, ]; let resolving = false; // Existing url rels come free with fetchRelease (inc=recording-level-rels), so // we read what's already linked straight off the track — no extra request. const linkedUrl = (t, p) => (t.recUrls || []).find(u => p.test(u)) || null; // Bandcamp album page (fetched once): ordered [{title, url}] from data-tralbum. let _bcList = null, _bcPromise = null; async function bcAlbum() { if (_bcList && _bcList.length) return _bcList; // an empty result (network hiccup, anti-bot, transient block) must NOT stick — retry on the next Find links click rather than caching a permanent miss for the rest of the page session if (_bcPromise) return _bcPromise; const url = RELEASE && RELEASE.bandcampUrl; if (!url) { _bcList = []; return _bcList; } _bcPromise = (async () => { const r = await gmGet(url, { 'Accept': 'text/html' }); if (r.status !== 200) return []; const m = (r.responseText || '').match(/data-tralbum="([^"]+)"/); if (!m) return []; const json = m[1].replace(/"/g, '"').replace(/�?39;/g, "'").replace(/&/g, '&'); let j; try { j = JSON.parse(json); } catch (e) { return []; } let origin = ''; try { origin = new URL(j.url).origin; } catch (e) { origin = url.replace(/\/album\/.*$/, ''); } return (j.trackinfo || []).map(ti => ({ title: ti.title || '', url: ti.title_link ? origin + ti.title_link : '' })).filter(ti => ti.url); })(); _bcList = await _bcPromise.catch(() => []); _bcPromise = null; return _bcList; } // #463 keep ALL Unicode letters/numbers, not just ASCII — else a non-Latin title (Cyrillic, // CJK, …) collapses to '' and the position title-guard bails, refusing an otherwise-identical // match (e.g. "слезы завтра"). NFD + the 0x300-0x36f drop still folds Latin diacritics (café→cafe). const _nrm = s => [...(s || '').toLowerCase().normalize('NFD')].filter(c => { const x = c.charCodeAt(0); return x < 0x300 || x > 0x36f; }).join('').replace(/[^\p{L}\p{N}]+/gu, ' ').trim(); // A position match agrees on the title: the same, one inside the other (a version tag), or the // same words with a typo in one (#661: Apple's "Les Ecrocs" is MusicBrainz's "Les Escrocs") function _sameTitle(x, y) { const a = _nrm(x), b = _nrm(y); if (!a || !b) return false; if (a === b || a.indexOf(b) >= 0 || b.indexOf(a) >= 0) return true; const aw = a.split(' '), bw = b.split(' '); return aw.length === bw.length && aw.every((w, i) => wordTypo(w, bw[i])); } async function bcResolve(t, idx) { const list = await bcAlbum(); const e = list[idx]; if (!e) return null; // position match must agree on title (else the editions are out of sync) — don't add a likely-wrong link return _sameTitle(e.title, t.title) ? e.url : null; } // YouTube Music (#640), through the API its web player uses (anonymous). Two routes: // · the release's own YouTube Music album, when it has a link to one: its tracklist, // matched by position (or, when the album lists fewer songs, the one song with the // track's title), with the title and length checked; // · otherwise, or for a track the album doesn't have: the ISRC searched with the "songs" // filter. Of the first three results, one that is official audio (MUSIC_VIDEO_TYPE_ATV, // not a user upload), has the track's title, is within 3 s of its length, AND comes from // this release's album. Labels reuse an ISRC across versions: JP92Q2400507 is both // "メズマライザー" and its "Critical Damage ver.", same title, same 2:37, and the search // brings up the original's song from "Mesmerizer" (rinsuki). A search row reads // "Kerala" | "Bonobo • Migration • 3:58", the album a link to it. // Where each link came from is kept for the edit note (ytSource): once a song is delisted // or geo-blocked, its URL alone no longer says which album it was. const YTM_SONGS_FILTER = 'EgWKAQIIAWoKEAkQBRAKEAMQBA=='; const ytSource = new Map(); // watch URL → { album, albumId, how } const ytText = x => (x && x.runs ? x.runs.map(r => r.text).join('') : ''); const ytWatch = vid => 'https://music.youtube.com/watch?v=' + vid; const ytSame = _sameTitle; async function ytCall(endpoint, body) { const r = await gmPost('https://music.youtube.com/youtubei/v1/' + endpoint + '?prettyPrint=false', JSON.stringify(Object.assign({ context: { client: { clientName: 'WEB_REMIX', clientVersion: '1.20250101.01.00', hl: 'en', gl: 'US' } } }, body)), { 'Content-Type': 'application/json' }); let j = null; try { j = JSON.parse(r.responseText || 'null'); } catch (e) {} if (r.status !== 200 || !j) { Log.warn('YouTube Music: ' + endpoint + ' ' + (body.query || body.browseId) + ' failed (HTTP ' + r.status + ') — its API may have changed'); return null; } return j; } // The songs of a search or an album page: { vid, title, artist, len, type, album, albumId }. function ytSongs(j) { const out = []; const walk = o => { if (!o || typeof o !== 'object') return; const r = o.musicResponsiveListItemRenderer; if (r) { const col = i => r.flexColumns && r.flexColumns[i] && r.flexColumns[i].musicResponsiveListItemFlexColumnRenderer && r.flexColumns[i].musicResponsiveListItemFlexColumnRenderer.text; const sub = ytText(col(1)).split(' • '); const albumRun = ((col(1) && col(1).runs) || []).find(x => x.navigationEndpoint && x.navigationEndpoint.browseEndpoint && /^MPREb_/.test(x.navigationEndpoint.browseEndpoint.browseId || '')); const fixed = r.fixedColumns && r.fixedColumns[0] && r.fixedColumns[0].musicResponsiveListItemFixedColumnRenderer; const vid = (r.playlistItemData && r.playlistItemData.videoId) || (JSON.stringify(r).match(/"videoId":"([\w-]{11})"/) || [])[1]; const type = (JSON.stringify(r).match(/MUSIC_VIDEO_TYPE_[A-Z]+/) || [''])[0].replace('MUSIC_VIDEO_TYPE_', ''); if (vid) out.push({ vid, title: ytText(col(0)), artist: sub[0] || '', type, len: durToSec(fixed ? ytText(fixed.text) : sub[sub.length - 1]), album: albumRun ? albumRun.text : null, albumId: albumRun ? albumRun.navigationEndpoint.browseEndpoint.browseId : null }); return; } for (const k in o) walk(o[k]); }; walk(j); return out; } // Why a song isn't this track ('' when it is): the title, and the length within 3 s. function ytMismatch(s, t) { const want = durToSec(t.dur); if (!ytSame(s.title, t.title)) return 'another title'; if (want != null && (s.len == null || Math.abs(s.len - want) > 3)) return 'another length (' + (s.len == null ? '?' : msToMmSs(s.len * 1000)) + ' vs ' + t.dur + ')'; return ''; } // The release's YouTube Music album (fetched once): { id, name, tracks: [song…] }. let _ytAlbum = null, _ytAlbumP = null; async function ytAlbum() { if (_ytAlbum) return _ytAlbum; if (_ytAlbumP) return _ytAlbumP; const url = RELEASE && RELEASE.ytmUrl; if (!url) return { tracks: [] }; _ytAlbumP = (async () => { let id = (url.match(/browse\/(MPREb_[\w-]+)/) || [])[1]; const list = (url.match(/[?&]list=(OLAK5uy_[\w-]+)/) || [])[1]; if (!id && list) { const pj = await ytCall('browse', { browseId: 'VL' + list }); id = pj && (JSON.stringify(pj).match(/"(MPREb_[\w-]+)"/) || [])[1]; } if (!id) { Log.warn('YouTube Music: the release\'s album link ' + url + ' names no album — each track falls back to its ISRC'); return { tracks: [] }; } const j = await ytCall('browse', { browseId: id }); let h = null; const walk = o => { if (!o || typeof o !== 'object' || h) return; if (o.musicResponsiveHeaderRenderer) { h = o.musicResponsiveHeaderRenderer; return; } for (const k in o) walk(o[k]); }; walk(j); const name = h ? ytText(h.title) : ''; const tracks = j ? ytSongs(j) : []; Log.info('YouTube Music album "' + name + '" (' + id + '): ' + tracks.length + ' song(s), from the release\'s link'); return { id, name, tracks }; })(); const res = await _ytAlbumP.catch(() => ({ tracks: [] })); _ytAlbumP = null; if (res.tracks.length) _ytAlbum = res; return res; } async function ytResolve(isrc, t, idx) { // 1. the release's own album: by position, else the one song with the track's title const alb = await ytAlbum(); if (alb.tracks.length) { const at = alb.tracks[idx]; let s = at && !ytMismatch(at, t) ? at : null; if (!s) { const c = alb.tracks.filter(x => !ytMismatch(x, t)); if (c.length === 1) s = c[0]; } if (s) { const url = ytWatch(s.vid); const sib = RELEASE.rgFrom && RELEASE.rgFrom.ytmUrl; // #302: the link came from a sibling release ytSource.set(url, { album: alb.name, albumId: alb.id, how: (sib ? 'the album linked from ' + MB_ROOT + '/release/' + sib.release : 'the release\'s album') + ', track ' + (alb.tracks.indexOf(s) + 1) }); Log.info('YouTube Music track ' + (idx + 1) + ' → "' + s.title + '" ' + (s.len == null ? '' : msToMmSs(s.len * 1000)) + ' from the album "' + alb.name + '"'); return url; } Log.info('YouTube Music track ' + (idx + 1) + ' "' + t.title + '": not on the album "' + alb.name + '"' + (at ? ' (position ' + (idx + 1) + ' is "' + at.title + '", ' + ytMismatch(at, t) + ')' : '') + (isrc ? ' — trying its ISRC' : '')); } // 2. the ISRC, searched if (!isrc) return null; const j = await ytCall('search', { query: isrc, params: YTM_SONGS_FILTER }); if (!j) return null; const songs = ytSongs(j).slice(0, 3); for (const s of songs) { const why = s.type !== 'ATV' ? 'not official audio (' + (s.type || '?') + ')' : ytMismatch(s, t) || (!ytSame(s.album, RELEASE.title) ? 'from the album "' + (s.album || '?') + '", not "' + RELEASE.title + '" — the ISRC may be shared with another version' : ''); Log.info('YouTube Music ' + isrc + ' → "' + s.title + '" by ' + (s.artist || '?') + ' ' + (s.len == null ? '' : msToMmSs(s.len * 1000)) + (s.album ? ' on "' + s.album + '"' : '') + (why ? ' — skipped: ' + why : ' — matches "' + t.title + '"')); if (!why) { const url = ytWatch(s.vid); ytSource.set(url, { album: s.album, albumId: s.albumId, how: 'ISRC ' + isrc }); return url; } } if (!songs.length) Log.info('YouTube Music ' + isrc + ': no songs'); return null; } // Amazon Music (#644), through the API its web player uses, as a guest (no login). It shows no // ISRCs, so only by ALBUM, like Bandcamp: the release's Amazon Music album (when it has a link // to one) lists every track with its id and length, matched by position — or, when the album // lists them in another order, the one track with this title — with the title and the length // (within 3 s) checked. The per-track link is music.amazon.com/tracks/, a "streaming page" // (979), which MusicBrainz's editor picks for it too. music.amazon.com/config.json hands out the // guest session (CSRF token, device and session id), which each request carries in its body. let _azSession = null; function azSession() { if (!_azSession) _azSession = (async () => { const r = await http({ method: 'POST', url: 'https://music.amazon.com/config.json', data: '', headers: {}, anonymous: true }); let j = null; try { j = JSON.parse(r.responseText || 'null'); } catch (e) {} if (r.status !== 200 || !j || !j.csrf || !j.deviceId) { Log.warn('Amazon Music: no guest session (HTTP ' + r.status + ') — its web player may have changed'); return null; } Log.info('Amazon Music: guest session (marketplace ' + (j.marketplaceId || '?') + ')'); return j; })().catch(e => { Log.warn('Amazon Music: no guest session — ' + errText(e)); return null; }).then(s => { if (!s) _azSession = null; return s; }); return _azSession; } async function azCall(endpoint, body) { const s = await azSession(); if (!s) return null; const headers = JSON.stringify({ 'x-amzn-authentication': JSON.stringify({ interface: 'ClientAuthenticationInterface.v1_0.ClientTokenElement', accessToken: s.accessToken || '' }), 'x-amzn-device-model': 'WEBPLAYER', 'x-amzn-device-width': '1920', 'x-amzn-device-family': 'WebPlayer', 'x-amzn-device-id': s.deviceId, 'x-amzn-user-agent': navigator.userAgent, 'x-amzn-session-id': s.sessionId, 'x-amzn-device-height': '1080', 'x-amzn-request-id': (crypto.randomUUID ? crypto.randomUUID() : String(Math.random()).slice(2)), 'x-amzn-device-language': 'en_US', 'x-amzn-currency-of-preference': 'USD', 'x-amzn-os-version': '1.0', 'x-amzn-application-version': s.version, 'x-amzn-device-time-zone': 'UTC', 'x-amzn-timestamp': String(Date.now()), 'x-amzn-csrf': JSON.stringify({ interface: 'CSRFInterface.v1_0.CSRFHeaderElement', token: s.csrf.token, timestamp: s.csrf.ts, rndNonce: s.csrf.rnd }), 'x-amzn-music-domain': 'music.amazon.com', 'x-amzn-referer': '', 'x-amzn-affiliate-tags': '', 'x-amzn-ref-marker': '', 'x-amzn-page-url': 'https://music.amazon.com/', 'x-amzn-weblab-id-overrides': '', 'x-amzn-video-player-token': '', 'x-amzn-feature-flags': '', 'x-amzn-has-profile-id': '', 'x-amzn-age-band': '', }); const r = await http({ method: 'POST', url: 'https://na.mesk.skill.music.a2z.com/api/' + endpoint, data: JSON.stringify(Object.assign({}, body, { headers })), headers: { 'Content-Type': 'text/plain;charset=UTF-8' }, anonymous: true }); let j = null; try { j = JSON.parse(r.responseText || 'null'); } catch (e) {} const tpl = j && j.methods && (j.methods.find(m => m.template) || {}).template; if (r.status !== 200 || !tpl) { Log.warn('Amazon Music: ' + endpoint + ' ' + (body.id || '') + ' failed (HTTP ' + r.status + ') — its API may have changed'); return null; } return tpl; } const azSecs = x => { const p = String(x || '').split(':').map(Number); return p.length > 1 && p.every(n => !isNaN(n)) ? p.reduce((a, n) => a * 60 + n, 0) : null; }; // "05:38", "1:02:03" // The release's Amazon Music album (fetched once): { id, name, tracks: [{ id, title, len }] }. let _azAlbum = null, _azAlbumP = null; async function azAlbum() { if (_azAlbum) return _azAlbum; if (_azAlbumP) return _azAlbumP; const id = ((RELEASE && RELEASE.amzUrl) || '').match(/\/albums\/([A-Z0-9]{10})/i); if (!id) return { tracks: [] }; _azAlbumP = (async () => { const tpl = await azCall('showCatalogAlbum', { id: id[1].toUpperCase(), userHash: JSON.stringify({ level: 'LIBRARY_MEMBER' }) }); if (!tpl) return { tracks: [] }; const name = (tpl.headerText && tpl.headerText.text) || ''; const tracks = []; for (const w of tpl.widgets || []) for (const it of w.items || []) { const tid = (String((it.primaryTextLink && it.primaryTextLink.deeplink) || '').match(/^\/tracks\/([A-Z0-9]{10})/) || String((it.primaryLink && it.primaryLink.deeplink) || '').match(/[?&]trackAsin=([A-Z0-9]{10})/) || [])[1]; if (tid && !tracks.some(x => x.id === tid)) tracks.push({ id: tid, title: typeof it.primaryText === 'object' ? (it.primaryText && it.primaryText.text) || '' : it.primaryText || '', len: azSecs(it.secondaryText3) }); } Log.info('Amazon Music album "' + name + '" (' + id[1] + '): ' + tracks.length + ' track(s), from the release\'s link'); return { id: id[1].toUpperCase(), name, tracks }; })(); const res = await _azAlbumP.catch(() => ({ tracks: [] })); _azAlbumP = null; if (res.tracks.length) _azAlbum = res; return res; } async function azResolve(t, idx) { const alb = await azAlbum(); if (!alb.tracks.length) return null; const at = alb.tracks[idx]; let s = at && !ytMismatch(at, t) ? at : null; if (!s) { const c = alb.tracks.filter(x => !ytMismatch(x, t)); if (c.length === 1) s = c[0]; } if (!s) { Log.info('Amazon Music track ' + (idx + 1) + ' "' + t.title + '": not on the album "' + alb.name + '"' + (at ? ' (position ' + (idx + 1) + ' is "' + at.title + '", ' + ytMismatch(at, t) + ')' : '')); return null; } Log.info('Amazon Music track ' + (idx + 1) + ' → "' + s.title + '" ' + (s.len == null ? '' : msToMmSs(s.len * 1000)) + ' from the album "' + alb.name + '"'); return 'https://music.amazon.com/tracks/' + s.id; } // Apple Music album page (fetched once): ordered [{title, url}] from its ld+json. let _amList = null, _amPromise = null; async function amAlbum() { if (_amList && _amList.length) return _amList; // see bcAlbum: don't let a transient empty fetch stick forever if (_amPromise) return _amPromise; const url = RELEASE && RELEASE.appleUrl; if (!url) { _amList = []; return _amList; } _amPromise = (async () => { const r = await gmGet(url, { 'Accept': 'text/html' }); if (r.status !== 200) return []; const m = (r.responseText || '').match(/]+type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/i); if (!m) return []; let j; try { j = JSON.parse(m[1]); } catch (e) { return []; } return (j.tracks || []).map(t => ({ title: t.name || '', url: t.url || '' })).filter(t => t.url); })(); _amList = await _amPromise.catch(() => []); _amPromise = null; return _amList; } async function amResolve(t, idx) { const list = await amAlbum(); const e = list[idx]; if (!e) return null; return _sameTitle(e.title, t.title) ? e.url : null; } // SoundCloud set (fetched once via api-v2): ordered [{title, url}] per track. Like // Apple/Bandcamp we resolve the per-track link BY POSITION (with a title guard) rather // than by ISRC — a set is an ordered playlist, and this works even for tracks that // don't carry an ISRC yet / whose SoundCloud upload ISRC differs from MB's. #439 let _scLinkList = null, _scLinkPromise = null; async function scAlbum() { if (_scLinkList && _scLinkList.length) return _scLinkList; // see bcAlbum: don't let a transient empty fetch stick forever if (_scLinkPromise) return _scLinkPromise; const setUrl = RELEASE && RELEASE.scUrl; if (!setUrl) { _scLinkList = []; return _scLinkList; } _scLinkPromise = (async () => { const cid = await soundcloudClientId(); const rj = async (u) => { const r = await gmGet(u, { 'Accept': 'application/json' }); return r.status === 200 ? JSON.parse(r.responseText || 'null') : null; }; const pl = await rj(SC.api + '/resolve?url=' + encodeURIComponent(setUrl) + '&client_id=' + cid); if (pl && pl.kind === 'track') return [{ title: pl.title || '', url: pl.permalink_url || '' }].filter(t => t.url); // single-track release (#439) if (!pl || pl.kind !== 'playlist') return []; const stubs = (pl.tracks || []).filter(t => t && t.id); const byId = new Map(); (pl.tracks || []).forEach(t => { if (t && t.title) byId.set(t.id, t); }); const missing = stubs.filter(t => !byId.has(t.id)).map(t => t.id); for (let i = 0; i < missing.length; i += 50) { const b = await rj(SC.api + '/tracks?ids=' + encodeURIComponent(missing.slice(i, i + 50).join(',')) + '&client_id=' + cid); (b || []).forEach(t => { if (t && t.id != null) byId.set(t.id, t); }); } return stubs.map(s => byId.get(s.id)).filter(Boolean).map(t => ({ title: t.title || '', url: t.permalink_url || '' })).filter(t => t.url); })(); _scLinkList = await _scLinkPromise.catch(() => []); _scLinkPromise = null; return _scLinkList; } let _akLinkList = null, _akLinkPromise = null; async function akAlbum() { if (_akLinkList && _akLinkList.length) return _akLinkList; // see bcAlbum: don't let a transient empty fetch stick if (_akLinkPromise) return _akLinkPromise; const u = RELEASE && RELEASE.akUrl; if (!u) { _akLinkList = []; return _akLinkList; } _akLinkPromise = audiomackRelease(u).then(r => r.tracks.filter(t => t.url)); _akLinkList = await _akLinkPromise.catch(e => { Log.warn('Audiomack track links: ' + errText(e)); return []; }); _akLinkPromise = null; return _akLinkList; } async function akResolve(t, idx) { const e = (await akAlbum())[idx]; return e && _sameTitle(e.title, t.title) ? e.url : null; } async function scResolve(t, idx) { const list = await scAlbum(); const e = list[idx]; if (!e) return null; return _sameTitle(e.title, t.title) ? e.url : null; } // Spotify embed page (fetched once): ordered [{title, url}] from its __NEXT_DATA__ // trackList. The /embed/album/ page is server-rendered and token-free (#458), and // each entry carries uri "spotify:track:" + title, so — like Bandcamp/Apple — we // match per track BY POSITION with a title guard rather than by ISRC (Spotify's ISRCs // come from ISRC Hunt, which never exposes the track id). Spotify has a free tier → // recording↔url "free streaming" (268). function _spTrackList(o, depth) { if (!o || depth > 8 || typeof o !== 'object') return null; if (Array.isArray(o.trackList)) return o.trackList; for (const k in o) { const r = _spTrackList(o[k], (depth || 0) + 1); if (r) return r; } return null; } let _spList = null, _spPromise = null; async function spAlbum() { // an empty [] here (network hiccup, or open.spotify.com's anti-bot serving a // consent/interstitial page instead of the real embed) used to get cached in // _spList forever — and `if (_spList)` treats an empty array as truthy, so every // later Find-links click silently kept reusing that permanent miss for the rest // of the page session, with no way to retry short of a full page reload. if (_spList && _spList.length) return _spList; if (_spPromise) return _spPromise; const id = RELEASE && RELEASE.spotifyId; if (!id) { _spList = []; return _spList; } _spPromise = (async () => { const r = await gmGet('https://open.spotify.com/embed/album/' + id, { 'Accept': 'text/html' }); if (r.status !== 200) return []; const html = r.responseText || ''; let list = []; const m = html.match(/