MusicBrainz: Batch Remove Cover Art

Allows batch removing cover art from MusicBrainz releases.

当前为 2025-07-25 提交的版本,查看 最新版本

// ==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();
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址