// ==UserScript==
// @name MusicBrainz: Batch Remove Cover Art
// @namespace https://musicbrainz.org/user/chaban
// @version 0.4.0
// @description Allows batch removing cover art from MusicBrainz releases.
// @tag ai-created
// @author chaban
// @license MIT
// @match *://musicbrainz.org/release/*/cover-art
// @connect musicbrainz.org
// @icon https://musicbrainz.org/static/images/favicons/android-chrome-512x512.png
// @grant GM.xmlHttpRequest
// @grant GM.addStyle
// ==/UserScript==
(function() {
'use strict';
GM.addStyle(`
.batch-remove-container {
margin-top: 1em;
padding: 1em;
border: 1px solid var(--border, #ccc);
background-color: var(--background-accent, #f9f9f9);
border-radius: 5px;
}
.batch-remove-container h3 {
margin-top: 0;
}
.batch-remove-container textarea {
width: 100%;
min-height: 60px;
margin-bottom: 10px;
background-color: var(--background-dimmed, white);
color: var(--text, black);
border: 1px solid var(--border-accent, #aaa);
border-radius: 3px;
}
.batch-remove-buttons button {
margin-right: 10px;
border-radius: 4px;
padding: 8px 12px;
font-weight: bold;
cursor: pointer;
border-width: 1px;
border-style: solid;
}
.batch-remove-buttons button.positive {
background-color: var(--positive-emphasis, #28a745);
border-color: var(--positive, #252);
color: var(--text, white);
}
.batch-remove-buttons button.negative {
background-color: var(--negative-emphasis, #dc3545);
border-color: var(--negative, #522);
color: var(--text, white);
}
.batch-remove-buttons button:hover:not(:disabled) {
filter: brightness(1.2);
}
.batch-remove-buttons button:disabled {
background-color: var(--background-emphasis, #6c757d);
color: var(--text-weak, #e1e1e1);
border-color: var(--border-dimmed, #555);
cursor: not-allowed;
opacity: 0.7;
}
.mb-batch-remove-artwork-wrapper {
position: relative;
display: inline-block;
vertical-align: top;
}
.mb-batch-remove-artwork-wrapper .cover-art-checkbox {
position: absolute;
top: 5px;
left: 5px;
z-index: 10;
margin: 0;
}
.mb-batch-remove-artwork-wrapper .status {
position: absolute;
top: 5px;
right: 5px;
z-index: 10;
margin: 0;
font-size: 0.9em;
font-weight: bold;
text-align: right;
width: 150px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mb-batch-remove-artwork-wrapper .status-success {
color: var(--positive-emphasis, green);
}
.mb-batch-remove-artwork-wrapper .status-error {
color: var(--negative-emphasis, red);
}
/* Progress bar styling */
.progress-bar-container {
width: 100%;
background-color: var(--background-dimmed, #f3f3f3);
border-radius: 5px;
overflow: hidden;
margin-top: 10px;
height: 20px;
}
.progress-bar {
height: 100%;
width: 0%;
background-color: var(--positive-emphasis, #4CAF50);
text-align: center;
color: var(--text, white);
line-height: 20px;
font-size: 0.8em;
transition: width 0.3s ease-in-out;
}
`);
const getReleaseId = () => {
const pathParts = window.location.pathname.split('/');
if (pathParts.length >= 4 && pathParts[1] === 'release' && pathParts[3] === 'cover-art') {
return pathParts[2];
}
return null;
};
const releaseId = getReleaseId();
if (!releaseId) {
console.error('[MusicBrainz: Batch Remove Cover Art] Could not determine release ID. Script will not run.');
return;
}
let isAborting = false;
/**
* Observes the DOM for the presence of cover art elements and initializes the batch removal functionality.
* Uses a MutationObserver for dynamic content loading, specifically waiting for CAA dimensions to load.
*/
const observeDOM = () => {
const contentArea = document.getElementById('content');
if (!contentArea) {
setTimeout(observeDOM, 500);
return;
}
let initialized = false;
const init = () => {
if (initialized) return;
initialized = true;
observer.disconnect();
setTimeout(initBatchRemove, 100);
};
const observer = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === 'characterData' &&
mutation.target.parentElement?.classList.contains('ROpdebee_dimensions') &&
!mutation.target.textContent.includes('pending')) {
console.log('[MusicBrainz: Batch Remove Cover Art] Detected CAA dimensions script has finished. Initializing...');
init();
return;
}
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
const dimensionSpan = contentArea.querySelector('.artwork-cont .ROpdebee_dimensions');
if (dimensionSpan && !dimensionSpan.textContent.includes('pending')) {
console.log('[MusicBrainz: Batch Remove Cover Art] Detected fully-formed CAA dimensions. Initializing...');
init();
return;
}
}
}
});
observer.observe(contentArea, {
childList: true,
subtree: true,
characterData: true
});
setTimeout(() => {
if (!initialized && document.querySelector('.artwork-cont .buttons a[href*="/remove-cover-art/"]')) {
console.log('[MusicBrainz: Batch Remove Cover Art] CAA dimensions script not detected, initializing as fallback.');
init();
}
}, 3000);
const existingDimensionSpan = contentArea.querySelector('.artwork-cont .ROpdebee_dimensions');
if (existingDimensionSpan && !existingDimensionSpan.textContent.includes('pending')) {
console.log('[MusicBrainz: Batch Remove Cover Art] CAA dimensions script has already run. Initializing...');
init();
}
};
/**
* Initializes the batch removal UI and logic on the cover art page.
*/
const initBatchRemove = () => {
const coverArtDivs = Array.from(document.querySelectorAll('.artwork-cont'));
if (coverArtDivs.length === 0) {
console.log('[MusicBrainz: Batch Remove Cover Art] No cover art found to process.');
return;
}
const batchControlsContainer = document.createElement('div');
batchControlsContainer.className = 'batch-remove-container';
batchControlsContainer.innerHTML = `
<h3>Batch Remove Cover Art</h3>
<div><label><input type="checkbox" id="selectAllCovers"> Select All</label></div>
<p>Edit Note (required):</p>
<textarea id="editNote" placeholder="Reason for removal (e.g., 'Low quality', 'Duplicate', 'Not applicable')." required></textarea>
<div class="batch-remove-buttons">
<button id="removeSelectedBtn" class="positive">Remove Selected Cover Art</button>
<button id="abortBtn" class="negative" disabled>Abort</button>
</div>
<div class="progress-bar-container" style="display: none;"><div class="progress-bar" id="progressBar">0%</div></div>
<div id="statusMessages"></div>
`;
const addCoverArtButton = document.querySelector('.buttons.ui-helper-clearfix');
if (addCoverArtButton) {
addCoverArtButton.after(batchControlsContainer);
} else {
document.getElementById('content')?.appendChild(batchControlsContainer);
}
const selectAllCheckbox = document.getElementById('selectAllCovers');
const removeSelectedBtn = document.getElementById('removeSelectedBtn');
const abortBtn = document.getElementById('abortBtn');
const editNoteTextarea = document.getElementById('editNote');
const progressBarContainer = document.querySelector('.progress-bar-container');
const progressBar = document.getElementById('progressBar');
const statusMessages = document.getElementById('statusMessages');
let totalRemovals = 0;
let completedRemovals = 0;
coverArtDivs.forEach((artworkContDiv) => {
if (artworkContDiv.closest('.mb-batch-remove-artwork-wrapper')) {
return;
}
const removeLink = artworkContDiv.querySelector('.buttons a[href*="/remove-cover-art/"]');
if (removeLink) {
const newWrapper = document.createElement('div');
newWrapper.className = 'mb-batch-remove-artwork-wrapper';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'cover-art-checkbox';
checkbox.dataset.removeUrl = removeLink.href;
const statusSpan = document.createElement('span');
statusSpan.className = 'status';
artworkContDiv.parentNode.insertBefore(newWrapper, artworkContDiv);
newWrapper.appendChild(artworkContDiv);
newWrapper.appendChild(checkbox);
newWrapper.appendChild(statusSpan);
}
});
/**
* Resets the UI elements to their initial state after batch processing or abortion.
* @param {boolean} aborted - True if the process was aborted by the user.
*/
const resetUI = (aborted = false) => {
removeSelectedBtn.disabled = false;
selectAllCheckbox.disabled = false;
editNoteTextarea.disabled = false;
abortBtn.disabled = true;
progressBarContainer.style.display = 'none';
progressBar.style.width = '0%';
progressBar.textContent = '0%';
statusMessages.innerHTML = aborted ? '<p>Batch removal aborted.</p>' : '';
document.querySelectorAll('input.cover-art-checkbox').forEach(cb => {
if (!cb.dataset.processed) {
cb.disabled = false;
}
});
isAborting = false;
};
selectAllCheckbox.addEventListener('change', (event) => {
document.querySelectorAll('input.cover-art-checkbox:not(:disabled)').forEach(cb => {
cb.checked = event.target.checked;
});
});
removeSelectedBtn.addEventListener('click', async () => {
const selectedCheckboxes = Array.from(document.querySelectorAll('input.cover-art-checkbox:checked'));
if (selectedCheckboxes.length === 0) {
alert('Please select at least one cover art to remove.');
return;
}
const editNote = editNoteTextarea.value.trim();
if (!editNote) {
alert('Please provide an edit note for the removal.');
editNoteTextarea.focus();
return;
}
removeSelectedBtn.disabled = true;
selectAllCheckbox.disabled = true;
editNoteTextarea.disabled = true;
abortBtn.disabled = false;
progressBarContainer.style.display = 'block';
statusMessages.innerHTML = '';
totalRemovals = selectedCheckboxes.length;
completedRemovals = 0;
updateProgressBar();
for (const checkbox of selectedCheckboxes) {
if (isAborting) {
statusMessages.innerHTML += '<p>Batch process interrupted.</p>';
break;
}
const statusSpan = checkbox.closest('.mb-batch-remove-artwork-wrapper').querySelector('.status');
statusSpan.className = 'status';
statusSpan.textContent = 'Submitting...';
try {
await submitRemoval(checkbox.dataset.removeUrl, editNote);
statusSpan.textContent = 'Removal submitted.';
statusSpan.classList.add('status-success');
checkbox.dataset.processed = 'true';
} catch (error) {
statusSpan.textContent = `Error: ${error.message || 'Failed to submit.'}`;
statusSpan.classList.add('status-error');
console.error(`Error removing image:`, error);
} finally {
completedRemovals++;
updateProgressBar();
checkbox.disabled = true;
checkbox.checked = false;
}
await new Promise(resolve => setTimeout(resolve, 1000));
}
resetUI(isAborting);
if (!isAborting) {
statusMessages.innerHTML += `<p>Batch removal complete. Processed ${completedRemovals} of ${totalRemovals} selected images.</p>`;
}
});
abortBtn.addEventListener('click', () => {
isAborting = true;
statusMessages.innerHTML = '<p>Aborting process, please wait...</p>';
abortBtn.disabled = true;
});
/**
* Updates the progress bar visually.
*/
const updateProgressBar = () => {
const percentage = totalRemovals > 0 ? (completedRemovals / totalRemovals) * 100 : 0;
progressBar.style.width = `${percentage}%`;
progressBar.textContent = `${Math.round(percentage)}%`;
if (completedRemovals > 0 && completedRemovals === totalRemovals) {
progressBar.textContent = 'Complete!';
}
};
/**
* Submits the removal form for a single cover art via GM.xmlHttpRequest.
* @param {string} url The URL to submit the removal request to.
* @param {string} editNote The edit note for the removal.
* @returns {Promise<any>} A promise that resolves on successful submission or rejects on error.
*/
const submitRemoval = (url, editNote) => {
const formData = new URLSearchParams();
formData.append('confirm.edit_note', editNote);
return new Promise((resolve, reject) => {
GM.xmlHttpRequest({
method: 'POST',
url: url,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Referer': window.location.href
},
data: formData.toString(),
onload: (response) => {
if (response.status === 200 && response.finalUrl.includes('/cover-art')) {
resolve(response);
} else {
reject(new Error(`Server returned status ${response.status}.`));
}
},
onerror: () => reject(new Error('Network error or request failed.')),
ontimeout: () => reject(new Error('Request timed out.'))
});
});
};
};
observeDOM();
})();