// ==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 inside the first of them. // So this never matched anything and the click never happened; the input was // reachable only because its step is the default one. // // It is called once, NOT awaited. Awaiting it was a mistake of mine: on the // release editor the tab does not exist, so the wait could only ever run to // its full timeout before injectInto even started — pure dead time added to // every single background add. pcMark(`helper start on ${entityType}`, `${urls.length} url(s) queued, readyState=${document.readyState}`); // #556: read the hash HERE rather than after the inject — the keep-awake // tone has to start before MusicBrainz's throttled work does, not after. const autoCommit = /pc-autocommit/.test(location.hash); // On the happy path the tone dies with the document when MusicBrainz // redirects, a second or two later. Every OTHER path leaves this tab open // on purpose — nothing landed, no submit button, a crash — and a tab left // open would be a tab humming away with the audio indicator lit until // someone closes it. hush() is called explicitly on each of those. // #556: say when it is OFF, too. A run with the setting disabled logged // NOTHING about keep-awake, so "it didn't work again" and "the mechanism // failed" produced identical output and we both guessed. Silence is not // a diagnosis. const wantAwake = autoCommit && GM_getValue('pc:bg-audio', false); if (autoCommit && !wantAwake) pcMark('keep-awake', 'OFF — enable "Keep background-add tabs awake" in settings to skip the throttling'); awake = wantAwake ? pcKeepAwake() : null; pcOpenExternalLinks(); await pcWait(200); const result = await injectInto(urls, key) || { injected: 0 }; pcMark('inject finished', `${result.injected} confirmed`); // #464: right-click "add in background" — auto-submit once the URLs are in, // and mark this tab so the redirect back to the clean /release/ page // (only that landing page is @match'd; release-group has no such page) can // post "committed" + close itself. Only the release flow supports this. // #559: the release-group editor takes the same route now, for the Discogs // master URL. Its form is NOT the release wizard, so the button differs — // see findSubmit below. (`autoCommit` is read at the top of the helper — // the keep-awake tone needs it before the inject, not after.) // #556 (majkinetor): NEVER submit a run that added nothing. This used to // fire unconditionally, and the release editor's submit is enabled by ANY // pending change — so with Apollo's auto search-and-replace configured, its // edit made the form dirty, Platform Check submitted it as if the links had // gone in (edit 152603580: an S&R edit, no link), then closed the tab and // reloaded the opener. The user got an edit they did not ask for at that // moment, and every diagnostic went with the closed tab. Failing here means // the tab stays open with its banner and console intact, and the queued // links survive for a retry — which is what actually worked on his 2nd try. if (autoCommit && !result.injected) { try { console.warn('[Platform Check] background add: nothing landed — NOT submitting. The tab is left open and the links stay queued; press ↻ or the + again to retry.'); } catch (e) {} hush(); showInjectBanner('Platform Check: no link could be added — not submitting. The links are still queued, so you can retry; this tab is left open on purpose.', [], { fail: true }); } else if (autoCommit) { // #465 (chaban-mb): the release editor is the multi-step wizard, not the // simple artist/label/place form — its submit button is #enter-edit. Same // finder Apollo's compact nav bar already uses for this exact form. // #559: the release-GROUP editor is the plain entity form and has no // #enter-edit at all (measured on a live page: `#enter-edit` absent, // `button.submit.positive` reading "Enter edit" present), so each entity // type gets the finder that actually matches its own form. const findSubmit = entityType === 'release-group' ? () => document.querySelector('button.submit.positive') || [...document.querySelectorAll('button')].find(b => /enter edit/i.test(b.textContent || '')) : () => document.querySelector('#enter-edit') || [...document.querySelectorAll('button')].find(b => /enter edit/i.test(b.textContent || '')); // #556: wait for it to be ENABLED, not merely present. MusicBrainz // disables Enter edit while the editor is validating, and the old // wait resolved on the disabled button the instant it appeared — // then took the "no submit button found" branch and gave up without // ever clicking. Same 5s budget; it just spends it on the right // condition. A disabled one that never enables is reported as that, // rather than as "not found", which is a different bug entirely. let sawDisabled = false; const btn = await pcWaitFor(() => { const b = findSubmit(); if (!b) return null; if (b.disabled) { sawDisabled = true; return null; } return b; }, 5000); pcMark('submit button', btn ? 'enabled' : (sawDisabled ? 'STILL DISABLED after 5s' : 'not found')); if (btn) { try { sessionStorage.setItem(entityType === 'release-group' ? 'pc:autocommit-close-rg' : 'pc:autocommit-close', mbid); } catch (e) {} btn.click(); pcMark('Enter edit clicked — waiting for MusicBrainz to submit and redirect'); } else { hush(); showInjectBanner(sawDisabled ? 'Platform Check: background add — MusicBrainz kept "Enter edit" disabled, review manually' : 'Platform Check: background add — no submit button found, review manually', [], { fail: true }); } } } catch (e) { // Last-resort surface so the user sees *something* on the page when // a Firefox-specific exception kills the inject path silently. A banner // is no use in a BACKGROUND add tab, which nobody looks at — so say it // in the console too, where it survives for a bug report. try { hush(); } catch (_) {} try { console.error('[Platform Check] inject helper crashed —', e); } catch (_) {} try { showInjectBanner(`Platform Check: inject helper crashed — ${e.name || 'Error'}: ${e.message || e}`, [], { fail: true }); } catch (_) { /* nothing else we can do here */ } } } // #556: open the release editor's "External links" step. Returns the tab element // once it exists (so it can be awaited with pcWaitFor), null while it does not. // Clicking an already-active step is a no-op, so this is safe to call repeatedly. function pcOpenExternalLinks() { const tab = [...document.querySelectorAll('a, button, li')] .find(el => /^external\s+links$/i.test(el.textContent?.trim() || '')); if (!tab) return null; try { tab.click(); } catch (e) { /* not clickable yet — the caller keeps polling */ } return tab; } // ⚠ #556 — THE bug, after five wrong diagnoses. This used to identify the field // by its PLACEHOLDER TEXT alone, and Apollo Editor rewrites that text. // // Apollo's multi-link paste (majkinetor: "make a hint show that it can be // multiple links") replaces MusicBrainz's "Add another link" with // "Paste one or more links", re-applying it from a MutationObserver on // #external-links-editor so it survives every React re-render. From that moment // this function matched nothing, Platform Check waited out its 25s and reported // no "Add another link" input ever appeared (25s, External links step never rendered) // — no link added, nothing submitted, exactly what he saw. // // Which is a RACE between two of our own scripts, and that is why it looked like // a hidden-tab bug for a fortnight: whoever gets to the field first wins. In a // foreground tab Platform Check usually arrives before Apollo has installed its // observer; in a background tab, where our poll runs on a ~1s clamp, Apollo has // always won by the time we look. Hence "every time I switch to the tab and // watch it, it works" — and hence, too, "I repeated it and it worked". // // Measured on the sandbox (probe-556-who-eats-the-field.mjs), each script alone // on the release editor: // (none) YES · platform_check YES · art_station YES · credit_hoarder YES // group_therapy YES · isrc_scout YES · mammoth YES // apollo_editor NO · string_theory NO // // So: find the field STRUCTURALLY — the empty url input inside MusicBrainz's own // #external-links-editor, which is what the row actually is and what Apollo's own // CSS keys on. Placeholder text is a label for humans; no script should route on // another script's cosmetics. The old text match stays as a fallback for forms // that have no #external-links-editor. function findAddLinkInput() { // The editor renders one input[type=url] per link plus a trailing EMPTY one: // that empty one is the "add" row, whatever it happens to say today. const ed = document.getElementById('external-links-editor'); if (ed) { const empty = [...ed.querySelectorAll('input[type="url"]')].filter(i => !i.value); if (empty.length) return empty[empty.length - 1]; } // Fallback: /release//edit uses placeholder "Add another link", // /release-group//edit uses "Add link" (no "another"). const all = [...document.querySelectorAll('input[type="text"], input[type="url"], input:not([type])')]; const RE = /^(?:add (?:another )?link|add another url|paste one or more links)$/i; return all.find(i => RE.test((i.placeholder || '').trim()) && !i.value) || all.find(i => RE.test((i.placeholder || '').trim())) || null; } async function injectInto(urls, storageKey) { // Force-type providers: MB's URL auto-classifier leaves the type empty // for Apple Music + Bandcamp ("Please select a link type" warning). Map // host pattern → ordered list of preferred link_type_ids. We pick the // first one whose value actually appears as an