MusicBrainz: Add search link for barcode

Searches for existing releases in "Add release" edits by barcode, highlights and adds a search link on match

// ==UserScript==
// @name        MusicBrainz: Add search link for barcode
// @namespace   https://musicbrainz.org/user/chaban
// @description Searches for existing releases in "Add release" edits by barcode, highlights and adds a search link on match
// @version     3.0
// @tag         ai-created
// @author      chaban
// @license     MIT
// @match       *://*.musicbrainz.org/edit/*
// @match       *://*.musicbrainz.org/search/edits*
// @match       *://*.musicbrainz.org/*/*/edits
// @match       *://*.musicbrainz.org/*/*/open_edits
// @match       *://*.musicbrainz.org/user/*/edits*
// @icon        https://musicbrainz.org/static/images/favicons/android-chrome-512x512.png
// @grant       GM_xmlhttpRequest
// @grant       GM_info
// ==/UserScript==

(function() {
    'use strict';

    /**
     * Configuration object to centralize all constants and settings.
     */
    const Config = {
        BARCODE_REGEX: /(\b\d{8,14}\b)/g,
        TARGET_SELECTOR: '.add-release',
        API_BASE_URL: 'https://musicbrainz.org/ws/2/release/',
        MAX_RETRIES: 5,
        SHORT_APP_NAME: 'UserJS.BarcodeLink',
        USER_AGENT: '', // Will be dynamically set in init
    };

    /**
     * Utility functions.
     */
    const Utils = {
        /**
         * Pauses execution for a given number of milliseconds.
         * @param {number} ms - The number of milliseconds to sleep.
         * @returns {Promise<void>} A promise that resolves after the specified delay.
         */
        delay: function(ms) {
            return new Promise(resolve => setTimeout(resolve, ms));
        },

        /**
         * Parses raw response headers string into a simple object.
         * @param {string} headerStr - The raw headers string.
         * @returns {Object} An object mapping header names to their values.
         */
        parseHeaders: function(headerStr) {
            const headers = {};
            if (!headerStr) return headers;
            headerStr.split('\n').forEach(line => {
                const parts = line.split(':');
                if (parts.length > 1) {
                    const key = parts[0].trim().toLowerCase();
                    const value = parts.slice(1).join(':').trim();
                    headers[key] = value;
                }
            });
            return headers;
        }
    };

    /**
     * Handles all interactions with the MusicBrainz API, including rate limiting, retries, and pagination.
     */
    const MusicBrainzAPI = {
        _lastRequestFinishedTime: 0, // Timestamp of when the last request successfully finished (or failed)
        _nextAvailableRequestTime: 0, // Earliest time the next request can be made, considering API hints

        /**
         * Sends a single GM_xmlhttpRequest to the MusicBrainz API.
         * Handles response parsing and updates global rate limiting state.
         * @param {string} url - The full URL for the API request.
         * @returns {Promise<Object>} - Resolves with parsed JSON data, rejects on error or malformed response.
         */
        _sendHttpRequest: function(url) {
            return new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method: 'GET',
                    url: url,
                    headers: {
                        'User-Agent': Config.USER_AGENT,
                        'Accept': 'application/json'
                    },
                    onload: (res) => {
                        this._lastRequestFinishedTime = Date.now(); // Mark end of this request attempt

                        const headers = Utils.parseHeaders(res.responseHeaders);
                        const rateLimitReset = parseInt(headers['x-ratelimit-reset'], 10) * 1000; // Convert to ms epoch
                        const rateLimitRemaining = parseInt(headers['x-ratelimit-remaining'], 10);
                        const retryAfterSeconds = parseInt(headers['retry-after'], 10);
                        const rateLimitZone = headers['x-ratelimit-zone'];

                        // Update _nextAvailableRequestTime based on response headers
                        if (!isNaN(retryAfterSeconds) && retryAfterSeconds > 0) {
                            this._nextAvailableRequestTime = this._lastRequestFinishedTime + (retryAfterSeconds * 1000);
                            console.warn(`[${Config.SHORT_APP_NAME}]: Server requested Retry-After: ${retryAfterSeconds}s. Next request delayed until ${new Date(this._nextAvailableRequestTime).toLocaleTimeString()}.`);
                        } else if (!isNaN(rateLimitReset) && rateLimitRemaining === 0) {
                            this._nextAvailableRequestTime = rateLimitReset;
                            console.warn(`[${Config.SHORT_APP_NAME}]: Rate limit exhausted for zone "${rateLimitZone}". Next request delayed until ${new Date(this._nextAvailableRequestTime).toLocaleTimeString()}.`);
                        } else if (res.status === 503) {
                            this._nextAvailableRequestTime = this._lastRequestFinishedTime + 5000; // Default 5s delay for 503 if no Retry-After
                            console.warn(`[${Config.SHORT_APP_NAME}]: 503 Service Unavailable. Defaulting to 5s delay.`);
                        } else {
                            this._nextAvailableRequestTime = Math.max(this._nextAvailableRequestTime, this._lastRequestFinishedTime + 1000); // Ensure at least 1s gap
                        }

                        if (res.status >= 200 && res.status < 300) {
                            try {
                                const data = JSON.parse(res.responseText);
                                resolve(data);
                            } catch (e) {
                                console.error(`[${Config.SHORT_APP_NAME}]: Error parsing JSON for URL ${url.substring(0, 100)}...:`, e);
                                reject(new Error(`JSON parsing error for URL ${url.substring(0, 100)}...`));
                            }
                        } else if (res.status === 503) {
                            reject(new Error('Rate limit hit or server overloaded'));
                        } else {
                            console.error(`[${Config.SHORT_APP_NAME}]: API request for URL ${url.substring(0, 100)}... failed with status ${res.status}: ${res.statusText}`);
                            reject(new Error(`API error ${res.status} for URL ${url.substring(0, 100)}...`));
                        }
                    },
                    onerror: (error) => {
                        this._lastRequestFinishedTime = Date.now();
                        this._nextAvailableRequestTime = Math.max(this._nextAvailableRequestTime, this._lastRequestFinishedTime + 5000); // Default 5s delay for network errors
                        console.error(`[${Config.SHORT_APP_NAME}]: Network error for URL ${url.substring(0, 100)}...:`, error);
                        reject(new Error(`Network error for URL ${url.substring(0, 100)}...`));
                    },
                    ontimeout: () => {
                        this._lastRequestFinishedTime = Date.now();
                        this._nextAvailableRequestTime = Math.max(this._nextAvailableRequestTime, this._lastRequestFinishedTime + 5000); // Default 5s delay for timeouts
                        console.warn(`[${Config.SHORT_APP_NAME}]: Request for URL ${url.substring(0, 100)}... timed out.`);
                        reject(new Error(`Timeout for URL ${url.substring(0, 100)}...`));
                    }
                });
            });
        },

        /**
         * Executes an API call with retry logic and rate limiting delays.
         * @param {string} url - The URL for the API request.
         * @param {string} logContext - A string to append to log messages (e.g., "query: X, offset: Y").
         * @returns {Promise<Object>} - Resolves with parsed JSON data, rejects if all retries fail.
         */
        _executeApiCallWithRetries: async function(url, logContext) {
            for (let i = 0; i < Config.MAX_RETRIES; i++) {
                const now = Date.now();
                let waitTime = 0;

                if (now < this._nextAvailableRequestTime) {
                    waitTime = this._nextAvailableRequestTime - now;
                } else {
                    const timeSinceLastRequest = now - this._lastRequestFinishedTime;
                    if (timeSinceLastRequest < 1000) { // Enforce minimum 1 second between requests
                        waitTime = 1000 - timeSinceLastRequest;
                    }
                }

                if (waitTime > 0) {
                    console.log(`[${Config.SHORT_APP_NAME}]: Waiting for ${waitTime}ms before sending request (${logContext}).`);
                    await Utils.delay(waitTime);
                }

                try {
                    return await this._sendHttpRequest(url);
                } catch (error) {
                    if (i < Config.MAX_RETRIES - 1 && (error.message.includes('Rate limit hit') || error.message.includes('Network error') || error.message.includes('Timeout') || error.message.includes('server overloaded'))) {
                        console.warn(`[${Config.SHORT_APP_NAME}]: Retrying request (${logContext}) (attempt ${i + 1}/${Config.MAX_RETRIES}). Error: ${error.message}`);
                    } else {
                        throw error; // Re-throw if max retries reached or unretryable error
                    }
                }
            }
            // This part should ideally not be reached if MAX_RETRIES is handled by the throw in catch
            throw new Error(`[${Config.SHORT_APP_NAME}]: Failed to complete request after ${Config.MAX_RETRIES} attempts (${logContext}).`);
        },

        /**
         * Fetches data from MusicBrainz API with dynamic rate limiting and pagination.
         * @param {string} query - The search query for barcodes.
         * @returns {Promise<{releases: Array, count: number}>} - Resolves with an object containing all fetched releases and their count.
         */
        fetchBarcodeData: async function(query) {
            const BASE_SEARCH_URL = `${Config.API_BASE_URL}?fmt=json`;

            let allReleases = [];
            let currentOffset = 0;
            const limit = 100; // MusicBrainz API max limit per request
            let totalCount = 0; // Will be updated by the first successful response

            // Loop to fetch all pages until totalCount is reached or no more releases are returned
            do {
                const url = `${BASE_SEARCH_URL}&query=${encodeURIComponent(query)}&limit=${limit}&offset=${currentOffset}`;
                const logContext = `query: ${query.substring(0, 50)}..., offset: ${currentOffset}`;
                let responseData;
                let fetchedAnyReleasesOnCurrentPage = false; // Track if current fetch returned any releases

                try {
                    responseData = await this._executeApiCallWithRetries(url, logContext);
                } catch (error) {
                    console.error(`[${Config.SHORT_APP_NAME}]: Failed to fetch page for query ${query.substring(0, 50)}... (offset: ${currentOffset}): ${error.message}`);
                    // If a page fetch fails after retries, we return what we have so far.
                    return { releases: allReleases, count: allReleases.length };
                }

                // If we get a valid response with releases
                if (responseData && Array.isArray(responseData.releases)) {
                    if (responseData.releases.length > 0) {
                        allReleases = allReleases.concat(responseData.releases);
                        fetchedAnyReleasesOnCurrentPage = true;
                    }

                    if (totalCount === 0) { // Set totalCount only on the first successful response
                        totalCount = responseData.count;
                        // If totalCount is 0 from the first response, and no releases, stop immediately
                        if (totalCount === 0 && responseData.releases.length === 0) {
                            console.log(`[${Config.SHORT_APP_NAME}]: No releases found for query ${query.substring(0, 50)}... (initial count 0).`);
                            break;
                        }
                    }

                    currentOffset += responseData.releases.length; // Increment offset by actual items received

                    // If the current page returned fewer than 'limit' items, it's likely the last page.
                    if (responseData.releases.length < limit) {
                        console.log(`[${Config.SHORT_APP_NAME}]: Last page fetched for query ${query.substring(0, 50)}... (returned ${responseData.releases.length} releases). Terminating pagination.`);
                        break;
                    }

                    // If no releases were returned on this page (and it wasn't due to `limit` being honored for a small final page),
                    // but we expected more (totalCount > currentOffset), it means no more results are available.
                    if (!fetchedAnyReleasesOnCurrentPage && currentOffset < totalCount) {
                        console.warn(`[${Config.SHORT_APP_NAME}]: Expected more releases but received none for query ${query.substring(0, 50)}... (offset: ${currentOffset}). Terminating pagination.`);
                        break;
                    }

                } else {
                    // If response is malformed or releases array is missing/not an array,
                    // treat this as the end of data for this query.
                    console.warn(`[${Config.SHORT_APP_NAME}]: Malformed response or no releases array for query ${query.substring(0, 50)}... (offset: ${currentOffset}). Assuming no more data from this point.`);
                    break;
                }

                // Final check to ensure we don't loop indefinitely if totalCount is inaccurate or changes
                if (totalCount > 0 && currentOffset >= totalCount) {
                    console.log(`[${Config.SHORT_APP_NAME}]: All ${totalCount} releases fetched for query ${query.substring(0, 50)}...`);
                    break;
                }

            } while (true); // Loop indefinitely until one of the break conditions is met

            return { releases: allReleases, count: allReleases.length }; // Return collected releases and actual count
        }
    };

    /**
     * Scans the DOM for barcode elements and manages their associated data.
     */
    const DOMScanner = {
        _barcodeToSpansMap: new Map(), // Map<string, HTMLElement[]>
        _uniqueBarcodes: new Set(), // Set<string>

        /**
         * Finds barcodes in text nodes and wraps them in spans, storing references.
         * @param {Node} node - The current DOM node to process.
         */
        collectBarcodesAndCreateSpans: function(node) {
            if (node.nodeType === Node.TEXT_NODE) {
                const originalText = node.textContent;
                const matches = [...originalText.matchAll(Config.BARCODE_REGEX)];
                if (matches.length === 0) return;

                let lastIndex = 0;
                const fragment = document.createDocumentFragment();

                for (const match of matches) {
                    const barcode = match[0];
                    const startIndex = match.index;
                    const endIndex = startIndex + barcode.length;

                    if (startIndex > lastIndex) {
                        fragment.appendChild(document.createTextNode(originalText.substring(lastIndex, startIndex)));
                    }

                    const barcodeSpan = document.createElement('span');
                    barcodeSpan.textContent = barcode; // Only barcode text initially

                    // Store reference to the span element
                    if (!this._barcodeToSpansMap.has(barcode)) {
                        this._barcodeToSpansMap.set(barcode, []);
                    }
                    this._barcodeToSpansMap.get(barcode).push(barcodeSpan);
                    this._uniqueBarcodes.add(barcode); // Add to unique set

                    fragment.appendChild(barcodeSpan);
                    lastIndex = endIndex;
                }

                if (lastIndex < originalText.length) {
                    fragment.appendChild(document.createTextNode(originalText.substring(lastIndex)));
                }

                if (fragment.hasChildNodes()) {
                    node.parentNode.insertBefore(fragment, node);
                    node.remove();
                }

            } else if (node.nodeType === Node.ELEMENT_NODE) {
                if (node.tagName !== 'SCRIPT' && node.tagName !== 'STYLE') {
                    const children = Array.from(node.childNodes);
                    for (const child of children) {
                        this.collectBarcodesAndCreateSpans(child);
                    }
                }
            }
        },

        /**
         * Returns the set of unique barcodes found.
         * @returns {Set<string>} A set of unique barcode strings.
         */
        getUniqueBarcodes: function() {
            return this._uniqueBarcodes;
        },

        /**
         * Returns the map of barcodes to their corresponding span elements.
         * @returns {Map<string, HTMLElement[]>} A map where keys are barcodes and values are arrays of their span elements.
         */
        getBarcodeSpansMap: function() {
            return this._barcodeToSpansMap;
        }
    };

    /**
     * Main application logic for the userscript.
     */
    const BarcodeLinkerApp = {
        /**
         * Initializes the application.
         */
        init: function() {
            // Set the User-Agent string once on initialization
            Config.USER_AGENT = `${Config.SHORT_APP_NAME}/${GM_info.script.version} ( ${GM_info.script.namespace} )`;
            this.processAddReleaseTables();
        },

        /**
         * Processes all "Add release" tables to find barcodes, fetch data, and update the DOM.
         */
        processAddReleaseTables: async function() {
            const tables = document.querySelectorAll(Config.TARGET_SELECTOR);

            // First pass: Collect all unique barcodes and create initial spans
            tables.forEach(table => {
                table.querySelectorAll('td').forEach(cell => {
                    DOMScanner.collectBarcodesAndCreateSpans(cell);
                });
            });

            const uniqueBarcodes = DOMScanner.getUniqueBarcodes();
            if (uniqueBarcodes.size === 0) {
                console.log(`[${Config.SHORT_APP_NAME}]: No barcodes found to process.`);
                return;
            }

            // Construct the combined Lucene query
            const combinedQuery = Array.from(uniqueBarcodes).map(b => `barcode:${b}`).join(' OR ');

            try {
                const data = await MusicBrainzAPI.fetchBarcodeData(combinedQuery);

                if (data && data.releases) {
                    // Group releases by barcode for easier processing
                    const releasesByBarcode = new Map(); // Map<string, any[]>
                    data.releases.forEach(release => {
                        if (release.barcode) {
                            if (!releasesByBarcode.has(release.barcode)) {
                                releasesByBarcode.set(release.barcode, []);
                            }
                            releasesByBarcode.get(release.barcode).push(release);
                        }
                    });

                    // Process each unique barcode based on the batched results
                    uniqueBarcodes.forEach(barcode => {
                        const spans = DOMScanner.getBarcodeSpansMap().get(barcode);
                        const releasesForBarcode = releasesByBarcode.get(barcode) || []; // This will be empty if no releases for this barcode

                        // Link and highlight ONLY if there are multiple releases for this specific barcode
                        if (spans && releasesForBarcode.length > 1) {
                            const searchUrl = `//musicbrainz.org/search?type=release&method=advanced&query=barcode:${barcode}`;
                            const searchLink = document.createElement('a');
                            searchLink.href = searchUrl;
                            searchLink.setAttribute('target', '_blank');
                            searchLink.textContent = 'Search';

                            spans.forEach(barcodeSpan => {
                                // Append link
                                barcodeSpan.appendChild(document.createTextNode(' ('));
                                barcodeSpan.appendChild(searchLink.cloneNode(true)); // Clone to avoid moving element if same barcode appears multiple times
                                barcodeSpan.appendChild(document.createTextNode(')'));

                                // Apply highlighting
                                barcodeSpan.style.backgroundColor = 'yellow';
                                barcodeSpan.title = `Multiple MusicBrainz releases found for barcode: ${barcode}`;
                            });
                        }
                    });
                } else {
                    console.warn(`[${Config.SHORT_APP_NAME}]: No releases found for any barcodes in the batch query, or malformed response.`);
                }
            } catch (error) {
                console.error(`[${Config.SHORT_APP_NAME}]: Failed to fetch data for all barcodes: ${error.message}`);
            }
        }
    };

    // Start the application
    BarcodeLinkerApp.init();

})();