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-05-31 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴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     2.2
// @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';

    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();
})();