Beatport: MusicBrainz Importer

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();

})();