Adds a warning on the recording merge page when the lengths differ by at least 15 seconds
// ==UserScript==
// @name MusicBrainz: Warn on significant length differences during recording merge (MBS-10966)
// @namespace https://musicbrainz.org/user/chaban
// @version 1.2
// @description Adds a warning on the recording merge page when the lengths differ by at least 15 seconds
// @tag ai-created
// @author chaban
// @license MIT
// @match *://*.musicbrainz.org/recording/merge*
// @icon https://musicbrainz.org/static/images/favicons/android-chrome-512x512.png
// @grant none
// ==/UserScript==
(function () {
'use strict';
/**
* Converts a time string (MM:SS, H:MM:SS, or milliseconds) to total seconds.
* Handles formats like "5:36", "1:05:30", "200", or "200 ms".
* Returns null if the length is unknown (e.g., "?:??").
* @param {string} timeString - The time string to parse.
* @returns {number|null} The total duration in seconds, or null if parsing fails or length is unknown.
*/
function parseTimeToSeconds(timeString) {
timeString = timeString.trim();
// Ignore unknown lengths like "?:??"
if (timeString === '?:??') {
return null;
}
// Attempt to parse MM:SS or H:MM:SS format first
const parts = timeString.split(':');
if (parts.length >= 2) {
let totalSeconds = 0;
let allPartsAreNumbers = true;
for (let i = 0; i < parts.length; i++) {
const part = parseInt(parts[i], 10);
if (isNaN(part)) {
allPartsAreNumbers = false;
break;
}
if (i === parts.length - 1) { // Seconds part
totalSeconds += part;
} else if (i === parts.length - 2) { // Minutes part
totalSeconds += part * 60;
} else if (i === parts.length - 3) { // Hours part
totalSeconds += part * 3600;
}
}
if (allPartsAreNumbers) {
return totalSeconds;
}
}
// If not MM:SS or H:MM:SS, try parsing as a number, potentially with "ms" suffix.
let numericValue;
if (timeString.toLowerCase().endsWith('ms')) {
// Remove "ms" suffix and parse
const msString = timeString.substring(0, timeString.length - 2).trim();
numericValue = parseFloat(msString);
} else {
// Try parsing as a direct number
numericValue = parseFloat(timeString);
}
if (!isNaN(numericValue)) {
// If it's a number, assume it's milliseconds and convert to seconds.
return numericValue / 1000;
}
console.warn('Could not parse time string:', timeString);
return null; // Default to null if parsing fails
}
/**
* Creates a warning message HTML div element.
* @param {string} message - The warning text to display.
* @returns {HTMLDivElement} The created warning div.
*/
function createWarningDiv(message) {
const warningDiv = document.createElement('div');
warningDiv.classList.add('warning', 'warning-lengths-differ'); // Add specific class for easy identification and removal
const paragraph = document.createElement('p');
const strong = document.createElement('strong');
strong.textContent = 'Warning:';
paragraph.appendChild(strong);
paragraph.appendChild(document.createTextNode(' ' + message));
warningDiv.appendChild(paragraph);
return warningDiv;
}
/**
* Processes all relevant tables on the page to identify and mark length discrepancies,
* and inserts a textual warning if needed.
*/
function processTables() {
const contentDiv = document.getElementById('content');
if (!contentDiv) {
// If the main content area is not found, exit.
return;
}
// Remove any previously added length warnings to prevent duplicates on re-runs.
document.querySelectorAll('.warning-lengths-differ').forEach(warn => warn.remove());
// Select all tables that contain recording data, both on the merge form and on the post-merge view.
const tablesToProcess = document.querySelectorAll('form table.tbl, table.details.merge-recordings table.tbl');
let overallNeedsWarning = false; // Flag to determine if a textual warning is needed for the page.
tablesToProcess.forEach(table => {
let lengthColumnIndex = -1;
// Find the index of the 'Length' column by checking table headers.
const headers = table.querySelectorAll('thead th');
headers.forEach((header, index) => {
if (header.textContent.trim() === 'Length') {
lengthColumnIndex = index;
}
});
// Only proceed if the 'Length' column is found.
if (lengthColumnIndex !== -1) {
const lengthCells = [];
const parsedLengths = []; // Stores { cell: HTMLElement, seconds: number|null }
// Iterate through table rows to collect length cells and their parsed values.
const rows = table.querySelectorAll('tbody tr');
rows.forEach(row => {
const cells = row.querySelectorAll('td');
if (cells.length > lengthColumnIndex) {
const lengthCell = cells[lengthColumnIndex];
const seconds = parseTimeToSeconds(lengthCell.textContent.trim());
lengthCells.push(lengthCell); // Keep track of all cells
parsedLengths.push({ cell: lengthCell, seconds: seconds });
}
});
// Filter out unknown lengths for comparison.
const knownLengths = parsedLengths.filter(item => item.seconds !== null);
// If there are at least two known lengths, compare them.
if (knownLengths.length >= 2) {
let tableNeedsWarning = false; // Flag for the current table.
// Compare each known length with every other known length in the table.
for (let i = 0; i < knownLengths.length; i++) {
for (let j = i + 1; j < knownLengths.length; j++) {
const diff = Math.abs(knownLengths[i].seconds - knownLengths[j].seconds);
if (diff >= 15) { // Check if the difference is 15 seconds or more.
tableNeedsWarning = true;
overallNeedsWarning = true; // Set overall warning flag if any table needs it.
break; // Found a significant difference, no need to check further in this table.
}
}
if (tableNeedsWarning) {
break;
}
}
// If a warning is needed for this table, add the 'warn-lengths' class to all its length cells
// that have a known length.
if (tableNeedsWarning) {
parsedLengths.forEach(item => {
if (item.seconds !== null) { // Only apply class to cells with known lengths
item.cell.classList.add('warn-lengths');
}
});
}
}
}
});
// If any significant length difference was found across all processed tables,
// create and insert the textual warning message.
if (overallNeedsWarning) {
const warningMessage = "Some of the recordings you're merging have significantly different lengths (15 seconds or more). Please check if they are indeed the same recordings.";
const newWarningDiv = createWarningDiv(warningMessage);
const isrcWarning = contentDiv.querySelector('.warning-isrcs-differ');
const mergeForm = contentDiv.querySelector('form[method="post"]');
const postMergeDetailsTable = contentDiv.querySelector('table.details.merge-recordings');
if (isrcWarning) {
// If the ISRC warning exists, insert our warning directly after it.
isrcWarning.parentNode.insertBefore(newWarningDiv, isrcWarning.nextSibling);
} else if (mergeForm) {
// If no ISRC warning but on a pre-merge page, insert our warning before the main form.
// This places it where the ISRC warning would typically appear.
contentDiv.insertBefore(newWarningDiv, mergeForm);
} else if (postMergeDetailsTable) {
// On a post-merge page, insert our warning before the details table.
contentDiv.insertBefore(newWarningDiv, postMergeDetailsTable);
}
}
}
// Execute the main function when the DOM is initially loaded.
processTables();
// Use a MutationObserver to re-run the script if the DOM changes.
// This is crucial for dynamic content loading, although for merge pages,
// most relevant content is usually present on initial load. It helps
// ensure the script works even if parts of the page are updated.
const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
if (mutation.addedNodes.length > 0) {
// Check if any newly added nodes or their descendants are relevant tables.
const relevantChange = Array.from(mutation.addedNodes).some(node =>
node.nodeType === 1 && (
node.matches('form table.tbl, table.details.merge-recordings') ||
node.querySelector('form table.tbl, table.details.merge-recordings table.tbl')
)
);
if (relevantChange) {
processTables(); // Re-process tables if relevant changes are detected.
}
}
});
});
// Start observing the entire document body for changes in its child elements and their subtrees.
observer.observe(document.body, { childList: true, subtree: true });
})();