Musicbrainz: Compare AcoustIDs easier!

Displays AcoustID fingerprints in more places at MusicBrainz.

// ==UserScript==
// @name          Musicbrainz: Compare AcoustIDs easier!
// @namespace     https://musicbrainz.org/user/chaban
// @version       1.0.0
// @tag           ai-created
// @description   Displays AcoustID fingerprints in more places at MusicBrainz.
// @author        otringal, chaban
// @license       MIT
// @match         *://*.musicbrainz.org/artist/*/recordings*
// @match         *://*.musicbrainz.org/artist/*/*edits*
// @match         *://*.musicbrainz.org/collection/*/*
// @match         *://*.musicbrainz.org/edit/*
// @match         *://*.musicbrainz.org/recording/*
// @match         *://*.musicbrainz.org/release-group/*
// @match         *://*.musicbrainz.org/release/*
// @match         *://*.musicbrainz.org/search/*
// @match         *://*.musicbrainz.org/user/*/edits*
// @match         *://*.musicbrainz.org/user/*/votes*
// @exclude-match *://*.musicbrainz.org/release/*/edit*
// @exclude-match *://*.musicbrainz.org/release/*/edit-relationships*
// @icon          https://acoustid.org/static/acoustid-wave-12.png
// @connect       api.acoustid.org
// @grant         none
// @run-at        document-end
// ==/UserScript==

