// ==UserScript==
// @name MusicBrainz: Reports Statistics
// @namespace https://musicbrainz.org/user/chaban
// @version 1.0.20
// @description Hides report links on MusicBrainz if the report contains no items. Also indicates report changes since the last recorded data.
// @tag ai-created
// @author chaban
// @license MIT
// @match *://*.musicbrainz.org/reports
// @icon https://musicbrainz.org/static/images/favicons/android-chrome-512x512.png
// @grant GM_xmlhttpRequest
// @grant GM_info
// ==/UserScript==
(function() {
'use strict';
const currentScriptVersion = GM_info.script.version; // Get script version from metadata
const CURRENT_CACHE_VERSION = '1.5'; // Reverted to 1.5 as it represents the current stable data structure
const INTERNAL_CACHE_DURATION = 1 * 60 * 60 * 1000; // 1 hour for in-session throttling
const REQUEST_DELAY = 1000; // Delay between requests in milliseconds (1 request per second)
const HISTORY_MAX_DAYS = 30; // Max number of historical data points to store
// MusicBrainz report generation time in UTC
const MB_REPORT_GENERATION_HOUR_UTC = 0;
const MB_REPORT_GENERATION_MINUTE_UTC = 10;
const CENTRAL_CACHE_KEY = 'musicbrainz_reports_cache';
let progressBarContainer;
let progressBar;
let totalLinksToFetch = 0;
let fetchedLinksCount = 0;
/**
* Creates and initializes the progress bar elements.
*/
function createProgressBar() {
progressBarContainer = document.createElement('div');
progressBarContainer.id = 'mb-report-hider-progress-container';
Object.assign(progressBarContainer.style, {
position: 'fixed',
top: '0',
left: '0',
width: '100%',
height: '8px',
backgroundColor: '#e0e0e0',
zIndex: '9999',
display: 'none'
});
progressBar = document.createElement('div');
progressBar.id = 'mb-report-hider-progress-bar';
Object.assign(progressBar.style, {
width: '0%',
height: '100%',
backgroundColor: '#4CAF50',
transition: 'width 0.3s ease-in-out'
});
progressBarContainer.appendChild(progressBar);
document.documentElement.appendChild(progressBarContainer);
}
/**
* Updates the progress bar's width.
*/
function updateProgressBar() {
if (totalLinksToFetch === 0) {
progressBar.style.width = '0%';
} else {
const percentage = (fetchedLinksCount / totalLinksToFetch) * 100;
progressBar.style.width = `${percentage}%`;
}
}
/**
* Shows the progress bar.
*/
function showProgressBar() {
if (progressBarContainer) {
progressBarContainer.style.display = 'block';
}
}
/**
* Hides the progress bar.
*/
function hideProgressBar() {
if (progressBarContainer) {
progressBarContainer.style.display = 'none';
}
}
/**
* Extracts just the report name from a full MusicBrainz report URL.
* E.g., "https://musicbrainz.org/report/ArtistsThatMayBeGroups" -> "ArtistsThatMayBeGroups"
* @param {string} fullUrl The full URL of the report.
* @returns {string} The simplified report name.
*/
function getReportName(fullUrl) {
try {
const url = new URL(fullUrl);
const pathParts = url.pathname.split('/');
for (let i = pathParts.length - 1; i >= 0; i--) {
if (pathParts[i]) {
return pathParts[i];
}
}
return url.pathname;
} catch (e) {
console.error("Error parsing URL to get report name:", fullUrl, e);
return fullUrl;
}
}
/**
* Parses the "Generated on" timestamp string from report HTML.
* Example: "Generated on 2025-05-25 02:20 GMT+2"
* @param {string} htmlContent The HTML content of the report page.
* @returns {number|null} UTC milliseconds timestamp, or null if not found/parsed.
*/
function parseGeneratedOnTimestamp(htmlContent) {
const match = htmlContent.match(/Generated on (\d{4}-\d{2}-\d{2} \d{2}:\d{2} GMT[+-]\d{1,2})/);
if (match && match[1]) {
try {
// Replace GMT+Z with Z (ISO 8601 format for UTC offset)
const dateString = match[1].replace(/GMT([+-]\d{1,2})/, '$1:00');
// Create a Date object from the string, which should correctly parse with offset
const date = new Date(dateString);
return date.getTime(); // Return UTC milliseconds
} catch (e) {
console.error("Error parsing generated timestamp:", match[1], e);
}
}
return null;
}
/**
* Extracts item count and generated timestamp from report HTML.
* @param {string} htmlContent The HTML content of the report page.
* @returns {{itemCount: number, mbGeneratedTimestamp: number|null}}
*/
function extractReportData(htmlContent) {
let itemCount = 0;
const countMatch = htmlContent.match(/Total\s+[\w\s-]+?\s+found:\s*(\d+)/i);
if (countMatch && countMatch[1]) {
itemCount = parseInt(countMatch[1], 10);
} else {
// Fallback check if table tbody is empty, assuming 0 items
const parser = new DOMParser();
const doc = parser.parseFromString(htmlContent, 'text/html');
const tableBody = doc.querySelector('table.tbl tbody');
if (tableBody && tableBody.children.length === 0) {
itemCount = 0;
}
}
const mbGeneratedTimestamp = parseGeneratedOnTimestamp(htmlContent);
return { itemCount, mbGeneratedTimestamp };
}
/**
* Fetches the content of a given URL using GM_xmlhttpRequest.
* @param {string} url The URL to fetch.
* @returns {Promise<string>} A promise that resolves with the response text.
*/
function fetchUrlContent(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: url,
onload: function(response) {
if (response.status === 200) {
resolve(response.responseText);
} else {
console.error(`Failed to fetch ${url}: Status ${response.status}`);
reject(new Error(`Failed to fetch ${url}: Status ${response.status}`));
}
},
onerror: function(error) {
console.error(`Error fetching ${url}:`, error);
reject(error);
}
});
});
}
/**
* Pauses execution for a given number of milliseconds.
* @param {number} ms The number of milliseconds to wait.
* @returns {Promise<void>} A promise that resolves after the delay.
*/
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Calculates the UTC timestamp for today's 00:00:00.000.
* This is used as a boundary to determine if a cached report's generation time is 'today' or 'yesterday/earlier'.
* @returns {number} UTC milliseconds timestamp for today at midnight.
*/
function getTodayMidnightUTC() {
const now = new Date();
// Create a Date object for current day, 00:00:00.000 UTC
return Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 0, 0, 0, 0);
}
/**
* Formats a duration in milliseconds into a human-readable string.
* @param {number} ms The duration in milliseconds.
* @returns {string} Human-readable duration (e.g., "5 days ago", "1 hour ago").
*/
function formatTimeAgo(ms) {
if (ms < 0) return 'in the future'; // Should not happen for 'ago'
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
const months = Math.floor(days / 30.4375); // Average days in a month
const years = Math.floor(days / 365.25); // Average days in a year
if (years > 0) return `${years} year${years > 1 ? 's' : ''} ago`;
if (months > 0) return `${months} month${months > 1 ? 's' : ''} ago`;
if (days > 0) return `${days} day${days > 1 ? 's' : ''} ago`;
if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ago`;
if (minutes > 0) return `${minutes} minute${minutes > 1 ? 's' : ''} ago`;
return `${seconds} second${seconds > 1 ? 's' : ''} ago`;
}
/**
* Calculates the change in item count and formats it for display.
* @param {Array<Object>} history The report's history array.
* @returns {string} Formatted change string (e.g., "▲ +5 (2.5%) since 3 days ago").
*/
function getChangeIndicator(history) {
if (!history || history.length < 1) {
return '<span class="report-change-indicator" style="color: grey;">(No History)</span>';
}
const currentEntry = history[history.length - 1];
if (currentEntry.itemCount === -1) {
return '<span class="report-change-indicator" style="color: grey;">(Unknown Count)</span>';
}
let previousEntry = null;
for (let i = history.length - 2; i >= 0; i--) {
if (history[i].mbGeneratedTimestamp !== currentEntry.mbGeneratedTimestamp && history[i].itemCount !== -1) {
previousEntry = history[i];
break;
}
}
if (!previousEntry) {
return '<span class="report-change-indicator" style="color: grey;">(New)</span>';
}
const change = currentEntry.itemCount - previousEntry.itemCount;
let percentageChange = null;
if (previousEntry.itemCount !== 0) {
percentageChange = (change / previousEntry.itemCount) * 100;
}
let arrow = '↔';
let color = 'grey';
if (change > 0) {
arrow = '▲';
color = 'green';
} else if (change < 0) {
arrow = '▼';
color = 'red';
}
const changeText = `${arrow} ${change > 0 ? '+' : ''}${change}`;
const percentageText = percentageChange !== null ? ` (${percentageChange.toFixed(1)}%)` : '';
let periodText = '';
if (currentEntry.mbGeneratedTimestamp && previousEntry.mbGeneratedTimestamp) {
const timeDiff = Math.abs(currentEntry.mbGeneratedTimestamp - previousEntry.mbGeneratedTimestamp);
periodText = ` (${formatTimeAgo(timeDiff)})`;
} else if (currentEntry.lastFetchedTimestamp && previousEntry.lastFetchedTimestamp) {
const timeDiff = Math.abs(currentEntry.lastFetchedTimestamp - previousEntry.lastFetchedTimestamp);
periodText = ` (fetched ${formatTimeAgo(timeDiff)} apart)`;
}
return `<span class="report-change-indicator" style="color: ${color};">${changeText}${percentageText}${periodText}</span>`;
}
/**
* Main execution function to scan, manage cache, and process reports.
*/
async function init() {
createProgressBar();
const currentReportLinks = Array.from(document.querySelectorAll('#content ul li a[href*="/report/"]'));
if (currentReportLinks.length === 0) {
console.log('No report links found on this page.');
hideProgressBar();
return;
}
let parsedCache = {};
let newReportCache = {};
let currentCacheVersion = null;
let currentScriptVersionInCache = null;
let forceAllFetchesDueToStructureChange = false;
try {
const cachedData = localStorage.getItem(CENTRAL_CACHE_KEY);
if (cachedData) {
parsedCache = JSON.parse(cachedData);
currentCacheVersion = parsedCache.cache_version;
currentScriptVersionInCache = parsedCache.script_version;
console.log(`Centralized cache loaded. Cache Version: ${currentCacheVersion || 'none'}, Script Version: ${currentScriptVersionInCache || 'none'}`);
if (currentCacheVersion === CURRENT_CACHE_VERSION) {
newReportCache = parsedCache.reports || {};
} else {
console.log(`Cache version mismatch. Current: ${CURRENT_CACHE_VERSION}, Cached: ${currentCacheVersion || 'none'}. Initiating full refresh.`);
newReportCache = {}; // Start with an empty cache for the new structure
// Only migrate if the cached version is exactly the previous "stable" structure version (1.5).
// For any other old version or unversioned cache, a full refresh will occur.
if (currentCacheVersion === '1.5' && parsedCache.reports) {
console.log(`Migrating central cache from version ${currentCacheVersion}.`);
for (const reportName in parsedCache.reports) {
newReportCache[reportName] = parsedCache.reports[reportName]; // Copy as is, structure compatible
}
}
forceAllFetchesDueToStructureChange = true;
}
} else {
console.log("No centralized cache found. All reports will be fetched.");
newReportCache = {}; // Ensure it's empty
forceAllFetchesDueToStructureChange = true;
}
} catch (e) {
console.error("Error loading or parsing centralized cache. All reports will be fetched as fallback:", e);
newReportCache = {}; // Ensure it's empty
forceAllFetchesDueToStructureChange = true;
}
const linksToFetch = [];
const todayMidnightUTC = getTodayMidnightUTC();
// Phase 1: Identify reports that need fetching or hiding based on cache
for (const link of currentReportLinks) {
const fullReportUrl = link.href;
const reportName = getReportName(fullReportUrl);
const cachedEntry = newReportCache[reportName]; // Use newReportCache which contains migrated data
const parentLi = link.closest('li');
let needsFetch = false;
let debugReason = "No cache entry";
if (forceAllFetchesDueToStructureChange) {
needsFetch = true;
debugReason = "Cache version updated (forced full refresh)";
} else {
if (!cachedEntry || !cachedEntry.lastFetchedTimestamp || (Date.now() - cachedEntry.lastFetchedTimestamp >= INTERNAL_CACHE_DURATION)) {
let latestMbGeneratedTimestamp = null;
if (cachedEntry && cachedEntry.history && cachedEntry.history.length > 0) {
latestMbGeneratedTimestamp = cachedEntry.history[cachedEntry.history.length - 1].mbGeneratedTimestamp;
}
if (!latestMbGeneratedTimestamp || latestMbGeneratedTimestamp < todayMidnightUTC) {
needsFetch = true;
debugReason = latestMbGeneratedTimestamp ? "Data older than today's 00:00 UTC" : "No MB generated timestamp in cache";
} else {
debugReason = "MB data for today is already cached";
}
} else {
debugReason = "Recently fetched in this session";
}
}
if (needsFetch) {
linksToFetch.push({ link, parentLi, fullReportUrl, reportName });
console.log(`[Fetch Needed] ${reportName} (Reason: ${debugReason})`);
}
// Always add change indicator for current report, using the data currently in newReportCache
if (parentLi) {
const currentReportDataForDisplay = newReportCache[reportName] || { history: [] };
const latestItemCount = currentReportDataForDisplay.history && currentReportDataForDisplay.history.length > 0 ?
currentReportDataForDisplay.history[currentReportDataForDisplay.history.length - 1].itemCount : -1;
if (latestItemCount === 0) {
parentLi.style.display = 'none';
console.log(`[Display Update] Hidden: ${reportName} (Items: ${latestItemCount})`);
} else if (latestItemCount > 0) {
parentLi.style.display = '';
console.log(`[Display Update] Kept: ${reportName} (Items: ${latestItemCount})`);
} else {
parentLi.style.display = '';
console.log(`[Display Update] Kept: ${reportName} (Items: unknown)`);
}
const changeIndicatorHtml = getChangeIndicator(currentReportDataForDisplay.history);
const indicatorSpan = document.createElement('span');
indicatorSpan.innerHTML = ` ${changeIndicatorHtml}`;
link.parentNode.insertBefore(indicatorSpan, link.nextSibling);
}
}
totalLinksToFetch = linksToFetch.length;
if (totalLinksToFetch === 0) {
console.log('All report statuses are validly cached. No new fetches needed.');
hideProgressBar();
localStorage.setItem(CENTRAL_CACHE_KEY, JSON.stringify({
script_version: currentScriptVersion,
cache_version: CURRENT_CACHE_VERSION,
reports: newReportCache
}));
return;
}
showProgressBar();
// Phase 2: Fetch and process reports that need updating
for (const { link, parentLi, fullReportUrl, reportName } of linksToFetch) {
try {
console.log(`[Fetching] ${reportName}...`);
const htmlContent = await fetchUrlContent(fullReportUrl);
const { itemCount, mbGeneratedTimestamp } = extractReportData(htmlContent);
let currentReportEntry = newReportCache[reportName] || { history: [] };
if (mbGeneratedTimestamp !== null) {
const lastHistoryEntry = currentReportEntry.history[currentReportEntry.history.length - 1];
if (!lastHistoryEntry || lastHistoryEntry.mbGeneratedTimestamp !== mbGeneratedTimestamp) {
currentReportEntry.history.push({ mbGeneratedTimestamp, itemCount });
} else {
lastHistoryEntry.itemCount = itemCount;
}
currentReportEntry.history = currentReportEntry.history.slice(Math.max(currentReportEntry.history.length - HISTORY_MAX_DAYS, 0));
}
currentReportEntry.lastFetchedTimestamp = Date.now();
newReportCache[reportName] = currentReportEntry;
if (itemCount === 0) {
if (parentLi) parentLi.style.display = 'none';
console.log(`[Fetched] Hidden: ${reportName} (Items: ${itemCount})`);
} else {
if (parentLi) parentLi.style.display = '';
console.log(`[Fetched] Kept: ${reportName} (Items: ${itemCount})`);
}
if (parentLi) {
const existingIndicator = parentLi.querySelector('.report-change-indicator');
if (existingIndicator) {
existingIndicator.remove();
}
const changeIndicatorHtml = getChangeIndicator(currentReportEntry.history);
const indicatorSpan = document.createElement('span');
indicatorSpan.innerHTML = ` ${changeIndicatorHtml}`;
link.parentNode.insertBefore(indicatorSpan, link.nextSibling);
}
} catch (error) {
console.error(`[Error] Could not process report ${reportName}:`, error);
} finally {
fetchedLinksCount++;
updateProgressBar();
if (fetchedLinksCount < totalLinksToFetch) {
await sleep(REQUEST_DELAY);
}
}
}
try {
localStorage.setItem(CENTRAL_CACHE_KEY, JSON.stringify({
script_version: currentScriptVersion,
cache_version: CURRENT_CACHE_VERSION,
reports: newReportCache
}));
console.log("Reports cache updated and versioned in localStorage.");
} catch (e) {
console.error("Error saving reports cache to localStorage:", e);
}
progressBar.style.width = '100%';
setTimeout(hideProgressBar, 500);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();