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     2.1
// @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*
// @grant       GM_xmlhttpRequest
// @grant       GM_info
// ==/UserScript==

(function() {
    'use strict';

    const barcodeRegex = /(\b\d{8,14}\b)/g;
    const targetSelector = '.add-release';
    const API_BASE_URL = 'https://musicbrainz.org/ws/2/release/';

    const MAX_RETRIES = 5;

    // Global state for dynamic rate limiting based on API response headers
    let lastRequestFinishedTime = 0; // Timestamp of when the last request successfully finished (or failed)
    let nextAvailableRequestTime = 0; // Earliest time the next request can be made, considering API hints

    // Store a mapping of barcode to their corresponding span elements
    const barcodeToSpansMap = new Map(); // Map<string, HTMLElement[]>
    const uniqueBarcodes = new Set(); // Set<string>

    // Define a short application name for the User-Agent string with a prefix
    const SHORT_APP_NAME = 'UserJS.BarcodeLink';

    // Helper function for delay
    function delay(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }

    // Helper to parse response headers string into a simple object
    function parseHeaders(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;
    }

    // Function to fetch data from MusicBrainz API with dynamic rate limiting based on headers
    async function fetchBarcodeData(query) {
        // Dynamically get script version from GM_info, use custom short app name
        const USER_AGENT = `${SHORT_APP_NAME}/${GM_info.script.version} ( ${GM_info.script.namespace} )`;

        for (let i = 0; i < MAX_RETRIES; i++) {
            const now = Date.now();
            let waitTime = 0;

            if (now < nextAvailableRequestTime) {
                waitTime = nextAvailableRequestTime - now;
            } else {
                const timeSinceLastRequest = now - lastRequestFinishedTime;
                if (timeSinceLastRequest < 1000) {
                    waitTime = 1000 - timeSinceLastRequest;
                }
            }

            if (waitTime > 0) {
                console.log(`[${GM_info.script.name}] Waiting for ${waitTime}ms before sending request for query: ${query.substring(0, 50)}...`);
                await delay(waitTime);
            }

            try {
                return await new Promise((resolve, reject) => {
                    GM_xmlhttpRequest({
                        method: 'GET',
                        url: `${API_BASE_URL}?query=${encodeURIComponent(query)}&fmt=json`,
                        headers: {
                            'User-Agent': USER_AGENT,
                            'Accept': 'application/json'
                        },
                        onload: function(response) {
                            lastRequestFinishedTime = Date.now(); // Mark end of this request attempt

                            const headers = parseHeaders(response.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) {
                                nextAvailableRequestTime = lastRequestFinishedTime + (retryAfterSeconds * 1000);
                                console.warn(`[${GM_info.script.name}] Server requested Retry-After: ${retryAfterSeconds}s. Next request delayed until ${new Date(nextAvailableRequestTime).toLocaleTimeString()}.`);
                            } else if (!isNaN(rateLimitReset) && rateLimitRemaining === 0) {
                                nextAvailableRequestTime = rateLimitReset;
                                console.warn(`[${GM_info.script.name}] Rate limit exhausted for zone "${rateLimitZone}". Next request delayed until ${new Date(nextAvailableRequestTime).toLocaleTimeString()}.`);
                            } else if (response.status === 503) {
                                nextAvailableRequestTime = lastRequestFinishedTime + 5000;
                                console.warn(`[${GM_info.script.name}] 503 Service Unavailable for query ${query.substring(0, 50)}.... Defaulting to 5s delay.`);
                            } else {
                                nextAvailableRequestTime = Math.max(nextAvailableRequestTime, lastRequestFinishedTime + 1000);
                            }

                            if (response.status >= 200 && response.status < 300) {
                                try {
                                    const data = JSON.parse(response.responseText);
                                    resolve(data);
                                } catch (e) {
                                    console.error(`[${GM_info.script.name}] Error parsing JSON for query ${query.substring(0, 50)}...:`, e);
                                    reject(new Error(`JSON parsing error for query ${query.substring(0, 50)}...`));
                                }
                            } else if (response.status === 503) {
                                reject(new Error('Rate limit hit or server overloaded'));
                            } else {
                                console.error(`[${GM_info.script.name}] API request for query ${query.substring(0, 50)}... failed with status ${response.status}: ${response.statusText}`);
                                reject(new Error(`API error ${response.status} for query ${query.substring(0, 50)}...`));
                            }
                        },
                        onerror: function(error) {
                            lastRequestFinishedTime = Date.now();
                            nextAvailableRequestTime = Math.max(nextAvailableRequestTime, lastRequestFinishedTime + 5000);
                            console.error(`[${GM_info.script.name}] Network error for query ${query.substring(0, 50)}...:`, error);
                            reject(new Error(`Network error for query ${query.substring(0, 50)}...`));
                        },
                        ontimeout: function() {
                            lastRequestFinishedTime = Date.now();
                            nextAvailableRequestTime = Math.max(nextAvailableRequestTime, lastRequestFinishedTime + 5000);
                            console.warn(`[${GM_info.script.name}] Request for query ${query.substring(0, 50)}... timed out.`);
                            reject(new Error(`Timeout for query ${query.substring(0, 50)}...`));
                        }
                    });
                });
            } catch (error) {
                if (i < MAX_RETRIES - 1 && (error.message.includes('Rate limit hit') || error.message.includes('Network error') || error.message.includes('Timeout'))) {
                    console.warn(`[${GM_info.script.name}] Retrying query ${query.substring(0, 50)}... (attempt ${i + 1}/${MAX_RETRIES}). Error: ${error.message}`);
                } else {
                    throw error;
                }
            }
        }
    }

    // Function to find barcodes and store their associated span elements
    function collectBarcodesAndCreateSpans(node) {
        if (node.nodeType === Node.TEXT_NODE) {
            const originalText = node.textContent;
            const matches = [...originalText.matchAll(barcodeRegex)];
            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 (!barcodeToSpansMap.has(barcode)) {
                    barcodeToSpansMap.set(barcode, []);
                }
                barcodeToSpansMap.get(barcode).push(barcodeSpan);
                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) {
                    collectBarcodesAndCreateSpans(child);
                }
            }
        }
    }

    async function processAddReleaseTables() {
        const tables = document.querySelectorAll(targetSelector);

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

        if (uniqueBarcodes.size === 0) {
            console.log(`[${GM_info.script.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 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 = barcodeToSpansMap.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(`[${GM_info.script.name}] No releases found for any barcodes in the batch query.`);
            }
        } catch (error) {
            console.error(`[${GM_info.script.name}] Failed to fetch data for all barcodes: ${error.message}`);
        }
    }

    // Start the process
    processAddReleaseTables();
})();