MusicBrainz: Artwork Uploader Turbo

Allows for multiple artwork images to be uploaded simultaneously and recursively upload directories.

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         MusicBrainz: Artwork Uploader Turbo
// @namespace    https://musicbrainz.org/user/chaban
// @version      3.1.0
// @tag          ai-created
// @description  Allows for multiple artwork images to be uploaded simultaneously and recursively upload directories.
// @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';

    // --- MAIN APPLICATION ---
    const ArtworkUploaderTurbo = {
        // --- CONFIGURATION ---
        UPLOAD_WORKER_LIMIT: 4,
        INITIAL_RETRY_DELAY_MS: 2000,
        MAX_RETRY_DELAY_MS: 60000,
        SCRIPT_NAME: '[MusicBrainz: Artwork Uploader Turbo]',

        // --- STATE ---
        state: {
            files: [],
            ui: {},
            upvm: null, // To hold the captured ViewModel instance
        },

        // --- LOGGER UTILITY ---
        logger: {
            log: (...args) => console.log(ArtworkUploaderTurbo.SCRIPT_NAME, ...args),
            warn: (...args) => console.warn(ArtworkUploaderTurbo.SCRIPT_NAME, ...args),
            error: (...args) => console.error(ArtworkUploaderTurbo.SCRIPT_NAME, ...args),
        },

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

        // --- UI RENDERING ---
        UI: {
            init() {
                this.injectStyles();
                this.createMainContainer();
                this.createDebugUI(ArtworkUploaderTurbo.state.ui.mainContainer);
            },

            injectStyles() {
                const styleSheet = document.createElement('style');
                styleSheet.type = 'text/css';
                styleSheet.innerText = `
                    #mb-artwork-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-artwork-uploader-turbo-container summary { font-weight: bold; cursor: pointer; }
                    #mb-artwork-uploader-turbo-container .status-list-item.done { color: var(--positive-emphasis, lightgreen); }
                    #mb-artwork-uploader-turbo-container .status-list-item.error { color: var(--negative-emphasis, red); }
                `;
                document.head.appendChild(styleSheet);
            },

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

            createCollapsibleSection(container, title, isOpen = false) {
                const details = document.createElement('details');
                details.open = isOpen;
                const summary = document.createElement('summary');
                summary.textContent = title;
                details.append(summary);
                container.append(details);
                return details;
            },

            createDebugUI(container) {
                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);
                ArtworkUploaderTurbo.state.ui.fileList = list;
            },

            updateDebugUI() {
                const { fileList } = ArtworkUploaderTurbo.state.ui;
                if (!fileList) return;

                requestAnimationFrame(() => {
                    fileList.innerHTML = '';
                    for (const file of ArtworkUploaderTurbo.state.files) {
                        const item = document.createElement('li');
                        item.className = 'status-list-item';
                        const status = file.status();
                        const stage = file._script?.stage ?? 'Pending';
                        let statusText = '';

                        if (stage === 'Failed' && file._script?.httpStatus !== undefined) {
                            statusText = `(${status}, HTTP ${file._script.httpStatus ?? 'N/A'})`;
                        } else if (status.includes('error') || stage.toLowerCase() !== status.toLowerCase()) {
                            statusText = `(${status})`;
                        }

                        item.textContent = `${file.name}: ${stage}${statusText ? ' ' + statusText : ''}`;
                        if (status === 'done') item.classList.add('done');
                        else if (status?.includes('error')) item.classList.add('error');
                        fileList.append(item);
                    }
                });
            },
        },

        // --- DIRECTORY UPLOADER FEATURE ---
        DirectoryUploader: {
            _addFilesButton: null,
            _dirInput: null,
            _originalButtonText: '',

            init() {
                const observer = new MutationObserver((mutations, obs) => {
                    const button = document.querySelector('span.fileinput-button.buttons button.add-files');
                    if (button) {
                        this._enhanceButton(button);
                        obs.disconnect();
                    }
                });
                observer.observe(document.body, { childList: true, subtree: true });
            },

            _enhanceButton(button) {
                this._addFilesButton = button;
                this._originalButtonText = button.textContent;

                this._dirInput = document.createElement('input');
                this._dirInput.type = 'file';
                this._dirInput.webkitdirectory = true;
                this._dirInput.multiple = true;
                this._dirInput.style.display = 'none';
                document.body.append(this._dirInput);

                this._addFilesButton.addEventListener('click', this._handleClick.bind(this), true);
                this._dirInput.addEventListener('change', this._handleDirectorySelection.bind(this));
                document.addEventListener('keydown', this._handleShiftState.bind(this));
                document.addEventListener('keyup', this._handleShiftState.bind(this));
                window.addEventListener('blur', () => {
                    this._addFilesButton.textContent = this._originalButtonText;
                });

                this._addFilesButton.setAttribute('title', 'Hold Shift to select a directory');
            },

            _handleClick(event) {
                if (event.shiftKey) {
                    event.stopImmediatePropagation();
                    event.preventDefault();
                    this._dirInput.click();
                }
            },

            _handleShiftState(event) {
                if (event.key === 'Shift') {
                    this._addFilesButton.textContent = event.type === 'keydown'
                        ? 'Select directory...'
                        : this._originalButtonText;
                }
            },

            async _handleDirectorySelection(event) {
                const files = Array.from(event.target.files);
                if (files.length === 0) {
                    ArtworkUploaderTurbo.logger.warn('No files found in selected directory.');
                    return;
                }

                if (ArtworkUploaderTurbo.state.upvm?.addFile) {
                    const validationPromises = files.map(file =>
                        ArtworkUploaderTurbo.toNativePromise(MB.Art.validate_file(file))
                            .then(() => ({ file, valid: true }))
                            .catch(() => ({ file, valid: false }))
                    );

                    const results = await Promise.all(validationPromises);
                    const validFiles = results.filter(r => r.valid).map(r => r.file);

                    if (validFiles.length > 0) {
                        ArtworkUploaderTurbo.logger.log(`Adding ${validFiles.length} valid files.`);
                    }
                    if (validFiles.length < files.length) {
                        ArtworkUploaderTurbo.logger.log(`Ignoring ${files.length - validFiles.length} invalid files.`);
                    }

                    validFiles.forEach(file => ArtworkUploaderTurbo.state.upvm.addFile(file));

                    const formName = window.__MB__.$c.action.name.replace(/_/g, '-');
                    document.querySelector(`#${formName}-submit`).disabled = false;
                } else {
                    ArtworkUploaderTurbo.logger.error("Could not access the captured UploadProcessViewModel.");
                }

                event.target.value = '';
            },
        },

        // --- MAIN UPLOADER LOGIC ---
        Uploader: {
            init() {
                const { name: actionName } = window.__MB__.$c.action;
                const pageInfo = this._getPageInfo(actionName);
                if (!pageInfo) return;

                MB.Art.add_art_submit = this.run.bind(this, pageInfo);
            },

            _getPageInfo(actionName) {
                let entityType, archiveName;
                switch (actionName) {
                    case 'add_cover_art': [entityType, archiveName] = ['release', 'cover']; break;
                    case 'add_event_art': [entityType, archiveName] = ['event', 'event']; break;
                    default: return null;
                }
                const formName = actionName.replace(/_/g, '-');
                return { entityType, archiveName, formName };
            },

            async run({ entityType, archiveName, formName }, gid, upvm) {
                ArtworkUploaderTurbo.state.files = upvm.files_to_upload().filter(f => f.status() !== 'done');
                if (ArtworkUploaderTurbo.state.files.length === 0) return;

                ArtworkUploaderTurbo.UI.updateDebugUI();
                this._prepareUI(formName);

                const pipeline = new this.Pipeline(gid, ArtworkUploaderTurbo.state.files, formName);
                await pipeline.start();
                this._finalize(pipeline.hasCriticalError, entityType, archiveName, gid, formName);
            },

            _prepareUI(formName) {
                $('.add-files.row, #cover-art-position-row, #event-art-position-row').hide();
                document.querySelector('#content').scrollIntoView({ behavior: 'smooth' });
                document.querySelector(`#${formName}-submit`).disabled = true;
            },

            _finalize(hasError, entityType, archiveName, gid, formName) {
                if (!hasError) {
                    const container = ArtworkUploaderTurbo.state.ui.mainContainer;
                    if (container) container.remove();
                    window.location.href = `/${entityType}/${gid}/${archiveName}-art`;
                } else {
                    ArtworkUploaderTurbo.logger.log('Process finished. Some files failed and could not be retried.');
                    document.querySelector(`#${formName}-submit`).disabled = false;
                }
            },

            Pipeline: class {
                constructor(gid, allFiles, formName) {
                    this.gid = gid;
                    this.allFiles = allFiles;
                    this.formName = formName;
                    this.filesToSign = [...allFiles];
                    this.filesToUpload = [];
                    this.filesToSubmit = [];
                    this.processedFileCount = 0;
                    this.hasCriticalError = false;
                }

                async start() {
                    const promises = [
                        this._signerThread(),
                        this._submitterThread(),
                        ...Array(ArtworkUploaderTurbo.UPLOAD_WORKER_LIMIT).fill(null).map(() => this._uploaderWorker())
                    ];
                    await Promise.all(promises);
                }

                async _handleRetry(file, error) {
                    const httpStatus = error[0]?.status ?? null;
                    const isRetriable = (httpStatus >= 500 || httpStatus === 429 || httpStatus === 408 || httpStatus === 0 || httpStatus === null);

                    if (isRetriable) {
                        file._script.retryDelay = file._script.retryDelay || ArtworkUploaderTurbo.INITIAL_RETRY_DELAY_MS;
                        file._script.stage = `Retrying (HTTP ${httpStatus ?? 'N/A'})...`;
                        ArtworkUploaderTurbo.UI.updateDebugUI();
                        await new Promise(resolve => setTimeout(resolve, file._script.retryDelay));
                        file._script.retryDelay = Math.min(file._script.retryDelay * 2, ArtworkUploaderTurbo.MAX_RETRY_DELAY_MS);
                        return true;
                    }

                    file._script.stage = `Failed`;
                    file._script.httpStatus = httpStatus;
                    this.hasCriticalError = true;
                    ArtworkUploaderTurbo.logger.error(`Unrecoverable error for file "${file.name}": ${file.status()} (HTTP Status: ${httpStatus ?? 'N/A'})`);
                    ArtworkUploaderTurbo.UI.updateDebugUI();
                    return false;
                }

                async _signerThread() {
                    while (this.processedFileCount + this.filesToSubmit.length + this.filesToUpload.length < this.allFiles.length) {
                        const file = this.filesToSign.shift();
                        if (!file) { await new Promise(r => setTimeout(r, 100)); continue; }
                        if (!file._script) file._script = {};

                        while (true) {
                            try {
                                file.status(MB.Art.upload_status_enum.signing);
                                file._script.stage = 'Signing';
                                ArtworkUploaderTurbo.UI.updateDebugUI();
                                file.postfields = await ArtworkUploaderTurbo.toNativePromise(MB.Art.sign_upload(file, this.gid, file.mimeType()));
                                this.filesToUpload.push(file);
                                break;
                            } catch (error) {
                                if (!(await this._handleRetry(file, error))) break;
                            }
                        }
                    }
                }

                async _uploaderWorker() {
                     while (this.processedFileCount < this.allFiles.length && !this.hasCriticalError) {
                        const file = this.filesToUpload.shift();
                        if (!file) { await new Promise(r => setTimeout(r, 100)); continue; }

                        while (true) {
                            try {
                                file.status(MB.Art.upload_status_enum.uploading);
                                file._script.stage = 'Uploading';
                                ArtworkUploaderTurbo.UI.updateDebugUI();
                                await ArtworkUploaderTurbo.toNativePromise(MB.Art.upload_image(file.postfields, file.data)
                                    .progress(value => { file.progress(10 + (value * 0.8)); }));
                                this.filesToSubmit.push(file);
                                break;
                            } catch (error) {
                                if (!(await this._handleRetry(file, error))) break;
                            }
                        }
                    }
                }

                async _submitterThread() {
                    const startingPosition = parseInt($(`#id-${this.formName}\\.position`).val(), 10);
                    while (this.processedFileCount < this.allFiles.length && !this.hasCriticalError) {
                        const file = this.filesToSubmit.shift();
                        if (!file) { await new Promise(r => setTimeout(r, 100)); continue; }

                        const position = startingPosition + this.allFiles.indexOf(file);

                        while (true) {
                            try {
                                file.status(MB.Art.upload_status_enum.submitting);
                                file._script.stage = 'Submitting';
                                ArtworkUploaderTurbo.UI.updateDebugUI();
                                await ArtworkUploaderTurbo.toNativePromise(MB.Art.submit_edit(file, file.postfields, file.mimeType(), position));
                                file.progress(100);
                                file.status(MB.Art.upload_status_enum.done);
                                file._script.stage = 'Done';
                                this.processedFileCount++;
                                ArtworkUploaderTurbo.UI.updateDebugUI();
                                break;
                            } catch (error) {
                                if (!(await this._handleRetry(file, error))) break;
                            }
                        }
                    }
                }
            }
        },

        // --- SCRIPT INITIALIZATION ---
        init() {
            const checkMB = setInterval(() => {
                if (window.MB?.Art?.add_art_submit && window.MB?.Art?.UploadProcessViewModel && window.__MB__?.$c && window.$) {
                    clearInterval(checkMB);

                    const originalVM = MB.Art.UploadProcessViewModel;
                    MB.Art.UploadProcessViewModel = function(...args) {
                        const instance = new originalVM(...args);
                        ArtworkUploaderTurbo.state.upvm = instance;
                        ArtworkUploaderTurbo.logger.log('Successfully captured UploadProcessViewModel instance.');
                        return instance;
                    };

                    this.UI.init();
                    this.Uploader.init();
                    this.DirectoryUploader.init();
                }
            }, 50);
        }
    };

    ArtworkUploaderTurbo.init();

})();