Greasy Fork is available in English.

MusicBrainz: Reports Statistics

Hides report links on MusicBrainz if the report contains no items. Also indicates report changes since the last recorded data.

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