// ==UserScript==
// @name Click buttons across tabs
// @namespace https://musicbrainz.org/user/chaban
// @version 4.1.0
// @tag ai-created
// @description Clicks specified buttons across tabs using the Broadcast Channel API and closes tabs after successful submission.
// @author chaban
// @license MIT
// @match *://*.musicbrainz.org/*
// @match *://magicisrc.kepstin.ca/*
// @match *://magicisrc-beta.kepstin.ca/*
// @match *://isrchunt.com/*
// @run-at document-start
// @grant GM.info
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// @grant GM.getValue
// @grant GM.setValue
// @grant window.close
// ==/UserScript==
(async function () {
'use strict';
const scriptName = GM.info.script.name;
const tabId = `[${Math.random().toString(36).substring(2, 6)}]`;
console.log(`%c[${scriptName}] ${tabId} Script initialization started on ${location.href}`, 'font-weight: bold;');
/**
* @typedef {Object} SiteConfig
* @property {string|string[]} hostnames - Hostnames where this configuration applies.
* @property {string|string[]} paths - URL paths where this configuration is active.
* @property {string} buttonSelector - The CSS selector for the button to be clicked.
* @property {string} [channelName] - The BroadcastChannel name for cross-tab communication.
* @property {string} [messageTrigger] - The message that triggers the action on the channel.
* @property {string} [menuCommandName] - The name for the userscript menu command.
* @property {(RegExp|string)[]} [successUrlPatterns] - URL patterns that indicate a successful submission.
* @property {boolean} [shouldCloseAfterSuccess=false] - Whether to close the tab after a successful submission.
* @property {boolean} [autoClick=false] - Whether to click the button automatically on page load.
* @property {() => boolean} [isNoOp] - A function that checks if the current page state represents a no-op submission (e.g., a "no changes" banner).
* @property {(config: SiteConfig, triggerAction: () => Promise<boolean>) => void} [submissionHandler] - Custom logic to execute when a submission is triggered, like rate-limiting or pre-flight checks.
*/
const siteConfigurations = [
{
hostnames: ['musicbrainz.org'],
paths: ['/edit-relationships'],
buttonSelector: '.rel-editor > button',
autoClick: true,
successUrlPatterns: [],
shouldCloseAfterSuccess: false,
},
{
hostnames: ['musicbrainz.org'],
paths: ['/edit', '/edit-relationships', '/add-cover-art'],
channelName: 'mb_edit_channel',
messageTrigger: 'submit-edit',
buttonSelector: 'button.submit.positive[type="submit"]',
menuCommandName: 'MusicBrainz: Submit Edit (All Tabs)',
successUrlPatterns: [/^https?:\/\/(?:beta\.)?musicbrainz\.org\/(?!collection\/)[^/]+\/[a-f0-9\-]{36}(?:\/cover-art)?\/?$/],
shouldCloseAfterSuccess: true,
/** Checks for the "no changes have been made" banner on MusicBrainz. */
isNoOp: () => {
const noChangesBanner = document.querySelector('.banner.warning-header');
return noChangesBanner?.textContent.includes(
'The data you have submitted does not make any changes to the data already present.'
);
},
/** Wraps the submission call in a rate limiter. */
submissionHandler: (_config, triggerAction) => {
rateLimitedMBSubmit(triggerAction);
},
},
{
hostnames: ['magicisrc.kepstin.ca', 'magicisrc-beta.kepstin.ca'],
paths: ['/'],
channelName: 'magicisrc_submit_channel',
messageTrigger: 'submit-isrcs',
buttonSelector: '[onclick^="doSubmitISRCs"]',
menuCommandName: 'MagicISRC: Submit ISRCs (All Tabs)',
successUrlPatterns: [/\?.*submit=1/],
shouldCloseAfterSuccess: true,
/** Handles pre-submission checks for MagicISRC. */
submissionHandler: (config, triggerAction) => {
onDOMLoaded(async () => {
const isLoggedIn = !!document.querySelector('button[onclick^="doLogout();"]');
const isErrorPage = !!document.querySelector('h1.h3')?.textContent.includes('An error occured');
const submitButtonExists = !!document.querySelector(config.buttonSelector);
const enableReload = await GM.getValue(MAGICISRC_ENABLE_AUTO_RELOAD, true);
if (isLoggedIn && !isErrorPage && !submitButtonExists) {
checkAndCloseOnSuccess(config, 'Detected MagicISRC page with no new ISRCs to submit.');
return;
}
if (isErrorPage && !enableReload) {
handleMagicISRCReload(true);
return;
}
if (!isErrorPage) {
sessionStorage.removeItem(RELOAD_ATTEMPTS_KEY);
debugLog(`Requesting MagicISRC submit lock...`);
navigator.locks.request(MAGICISRC_SUBMIT_LOCK_KEY, async () => {
debugLog(`Acquired MagicISRC submit lock. Waiting 1s before submission.`, 'green');
await new Promise(resolve => setTimeout(resolve, 1000));
triggerAction();
});
}
});
},
},
{
hostnames: ['isrchunt.com'],
paths: ['/spotify/importisrc', '/deezer/importisrc'],
channelName: 'isrc_hunt_submit_channel',
messageTrigger: 'submit-isrcs',
buttonSelector: 'form[action$="/importisrc"] button[type="submit"]',
menuCommandName: 'ISRC Hunt: Submit ISRCs (All Tabs)',
successUrlPatterns: [/\?.*submitted=1/],
shouldCloseAfterSuccess: true,
/** Handles pre-submission checks for ISRC Hunt. */
submissionHandler: (_config, triggerAction) => {
debugLog(`Requesting ISRC Hunt submit lock...`);
navigator.locks.request(ISRC_HUNT_SUBMIT_LOCK_KEY, async () => {
debugLog(`Acquired ISRC Hunt submit lock. Waiting 1s before submission.`, 'green');
await new Promise(resolve => setTimeout(resolve, 1000));
triggerAction();
});
},
},
];
const SUBMISSION_TRIGGERED_FLAG = 'broadcastChannelSubmissionState';
const RELOAD_ATTEMPTS_KEY = 'magicisrc_reload_attempts';
const RELOAD_LOCK_KEY = 'magicisrc-reload-lock';
const MAGICISRC_SUBMIT_LOCK_KEY = 'magicisrc-submit-lock';
const ISRC_HUNT_SUBMIT_LOCK_KEY = 'isrc-hunt-submit-lock';
const MB_SUBMIT_COORDINATION_LOCK_KEY = 'mb-submit-coordination-lock';
const MB_LAST_SUBMIT_TIMESTAMP_KEY = 'mb_last_submit_timestamp';
const DEBUG_LOG_CHANNEL_NAME = `${scriptName}_debug_log`;
const MUSICBRAINZ_SUBMITS_PER_SECOND_SETTING = 'mb_submits_per_second';
const MUSICBRAINZ_DISABLE_RATE_LIMITER_SETTING = 'mb_disable_rate_limiter';
const DISABLE_AUTO_CLOSE_SETTING = 'mb_button_clicker_disableAutoClose';
const MAGICISRC_ENABLE_AUTO_RELOAD = 'magicisrc_enableAutoReload';
const DEBUG_LOGGING_SETTING = 'debug_logging_enabled';
let registeredMenuCommandIDs = [];
let debugLogChannel;
/**
* @summary Sends a log message to all tabs if debug logging is enabled.
* @param {string} message The message to log.
* @param {string} [color] Optional CSS color for the message.
*/
async function debugLog(message, color = 'blue') {
const debugEnabled = await GM.getValue(DEBUG_LOGGING_SETTING, false);
if (!debugEnabled) return;
if (!debugLogChannel) {
debugLogChannel = new BroadcastChannel(DEBUG_LOG_CHANNEL_NAME);
}
debugLogChannel.postMessage({
tabId,
message,
color,
timestamp: new Date().toISOString(),
});
}
/**
* @summary Executes a callback when the DOM is ready, or immediately if it's already loaded.
* @param {Function} callback The function to execute.
*/
function onDOMLoaded(callback) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', callback);
} else {
callback();
}
}
/**
* @summary Finds all site configurations that are active for the current page URL path.
* @returns {SiteConfig[]} An array of active configurations.
*/
function getActiveConfigs() {
const currentHostname = location.hostname;
const currentPathname = location.pathname;
return siteConfigurations.filter(config => {
const hostnames = Array.isArray(config.hostnames) ? config.hostnames : [config.hostnames];
const paths = Array.isArray(config.paths) ? config.paths : [config.paths];
const hostnameMatches = hostnames.some(h => currentHostname.includes(h));
const pathMatches = paths.some(p => currentPathname.endsWith(p));
return hostnameMatches && pathMatches;
});
}
/**
* @summary Waits for a button to appear and become enabled, then clicks it.
* @param {SiteConfig} config - The configuration object for the button.
* @param {Function} [onClick] - An optional callback to execute immediately after the click.
* @returns {Promise<boolean>} Resolves to true if clicked, false otherwise.
*/
async function waitForButtonAndClick(config, onClick) {
return new Promise(resolve => {
const checkAndClick = (obs) => {
const btn = document.querySelector(config.buttonSelector);
if (btn && !btn.disabled) {
debugLog(`Button "${config.buttonSelector}" found and enabled. Clicking.`, 'green');
btn.click();
onClick?.(btn);
if (obs) obs.disconnect();
resolve(true);
return true;
}
return false;
};
onDOMLoaded(() => {
if (checkAndClick(null)) return;
const observer = new MutationObserver((_, obs) => checkAndClick(obs));
observer.observe(document.body, { childList: true, subtree: true, attributes: true });
});
});
}
/**
* @summary Checks if the current URL matches a success pattern for a given configuration.
* @param {SiteConfig} config - The site configuration.
* @param {boolean} [quiet=false] - If true, suppresses console logs.
* @returns {boolean} True if the URL matches a success pattern.
*/
function isSubmissionSuccessful(config, quiet = false) {
if (!config?.successUrlPatterns?.length) return false;
const url = location.href;
const isSuccess = config.successUrlPatterns.some(pattern =>
(typeof pattern === 'string' ? url.includes(pattern) : pattern.test(url))
);
if (isSuccess && !quiet) {
debugLog(`URL "${url}" matches success pattern.`);
}
return isSuccess;
}
/**
* @summary Checks if a submission was successful and closes the tab if configured to do so.
* @param {SiteConfig} config - The site configuration for success checking.
* @param {string|null} [preSubmissionNoOpReason=null] - A string indicating a no-op reason detected before submission.
*/
async function checkAndCloseOnSuccess(config, preSubmissionNoOpReason = null) {
if (!config || sessionStorage.getItem(SUBMISSION_TRIGGERED_FLAG) !== 'true') return;
const isPostSubmitNoOp = config.isNoOp?.() ?? false;
if (isSubmissionSuccessful(config) || isPostSubmitNoOp || preSubmissionNoOpReason) {
if (isPostSubmitNoOp) {
debugLog(`Detected a post-submission no-op state. Treating as success.`);
} else if (preSubmissionNoOpReason) {
debugLog(`${preSubmissionNoOpReason} Treating as success.`);
}
debugLog(`Submission successful. Clearing flag.`);
sessionStorage.removeItem(SUBMISSION_TRIGGERED_FLAG);
const disableAutoClose = await GM.getValue(DISABLE_AUTO_CLOSE_SETTING, false);
if (disableAutoClose) {
debugLog(`Auto-closing is DISABLED by user setting.`, 'orange');
} else {
debugLog(`Closing tab.`, 'green');
setTimeout(() => window.close(), 200);
}
}
}
/**
* @summary Handles the reload logic for MagicISRC pages with exponential backoff and a Web Lock.
* @param {boolean} [manual=false] - If true, bypasses the 'enableReload' check and forces the reload logic.
*/
async function handleMagicISRCReload(manual = false) {
const enableReload = await GM.getValue(MAGICISRC_ENABLE_AUTO_RELOAD, true);
if (!enableReload && !manual) {
debugLog(`MagicISRC automatic reload is DISABLED.`, 'orange');
return;
}
debugLog(`An error occurred. Requesting reload lock...`, 'red');
navigator.locks.request(RELOAD_LOCK_KEY, async () => {
debugLog(`Acquired reload lock. This tab will handle the reload.`, 'red');
let attempts = parseInt(sessionStorage.getItem(RELOAD_ATTEMPTS_KEY) || '0');
attempts++;
const backoffSeconds = Math.pow(2, Math.min(attempts, 6));
const jitter = Math.random();
const delay = (backoffSeconds + jitter) * 1000;
debugLog(`MagicISRC error detected. Reload attempt ${attempts}. Retrying in ${Math.round(delay / 1000)}s.`, 'red');
sessionStorage.setItem(RELOAD_ATTEMPTS_KEY, attempts.toString());
await new Promise(resolve => setTimeout(resolve, delay));
if (typeof window.onPopState === 'function') {
debugLog(`Re-triggering data fetch via onPopState.`);
window.onPopState();
} else {
debugLog(`window.onPopState is not available. Falling back to reload.`, 'red');
location.reload();
}
});
}
/**
* @summary Sets up listeners and handlers specific to MagicISRC pages.
* @description Injects a script to intercept fetch/render errors and sets up a message listener to handle them.
*/
function setupMagicISRC() {
if (!location.hostname.includes('magicisrc')) return;
debugLog(`MagicISRC page detected. Setting up special handlers.`);
const script = document.createElement('script');
script.textContent = `
(() => {
const post = (type) => window.postMessage({ source: '${scriptName}', type }, location.origin);
const origFetch = window.fetch;
window.fetch = (...args) => origFetch(...args).catch(err => { post('FETCH_ERROR'); throw err; });
const origRenderError = window.renderError;
window.renderError = (...args) => {
post('RENDER_ERROR');
if(origRenderError) origRenderError.apply(this, args);
};
})();
`;
document.documentElement.appendChild(script);
script.remove();
window.addEventListener('message', (event) => {
if (event.origin !== location.origin || event.data?.source !== scriptName) return;
if (event.data.type === 'FETCH_ERROR' || event.data.type === 'RENDER_ERROR') {
handleMagicISRCReload();
}
});
}
/**
* @summary Registers all userscript menu commands and settings toggles.
*/
async function setupMenuCommands() {
for (const commandId of registeredMenuCommandIDs) {
try {
GM_unregisterMenuCommand(commandId);
} catch (e) { /* ignore */ }
}
registeredMenuCommandIDs = [];
const registerCommand = (name, func) => {
const id = GM_registerMenuCommand(name, func);
registeredMenuCommandIDs.push(id);
};
const settings = [
{
key: DISABLE_AUTO_CLOSE_SETTING,
getLabel: async (value) => `Auto Close Tabs: ${value ? 'DISABLED' : 'ENABLED'}`,
onClick: async (currentValue) => GM.setValue(DISABLE_AUTO_CLOSE_SETTING, !currentValue),
defaultValue: false,
},
{
key: MAGICISRC_ENABLE_AUTO_RELOAD,
getLabel: async (value) => `MagicISRC Auto Reload: ${value ? 'ENABLED' : 'DISABLED'}`,
onClick: async (currentValue) => GM.setValue(MAGICISRC_ENABLE_AUTO_RELOAD, !currentValue),
defaultValue: true,
},
{
key: DEBUG_LOGGING_SETTING,
getLabel: async (value) => `Debug Logging: ${value ? 'ENABLED' : 'DISABLED'}`,
onClick: async (currentValue) => GM.setValue(DEBUG_LOGGING_SETTING, !currentValue),
defaultValue: false,
},
{
key: MUSICBRAINZ_SUBMITS_PER_SECOND_SETTING,
getLabel: async (value) => `MusicBrainz Edit Submits / sec (Current: ${value})`,
onClick: async (currentValue) => {
const newValue = prompt(`Enter new max submissions per second for MusicBrainz:`, currentValue);
const newRate = parseInt(newValue, 10);
if (!isNaN(newRate) && newRate > 0) {
await GM.setValue(MUSICBRAINZ_SUBMITS_PER_SECOND_SETTING, newRate);
} else if (newValue !== null) {
alert('Please enter a valid positive number.');
}
},
defaultValue: 10,
},
{
key: MUSICBRAINZ_DISABLE_RATE_LIMITER_SETTING,
getLabel: async (value) => `MusicBrainz Rate Limiter: ${value ? 'DISABLED' : 'ENABLED'}`,
onClick: async (currentValue) => GM.setValue(MUSICBRAINZ_DISABLE_RATE_LIMITER_SETTING, !currentValue),
defaultValue: false,
},
];
for (const setting of settings) {
const value = await GM.getValue(setting.key, setting.defaultValue);
registerCommand(await setting.getLabel(value), async () => {
await setting.onClick(value);
await setupMenuCommands();
});
}
const activeConfigs = getActiveConfigs();
const configsForMenu = activeConfigs.filter(c => !c.autoClick && c.menuCommandName);
for (const config of configsForMenu) {
registerCommand(config.menuCommandName, () => {
const channel = new BroadcastChannel(config.channelName);
channel.postMessage(config.messageTrigger);
channel.close();
});
}
debugLog(`Menu commands updated.`);
}
/**
* @summary Executes a callback after ensuring the configured rate limit is not exceeded.
* @param {Function} callback The function to execute.
*/
async function rateLimitedMBSubmit(callback) {
const limiterDisabled = await GM.getValue(MUSICBRAINZ_DISABLE_RATE_LIMITER_SETTING, false);
if (limiterDisabled) {
debugLog('MusicBrainz rate limiter is disabled. Submitting immediately.', 'orange');
callback();
return;
}
const submitsPerSecond = await GM.getValue(MUSICBRAINZ_SUBMITS_PER_SECOND_SETTING, 10);
const requiredInterval = 1000 / submitsPerSecond;
debugLog(`Requesting MB submission lock...`);
navigator.locks.request(MB_SUBMIT_COORDINATION_LOCK_KEY, async () => {
debugLog(`Acquired MB submission lock.`, 'green');
const lastSubmit = await GM.getValue(MB_LAST_SUBMIT_TIMESTAMP_KEY, 0);
const now = Date.now();
const elapsed = now - lastSubmit;
if (elapsed < requiredInterval) {
const waitTime = requiredInterval - elapsed;
debugLog(`Rate limiting: waiting ${waitTime.toFixed(0)}ms...`, 'orange');
await new Promise(resolve => setTimeout(resolve, waitTime));
}
await GM.setValue(MB_LAST_SUBMIT_TIMESTAMP_KEY, Date.now().toString());
debugLog(`Executing submission.`, 'darkgreen');
callback();
});
}
/**
* @summary Sets up listeners for specified configurations, handling auto-clicks or broadcast channel messages.
* @param {SiteConfig[]} configs - An array of configuration objects.
*/
function setupConfigListeners(configs) {
for (const config of configs) {
const triggerAction = () => waitForButtonAndClick(config);
if (config.autoClick) {
debugLog(`Setting up auto-click for "${config.buttonSelector}".`);
triggerAction();
continue;
}
if (config.channelName) {
const channel = new BroadcastChannel(config.channelName);
channel.onmessage = async (event) => {
if (event.data !== config.messageTrigger) return;
debugLog(`Received trigger "${event.data}".`);
sessionStorage.setItem(SUBMISSION_TRIGGERED_FLAG, 'true');
if (config.submissionHandler) {
config.submissionHandler(config, triggerAction);
} else {
triggerAction();
}
};
}
}
}
/**
* @summary Wraps a method on the history object to call a callback after it executes.
* @param {'pushState'|'replaceState'} methodName The name of the history method to wrap.
* @param {Function} callback The function to call after the original method.
*/
function wrapHistoryMethod(methodName, callback) {
const original = history[methodName];
history[methodName] = function (...args) {
original.apply(this, args);
callback();
};
}
/**
* @summary Sets up listeners for URL changes to check for submission success.
* @description This is a global handler that runs on all matched pages. It checks if a submission
* was triggered and then determines if the current page is a success page for any configuration.
*/
function setupSuccessHandling() {
const potentialSuccessConfigs = siteConfigurations.filter(c => c.shouldCloseAfterSuccess);
if (potentialSuccessConfigs.length === 0) return;
const runCheck = () => {
if (sessionStorage.getItem(SUBMISSION_TRIGGERED_FLAG) !== 'true') return;
for (const config of potentialSuccessConfigs) {
const isSuccess = isSubmissionSuccessful(config, true);
const isNoOp = config.isNoOp?.() ?? false;
if (isSuccess || isNoOp) {
checkAndCloseOnSuccess(config);
return;
}
}
};
onDOMLoaded(runCheck);
wrapHistoryMethod('pushState', runCheck);
wrapHistoryMethod('replaceState', runCheck);
window.addEventListener('popstate', runCheck);
}
/**
* @summary Main script entry point.
*/
async function main() {
await setupMenuCommands();
const debugEnabled = await GM.getValue(DEBUG_LOGGING_SETTING, false);
if (debugEnabled) {
const logReceiver = new BroadcastChannel(DEBUG_LOG_CHANNEL_NAME);
logReceiver.onmessage = (event) => {
const { tabId: msgTabId, message, color, timestamp } = event.data;
console.log(`%c[${scriptName}] [${timestamp}] ${msgTabId} ${message}`, `color: ${color}`);
};
}
const activeConfigs = getActiveConfigs();
if (activeConfigs.length > 0) {
setupConfigListeners(activeConfigs);
}
setupMagicISRC();
setupSuccessHandling();
debugLog(`Initialization finished.`);
}
main();
})();