// ==UserScript==
// @name Falcon — bulk MusicBrainz link editor
// @namespace https://github.com/majkinetor/musicbrainz-userscripts
// @version 2026.10.3
// @description Add external links to a BATCH of MusicBrainz artists/labels/recordings at once — no popup-per-entity, no tab churn. A small pool of persistent worker iframes churns through a queue, each submitting its own edit and moving straight to the next entity. Paste a list, hand it a queue via a `?falcon=` URL param, or click "Send to Falcon" on a Harmony actions page to import its suggested links directly.
// @author majkinetor
// @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4IiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+CiAgPHBhdGggZD0iTTY0IDEwIEM4MiAyOCA5MCA1NiA5MCA4MCBMMzggODAgQzM4IDU2IDQ2IDI4IDY0IDEwIFoiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzFiMmE0YSIgc3Ryb2tlLXdpZHRoPSI3IiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2UtbGluZWNhcD0icm91bmQiLz4KICA8cGF0aCBkPSJNMzggODAgTDIwIDExMCBMNDAgOTYgWiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMWIyYTRhIiBzdHJva2Utd2lkdGg9IjciIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgogIDxwYXRoIGQ9Ik05MCA4MCBMMTA4IDExMCBMODggOTYgWiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMWIyYTRhIiBzdHJva2Utd2lkdGg9IjciIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgogIDxjaXJjbGUgY3g9IjY0IiBjeT0iNDQiIHI9IjEwIiBmaWxsPSIjMWIyYTRhIi8+CiAgPHBhdGggZD0iTTUwIDgwIEw0NSAxMDggTDY0IDEyMiBMODMgMTA4IEw3OCA4MCBaIiBmaWxsPSIjZmY2YTAwIiBzdHJva2U9IiMxYjJhNGEiIHN0cm9rZS13aWR0aD0iNSIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPgo8L3N2Zz4K
// @homepageURL https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/falcon/README.md
// @match https://*.musicbrainz.org/*
// @match https://harmony.pulsewidth.org.uk/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_xmlhttpRequest
// @grant GM_openInTab
// @grant unsafeWindow
// @connect *
// @noframes
// ==/UserScript==
(function () {
'use strict';
// one copy per page: with String Theory and a standalone install both on, the newer one runs (#653)
if (!mbuClaim('falcon', 'Falcon')) 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;
}
//
const VERSION = '?'; // GM_info carries the real one; every script manager provides it (a hard-coded copy only ever went stale)
const scriptVersion = () => { try { return GM_info.script.version || VERSION; } catch (e) { return VERSION; } };
const NAME = 'Falcon';
// The shared design tokens (#562). Definitions live in dev/tokens/design-tokens.mjs
// and are inlined here by dev/tokens/sync-tokens.mjs — edit them THERE, never here.
// — 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 */ }
if (note && mbuClaimCmp(note.ver, ver) <= 0) { try { localStorage.removeItem(noteKey); } catch (e) { /* storage blocked */ } 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:'
const MB_ORIGIN = location.origin;
// the ACTUAL MusicBrainz origin — used to build the outbound url when this script
// is running ON Harmony (there, MB_ORIGIN above is Harmony's own origin, not MB's).
//
// ⚠ This must follow whichever MusicBrainz server the panel is open on, not be
// pinned to production. @match covers *.musicbrainz.org, which includes
// test.musicbrainz.org and beta — and a hardcoded production target meant a
// batch queued while testing on the SANDBOX would build its edit urls against
// the LIVE site and quietly edit real data. Workers are same-origin iframes, so
// a cross-origin target could not work anyway. Only fall back to production for
// the one case the comment above describes: running on Harmony, where the
// current origin genuinely isn't MusicBrainz.
const MB_TARGET = /(^|\.)musicbrainz\.org$/i.test(location.hostname) ? location.origin : 'https://musicbrainz.org';
const ON_HARMONY = /(^|\.)harmony\.pulsewidth\.org\.uk$/i.test(location.hostname);
const HELP_URL = 'https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/falcon/README.md';
// simple rocket glyph — reused at both launcher and panel-header size (currentColor)
const ICON = '';
// mbRestackCorner, the #468 corner slots, is in the ST-UI block (dev/ui/ui-components.mjs).
/* ── settings ────────────────────────────────────────────────────────── */
const cfg = {
// Default 5, from majkinetor measuring a real batch: 1.8s/item at 5 workers
// vs 2.3s at 3. Above that the gain flattens and MB gets a rougher ride, so
// the cap stays at 6.
get workers() { const n = Number(GM_getValue('falcon:workers', 5)); return Math.max(1, Math.min(6, isFinite(n) ? n : 5)); },
set workers(n) { GM_setValue('falcon:workers', Math.max(1, Math.min(6, Number(n) || 5))); },
// #467 (majkinetor): "Add worker window size so I can see better what is
// wrong [with] multiple windows" — the default thumbnail is too small to
// read a validation message, so make it adjustable and remember it. Card
// height tracks width at 4:5.
get workerSize() { const n = Number(GM_getValue('falcon:workerSize', 260)); return Math.max(200, Math.min(900, isFinite(n) ? n : 260)); },
set workerSize(n) { GM_setValue('falcon:workerSize', Math.max(200, Math.min(900, Number(n) || 260))); },
// #508 (majkinetor): "Options" screen additions.
get hideLauncher() { return GM_getValue('falcon:hideLauncher', false) === true; },
// #663: the queue's layout, 'list' (a labelled form per open row) or 'grid' (one line per row)
// #663: the locale a typed alias gets when it carries no @locale of its own ('' = none)
get aliasLang() { return String(GM_getValue('falcon:aliasLang', '') || ''); },
set aliasLang(v) { GM_setValue('falcon:aliasLang', String(v || '').trim()); },
get queueView() { return GM_getValue('falcon:queueView', 'list') === 'grid' ? 'grid' : 'list'; },
set queueView(v) { GM_setValue('falcon:queueView', v === 'grid' ? 'grid' : 'list'); },
set hideLauncher(v) { GM_setValue('falcon:hideLauncher', !!v); },
get coverOnlyIfNone() { return GM_getValue('falcon:coverOnlyIfNone', false) === true; },
set coverOnlyIfNone(v) { GM_setValue('falcon:coverOnlyIfNone', !!v); },
// #537 (majkinetor): "I think this is all very complicated and is not
// something Falcon should do. Add an option to not process Harmony covers."
// Cover art is the one payload Falcon cannot get exactly right on its own:
// Harmony gives the image URL the provider's own API returns, which is
// often not the largest that provider will serve, and reaching the bigger
// one means owning per-provider URL rules (see the issue). With this on,
// Falcon leaves covers alone entirely and you use ECAU or Art Station for
// them; everything else in a Harmony batch still goes through as usual.
get skipHarmonyCovers() { return GM_getValue('falcon:skipHarmonyCovers', false) === true; },
set skipHarmonyCovers(v) { GM_setValue('falcon:skipHarmonyCovers', !!v); },
// #508 follow-up (majkinetor): "Auto start Harmony import (off by default)".
get autoStartHarmonyImport() { return GM_getValue('falcon:autoStartHarmonyImport', false) === true; },
set autoStartHarmonyImport(v) { GM_setValue('falcon:autoStartHarmonyImport', !!v); },
// #557 (majkinetor): "Current auto start works on MB side. Let's have another
// option 'Auto send' in Harmony category. Having both enabled would
// automatically finish everything after successful Harmony import." This is
// the HARMONY half — it presses "Send to Falcon" for you; autoStartHarmonyImport
// above is the MB half that then starts the queue.
get autoSendFromHarmony() { return GM_getValue('falcon:autoSendFromHarmony', false) === true; },
set autoSendFromHarmony(v) { GM_setValue('falcon:autoSendFromHarmony', !!v); },
// How long the cancel window lasts before an auto-send fires. Not exposed in
// the options UI — it exists so the send is interruptible, not to be tuned.
get autoSendDelayMs() { const n = Number(GM_getValue('falcon:autoSendDelayMs', 4000)); return Math.max(0, Math.min(30000, isFinite(n) ? n : 4000)); },
set autoSendDelayMs(v) { GM_setValue('falcon:autoSendDelayMs', Math.max(0, Math.min(30000, Number(v) || 0))); },
// #590: no option. majkinetor, after trying the first cut: "lets just look
// for MB errors (without an option) and send at the end whatever is there."
// Not in the options UI, same reasoning as autoSendDelayMs: this bounds the
// retry so it can't become a loop against MusicBrainz, it is not a knob.
get harmonyReloadMax() { const n = Number(GM_getValue('falcon:harmonyReloadMax', 5)); return Math.max(0, Math.min(20, isFinite(n) ? n : 5)); },
set harmonyReloadMax(v) { GM_setValue('falcon:harmonyReloadMax', Math.max(0, Math.min(20, Number(v) || 0))); },
// #588 (chaban-mb / majkinetor): "add new Harmony option: Reload release page
// after import without errors." chaban runs Falcon fully automatic, so all he
// ever sees is a drained queue over a stale release view. majkinetor's own
// ruling on #588 was that a blanket standard is wrong (PC restarts because
// Scout must see it; Falcon doesn't because you want the queue's results) —
// hence an option, off by default, and the run log survives the reload
// regardless since #512 put sessions in localStorage.
get reloadReleaseAfterImport() { return GM_getValue('falcon:reloadReleaseAfterImport', false) === true; },
set reloadReleaseAfterImport(v) { GM_setValue('falcon:reloadReleaseAfterImport', !!v); },
// #512 (majkinetor): "keep configurable number of last runs in local
// storage so those can be selected and loaded by datetime".
get logHistoryCount() { const n = Number(GM_getValue('falcon:logHistoryCount', 20)); return Math.max(1, Math.min(100, isFinite(n) ? n : 20)); },
set logHistoryCount(n) { GM_setValue('falcon:logHistoryCount', Math.max(1, Math.min(100, Number(n) || 20))); },
// #508 follow-up (majkinetor): "Open from Harmony in new tab (on by
// default)... Implement 'Off' option that doesn't open new tab but
// opens MB in existing one" — the existing one being the Harmony tab
// itself, navigated away to MB instead of window.open()'d elsewhere.
get openHarmonyInNewTab() { return GM_getValue('falcon:openHarmonyInNewTab', true) === true; },
set openHarmonyInNewTab(v) { GM_setValue('falcon:openHarmonyInNewTab', !!v); },
// #578. This started life as "add ?tport= or not" and is now narrower:
// ?tport= is always added (see picardParam), so the tagger button is always
// there to click; this governs only whether Falcon ALSO calls Picard by
// itself when a run finishes — "[x] Automatically send to Picard using port
// [8000]". Off by default, since an unattended hand-over to a program that
// may not be running is not a sensible default.
//
// The storage key still says sendToPicard rather than autoSendToPicard: it
// is the same setting doing the same job for the same people, and renaming
// it would silently reset it for anyone who has already ticked the box.
get sendToPicard() { return GM_getValue('falcon:sendToPicard', false) === true; },
set sendToPicard(v) { GM_setValue('falcon:sendToPicard', !!v); },
// Clamped on the way in AND on the way out, so a hand-edited stored value
// can't put something that isn't a port into a URL.
get picardPort() { const n = Math.floor(Number(GM_getValue('falcon:picardPort', 8000))); return (isFinite(n) && n >= 1 && n <= 65535) ? n : 8000; },
set picardPort(v) { const n = Math.floor(Number(v)); GM_setValue('falcon:picardPort', (isFinite(n) && n >= 1 && n <= 65535) ? n : 8000); },
};
/* ── tiny logger — kept in-memory + console, surfaced in the panel's log tab ── */
// #467 (majkinetor: "It closed the window so I couldnt take a log (fix it)").
// The log lived only in memory, so a tab that closed — or crashed, which is
// exactly when the log matters most — took the evidence with it. It's now
// mirrored into GM storage as it's written and restored on load, so the
// previous session's log is still there to copy afterwards.
// majkinetor, #467, emphatically: "logs do not work correctly, as I can't get
// previous logs when it closes... I DON'T WANT LOGS FROM OTHER RUNS. I WANT
// YOU TO KEEP THE WINDOW OPEN AND HAVE A SINGLE LOG OF THAT SESSION."
//
// Two separate bugs were behind that, both fixed here.
//
// 1. NOTHING WAS EVER WRITTEN. The persist was debounced by 10s, and a run of
// 8 recordings takes about 10s — so on a short run the first write hadn't
// fired yet when the tab went away, and `pagehide` + GM_setValue is not
// reliably flushed by a real userscript manager on the way out. Net effect:
// the one situation the log exists for was the one situation it was empty.
//
// So the live mirror is now localStorage, not GM storage. It is synchronous,
// same-origin, costs nothing next to an extension-storage round trip, and —
// the point — survives the tab navigating within musicbrainz.org, which is
// exactly the failure being chased.
//
// 2. SESSIONS WERE MERGED. The restore did `LOG.push(...previous)`, so every
// log opened with the last run's lines above the current ones. Each run now
// gets its own id and its own key; the panel shows THIS session only, and an
// earlier session is reachable deliberately rather than pasted on top.
const LOG_PERSIST_MAX = 400;
const LS = (() => { try { return window.localStorage; } catch (e) { return null; } })();
const LS_PREFIX = 'falcon:session:';
// A session id has to be stable across a navigation (so a tab that gets
// navigated mid-run keeps appending to the same session rather than orphaning
// it) but must not collide between runs. Time-based, recorded in storage.
let SESSION_ID = '';
let _sessionSeq = 0;
const LOG = [];
// #512: which session the Log tab is currently SHOWING — null means the
// live, still-appending one; any other value is a historical session id
// picked from #falcon-log-history, rendered read-only from its persisted
// snapshot instead of the in-memory LOG array.
let _viewingSession = null;
// #512 (majkinetor): "Fix Log that it doesn't show anything from previous
// runs anymore. ... it can keep configurable number of last runs in local
// storage so those can be selected and loaded by datetime." — every
// session WAS already persisted individually (the per-run isolation above
// was deliberate), just with no way back to an older one. listSessionKeys
// enumerates them oldest-to-newest by the id's own leading timestamp;
// pruneOldSessions caps how many stick around.
// #512 follow-up (majkinetor, live): "falcon:session:midrun" (added
// alongside "falcon:session:current" for the reattach fix above) shares
// the SAME prefix as an actual session id key — excluding keys by name
// one at a time is fragile the moment another sibling key is added.
// Instead require the remainder to actually LOOK like a session id (its
// own YYYYMMDDHHMMSS-N shape), which every real one always has.
const SESSION_ID_RE = /^\d{14}-\d+$/;
function listSessionKeys() {
if (!LS) return [];
const out = [];
try {
for (let i = 0; i < LS.length; i++) {
const k = LS.key(i);
if (!k || !k.startsWith(LS_PREFIX)) continue;
const id = k.slice(LS_PREFIX.length);
if (SESSION_ID_RE.test(id)) out.push(id);
}
} catch (e) {}
return out.sort(); // the id's own YYYYMMDDHHMMSS-N prefix sorts chronologically as a string
}
// #512 follow-up (majkinetor, live): "Logs sometimes do not have a name
// although in log it is present... log must be started before any missing
// names are fetched, but log can be renamed in any case later" — the
// history label used to be mined from the log's own "[names] release:..."
// line, but that line is written once, near session start, and
// LOG_PERSIST_MAX (400) trims it off the FRONT the moment a big run's
// chatter pushes past that window — exactly what happened to his 31-item
// run. The release name is now also stashed in its own small, never-
// trimmed key the moment it's known, independent of log survival.
function sessionNameKey(id) { return LS_PREFIX + id + ':name'; }
function noteSessionReleaseName(name) {
if (!LS || !SESSION_ID || !name) return;
try { if (!LS.getItem(sessionNameKey(SESSION_ID))) LS.setItem(sessionNameKey(SESSION_ID), name); } catch (e) {}
}
function deleteSessionData(id) {
if (!LS) return;
try { LS.removeItem(LS_PREFIX + id); LS.removeItem(sessionNameKey(id)); } catch (e) {}
}
// prefers the dedicated key; falls back to mining the log text for a
// session persisted before this fix, so existing history isn't blanked.
function sessionReleaseName(id) {
if (!LS) return null;
try { const n = LS.getItem(sessionNameKey(id)); if (n) return n; } catch (e) {}
return extractReleaseName(loadSessionLines(id) || []);
}
function pruneOldSessions() {
if (!LS) return;
const ids = listSessionKeys();
const excess = ids.length - cfg.logHistoryCount;
if (excess <= 0) return;
ids.slice(0, excess).forEach(deleteSessionData);
}
// #512 follow-up (majkinetor, live: "I got bunch of historic logs without
// any processing... We should have only processing logs.") — calling
// newSession() on every fresh `?falcon=` seed (the earlier #512 fix, so a
// NEW seed never inherits a stale session) means a seed the user never
// actually Starts — just looked at, or closed the tab on — still leaves
// behind a session containing nothing but tab-unload noise and a stale-
// token warning. Real work always logs "starting N worker(s)"; anything
// that never reaches that line isn't worth keeping in history at all.
function sessionHasRealWork(lines) {
return lines.some(l => /INFO\s+starting \d+ worker/.test(l));
}
function newSession(reason) {
// the OUTGOING session, about to be superseded — delete it outright if
// it never did any real work, instead of leaving it to clutter history
// until pruneOldSessions() eventually ages it out. Read the PERSISTED
// previous-current id from storage rather than trusting in-memory
// SESSION_ID/LOG: a seed that was never Started never reattaches (#512
// follow-up — reattach now requires a genuinely mid-run session), so
// this page's own SESSION_ID is still null even though a stale
// "current" pointer and its noise-only log are sitting in storage.
if (LS) {
const prevId = LS.getItem('falcon:session:current');
if (prevId) {
const prevLines = prevId === SESSION_ID ? LOG : loadSessionLines(prevId);
if (prevLines && !sessionHasRealWork(prevLines)) {
deleteSessionData(prevId);
}
}
}
// second-resolution alone collides when two runs start in the same second
// (which a test does, and an impatient re-Start does too), silently merging
// them back into one log — the exact thing this is meant to stop.
SESSION_ID = new Date().toISOString().replace(/[^0-9]/g, '').slice(0, 14) + '-' + (++_sessionSeq);
LOG.length = 0;
try { if (LS) LS.setItem('falcon:session:current', SESSION_ID); } catch (e) {}
/* #593 (majkinetor): "It didn't have a name (just date) while release name
is resolved." The name WAS resolved — against the previous session. A
Harmony seed calls newSession() at boot, the release name arrives a moment
later from fetchEntityName and is stashed under THAT id, and then Start
mints a second session for the run itself. The run's own session, the one
history lists, never had a name stored for it.
The queue survives across the two, so carry the name over. */
try {
const rel = queue.find(i => i.entityType === 'release' && i.name);
if (rel) noteSessionReleaseName(rel.name);
} catch (e) {}
pruneOldSessions();
log('info', `=== session ${SESSION_ID} started (${reason}) ===`);
log('info', mbuStartupInfo('Falcon'));
}
// reattach to an in-flight session after a navigation, so its log is
// continuous — but ONLY if a run was actually still going when the tab
// last unloaded. #512 follow-up (majkinetor, live: "I reload the MB page
// and expect empty log, but I get this" — a FULL 358-line finished run's
// log, well after it had already completed): a plain reload/navigation
// with no `?falcon=` seed never calls newSession() at all, so it always
// fell through to this same reattach — correct for a run that's mid-
// flight (the whole point of this code), wrong once the run is long over
// and there's nothing left to keep continuous.
try {
const cur = LS && LS.getItem('falcon:session:current');
const midrun = LS && LS.getItem('falcon:session:midrun') === '1';
const prev = cur && midrun && LS.getItem(LS_PREFIX + cur);
if (cur && prev) { SESSION_ID = cur; LOG.push(...JSON.parse(prev)); }
} catch (e) {}
/* #593 (majkinetor): "it is missing starting lines" — a stored log began
* mid-run, 53 seconds after its own session start.
*
* LOG.slice(-LOG_PERSIST_MAX) keeps the LAST 400 lines, so every run bigger
* than that lost its opening: the options dump, the queue contents, the
* "[names] release:… " line and "starting N worker(s)". Those are the lines
* you need to read a run back, and two other things quietly depended on them:
*
* · sessionHasRealWork() matches "starting N worker(s)". Trimmed away, a
* big run's log looks like it did nothing, and newSession() DELETES it
* when the next run supersedes it — the log disappears from history.
* · extractReleaseName() mines "[names] release:…", the fallback for
* sessions stored before the dedicated name key existed.
*
* So keep both ends and drop the middle, which is per-url worker chatter and
* the least worth storing. Same 400-line budget, no extra storage.
*/
const LOG_PERSIST_HEAD = 140;
function persistWindow(lines) {
if (lines.length <= LOG_PERSIST_MAX) return lines;
const tail = LOG_PERSIST_MAX - LOG_PERSIST_HEAD - 1;
const cut = lines.length - LOG_PERSIST_HEAD - tail;
return [
...lines.slice(0, LOG_PERSIST_HEAD),
`[--:--:--] INFO ——— ${cut} line(s) from the middle of this run were dropped to fit the stored-log budget; its start and end are kept in full ———`,
...lines.slice(-tail),
];
}
let _persistTimer = null, _lastPersisted = '';
function writeLogNow() {
if (!SESSION_ID) return;
try {
const payload = JSON.stringify(persistWindow(LOG));
if (payload === _lastPersisted) return;
_lastPersisted = payload;
if (LS) LS.setItem(LS_PREFIX + SESSION_ID, payload);
} catch (e) {}
}
// Very short debounce. 500ms was still enough to lose the end of a run:
// majkinetor's log stopped MID-LINE with no unload marker, meaning the content
// process died outright rather than navigating, so nothing got the chance to
// flush. At 100ms a hard crash costs at most the last line or two, and
// localStorage is cheap enough to absorb the extra writes.
function persistLog() {
if (_persistTimer) return;
_persistTimer = setTimeout(() => { _persistTimer = null; writeLogNow(); }, 100);
}
// Forensics for the bug he's reporting: the panel vanishing mid-run. If the
// top-level page unloads while work is in flight, something navigated the tab
// out from under us — record that fact synchronously, with where it went, so
// the next page load can say so instead of presenting a mysteriously short log.
function noteUnload(ev) {
try {
// ⚠ Log EVERY unload, not only mid-run ones. This used to fire its marker
// only while items were still active or queued — so when the tab went away
// right AFTER a run completed, the queue was empty, nothing was written,
// and the silence read as "the tab was fine". A blind spot sitting exactly
// where the bug lives.
const busy = typeof queue !== 'undefined' && queue.some(i => i.status === 'active' || i.status === 'queued');
// #512 follow-up (majkinetor, live): "those 2 errors appear in all logs
// and are not actually any errors" — ERROR was right for the case this
// was built to catch (a navigation stealing the tab MID-run, an actual
// symptom worth flagging), but every tab close/reload also unloads
// AFTER a run already finished cleanly — expected, not a bug, so it's
// only ever noise there. Same message, level (and the "report this"
// ask) now follow `busy`, which already tells the two cases apart.
LOG.push(`[${new Date().toISOString().slice(11, 19)}] ${busy ? 'ERROR' : 'INFO '} *** THIS TAB IS BEING UNLOADED (${busy ? 'MID-RUN' : 'after the run finished'}) *** from ${location.href} via ${(ev && ev.type) || '?'} — the panel and its workers are being destroyed by the page going away, NOT by Falcon closing them.${busy ? ' Report this line.' : ''}`);
// #512 follow-up: only a run that's genuinely still going should get
// reattached to on the next load — see the reattach logic above. NOT
// the same thing as `busy` above: a seeded-but-never-Started item
// sits in 'queued' forever, which `busy` (deliberately) still counts
// as "there's unfinished work" for the log message's own wording —
// but nothing was ever actually IN PROGRESS, so it must NOT reattach.
// `running` is the narrower, correct signal: true only between an
// actual start() and its natural completion or stop().
try { if (LS) LS.setItem('falcon:session:midrun', running ? '1' : '0'); } catch (e) {}
writeLogNow();
} catch (e) {}
}
try {
window.addEventListener('pagehide', noteUnload);
window.addEventListener('beforeunload', noteUnload);
} catch (e) {}
function log(level, msg) {
const line = `[${new Date().toISOString().slice(11, 19)}] ${level.toUpperCase().padEnd(5)} ${msg}`;
LOG.push(line); if (LOG.length > 1500) LOG.shift();
persistLog();
try { (console[level] || console.log).call(console, '[Falcon]', msg); } catch (e) {}
scheduleRender('log');
}
// #467 (majkinetor: "it was slow and UI was frozen after some time"): every
// log line used to re-render the whole log pane synchronously (join of up to
// 1500 lines + textContent + scroll), and every queue mutation re-rendered
// the entire queue list. Under 3 concurrent workers that's a lot of layout
// thrash on the SAME main thread the worker iframes are competing for.
// Coalesce instead: mark what's dirty and repaint once per animation frame.
let _dirty = {}, _rafPending = false;
function scheduleRender(what) {
_dirty[what] = true;
if (_rafPending) return;
_rafPending = true;
requestAnimationFrame(() => {
_rafPending = false;
const d = _dirty; _dirty = {};
if (d.queue) renderQueue();
if (d.log) renderLog();
if (d.workers) renderWorkerLayout();
});
}
// #467 (majkinetor, discussion #459): "some workers still randomly do not
// submit stuff for the unknown reason" — intermittent and not reproducible
// locally, so every step of a worker's run is traced at debug level, tagged
// with WHICH worker (they run concurrently, so untagged lines interleave into
// an unreadable mess). The goal is that a single real run's log says exactly
// where a worker stopped short, without needing a live repro.
const debugOn = () => GM_getValue('falcon:debug', true);
function dbg(tag, msg) { if (debugOn()) log('debug', `${tag} ${msg}`); }
/* ── queue item shape: {id, entityType, mbid, urls: [{url,linkTypeId}], note,
disambiguation, isrcs, video, source, aliases: [{name, locale, type,
primary, sortName, begin, end, ended}], cover: [{url, comment, type, candidates:
[{provider,url,width?,height?,size?}]}], coverExistingCount, urlResults,
status, error} ── entityType is one of artist/label/recording/release/
release_group. Field usage by type (#496 — the rest are always present on
the item internally for simplicity, but only these are ever read/rendered):
- urls[]: all types (release_group and release included, #495)
- isrcs[]: recording only
- aliases[]: all types (#535) — each entry is one MB edit, submitted
through ///add-alias rather than the entity's own edit
form (see submitAlias). `type` is the alias type's NAME as MB spells
it for that entity ("Recording name", "Search hint", "Legal name" on
artists) and is resolved against the form's own