// ==UserScript==
// @name Platform Check
// @namespace http://tampermonkey.net/
// @version 2026.10.4
// @description Find a MusicBrainz release on online platforms like Spotify, Discogs, Bandcamp, HDtracks etc.. Uses existing URL relationships when present, otherwise searches for release online using several methods.
// @author majkinetor
// @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4IiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+DQogIDx0aXRsZT5NQiBQbGF0Zm9ybSBDaGVjazwvdGl0bGU+CiAgDQogIDxnIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzJhMWE1MiIgc3Ryb2tlLXdpZHRoPSI5IiBzdHJva2UtbGluZWNhcD0icm91bmQiPg0KICAgIDxwYXRoIGQ9Ik00MCA4OCBBMzQgMzQgMCAwIDEgNDAgNDAiLz4NCiAgICA8cGF0aCBkPSJNMjkgOTkgQTUwIDUwIDAgMCAxIDI5IDI5Ii8+DQogICAgPHBhdGggZD0iTTg4IDg4IEEzNCAzNCAwIDAgMCA4OCA0MCIvPg0KICAgIDxwYXRoIGQ9Ik05OSA5OSBBNTAgNTAgMCAwIDAgOTkgMjkiLz4NCiAgPC9nPg0KICA8Y2lyY2xlIGN4PSI2NCIgY3k9IjY0IiByPSIyMCIgZmlsbD0iI2U4MjAxYSIvPg0KPC9zdmc+DQo=
// @homepageURL https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/platform_check/README.md
// @match https://*.musicbrainz.org/release/*
// @match https://*.musicbrainz.org/release-group/*
// @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 query.wikidata.org
// @connect search.brave.com
// @connect html.duckduckgo.com
// @connect duckduckgo.com
// @connect api.discogs.com
// @connect www.discogs.com
// @connect open.spotify.com
// @connect bandcamp.com
// @connect soundcloud.com
// @connect api-v2.soundcloud.com
// @connect a-v2.sndcdn.com
// @connect api.audiomack.com
// @connect api.7digital.com
// @connect us.7digital.com
// @connect js-cdn.7digital.com
// @connect api.deezer.com
// @connect itunes.apple.com
// @connect music.apple.com
// @connect amp-api.music.apple.com
// @connect openapi.tidal.com
// @connect auth.tidal.com
// @connect volumo.com
// @connect www.qobuz.com
// @connect qobuz.com
// @connect hdtracks.azurewebsites.net
// @connect music.youtube.com
// @connect music.amazon.com
// @connect na.mesk.skill.music.a2z.com
// @connect api.beatport.com
// @connect tools.wallstream.com
// @connect *
// ==/UserScript==
(function () {
'use strict';
// one copy per page: with String Theory and a standalone install both on, the newer one runs (#653)
if (!mbuClaim('platform_check', 'Platform Check')) 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;
}
//
// MusicBrainz origin of the current page (musicbrainz.org or beta.musicbrainz.org),
// so the script's own MB API calls + edit-page links stay on the same server.
const MB_ORIGIN = location.origin;
// #464: cross-tab signal for the background "right-click add" flow — the opener
// listens for the background tab's "committed" postMessage so it can close that
// tab and refresh, mirroring Credit Hoarder / Apollo Editor's own add-link channel.
const PC_CHANNEL = ('BroadcastChannel' in window) ? new BroadcastChannel('platform-check-inject') : null;
// (#623: the two one-time #501 migrations that used to run here on every load —
// sweeping cache entries out of GM storage, and copying the old bare prov_* toggles
// to pc:prov_* — are retired; every install has long since run them.)
// ─── Release editor sub-pages (/edit, /edit-relationships) ────────────────
// + click on /release stashes OK URLs in `pc:pending:` (localStorage)
// and opens the release's /edit page. We fill the "Add another link" input then set the
// type chooser in the next-sibling 's .
if (/\/release\/[0-9a-f-]{36}\/edit(?:[?#/]|$)/.test(window.location.pathname)) {
runInjectHelper('release');
return;
}
if (/\/release-group\/[0-9a-f-]{36}\/edit(?:[?#/]|$)/.test(window.location.pathname)) {
runInjectHelper('release-group');
return;
}
// #464: a background-add tab (right-click on +) marks itself before auto-submitting
// so that once MB redirects here to the clean release page, it can post "committed"
// back to the opener and close itself — the opener never has to poll or refocus.
if (/^\/release\/[0-9a-f-]{36}\/?(?:[?#]|$)/.test(window.location.pathname)) {
let closeMbid = null;
try { closeMbid = sessionStorage.getItem('pc:autocommit-close'); } catch (e) {}
if (closeMbid) {
try { sessionStorage.removeItem('pc:autocommit-close'); } catch (e) {}
pcMark('landed back on the release page — edit committed, closing the tab');
try { sessionStorage.removeItem('pc:bg-t0'); } catch (e) {}
if (PC_CHANNEL) { try { PC_CHANNEL.postMessage({ type: 'pc-edit-committed', mbid: closeMbid }); } catch (e) {} }
setTimeout(() => { try { window.close(); } catch (e) {} }, 80);
return;
}
}
// #559 (majkinetor): "Discogs master link should be also added in the background
// and tab auto closed when right click is issued on [+] button." The master goes
// onto the release GROUP, and the background flow above never covered it — a
// right-click add opened the release-group editor in a normal, focused tab and
// left it there. Same three-part shape as the release flow: the edit tab marks
// itself, submits, and this landing branch (MB redirects here after a successful
// release-group edit) posts "committed" back and closes the tab.
//
// ⚠ This is why the @match now covers /release-group/* and not just its two edit
// forms — the landing page has to run the script for the tab to close itself.
// Nothing else changes on a release-group page: the guard below stops the
// dashboard mounting anywhere but a release.
if (/^\/release-group\/[0-9a-f-]{36}\/?(?:[?#]|$)/.test(window.location.pathname)) {
let closeRg = null;
try { closeRg = sessionStorage.getItem('pc:autocommit-close-rg'); } catch (e) {}
if (closeRg) {
try { sessionStorage.removeItem('pc:autocommit-close-rg'); } catch (e) {}
pcMark('landed back on the release-group page — edit committed, closing the tab');
try { sessionStorage.removeItem('pc:bg-t0'); } catch (e) {}
if (PC_CHANNEL) { try { PC_CHANNEL.postMessage({ type: 'pc-rg-edit-committed', mbid: closeRg }); } catch (e) {} }
setTimeout(() => { try { window.close(); } catch (e) {} }, 80);
return;
}
}
// The dashboard belongs on a release page and nowhere else. Every branch above is
// a helper for the other pages the @match list covers (the edit forms and the
// post-commit landing pages) and each returns on its own — but the panel below is
// built at top level, and `mbid` is read positionally from the path, so a
// release-group MBID would have sailed straight through and mounted a dashboard
// there. Making the assumption explicit rather than implicit in the @match. #559
if (!/^\/release\/[0-9a-f-]{36}/.test(window.location.pathname)) return;
// Safe setTimeout wrapper. Firefox throws NS_ERROR_NOT_INITIALIZED from
// setTimeout when the script context is being torn down — the Promise
// constructor turns that into an unhandled rejection that aborts everything.
// Catch + fall back to a requestAnimationFrame-driven busy wait so the
// caller still gets a real time delay (not a zero-delay microtask burst
// that would make polling loops exit instantly with "never appeared").
function pcWait(ms) {
return new Promise(resolve => {
try { setTimeout(resolve, ms); return; } catch (_) {}
// Fallback: RAF-based wait. Each frame is ~16ms; loop until elapsed.
const start = Date.now();
const tick = () => {
if (Date.now() - start >= ms) { resolve(); return; }
try { requestAnimationFrame(tick); }
catch (_) { resolve(); }
};
tick();
});
}
// #556 (majkinetor): "It seems to work now but it takes around 30s. If I visit
// the tab, the links are set and it looks idle (and finishes immediately)."
//
// A background add is a chain of six or seven waits across TWO documents, and
// until now the console said only that it had finished. "Takes 30s" and "hung
// on step 4 for 28s then flew" are the same line of output, and I have twice
// gone looking in the wrong half of the flow because of it.
//
// So: one timeline, elapsed from the moment the helper starts, carried across
// the submit redirect in sessionStorage so the landing page's entries continue
// the same clock rather than starting a second one. Every entry records whether
// the tab was hidden at the time, because that is the axis the whole bug lives
// on.
// ⚠ the key is a literal, NOT a module-level const: the landing-page branch near
// the top of the IIFE calls pcMark and returns before any `const` down here has
// been initialised, so referencing one would throw from the temporal dead zone.
// The same trap is documented on injectInto's TYPE_FORCE.
function pcT0() {
try {
const v = Number(sessionStorage.getItem('pc:bg-t0'));
if (v > 0) return v;
const now = Date.now();
sessionStorage.setItem('pc:bg-t0', String(now));
return now;
} catch (e) { return Date.now(); }
}
// ⚠ #556 (majkinetor): "how am I supposed to fetch this log if both original and
// new tab gets killed (first one killed, other one reloaded)". Quite right — a
// console timeline in a tab that closes itself is a timeline nobody can read.
// So every mark goes three places:
//
// 1. the console of the tab it happened in (useful only if you are watching);
// 2. localStorage, which OUTLIVES both the closed tab and the opener's reload;
// 3. the BroadcastChannel, so the opener's activity log shows it LIVE, while
// the background add is still running.
//
// The dashboard drains (2) when it mounts — i.e. right after the post-commit
// reload — and listens for (3), deduplicating on the entry id so a mark that
// arrives both ways is logged once. Between them, the whole timeline is in the
// Log window by the time the tab is gone, whether or not it was open at the time.
// ⚠ the sequence counter lives on the FUNCTION, not in a module-level `let`.
// runInjectHelper is called from the top of the IIFE and reaches its first mark
// before anything declared down here is initialised, so a `let` here is in the
// temporal dead zone and reading it throws — which is not a broken log line but
// a dead background add, caught by the helper's outer catch and reported as
// "inject helper crashed". Cost me a run to rediscover the trap the comment two
// functions up already warns about. Function declarations are hoisted whole, so
// a property on one is always safe.
//
// The whole body is wrapped, too: diagnostics must never be able to break the
// thing they are diagnosing.
function pcMark(stage, extra) {
try {
const ms = Date.now() - pcT0();
const line = `+${(ms / 1000).toFixed(1)}s ${stage}${extra === undefined ? '' : ' ' + extra}${document.hidden ? ' (tab hidden)' : ''}`;
try { console.info('[Platform Check] bg ' + line); } catch (e) {}
// The id must be unique ACROSS DOCUMENTS. The timeline deliberately spans
// two — the editor tab and the page MusicBrainz redirects to — and the
// second one starts a fresh script with the counter back at 0 while
// pcT0() still reads the same start time out of sessionStorage. `t0-seq`
// therefore made the landing page's entry collide with the first one, and
// the reader's dedupe threw away the "edit committed" line: the single
// most useful entry, missing from both delivery paths. Elapsed ms plus a
// per-document tag separates them.
pcMark.seq = (pcMark.seq || 0) + 1;
pcMark.tag = pcMark.tag || Math.random().toString(36).slice(2, 8);
const entry = { id: `${pcT0()}-${ms}-${pcMark.seq}-${pcMark.tag}`, line };
// Capped: a run is a dozen entries, but a loop that never finishes must
// not grow this without bound.
try {
const prev = JSON.parse(localStorage.getItem('pc:bg-log') || '[]');
const next = (Array.isArray(prev) ? prev : []).concat(entry).slice(-120);
localStorage.setItem('pc:bg-log', JSON.stringify(next));
} catch (e) {}
try { if (PC_CHANNEL) PC_CHANNEL.postMessage({ type: 'pc-bg-log', entry }); } catch (e) {}
} catch (e) { /* a timeline is never worth failing an edit over */ }
}
// #556 — keeping a background-add tab out of Firefox's timer throttling.
//
// Why at all: majkinetor's timelines of the SAME two-link add, tab never shown
// in any of them, are the controlled trial.
//
// throttled field 5.7s · Enter edit -> committed 14.5s · total 20.2s
// inaudible tone field 1.5s · Enter edit -> committed 2.1s · total 3.7s
// webrtc loopback field 4.6s · Enter edit -> committed 16.8s · total 21.4s
//
// 5.5x for the tone, and not only on the submit — MusicBrainz renders the
// External links field 3.8x faster too, because the throttling was never about
// our code. A background tab has its timers held to one step per second, and
// once it has spent its execution budget individual ones stretch to as much as
// fifteen (measured in his log: a setTimeout(…,1500) that fired 8.3s late).
//
// ⚠ THE WEBRTC LOOPBACK DOES NOT WORK HERE, so do not re-add it. chaban
// suggested it and it is much the nicer trade on paper — a data-channel-only
// loopback between two peers in the same document needs no getUserMedia, so no
// permission, no prompt and no tab indicator. It connects in under a second
// (measured: `webrtc loopback open` at +0.8s) and then makes no difference at
// all: 16.8s to commit against 16.9s with nothing running. Whatever Firefox
// exempts for an active peer connection, it is not the timer throttling — at
// least not for a data channel carrying no media.
//
// So the tone it is. An audible tab IS exempt, and that is measured, not
// assumed. Two costs, both real:
// - a tab opened by GM_openInTab has no user activation, so Firefox's autoplay
// policy leaves the context `suspended` until the user grants the site
// Autoplay > Allow Audio by hand. Hence off by default, and the state is
// reported into the timeline rather than left to be guessed at;
// - the tab shows the speaker icon while it runs. Nothing to be done: being
// audible is the exemption.
//
// Neither exemption can be tested here — Playwright will not produce a genuinely
// backgrounded tab (which is why e2e-556's --suspend models the constraints
// instead) and it relaxes autoplay besides. What the harness DOES check is that
// the mechanism engages and reports itself; the elapsed time in a real run is
// the rest of the answer.
function pcKeepAwake() {
try {
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) { pcMark('keep-awake', 'no AudioContext in this browser — the tab will be throttled'); return null; }
const ctx = new AC();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
// Non-zero on purpose: a truly silent stream may not mark the tab
// audible, which is the entire point. 30Hz at this level is below
// anything a speaker will reproduce.
gain.gain.value = 0.0008;
osc.frequency.value = 30;
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
try { const p = ctx.resume && ctx.resume(); if (p && p.catch) p.catch(() => {}); } catch (e) {}
pcMark('keep-awake audio started', `state=${ctx.state}`);
// The state right after construction is not the final answer; autoplay
// blocking shows up a beat later as 'suspended'.
try {
setTimeout(() => pcMark('keep-awake audio', ctx.state === 'running'
? 'state=running — the tab is audible, so it should not be throttled'
: `state=${ctx.state} — BLOCKED by the autoplay policy, so this setting is doing nothing. `
+ 'Allow Audio for musicbrainz.org in the padlock menu to let it try.'), 1500);
} catch (e) {}
return ctx;
} catch (e) {
pcMark('keep-awake audio failed', String((e && e.message) || e));
return null;
}
}
// Called once at the start of a background add, so the Log shows THIS run rather
// than this one appended to every previous one.
function pcMarkReset() {
try { localStorage.removeItem('pc:bg-log'); } catch (e) {}
try { sessionStorage.removeItem('pc:bg-t0'); } catch (e) {}
pcMark.seq = 0;
}
// MutationObserver-backed waiter — resolves the moment `predicate()`
// returns truthy, falls back to a slow poll if observers aren't usable.
// Safer than fixed-cadence polling because it doesn't rely on setTimeout
// running on time, and it picks up the target as soon as MB inserts it.
function pcWaitFor(predicate, timeoutMs = 10000) {
return new Promise(resolve => {
const found = predicate();
if (found) return resolve(found);
let done = false;
const finish = result => { if (done) return; done = true; obs?.disconnect(); resolve(result); };
let obs = null;
try {
obs = new MutationObserver(() => {
const r = predicate();
if (r) finish(r);
});
obs.observe(document.documentElement, { childList: true, subtree: true, attributes: true });
} catch (_) { /* observer broken — rely on RAF poll below */ }
// #556 (majkinetor): "It never happens when not invoked as background task"
// and "when it fails, the links are NEVER added". Both are true of this loop
// and of nothing else in the flow.
//
// It polled with requestAnimationFrame. A background-add tab is opened with
// GM_openInTab(..., { active:false }), i.e. HIDDEN — and browsers suspend rAF
// outright in hidden tabs (measured here at 14x throttling even for a merely
// unfocused one). So the poll stops running while the deadline, being wall
// clock, keeps advancing. The tab is then either stuck with nothing settled,
// or — the moment it is looked at — resumes, finds the elapsed time already
// past the timeout, and gives up on its very first tick. That is exactly an
// all-or-nothing failure that cannot happen in the foreground.
//
// So: poll on a TIMER instead. setTimeout is clamped to ~1s in a hidden tab
// but it still runs, so the wait observes the element appearing either way.
// That one change is the whole fix.
//
// What is deliberately NOT here, having been tried and reverted: excusing a
// hidden tab from the deadline (accumulating only "visible" time, with an
// absolute cap as a backstop). It sounds right — a hidden tab is stopped,
// not slow — but a wait that legitimately never succeeds then runs to the
// cap every time, and with the cap at minutes that is far worse than giving
// up. majkinetor got multi-minute hangs in BOTH foreground and background
// out of it. The deadline is plain wall clock, as it was.
//
// Nor is a "be patient while the page is still mutating" budget, which I
// built and threw away on the day the real cause turned up: it made the
// hidden case merely slower to fail, because what the wait was waiting for
// had been RENAMED (see findAddLinkInput) and was never going to match.
const started = Date.now();
const poll = () => {
if (done) return;
const r = predicate();
if (r) return finish(r);
if (Date.now() - started >= timeoutMs) return finish(null);
try { setTimeout(poll, 100); } catch (_) { finish(null); }
};
poll();
});
}
// #556: MusicBrainz sometimes serves a "Verifying your browser" challenge instead
// of the page. The URL is unchanged, so the inject helper would run against the
// challenge, find no "Add another link" input, and burn the queued payload. Falcon
// hit exactly this and had to add the same guard (#551). Detected the same way.
// ⚠ The patterns live inside the function. This runs on the editor page, above the
// script's early return, where a const declared out here is never initialised: as
// module consts they threw, the catch below answered "no", and the guard never fired.
function pcIsVerifyInterstitial(doc) {
const d = doc || document;
const TITLE = /^\s*Verifying your browser\s*$/i, NOSCRIPT = /JavaScript is required to access this page/i;
try {
if (TITLE.test(d.title || '')) return true;
return [...d.querySelectorAll('noscript')].some(n => NOSCRIPT.test(n.textContent || ''));
} catch (e) { return false; }
}
async function runInjectHelper(entityType) {
// #556: declared OUT here, not inside the try — the catch below is a
// different block and could not see a `const` from inside it, so the crash
// path would have thrown a ReferenceError trying to stop the tone instead of
// stopping it. See pcKeepAwake for what this is.
let awake = null;
const hush = () => { try { awake && awake.close && awake.close(); } catch (e) {} };
try {
// Stand down entirely on the browser-check page: the real page loads a
// moment later and this runs again there, with the payload still intact. #556
if (pcIsVerifyInterstitial()) {
try { console.info('[Platform Check] MusicBrainz served its "Verifying your browser" challenge instead of the editor — standing down, the queued links are untouched.'); } catch (e) {}
return;
}
const re = new RegExp(`/${entityType}/([0-9a-f-]{36})`);
const mbid = (window.location.pathname.match(re) || [])[1];
if (!mbid) return;
const key = entityType === 'release-group' ? `pc:pending:rg:${mbid}` : `pc:pending:${mbid}`;
const raw = localStorage.getItem(key);
// No pending payload means the user navigated to /edit themselves,
// not via the panel's + button. Stay silent — banner noise on every
// direct edit-page visit is worse than the diagnostic value (the
// 2nd-click-doesn't-work bug it was diagnosing has been fixed).
if (!raw) return;
let pending;
try { pending = JSON.parse(raw); }
catch (e) {
showInjectBanner(`Platform Check: pending payload not JSON: ${e.message}`, [], { fail: true });
return;
}
const urls = Object.values(pending || {}).filter(Boolean);
if (urls.length === 0) return;
// #556: this lookup used to run ONCE, immediately, and was a no-op besides.
// Measured live on the release editor: there is no "External links" TAB at
// all — the wizard's steps are Release information / Tracklist / Recordings
// / Edit note, and External links is a