MusicBrainz: Add search link for barcode

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

当前为 2025-09-06 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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.1.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*
// @connect     musicbrainz.org
// @icon        https://musicbrainz.org/static/images/favicons/android-chrome-512x512.png
// @grant       GM_xmlhttpRequest
// ==/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: '',
        SEARCH_LINK_CLASS: 'mb-barcode-search-link',
        PROCESSED_BARCODE_SPAN_CLASS: 'mb-barcode-processed',
    };

    /**
     * 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,
        _nextAvailableRequestTime: 0,

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

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

                        if (!isNaN(retryAfterSeconds) && retryAfterSeconds > 0) {
                            this._nextAvailableRequestTime = this._lastRequestFinishedTime + (retryAfterSeconds * 1000);
                            console.warn(`[${GM.info.script.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(`[${GM.info.script.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;
                            console.warn(`[${GM.info.script.name}] 503 Service Unavailable. Defaulting to 5s delay.`);
                        } else {
                            this._nextAvailableRequestTime = Math.max(this._nextAvailableRequestTime, this._lastRequestFinishedTime + 1000);
                        }

                        if (res.status >= 200 && res.status < 300) {
                            try {
                                const data = JSON.parse(res.responseText);
                                resolve(data);
                            } catch (e) {
                                console.error(`[${GM.info.script.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(`[${GM.info.script.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);
                        console.error(`[${GM.info.script.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);
                        console.warn(`[${GM.info.script.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) {
                        waitTime = 1000 - timeSinceLastRequest;
                    }
                }

                if (waitTime > 0) {
                    console.log(`[${GM.info.script.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(`[${GM.info.script.name}] Retrying request (${logContext}) (attempt ${i + 1}/${Config.MAX_RETRIES}). Error: ${error.message}`);
                    } else {
                        throw error;
                    }
                }
            }
            throw new Error(`[${GM.info.script.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;
            let totalCount = 0;

            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;

                try {
                    responseData = await this._executeApiCallWithRetries(url, logContext);
                } catch (error) {
                    console.error(`[${GM.info.script.name}] Failed to fetch page for query ${query.substring(0, 50)}... (offset: ${currentOffset}): ${error.message}`);
                    return { releases: allReleases, count: allReleases.length };
                }

                if (responseData && Array.isArray(responseData.releases)) {
                    if (responseData.releases.length > 0) {
                        allReleases = allReleases.concat(responseData.releases);
                        fetchedAnyReleasesOnCurrentPage = true;
                    }

                    if (totalCount === 0) {
                        totalCount = responseData.count;
                        if (totalCount === 0 && responseData.releases.length === 0) {
                            console.log(`[${GM.info.script.name}] No releases found for query ${query.substring(0, 50)}... (initial count 0).`);
                            break;
                        }
                    }

                    currentOffset += responseData.releases.length;

                    if (responseData.releases.length < limit) {
                        console.log(`[${GM.info.script.name}] Last page fetched for query ${query.substring(0, 50)}... (returned ${responseData.releases.length} releases). Terminating pagination.`);
                        break;
                    }

                    if (!fetchedAnyReleasesOnCurrentPage && currentOffset < totalCount) {
                        console.warn(`[${GM.info.script.name}] Expected more releases but received none for query ${query.substring(0, 50)}... (offset: ${currentOffset}). Terminating pagination.`);
                        break;
                    }

                } else {
                    console.warn(`[${GM.info.script.name}] Malformed response or no releases array for query ${query.substring(0, 50)}... (offset: ${currentOffset}). Assuming no more data from this point.`);
                    break;
                }

                if (totalCount > 0 && currentOffset >= totalCount) {
                    console.log(`[${GM.info.script.name}] All ${totalCount} releases fetched for query ${query.substring(0, 50)}...`);
                    break;
                }

            } while (true);

            return { releases: allReleases, count: allReleases.length };
        }
    };

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

        /**
         * 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) {
                if (node.parentNode && node.parentNode.classList && node.parentNode.classList.contains(Config.PROCESSED_BARCODE_SPAN_CLASS)) {
                    return;
                }

                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;
                    barcodeSpan.classList.add(Config.PROCESSED_BARCODE_SPAN_CLASS);

                    if (!this._barcodeToSpansMap.has(barcode)) {
                        this._barcodeToSpansMap.set(barcode, []);
                    }
                    this._barcodeToSpansMap.get(barcode).push(barcodeSpan);
                    this._uniqueBarcodes.add(barcode);

                    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' && !node.classList.contains(Config.PROCESSED_BARCODE_SPAN_CLASS)) {
                    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() {
            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);

            tables.forEach(table => {
                table.querySelectorAll('td').forEach(cell => {
                    DOMScanner.collectBarcodesAndCreateSpans(cell);
                });
            });

            const uniqueBarcodes = DOMScanner.getUniqueBarcodes();
            if (uniqueBarcodes.size === 0) {
                console.log(`[${GM.info.script.name}] No barcodes found to process.`);
                return;
            }

            const combinedQuery = Array.from(uniqueBarcodes).map(b => `barcode:${b}`).join(' OR ');

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

                if (data && data.releases) {
                    const releasesByBarcode = new Map();
                    data.releases.forEach(release => {
                        if (release.barcode) {
                            if (!releasesByBarcode.has(release.barcode)) {
                                releasesByBarcode.set(release.barcode, []);
                            }
                            releasesByBarcode.get(release.barcode).push(release);
                        }
                    });

                    uniqueBarcodes.forEach(barcode => {
                        const spans = DOMScanner.getBarcodeSpansMap().get(barcode);
                        const releasesForBarcode = releasesByBarcode.get(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';
                            searchLink.classList.add(Config.SEARCH_LINK_CLASS);

                            spans.forEach(barcodeSpan => {
                                if (!barcodeSpan.querySelector(`.${Config.SEARCH_LINK_CLASS}`)) {
                                    barcodeSpan.appendChild(document.createTextNode(' ('));
                                    barcodeSpan.appendChild(searchLink.cloneNode(true));
                                    barcodeSpan.appendChild(document.createTextNode(')'));

                                    barcodeSpan.style.backgroundColor = 'yellow';
                                    barcodeSpan.title = `Multiple MusicBrainz releases found for barcode: ${barcode}`;
                                } else {
                                    console.log(`[${GM.info.script.name}] Skipping duplicate link addition for barcode ${barcode} in span. Link already exists.`);
                                }
                            });
                        }
                    });
                } else {
                    console.warn(`[${GM.info.script.name}] No releases found for any barcodes in the batch query, or malformed response.`);
                }
            } catch (error) {
                console.error(`[${GM.info.script.name}] Failed to fetch data for all barcodes: ${error.message}`);
            }
        }
    };

    BarcodeLinkerApp.init();

})();