您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Adds MusicBrainz status icons to Beatport releases and allows importing them with Harmony
// ==UserScript== // @name Beatport: MusicBrainz Importer // @namespace https://musicbrainz.org/user/chaban // @version 2.3.2 // @description Adds MusicBrainz status icons to Beatport releases and allows importing them with Harmony // @tag ai-created // @author RustyNova, chaban // @license MIT // @match https://www.beatport.com/* // @connect musicbrainz.org // @icon https://www.google.com/s2/favicons?sz=64&domain=beatport.com // @grant GM.xmlHttpRequest // @grant GM.info // @run-at document-idle // ==/UserScript== (function() { 'use strict'; /** * Configuration object to centralize all constants. */ const Config = { SHORT_APP_NAME: 'UserJS.BeatportMusicBrainzImporter', HARMONY_BASE_URL: 'https://harmony.pulsewidth.org.uk/release', MUSICBRAINZ_API_BASE_URL: 'https://musicbrainz.org/ws/2/url', MUSICBRAINZ_BASE_URL: 'https://musicbrainz.org/', HARMONY_ICON_URL: 'https://harmony.pulsewidth.org.uk/favicon.svg', MUSICBRAINZ_ICON_URL: 'https://raw.githubusercontent.com/metabrainz/musicbrainz-server/master/root/static/images/entity/release.svg', SUPPORTED_PATHS: [ '/my-beatport', '/label/', '/artist/', '/track/', '/genre/', '/release/' ], HARMONY_DEFAULT_PARAMS: { gtin: '', region: 'us', category: 'preferred' }, SELECTORS: { RELEASE_ROW: '[class*="TableRow"]', RELEASE_LINK: '[href*="/release/"]', ANCHOR: '.date', ICONS_CONTAINER: '.button_container', RELEASE_CONTROLS_CONTAINER: '[class*="CollectionControls-style__Wrapper"]' }, CLASS_NAMES: { STATUS_ICON: 'status-icon', HARMONY_ICON: 'harmony-icon', RELEASE_ICON: 'release-icon', ICONS_CONTAINER: 'button_container', BUTTON_MUSICBRAINZ: 'button_musicbrainz', BUTTON_HARMONY: 'button_harmony', }, MB_API_BATCH_SIZE: 100, MB_API_MAX_RETRIES: 5, MB_API_TIMEOUT_MS: 10000, OBSERVER_CONFIG: { root: document, options: { subtree: true, childList: true } }, }; /** * Utility function to pause 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. */ function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } /** * General utility functions. */ const Utils = { /** * Safely retrieves nested properties from an object. * @param {object} obj - The object to traverse. * @param {string[]} path - An array of property names representing the path to the desired value. * @returns {any | undefined} The value at the specified path, or undefined if any part of the path is missing. */ _getNestedProperty: function(obj, path) { return path.reduce((acc, part) => (acc && acc[part] !== undefined) ? acc[part] : undefined, obj); }, /** * Retrieves the __NEXT_DATA__ object from the page. * @returns {object | null} The parsed __NEXT_DATA__ object, or null if not found or parsing fails. */ _getNextData: function() { const nextDataScript = document.getElementById('__NEXT_DATA__'); if (nextDataScript && nextDataScript.textContent) { try { return JSON.parse(nextDataScript.textContent); } catch (e) { return null; } } return null; }, /** * Extracts the base pathname from a URL, removing any leading language prefix (e.g., /de/, /fr/). * @param {string} pathname - The window.location.pathname string. * @returns {string} The pathname without a language prefix. */ _getBasePathname: function(pathname) { const langPrefixRegex = /^\/[a-z]{2}\//; if (langPrefixRegex.test(pathname)) { return '/' + pathname.substring(pathname.indexOf('/', 1) + 1); } return pathname; }, /** * Finds and extracts the current release data from the __NEXT_DATA__ object. * Prioritizes 'release' directly, then looks into 'dehydratedState.queries'. * @returns {object | null} The release data object, or null if not found. */ getReleaseDataFromNextData: function() { const parsedData = this._getNextData(); if (!parsedData) { return null; } // 1. Try to get release data directly from pageProps.release let release = this._getNestedProperty(parsedData, ['props', 'pageProps', 'release']); if (release && release.id) { return release; } // 2. If not found directly, search within dehydratedState.queries const queries = this._getNestedProperty(parsedData, ['props', 'pageProps', 'dehydratedState', 'queries']); if (Array.isArray(queries)) { const currentReleaseId = window.location.pathname.split('/').pop(); // Extract ID from URL for (const query of queries) { const queryData = this._getNestedProperty(query, ['state', 'data']); if (queryData) { // Case 1: queryData.results is an array of releases (e.g., label releases list) if (Array.isArray(queryData.results)) { const foundRelease = queryData.results.find(item => item.id && item.id.toString() === currentReleaseId ); if (foundRelease) { return foundRelease; } } // Case 2: queryData itself is the release object (e.g., single release page data) else if (queryData.id && queryData.id.toString() === currentReleaseId) { return queryData; } } } } return null; // Release data not found in __NEXT_DATA__ }, /** * Extracts artist and release name from the Open Graph title meta tag. * @returns {{artist: string, release: string}|null} An object with artist and release, or null if not found. */ getArtistAndReleaseFromMetaTags: function() { const ogTitleMeta = document.querySelector('meta[property="og:title"]'); if (ogTitleMeta && ogTitleMeta.content) { const ogTitle = ogTitleMeta.content; const parts = ogTitle.split(' - '); if (parts.length >= 2) { let artist = parts[0].trim(); let release = parts[1]; // Remove the label part if present (e.g., "[We Are Trance]") const labelMatch = release.match(/\[.*?\]/); if (labelMatch) { release = release.replace(labelMatch[0], '').trim(); } // Remove the trailing Beatport suffix (e.g., "| Music & Downloads on Beatport") const beatportSuffix = " | Music & Downloads on Beatport"; if (release.endsWith(beatportSuffix)) { release = release.substring(0, release.length - beatportSuffix.length).trim(); } return { artist: artist, release: release }; } } return null; }, /** * Waits for an element matching the selector to appear in the DOM. * @param {string} selector - The CSS selector for the element to wait for. * @returns {Promise<HTMLElement>} A promise that resolves with the element when found. */ waitForElement: function(selector) { return new Promise((resolve) => { const observer = new MutationObserver((mutations, obs) => { const element = document.querySelector(selector); if (element) { obs.disconnect(); resolve(element); } }); observer.observe(document.body, { childList: true, subtree: true }); const element = document.querySelector(selector); if (element) { observer.disconnect(); resolve(element); } }); } }; /** * Constructs the Harmony import URL for a given Beatport release URL. * @param {string} releaseUrl - The Beatport release URL. * @returns {string} The complete Harmony import URL. */ function getHarmonyImportUrl(releaseUrl) { const harmonyParams = new URLSearchParams(); for (const [key, value] of Object.entries(Config.HARMONY_DEFAULT_PARAMS)) { harmonyParams.set(key, value); } harmonyParams.set('url', releaseUrl); return `${Config.HARMONY_BASE_URL}?${harmonyParams.toString()}`; } /** * Constructs the MusicBrainz release URL. * @param {string} type - The MusicBrainz entity type (e.g., "release", "release-group"). * @param {string} mbid - The MusicBrainz ID of the entity. * @returns {string} The complete MusicBrainz release URL. */ function getMusicBrainzReleaseUrl(type, mbid) { return `${Config.MUSICBRAINZ_BASE_URL}${type}/${mbid}`; } /** * Constructs the MusicBrainz tag lookup (search) URL using URLSearchParams. * @param {string} artist - The artist name. * @param {string} release - The release name. * @returns {string} The complete MusicBrainz tag lookup URL. */ function getMusicBrainzSearchUrl(artist, release) { const baseUrl = new URL('taglookup/index', Config.MUSICBRAINZ_BASE_URL); baseUrl.searchParams.set('tag-lookup.artist', artist); baseUrl.searchParams.set('tag-lookup.release', release); return baseUrl.toString(); } /** * Manages the creation and appending of status icons to the DOM. */ const IconManager = { /** * Creates and appends a "missing" icon (linking to Harmony) to the given container. * @param {HTMLElement} container - The container element to which the icon will be appended. * @param {string} releaseUrl - The Beatport release URL to be used in the Harmony link. */ addMissingIcon: function(container, releaseUrl) { let iconLink = document.createElement("a"); iconLink.className = `${Config.CLASS_NAMES.STATUS_ICON} ${Config.CLASS_NAMES.HARMONY_ICON}`; iconLink.href = getHarmonyImportUrl(releaseUrl); iconLink.target = "_blank"; iconLink.title = "Import with Harmony" container.appendChild(iconLink); }, /** * Creates and appends a "release" icon (linking to MusicBrainz) to the given container. * @param {string} container - The container element to which the icon will be appended. * @param {string} type - The MusicBrainz entity type (e.g., "release", "release-group"). * @param {string} mbid - The MusicBrainz ID of the entity. */ addReleaseIcon: function(container, type, mbid) { let iconLink = document.createElement("a"); iconLink.className = `${Config.CLASS_NAMES.STATUS_ICON} ${Config.CLASS_NAMES.RELEASE_ICON}`; iconLink.href = getMusicBrainzReleaseUrl(type, mbid); iconLink.target = "_blank"; iconLink.title = "Open in MusicBrainz" container.appendChild(iconLink); }, /** * Processes a single release row to add MusicBrainz status icons based on lookup results. * @param {HTMLElement} rowElement - The DOM element representing a single release row. * @param {string} releaseUrl - The Beatport URL of the release. * @param {Array|null} mbStatus - The MusicBrainz status ([targetType, mbid]) or null if not found. */ updateReleaseRow: async function(rowElement, releaseUrl, mbStatus) { const dateDiv = rowElement.querySelector(Config.SELECTORS.ANCHOR); if (!dateDiv) { return; } // Disconnect observer before modifying DOM BeatportMusicBrainzImporter._observerInstance.disconnect(); let existingIconsContainer = dateDiv.querySelector(`.${Config.CLASS_NAMES.ICONS_CONTAINER}`); if (existingIconsContainer) { existingIconsContainer.remove(); } let iconsContainer = document.createElement("div"); iconsContainer.className = Config.CLASS_NAMES.ICONS_CONTAINER; if (mbStatus !== null) { this.addReleaseIcon(iconsContainer, mbStatus[0], mbStatus[1]); } else { this.addMissingIcon(iconsContainer, releaseUrl); } dateDiv.appendChild(iconsContainer); BeatportMusicBrainzImporter._observerInstance.observe(Config.OBSERVER_CONFIG.root, Config.OBSERVER_CONFIG.options); } }; /** * Manages the injection of import/search buttons on release detail pages. */ const ButtonManager = { /** * Creates an onclick handler function that opens a given URL in a new tab. * @param {string} url - The URL to open. * @returns {function} An event handler function. */ _createOpenWindowHandler: function(url) { return function() { window.open(url, '_blank').focus(); }; }, /** * Adds an "Import with Harmony" button. * @param {HTMLElement} container - The container to append the button to. * @param {string} releaseUrl - The current Beatport release URL. */ addHarmonyImportButton: function(container, releaseUrl) { let button = document.createElement("button"); button.textContent = "Import with Harmony"; button.className = `${Config.CLASS_NAMES.BUTTON_HARMONY}`; button.title = "Import with Harmony" button.onclick = this._createOpenWindowHandler(getHarmonyImportUrl(releaseUrl)); container.appendChild(button); }, /** * Adds an "Open in MusicBrainz" button. * @param {HTMLElement} container - The container to append the button to. * @param {string} type - The MusicBrainz entity type. * @param {string} mbid - The MusicBrainz ID. */ addOpenMusicBrainzButton: function(container, type, mbid) { let button = document.createElement("button"); button.textContent = "Open in MusicBrainz"; button.className = `${Config.CLASS_NAMES.BUTTON_MUSICBRAINZ}`; button.title = "Open in MusicBrainz"; button.onclick = this._createOpenWindowHandler(getMusicBrainzReleaseUrl(type, mbid)); container.appendChild(button); }, /** * Adds a "Search in MusicBrainz" button. * @param {HTMLElement} container - The container to append the button to. * @param {string} artist - The artist name. * @param {string} release - The release name. */ addSearchMusicBrainzButton: function(container, artist, release) { let button = document.createElement("button"); button.textContent = "Search in MusicBrainz"; button.className = `${Config.CLASS_NAMES.BUTTON_MUSICBRAINZ}`; button.title = "Search in MusicBrainz"; button.onclick = this._createOpenWindowHandler(getMusicBrainzSearchUrl(artist, release)); container.appendChild(button); }, /** * Processes the current release page to add import/search buttons. * @param {Array|null|undefined} mbStatus - The MusicBrainz status for the current URL. */ processReleasePageButtons: async function(mbStatus) { const anchor = await Utils.waitForElement(Config.SELECTORS.RELEASE_CONTROLS_CONTAINER); if (!anchor) { return; } BeatportMusicBrainzImporter._observerInstance.disconnect(); let container = anchor.querySelector(`.${Config.CLASS_NAMES.ICONS_CONTAINER}`); if (container) { while (container.firstChild) { container.removeChild(container.firstChild); } } else { container = document.createElement("div"); container.className = Config.CLASS_NAMES.ICONS_CONTAINER; anchor.appendChild(container); } const currentUrl = window.location.href; this.addHarmonyImportButton(container, currentUrl); if (mbStatus !== null && mbStatus !== undefined) { this.addOpenMusicBrainzButton(container, mbStatus[0], mbStatus[1]); } else { let artistName = ''; let releaseName = ''; // Try to get data from the new, more robust __NEXT_DATA__ extraction const releaseDataFromNext = Utils.getReleaseDataFromNextData(); if (releaseDataFromNext) { artistName = releaseDataFromNext.artists[0]?.name || ''; releaseName = releaseDataFromNext.name || ''; } // Fallback to meta tags if __NEXT_DATA__ is still incomplete or didn't contain the specific release if (!artistName || !releaseName) { const metaData = Utils.getArtistAndReleaseFromMetaTags(); if (metaData) { artistName = metaData.artist; releaseName = metaData.release; } } if (artistName && releaseName) { this.addSearchMusicBrainzButton(container, artistName, releaseName); } } BeatportMusicBrainzImporter._observerInstance.observe(Config.OBSERVER_CONFIG.root, Config.OBSERVER_CONFIG.options); } }; /** * Handles all interactions with the MusicBrainz API, including caching and retries. */ const MusicBrainzAPI = { _urlCache: new Map(), /** * Queries MusicBrainz for multiple URLs in batches using the 'resource' parameter. * @param {string[]} urlsToQuery - An array of URLs to query. * @returns {Promise<Map<string, Array|null|undefined>>} A promise that resolves to a Map where keys are URLs * and values are either [targetType, mbid], null (not found), or undefined (API failed). */ lookupUrls: async function(urlsToQuery) { const resultsMap = new Map(); const uniqueUrlsToQuery = [...new Set(urlsToQuery)]; const uncachedUrls = uniqueUrlsToQuery.filter(url => { const cached = this._urlCache.get(url); if (cached !== undefined) { resultsMap.set(url, cached); return false; } return true; }); if (uncachedUrls.length === 0) { return resultsMap; } for (let i = 0; i < uncachedUrls.length; i += Config.MB_API_BATCH_SIZE) { const batch = uncachedUrls.slice(i, i + Config.MB_API_BATCH_SIZE); const apiUrl = new URL(Config.MUSICBRAINZ_API_BASE_URL); batch.forEach(url => { const parsedUrl = new URL(url); const normalizedPathname = Utils._getBasePathname(parsedUrl.pathname); const normalizedUrl = `${parsedUrl.origin}${normalizedPathname}${parsedUrl.search}`; apiUrl.searchParams.append('resource', normalizedUrl); }); apiUrl.searchParams.set('inc', 'release-rels'); apiUrl.searchParams.set('fmt', 'json'); let tries = 0; let success = false; while (!success && tries < Config.MB_API_MAX_RETRIES) { await sleep(1000 * tries); try { const response = await GM.xmlHttpRequest({ url: apiUrl.toString(), method: "GET", responseType: "json", headers: { 'Accept': "application/json", 'Origin': location.origin, 'User-Agent': `${Config.SHORT_APP_NAME}/${GM_info.script.version} ( ${GM_info.script.namespace} )`, }, anonymous: true, timeout: Config.MB_API_TIMEOUT_MS }); if (response.status === 200 && response.response) { let urlEntities; if (batch.length === 1) { urlEntities = [response.response]; } else if (Array.isArray(response.response.urls)) { urlEntities = response.response.urls; } else { batch.forEach(url => this._urlCache.set(url, null)); success = true; continue; } this._processApiResponse(batch, urlEntities, resultsMap); success = true; } else if (response.status === 404) { batch.forEach(url => this._urlCache.set(url, null)); success = true; } else if (response.status === 503) { tries++; } else { tries++; } } catch (e) { tries++; } } if (!success) { batch.forEach(url => { this._urlCache.set(url, undefined); resultsMap.delete(url); }); } } return resultsMap; } , /** * Internal helper to process successful API responses and update cache/results. * @param {string[]} originalUrlsInBatch - The original batch of URLs sent in the request. * @param {Array<Object>} urlEntities - The 'urls' array from the MusicBrainz API response (or single object wrapped in array). * @param {Map<string, Array|null|undefined>} resultsMap - The map to store current results. */ _processApiResponse: function(originalUrlsInBatch, urlEntities, resultsMap) { originalUrlsInBatch.forEach(originalUrl => { const urlEntity = urlEntities.find(ue => { const parsedOriginalUrl = new URL(originalUrl); const normalizedOriginalPathname = Utils._getBasePathname(parsedOriginalUrl.pathname); const normalizedOriginalUrl = `${parsedOriginalUrl.origin}${normalizedOriginalPathname}${parsedOriginalUrl.search}`; const parsedEntityResource = new URL(ue.resource); const normalizedEntityResourcePathname = Utils._getBasePathname(parsedEntityResource.pathname); const normalizedEntityResource = `${parsedEntityResource.origin}${normalizedEntityResourcePathname}${parsedEntityResource.search}`; return normalizedOriginalUrl === normalizedEntityResource; }); if (urlEntity) { if (urlEntity.relations && urlEntity.relations.length > 0) { // Find the first relation that targets a 'release' const releaseRelation = urlEntity.relations.find(rel => rel['target-type'] === 'release'); if (releaseRelation && releaseRelation.release && releaseRelation.release.id) { const targetType = releaseRelation['target-type']; const mbid = releaseRelation.release.id; this._urlCache.set(originalUrl, [targetType, mbid]); resultsMap.set(originalUrl, [targetType, mbid]); } else { this._urlCache.set(originalUrl, null); resultsMap.set(originalUrl, null); } } else { this._urlCache.set(originalUrl, null); resultsMap.set(originalUrl, null); } } else { this._urlCache.set(originalUrl, null); resultsMap.set(originalUrl, null); } }); } }; /** * Scans the DOM for release rows and extracts relevant information. */ const DOMScanner = { /** * Checks if the current page URL matches any of the supported patterns. * @returns {boolean} True if the current page is supported, false otherwise. */ isSupportedPage: function() { const pathname = window.location.pathname; const basePathname = Utils._getBasePathname(pathname); return Config.SUPPORTED_PATHS.some(path => basePathname.startsWith(path)); }, /** * Checks if the current page is a specific release detail page. * @returns {boolean} True if on a release detail page, false otherwise. */ isReleaseDetailPage: function() { const pathname = window.location.pathname; const basePathname = Utils._getBasePathname(pathname); return basePathname.startsWith('/release/'); }, /** * Finds all unprocessed release rows and extracts their URLs and corresponding DOM elements. * @returns {Array<{url: string, element: HTMLElement}>} An array of objects, each containing * a release URL and its DOM element. */ getReleasesToProcess: function() { const releases = document.querySelectorAll(Config.SELECTORS.RELEASE_ROW); const unprocessedReleases = []; for (const releaseRow of releases) { const releaseLinkElement = releaseRow.querySelector(Config.SELECTORS.RELEASE_LINK); if (releaseLinkElement && releaseLinkElement.href) { const url = releaseLinkElement.href; const dateDiv = releaseRow.querySelector(Config.SELECTORS.ANCHOR); const existingIconsContainer = dateDiv ? dateDiv.querySelector(`.${Config.CLASS_NAMES.ICONS_CONTAINER}`) : null; if (!existingIconsContainer) { unprocessedReleases.push({ url: url, element: releaseRow }); } } } return unprocessedReleases; } }; /** * Main application logic for the userscript. */ const BeatportMusicBrainzImporter = { _runningUpdate: false, _scheduleUpdate: false, _observerTimeoutId: null, _previousUrl: '', _observerInstance: null, // General DOM MutationObserver instance _nprogressObserver: null, // NProgress specific MutationObserver instance /** * Initializes the application: injects CSS and sets up the MutationObserver. */ init: function() { this._injectCSS(); this._setupObservers(); this._previousUrl = window.location.href; // Initial run after NProgress finishes this._waitForNProgressToFinish().then(() => this.runUpdate()); }, /** * Injects custom CSS rules into the document head. */ _injectCSS: function() { const head = document.head || document.getElementsByTagName('head')[0]; if (head) { const style = document.createElement('style'); style.type = 'text/css'; style.textContent = ` /* Status Icons CSS */ .${Config.CLASS_NAMES.STATUS_ICON} { margin: 0px 5px; width: 20px; height: 20px; display: inline-block; background-repeat: no-repeat; background-position: center; background-size: 20px; } .${Config.CLASS_NAMES.HARMONY_ICON} { background-image: url("${Config.HARMONY_ICON_URL}") !important; } .${Config.CLASS_NAMES.RELEASE_ICON} { background-image: url("${Config.MUSICBRAINZ_ICON_URL}") !important; } /* Combined container for both status icons and import buttons */ .${Config.CLASS_NAMES.ICONS_CONTAINER} { display: flex; align-items: center; gap: 10px; /* Space between buttons/icons */ flex-wrap: wrap; /* Allow wrapping on smaller screens */ justify-content: flex-start; /* Align buttons to the left */ } /* Adjust anchor display to accommodate icons/buttons */ ${Config.SELECTORS.ANCHOR} { display: flex; align-items: center; justify-content: space-between; } /* Import Buttons CSS (from original import script) */ .${Config.CLASS_NAMES.BUTTON_MUSICBRAINZ} { background-color: #BA478F; padding: 2px 6px; border-radius: 4px; color: white; font-weight: bold; cursor: pointer; border: none; transition: background-color 0.2s ease; } .${Config.CLASS_NAMES.BUTTON_MUSICBRAINZ}:hover { background-color: #9e3a79; } .${Config.CLASS_NAMES.BUTTON_HARMONY} { background-color: #c45555; padding: 2px 6px; border-radius: 4px; color: white; font-weight: bold; cursor: pointer; border: none; transition: background-color 0.2s ease; } .${Config.CLASS_NAMES.BUTTON_HARMONY}:hover { background-color: #a34545; } /* Ensure the parent of ShareContainer has flex or block to allow button container to sit well */ .${Config.SELECTORS.RELEASE_CONTROLS_CONTAINER} { display: flex; flex-direction: row; align-items: center; gap: 15px; flex-wrap: wrap; } `; head.appendChild(style); } }, /** * Waits for the NProgress busy class to be removed from the HTML element. * @returns {Promise<void>} A promise that resolves when 'nprogress-busy' class is removed. */ _waitForNProgressToFinish: function() { return new Promise(resolve => { const htmlElement = document.documentElement; if (!htmlElement.classList.contains('nprogress-busy')) { resolve(); // Already done loading return; } // Disconnect any existing NProgress observer to prevent duplicates if called multiple times if (this._nprogressObserver) { this._nprogressObserver.disconnect(); this._nprogressObserver = null; } // Create an observer to watch for the 'nprogress-busy' class removal this._nprogressObserver = new MutationObserver((mutationsList) => { for (const mutation of mutationsList) { if (mutation.type === 'attributes' && mutation.attributeName === 'class') { if (!htmlElement.classList.contains('nprogress-busy')) { this._nprogressObserver.disconnect(); this._nprogressObserver = null; // Clear reference resolve(); break; // Stop iterating mutations and resolve } } } }); this._nprogressObserver.observe(htmlElement, { attributes: true, attributeFilter: ['class'] }); }); }, /** * Sets up all observers (MutationObserver and History API listeners). */ _setupObservers: function() { const self = this; const originalPushState = history.pushState; const originalReplaceState = history.replaceState; history.pushState = function() { originalPushState.apply(this, arguments); // Only trigger if URL actually changed to avoid redundant calls if state is pushed without URL change if (window.location.href !== self._previousUrl) { self._previousUrl = window.location.href; MusicBrainzAPI._urlCache.clear(); self._waitForNProgressToFinish().then(() => self.runUpdate()); } }; history.replaceState = function() { originalReplaceState.apply(this, arguments); // Only trigger if URL actually changed if (window.location.href !== self._previousUrl) { self._previousUrl = window.location.href; MusicBrainzAPI._urlCache.clear(); self._waitForNProgressToFinish().then(() => self.runUpdate()); } }; window.addEventListener('popstate', () => { // popstate always means URL changed (back/forward) self._previousUrl = window.location.href; MusicBrainzAPI._urlCache.clear(); self._waitForNProgressToFinish().then(() => self.runUpdate()); }); // 2. MutationObserver for dynamic content loading on the same page (e.g., infinite scroll) const observer = new MutationObserver((mutations) => { // Only proceed if NProgress is not active AND it's a list page. if (!document.documentElement.classList.contains('nprogress-busy') && !DOMScanner.isReleaseDetailPage()) { // Debounce general DOM mutations if (this._observerTimeoutId) { clearTimeout(this._observerTimeoutId); } this._observerTimeoutId = setTimeout(async () => { this._observerTimeoutId = null; // Only run update if there are new releases to process if (DOMScanner.getReleasesToProcess().length > 0) { this.runUpdate(); } }, 50); } // The else if (window.location.href !== this._previousUrl) block is no longer needed here // because the History API listeners handle URL changes and trigger _waitForNProgressToFinish. }); this._observerInstance = observer; observer.observe(Config.OBSERVER_CONFIG.root, Config.OBSERVER_CONFIG.options); }, /** * Main function to execute the process of scanning for releases, fetching data, and updating UI. * This function handles both status icons on list pages and import buttons on detail pages. */ runUpdate: async function() { if (this._runningUpdate) { this._scheduleUpdate = true; return; } this._runningUpdate = true; if (!DOMScanner.isSupportedPage()) { this._runningUpdate = false; return; } const isDetailPage = DOMScanner.isReleaseDetailPage(); if (isDetailPage) { const currentUrl = window.location.href; const mbResultsMap = await MusicBrainzAPI.lookupUrls([currentUrl]); const mbStatus = mbResultsMap.get(currentUrl); await ButtonManager.processReleasePageButtons(mbStatus); this._runningUpdate = false; return; } // Only proceed with list page status icons if it's NOT a detail page const releasesToProcess = DOMScanner.getReleasesToProcess(); if (releasesToProcess.length === 0) { this._runningUpdate = false; return; } const urls = releasesToProcess.map(r => r.url); const mbResultsMap = await MusicBrainzAPI.lookupUrls(urls); for (const { url, element } of releasesToProcess) { const status = mbResultsMap.get(url); if (status !== undefined) { await IconManager.updateReleaseRow(element, url, status); } } this._runningUpdate = false; } }; BeatportMusicBrainzImporter.init(); })();