// ==UserScript==
// @name MusicBrainz: Artwork Uploader Turbo
// @namespace https://musicbrainz.org/user/chaban
// @version 3.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 UPLOAD_WORKER_LIMIT = 4;
const INITIAL_RETRY_DELAY_MS = 2000;
const MAX_RETRY_DELAY_MS = 60000;
// --- 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(SCRIPT_NAME, ...args),
warn: (...args) => console.warn(SCRIPT_NAME, ...args),
error: (...args) => console.error(SCRIPT_NAME, ...args),
};
// --- SCRIPT STATE ---
const STATE = {
debug: {
files: [],
ui: {},
},
ui: {
mainContainer: null,
},
};
// --- PROMISE HELPERS ---
function toNativePromise(deferred) {
return new Promise((resolve, reject) => {
deferred.done(resolve).fail((...args) => reject(args));
});
}
// --- 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;
},
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;
requestAnimationFrame(() => {
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 = ''; // Default to empty
// Only show status if it's an error or different from the stage
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');
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();
UI.createDebugUI(STATE.ui.mainContainer);
MB.Art.add_art_submit = async function(gid, upvm) {
STATE.debug.files = upvm.files_to_upload();
const allFiles = STATE.debug.files.filter(f => f.status() !== 'done');
if (allFiles.length === 0) return;
UI.updateDebugUI();
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);
// --- Pipeline Queues ---
const filesToSign = [...allFiles];
const filesToUpload = [];
const filesToSubmit = [];
const totalFileCount = allFiles.length;
let processedFileCount = 0;
let hasCriticalError = false;
const handleRetry = async (fileUpload, error) => {
const httpStatus = error[0]?.status ?? null;
const isRetriable = (httpStatus >= 500 || httpStatus === 429 || httpStatus === 408 || httpStatus === 0 || httpStatus === null);
if (isRetriable) {
fileUpload._script.retryDelay = fileUpload._script.retryDelay || INITIAL_RETRY_DELAY_MS;
fileUpload._script.stage = `Retrying (HTTP ${httpStatus ?? 'N/A'})...`;
UI.updateDebugUI();
await new Promise(resolve => setTimeout(resolve, fileUpload._script.retryDelay));
fileUpload._script.retryDelay = Math.min(fileUpload._script.retryDelay * 2, MAX_RETRY_DELAY_MS);
return true; // Is retriable
}
fileUpload._script.stage = `Failed`;
fileUpload._script.httpStatus = httpStatus;
logger.error(`Unrecoverable error for file "${fileUpload.name}": ${fileUpload.status()} (HTTP Status: ${httpStatus ?? 'N/A'})`);
hasCriticalError = true;
UI.updateDebugUI();
return false; // Not retriable
};
// --- Pipeline Stage 1: Signer Thread ---
const signerThread = async () => {
while (processedFileCount + filesToSubmit.length + filesToUpload.length < totalFileCount) {
const file = filesToSign.shift();
if (!file) {
await new Promise(r => setTimeout(r, 100)); // Wait for more files
continue;
}
if (!file._script) file._script = {};
while (true) {
try {
file.status(MB.Art.upload_status_enum.signing);
file._script.stage = 'Signing';
UI.updateDebugUI();
const postfields = await toNativePromise(MB.Art.sign_upload(file, gid, file.mimeType()));
file.postfields = postfields;
filesToUpload.push(file); // Hand off to next stage
break; // Success
} catch (error) {
if (!(await handleRetry(file, error))) break; // Critical error
}
}
}
};
// --- Pipeline Stage 2: Uploader Pool ---
const uploaderWorker = async () => {
while (processedFileCount < totalFileCount && !hasCriticalError) {
const file = 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';
UI.updateDebugUI();
await toNativePromise(MB.Art.upload_image(file.postfields, file.data)
.progress(value => { file.progress(10 + (value * 0.8)); }));
filesToSubmit.push(file); // Hand off to next stage
break; // Success
} catch (error) {
if (!(await handleRetry(file, error))) break; // Critical error
}
}
}
};
// --- Pipeline Stage 3: Submitter Thread ---
const submitterThread = async () => {
while (processedFileCount < totalFileCount && !hasCriticalError) {
const file = filesToSubmit.shift();
if (!file) {
await new Promise(r => setTimeout(r, 100));
continue;
}
const position = startingPosition + allFiles.indexOf(file);
while (true) {
try {
file.status(MB.Art.upload_status_enum.submitting);
file._script.stage = 'Submitting';
UI.updateDebugUI();
await 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';
UI.updateDebugUI();
processedFileCount++; // Mark as complete
break; // Success
} catch (error) {
if (!(await handleRetry(file, error))) break; // Critical error
}
}
}
};
// --- Start and await all pipeline processes ---
const pipelinePromises = [
signerThread(),
submitterThread(),
...Array(UPLOAD_WORKER_LIMIT).fill(null).map(() => uploaderWorker())
];
await Promise.all(pipelinePromises);
// --- Finalization ---
if (!hasCriticalError) {
if (STATE.ui.mainContainer) STATE.ui.mainContainer.remove();
window.location.href = `/${entityType}/${gid}/${archiveName}-art`;
} else {
logger.log('Process finished. Some files failed and could not be retried.');
$(`#${formName}-submit`).prop('disabled', false);
}
};
}
})();