// ==UserScript== // @name Group Therapy // @namespace https://github.com/majkinetor/musicbrainz-userscripts // @version 2026.7.9 // @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 // @grant GM_getValue // @grant GM_setValue // @run-at document-end // @noframes // ==/UserScript== /* eslint-disable no-undef */ (function () { 'use strict'; const VERSION = (typeof GM_info !== 'undefined' && GM_info && GM_info.script && GM_info.script.version) || '2026.7.7'; // from the @version header at runtime const W = (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window); // ── tiny DOM helpers ────────────────────────────────────────────────────── const el = (tag, cls, txt) => { const e = document.createElement(tag); if (cls) e.className = cls; if (txt != null) e.textContent = txt; 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 ROLE_STOP = new Set(['odd', 'even', 'highlighted', 'selected', 'subrow', 'rel-add', 'rel-edit', 'rel-remove']); const pickRoleClass = tr => { if (!tr) return null; for (const c of tr.classList) if (!ROLE_STOP.has(c) && /^[a-z][a-z0-9-]*$/.test(c)) return c; return null; }; 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. const _medCache = new WeakMap(); const mediumNumberOf = tr => { if (!tr) return null; if (_medCache.has(tr)) return _medCache.get(tr); let med = null; const tbl = tr.closest && tr.closest('table'); if (tbl) { const all = [...tbl.querySelectorAll('tr')], idx = all.indexOf(tr); for (let i = idx - 1; i >= 0; i--) { if (all[i].classList && all[i].classList.contains('subh')) { const m = (all[i].textContent || '').match(/(\d+)/); med = m ? m[1] : null; break; } } } _medCache.set(tr, med); return med; }; // 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 => { const tbl = tr && tr.closest && tr.closest('table'); if (!tbl) return ''; const all = [...tbl.querySelectorAll('tr')], idx = all.indexOf(tr); for (let i = idx - 1; i >= 0; i--) { if (all[i].classList && all[i].classList.contains('subh')) return (all[i].textContent || '').replace(/^\s*\d+\s*/, '').replace(/^[^A-Za-z0-9]+/, '').trim(); } return ''; }; // 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). const posLabel = tr => { 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) && 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 rowHasClass = (tr, cls) => !!(tr && cls && tr.classList.contains(cls)); 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 return `${name} by ${s.author || 'majkinetor'} v${s.version || VERSION} - ${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; } 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