// ==UserScript==
// @name Click buttons across tabs
// @namespace https://musicbrainz.org/user/chaban
// @version 3.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/*
// @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;
console.log(`%c[${scriptName}] Script initialization started on ${location.href}`, 'font-weight: bold;');
/**
* @typedef {Object} SiteConfig
* @property {string|string[]} hostnames
* @property {string|string[]} paths
* @property {string} buttonSelector
* @property {string} [channelName]
* @property {string} [messageTrigger]
* @property {string} [menuCommandName]
* @property {(RegExp|string)[]} [successUrlPatterns]
* @property {boolean} [shouldCloseAfterSuccess=false]
* @property {boolean} [autoClick=false]
* @property {boolean} [disableDelay=false]
*/
const siteConfigurations = [
{
hostnames: ['musicbrainz.org'],
paths: ['/edit-relationships'],
buttonSelector: '.rel-editor > button',
autoClick: true,
disableDelay: 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,
},
{
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,
},
];
const SUBMISSION_TRIGGERED_FLAG = 'broadcastChannelSubmissionState';
const GLOBAL_CLOSE_TAB_CHANNEL_NAME = 'global_close_tab_channel';
const GLOBAL_CLOSE_TAB_MESSAGE_TRIGGER = 'close-this-tab';
const GLOBAL_CLOSE_TAB_MENU_COMMAND_NAME = 'Global: Close This Tab (All Tabs)';
const DELAY_MODE_SETTING = 'mb_button_clicker_delayMode';
const STATIC_MAX_DELAY_SETTING = 'mb_button_clicker_staticMaxDelay';
const DISABLE_AUTO_CLOSE_SETTING = 'mb_button_clicker_disableAutoClose';
const MAGICISRC_ENABLE_AUTO_RELOAD = 'magicisrc_enableAutoReload';
let registeredMenuCommandIDs = [];
/**
* 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();
}
}
/**
* Finds all site configurations that are active for the current page URL path.
* This is used to determine which buttons to listen for on the current page.
* @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;
});
}
/**
* Waits for a button to appear and become enabled, then clicks it after a configured delay.
* @param {string} selector - The CSS selector for the button.
* @param {boolean} disableDelay - If true, clicks immediately.
* @returns {Promise<boolean>} Resolves to true if clicked, false otherwise.
*/
async function waitForButtonAndClick(selector, disableDelay) {
return new Promise(resolve => {
const checkAndClick = async (obs) => {
const btn = document.querySelector(selector);
if (btn && !btn.disabled) {
console.log(`%c[${scriptName}] Button "${selector}" found and enabled. Clicking.`, 'color: green; font-weight: bold;');
const delayMs = await getCalculatedDelay(disableDelay);
setTimeout(() => btn.click(), delayMs);
if (obs) obs.disconnect();
resolve(true);
return true;
}
return false;
};
onDOMLoaded(() => {
checkAndClick(null).then(clicked => {
if (clicked) return;
const observer = new MutationObserver((_, obs) => checkAndClick(obs));
observer.observe(document.body, { childList: true, subtree: true, attributes: true });
});
});
});
}
/**
* 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}
*/
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) {
console.log(`[${scriptName}] URL "${url}" matches success pattern.`);
}
return isSuccess;
}
/**
* Checks if a submission was successful and closes the tab if configured to do so.
* This now also includes checking for the "no changes" banner on MusicBrainz.
* @param {SiteConfig} config - The site configuration for success checking.
*/
async function checkAndCloseOnSuccess(config) {
if (!config || sessionStorage.getItem(SUBMISSION_TRIGGERED_FLAG) !== 'true') return;
const noChangesBanner = document.querySelector('.banner.warning-header');
const isNoOpSubmission = noChangesBanner?.textContent.includes(
'The data you have submitted does not make any changes to the data already present.'
);
if (isSubmissionSuccessful(config) || isNoOpSubmission) {
if (isNoOpSubmission) {
console.log(`[${scriptName}] Detected 'no changes' banner. Treating as success.`);
}
console.log(`[${scriptName}] Submission successful. Clearing flag.`);
sessionStorage.removeItem(SUBMISSION_TRIGGERED_FLAG);
const disableAutoClose = await GM.getValue(DISABLE_AUTO_CLOSE_SETTING, false);
if (disableAutoClose) {
console.info(`%c[${scriptName}] Auto-closing is DISABLED by user setting.`, 'color: orange;');
} else {
console.log(`%c[${scriptName}] Closing tab.`, 'color: green; font-weight: bold;');
setTimeout(() => window.close(), 200);
}
}
}
/**
* Sets up listeners and handlers specific to MagicISRC pages.
*/
function setupMagicISRC() {
if (!location.hostname.includes('magicisrc')) return;
console.log(`[${scriptName}] MagicISRC page detected. Setting up special handlers.`);
const script = document.createElement('script');
script.textContent = `
(() => {
const post = (type, data) => window.postMessage({ source: '${scriptName}', type, ...data }, location.origin);
const origFetch = window.fetch;
window.fetch = (...args) => origFetch(...args).catch(err => { post('FETCH_ERROR'); throw err; });
window.renderError = () => { post('RENDER_ERROR'); };
})();
`;
document.documentElement.appendChild(script);
script.remove();
window.addEventListener('message', async (event) => {
if (event.origin !== location.origin || event.data?.source !== scriptName) return;
if (event.data.type === 'FETCH_ERROR' || event.data.type === 'RENDER_ERROR') {
console.warn(`%c[${scriptName}] Intercepted MagicISRC error: ${event.data.type}`, 'color: red;');
const enableReload = await GM.getValue(MAGICISRC_ENABLE_AUTO_RELOAD, true);
if (enableReload) {
const delay = await getCalculatedDelay();
console.log(`Reloading in ${delay / 1000}s.`);
setTimeout(() => location.reload(), delay);
}
}
});
if (sessionStorage.getItem(SUBMISSION_TRIGGERED_FLAG) === 'true') {
const config = getActiveConfigs().find(c => c.channelName === 'magicisrc_submit_channel');
if (config) {
waitForButtonAndClick(config.buttonSelector, config.disableDelay);
}
}
}
/**
* Calculates a random click delay based on user settings.
* @param {boolean} [disable=false] - If true, forces a 0ms delay.
* @returns {Promise<number>} Delay in milliseconds.
*/
async function getCalculatedDelay(disable = false) {
if (disable) return 0;
const delayMode = await GM.getValue(DELAY_MODE_SETTING, 'dynamic');
if (delayMode === 'static') {
const max = await GM.getValue(STATIC_MAX_DELAY_SETTING, 6);
return Math.floor(Math.random() * (max + 1)) * 1000;
}
const cores = Math.max(1, navigator.hardwareConcurrency || 1);
const max = Math.max(0, Math.round(120 / cores));
return Math.floor(Math.random() * (max + 1)) * 1000;
}
/**
* Registers all userscript menu commands.
*/
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 delayMode = await GM.getValue(DELAY_MODE_SETTING, 'dynamic');
const disableAutoClose = await GM.getValue(DISABLE_AUTO_CLOSE_SETTING, false);
const enableMagicISRCReload = await GM.getValue(MAGICISRC_ENABLE_AUTO_RELOAD, true);
registerCommand(`Delay Mode: ${delayMode === 'dynamic' ? 'DYNAMIC' : 'STATIC'}`, async () => {
await GM.setValue(DELAY_MODE_SETTING, delayMode === 'dynamic' ? 'static' : 'dynamic');
await setupMenuCommands();
});
if (delayMode === 'static') {
const currentStaticMax = await GM.getValue(STATIC_MAX_DELAY_SETTING, 6);
registerCommand(`Set Static Max Delay (Current: ${currentStaticMax}s)`, async () => {
const newValue = prompt(`Enter new max delay in seconds:`, currentStaticMax);
if (newValue !== null && !isNaN(newValue) && parseFloat(newValue) >= 0) {
await GM.setValue(STATIC_MAX_DELAY_SETTING, parseFloat(newValue));
await setupMenuCommands();
}
});
}
registerCommand(`Auto Close Tabs: ${disableAutoClose ? 'DISABLED' : 'ENABLED'}`, async () => {
await GM.setValue(DISABLE_AUTO_CLOSE_SETTING, !disableAutoClose);
await setupMenuCommands();
});
registerCommand(`MagicISRC Auto Reload: ${enableMagicISRCReload ? 'ENABLED' : 'ENABLED'}`, async () => {
await GM.setValue(MAGICISRC_ENABLE_AUTO_RELOAD, !enableMagicISRCReload);
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();
});
}
const successConfig = siteConfigurations.find(c =>
(Array.isArray(c.hostnames) ? c.hostnames.some(h => location.hostname.includes(h)) : location.hostname.includes(c.hostnames))
&& c.shouldCloseAfterSuccess && isSubmissionSuccessful(c, true)
);
if (successConfig) {
registerCommand(GLOBAL_CLOSE_TAB_MENU_COMMAND_NAME, () => {
const channel = new BroadcastChannel(GLOBAL_CLOSE_TAB_CHANNEL_NAME);
channel.postMessage(GLOBAL_CLOSE_TAB_MESSAGE_TRIGGER);
channel.close();
});
}
console.log(`[${scriptName}] Menu commands updated. Total: ${registeredMenuCommandIDs.length}`);
}
/**
* Sets up listeners for broadcast channel messages to click buttons.
* @param {SiteConfig[]} configs - The active configurations for the current page.
*/
function setupConfigListeners(configs) {
for (const config of configs) {
if (config.autoClick) {
console.log(`[${scriptName}] Setting up auto-click for "${config.buttonSelector}".`);
waitForButtonAndClick(config.buttonSelector, config.disableDelay);
} else if (config.channelName) {
const channel = new BroadcastChannel(config.channelName);
channel.onmessage = (event) => {
if (event.data === config.messageTrigger) {
console.log(`[${scriptName}] Received trigger "${event.data}". Clicking button.`);
sessionStorage.setItem(SUBMISSION_TRIGGERED_FLAG, 'true');
waitForButtonAndClick(config.buttonSelector, config.disableDelay);
}
};
}
}
}
/**
* Sets up listeners for URL changes to check for submission success.
* @param {SiteConfig} config - The active configuration that should be checked for success.
*/
function setupSuccessHandling(config) {
if (!config) return;
const runCheck = () => checkAndCloseOnSuccess(config);
if (isSubmissionSuccessful(config, true)) {
console.log(`[${scriptName}] Success URL detected at page start, checking immediately.`);
runCheck();
} else {
console.log(`[${scriptName}] Deferring success check until DOM is loaded.`);
onDOMLoaded(runCheck);
}
const originalPushState = history.pushState;
history.pushState = function(...args) {
originalPushState.apply(this, args);
runCheck();
};
const originalReplaceState = history.replaceState;
history.replaceState = function(...args) {
originalReplaceState.apply(this, args);
runCheck();
};
window.addEventListener('popstate', runCheck);
if (isSubmissionSuccessful(config)) {
const closeChannel = new BroadcastChannel(GLOBAL_CLOSE_TAB_CHANNEL_NAME);
closeChannel.onmessage = (event) => {
if (event.data === GLOBAL_CLOSE_TAB_MESSAGE_TRIGGER) {
setTimeout(() => window.close(), 50);
}
};
}
}
/**
* Main script entry point.
*/
async function main() {
await setupMenuCommands();
const activeConfigs = getActiveConfigs();
if (activeConfigs.length > 0) {
setupConfigListeners(activeConfigs);
}
const successConfig = siteConfigurations.find(c =>
(Array.isArray(c.hostnames) ? c.hostnames.some(h => location.hostname.includes(h)) : location.hostname.includes(c.hostnames))
&& c.shouldCloseAfterSuccess
);
setupMagicISRC();
setupSuccessHandling(successConfig);
console.log(`%c[${scriptName}] Initialization finished.`, 'font-weight: bold;');
}
main();
})();