Click buttons across tabs

Clicks specified buttons across tabs using the Broadcast Channel API and closes tabs after successful submission.

// ==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();

})();