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