// ==UserScript== // @name Platform Check // @namespace http://tampermonkey.net/ // @version 2026.6.22 // @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/*/edit // @match https://*.musicbrainz.org/release-group/*/edit-relationships // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @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 api.deezer.com // @connect itunes.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 api.beatport.com // @connect sambl.lioncat6.com // @connect * // ==/UserScript== (function () { 'use strict'; // 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; // ─── Release editor sub-pages (/edit, /edit-relationships) ──────────────── // + click on /release stashes OK URLs in `pc:pending:` 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; } // 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(); }); } // 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 */ } const start = Date.now(); const poll = () => { if (done) return; const r = predicate(); if (r) return finish(r); if (Date.now() - start >= timeoutMs) return finish(null); try { requestAnimationFrame(poll); } catch (_) { try { setTimeout(poll, 100); } catch (_) { finish(null); } } }; poll(); }); } async function runInjectHelper(entityType) { try { 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 = GM_getValue(key, null); // 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; const tab = [...document.querySelectorAll('a, button, li')].find(el => /^external\s+links$/i.test(el.textContent?.trim() || '')); if (tab) tab.click(); await pcWait(200); await injectInto(urls, key); } catch (e) { // Last-resort surface so the user sees *something* on the page when // a Firefox-specific exception kills the inject path silently. try { showInjectBanner(`Platform Check: inject helper crashed — ${e.name || 'Error'}: ${e.message || e}`, [], { fail: true }); } catch (_) { /* nothing else we can do here */ } } } function findAddLinkInput() { // /release//edit uses placeholder "Add another link". // /release-group//edit uses placeholder "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)$/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