您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Allows for multiple artwork images to be uploaded simultaneously.
当前为
// ==UserScript== // @name MusicBrainz: Artwork Uploader Turbo // @namespace https://musicbrainz.org/user/chaban // @version 2.0.1 // @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-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; } #mb-artwork-uploader-turbo-container form input, #mb-artwork-uploader-turbo-container form select { border: 1px solid #999; border-radius: 3px; } #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); } // --- LOGGER UTILITY --- const SCRIPT_NAME= '[MusicBrainz: Artwork Uploader Turbo]'; 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-artwork-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或关注我们的公众号极客氢云获取最新地址