// ==UserScript== // @name Group Therapy // @namespace https://github.com/majkinetor/musicbrainz-userscripts // @version 2026.10.3 // @description MusicBrainz relationship helpers: batch-delete rel groups from a right-click menu, page-wide hover highlight with a count tooltip, and copy/move credits between recordings & clone release credits. Chrome-light — context menus + hover, no toolbar. // @author majkinetor // @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4Ij48ZyBmaWxsPSJub25lIiBzdHJva2U9IiM1YjZiN2EiIHN0cm9rZS13aWR0aD0iNyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIj48bGluZSB4MT0iMzQiIHkxPSI0MiIgeDI9Ijk0IiB5Mj0iNDIiLz48bGluZSB4MT0iMzQiIHkxPSI0MiIgeDI9IjY0IiB5Mj0iOTQiLz48bGluZSB4MT0iOTQiIHkxPSI0MiIgeDI9IjY0IiB5Mj0iOTQiLz48L2c+PGcgZmlsbD0iIzJlOWU1YiIgc3Ryb2tlPSIjMjU2ZjQzIiBzdHJva2Utd2lkdGg9IjQiPjxjaXJjbGUgY3g9IjM0IiBjeT0iNDIiIHI9IjE2Ii8+PGNpcmNsZSBjeD0iOTQiIGN5PSI0MiIgcj0iMTYiLz48Y2lyY2xlIGN4PSI2NCIgY3k9Ijk0IiByPSIxNiIvPjwvZz48L3N2Zz4= // @match *://*.musicbrainz.org/release/*/edit-relationships // @match *://*.musicbrainz.org/artist/* // @match *://*.musicbrainz.org/label/* // @match *://*.musicbrainz.org/place/* // @grant GM_getValue // @grant GM_setValue // @grant GM_openInTab // @grant unsafeWindow // @run-at document-end // @noframes // ==/UserScript== /* eslint-disable no-undef */ (function () { 'use strict'; // one copy per page: with String Theory and a standalone install both on, the newer one runs (#653) if (!mbuClaim('group_therapy', 'Group Therapy')) 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 = (typeof GM_info !== 'undefined' && GM_info && GM_info.script && GM_info.script.version) || '?'; // from the @version header at runtime const W = (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window); // ── tiny DOM helpers ────────────────────────────────────────────────────── // #522 follow-up (majkinetor, live): "why is LastPass recognizing edits as // passwords?" — password managers heuristically flag plain text inputs // that carry no autocomplete/ignore hint. Every this tool creates // is a search/filter/edit box, never a credential, so opt every one of // them out up front rather than chasing individual false positives. const el = (tag, cls, txt) => { const e = document.createElement(tag); if (cls) e.className = cls; if (txt != null) e.textContent = txt; if (tag === 'input') { e.autocomplete = 'off'; e.setAttribute('data-lpignore', 'true'); e.setAttribute('data-1p-ignore', 'true'); e.setAttribute('data-bwignore', 'true'); e.setAttribute('data-form-type', 'other'); } return e; }; const trunc = (s, n) => { s = String(s || ''); return s.length > n ? s.slice(0, n - 1) + '…' : s; }; // MB renders each rel as …
name … const REMOVE_SEL = 'button.icon.remove-item'; const pickRoleLabel = tr => { const l = tr && tr.querySelector('th.link-phrase label'); return l ? (l.textContent || '').replace(/:\s*$/, '').trim() : 'role'; }; // medium number a track row belongs to — the nearest preceding `tr.subh` ("1▼CD" → 1). Each medium // is its own , so we scan the table's rows in document order (not just siblings). Cached per row. // #623 (sweep): one pass over the table fills both caches for every row, instead of // re-scanning all its rows once per row asked about (O(rows²) on a big box set — the // hover highlight asks for every hit, and the format lookup was never cached at all). const _medCache = new WeakMap(), _fmtCache = new WeakMap(); const indexMedia = tbl => { let med = null, fmt = ''; for (const row of tbl.querySelectorAll('tr')) { if (row.classList && row.classList.contains('subh')) { const t = row.textContent || '', m = t.match(/(\d+)/); med = m ? m[1] : null; fmt = t.replace(/^\s*\d+\s*/, '').replace(/^[^A-Za-z0-9]+/, '').trim(); } else { _medCache.set(row, med); _fmtCache.set(row, fmt); } } }; const mediumNumberOf = tr => { if (!tr) return null; if (!_medCache.has(tr)) { const tbl = tr.closest && tr.closest('table'); if (!tbl) return null; indexMedia(tbl); } return _medCache.has(tr) ? _medCache.get(tr) : null; }; // medium FORMAT label for a track row (from the nearest preceding tr.subh, e.g. "1▼CD" → "CD", "2▼12″ Vinyl" → "12″ Vinyl") const mediumFormatOf = tr => { if (!tr) return ''; if (!_fmtCache.has(tr)) { const tbl = tr.closest && tr.closest('table'); if (!tbl) return ''; indexMedia(tbl); } return _fmtCache.get(tr) || ''; }; // track position label from the row's position cell — handles vinyl/multi-disc numbers ("D5", "A1") // as well as plain "5". On multi-medium releases, a plain number is prefixed with the medium so // "1" on CD 1 vs CD 2 read as "1.1" / "2.1". Returns a string (or null for non-track rows). // `multi` (optional): whether the release has several media, when the caller already // knows — the highlight asks once, not once per hit (#623) const posLabel = (tr, multi) => { if (!tr || !tr.querySelector) return null; const c = tr.querySelector('td.pos'); let t = c ? (c.textContent || '').trim() : ''; if (!t) { const m = (tr.textContent || '').match(/^\s*(\d+)\b/); t = m ? m[1] : ''; } if (!t) return null; if (/^\d+$/.test(t) && (multi != null ? multi : document.querySelectorAll('tr.subh').length > 1)) { const med = mediumNumberOf(tr); if (med) t = med + '.' + t; } return t; }; const targetHref = item => { const a = item && item.querySelector('a[href*="/artist/"], a[href*="/work/"], a[href*="/label/"], a[href*="/place/"], a[href*="/recording/"], a[href*="/url/"], a[href*="/event/"], a[href*="/instrument/"]'); return a ? a.getAttribute('href') : null; }; const targetLabel = item => { const a = item && item.querySelector('a[href*="/"]'); return a ? (a.textContent || '').trim() : 'target'; }; const itemHasHref = (item, href) => !!(href && item.querySelector(`a[href="${CSS.escape(href)}"]`)); // a rel's "role" for grouping = its link type PLUS its attributes — because e.g. every instrument rel // shares the one "instrument" link type and the specific instrument (drums, shakers, …) is an // attribute; matching on the CSS role class alone would lump drums + shakers + vocals together. const _roleKeyCache = new WeakMap(); function relRoleKey(item) { if (_roleKeyCache.has(item)) return _roleKeyCache.get(item); const rel = relFromNode(item); let key = null; if (rel) { let attrs = ''; try { if (rel.attributes) attrs = [...W.MB.tree.iterate(rel.attributes)].map(a => a.typeID).sort((x, y) => x - y).join(','); } catch (e) {} key = rel.linkTypeID + '#' + attrs; } _roleKeyCache.set(item, key); return key; } // collect peer relationship-items matching a scope relative to a seed × button function collect(seedBtn, scope) { const seedItem = seedBtn.closest('.relationship-item'); if (!seedItem) return []; const seedKey = relRoleKey(seedItem), href = targetHref(seedItem); return [...document.querySelectorAll('.relationship-item')].filter(item => { if (scope === 'role') return relRoleKey(item) === seedKey; if (scope === 'target') return itemHasHref(item, href); return relRoleKey(item) === seedKey && itemHasHref(item, href); // role+target }); } const removeButtons = items => items.map(it => it.querySelector(REMOVE_SEL)).filter(Boolean); // ── edit-note signature — stamped into MB's edit-note field ONLY when GT actually changes something ── const GT_HOMEPAGE = 'https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/group_therapy/README.md'; function editNoteSig() { let s = {}; try { if (typeof GM_info !== 'undefined' && GM_info.script) s = GM_info.script; } catch (e) {} const name = (s.name || 'Group Therapy').split(/\s+[—–-]\s+/)[0].trim(); // drop the "— MusicBrainz relationship helper" suffix // Same shape as every other script in the repo: " v by - ". // GT used to put the author before the version, which is the odd one out and made // version stats unparseable for anything reading edit notes (dev/script-metrics #570). return `${name} v${s.version || VERSION} by ${s.author || 'majkinetor'} - ${s.homepageURL || s.homepage || GT_HOMEPAGE}`; } // Stamp our signature into MB's edit-note field and, under it, an accumulating list of what GT did // ("Copied credits from track 4 to tracks 1–5", "Removed guitar (14)"). Any note that preceded ours // (another script's) is preserved ahead of our block. Idempotent per identical action line. function stampEditNote(action) { const ta = document.querySelector('textarea.edit-note, #edit-note-text'); if (!ta) return; const sig = editNoteSig(), cur = ta.value || ''; let pre = cur.replace(/\s+$/, ''), ourLines = []; const idx = cur.indexOf(sig); if (idx >= 0) { pre = cur.slice(0, idx).replace(/\s+$/, ''); ourLines = cur.slice(idx + sig.length).split('\n').map(l => l.trim()).filter(Boolean); } if (action && !ourLines.includes(action)) ourLines.push(action); const block = ourLines.length ? `${sig}\n\n${ourLines.join('\n')}` : sig; const next = pre ? `${pre}\n\n${block}` : block; try { const set = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(ta), 'value').set; set.call(ta, next); ta.dispatchEvent(new Event('input', { bubbles: true })); } catch (e) {} } function markUsed(action) { try { stampEditNote(action); } catch (e) {} } // ── subtle context menu ─────────────────────────────────────────────────── let menuEl = null; function closeMenu() { if (menuEl) { menuEl.remove(); menuEl = null; document.removeEventListener('mousedown', onDocDown, true); document.removeEventListener('keydown', onKey, true); } } function onDocDown(e) { if (menuEl && !menuEl.contains(e.target)) closeMenu(); } function onKey(e) { if (e.key === 'Escape') closeMenu(); } function openMenu(x, y, items) { // items: [{label, sub, danger, run} | 'sep'] closeMenu(); menuEl = el('div', 'gt-menu'); for (const it of items) { if (it === 'sep') { menuEl.appendChild(el('div', 'gt-sep')); continue; } if (it.header != null) { const h = el('div', 'gt-hdr', it.header); it._set = v => { try { h.textContent = v; } catch (e) {} }; menuEl.appendChild(h); continue; } // #377 live header if (it.note != null) { menuEl.appendChild(el('div', 'gt-note', it.note)); continue; } // #597: a text field inside the menu — the track selector for the vertical // moves. onInput reports back {text, ok, title} so the row underneath can // say what the spec actually matched BEFORE anything is copied, the same // way the text parser's Scope does. if (it.input) { const wrap = el('div', 'gt-mi-input'); const inp = el('input', 'gt-mi-in'); inp.type = 'text'; inp.placeholder = it.input.placeholder || ''; inp.title = it.input.title || ''; inp.value = it.input.value || ''; const info = el('div', 'gt-mi-ininfo', ''); const upd = () => { const res = it.input.onInput(inp.value) || {}; info.textContent = res.text || ''; info.style.color = res.ok ? 'var(--mbu-ok)' : 'var(--mbu-warn)'; info.title = res.title || ''; }; inp.addEventListener('input', upd); // A menu whose CONTENT depends on the spec (the ⬇ checklist is built // from the tracks being scanned) has to be rebuilt, and rebuilding on // every keystroke would yank the field out from under the typing. So // the live echo runs on input and the rebuild waits for `change` — // Enter, or clicking away. if (it.input.onChange) inp.addEventListener('change', () => it.input.onChange(inp.value)); wrap.append(inp, info); menuEl.appendChild(wrap); upd(); setTimeout(() => { try { inp.focus(); } catch (e) {} }, 0); continue; } if (it.checklist) { // per-credit toggles for copy/move — clicking a box toggles, doesn't close the menu const box = el('div', 'gt-ck-list'); it.checklist.forEach(entry => { const lab = el('label', 'gt-ck'); const cb = el('input', 'gt-ck-cb'); cb.type = 'checkbox'; cb.checked = entry.checked !== false; entry.cb = cb; cb.addEventListener('change', () => { if (it.onToggle) it.onToggle(); }); // whole row toggles (it's a