// ==UserScript==
// @name MB: Copy Recordings From Release
// @namespace https://github.com/YoGo9
// @version 5/28/2026
// @description On the Recordings tab of the release editor, paste a release MBID or URL to auto-assign all recordings by track position
// @author YoGo9
// @homepage https://github.com/YoGo9/Scripts
// @updateURL https://raw.githubusercontent.com/YoGo9/Scripts/main/CopyRecordingsFromRelease.user.js
// @downloadURL https://raw.githubusercontent.com/YoGo9/Scripts/main/CopyRecordingsFromRelease.user.js
// @match *://*.musicbrainz.org/release/add*
// @match *://*.musicbrainz.org/release/*/edit*
// @grant none
// ==/UserScript==
(function () {
'use strict';
const MBID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
// ── UI injection ──────────────────────────────────────────────────────────
function injectUI() {
if (document.getElementById('cfr-widget')) return;
const anchor = document.querySelector('.changes');
if (!anchor) return;
const wrapper = document.createElement('div');
wrapper.id = 'cfr-widget';
wrapper.style.cssText = [
'margin: 12px 0 0 0',
'padding: 10px 12px',
'background: #f0f4ff',
'border: 1px solid #99a8d0',
'border-radius: 4px',
'font-size: 13px',
'clear: both',
].join(';');
wrapper.innerHTML =
'📋 Copy recordings from another release' +
'
' +
'' +
'' +
'
' +
'';
anchor.appendChild(wrapper);
document.getElementById('cfr-btn').addEventListener('click', onApply);
document.getElementById('cfr-input').addEventListener('keydown', function (e) {
if (e.key === 'Enter') onApply();
});
}
// ── Helpers ───────────────────────────────────────────────────────────────
function setStatus(msg, color) {
var el = document.getElementById('cfr-status');
if (el) { el.textContent = msg; el.style.color = color || '#555'; }
}
async function onApply() {
var raw = (document.getElementById('cfr-input') || {}).value;
if (!raw || !raw.trim()) { setStatus('Please paste a release MBID or URL.', '#a00'); return; }
var match = raw.trim().match(MBID_RE);
if (!match) { setStatus('Could not find a valid MBID in the input.', '#a00'); return; }
var mbid = match[0];
setStatus('Fetching release ' + mbid + '\u2026');
var releaseData;
try {
var resp = await fetch('/ws/2/release/' + mbid + '?inc=recordings+artist-credits&fmt=json');
if (!resp.ok) throw new Error('HTTP ' + resp.status);
releaseData = await resp.json();
} catch (err) {
setStatus('Error fetching release: ' + err.message, '#a00');
return;
}
applyRecordings(releaseData);
}
// ── Apply recordings ──────────────────────────────────────────────────────
function applyRecordings(releaseData) {
// Build map: "mediumPos:trackPos" -> WS2 recording object
// WS2 medium.position and track.position are both 1-based integers
var trackMap = new Map();
(releaseData.media || []).forEach(function (medium) {
var medPos = medium.position;
(medium.tracks || []).forEach(function (track) {
if (track.recording) {
trackMap.set(medPos + ':' + track.position, track.recording);
}
});
});
if (!trackMap.size) {
setStatus('No recordings found in that release.', '#a00');
return;
}
var vm = getReleaseEditorVM();
if (!vm) {
setStatus('Could not access the release editor view-model.', '#a00');
return;
}
var release = vm.rootField.release();
if (!release) { setStatus('No release loaded in editor.', '#a00'); return; }
var applied = 0, skipped = 0, notFound = 0;
release.mediums().forEach(function (medium) {
var medPos = medium.position(); // KO observable, 1-based
medium.tracks().forEach(function (track) {
var trackPos = track.position(); // 1-based (0 = pregap)
var key = medPos + ':' + trackPos;
var recData = trackMap.get(key);
if (!recData) { notFound++; return; }
// Already has this exact recording
if (track.hasExistingRecording() && track.recording() && track.recording().gid === recData.id) {
skipped++;
return;
}
try {
// WS2 uses rec.id for MBIDs; MB.entity expects rec.gid
// WS2 artist-credit uses artist.id; MB.entity expects artist.gid
var names = (recData['artist-credit'] || [])
.filter(function (ac) { return ac && typeof ac === 'object' && ac.artist; })
.map(function (ac) {
return {
name: ac.name || ac.artist.name || '',
joinPhrase: ac.joinphrase || '',
artist: {
gid: ac.artist.id,
name: ac.artist.name || '',
sortName: ac.artist['sort-name'] || '',
entityType: 'artist',
},
};
});
var entityData = {
gid: recData.id,
name: recData.title,
length: recData.length || null,
artistCredit: { names: names },
};
var entity = MB.entity(entityData, 'recording');
track.recording(entity);
applied++;
} catch (e) {
console.error('[CFR] Error on track', trackPos, e);
notFound++;
}
});
});
var color = applied > 0 ? '#007700' : '#a00';
setStatus('Done: ' + applied + ' applied, ' + skipped + ' already set, ' + notFound + ' not matched.', color);
}
// ── Access the KO view-model ──────────────────────────────────────────────
// viewModel.js: MB.releaseEditor = { rootField: { release: ko.observable() } }
// init.js: MB._releaseEditor = releaseEditor (full instance)
function getReleaseEditorVM() {
try {
if (window.MB && window.MB.releaseEditor && window.MB.releaseEditor.rootField)
return window.MB.releaseEditor;
if (window.MB && window.MB._releaseEditor && window.MB._releaseEditor.rootField)
return window.MB._releaseEditor;
// Fallback: KO context walk
var changesDiv = document.querySelector('.changes[data-bind]');
if (changesDiv) {
var ctx = ko.contextFor(changesDiv);
if (ctx && ctx.$root && ctx.$root.rootField) return ctx.$root;
if (ctx && ctx.$parents) {
for (var i = 0; i < ctx.$parents.length; i++) {
if (ctx.$parents[i] && ctx.$parents[i].rootField) return ctx.$parents[i];
}
}
}
} catch (e) {
console.error('[CFR] getReleaseEditorVM error:', e);
}
return null;
}
// ── Tab-change observer ───────────────────────────────────────────────────
var observer = new MutationObserver(function () {
if (!document.getElementById('cfr-widget')) injectUI();
});
observer.observe(document.body, { childList: true, subtree: true });
injectUI();
})();