MusicBrainz: Reports Statistics

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

当前为 2025-05-27 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name        MusicBrainz: Reports Statistics
// @namespace   https://musicbrainz.org/user/chaban
// @version     1.0.19
// @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
// @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();
    }
})();