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 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 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();
})();