(function () {
  'use strict';

  // -- USER SETTINGS --
  const enableMiniIcons = false;
  const enableAcoustList = true;
  const addShowHideButton = true;
  const alwaysShowIds = 5;
  const numCharacters = 6;

  // -- STYLES --
  const css = document.createElement('style');
  css.textContent = `
    td > a[href^='//acoustid.org/track/'] > code {
      display: inline-block;
      white-space: nowrap;
      overflow-x: hidden;
      width: ${numCharacters}ch;
      vertical-align: bottom;
    }
    .hidelist,
    .hidelist + br {
      display: none;
    }
    .showids span {
      white-space: nowrap;
      margin: 0.4em 0;
      padding: 0.1em 0.3em;
      font-size: smaller;
      text-transform: uppercase;
      font-weight: 600;
      background-color: rgba(250, 200, 35, 0.5);
      cursor: pointer;
    }
    .acoustid-icon {
      float: right;
    }
  `;
  document.head.appendChild(css);

  // -- UTILITY FUNCTIONS --

  /** Generates a random hex color code. */
  function getRandomColor() {
    const letters = '89ABCDEF';
    let color = '#';
    for (let i = 0; i < 6; i++) {
      color += letters[Math.floor(Math.random() * letters.length)];
    }
    return color;
  }

  /** Extracts the recording MBID from a URL. */
  function extractRecordingMBID(link) {
    if (link?.href) {
      const parts = link.href.split('/');
      if (parts[3] === 'recording') return parts[4];
    }
  }

  // -- API FUNCTIONS --

  /** Fetches AcoustID data for a list of MBIDs. Always returns an array. */
  async function fetchAcoustIDData(mbids) {
    if (mbids.length === 0) return [];
    const params = new URLSearchParams({ format: 'json', batch: '1' });
    mbids.forEach((mbid) => params.append('mbid', mbid));

    const response = await fetch(
      `//api.acoustid.org/v2/track/list_by_mbid?${params.toString()}`,
      {
        referrerPolicy: 'strict-origin-when-cross-origin',
      }
    );

    if (!response.ok) {
      throw new Error(`AcoustID API request failed: ${response.status}`);
    }
    const json = await response.json();
    if (json.status !== 'ok' || !Array.isArray(json.mbids)) {
      console.error(
        'AcoustID API returned an error:',
        json.error?.message || 'Unexpected format'
      );
      return []; // Return an empty array to prevent TypeErrors
    }
    return json.mbids;
  }

  // -- CORE LOGIC --

  /** Processes API data to find duplicates and map colors. */
  function processAcoustIDData(apiData) {
    const counts = {};
    const colorMap = {};
    for (const mbidData of apiData) {
      if (mbidData.tracks) {
        for (const track of mbidData.tracks) {
          counts[track.id] = (counts[track.id] || 0) + 1;
        }
      }
    }
    for (const acoustID in counts) {
      if (counts[acoustID] > 1) {
        colorMap[acoustID] = getRandomColor();
      }
    }
    return { colorMap };
  }

  /** Creates a DOM fragment of AcoustID links for a single recording. */
  function createAcoustIDFragment(tracks, colorMap) {
    const fragment = document.createDocumentFragment();
    tracks.sort((a, b) => a.id.localeCompare(b.id));

    tracks.forEach((track, index) => {
      const link = document.createElement('a');
      link.href = `//acoustid.org/track/${track.id}`;
      const code = document.createElement('code');
      code.textContent = track.id;
      link.appendChild(code);

      const color = colorMap[track.id];
      if (color) link.style.backgroundColor = color;

      if (addShowHideButton && index >= alwaysShowIds) {
        link.classList.add('hidelist');
      }

      fragment.appendChild(link);
      fragment.appendChild(document.createElement('br'));
    });

    if (addShowHideButton && tracks.length > alwaysShowIds) {
      const hiddenCount = tracks.length - alwaysShowIds;
      const toggleButton = document.createElement('div');
      toggleButton.className = 'showids allids';
      const span = document.createElement('span');
      span.textContent = `show all (+${hiddenCount})`;
      toggleButton.appendChild(span);
      fragment.appendChild(toggleButton);
    }
    return fragment;
  }

  /** Injects AcoustID icons on artist recording and release pages. */
  async function updateArtistRecordingsPage() {
    const recordingCells = document.querySelectorAll('.tbl tr td + td:not(.video)');
    const mbids = [...recordingCells]
      .map((cell) => extractRecordingMBID(cell.querySelector('a')))
      .filter(Boolean);
    if (mbids.length === 0) return;

    try {
      const acoustidData = await fetchAcoustIDData(mbids);
      const dataByMBID = new Map(acoustidData.map((d) => [d.mbid, d]));

      recordingCells.forEach((cell) => {
        const mbid = extractRecordingMBID(cell.querySelector('a'));
        const mbidData = dataByMBID.get(mbid);
        if (mbidData?.tracks?.length > 0) {
          mbidData.tracks.forEach((track) => {
            const link = document.createElement('a');
            link.href = `//acoustid.org/track/${track.id}`;

            const img = document.createElement('img');
            img.src = '//acoustid.org/static/acoustid-wave-12.png';
            img.title = track.id;
            img.alt = 'AcoustID';
            img.className = 'acoustid-icon';
            link.appendChild(img);

            cell.querySelector('a:first-of-type').after(link);
          });
        }
      });
    } catch (error) {
      console.error('AcoustID Script Error (Artist Page):', error);
    }
  }

  /** Injects a new table column with AcoustIDs on merge edit pages. */
  async function updateMergeOrEdits() {
    const allMBIDs = [
      ...document.querySelectorAll(
        '.details.merge-recordings .tbl a[href*="/recording/"]'
      ),
    ]
      .map(extractRecordingMBID)
      .filter(Boolean);

    if (allMBIDs.length === 0) return;

    try {
      const acoustidData = await fetchAcoustIDData(allMBIDs);
      const { colorMap } = processAcoustIDData(acoustidData);
      const dataByMBID = new Map(acoustidData.map((d) => [d.mbid, d]));

      document.querySelectorAll('.details.merge-recordings table.tbl').forEach((table) => {
        const header = table.querySelector('thead tr th:nth-child(2)');
        const dataCells = table.querySelectorAll('tbody tr td:nth-child(1)');

        if (!header || dataCells.length === 0) return;

        const newHeader = document.createElement('th');
        newHeader.textContent = 'AcoustIDs';
        header.insertAdjacentElement('afterend', newHeader);

        dataCells.forEach((cell) => {
          const mbid = extractRecordingMBID(cell.querySelector('a'));
          const mbidData = dataByMBID.get(mbid);
          const acoustidCell = document.createElement('td');
          if (mbidData?.tracks?.length > 0) {
            acoustidCell.appendChild(createAcoustIDFragment(mbidData.tracks, colorMap));
          }
          cell.nextElementSibling.insertAdjacentElement('afterend', acoustidCell);
        });
      });
    } catch (error) {
      console.error('AcoustID Script Error (Merge Page):', error);
    }
  }

  // -- UI EVENT HANDLING --

  /** Sets up a single delegated event listener for all show/hide buttons. */
  function setupShowHideListener() {
    if (document.body.dataset.showhideListenerAttached) return;
    document.body.dataset.showhideListenerAttached = 'true';

    document.body.addEventListener('click', (event) => {
      const buttonContainer = event.target.closest('.showids');
      if (!buttonContainer) return;

      const parent = buttonContainer.parentElement;
      const hiddenItems = parent.querySelectorAll('.hidelist');

      if (buttonContainer.classList.contains('allids')) {
        buttonContainer.classList.replace('allids', 'lessids');
        buttonContainer.querySelector('span').textContent = 'show less';
        hiddenItems.forEach((el) => {
          el.style.display = 'inline';
          const br = el.nextElementSibling;
          if (br?.tagName === 'BR') br.style.display = 'inline';
        });
      } else {
        buttonContainer.classList.replace('lessids', 'allids');
        buttonContainer.querySelector('span').textContent = `show all (+${hiddenItems.length})`;
        hiddenItems.forEach((el) => {
          el.style.display = 'none';
          const br = el.nextElementSibling;
          if (br?.tagName === 'BR') br.style.display = 'none';
        });
      }
    });
  }

  // -- MAIN ROUTER --

  /** Determines which script functions to run based on the page URL. */
  function main() {
    const path = window.location.href;
    if (
      enableMiniIcons &&
      (path.includes('/recordings') || path.includes('/release/'))
    ) {
      updateArtistRecordingsPage();
    } else if (enableAcoustList && (path.includes('/edit') || path.includes('/votes'))) {
      updateMergeOrEdits();
      setupShowHideListener();
    }
  }

  main();
})();