MusicBrainz: Cover Art Uploader Turbo

Allows for multiple artwork images to be uploaded simultaneously.

目前為 2025-08-02 提交的版本,檢視 最新版本

// ==UserScript==
// @name         MusicBrainz: Cover Art Uploader Turbo
// @namespace    https://musicbrainz.org/user/chaban
// @version      2.0.0
// @tag          ai-created
// @description  Allows for multiple artwork images to be uploaded simultaneously.
// @author       chaban
// @license      MIT
// @match        *://*.musicbrainz.org/release/*/add-cover-art*
// @match        *://*.musicbrainz.org/event/*/add-event-art*
// @grant        none
// @icon         https://musicbrainz.org/static/images/favicons/android-chrome-512x512.png
// @run-at       document-start
// ==/UserScript==

(function() {
    'use strict';

    // --- CONFIGURATION ---
    const CONCURRENCY_LIMIT = 4;
    const INITIAL_RETRY_DELAY_MS = 2000;
    const MAX_RETRY_DELAY_MS = 60000;
    const SHOW_SIMULATOR = false; // Set to true to show the error simulator UI for debugging

    // --- STYLES ---
    function injectStyles() {
        const styleSheet = document.createElement('style');
        styleSheet.type = 'text/css';
        styleSheet.innerText = `
            #mb-uploader-turbo-container {
                background-color: var(--background-accent, #f9f9f9);
                border: 1px solid #ccc;
                color: var(--text, black);
                position: fixed; right: 10px; bottom: 10px; padding: 10px; max-width: 450px;
                box-shadow: 1pt 1pt 2pt gray; z-index: 1000; font-size: small;
            }
            #mb-uploader-turbo-container summary {
                font-weight: bold;
            }
            #mb-uploader-turbo-container form input,
            #mb-uploader-turbo-container form select {
                border: 1px solid #999;
                border-radius: 3px;
            }
            #mb-uploader-turbo-container .status-list-item.done { color: var(--positive-emphasis, lightgreen); }
            #mb-uploader-turbo-container .status-list-item.error { color: var(--negative-emphasis, red); }
            }
        `;
        document.head.appendChild(styleSheet);
    }

    // --- LOGGER UTILITY ---
    const LOG_PREFIX = '[MusicBrainz: Cover Art & Event Art Uploader]';
    const logger = {
        log: (...args) => console.log(LOG_PREFIX, ...args),
        warn: (...args) => console.warn(LOG_PREFIX, ...args),
        error: (...args) => console.error(LOG_PREFIX, ...args),
    };

    // --- SCRIPT STATE ---
    const STATE = {
        simulator: {
            enabled: false,
            failOn: 'upload',
            status: 503,
            failCount: 1,
            attemptCounter: { sign: 0, upload: 0, submit: 0 },
        },
        debug: {
            files: [],
            ui: {},
        },
        ui: {
            mainContainer: null,
        },
    };

    // --- PROMISE HELPERS ---
    function toNativePromise(deferred) {
        return new Promise((resolve, reject) => {
            deferred.done(resolve).fail((...args) => reject(args));
        });
    }

    class AsyncLock {
        constructor() {
            this.promise = Promise.resolve();
        }
        acquire(task) {
            const result = this.promise.then(() => task());
            this.promise = result.catch(() => {});
            return result;
        }
    }

    // --- UI RENDERING ---
    const UI = {
        createCollapsibleSection(container, title, isOpen = false) {
            const details = document.createElement('details');
            details.open = isOpen;
            const summary = document.createElement('summary');
            summary.textContent = title;
            summary.style.cursor = 'pointer';
            details.append(summary);
            container.append(details);
            return details;
        },

        createMainContainer() {
            if (STATE.ui.mainContainer) return;
            const container = document.createElement('div');
            container.id = 'mb-uploader-turbo-container';
            document.body.append(container);
            STATE.ui.mainContainer = container;
        },

        createSimulatorUI(container) {
            const section = this.createCollapsibleSection(container, 'Error Simulator');
            const form = document.createElement('form');
            form.style.cssText = 'display: flex; flex-flow: column; gap: 5px; margin-top: 10px; padding-left: 10px;';
            const createInput = (label, id, type, value, options) => {
                const wrapper = document.createElement('div');
                const lbl = document.createElement('label');
                lbl.textContent = `${label}: `;
                let input;
                if (type === 'select') {
                    input = document.createElement('select');
                    options.forEach(opt => input.add(new Option(opt, opt)));
                } else {
                    input = document.createElement('input');
                    input.type = type;
                }
                input.id = id;
                input[type === 'checkbox' ? 'checked' : 'value'] = value;
                input.style.width = type === 'checkbox' ? 'auto' : '100px';
                input.addEventListener('change', (e) => {
                    const key = e.target.id;
                    const val = type === 'checkbox' ? e.target.checked : (type === 'number' ? parseInt(e.target.value, 10) : e.target.value);
                    STATE.simulator[key] = val;
                });
                wrapper.append(lbl, input);
                return wrapper;
            };
            form.append(
                createInput('Enabled', 'enabled', 'checkbox', STATE.simulator.enabled),
                createInput('Fail Operation', 'failOn', 'select', STATE.simulator.failOn, ['sign', 'upload', 'submit']),
                createInput('Status Code', 'status', 'number', STATE.simulator.status),
                createInput('Fail on Nth Attempt', 'failCount', 'number', STATE.simulator.failCount)
            );
            section.append(form);
        },

        createDebugUI(container) {
            if (STATE.debug.ui.container) return;
            const section = this.createCollapsibleSection(container, 'Upload Status', true);
            const list = document.createElement('ul');
            list.style.cssText = 'list-style: none; padding: 0 0 0 10px; margin-top: 10px; max-height: 150px; overflow-y: auto;';
            section.append(list);
            STATE.debug.ui.fileList = list;
        },

        updateDebugUI() {
            if (!STATE.debug.ui.fileList) return;
            STATE.debug.ui.fileList.innerHTML = '';
            for (const file of STATE.debug.files) {
                const item = document.createElement('li');
                item.className = 'status-list-item';
                const status = file.status();
                const stage = file._script?.stage ?? 'pending';
                let statusText = `(${status})`;
                if (stage === 'Failed' && file._script?.httpStatus !== undefined) {
                    statusText = `(${status}, HTTP ${file._script.httpStatus})`;
                }
                if (status === 'done') item.classList.add('done');
                else if (status?.includes('error')) item.classList.add('error');
                item.textContent = `${file.name}: ${stage} ${statusText}`;
                STATE.debug.ui.fileList.append(item);
            }
        },
    };

    // --- CORE LOGIC ---
    const checkMB = setInterval(() => {
        if (window.MB?.Art?.add_art_submit && window.MB?.Art?.upload_status_enum && window.__MB__?.$c && window.$) {
            clearInterval(checkMB);
            injectStyles();
            overrideUploader();
        }
    }, 50);

    function overrideUploader() {
        const action = window.__MB__.$c.action.name;
        let entityType, archiveName, formName;
        switch (action) {
            case 'add_cover_art': [entityType, archiveName] = ['release', 'cover']; break;
            case 'add_event_art': [entityType, archiveName] = ['event', 'event']; break;
            default: return;
        }
        formName = action.replace(/_/g, '-');

        UI.createMainContainer();
        if (SHOW_SIMULATOR) {
            UI.createSimulatorUI(STATE.ui.mainContainer);
        }
        UI.createDebugUI(STATE.ui.mainContainer);

        MB.Art.add_art_submit = async function(gid, upvm) {
            STATE.debug.files = upvm.files_to_upload();
            const filesToUpload = STATE.debug.files.filter(f => f.status() !== 'done');
            if (filesToUpload.length === 0) return;

            UI.updateDebugUI();
            STATE.simulator.attemptCounter = { sign: 0, upload: 0, submit: 0 };

            let startingPosition = parseInt($(`#id-${formName}\\.position`).val(), 10);
            $('.add-files.row, #cover-art-position-row, #event-art-position-row').hide();
            $('#content')[0].scrollIntoView({ behavior: 'smooth' });
            $(`#${formName}-submit`).prop('disabled', true);

            const signLock = new AsyncLock();
            const submitLock = new AsyncLock();

            const processFile = async (fileUpload) => {
                if (!fileUpload._script) fileUpload._script = {};
                const position = startingPosition + upvm.files_to_upload().indexOf(fileUpload);
                let currentDelay = INITIAL_RETRY_DELAY_MS;

                const simulateFailure = (opName) => {
                    STATE.simulator.attemptCounter[opName]++;
                    if (SHOW_SIMULATOR && STATE.simulator.enabled && STATE.simulator.failOn === opName && STATE.simulator.attemptCounter[opName] === STATE.simulator.failCount) {
                        logger.warn(`SIMULATOR: Failing '${opName}' on attempt #${STATE.simulator.attemptCounter[opName]} with status ${STATE.simulator.status}`);
                        throw { isSimulation: true, status: STATE.simulator.status };
                    }
                };

                while (true) {
                    try {
                        fileUpload.status(MB.Art.upload_status_enum.signing);
                        fileUpload._script.stage = 'Signing';
                        UI.updateDebugUI();
                        simulateFailure('sign');
                        const postfields = await signLock.acquire(() => toNativePromise(MB.Art.sign_upload(fileUpload, gid, fileUpload.mimeType())));
                        fileUpload.postfields = postfields;

                        fileUpload.status(MB.Art.upload_status_enum.uploading);
                        fileUpload._script.stage = 'Uploading';
                        UI.updateDebugUI();
                        simulateFailure('upload');
                        await toNativePromise(MB.Art.upload_image(fileUpload.postfields, fileUpload.data)
                            .progress(value => { fileUpload.progress(10 + (value * 0.8)); }));

                        fileUpload.status(MB.Art.upload_status_enum.submitting);
                        fileUpload._script.stage = 'Submitting';
                        UI.updateDebugUI();
                        simulateFailure('submit');
                        await submitLock.acquire(() => toNativePromise(MB.Art.submit_edit(fileUpload, fileUpload.postfields, fileUpload.mimeType(), position)));

                        fileUpload.status(MB.Art.upload_status_enum.done);
                        fileUpload._script.stage = 'Done';
                        break;
                    } catch (error) {
                        let httpStatus = null;
                        if (error.isSimulation) httpStatus = error.status;
                        else if (error[0]?.status !== undefined) httpStatus = error[0].status;

                        const isRetriable = (httpStatus >= 500 || httpStatus === 429 || httpStatus === 408 || httpStatus === 0);

                        if (error.isSimulation) {
                            const statusEnum = MB.Art.upload_status_enum;
                            fileUpload.status(isRetriable ? statusEnum.slowdown_error : statusEnum.submit_error);
                        }

                        if (isRetriable) {
                            fileUpload._script.stage = `Retrying (HTTP ${httpStatus})...`;
                            UI.updateDebugUI();
                            await new Promise(resolve => setTimeout(resolve, currentDelay));
                            currentDelay = Math.min(currentDelay * 2, MAX_RETRY_DELAY_MS);
                            continue;
                        } else {
                            const finalStatus = fileUpload.status();
                            fileUpload._script.stage = `Failed`;
                            fileUpload._script.httpStatus = httpStatus;
                            logger.error(`Unrecoverable error for file "${fileUpload.name}": ${finalStatus} (HTTP Status: ${httpStatus ?? 'N/A'})`);
                            break;
                        }
                    }
                }
                UI.updateDebugUI();
            };

            const workerPromises = Array(CONCURRENCY_LIMIT).fill(null).map(() => (async () => {
                while (filesToUpload.length > 0) {
                    const file = filesToUpload.shift();
                    if (file) await processFile(file);
                }
            })());

            await Promise.all(workerPromises);
            await submitLock.promise;

            const allSucceeded = upvm.files_to_upload().every(f => f.status() === 'done');

            if (allSucceeded) {
                if (STATE.ui.mainContainer) STATE.ui.mainContainer.remove();
                window.location.href = `/${entityType}/${gid}/${archiveName}-art`;
            } else {
                logger.log('Process finished. Some files may have failed. Please review their statuses.');
                $(`#${formName}-submit`).prop('disabled', false);
            }
        };
    }
})();

QingJ © 2025

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