您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Allows for multiple artwork images to be uploaded simultaneously and recursively upload directories.
// ==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(); })();