// ==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.0
// @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';
/**
* 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: '', // Will be dynamically set in init
};
/**
* 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, // Timestamp of when the last request successfully finished (or failed)
_nextAvailableRequestTime: 0, // Earliest time the next request can be made, considering API hints
/**
* 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(); // Mark end of this request attempt
const headers = Utils.parseHeaders(res.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) {
this._nextAvailableRequestTime = this._lastRequestFinishedTime + (retryAfterSeconds * 1000);
console.warn(`[${Config.SHORT_APP_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(`[${Config.SHORT_APP_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; // Default 5s delay for 503 if no Retry-After
console.warn(`[${Config.SHORT_APP_NAME}]: 503 Service Unavailable. Defaulting to 5s delay.`);
} else {
this._nextAvailableRequestTime = Math.max(this._nextAvailableRequestTime, this._lastRequestFinishedTime + 1000); // Ensure at least 1s gap
}
if (res.status >= 200 && res.status < 300) {
try {
const data = JSON.parse(res.responseText);
resolve(data);
} catch (e) {
console.error(`[${Config.SHORT_APP_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(`[${Config.SHORT_APP_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); // Default 5s delay for network errors
console.error(`[${Config.SHORT_APP_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); // Default 5s delay for timeouts
console.warn(`[${Config.SHORT_APP_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) { // Enforce minimum 1 second between requests
waitTime = 1000 - timeSinceLastRequest;
}
}
if (waitTime > 0) {
console.log(`[${Config.SHORT_APP_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(`[${Config.SHORT_APP_NAME}]: Retrying request (${logContext}) (attempt ${i + 1}/${Config.MAX_RETRIES}). Error: ${error.message}`);
} else {
throw error; // Re-throw if max retries reached or unretryable error
}
}
}
// This part should ideally not be reached if MAX_RETRIES is handled by the throw in catch
throw new Error(`[${Config.SHORT_APP_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; // MusicBrainz API max limit per request
let totalCount = 0; // Will be updated by the first successful response
// Loop to fetch all pages until totalCount is reached or no more releases are returned
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; // Track if current fetch returned any releases
try {
responseData = await this._executeApiCallWithRetries(url, logContext);
} catch (error) {
console.error(`[${Config.SHORT_APP_NAME}]: Failed to fetch page for query ${query.substring(0, 50)}... (offset: ${currentOffset}): ${error.message}`);
// If a page fetch fails after retries, we return what we have so far.
return { releases: allReleases, count: allReleases.length };
}
// If we get a valid response with releases
if (responseData && Array.isArray(responseData.releases)) {
if (responseData.releases.length > 0) {
allReleases = allReleases.concat(responseData.releases);
fetchedAnyReleasesOnCurrentPage = true;
}
if (totalCount === 0) { // Set totalCount only on the first successful response
totalCount = responseData.count;
// If totalCount is 0 from the first response, and no releases, stop immediately
if (totalCount === 0 && responseData.releases.length === 0) {
console.log(`[${Config.SHORT_APP_NAME}]: No releases found for query ${query.substring(0, 50)}... (initial count 0).`);
break;
}
}
currentOffset += responseData.releases.length; // Increment offset by actual items received
// If the current page returned fewer than 'limit' items, it's likely the last page.
if (responseData.releases.length < limit) {
console.log(`[${Config.SHORT_APP_NAME}]: Last page fetched for query ${query.substring(0, 50)}... (returned ${responseData.releases.length} releases). Terminating pagination.`);
break;
}
// If no releases were returned on this page (and it wasn't due to `limit` being honored for a small final page),
// but we expected more (totalCount > currentOffset), it means no more results are available.
if (!fetchedAnyReleasesOnCurrentPage && currentOffset < totalCount) {
console.warn(`[${Config.SHORT_APP_NAME}]: Expected more releases but received none for query ${query.substring(0, 50)}... (offset: ${currentOffset}). Terminating pagination.`);
break;
}
} else {
// If response is malformed or releases array is missing/not an array,
// treat this as the end of data for this query.
console.warn(`[${Config.SHORT_APP_NAME}]: Malformed response or no releases array for query ${query.substring(0, 50)}... (offset: ${currentOffset}). Assuming no more data from this point.`);
break;
}
// Final check to ensure we don't loop indefinitely if totalCount is inaccurate or changes
if (totalCount > 0 && currentOffset >= totalCount) {
console.log(`[${Config.SHORT_APP_NAME}]: All ${totalCount} releases fetched for query ${query.substring(0, 50)}...`);
break;
}
} while (true); // Loop indefinitely until one of the break conditions is met
return { releases: allReleases, count: allReleases.length }; // Return collected releases and actual count
}
};
/**
* Scans the DOM for barcode elements and manages their associated data.
*/
const DOMScanner = {
_barcodeToSpansMap: new Map(), // Map<string, HTMLElement[]>
_uniqueBarcodes: new Set(), // Set<string>
/**
* 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) {
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; // Only barcode text initially
// Store reference to the span element
if (!this._barcodeToSpansMap.has(barcode)) {
this._barcodeToSpansMap.set(barcode, []);
}
this._barcodeToSpansMap.get(barcode).push(barcodeSpan);
this._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) {
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() {
// Set the User-Agent string once on initialization
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);
// First pass: Collect all unique barcodes and create initial spans
tables.forEach(table => {
table.querySelectorAll('td').forEach(cell => {
DOMScanner.collectBarcodesAndCreateSpans(cell);
});
});
const uniqueBarcodes = DOMScanner.getUniqueBarcodes();
if (uniqueBarcodes.size === 0) {
console.log(`[${Config.SHORT_APP_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 MusicBrainzAPI.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 = DOMScanner.getBarcodeSpansMap().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(`[${Config.SHORT_APP_NAME}]: No releases found for any barcodes in the batch query, or malformed response.`);
}
} catch (error) {
console.error(`[${Config.SHORT_APP_NAME}]: Failed to fetch data for all barcodes: ${error.message}`);
}
}
};
// Start the application
BarcodeLinkerApp.init();
})();