Import Discogs Credits

User interface for importing Discogs release credits to MusicBrainz relationships

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Import Discogs Credits
// @namespace    majkinetor
// @version      2026.5.29.091021
// @description  User interface for importing Discogs release credits to MusicBrainz relationships
// @author       majkinetor
// @match        https://musicbrainz.org/release/*/edit-relationships
// @match        https://musicbrainz.org/artist/*
// @match        https://musicbrainz.org/label/*
// @match        https://musicbrainz.org/place/*
// @match        https://beta.musicbrainz.org/release/*/edit-relationships
// @match        https://beta.musicbrainz.org/artist/*
// @match        https://beta.musicbrainz.org/label/*
// @match        https://beta.musicbrainz.org/place/*
// @license      MIT
// @homepageURL  https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/discogs_credits/README.md
// @supportURL   https://github.com/majkinetor/musicbrainz-userscripts/issues
// @grant        unsafeWindow
// ==/UserScript==

(() => {
  // src/constants.js
  var REL_TEMPLATE = {
    _lineage: [],
    _original: null,
    _status: 1,
    attributes: null,
    begin_date: null,
    editsPending: false,
    end_date: null,
    ended: false,
    entity0_credit: "",
    entity1_credit: "",
    id: null,
    linkOrder: 0,
    linkTypeID: null
  };
  var SELECTORS = {
    MediumsInput: ".multiselect-input",
    MediumsInputOptions: ".multiselect-input + .menu a",
    InstrumentsInput: "#add-relationship-dialog .multiselect.instrument input[aria-autocomplete]",
    VocalsTypeInput: "#add-relationship-dialog .multiselect.vocal input[aria-autocomplete]",
    AddRelationshipsDialogEntityType: "#add-relationship-dialog .entity-type",
    AddRelationshipsDialogRelationshipType: "#add-relationship-dialog input.relationship-type",
    AddRelationshipsDialogRelationshipTarget: "#add-relationship-dialog input.relationship-target",
    AddRelationshipsDialogEntityCredit: "#add-relationship-dialog input.entity-credit",
    AddRelationshipsDialogDoneButton: "#add-relationship-dialog .buttons button.positive",
    AddRelationshipsDialogError: "#add-relationship-dialog .error",
    AddRelationshipsDialogCancelButton: "#add-relationship-dialog .buttons button.negative",
    AddReleaseRelationshipButton: "#release-rels button.add-relationship",
    EditNote: "#edit-note-text",
    TaskInput: "#add-relationship-dialog .attribute-container.task input"
  };
  var DISCOGS_LOGO_URL = "https://volkerzell.de/favicons/discogs.png";
  var EQUIVALENCE_SETS = [
    ["writer", "composer"]
  ];
  var DISCOGS_CHANNEL = new BroadcastChannel("discogs-importer-artist");
  var pageWindow = typeof unsafeWindow !== "undefined" ? unsafeWindow : window;

  // src/log.js
  var _logs = null;
  function setLogContainer(el) {
    _logs = el;
  }
  function getLogContainer() {
    return _logs;
  }
  function _emit(html, plainText) {
    if (!_logs) return;
    const li = document.createElement("li");
    const d = /* @__PURE__ */ new Date();
    const pad = (n) => String(n).padStart(2, "0");
    const stamp = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
    li.innerHTML = `<span style="color:#999;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:0.82em;">${stamp}</span> ${html}`;
    _logs.insertAdjacentElement("beforeend", li);
    const bar = document.querySelector(".discogs-bar");
    if (bar?._setProgress) {
      bar._setProgress(null, plainText.replace(/<[^>]*>/g, "").trim().substring(0, 120));
    }
  }
  var log = {
    info: (msg) => _emit(msg, msg),
    warn: (msg) => _emit(`<span style="color:orange">WARN ${msg}</span>`, `WARN ${msg}`),
    error: (msg) => _emit(`<span style="color:red">ERR ${msg}</span>`, `ERR ${msg}`)
  };
  var _debugUl = null;
  var _debugStartT = null;
  function _ensureDebugUl() {
    if (_debugUl) return _debugUl;
    if (!_logs) return null;
    const details = document.createElement("details");
    details.style.cssText = "margin:0.3rem 0;";
    const summary = document.createElement("summary");
    summary.textContent = "Preflight diagnostics";
    summary.style.cssText = "cursor:pointer;font-size:0.8rem;color:#888;user-select:none;";
    details.appendChild(summary);
    const ul = document.createElement("ul");
    ul.style.cssText = "list-style:none;margin:0.3rem 0;padding:0.4rem 0.6rem;background:#f7f7f7;border-radius:0.25rem;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:0.72rem;color:#444;max-height:24rem;overflow-y:auto;";
    details.appendChild(ul);
    const li = document.createElement("li");
    li.style.listStyle = "none";
    li.appendChild(details);
    _logs.appendChild(li);
    _debugUl = ul;
    _debugStartT = performance.now();
    return _debugUl;
  }
  function logDebug(line) {
    const ul = _ensureDebugUl();
    if (!ul) return;
    const t = Math.round(performance.now() - _debugStartT);
    const row = document.createElement("div");
    row.textContent = `[+${t}ms] ${line}`;
    ul.appendChild(row);
  }

  // src/api-mb.js
  async function fetchMBEntity(mbid) {
    const res = await fetch(`/ws/js/entity/${mbid}`);
    if (!res.ok) throw new Error(`/ws/js/entity/${mbid} \u2192 ${res.status}`);
    return res.json();
  }
  var mbThrottle = /* @__PURE__ */ (() => {
    const MAX_CONCURRENT = 4;
    let _running = 0;
    let _pauseUntil = 0;
    const _queue = [];
    let _totalRequests = 0;
    let _rateLimited = 0;
    async function _waitForPause() {
      let wait = _pauseUntil - Date.now();
      if (wait <= 0) return;
      logDebug(`throttle: waiting ${wait}ms for shared pause`);
      while ((wait = _pauseUntil - Date.now()) > 0) {
        await new Promise((r) => setTimeout(r, wait));
      }
    }
    function _drain() {
      while (_running < MAX_CONCURRENT && _queue.length > 0) {
        _running++;
        const item = _queue.shift();
        _run(item).finally(() => {
          _running--;
          _drain();
        });
      }
    }
    let _diagReqSeq = 0;
    const REQUEST_TIMEOUT_MS = 1e4;
    async function _run(item) {
      const tag = `req#${++_diagReqSeq}`;
      const shortUrl = item.url.replace("//musicbrainz.org", "").replace(/^https:/, "");
      for (let attempt = 0; attempt <= item.retries; attempt++) {
        await _waitForPause();
        _totalRequests++;
        const attemptTag = attempt === 0 ? "" : ` (retry ${attempt})`;
        logDebug(`${tag} [running=${_running} queued=${_queue.length}] GET ${shortUrl}${attemptTag}`);
        const t0 = Date.now();
        const ctrl = new AbortController();
        const timer = setTimeout(() => ctrl.abort(), REQUEST_TIMEOUT_MS);
        try {
          const res = await fetch(item.url, { signal: ctrl.signal });
          const elapsed = Date.now() - t0;
          if (res.status === 429 || res.status === 503) {
            _rateLimited++;
            const ra = parseInt(res.headers.get("Retry-After"), 10);
            const waitMs = ra > 0 ? ra * 1e3 : Math.min(1e3 * Math.pow(2, attempt), 3e4);
            _pauseUntil = Math.max(_pauseUntil, Date.now() + waitMs);
            logDebug(`${tag} <- ${res.status} in ${elapsed}ms; shared pause pushed to +${waitMs}ms`);
            continue;
          }
          if (!res.ok) {
            logDebug(`${tag} <- ${res.status} (give up) in ${elapsed}ms`);
            item.resolve(null);
            return;
          }
          const data = item.wantJson ? await res.json() : res;
          logDebug(`${tag} <- ${res.status} in ${elapsed}ms`);
          item.resolve(data);
          return;
        } catch (e) {
          const elapsed = Date.now() - t0;
          const isTimeout = e?.name === "AbortError";
          const reason = isTimeout ? `timed out after ${REQUEST_TIMEOUT_MS}ms` : `${e?.message || e}`;
          if (isTimeout) {
            _rateLimited++;
            const waitMs = Math.min(1e3 * Math.pow(2, attempt), 8e3);
            _pauseUntil = Math.max(_pauseUntil, Date.now() + waitMs);
            logDebug(`${tag} threw in ${elapsed}ms: ${reason}; shared pause pushed to +${waitMs}ms`);
          } else {
            logDebug(`${tag} threw in ${elapsed}ms: ${reason}`);
          }
          if (attempt === item.retries) {
            item.resolve(null);
            return;
          }
          await new Promise((r) => setTimeout(r, 500));
        } finally {
          clearTimeout(timer);
        }
      }
      item.resolve(null);
    }
    function _enqueue(url, retries, wantJson) {
      return new Promise((resolve) => {
        _queue.push({ url, retries, wantJson, resolve });
        _drain();
      });
    }
    return {
      fetchJson: (url, retries = 3) => _enqueue(url, retries, true),
      fetchRaw: (url, retries = 3) => _enqueue(url, retries, false),
      stats: () => ({
        total: _totalRequests,
        rateLimited: _rateLimited,
        inFlight: _running,
        queued: _queue.length
      })
    };
  })();
  async function fetchWithRetry(url, retries = 4) {
    return mbThrottle.fetchJson(url, retries);
  }
  function getDiscogsUrlForRelease(mbid) {
    const url = `/ws/js/release/${mbid}?fmt=json&inc=rels`;
    return fetch(url).then((body) => body.json()).then((json) => {
      const matchingRel = (json.relationships || []).find((rel) => {
        return rel.target?.sidebar_name === "Discogs";
      });
      return matchingRel?.target?.href_url || null;
    });
  }
  function resolveLinkTypeId(name, type0, type1) {
    const lt = pageWindow.MB?.linkedEntities?.link_type;
    if (!lt) {
      log.error("MB.linkedEntities.link_type not available");
      return null;
    }
    const needle = name.toLowerCase().trim();
    const stripAttrs = (s) => (s || "").toLowerCase().replace(/\{[^}]*\}/g, "").replace(/\s+/g, " ").trim();
    const candidates = Object.values(lt).filter(
      (v) => v.type0 === type0 && v.type1 === type1 && !v.deprecated
    );
    for (const v of candidates) {
      if ((v.name || "").toLowerCase() === needle) return v.id;
    }
    for (const v of candidates) {
      if (stripAttrs(v.link_phrase) === needle) return v.id;
      if (stripAttrs(v.reverse_link_phrase) === needle) return v.id;
    }
    const contains = candidates.filter((v) => {
      const blobs = [v.name, stripAttrs(v.link_phrase), stripAttrs(v.reverse_link_phrase)].filter(Boolean);
      return blobs.some((b) => b.includes(needle) || needle.includes(b));
    });
    if (contains.length > 0) {
      contains.sort((a, b) => ((a.name || "").length || 999) - ((b.name || "").length || 999));
      const best = contains[0];
      if ((best.name || "").toLowerCase() !== needle) {
        log.info(`Fuzzy match: "${name}" \u2192 "${best.name}" (${type0}\u2192${type1})`);
      }
      return best.id;
    }
    const availableNames = candidates.map((v) => v.name).filter(Boolean).sort().join(", ");
    const allByName = Object.values(lt).filter(
      (v) => (v.name || "").toLowerCase() === needle || stripAttrs(v.link_phrase) === needle || stripAttrs(v.reverse_link_phrase) === needle
    );
    const deprecatedHit = allByName.find((v) => v.type0 === type0 && v.type1 === type1 && v.deprecated);
    const wrongPairHits = allByName.filter((v) => !(v.type0 === type0 && v.type1 === type1));
    if (deprecatedHit) {
      const altPairs = [...new Set(allByName.filter((v) => !v.deprecated).map((v) => `${v.type0}\u2192${v.type1}`))].join(", ");
      log.error(`"${name}" (${type0}\u2192${type1}) is deprecated by MB and would block the commit \u2014 skipping${altPairs ? `. Valid alternative(s): ${altPairs}` : ""}.`);
    } else if (wrongPairHits.length > 0) {
      const hitDesc = wrongPairHits.map((v) => `${v.name}(${v.type0}\u2192${v.type1})`).join(", ");
      log.warn(`No "${name}" link type for (${type0}\u2192${type1}) \u2014 exists for other entity pairs: ${hitDesc} \u2014 skipping`);
    } else {
      log.warn(`Unknown link type "${name}" (${type0}\u2192${type1}). Available for this pair: ${availableNames || "none"}`);
    }
    return null;
  }

  // src/storage.js
  var DB_NAME = "mblink";
  var DB_VERSION = 2;
  var STORE = "entity_cache";
  var db = null;
  var _request = indexedDB.open(DB_NAME, DB_VERSION);
  _request.onerror = function() {
    console.error("Why didn't you allow my web app to use IndexedDB?!");
  };
  _request.onsuccess = function(event) {
    db = event.target.result;
  };
  _request.onupgradeneeded = function(event) {
    const upgradeDb = event.target.result;
    if (!upgradeDb.objectStoreNames.contains(STORE)) {
      upgradeDb.createObjectStore(STORE, { keyPath: "discogs_id" });
    }
  };
  function mbUrlOf(entityType, mbid) {
    return `//musicbrainz.org/${entityType}/${mbid}`;
  }
  function readIdbRecord(key) {
    return new Promise((resolve) => {
      if (!key || !db) return resolve(null);
      try {
        const tx = db.transaction([STORE], "readonly");
        const req = tx.objectStore(STORE).get(key);
        req.onsuccess = () => resolve(req.result || null);
        req.onerror = () => resolve(null);
      } catch (e) {
        resolve(null);
      }
    });
  }
  function writeIdbRecord(key, partial) {
    return new Promise((resolve) => {
      if (!key || !db) return resolve(null);
      try {
        const tx = db.transaction([STORE], "readwrite");
        const store = tx.objectStore(STORE);
        const getReq = store.get(key);
        getReq.onsuccess = () => {
          const existing = getReq.result || {};
          const merged = { ...existing, ...partial, discogs_id: key, resolvedAt: (/* @__PURE__ */ new Date()).toISOString() };
          if (merged.mbid && merged.entityType && !partial.mbUrl) {
            merged.mbUrl = mbUrlOf(merged.entityType, merged.mbid);
          }
          const putReq = store.put(merged);
          putReq.onsuccess = () => resolve(merged);
          putReq.onerror = () => resolve(null);
        };
        getReq.onerror = () => resolve(null);
      } catch (e) {
        resolve(null);
      }
    });
  }
  function deleteIdbRecord(key) {
    return new Promise((resolve) => {
      if (!key || !db) return resolve(false);
      try {
        const tx = db.transaction([STORE], "readwrite");
        const store = tx.objectStore(STORE);
        const req = store.delete(key);
        req.onsuccess = () => resolve(true);
        req.onerror = () => resolve(false);
      } catch (e) {
        resolve(false);
      }
    });
  }

  // src/progress-bar.js
  var _pInterval = null;
  var _pPos = -40;
  function _showBar() {
    const row1 = document.querySelector(".discogs-bar-row1");
    const row2 = document.querySelector(".discogs-bar-row2");
    const r1h = row1 ? row1.getBoundingClientRect().height : 42;
    let pb = document.getElementById("discogs-pb");
    if (!pb) {
      pb = document.createElement("div");
      pb.id = "discogs-pb";
      pb.style.cssText = "position:fixed;left:0;right:0;height:5px;z-index:99999;background:#ddd;overflow:hidden;";
      const fill = document.createElement("div");
      fill.id = "discogs-pb-fill";
      fill.style.cssText = "position:absolute;top:0;height:100%;width:40%;background:#e8771d;transition:width 0.2s linear;";
      pb.appendChild(fill);
      document.body.appendChild(pb);
    }
    pb.style.top = r1h + "px";
    pb.style.display = "block";
    if (row2) row2.style.marginTop = r1h + 5 + "px";
    _startMarquee();
  }
  function _startMarquee() {
    clearInterval(_pInterval);
    const fill = document.getElementById("discogs-pb-fill");
    if (fill) {
      fill.style.width = "40%";
      fill.style.left = _pPos + "%";
      fill.style.transition = "";
    }
    _pPos = -40;
    _pInterval = setInterval(() => {
      _pPos += 1.5;
      if (_pPos > 100) _pPos = -40;
      const f = document.getElementById("discogs-pb-fill");
      if (f) f.style.left = _pPos + "%";
    }, 16);
  }
  function _setProgressPct(pct) {
    const p = Math.max(0, Math.min(100, Number(pct) || 0));
    clearInterval(_pInterval);
    _pInterval = null;
    const fill = document.getElementById("discogs-pb-fill");
    if (!fill) return;
    fill.style.left = "0";
    fill.style.transition = "width 0.2s linear";
    fill.style.width = p + "%";
  }
  function _hideBar() {
    clearInterval(_pInterval);
    _pInterval = null;
    const pb = document.getElementById("discogs-pb");
    if (pb) pb.style.display = "none";
    const row2 = document.querySelector(".discogs-bar-row2");
    if (row2) row2.style.marginTop = "";
  }

  // src/api-discogs.js
  var DISCOGS_URL_RE = /^https?:\/\/(?:www|api)\.discogs\.com\/(?:(?:(?!sell).+|sell.+)\/)?(master|release|artist|label)s?\/(\d+)(?:[^?#]*)(?:\?noanv=1|\?anv=[^=]+)?$/i;
  function parseDiscogsUrl(url) {
    const m = DISCOGS_URL_RE.exec(url);
    if (!m) return null;
    const type = m[1];
    const id = m[2];
    return {
      type,
      id,
      key: `${type}/${id}`,
      cleanUrl: `https://www.discogs.com/${type}/${id}`
    };
  }
  var _releaseDataCache = /* @__PURE__ */ new Map();
  function getDiscogsReleaseData(url) {
    if (_releaseDataCache.has(url)) return Promise.resolve(_releaseDataCache.get(url));
    return fetch(
      `${url.replace(
        "https://www.discogs.com/release/",
        "https://api.discogs.com/releases/"
      )}?token=gYAnSAmIoXiHezHBmHoqcBCuJRyQLJBYSjurbGTZ`
    ).then((body) => body.json()).then((json) => {
      _releaseDataCache.set(url, json);
      return json;
    });
  }
  var _entityDataCache = /* @__PURE__ */ new Map();
  function getDiscogsEntityData(resourceUrl) {
    if (!resourceUrl) return Promise.resolve(null);
    if (_entityDataCache.has(resourceUrl)) return Promise.resolve(_entityDataCache.get(resourceUrl));
    return fetch(`${resourceUrl}?token=gYAnSAmIoXiHezHBmHoqcBCuJRyQLJBYSjurbGTZ`).then((r) => r.ok ? r.json() : null).then((json) => {
      if (!json) {
        _entityDataCache.set(resourceUrl, null);
        return null;
      }
      const slim = {
        profile: json.profile || "",
        name: json.name || "",
        namevariations: json.namevariations || [],
        realname: json.realname || ""
      };
      _entityDataCache.set(resourceUrl, slim);
      return slim;
    }).catch(() => null);
  }

  // src/data/entity-map.js
  var ENTITY_TYPE_MAP = {
    // Places
    "Arranged At": {
      entityType: "place",
      linkType: "arranged at"
    },
    "Engineered At": {
      entityType: "place",
      linkType: "engineered at"
    },
    "Recorded At": {
      entityType: "place",
      linkType: "recorded at"
    },
    "Mixed At": {
      entityType: "place",
      linkType: "mixed at"
    },
    "Mastered At": {
      entityType: "place",
      linkType: "mastered at"
    },
    "Lacquer Cut At": {
      entityType: "place",
      linkType: "lacquer cut at"
    },
    "edited At": {
      entityType: "place",
      linkType: "edited at"
    },
    "Remixed At": {
      entityType: "place",
      linkType: "remixed at"
    },
    "Produced At": {
      entityType: "place",
      linkType: "produced at"
    },
    "Overdubbed At": null,
    "manufactured At": {
      entityType: "place",
      linkType: "manufactured at"
    },
    "Glass Mastered At": {
      entityType: "place",
      linkType: "glass mastered at"
    },
    "Pressed At": {
      entityType: "place",
      linkType: "pressed at"
    },
    "Designed At": null,
    "Filmed At": null,
    "Exclusive Retailer": null,
    // labels
    "Copyright (c)": {
      entityType: "label",
      linkType: "copyright"
    },
    "Phonographic Copyright (p)": {
      entityType: "label",
      linkType: "phonographic copyright"
    },
    "Copyright \xA9": {
      entityType: "label",
      linkType: "copyright"
    },
    "Phonographic Copyright \u2117": {
      entityType: "label",
      linkType: "phonographic copyright"
    },
    "Licensed From": {
      entityType: "label",
      linkType: "licensor"
    },
    "Licensed To": {
      entityType: "label",
      linkType: "licensee"
    },
    "Licensed Through": null,
    "Distributed By": {
      entityType: "label",
      linkType: "distributed"
    },
    "Made By": {
      entityType: "label",
      linkType: "manufactured"
    },
    "Manufactured By": {
      entityType: "label",
      linkType: "manufactured"
    },
    "Glass Mastered By": {
      entityType: "label",
      linkType: "glass mastered"
    },
    "Pressed By": {
      entityType: "label",
      linkType: "pressed"
    },
    "Marketed By": {
      entityType: "label",
      linkType: "marketed"
    },
    "Printed By": {
      entityType: "label",
      linkType: "printed"
    },
    "Promoted By": {
      entityType: "label",
      linkType: "promoted"
    },
    "Published By": {
      entityType: "label",
      linkType: "published"
    },
    "Rights Society": {
      entityType: "label",
      linkType: "rights society"
    },
    "Arranged For": {
      entityType: "label",
      linkType: "arranged for"
    },
    "Manufactured For": {
      entityType: "label",
      linkType: "manufactured for"
    },
    "Mixed For": {
      entityType: "label",
      linkType: "mixed for"
    },
    "Produced For": {
      entityType: "label",
      linkType: "produced for"
    },
    "Miscellaneous Support": {
      entityType: "label",
      linkType: "misc"
    },
    "Exported By": null,
    // Artists
    Performer: {
      entityType: "artist",
      linkType: "performer"
    },
    // Discogs role "Accompanied By" → MB has no dedicated link type or
    // attribute for this. Closest semantic fit is `performer` with the
    // `additional` attribute (used elsewhere in MB for non-primary
    // contributions). Previously this fell through to the INSTRUMENTS map
    // and was dispatched as a bare `instrument` rel with the bogus
    // attribute value "accompanied by" — which MB silently drops, leaving
    // a junk instrument rel with no instrument named.
    "Accompanied By": {
      entityType: "artist",
      linkType: "performer",
      attributes: ["additional"]
    },
    Instruments: {
      entityType: "artist",
      linkType: "instrument"
    },
    Vocals: {
      entityType: "artist",
      linkType: "vocal"
    },
    "Backing Vocals": {
      entityType: "artist",
      linkType: "vocal",
      attributes: [{ _type: "vocal", value: "background vocals" }]
    },
    Choir: {
      entityType: "artist",
      linkType: "vocal",
      attributes: [{ _type: "vocal", value: "choir vocals" }]
    },
    Chorus: {
      entityType: "artist",
      linkType: "vocal",
      attributes: [{ _type: "vocal", value: "choir vocals" }]
    },
    "Choir Vocals": {
      entityType: "artist",
      linkType: "vocal",
      attributes: [{ _type: "vocal", value: "choir vocals" }]
    },
    "Lead Vocals": {
      entityType: "artist",
      linkType: "vocal",
      attributes: [{ _type: "vocal", value: "lead vocals" }]
    },
    Orchestra: {
      entityType: "artist",
      linkType: "orchestra"
    },
    Conductor: {
      entityType: "artist",
      linkType: "conductor"
    },
    "Chorus Master": {
      entityType: "artist",
      linkType: "chorus master"
    },
    Concertmaster: {
      entityType: "artist",
      linkType: "concertmaster"
    },
    Concertmistress: {
      entityType: "artist",
      linkType: "concertmaster"
    },
    "Compiled By": {
      entityType: "artist",
      linkType: "compiler"
    },
    "DJ Mix": {
      entityType: "artist",
      linkType: "DJ-mixer"
    },
    Remix: {
      entityType: "artist",
      linkType: "remixer"
    },
    "contains samples by": {
      entityType: "artist",
      linkType: "contains samples by"
    },
    "Written-By": {
      entityType: "artist",
      linkType: "writer"
    },
    "Written By": {
      entityType: "artist",
      linkType: "writer"
    },
    "Composed By": {
      entityType: "artist",
      linkType: "composer"
    },
    "Words By": {
      entityType: "artist",
      linkType: "lyricist"
    },
    "Lyrics By": {
      entityType: "artist",
      linkType: "lyricist"
    },
    "Libretto By": {
      entityType: "artist",
      linkType: "librettist"
    },
    "Translated By": {
      entityType: "artist",
      linkType: "translator"
    },
    "Arranged By": {
      entityType: "artist",
      linkType: "arranger"
    },
    "Instrumentation By": {
      entityType: "artist",
      linkType: "instruments arranger"
    },
    "Orchestrated By": {
      entityType: "artist",
      linkType: "orchestrator"
    },
    "vocals arranger": {
      entityType: "artist",
      linkType: "vocals arranger"
    },
    Producer: {
      entityType: "artist",
      linkType: "producer"
    },
    "Co-producer": {
      entityType: "artist",
      linkType: "producer",
      // MB has no `co` attribute. The convention for "Co-X" is `additional`
      // on the base role (issue #3). See also the matching remap in
      // `getArtistRoles` for the regex /Co /.
      attributes: ["additional"]
    },
    "Executive-Producer": {
      entityType: "artist",
      linkType: "producer",
      attributes: ["executive"]
    },
    "Post Production": {
      entityType: "artist",
      linkType: "producer"
    },
    Engineer: {
      entityType: "artist",
      linkType: "engineer"
    },
    "Audio Engineer": {
      entityType: "artist",
      linkType: "audio engineer"
    },
    "Mastered By": {
      entityType: "artist",
      linkType: "mastering"
    },
    "Remastered By": {
      entityType: "artist",
      linkType: "mastering",
      attributes: ["re"]
    },
    "Lacquer Cut By": {
      entityType: "artist",
      linkType: "lacquer cut"
    },
    "sound engineer": {
      entityType: "artist",
      linkType: "sound engineer"
    },
    "Mixed By": {
      entityType: "artist",
      linkType: "mix"
    },
    "Recorded By": {
      entityType: "artist",
      linkType: "recording"
    },
    "Recording Engineer": {
      entityType: "artist",
      linkType: "recording"
    },
    "Programmed By": {
      entityType: "artist",
      linkType: "programming"
    },
    Editor: {
      entityType: "artist",
      linkType: "editor"
    },
    "Edited By": {
      entityType: "artist",
      linkType: "editor"
    },
    "balance engineer": {
      entityType: "artist",
      linkType: "engineer"
    },
    "copyrighted by": {
      entityType: "artist",
      linkType: "copyright"
    },
    "phonographic copyright by": {
      entityType: "artist",
      linkType: "phonographic copyright"
    },
    Legal: {
      entityType: "artist",
      linkType: "legal representation"
    },
    Booking: {
      entityType: "artist",
      linkType: "booking"
    },
    "Art Direction": {
      entityType: "artist",
      linkType: "art direction"
    },
    Artwork: {
      entityType: "artist",
      linkType: "artwork"
    },
    "Artwork By": {
      entityType: "artist",
      linkType: "artwork"
    },
    Cover: {
      entityType: "artist",
      linkType: "artwork"
    },
    Design: {
      entityType: "artist",
      linkType: "design"
    },
    "Graphic Design": {
      entityType: "artist",
      linkType: "graphic design"
    },
    Illustration: {
      entityType: "artist",
      linkType: "illustration"
    },
    "Booklet Editor": {
      entityType: "artist",
      linkType: "booklet editor"
    },
    Photography: {
      entityType: "artist",
      linkType: "photography"
    },
    "Photography By": {
      entityType: "artist",
      linkType: "photography"
    },
    Technician: {
      entityType: "artist",
      linkType: "instruments technician"
    },
    publisher: {
      entityType: "artist",
      linkType: "published"
    },
    "Liner Notes": {
      entityType: "artist",
      linkType: "liner notes"
    },
    "A&R": {
      entityType: "artist",
      linkType: "misc"
    },
    Advisor: {
      entityType: "artist",
      linkType: "misc"
    },
    "Concept By": {
      entityType: "artist",
      linkType: "misc"
    },
    Contractor: {
      entityType: "artist",
      linkType: "misc"
    },
    Coordinator: {
      entityType: "artist",
      linkType: "misc"
    },
    Management: {
      entityType: "artist",
      linkType: "misc"
    },
    "Musical Assistance": {
      entityType: "artist",
      linkType: "misc"
    },
    "Tour Manager": {
      entityType: "artist",
      linkType: "misc"
    },
    Other: {
      entityType: "artist",
      linkType: "misc"
    },
    "Public Relations": {
      entityType: "artist",
      linkType: "misc"
    },
    Promotion: {
      entityType: "artist",
      linkType: "misc"
    },
    Crew: {
      entityType: "artist",
      linkType: "misc"
    },
    "Supervised By": {
      entityType: "artist",
      linkType: "misc"
    },
    "Director Of Photography": {
      entityType: "artist",
      linkType: "photography",
      attributes: [{ _type: "task", value: "director of photography" }]
    }
  };

  // src/data/instruments.js
  var INSTRUMENTS = {
    Afox\u00E9: null,
    Agog\u00F4: null,
    Ashiko: null,
    Atabal: null,
    Bapang: null,
    "Clarinet": "clarinet",
    "Percussion": "percussion",
    "Congas": "congas",
    "Conga": "congas",
    "Conga Drum": "congas",
    "Bongos": "bongos",
    "Bongo": "bongos",
    "Tambourine": "tambourine",
    "Cuica": "cu\xEDca",
    "Guiro": "g\xFCiro",
    "G\xFCiro": "g\xFCiro",
    "Udu": "udu",
    "La\xFAd": "la\xFAd",
    "Laud": "la\xFAd",
    "Mbira": "mbira",
    "Kalimba": "mbira",
    "Slide Guitar": "slide guitar",
    "Acoustic Guitar": "acoustic guitar",
    "Double Bass": "double bass",
    "Goblet Drum": "goblet drum",
    "Maracas": "maracas",
    "Steel Drum": "steelpan",
    "Steelpan": "steelpan",
    "Electronics": "electronics",
    "Electronic": "electronics",
    "Synth Bass": "bass synthesizer",
    "Vocoder": "vocoder",
    "Xylophone": "xylophone",
    "Bells": "bells",
    "Chimes": "wind chimes",
    "Glockenspiel": "glockenspiel",
    "Shakers": "shaker",
    "Shaker": "shaker",
    "Cowbell": "cowbell",
    "Claves": "claves",
    "Timbales": "timbales",
    "Baritone Guitar": "baritone guitar",
    "Synth": "synthesizer",
    "Synthesizer": "synthesizer",
    "Synths": "synthesizer",
    "Keyboards": "keyboard",
    "Keyboard": "keyboard",
    "Organ": "organ",
    "Harmonium": "harmonium",
    "Piano": "piano",
    "Electric Piano": "electric piano",
    "Harpsichord": "harpsichord",
    "Celesta": "celesta",
    "Strings": "string instruments",
    "Flute": "flute",
    "Saxophone": "saxophone",
    "Vibraphone": "vibraphone",
    "Trumpet": "trumpet",
    "Trombone": "trombone",
    "Violin": "violin",
    "Cello": "cello",
    "Viola": "viola",
    "Harp": "harp",
    "Banjo": "banjo",
    "Mandolin": "mandolin",
    "Ukulele": "ukulele",
    "Harmonica": "harmonica",
    "Accordion": "accordion",
    "Oboe": "oboe",
    "Bassoon": "bassoon",
    "Tuba": "tuba",
    "French Horn": "French horn",
    "Marimba": "marimba",
    "Melodica": "melodica",
    "Sitar": "sitar",
    "Oud": "oud",
    "Kora": "kora",
    "Tabla": "tabla",
    "Didgeridoo": "didgeridoo",
    "Theremin": "theremin",
    "Flugelhorn": "flugelhorn",
    "Cornet": "cornet",
    "Alto Saxophone": "alto saxophone",
    "Tenor Saxophone": "tenor saxophone",
    "Soprano Saxophone": "soprano saxophone",
    "Baritone Saxophone": "baritone saxophone",
    "Bass Clarinet": "bass clarinet",
    "Bass Flute": "bass flute",
    "Piccolo": "piccolo",
    "Bass Drum": null,
    Bata: null,
    "Bell Tree": null,
    Bendir: null,
    Bodhr\u00E1n: null,
    "Body Percussion": null,
    Bombo: null,
    Bones: null,
    Buhay: null,
    Buk: null,
    Cabasa: null,
    Caixa: null,
    "Caja Vallenata": null,
    Caj\u00F3n: null,
    Calabash: null,
    Castanets: null,
    Caxixi: null,
    "Chak'chas": null,
    Chinch\u00EDn: null,
    Ching: null,
    Cymbal: null,
    Daf: null,
    Davul: null,
    Dhol: null,
    Dholak: null,
    Djembe: null,
    Doira: null,
    Doli: null,
    Drum: "drum set",
    "Drum Programming": null,
    Drums: "drum set",
    Dunun: null,
    "Electronic Drums": null,
    "Finger Cymbals": null,
    "Finger Snaps": null,
    "Frame Drum": null,
    "Friction Drum": null,
    Frottoir: null,
    Ganz\u00E1: null,
    Ghatam: null,
    Ghungroo: null,
    Gong: null,
    Guacharaca: null,
    Handbell: null,
    Handclaps: null,
    "Hang Drum": null,
    Hihat: null,
    Hosho: null,
    Hyoshigi: null,
    Idiophone: null,
    Jaggo: null,
    Janggu: null,
    Jing: null,
    "K'kwaengwari": null,
    Ka: null,
    "Kagura Suzu": null,
    Kanjira: null,
    Karkabas: null,
    Khartal: null,
    Khurdak: null,
    Kynggari: null,
    Lagerphone: null,
    "Lion's Roar": null,
    Madal: null,
    Mallets: null,
    "Monkey stick": null,
    Mridangam: null,
    Pakhavaj: null,
    Pandeiro: null,
    Rainstick: null,
    Ratchet: null,
    Rattle: "shaken idiophone",
    "Reco-reco": null,
    Repinique: null,
    Rototoms: null,
    Scraper: null,
    Shakubyoshi: null,
    Shekere: null,
    Shuitar: null,
    "Singing Bowls": null,
    Skratjie: null,
    Slapstick: null,
    "Slit Drum": null,
    Snare: null,
    Spoons: null,
    "Stomp Box": null,
    Surdo: null,
    Surigane: null,
    Taiko: null,
    "Talking Drum": null,
    "Tam-tam": null,
    Tambora: null,
    Tamboril: null,
    Tamborim: null,
    "Tan-Tan": null,
    "Tap Dance": null,
    "Tar (Drum)": null,
    "Temple Bells": null,
    "Temple Block": null,
    Thavil: null,
    Timpani: null,
    "Tom Tom": null,
    Triangle: null,
    T\u00FCng\u00FCr: null,
    Vibraslap: null,
    Washboard: null,
    Waterphone: null,
    "Wood Block": null,
    Zabumba: null,
    Amadinda: null,
    Angklung: null,
    Balafon: null,
    Boomwhacker: null,
    Carillon: null,
    Crotales: null,
    Guitaret: null,
    Lamellophone: null,
    Marimbula: null,
    Metallophone: null,
    "Musical Box": null,
    Prempensua: null,
    Slagbordun: null,
    "Steel Drums": "steelpan",
    "Thumb Piano": "mbira",
    Tubaphone: null,
    "Tubular Bells": null,
    Tun: null,
    Txalaparta: null,
    "Baby Grand Piano": null,
    Chamberlin: null,
    Claviorgan: null,
    "Concert Grand Piano": null,
    Dulcitone: null,
    "Electric Harmonium": null,
    "Electric Harpsichord": null,
    "Electric Organ": null,
    Fortepiano: null,
    "Grand Piano": null,
    Mellotron: null,
    Omnichord: null,
    "Ondes Martenot": null,
    "Parlour Grand Piano": null,
    Pedalboard: null,
    "Player Piano": null,
    Regal: null,
    Stylophone: null,
    "Tangent Piano": null,
    "Toy Piano": null,
    "Upright Piano": null,
    Virginal: null,
    "12-String Acoustic Guitar": null,
    "12-String Bass": null,
    "5-String Banjo": null,
    "6-String Banjo": null,
    "6-String Bass": null,
    "Acoustic Bass": null,
    "Arco Bass": null,
    Arpa: null,
    Autoharp: null,
    Baglama: null,
    "Bajo Quinto": null,
    "Bajo Sexto": null,
    Balalaika: null,
    Bandola: null,
    Bandura: null,
    Bandurria: null,
    Banhu: null,
    Banjolin: null,
    "Baroque Guitar": null,
    Baryton: null,
    "Bass Guitar": null,
    Berimbau: null,
    Bhapang: null,
    Biwa: null,
    "Blaster Beam": null,
    Bolon: null,
    Bouzouki: null,
    "Bulbul Tarang": null,
    Byzaanchi: null,
    Cavaquinho: null,
    "Cello Banjo": null,
    Changi: null,
    Chanzy: null,
    "Chapman Stick": null,
    Charango: null,
    Chitarrone: null,
    Chonguri: null,
    Chuniri: null,
    Cimbalom: null,
    Citole: null,
    Cittern: null,
    Cl\u00E0rsach: null,
    "Classical Guitar": null,
    Clavichord: null,
    Clavinet: null,
    Cobza: null,
    Contrabass: null,
    Cuatro: null,
    C\u00FCmb\u00FC\u015F: null,
    Cura: null,
    Deaejeng: null,
    "Diddley Bow": null,
    Dilruba: null,
    Dobro: null,
    Dojo: null,
    Dombra: null,
    Domra: null,
    Doshpuluur: null,
    Dulcimer: null,
    Dutar: null,
    "\u0110\xE0n b\u1EA7u": null,
    Ektare: null,
    "Electric Bass": null,
    "Electric Guitar": null,
    "Electric Upright Bass": null,
    "Electric Violin": null,
    "Epinette des Vosges": null,
    Erhu: null,
    Esraj: null,
    Fiddle: null,
    "Flamenco Guitar": null,
    "Fretless Bass": null,
    "Fretless Guitar": null,
    Gadulka: null,
    Gaohu: null,
    Gayageum: null,
    Geomungo: null,
    Giga: null,
    Gittern: null,
    Gottuv\u00E2dyam: null,
    Guimbri: null,
    Guitalele: null,
    Guitar: null,
    "Guitar Banjo": null,
    "Guitar Synthesizer": null,
    Guitarr\u00F3n: null,
    GuitarViol: null,
    Guqin: null,
    Gusli: null,
    Guzheng: null,
    Haegum: null,
    Halldorophone: null,
    Hardingfele: null,
    "Harp Guitar": null,
    Hummel: null,
    Huqin: null,
    "Hurdy Gurdy": null,
    Igil: null,
    Jarana: null,
    Jinghu: null,
    Jouhikko: null,
    Kabosy: null,
    Kamancha: null,
    Kankl\u0117s: null,
    Kantele: null,
    Kanun: null,
    Kemenche: null,
    Kirar: null,
    Kobyz: null,
    Kokyu: null,
    Koto: null,
    Krar: null,
    Langeleik: null,
    Laouto: null,
    "Lap Steel Guitar": null,
    Lavta: null,
    "Lead Guitar": null,
    Lira: null,
    "Lira da Braccio": null,
    Lirone: null,
    Liuqin: null,
    Lute: null,
    Lyre: null,
    Mandobass: null,
    Mandocello: null,
    Mandoguitar: null,
    Mandola: null,
    "Mandolin Banjo": null,
    Mandolincello: null,
    Marxophone: null,
    Masinko: null,
    Monochord: null,
    Morinhoor: null,
    "Mountain Dulcimer": null,
    "Musical Bow": null,
    Ngoni: null,
    Nyckelharpa: null,
    "Open-Back Banjo": null,
    Outi: null,
    Panduri: null,
    "Pedal Steel Guitar": null,
    "Piccolo Banjo": null,
    Pipa: null,
    "Plectrum Banjo": null,
    "Portuguese Guitar": null,
    Psalmodicon: null,
    Psaltery: null,
    Rabab: null,
    Rabeca: null,
    Rebab: null,
    Rebec: null,
    Reikin: null,
    "Requinto Guitar": null,
    "Resonator Banjo": null,
    "Resonator Guitar": null,
    "Rhythm Guitar": null,
    Ronroco: null,
    Ruan: null,
    Sanshin: null,
    Santoor: null,
    Sanxian: null,
    Sarangi: null,
    Sarod: null,
    "Selmer-Maccaferri Guitar": null,
    "Semi-Acoustic Guitar": null,
    Seperewa: null,
    "Shahi Baaja": null,
    Shamisen: null,
    Sintir: null,
    Spinet: null,
    "Steel Guitar": null,
    "Stroh Violin": null,
    Strumstick: null,
    Surbahar: null,
    "Svara Mandala": null,
    Swarmandel: null,
    Sympitar: null,
    SynthAxe: null,
    Taish\u014Dgoto: null,
    Talharpa: null,
    Tambura: null,
    Tamburitza: null,
    Tapboard: null,
    "Tar (lute)": null,
    "Tenor Banjo": null,
    "Tenor Guitar": null,
    Theorbo: null,
    Timple: null,
    Tiple: null,
    Tipple: null,
    Tonkori: null,
    Tres: null,
    "Tromba Marina": null,
    "Twelve-String Guitar": null,
    Tzouras: null,
    "Ukulele Banjo": null,
    \u00DCt\u0151gardon: null,
    Valiha: null,
    Veena: null,
    Vielle: null,
    Vihuela: null,
    Viol: null,
    "Viola Caipira": null,
    "Viola d'Amore": null,
    "Viola da Gamba": null,
    "Viola de Cocho": null,
    "Viola Kontra": null,
    "Viola Nordestina": null,
    "Violino Piccolo": null,
    Violoncello: null,
    Violone: null,
    "Washtub Bass": null,
    Xalam: null,
    "Yang T'Chin": null,
    Yanggeum: null,
    Zither: null,
    Zongora: null,
    Algoza: null,
    Alphorn: null,
    "Alto Clarinet": null,
    "Alto Flute": null,
    "Alto Horn": null,
    "Alto Recorder": null,
    Apito: null,
    Bagpipes: null,
    Bandoneon: null,
    Bansuri: null,
    "Baritone Horn": null,
    "Barrel Organ": null,
    "Bass Harmonica": null,
    "Bass Saxophone": null,
    "Bass Trombone": null,
    "Bass Trumpet": null,
    "Bass Tuba": null,
    "Basset Horn": null,
    Bawu: null,
    Bayan: null,
    Bellowphone: null,
    Beresta: null,
    "Blues Harp": null,
    "Bolivian Flute": null,
    Bombarde: null,
    Brass: null,
    "Brass Bass": null,
    Bucium: null,
    Bugle: null,
    Chalumeau: null,
    Chanter: null,
    Charamel: null,
    Chirimia: null,
    Clarion: null,
    Claviola: null,
    Comb: null,
    "Concert Flute": null,
    Concertina: null,
    Conch: null,
    "Contra-Alto Clarinet": null,
    "Contrabass Clarinet": null,
    "Contrabass Saxophone": null,
    Contrabassoon: null,
    "Cor Anglais": null,
    Cornett: null,
    Cromorne: null,
    Crumhorn: null,
    Daegeum: null,
    Danso: null,
    "Dili Tuiduk": null,
    Dizi: null,
    Drone: null,
    Duduk: null,
    Dulcian: null,
    Dulzaina: null,
    "Electronic Valve Instrument": null,
    "Electronic Wind Instrument": null,
    "English Horn": null,
    Euphonium: null,
    Fife: null,
    Flageolet: null,
    Flugabone: null,
    Fluier: null,
    Flumpet: null,
    "Flute D'Amour": null,
    Friscaletto: null,
    Fujara: null,
    Galoubet: null,
    Gemshorn: null,
    Gudastviri: null,
    Harmet: null,
    Heckelphone: null,
    Helicon: null,
    Hichiriki: null,
    "Highland Pipes": null,
    Horagai: null,
    Horn: null,
    Horns: null,
    Hotchiku: null,
    "Hunting Horn": null,
    Jug: null,
    Kagurabue: null,
    Kaval: null,
    Kazoo: null,
    Khene: null,
    Kortholt: null,
    Launeddas: null,
    Limbe: null,
    Liru: null,
    "Low Whistle": null,
    Lur: null,
    Lyricon: null,
    M\u00E4nkeri: null,
    Mellophone: null,
    Melodeon: null,
    Mey: null,
    Mizmar: null,
    Mizwad: null,
    Moce\u00F1o: null,
    "Mouth Organ": null,
    Murli: null,
    Musette: null,
    Nadaswaram: null,
    Ney: null,
    "Northumbrian Pipes": null,
    "Nose Flute": null,
    "Oboe d'Amore": null,
    "Oboe Da Caccia": null,
    Ocarina: null,
    Ophicleide: null,
    "Overtone Flute": null,
    Panpipes: null,
    "Piano Accordion": null,
    "Piccolo Flute": null,
    "Piccolo Trumpet": null,
    Pipe: null,
    Piri: null,
    Pito: null,
    Pixiephone: null,
    Quena: null,
    Quenacho: null,
    Quray: null,
    Rauschpfeife: null,
    Recorder: null,
    Reeds: null,
    Rhaita: null,
    Rondador: null,
    Rozhok: null,
    Ryuteki: null,
    Sackbut: null,
    Salamuri: null,
    Sampona: null,
    Sarrusophone: null,
    Saxello: null,
    Saxhorn: null,
    Schwyzer\u00F6rgeli: null,
    Serpent: null,
    Shakuhachi: null,
    Shanai: null,
    Shawm: null,
    Shenai: null,
    Sheng: null,
    Shinobue: null,
    Sho: null,
    "Shruti Box": null,
    "Slide Whistle": null,
    Smallpipes: null,
    Sodina: null,
    Sopilka: null,
    "Sopranino Saxophone": null,
    "Soprano Clarinet": null,
    "Soprano Cornet": null,
    "Soprano Flute": null,
    "Soprano Trombone": null,
    Souna: null,
    Sousaphone: null,
    "Subcontrabass Saxophone": null,
    Suling: null,
    Suona: null,
    Taepyungso: null,
    T\u00E1rogat\u00F3: null,
    "Tenor Horn": null,
    "Tenor Trombone": null,
    "Ti-tse": null,
    "Tin Whistle": null,
    Tonette: null,
    Txirula: null,
    Txistu: null,
    "Uilleann Pipes": null,
    "Valve Trombone": null,
    "Valve Trumpet": null,
    "Wagner Tuba": null,
    Whistle: null,
    "Whistling Water Jar": null,
    Wind: null,
    Woodwind: null,
    Xiao: null,
    Yorgaphone: null,
    Zhaleika: null,
    Zukra: null,
    Zurna: null,
    "Automatic Orchestra": null,
    Computer: null,
    "Drum Machine": null,
    Effects: null,
    Groovebox: null,
    Loops: null,
    "MIDI Controller": null,
    Noises: null,
    Sampler: null,
    Scratches: null,
    Sequencer: null,
    "Software Instrument": null,
    Talkbox: null,
    Tannerin: null,
    Tape: null,
    Turntables: null,
    // 'Accompanied By' deliberately NOT in INSTRUMENTS — it's a meta role,
    // not an instrument. Mapped in ENTITY_TYPE_MAP to performer + additional.
    "Audio Generator": null,
    "Backing Band": null,
    Band: null,
    Bass: null,
    "Brass Band": null,
    Bullroarer: null,
    "Concert Band": null,
    "E-Bow": null,
    Ensemble: null,
    Gamelan: null,
    "Glass Harmonica": null,
    Guest: null,
    Homus: null,
    Instruments: null,
    "Jew's Harp": null,
    Morchang: null,
    Musician: null,
    Orchestra: null,
    Performer: null,
    "Rhythm Section": null,
    Saw: null,
    Siren: null,
    Soloist: null,
    Sounds: null,
    Toy: null,
    Trautonium: null,
    "Wind Chimes": null,
    "Wobble Board": null
  };

  // src/mappers.js
  var INSTRUMENTS_CI = Object.fromEntries(
    Object.entries(INSTRUMENTS).map(([k, v]) => [k.toLowerCase(), v])
  );
  function guessSortName(name) {
    if (!name || !name.trim()) return name;
    name = name.trim();
    const articleRe = /^(the|a|an)\s+(.+)$/i;
    const honorifics = /^(dr\.?|prof\.?|sir|lady|lord|rev\.?|st\.?|dj|mc|mc\.?)\s+/i;
    const suffixRe = /^(.*?),?\s+(jr\.?|sr\.?|ii|iii|iv|v|esq\.?)$/i;
    const words = name.split(/\s+/);
    if (words.length === 1) return name;
    const articleMatch = name.match(articleRe);
    if (articleMatch) {
      const article = articleMatch[1];
      const rest = articleMatch[2];
      return `${rest}, ${article.charAt(0).toUpperCase() + article.slice(1).toLowerCase()}`;
    }
    let suffix = "";
    let baseName = name;
    const suffixMatch = name.match(suffixRe);
    if (suffixMatch) {
      baseName = suffixMatch[1].trim();
      suffix = " " + suffixMatch[2];
    }
    const baseWords = baseName.split(/\s+/);
    if (baseWords.length === 1) return name;
    const familyName = baseWords[baseWords.length - 1];
    const givenPart = baseWords.slice(0, -1).join(" ");
    return `${familyName}, ${givenPart}${suffix}`;
  }
  function getAllArtistTracks(tracklist, artistTracks) {
    return artistTracks.split(",").reduce((trackArray, trackNumber) => {
      if (/ to /.test(trackNumber)) {
        const parts = trackNumber.split(" to ");
        const startTrack = parts[0].trim().replace(".", "-");
        const lastTrack = parts[1].trim().replace(".", "-");
        let hasFoundStart = false, hasFoundEnd = false;
        tracklist.forEach((track) => {
          const resolvedTrackPosition = track.position.replace(".", "-");
          if (!hasFoundStart && resolvedTrackPosition === startTrack) {
            hasFoundStart = true;
            trackArray.push(track);
          } else if (hasFoundStart && !hasFoundEnd) {
            if (resolvedTrackPosition === lastTrack) {
              hasFoundEnd = true;
              trackArray.push(track);
            } else if (track.position === "") {
              hasFoundEnd = true;
            } else {
              trackArray.push(track);
            }
          }
        });
      } else {
        const track = tracklist.find((track2) => {
          return track2.position === trackNumber.trim();
        });
        if (track) {
          trackArray.push(track);
        }
      }
      return trackArray;
    }, []);
  }
  function convertPotentialDJMixers(json) {
    let djmixers = json.extraartists?.filter((artist) => artist.role === "DJ Mix") || [];
    djmixers = djmixers.map((artist) => {
      const tracks = getAllArtistTracks(json.tracklist, artist.tracks);
      const mediums = json.tracklist.reduce(
        (mediums2, track, index) => {
          if (track.type_ === "heading") {
            if (index > 0) {
              mediums2.push([]);
            }
          } else {
            mediums2[mediums2.length - 1].push(track);
          }
          return mediums2;
        },
        [[]]
      );
      tracks.forEach((t) => {
        for (let i = 0; i < mediums.length; i++) {
          mediums[i] = mediums[i].filter((track) => {
            return t.position !== track.position;
          });
        }
      });
      let mediumsDjAppearsOn = mediums.filter((medium) => medium.length === 0);
      if (mediumsDjAppearsOn.length !== mediums.length) {
        json.extraartists = json.extraartists?.filter((a) => {
          return a !== artist;
        }) || [];
        return Object.assign({}, ENTITY_TYPE_MAP["DJ Mix"], {
          artist,
          attributes: [
            () => {
              for (let j = mediums.length - 1; j >= 0; j--) {
                if (mediums[j].length === 0) {
                  $(SELECTORS.MediumsInput).click();
                  $($(SELECTORS.MediumsInputOptions).get(j)).click();
                }
              }
            }
          ]
        });
      } else if (mediumsDjAppearsOn.length === mediums.length) {
        json.extraartists = json.extraartists?.filter((a) => {
          return a !== artist;
        }) || [];
        return Object.assign({}, ENTITY_TYPE_MAP["DJ Mix"], {
          artist
        });
      }
      return null;
    }).filter((role) => role !== null);
    return djmixers;
  }
  function getArtistRoles(artist) {
    const roleStr = artist.role;
    const rawRoles = roleStr.split(",");
    if (/\([0-9]+\)/.test(artist.anv)) {
      artist.anv = artist.anv.replace(/\([0-9]+\)/, "").trim();
    }
    if (/\([0-9]+\)/.test(artist.name)) {
      artist.name = artist.name.replace(/\([0-9]+\)/, "").trim();
    }
    return rawRoles.map((role) => {
      let additionalAttributes = [];
      let rolePart = role.trim().split("[");
      const actualRole = rolePart[0].trim();
      if (/Recording Engineer/.test(rolePart[1]) && actualRole === "Engineer") {
        return Object.assign({}, ENTITY_TYPE_MAP["Recording Engineer"], {
          artist
        });
      }
      if (/Mastering Engineer/.test(rolePart[1]) && actualRole === "Engineer") {
        return Object.assign({}, ENTITY_TYPE_MAP["Mastered By"], {
          artist
        });
      }
      if (/Cover Design/.test(rolePart[1]) && actualRole === "Artwork") {
        return Object.assign({}, ENTITY_TYPE_MAP["Design"], {
          artist
        });
      }
      if (/Design/.test(rolePart[1]) && actualRole === "Cover") {
        return Object.assign({}, ENTITY_TYPE_MAP["Design"], {
          artist
        });
      }
      if (/Art/.test(rolePart[1]) && actualRole === "Cover") {
        return Object.assign({}, ENTITY_TYPE_MAP["Artwork"], {
          artist
        });
      }
      if (/Additional/.test(rolePart[1])) {
        additionalAttributes.push("additional");
      }
      if (/Assistant/.test(rolePart[1])) {
        additionalAttributes.push("assistant");
      }
      if (/Co /.test(rolePart[1])) {
        additionalAttributes.push("additional");
      }
      if (/Executive/.test(rolePart[1])) {
        additionalAttributes.push("executive");
      }
      if (/Associate/.test(rolePart[1])) {
        additionalAttributes.push("associate");
      }
      if (/Guest/.test(rolePart[1])) {
        additionalAttributes.push("guest");
      }
      if (/Solo/.test(rolePart[1])) {
        additionalAttributes.push("solo");
      }
      const mapping = ENTITY_TYPE_MAP[actualRole];
      if (mapping && mapping.linkType == "misc") {
        const taskValue = rolePart[1] ? rolePart[1].replace("]", "").trim().toLowerCase() : actualRole.trim().toLowerCase();
        additionalAttributes.push({ _type: "task", value: taskValue });
      }
      if (mapping && mapping.linkType == "engineer" && rolePart[1]) {
        additionalAttributes.push({ _type: "task", value: rolePart[1].replace("]", "").trim().toLowerCase() });
      }
      if (mapping && mapping.linkType == "mix" && rolePart[1]) {
        additionalAttributes.push({ _type: "task", value: rolePart[1].replace("]", "").trim().toLowerCase() });
      }
      if (mapping && mapping.linkType == "photography" && rolePart[1]) {
        additionalAttributes.push({ _type: "task", value: rolePart[1].replace("]", "").trim().toLowerCase() });
      }
      if (mapping && mapping.linkType == "artwork" && rolePart[1]) {
        additionalAttributes.push({ _type: "task", value: rolePart[1].replace("]", "").trim().toLowerCase() });
      }
      const actualRoleLc = actualRole.toLowerCase();
      if (!mapping && Object.prototype.hasOwnProperty.call(INSTRUMENTS_CI, actualRoleLc)) {
        let instrumentName = INSTRUMENTS_CI[actualRoleLc];
        let role2 = ENTITY_TYPE_MAP.Instruments;
        if (actualRoleLc === "drum programming") {
          role2 = ENTITY_TYPE_MAP["Programmed By"];
          instrumentName = INSTRUMENTS_CI["drum machine"];
        }
        return Object.assign({}, role2, {
          artist,
          attributes: instrumentName ? [{ _type: "instrument", value: instrumentName.toLowerCase() }] : []
        });
      }
      if (!mapping) {
        return null;
      }
      if (Array.isArray(mapping.attributes)) {
        additionalAttributes = additionalAttributes.concat(mapping.attributes);
      }
      return Object.assign({}, mapping, {
        artist,
        attributes: additionalAttributes
      });
    }).filter((resolvedRole) => {
      return !!resolvedRole;
    });
  }
  function rolesFromDiscogsArtists(artists) {
    return artists?.reduce((rolesArr, artist) => {
      const roles = getArtistRoles(artist);
      if (Array.isArray(roles) && roles.length > 0) {
        return rolesArr.concat(roles);
      }
      return rolesArr;
    }, []) || [];
  }

  // src/preflight.js
  var KIND_TABLE = {
    artist: { searchLimit: 10, resultKey: "artists", incRels: "artist-rels" },
    label: { searchLimit: 8, resultKey: "labels", incRels: "label-rels" },
    // Places also accept label-rels because MB editors often file a
    // facility as a label rather than a place (issue we've worked around
    // since the original company resolver).
    place: { searchLimit: 8, resultKey: "places", incRels: "place-rels+label-rels" }
  };
  async function resolveEntity(entity, kind, opts) {
    const { bypassIdb } = opts;
    const { searchLimit, resultKey, incRels } = KIND_TABLE[kind];
    const parsed = parseDiscogsUrl(entity.resource_url);
    const key = parsed?.key;
    const searchName = entity.name;
    const displayName = kind === "artist" ? entity.anv && entity.anv.trim() || entity.name : entity.name;
    const discogsHref = entity.resource_url.replace(/https:\/\/api\.discogs\.com\/(\w+?)s\/(\d+)/, "https://www.discogs.com/$1/$2");
    function buildResolved(mbUrl, mbName, mbDisambig, via2, actualKind = kind, fromCache = false, urlLinkedIds2, creditOverride) {
      return {
        type: "resolved",
        entityType: actualKind,
        entity,
        displayName,
        discogsHref,
        mbUrl,
        mbName,
        mbDisambig,
        // User's saved "Credited as" override from a prior session
        // (IDB `creditOverride` field). Review-table reads this in
        // `pickPrefill` to populate the field. Undefined when no
        // prior override exists. #105.
        creditOverride,
        // `urlLinkedIds` — MBIDs that have a relation to this Discogs URL,
        //                  harvested from the URL lookup done during
        //                  preflight. The review-table uses this to render
        //                  the "Add Discogs link" / "already linked" / "linked
        //                  to different MB <type>" badge without issuing
        //                  another `/ws/2/url?…` query per row. `undefined`
        //                  means "preflight didn't ask MB" (IDB hit on a
        //                  legacy record that predates this field), in which
        //                  case review-table falls back to its own per-row
        //                  fetch. `[]` means "asked MB, got no relations" —
        //                  no fallback needed.
        urlLinkedIds: urlLinkedIds2,
        // `via`      — the resolution mechanism (`name` / `url` / `both` / `user`,
        //              or `cache` only when a legacy IDB record predates the
        //              `resolvedVia` field and we genuinely can't recover it).
        // `fromCache`— whether THIS resolution came from IDB rather than a fresh
        //              MB lookup. The two are orthogonal: a name-resolved entity
        //              loaded from cache is `via='name'` + `fromCache=true`, and
        //              the UI surfaces both as `name (cache)`.
        logEntry: { displayName, discogsHref, mbUrl, mbName, mbDisambig, via: via2, fromCache }
      };
    }
    function buildAttention(nameMatches2, nameSearchFailed2, ambiguityReason, urlLinkedIds2, creditOverride) {
      return {
        type: "attention",
        entityType: kind,
        entity,
        displayName,
        discogsHref,
        nameMatches: nameMatches2 || [],
        // Saved "Credited as" override — see buildResolved. #105.
        creditOverride,
        // Same `urlLinkedIds` contract as on the resolved shape — review-table
        // uses it to skip the per-row URL fetch even for attention rows once
        // the user picks an MBID from the candidate list.
        urlLinkedIds: urlLinkedIds2,
        // Only artists track this — used by the review table to badge
        // entries that failed because of a rate-limited name search vs
        // entries that genuinely don't exist in MB.
        rateLimited: kind === "artist" && nameSearchFailed2 && !nameMatches2?.length,
        ambiguityReason: ambiguityReason || null
      };
    }
    async function fetchMbEntityInfo(et, mbid) {
      const json = await mbThrottle.fetchJson(`//musicbrainz.org/ws/2/${et}/${mbid}?fmt=json`);
      return json ? { name: json.name || null, disambiguation: json.disambiguation || "" } : { name: null, disambiguation: "" };
    }
    if (bypassIdb && key) {
      await deleteIdbRecord(key);
    }
    if (!bypassIdb && key) {
      const cachedRec = await readIdbRecord(key);
      if (cachedRec?.mbid && cachedRec?.entityType) {
        const via2 = cachedRec.resolvedVia || "cache";
        let cachedLinkedIds = cachedRec.urlLinkedIds;
        if (cachedLinkedIds === void 0 && (via2 === "url" || via2 === "both")) {
          cachedLinkedIds = [cachedRec.mbid];
        }
        if (cachedRec.name) {
          return buildResolved(
            cachedRec.mbUrl,
            cachedRec.name,
            cachedRec.disambiguation || "",
            via2,
            cachedRec.entityType,
            true,
            cachedLinkedIds,
            cachedRec.creditOverride
          );
        }
        const info = await fetchMbEntityInfo(cachedRec.entityType, cachedRec.mbid);
        if (info.name) {
          await writeIdbRecord(key, {
            name: info.name,
            disambiguation: info.disambiguation
          });
        }
        return buildResolved(
          cachedRec.mbUrl,
          info.name,
          info.disambiguation,
          via2,
          cachedRec.entityType,
          true,
          cachedLinkedIds,
          cachedRec.creditOverride
        );
      }
      if (cachedRec && Array.isArray(cachedRec.nameMatches)) {
        return buildAttention(cachedRec.nameMatches, false, null, cachedRec.urlLinkedIds, cachedRec.creditOverride);
      }
    }
    const [nameJson, urlJson] = await Promise.all([
      mbThrottle.fetchJson(
        `//musicbrainz.org/ws/2/${kind}?query=${encodeURIComponent(searchName)}&fmt=json&limit=${searchLimit}`
      ),
      parsed ? mbThrottle.fetchJson(
        `//musicbrainz.org/ws/2/url?resource=${encodeURIComponent(parsed.cleanUrl)}&inc=${incRels}&fmt=json`
      ) : Promise.resolve(null)
    ]);
    const nameSearchFailed = nameJson === null;
    const normalized = searchName.toLowerCase().trim();
    const nameMatches = !nameJson?.[resultKey] ? [] : nameJson[resultKey].filter((a) => a.name.toLowerCase().trim() === normalized || a.score != null && a.score >= 70).map((a) => ({
      id: a.id,
      name: a.name,
      disambiguation: a.disambiguation || a["disambiguation-comment"] || "",
      score: a.score || 0
    }));
    const exactNameMatches = nameMatches.filter((a) => a.name.toLowerCase().trim() === normalized);
    const nameHit = exactNameMatches.length === 1 ? {
      kind,
      mbid: exactNameMatches[0].id,
      name: exactNameMatches[0].name,
      disambiguation: exactNameMatches[0].disambiguation || ""
    } : null;
    let urlHit = null;
    const urlLinkedIds = (urlJson?.relations || []).map((r) => kind === "place" ? r.place?.id || r.label?.id || null : r[kind]?.id || null).filter(Boolean);
    if (urlJson?.relations?.length > 0) {
      const rel = kind === "place" ? urlJson.relations.find((r) => r.place || r.label) : urlJson.relations.find((r) => r[kind]);
      if (rel) {
        const actualKind = rel[kind] ? kind : rel.label ? "label" : "place";
        const a = rel[actualKind];
        urlHit = {
          kind: actualKind,
          mbid: a.id,
          name: a.name || null,
          disambiguation: a.disambiguation || ""
        };
      }
    }
    async function cacheAttention(matches) {
      if (key && !nameSearchFailed) {
        await writeIdbRecord(key, {
          mbid: null,
          entityType: null,
          name: null,
          mbUrl: null,
          disambiguation: "",
          resolvedVia: null,
          nameMatches: matches,
          urlLinkedIds
        });
      }
    }
    let resolved = null;
    let via = null;
    if (nameHit && urlHit) {
      if (nameHit.mbid === urlHit.mbid && nameHit.kind === urlHit.kind) {
        resolved = urlHit;
        via = "both";
      } else {
        await cacheAttention(nameMatches);
        return buildAttention(
          nameMatches,
          false,
          `name \u2192 ${nameHit.kind}/${nameHit.mbid}, URL \u2192 ${urlHit.kind}/${urlHit.mbid}`,
          urlLinkedIds
        );
      }
    } else if (urlHit) {
      resolved = urlHit;
      via = "url";
    } else if (nameHit) {
      resolved = nameHit;
      via = "name";
    }
    if (resolved) {
      const mbUrl = `//musicbrainz.org/${resolved.kind}/${resolved.mbid}`;
      let finalName = resolved.name;
      let finalDisam = resolved.disambiguation;
      if (!finalName) {
        const info = await fetchMbEntityInfo(resolved.kind, resolved.mbid);
        finalName = info.name || null;
        finalDisam = info.disambiguation || "";
      }
      if (key) {
        await writeIdbRecord(key, {
          mbid: resolved.mbid,
          entityType: resolved.kind,
          name: finalName,
          disambiguation: finalDisam || "",
          resolvedVia: via,
          urlLinkedIds
        });
      }
      return buildResolved(mbUrl, finalName, finalDisam || "", via, resolved.kind, false, urlLinkedIds);
    }
    await cacheAttention(nameMatches);
    return buildAttention(nameMatches, nameSearchFailed, null, urlLinkedIds);
  }
  async function resolveAll(entities, opts) {
    const { kindOf, progressLi, bypassIdb, progressLabel } = opts;
    const CONCURRENCY = 5;
    const MIN_GAP_MS = 50;
    let done = 0;
    const inFlightNames = /* @__PURE__ */ new Set();
    function setProgress() {
      if (entities.length > 0) {
        try {
          _setProgressPct(done / entities.length * 100);
        } catch (_) {
        }
      }
      if (!progressLi) return;
      const remaining = entities.length - done;
      const checking = inFlightNames.size ? ` \u2014 checking <em>${[...inFlightNames].join(", ")}</em>` : "";
      progressLi.innerHTML = `${progressLabel}\u2026 <strong>${done}/${entities.length}</strong> done${checking}` + (remaining === 0 ? " \u2714" : ` (${remaining} remaining)`);
    }
    const delay = (ms) => new Promise((r) => setTimeout(r, ms));
    const queue = entities.map((e, i) => ({ entity: e, index: i }));
    const results = new Array(entities.length);
    setProgress();
    async function worker(slotIndex) {
      const tag = `worker#${slotIndex}`;
      logDebug(`${tag} starting (stagger ${slotIndex * MIN_GAP_MS}ms)`);
      await delay(slotIndex * MIN_GAP_MS);
      let processed = 0;
      while (queue.length > 0) {
        const { entity, index } = queue.shift();
        const kind = kindOf(entity);
        if (!kind) {
          logDebug(`${tag} skip "${entity?.name || "?"}" \u2014 no resolvable kind`);
          done++;
          setProgress();
          continue;
        }
        const displayName = kind === "artist" ? entity.anv && entity.anv.trim() || entity.name : entity.name;
        inFlightNames.add(displayName);
        setProgress();
        const t0 = Date.now();
        logDebug(`${tag} resolving "${displayName}" (${kind})`);
        results[index] = await resolveEntity(entity, kind, { bypassIdb });
        const elapsed = Date.now() - t0;
        const r = results[index];
        const outcome = r?.type === "resolved" ? `resolved via ${r.logEntry?.via || "?"}${r.logEntry?.fromCache ? " (cache)" : ""}` : r?.type === "attention" ? `unresolved (${r.nameMatches?.length || 0} candidates)` : "skipped";
        logDebug(`${tag} "${displayName}" -> ${outcome} in ${elapsed}ms`);
        inFlightNames.delete(displayName);
        done++;
        processed++;
        setProgress();
      }
      logDebug(`${tag} finished (${processed} entit${processed === 1 ? "y" : "ies"})`);
    }
    const slots = Math.min(CONCURRENCY, entities.length);
    logDebug(`resolveAll: ${entities.length} entit${entities.length === 1 ? "y" : "ies"}, ${slots} worker slot(s)`);
    if (slots > 0) await Promise.all(Array.from({ length: slots }, (_, i) => worker(i)));
    logDebug(`resolveAll: done`);
    return { allResults: results.filter(Boolean) };
  }
  var ARTIST_KIND = () => "artist";
  var COMPANY_KIND = (c) => ENTITY_TYPE_MAP[c.entity_type_name]?.entityType ?? null;

  // src/review-table.js
  var _urlCheckSessionCache = /* @__PURE__ */ new Map();
  async function showReviewTable(allResults, rolesMap, companiesRolesMap, opts) {
    rolesMap = rolesMap || /* @__PURE__ */ new Map();
    companiesRolesMap = companiesRolesMap || /* @__PURE__ */ new Map();
    const onRefresh = opts?.onRefresh || null;
    const _preloadedNames = /* @__PURE__ */ new Map();
    const _nullNames = allResults.filter((r) => r.type === "resolved" && r.mbUrl && !r.mbName);
    for (const r of _nullNames) {
      const rUrl = r.entity?.resource_url;
      try {
        const idbKey = parseDiscogsUrl(rUrl)?.key;
        const rec = await readIdbRecord(idbKey);
        if (rec?.name) {
          _preloadedNames.set(rUrl, { name: rec.name, dis: rec.disambiguation || "" });
          continue;
        }
        const mbid = (r.mbUrl || "").split("/").pop().replace(/[^a-f0-9-]/g, "").substring(0, 36);
        if (!mbid) continue;
        const et = r.entityType || "artist";
        const data = await mbThrottle.fetchJson(`https://musicbrainz.org/ws/2/${et}/${mbid}?fmt=json`);
        if (data?.name) {
          _preloadedNames.set(rUrl, { name: data.name, dis: data.disambiguation || "" });
          if (idbKey) {
            await writeIdbRecord(idbKey, {
              mbid,
              entityType: et,
              name: data.name,
              disambiguation: data.disambiguation || ""
              // No resolvedVia change — this is just a name-display
              // populate; whatever set the cached mbid stays the
              // source of truth for `resolvedVia`.
            });
          }
        }
      } catch (e) {
      }
    }
    return new Promise((resolve) => {
      const rowState = /* @__PURE__ */ new Map();
      const attentionCount = allResults.filter((r) => r.type === "attention").length;
      const mismatchCount = allResults.filter((r) => {
        if (r.type !== "resolved") return false;
        const e = r.logEntry;
        return e && e.mbName && e.displayName && e.mbName.toLowerCase().trim() !== e.displayName.toLowerCase().trim();
      }).length;
      const URL_CHECK_CONCURRENCY = 5;
      const urlCheckPending = [];
      let urlCheckRunning = 0;
      let urlCheckStarted = false;
      function queuedUrlCheck(fn) {
        return new Promise((resolve2, reject) => {
          urlCheckPending.push({ fn, resolve: resolve2, reject });
          if (urlCheckRunning < URL_CHECK_CONCURRENCY) {
            runUrlCheckWorker();
          }
        });
      }
      async function runUrlCheckWorker() {
        urlCheckRunning++;
        while (urlCheckPending.length > 0) {
          const { fn, resolve: resolve2, reject } = urlCheckPending.shift();
          try {
            resolve2(await fn());
          } catch (e) {
            reject(e);
          }
        }
        urlCheckRunning--;
      }
      const VIA_STYLES = {
        both: { text: "name+url", color: "#2a7" },
        // green — high confidence
        url: { text: "url", color: "#46a" },
        // blue
        name: { text: "name", color: "#46a" },
        // blue
        user: { text: "user", color: "#777" },
        // grey
        cache: { text: "cache", color: "#777" }
        // grey (legacy: original mechanism unknown)
      };
      function viaCfg(via, fromCache) {
        const base = VIA_STYLES[via];
        if (!base) return null;
        if (fromCache && via !== "cache") {
          return { text: `${base.text} (cache)`, color: base.color };
        }
        return base;
      }
      function makeViaBadge(via, fromCache) {
        const cfg = viaCfg(via, fromCache);
        if (!cfg) return null;
        const span = document.createElement("span");
        span.textContent = cfg.text;
        span.title = fromCache && via !== "cache" ? `Resolved via ${via}, served from cache` : `Resolved via ${via}`;
        span.style.cssText = `font-size:0.68rem;background:#f5f5f5;color:${cfg.color};padding:0 0.35rem;border-radius:8px;border:1px solid #ddd;flex-shrink:0;`;
        return span;
      }
      const creditOverrides = /* @__PURE__ */ new Map();
      const existingCreditByMbid = computeExistingCreditByMbid();
      function computeExistingCreditByMbid() {
        const counts = /* @__PURE__ */ new Map();
        const MB = pageWindow?.MB;
        const iterate = MB?.tree?.iterate;
        if (!iterate) return counts;
        const valueOf = (yielded) => Array.isArray(yielded) ? yielded[1] : yielded;
        const isTree = (x) => x && typeof x === "object" && x.size != null && (x.left !== void 0 || x.right !== void 0 || x.value !== void 0);
        function tally(rel) {
          if (!rel || rel._status === 2) return;
          for (const side of [1, 0]) {
            const entity = rel[`entity${side}`];
            const tgt = entity?.gid;
            if (!tgt) continue;
            const credit = rel[`entity${side}_credit`] || entity.name;
            if (!credit) continue;
            if (!counts.has(tgt)) counts.set(tgt, /* @__PURE__ */ new Map());
            const m = counts.get(tgt);
            m.set(credit, (m.get(credit) || 0) + 1);
          }
        }
        function walkRels(rels) {
          if (!isTree(rels)) return;
          for (const e of iterate(rels)) tally(valueOf(e));
        }
        function walkPhraseGroups(phraseGroups) {
          if (!isTree(phraseGroups)) return;
          for (const e of iterate(phraseGroups)) {
            const pg = valueOf(e);
            if (pg?.relationships) walkRels(pg.relationships);
          }
        }
        function walkTypeGroups(byTypeId) {
          if (!isTree(byTypeId)) return;
          for (const e of iterate(byTypeId)) {
            const tg = valueOf(e);
            if (tg?.phraseGroups) walkPhraseGroups(tg.phraseGroups);
          }
        }
        function walkPerSource(perSource) {
          if (!isTree(perSource)) return;
          for (const e of iterate(perSource)) {
            walkTypeGroups(valueOf(e));
          }
        }
        function walkSource(root) {
          if (!isTree(root)) return;
          for (const e of iterate(root)) {
            walkPerSource(valueOf(e));
          }
        }
        try {
          walkSource(MB.relationshipEditor?.state?.existingRelationshipsBySource);
        } catch (e) {
        }
        try {
          walkSource(MB.relationshipEditor?.state?.relationshipsBySource);
        } catch (e) {
        }
        const out = /* @__PURE__ */ new Map();
        for (const [mbid, m] of counts) {
          let best = null, bestN = 0;
          for (const [credit, n] of m) {
            if (n > bestN) {
              best = credit;
              bestN = n;
            }
          }
          if (best) out.set(mbid, best);
        }
        return out;
      }
      const panel = document.createElement("div");
      panel.style.cssText = "border:2px solid #c8a000;border-radius:0.5rem;background:#fffef5;padding:1rem 1.5rem;margin:0.5rem 0;";
      {
        const _pb = document.getElementById("discogs-progress-bar");
        if (_pb) _pb.style.display = "none";
      }
      const _bar = document.querySelector(".discogs-bar");
      if (_bar) {
        _hideBar();
        const _r2 = _bar.querySelector(".discogs-bar-row2");
        if (_r2) _r2.style.marginTop = "";
      }
      const heading = document.createElement("div");
      heading.style.cssText = "display:flex;align-items:center;gap:0.6rem;margin:0 0 0.5rem;padding:0.4rem 0.6rem;border-radius:0.3rem;background:#f5e8a0;border:1px solid #d4b800;";
      if (onRefresh) {
        const refreshBtn = document.createElement("button");
        refreshBtn.textContent = "\u{1F504} Refresh from MB";
        refreshBtn.title = "Re-resolve every entity via MusicBrainz API, ignoring the local IDB cache";
        refreshBtn.style.cssText = "font-size:0.8rem;cursor:pointer;padding:0.2rem 0.5rem;border:1px solid #b59a00;border-radius:3px;background:#fff;color:#5a4000;flex-shrink:0;";
        refreshBtn.addEventListener("click", () => {
          refreshBtn.disabled = true;
          refreshBtn.textContent = "\u{1F504} Refreshing\u2026";
          (panelLi || panel).remove();
          onRefresh().then((freshResults) => {
            showReviewTable(freshResults, rolesMap, companiesRolesMap, { onRefresh }).then((confirmedMap) => resolve(confirmedMap));
          });
        });
        heading.appendChild(refreshBtn);
      }
      const headingText = document.createElement("span");
      headingText.style.cssText = "font-weight:bold;font-size:1rem;color:#5a4000;flex:1;";
      headingText.textContent = `Review \u2014 ${allResults.length} entit${allResults.length === 1 ? "y" : "ies"}`;
      heading.appendChild(headingText);
      panel.appendChild(heading);
      const intro = document.createElement("p");
      intro.style.cssText = "margin:0 0 0.75rem;font-size:0.85rem;color:#666;";
      intro.innerHTML = 'Review all artist matches before importing. <span style="background:#ffe0e0;padding:0 0.3rem;border-radius:2px;">Red rows</span> need attention. <span style="background:#fff8e1;padding:0 0.3rem;border-radius:2px;">Yellow rows</span> have a name mismatch \u2014 verify. Green rows are confirmed. Use the search or create buttons to resolve outstanding issues.';
      panel.appendChild(intro);
      const table = document.createElement("table");
      table.style.cssText = "border-collapse:collapse;width:100%;font-size:0.85rem;";
      const thead = document.createElement("thead");
      const hr = document.createElement("tr");
      hr.style.background = "#f5e8a0";
      ["Discogs entity", "MB match / search"].forEach((col) => {
        const th = document.createElement("th");
        th.style.cssText = "text-align:left;padding:0.3rem 0.5rem;border:1px solid #d4b800;white-space:nowrap;";
        th.textContent = col;
        hr.appendChild(th);
      });
      thead.appendChild(hr);
      table.appendChild(thead);
      const tbody = document.createElement("tbody");
      allResults.forEach((r) => {
        const entityType = r.entityType || "artist";
        const displayName = r.displayName || r.entity?.name || "";
        const discogsHref = r.discogsHref || "";
        const e = r.logEntry || null;
        const artist = r.entity;
        const isResolved = r.type === "resolved";
        const initMbUrl = isResolved ? r.mbUrl : null;
        const _entityKey = r.entity?.resource_url || r.entity?._syntheticKey || `_nourl_${r.entity?.name || r.displayName}`;
        const _pl = _preloadedNames.get(_entityKey) || _preloadedNames.get(r.entity?.resource_url);
        const initMbName = e && e.mbName ? e.mbName : _pl?.name || (isResolved ? r.mbName : null) || null;
        const initMbDisam = e && e.mbDisambig ? e.mbDisambig : _pl?.dis || r.mbDisambig || "";
        const nameMismatch = isResolved && initMbName && initMbName.toLowerCase().trim() !== displayName.toLowerCase().trim();
        const needsAttention = r.type === "attention";
        const rowBg = needsAttention ? "#ffe0e0" : nameMismatch ? "#fff8e1" : "#fff";
        const borderColor = needsAttention ? "#cc6666" : "#d4d4d4";
        const tr = document.createElement("tr");
        tr.style.cssText = `vertical-align:top;background:${rowBg};`;
        tr.dataset.entityKey = _entityKey;
        rowState.set(_entityKey, {
          mbUrl: initMbUrl,
          mbName: initMbName,
          mbDisambig: initMbDisam,
          confirmed: isResolved && !needsAttention,
          via: isResolved ? r.logEntry?.via || null : null,
          fromCache: isResolved ? r.logEntry?.fromCache || false : false
        });
        const tdDiscogs = document.createElement("td");
        tdDiscogs.style.cssText = `padding:0.3rem 0.5rem;border:1px solid ${borderColor};white-space:nowrap;`;
        const nameRow = document.createElement("div");
        nameRow.style.cssText = "display:flex;align-items:center;justify-content:space-between;gap:0.6rem;";
        const nameWrap = document.createElement("span");
        nameWrap.style.cssText = "min-width:0;overflow:hidden;text-overflow:ellipsis;";
        if (entityType !== "artist") {
          const badge = document.createElement("span");
          badge.textContent = entityType;
          badge.style.cssText = "font-size:0.7rem;background:#e0e0e0;border-radius:3px;padding:0 0.3rem;margin-right:0.3rem;color:#555;vertical-align:middle;";
          nameWrap.appendChild(badge);
        }
        const hasDiscogsUrl = !!r.entity?.resource_url;
        const dlA = document.createElement(hasDiscogsUrl ? "a" : "span");
        dlA.href = discogsHref;
        dlA.target = "_blank";
        dlA.rel = "noopener noreferrer nofollow";
        dlA.textContent = displayName;
        if (!hasDiscogsUrl) dlA.className = "discogs-entity-name";
        nameWrap.appendChild(dlA);
        const BADGE_BASE = "display:inline-flex;align-items:center;margin-left:0.35rem;padding:0.05rem 0.4rem;font-size:0.65rem;font-weight:600;border-radius:0.7rem;letter-spacing:0.01em;cursor:help;text-transform:lowercase;line-height:1.4;";
        if (!hasDiscogsUrl) {
          const noUrl = document.createElement("span");
          noUrl.textContent = "no profile";
          noUrl.title = "No Discogs artist page \u2014 name lookup unavailable, search MB manually";
          noUrl.style.cssText = BADGE_BASE + "background:#fde0e0;color:#a02020;border:1px solid #d44040;";
          nameWrap.appendChild(noUrl);
        }
        if (nameMismatch) {
          const w = document.createElement("span");
          w.textContent = "name differs";
          w.title = "MB entity name differs from the Discogs display name \u2014 double-check this is the right match";
          w.style.cssText = BADGE_BASE + "background:#fff1c4;color:#7a5800;border:1px solid #d4ad3a;";
          nameWrap.appendChild(w);
        }
        nameRow.appendChild(nameWrap);
        const actionsLine = document.createElement("span");
        actionsLine.style.cssText = "display:inline-flex;align-items:center;gap:0.3rem;flex-shrink:0;";
        nameRow.appendChild(actionsLine);
        tdDiscogs.appendChild(nameRow);
        tr.appendChild(tdDiscogs);
        const rolesList = r._roles || [];
        if (rolesList.length > 0) {
          const seen = /* @__PURE__ */ new Map();
          rolesList.forEach(({ displayLabel, linkType, trackPos }) => {
            const key = displayLabel || linkType;
            if (!key) return;
            const uniqueKey = key + (trackPos ? "[" + trackPos + "]" : "");
            if (seen.has(uniqueKey)) return;
            seen.set(uniqueKey, {
              roleKey: key,
              displayText: key + (trackPos ? " [" + trackPos + "]" : "")
            });
          });
          const chips = [...seen.values()];
          const rolesLine = document.createElement("div");
          rolesLine.style.cssText = "font-size:0.75rem;color:#888;margin-top:0.15rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:240px;";
          rolesLine.title = chips.map((c) => c.displayText).join(", ");
          chips.forEach((chip, i) => {
            if (i > 0) rolesLine.appendChild(document.createTextNode(", "));
            const span = document.createElement("span");
            span.className = "discogs-role-chip";
            span.dataset.roleKey = chip.roleKey;
            span.textContent = chip.displayText;
            rolesLine.appendChild(span);
          });
          tdDiscogs.appendChild(rolesLine);
        }
        const credLine = document.createElement("div");
        credLine.style.cssText = "display:flex;align-items:center;gap:0.3rem;margin-top:1rem;padding-top:0.25rem;max-width:280px;";
        const credLabel = document.createElement("label");
        credLabel.textContent = "Credited as:";
        credLabel.style.cssText = "font-size:0.72rem;color:#888;flex-shrink:0;";
        const credInput = document.createElement("input");
        credInput.type = "text";
        const CRED_BG_SAME = "#fff";
        const CRED_BG_DIFFERENT = "#fff4d0";
        credInput.style.cssText = "flex:1;padding:0.15rem 0.35rem;font-size:0.78rem;border:1px solid #ddd;border-radius:3px;background:" + CRED_BG_SAME + ";";
        credInput.placeholder = displayName;
        credInput.title = `Override the credited name dispatched with every rel for this entity.
Leave empty to use the default (Discogs name, or MB's most-frequent existing credit when known).`;
        function refreshCredBg() {
          const value = (credInput.value || "").trim();
          const same = value === "" || value === displayName;
          credInput.style.background = same ? CRED_BG_SAME : CRED_BG_DIFFERENT;
        }
        function pickPrefill(mbUrl) {
          if (r.creditOverride !== void 0 && r.creditOverride !== null && r.creditOverride !== "") {
            return r.creditOverride;
          }
          if (mbUrl) {
            const mbid = (String(mbUrl).split("/").pop() || "").replace(/[^a-f0-9-]/gi, "").slice(0, 36);
            if (mbid && existingCreditByMbid.has(mbid)) return existingCreditByMbid.get(mbid);
          }
          return displayName;
        }
        credInput.value = pickPrefill(r.mbUrl);
        credInput._userTouched = false;
        refreshCredBg();
        let _credSaveTimer;
        credInput.addEventListener("input", () => {
          credInput._userTouched = true;
          const url = credInput._activeMbUrl;
          if (url) creditOverrides.set(url, credInput.value);
          refreshCredBg();
          clearTimeout(_credSaveTimer);
          _credSaveTimer = setTimeout(() => {
            const idbKey = parseDiscogsUrl(r.entity?.resource_url)?.key;
            if (idbKey) writeIdbRecord(idbKey, { creditOverride: credInput.value });
          }, 500);
        });
        credInput._activeMbUrl = r.mbUrl;
        if (r.mbUrl) creditOverrides.set(r.mbUrl, credInput.value);
        const CRED_BTN_STYLE = "flex-shrink:0;padding:0.05rem 0.35rem;font-size:0.7rem;line-height:1;cursor:pointer;border:1px solid #c8a000;border-radius:3px;background:#fffbe6;color:#7a5000;";
        const mbBtn = document.createElement("button");
        mbBtn.type = "button";
        mbBtn.textContent = "MB";
        mbBtn.title = "Set Credited as to the MB entity name";
        mbBtn.style.cssText = CRED_BTN_STYLE;
        const dBtn = document.createElement("button");
        dBtn.type = "button";
        dBtn.textContent = "D";
        dBtn.title = "Set Credited as to the Discogs name";
        dBtn.style.cssText = CRED_BTN_STYLE;
        function currentMbName() {
          return rowState.get(_entityKey)?.mbName || r.mbName || null;
        }
        function refreshCredBtns() {
          const val = credInput.value;
          const mbName = currentMbName();
          mbBtn.style.display = !mbName || val === mbName ? "none" : "";
          dBtn.style.display = val === displayName ? "none" : "";
        }
        function setCredViaButton(value) {
          credInput.value = value;
          credInput._userTouched = true;
          credInput.dispatchEvent(new Event("input", { bubbles: true }));
        }
        mbBtn.addEventListener("click", () => {
          const mbName = currentMbName();
          if (mbName) setCredViaButton(mbName);
        });
        dBtn.addEventListener("click", () => setCredViaButton(displayName));
        credInput.addEventListener("input", refreshCredBtns);
        refreshCredBtns();
        credLine.appendChild(credLabel);
        credLine.appendChild(credInput);
        credLine.appendChild(mbBtn);
        credLine.appendChild(dBtn);
        tdDiscogs.appendChild(credLine);
        r._credInput = credInput;
        r._refreshCredBtns = refreshCredBtns;
        const tdMb = document.createElement("td");
        tdMb.style.cssText = `padding:0.3rem 0.5rem;border:1px solid ${borderColor};min-width:240px;`;
        const candidateList = document.createElement("div");
        candidateList.style.cssText = "display:flex;flex-direction:column;gap:0.2rem;margin-bottom:0.3rem;";
        const searchRow = document.createElement("div");
        searchRow.style.cssText = "display:flex;gap:0.3rem;";
        const searchInput = document.createElement("input");
        searchInput.type = "text";
        searchInput.value = displayName;
        searchInput.style.cssText = "flex:1;padding:0.15rem 0.35rem;font-size:0.82rem;border:1px solid #bbb;border-radius:3px;";
        const searchBtn = document.createElement("button");
        searchBtn.textContent = "\u{1F50D}";
        searchBtn.title = "Search MusicBrainz";
        searchBtn.style.cssText = "padding:0.15rem 0.35rem;cursor:pointer;";
        searchRow.appendChild(searchBtn);
        searchRow.appendChild(searchInput);
        tdMb.appendChild(candidateList);
        tdMb.appendChild(searchRow);
        tr.appendChild(tdMb);
        const tdAction = actionsLine;
        tbody.appendChild(tr);
        function setRowResolved(a) {
          const mbUrl = `//musicbrainz.org/${entityType}/${a.id}`;
          rowState.set(_entityKey, { mbUrl, mbName: a.name, mbDisambig: a.disambiguation || "", confirmed: true, via: "user", fromCache: false });
          if (r._credInput) {
            const oldUrl = r._credInput._activeMbUrl;
            if (oldUrl && oldUrl !== mbUrl) creditOverrides.delete(oldUrl);
            r._credInput._activeMbUrl = mbUrl;
            if (!r._credInput._userTouched) {
              const fresh = pickPrefill(mbUrl);
              r._credInput.value = fresh;
            }
            creditOverrides.set(mbUrl, r._credInput.value);
            refreshCredBg();
            if (r._refreshCredBtns) r._refreshCredBtns();
          }
          const _idbKey = r.entity?.resource_url ? parseDiscogsUrl(r.entity.resource_url)?.key : null;
          if (_idbKey) {
            writeIdbRecord(_idbKey, {
              mbid: a.id,
              entityType,
              name: a.name,
              disambiguation: a.disambiguation || "",
              resolvedVia: "user"
              // user picked this in the review table
            });
          }
          tr.style.background = "#f0fff0";
          searchInput.disabled = true;
          searchBtn.disabled = true;
          candidateList.innerHTML = "";
          const selRow = document.createElement("div");
          selRow.style.cssText = "padding:0.15rem 0.4rem;border:1px solid #5a5;border-radius:3px;background:#e8f8e8;display:flex;align-items:center;gap:0.4rem;font-size:0.85rem;";
          const selA = document.createElement("a");
          selA.href = "https:" + mbUrl;
          selA.target = "_blank";
          selA.rel = "noopener noreferrer nofollow";
          selA.textContent = "\u2713 " + a.name + (a.disambiguation ? ` (${a.disambiguation})` : "");
          selA.style.fontWeight = "bold";
          const undoBtn = document.createElement("button");
          undoBtn.textContent = "\u2715";
          undoBtn.title = "Clear selection";
          undoBtn.style.cssText = "font-size:0.75rem;cursor:pointer;padding:0 0.3rem;margin-left:auto;";
          undoBtn.addEventListener("click", () => setRowUnresolved());
          selRow.appendChild(selA);
          const viaBadge = makeViaBadge("user", false);
          if (viaBadge) selRow.appendChild(viaBadge);
          selRow.appendChild(undoBtn);
          candidateList.appendChild(selRow);
          renderActions(a);
          updateImportBtn();
        }
        function setRowUnresolved() {
          rowState.set(_entityKey, { mbUrl: null, mbName: null, mbDisambig: "", confirmed: false, via: null, fromCache: false });
          if (r._credInput && r._credInput._activeMbUrl) {
            creditOverrides.delete(r._credInput._activeMbUrl);
            r._credInput._activeMbUrl = null;
          }
          tr.style.background = "#ffe0e0";
          searchInput.disabled = false;
          searchBtn.disabled = false;
          candidateList.innerHTML = "";
          const none = document.createElement("div");
          none.style.cssText = "font-size:0.82rem;color:#888;";
          none.textContent = "No selection \u2014 search or create";
          candidateList.appendChild(none);
          renderActions(null);
          updateImportBtn();
        }
        const ACTION_CHIP_STYLE = "display:inline-flex;align-items:center;justify-content:center;min-width:1.6rem;height:1.6rem;padding:0 0.35rem;font-size:0.95rem;line-height:1;cursor:pointer;border:1px solid #d6d6d6;border-radius:0.3rem;background:#fafafa;";
        function renderActions(selected) {
          tdAction.innerHTML = "";
          if (selected) {
            let recheckUrlBypassCache = function() {
              _urlCheckSessionCache.delete(urlCheckCacheKey);
              try {
                localStorage.removeItem(urlCheckLsKey);
              } catch (e2) {
              }
              queuedUrlCheck(
                () => fetchWithRetry(`//musicbrainz.org/ws/2/url?resource=${encodeURIComponent(discogsHref)}&inc=${entityType}-rels&fmt=json`).then((json) => {
                  const linkedIds = (json.relations || []).filter((r2) => r2[entityType]).map((r2) => r2[entityType].id);
                  const result = linkedIds.includes(selected.id) ? "linked" : linkedIds.length > 0 ? "other" : "none";
                  _urlCheckSessionCache.set(urlCheckCacheKey, result);
                  try {
                    localStorage.setItem(urlCheckLsKey, JSON.stringify({ date: urlCheckToday, result }));
                  } catch (e2) {
                  }
                  applyUrlCheckResult(result);
                }).catch(() => applyUrlCheckResult("none"))
              );
            }, applyUrlCheckResult = function(result) {
              if (result === "linked") {
                linkSlot.textContent = "\u2713";
                linkSlot.title = "Discogs URL already linked to this MB " + entityType;
                linkSlot.style.color = "#5a5";
                linkSlot.style.fontWeight = "bold";
              } else if (result === "other") {
                linkSlot.textContent = "\u26A0\uFE0F";
                linkSlot.title = `Discogs URL is linked to a DIFFERENT MB ${entityType}`;
                linkSlot.style.color = "#c80";
              } else {
                linkSlot.textContent = "";
                linkSlot.style.color = "";
                const addLinkBtn = document.createElement("button");
                addLinkBtn.textContent = "\u{1F517}";
                addLinkBtn.title = "Add Discogs link to MB " + entityType;
                addLinkBtn.style.cssText = ACTION_CHIP_STYLE + "color:#e8771d;";
                addLinkBtn.addEventListener("click", () => {
                  const ltId = entityType === "label" ? "217" : entityType === "place" ? "705" : "180";
                  const p = new URLSearchParams({ [`edit-${entityType}.url.0.text`]: discogsHref, [`edit-${entityType}.url.0.link_type_id`]: ltId });
                  const mbid = selected.id.replace(/.*\//, "").replace(/[^a-f0-9-]/gi, "").substring(0, 36);
                  window.open(`https://musicbrainz.org/${entityType}/${mbid}/edit?${p}`, "_blank", "noopener,noreferrer");
                  linkSlot.innerHTML = "";
                  linkSlot.textContent = "\u2026";
                  linkSlot.title = "Verifying Discogs link on return to this tab\u2026";
                  linkSlot.style.color = "#888";
                  linkSlot.style.fontStyle = "italic";
                  const onReturn = () => {
                    if (document.visibilityState !== "visible") return;
                    document.removeEventListener("visibilitychange", onReturn);
                    window.removeEventListener("focus", onReturn);
                    recheckUrlBypassCache();
                  };
                  document.addEventListener("visibilitychange", onReturn);
                  window.addEventListener("focus", onReturn);
                });
                linkSlot.appendChild(addLinkBtn);
              }
            };
            const linkSlot = document.createElement("span");
            linkSlot.style.cssText = "display:inline-flex;align-items:center;font-size:0.8rem;color:#888;";
            linkSlot.textContent = "\u2026";
            linkSlot.title = "Checking whether MB already has this Discogs URL linked";
            tdAction.appendChild(linkSlot);
            const urlCheckCacheKey = `${selected.id}|${discogsHref}`;
            const urlCheckLsKey = `discogs-urlcheck-${selected.id}-${discogsHref.replace(/[^a-z0-9]/gi, "-").substring(0, 80)}`;
            const urlCheckToday = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
            const urlCheckExpiry = /* @__PURE__ */ new Date();
            urlCheckExpiry.setDate(urlCheckExpiry.getDate() - 7);
            const urlCheckExpiryStr = urlCheckExpiry.toISOString().slice(0, 10);
            let urlCheckCached = _urlCheckSessionCache.get(urlCheckCacheKey) ?? null;
            if (urlCheckCached === null) {
              try {
                const s = JSON.parse(localStorage.getItem(urlCheckLsKey) || "null");
                if (s?.date >= urlCheckExpiryStr) urlCheckCached = s.result;
              } catch (e2) {
              }
              if (urlCheckCached !== null) _urlCheckSessionCache.set(urlCheckCacheKey, urlCheckCached);
            }
            if (!discogsHref) {
              linkSlot.textContent = "\u26A0 No Discogs page";
              linkSlot.style.color = "#c80";
            } else if (urlCheckCached !== null) {
              applyUrlCheckResult(urlCheckCached);
            } else if (Array.isArray(r.urlLinkedIds)) {
              const result = r.urlLinkedIds.includes(selected.id) ? "linked" : r.urlLinkedIds.length > 0 ? "other" : "none";
              _urlCheckSessionCache.set(urlCheckCacheKey, result);
              try {
                localStorage.setItem(urlCheckLsKey, JSON.stringify({ date: urlCheckToday, result }));
              } catch (e2) {
              }
              applyUrlCheckResult(result);
            } else {
              queuedUrlCheck(
                () => fetchWithRetry(`//musicbrainz.org/ws/2/url?resource=${encodeURIComponent(discogsHref)}&inc=${entityType}-rels&fmt=json`).then((json) => {
                  const linkedIds = (json.relations || []).filter((r2) => r2[entityType]).map((r2) => r2[entityType].id);
                  const result = linkedIds.includes(selected.id) ? "linked" : linkedIds.length > 0 ? "other" : "none";
                  _urlCheckSessionCache.set(urlCheckCacheKey, result);
                  try {
                    localStorage.setItem(urlCheckLsKey, JSON.stringify({ date: urlCheckToday, result }));
                  } catch (e2) {
                  }
                  applyUrlCheckResult(result);
                }).catch(() => applyUrlCheckResult("none"))
              );
            }
          }
          function openCreateTab({ name, disambiguation } = {}) {
            const finalName = (name || displayName).trim();
            let createUrl;
            let createParams;
            if (entityType === "artist") {
              createParams = {
                "edit-artist.name": finalName,
                "edit-artist.sort_name": guessSortName(finalName),
                "edit-artist.type_id": "1"
              };
              if (discogsHref) {
                createParams["edit-artist.url.0.text"] = discogsHref;
                createParams["edit-artist.url.0.link_type_id"] = "180";
              }
              if (disambiguation) createParams["edit-artist.comment"] = disambiguation;
              createUrl = "https://musicbrainz.org/artist/create";
            } else {
              const ltId = entityType === "label" ? "217" : "705";
              createParams = {
                [`edit-${entityType}.name`]: finalName,
                [`edit-${entityType}.url.0.text`]: discogsHref,
                [`edit-${entityType}.url.0.link_type_id`]: ltId
              };
              if (disambiguation) createParams[`edit-${entityType}.comment`] = disambiguation;
              createUrl = `https://musicbrainz.org/${entityType}/create`;
            }
            const p = new URLSearchParams(createParams);
            const newTab = window.open(`${createUrl}?${p}`, "_blank");
            if (newTab) {
              const trySet = () => {
                try {
                  newTab.sessionStorage.setItem("discogs-importer-pending-artist", r.entity.resource_url);
                } catch (e2) {
                  setTimeout(trySet, 50);
                }
              };
              trySet();
            }
            const onCreated = (evt) => {
              if (evt.data?.type !== "artist-created") return;
              if (evt.data.resourceUrl !== r.entity.resource_url) return;
              DISCOGS_CHANNEL.removeEventListener("message", onCreated);
              _urlCheckSessionCache.set(`${evt.data.id}|${discogsHref}`, "linked");
              setRowResolved({ id: evt.data.id, name: evt.data.name, disambiguation: evt.data.disambiguation });
            };
            DISCOGS_CHANNEL.addEventListener("message", onCreated);
          }
          const createBtn = document.createElement("button");
          createBtn.textContent = "+";
          createBtn.title = "Create in MB with default Discogs name + URL";
          createBtn.style.cssText = ACTION_CHIP_STYLE + "color:#2a7;font-size:1.15rem;font-weight:600;";
          createBtn.addEventListener("click", () => openCreateTab());
          const createAdvBtn = document.createElement("button");
          createAdvBtn.textContent = "\u25BE";
          createAdvBtn.title = "Create in MB with editable name + disambiguation, pre-filled from the Discogs profile";
          createAdvBtn.style.cssText = ACTION_CHIP_STYLE + "color:#666;";
          createAdvBtn.addEventListener("click", () => openAdvancedCreatePopup());
          tdAction.appendChild(createBtn);
          tdAction.appendChild(createAdvBtn);
          async function openAdvancedCreatePopup() {
            const distinctRoles = [];
            const seen = /* @__PURE__ */ new Set();
            for (const role of r._roles || []) {
              const label = (role.displayLabel || role.linkType || "").trim();
              if (!label || seen.has(label)) continue;
              seen.add(label);
              distinctRoles.push(label);
              if (distinctRoles.length === 3) break;
            }
            const defaultDis = distinctRoles.join(", ");
            const overlay = document.createElement("div");
            overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,0.45);z-index:10000;display:flex;align-items:center;justify-content:center;";
            const modal = document.createElement("div");
            modal.style.cssText = "background:#fff;border-radius:0.5rem;padding:1.1rem 1.35rem 1rem;max-width:600px;width:92%;max-height:82vh;display:flex;flex-direction:column;gap:0.55rem;box-shadow:0 12px 32px rgba(0,0,0,0.32);font-family:inherit;";
            const heading2 = document.createElement("div");
            heading2.style.cssText = "font-weight:bold;font-size:1.02rem;color:#222;margin-bottom:0.15rem;";
            heading2.textContent = `Create ${entityType} in MusicBrainz`;
            modal.appendChild(heading2);
            const FIELD_LABEL = "font-size:0.78rem;color:#666;font-weight:600;letter-spacing:0.02em;text-transform:uppercase;margin-top:0.25rem;";
            const FIELD_INPUT = "padding:0.45rem 0.55rem;border:1px solid #c8c8c8;border-radius:0.3rem;font-size:0.93rem;font-family:inherit;";
            const nameLabel = document.createElement("label");
            nameLabel.style.cssText = FIELD_LABEL;
            nameLabel.textContent = "Name";
            modal.appendChild(nameLabel);
            const nameInput = document.createElement("input");
            nameInput.type = "text";
            nameInput.value = displayName;
            nameInput.style.cssText = FIELD_INPUT;
            modal.appendChild(nameInput);
            let nameUserTouched = false;
            nameInput.addEventListener("input", () => {
              nameUserTouched = true;
            });
            const disLabel = document.createElement("label");
            disLabel.style.cssText = FIELD_LABEL;
            disLabel.textContent = "Disambiguation";
            modal.appendChild(disLabel);
            const disInput = document.createElement("input");
            disInput.type = "text";
            disInput.value = defaultDis;
            disInput.style.cssText = FIELD_INPUT;
            modal.appendChild(disInput);
            let disUserTouched = false;
            disInput.addEventListener("input", () => {
              disUserTouched = true;
            });
            const profileLabel = document.createElement("div");
            profileLabel.style.cssText = "font-size:0.78rem;color:#888;margin-top:0.55rem;";
            profileLabel.textContent = "Discogs profile \u2014 select text to copy into Disambiguation";
            modal.appendChild(profileLabel);
            const profileBox = document.createElement("div");
            profileBox.style.cssText = "border:1px solid #e0e0e0;border-radius:0.3rem;padding:0.5rem 0.6rem;background:#fafafa;font-size:0.85rem;line-height:1.5;white-space:pre-wrap;overflow:auto;min-height:5rem;max-height:18rem;flex:1;color:#444;";
            profileBox.textContent = "Loading profile from Discogs\u2026";
            modal.appendChild(profileBox);
            const captureSelection = () => {
              const sel = window.getSelection();
              if (!sel || sel.isCollapsed) return;
              if (!profileBox.contains(sel.anchorNode)) return;
              const text = sel.toString().trim();
              if (!text) return;
              disInput.value = text;
              disUserTouched = true;
            };
            profileBox.addEventListener("mouseup", captureSelection);
            profileBox.addEventListener("keyup", captureSelection);
            const btnRow2 = document.createElement("div");
            btnRow2.style.cssText = "display:flex;gap:0.5rem;justify-content:flex-end;margin-top:0.55rem;";
            const cancelBtn = document.createElement("button");
            cancelBtn.textContent = "Cancel";
            cancelBtn.style.cssText = "padding:0.4rem 1rem;cursor:pointer;border:1px solid #c8c8c8;border-radius:0.25rem;background:#fafafa;color:#444;font-size:0.88rem;";
            const submitBtn = document.createElement("button");
            submitBtn.textContent = "Create \u2197";
            submitBtn.style.cssText = "padding:0.4rem 1.1rem;cursor:pointer;font-weight:bold;background:#2ecc40;color:#fff;border:none;border-radius:0.25rem;font-size:0.9rem;";
            btnRow2.appendChild(cancelBtn);
            btnRow2.appendChild(submitBtn);
            modal.appendChild(btnRow2);
            overlay.appendChild(modal);
            document.body.appendChild(overlay);
            const close = () => {
              document.removeEventListener("keydown", onKey);
              overlay.remove();
            };
            const submit = () => {
              const name = nameInput.value.trim();
              const dis = disInput.value.trim();
              close();
              openCreateTab({ name: name || displayName, disambiguation: dis || null });
            };
            const onKey = (ev) => {
              if (ev.key === "Escape") {
                close();
              } else if (ev.key === "Enter" && (ev.target === disInput || ev.target === nameInput)) submit();
            };
            document.addEventListener("keydown", onKey);
            overlay.addEventListener("click", (ev) => {
              if (ev.target === overlay) close();
            });
            cancelBtn.addEventListener("click", close);
            submitBtn.addEventListener("click", submit);
            disInput.focus();
            disInput.select();
            try {
              const data = await getDiscogsEntityData(r.entity?.resource_url);
              if (data?.realname && !nameUserTouched && data.realname.trim() !== displayName.trim()) {
                nameInput.value = data.realname.trim();
              }
              const lines = [];
              if (data?.namevariations?.length) lines.push(`Also known as: ${data.namevariations.slice(0, 6).join(", ")}`);
              if (data?.profile) {
                if (lines.length) lines.push("");
                lines.push(data.profile);
              }
              profileBox.textContent = lines.length ? lines.join("\n") : "(no Discogs profile)";
            } catch (e2) {
              profileBox.textContent = "(failed to load Discogs profile)";
            }
          }
        }
        function makeCandidateRow(a) {
          const row = document.createElement("div");
          row.style.cssText = "display:flex;align-items:center;gap:0.35rem;padding:0.2rem 0.35rem;border:1px solid #ddd;border-radius:3px;background:#fff;font-size:0.82rem;";
          const selBtn = document.createElement("button");
          selBtn.textContent = "\u2713";
          selBtn.title = "Select this candidate as the MB match";
          selBtn.style.cssText = "font-size:0.95rem;line-height:1;cursor:pointer;padding:0.1rem 0.45rem;white-space:nowrap;border:1px solid #b5d5b5;border-radius:0.25rem;background:#eaf6ea;color:#2a7;font-weight:600;flex-shrink:0;";
          selBtn.addEventListener("click", () => setRowResolved(a));
          row.appendChild(selBtn);
          const info = document.createElement("span");
          info.style.flex = "1";
          const nameA = document.createElement("a");
          nameA.href = `https://musicbrainz.org/${entityType}/${a.id}`;
          nameA.target = "_blank";
          nameA.rel = "noopener noreferrer nofollow";
          nameA.style.fontWeight = "bold";
          nameA.textContent = a.name;
          info.appendChild(nameA);
          if (a.disambiguation) {
            const d = document.createElement("span");
            d.style.cssText = "color:#777;margin-left:0.25rem;";
            d.textContent = `(${a.disambiguation})`;
            info.appendChild(d);
          }
          row.appendChild(info);
          return row;
        }
        function extractMbid(q) {
          const m = q.match(/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/i);
          return m ? m[0] : null;
        }
        function doSearch(q) {
          if (!q) return;
          const mbid = extractMbid(q);
          if (mbid) {
            candidateList.innerHTML = '<div style="font-size:0.82rem;color:#888;">Looking up MBID\u2026</div>';
            mbThrottle.fetchJson(`//musicbrainz.org/ws/2/${entityType}/${mbid}?fmt=json`).then((json) => {
              if (!json) return;
              candidateList.innerHTML = "";
              if (json.id) {
                candidateList.appendChild(makeCandidateRow({
                  id: json.id,
                  name: json.name,
                  disambiguation: json.disambiguation || ""
                }));
              } else {
                candidateList.innerHTML = '<div style="font-size:0.82rem;color:#888;">Not found</div>';
              }
            }).catch(() => {
              candidateList.innerHTML = `<div style="font-size:0.82rem;color:#c00;">MBID not found or wrong entity type</div>`;
            });
            return;
          }
          candidateList.innerHTML = '<div style="font-size:0.82rem;color:#888;font-style:italic;">Searching\u2026</div>';
          mbThrottle.fetchJson(`//musicbrainz.org/ws/2/${entityType}?query=${encodeURIComponent(q)}&fmt=json&limit=8`).then((json) => {
            if (!json) {
              candidateList.innerHTML = '<div style="font-size:0.82rem;color:#c00;">Search failed \u2014 MB unavailable</div>';
              return;
            }
            candidateList.innerHTML = "";
            const resultKey = entityType === "label" ? "labels" : entityType === "place" ? "places" : "artists";
            if (!json[resultKey] || json[resultKey].length === 0) {
              const none = document.createElement("div");
              none.style.cssText = "font-size:0.82rem;color:#888;";
              none.textContent = "No results";
              candidateList.appendChild(none);
            } else {
              json[resultKey].forEach((a) => candidateList.appendChild(makeCandidateRow(a)));
            }
          }).catch(() => {
            candidateList.innerHTML = '<div style="font-size:0.82rem;color:#c00;">Search failed</div>';
          });
        }
        let searchTimer;
        searchInput.addEventListener("input", () => {
          clearTimeout(searchTimer);
          searchTimer = setTimeout(() => doSearch(searchInput.value.trim()), 300);
        });
        searchInput.addEventListener("keydown", (ev) => {
          if (ev.key === "Enter") {
            ev.preventDefault();
            doSearch(searchInput.value.trim());
          }
        });
        searchBtn.addEventListener("click", () => doSearch(searchInput.value.trim()));
        if (isResolved && initMbUrl) {
          const mbid = initMbUrl.replace(/.*\//, "").replace(/[^a-f0-9-]/gi, "").substring(0, 36);
          const correctedMbUrl = `//musicbrainz.org/${entityType}/${mbid}`;
          const displayName2 = initMbName || mbid;
          if (!initMbName) {
            rowState.set(_entityKey, { mbUrl: initMbUrl, mbName: null, mbDisambig: "", confirmed: true, via: r.logEntry?.via || null, fromCache: r.logEntry?.fromCache || false });
            tr.style.background = "#fff8e1";
          }
          const fakeA = { id: mbid, name: displayName2, disambiguation: initMbDisam };
          candidateList.innerHTML = "";
          const selRow = document.createElement("div");
          selRow.style.cssText = "padding:0.15rem 0.4rem;border:1px solid #5a5;border-radius:3px;background:#e8f8e8;display:flex;align-items:center;gap:0.4rem;font-size:0.85rem;";
          const selA = document.createElement("a");
          selA.href = "https:" + correctedMbUrl;
          selA.target = "_blank";
          selA.rel = "noopener noreferrer nofollow";
          selA.textContent = "\u2713 " + displayName2 + (initMbDisam ? ` (${initMbDisam})` : "") + (!initMbName ? " \u26A0 name unknown" : "");
          selA.style.fontWeight = "bold";
          const undoBtn = document.createElement("button");
          undoBtn.textContent = "\u2715";
          undoBtn.title = "Clear selection";
          undoBtn.style.cssText = "font-size:0.75rem;cursor:pointer;padding:0 0.3rem;margin-left:auto;";
          undoBtn.addEventListener("click", () => setRowUnresolved());
          selRow.appendChild(selA);
          const viaBadge = makeViaBadge(r.logEntry?.via, r.logEntry?.fromCache);
          if (viaBadge) selRow.appendChild(viaBadge);
          selRow.appendChild(undoBtn);
          candidateList.appendChild(selRow);
          renderActions(fakeA);
        } else if (r.nameMatches && r.nameMatches.length > 0) {
          r.nameMatches.forEach((a) => candidateList.appendChild(makeCandidateRow(a)));
          renderActions(null);
        } else {
          const none = document.createElement("div");
          none.style.cssText = "font-size:0.82rem;color:#888;";
          none.textContent = needsAttention ? "No suggestions \u2014 search or create" : "";
          if (needsAttention) candidateList.appendChild(none);
          renderActions(null);
        }
      });
      table.appendChild(tbody);
      panel.appendChild(table);
      const btnRow = document.createElement("div");
      btnRow.style.cssText = "display:flex;gap:0.75rem;align-items:center;margin-top:0.75rem;flex-wrap:wrap;";
      const importBtn = document.createElement("button");
      importBtn.style.cssText = "border:none;padding:0.4rem 1.1rem;border-radius:0.3rem;cursor:pointer;font-weight:bold;font-size:0.95rem;";
      const issueNote = document.createElement("span");
      issueNote.style.cssText = "font-size:0.85rem;color:#7a5c00;";
      function updateImportBtn() {
        const unresolved = [...rowState.values()].filter((s) => !s.confirmed).length;
        const mismatch = [...rowState.values()].filter((s) => s.confirmed && s.mbName && s.mbName.toLowerCase().trim() !== s.mbUrl).length;
        if (unresolved === 0) {
          importBtn.textContent = "Start import \u2192";
          importBtn.style.background = "#2ecc40";
          importBtn.style.color = "#fff";
          issueNote.textContent = "";
        } else {
          importBtn.textContent = `Start import anyway \u2192`;
          importBtn.style.background = "#e0a800";
          importBtn.style.color = "#fff";
          issueNote.textContent = `\u26A0 ${unresolved} artist(s) unresolved \u2014 they will be skipped`;
        }
      }
      updateImportBtn();
      function buildStaticTableLi() {
        const tbl = document.createElement("table");
        tbl.style.cssText = "border-collapse:collapse;width:100%;font-size:0.78rem;margin:0.4rem 0;";
        const thRow = document.createElement("tr");
        thRow.style.background = "#f5f5f5";
        ["Discogs entity", "Roles / Tracks", "MB match", "MBID", "Resolved via"].forEach((h) => {
          const th = document.createElement("th");
          th.style.cssText = "text-align:left;padding:0.2rem 0.4rem;border:1px solid #ddd;white-space:nowrap;";
          th.textContent = h;
          thRow.appendChild(th);
        });
        tbl.appendChild(thRow);
        allResults.forEach((r) => {
          const _rKey = r.entity?.resource_url || r.entity?._syntheticKey || `_nourl_${r.entity?.name || r.displayName}`;
          const state = rowState.get(_rKey) || {};
          const tr2 = document.createElement("tr");
          const url = r.entity?.resource_url || r.entity?._syntheticKey || "";
          const rolesList2 = url ? rolesMap.get(url) || companiesRolesMap.get(url) || [] : [];
          const grouped2 = /* @__PURE__ */ new Map();
          rolesList2.forEach(({ displayLabel, linkType, trackPos }) => {
            const key = displayLabel || linkType;
            if (!grouped2.has(key)) grouped2.set(key, /* @__PURE__ */ new Set());
            if (trackPos) grouped2.get(key).add(trackPos);
          });
          const rolesText = [...grouped2.entries()].map(([label, tr]) => label + (tr.size ? " [" + [...tr].join(",") + "]" : "")).join("; ");
          const mbid = state.mbUrl ? state.mbUrl.replace(/.*\//, "").replace(/[^a-f0-9-]/gi, "").substring(0, 36) : "";
          const matchText = state.mbName || (state.mbUrl ? mbid : "");
          const vCfg = state.via ? viaCfg(state.via, state.fromCache) : null;
          const viaText = vCfg ? vCfg.text : state.mbUrl ? "\u2014" : "";
          [r.displayName || r.entity?.name, rolesText, matchText, mbid, viaText].forEach((val, ci) => {
            const td = document.createElement("td");
            td.style.cssText = "padding:0.15rem 0.4rem;border:1px solid #ddd;" + (ci === 2 && !val ? "color:#aaa;" : ci === 2 ? "color:#060;" : ci === 4 && vCfg ? `color:${vCfg.color};` : "");
            if (ci === 2 && mbid) {
              const a = document.createElement("a");
              a.href = "https:" + state.mbUrl;
              a.target = "_blank";
              a.rel = "noopener noreferrer nofollow";
              a.textContent = val || mbid;
              td.appendChild(a);
            } else {
              td.textContent = val || (ci === 1 ? "" : ci === 2 ? "\u2014" : "");
            }
            tr2.appendChild(td);
          });
          tbl.appendChild(tr2);
        });
        const tblLi = document.createElement("li");
        tblLi.style.cssText = "list-style:none;margin:0;padding:0;";
        tblLi.appendChild(tbl);
        return tblLi;
      }
      importBtn.addEventListener("click", () => {
        const confirmedMap = /* @__PURE__ */ new Map();
        rowState.forEach((s, key) => {
          if (s.mbUrl) confirmedMap.set(key, s.mbUrl);
        });
        getLogContainer().appendChild(buildStaticTableLi());
        const unresolvedCount = allResults.filter((r) => {
          const _k = r.entity?.resource_url || r.entity?._syntheticKey || `_nourl_${r.entity?.name || r.displayName}`;
          return !rowState.get(_k)?.confirmed;
        }).length;
        if (unresolvedCount > 0) {
          const unresolvedLi = document.createElement("li");
          unresolvedLi.style.cssText = "list-style:none;margin:0.2rem 0;font-size:0.82rem;color:#a06000;";
          unresolvedLi.textContent = `\u26A0 ${unresolvedCount} entity/entities unresolved \u2014 will be skipped`;
          getLogContainer().appendChild(unresolvedLi);
        }
        confirmedMap.unresolvedCount = unresolvedCount;
        confirmedMap.totalEntities = allResults.length;
        confirmedMap.creditOverrides = creditOverrides;
        (panelLi || panel).remove();
        resolve(confirmedMap);
      });
      btnRow.appendChild(importBtn);
      btnRow.appendChild(issueNote);
      panel.appendChild(btnRow);
      const panelLi = document.createElement("li");
      panelLi.style.cssText = "list-style:none;margin:0;padding:0;";
      panelLi.classList.add("discogs-review-panel-li");
      panelLi._buildStaticTableLi = buildStaticTableLi;
      panelLi.appendChild(panel);
      getLogContainer().appendChild(panelLi);
      getLogContainer().scrollIntoView({ behavior: "smooth", block: "nearest" });
      _hideBar();
    });
  }

  // src/editor-state.js
  async function waitForMBEditor(timeoutMs = 15e3) {
    log.info("Waiting for MB relationship editor\u2026");
    let waited = 0;
    while (waited < timeoutMs) {
      const MB = pageWindow.MB;
      const re = MB?.relationshipEditor;
      const st = re?.state;
      if (st?.entity) {
        log.info(`Editor ready (${waited}ms). Release: "${st.entity.name}"`);
        return re;
      }
      if (waited % 2e3 === 0 && waited > 0) {
        const mbKeys = MB ? Object.keys(MB).join(", ") : "undefined";
        const reKeys = re ? Object.keys(re).join(", ") : "undefined";
        const stKeys = st ? Object.keys(st).join(", ") : "undefined";
        log.info(`[${waited}ms] MB={${mbKeys}} re={${reKeys}} state={${stKeys}}`);
      }
      await new Promise((r) => setTimeout(r, 200));
      waited += 200;
    }
    log.error("MB editor not ready after 15s \u2014 aborting");
    return null;
  }
  function dispatchRelationship(re, sourceEntity, targetEntity, linkTypeID, credit, attributes, trackPos) {
    const swapped = sourceEntity.entityType > targetEntity.entityType;
    const e0 = swapped ? targetEntity : sourceEntity;
    const e1 = swapped ? sourceEntity : targetEntity;
    const ltEntry = pageWindow.MB?.linkedEntities?.link_type?.[linkTypeID];
    const ltName = ltEntry ? ltEntry.name : linkTypeID;
    let attrDesc = "";
    if (attributes) {
      try {
        const parts = [];
        for (const a of pageWindow.MB.tree.iterate(attributes)) {
          const n = a.type?.name || a.typeID;
          const v = a.text_value ? `=${a.text_value}` : "";
          if (n) parts.push(n + v);
        }
        if (parts.length) attrDesc = ` [${parts.join(", ")}]`;
      } catch (e) {
      }
    }
    const posLabel = trackPos != null && trackPos !== "" ? ` <span style="color:#888;font-size:0.85em">#${trackPos}</span>` : "";
    log.info(`\u2192 <strong>${ltName}</strong>${attrDesc}${posLabel}: ${sourceEntity.name || sourceEntity.gid} \u2194 ${targetEntity.name || targetEntity.gid}${credit && credit !== (targetEntity.name || targetEntity.gid) ? ` (credited: ${credit})` : ""}`);
    re.dispatch({
      type: "update-relationship-state",
      sourceEntity,
      batchSelectionCount: null,
      creditsToChangeForSource: "",
      creditsToChangeForTarget: "",
      oldRelationshipState: null,
      newRelationshipState: {
        ...REL_TEMPLATE,
        entity0: e0,
        entity0_credit: swapped ? credit || "" : "",
        entity1: e1,
        entity1_credit: swapped ? "" : credit || "",
        id: re.getRelationshipStateId(),
        linkTypeID,
        attributes: attributes || null
      }
    });
  }
  function buildAttributes(rawAttributes) {
    if (!rawAttributes || rawAttributes.length === 0) return null;
    const MB = pageWindow.MB;
    const tree = MB?.tree;
    const lat = MB?.linkedEntities?.link_attribute_type;
    if (!tree || !lat) return null;
    function findAttrByName(name) {
      const lower = name.toLowerCase().trim();
      for (const v of Object.values(lat)) {
        if (v.name?.toLowerCase() === lower) return v;
      }
      if (lower.length >= 4) {
        for (const v of Object.values(lat)) {
          const vl = v.name?.toLowerCase() || "";
          if (vl.length < 4) continue;
          if (vl.includes(lower) || lower.includes(vl)) return v;
        }
      }
      log.warn(`Attribute "${name}" not found in MB \u2014 dropping attribute but keeping the rel`);
      return null;
    }
    function extractFnValue(fn) {
      const src = fn.toString();
      const m = src.match(/,\s*['"`]([^'"`]+)['"`]\s*\)/);
      return m ? m[1] : null;
    }
    const attrObjs = [];
    const seen = /* @__PURE__ */ new Set();
    for (const attr of rawAttributes) {
      let attrName = null;
      let textValue = "";
      if (typeof attr === "string") {
        attrName = attr;
      } else if (attr && typeof attr === "object" && attr._type) {
        if (attr._type === "task") {
          attrName = "task";
          textValue = attr.value;
        } else {
          attrName = attr.value;
        }
      } else if (typeof attr === "function") {
        attrName = extractFnValue(attr);
      }
      if (!attrName) continue;
      const found = findAttrByName(attrName);
      if (!found || seen.has(found.id)) continue;
      seen.add(found.id);
      attrObjs.push({ type: found, typeID: found.id, credited_as: "", text_value: textValue });
    }
    if (attrObjs.length === 0) return null;
    attrObjs.sort((a, b) => a.typeID - b.typeID);
    try {
      return tree.fromDistinctAscArray(attrObjs);
    } catch (e) {
      log.warn(`Attribute tree build failed (${e.message}) \u2014 importing without attributes`);
      return null;
    }
  }

  // src/edit-note.js
  function buildEditNote(discogsUrl, opts, extraLines) {
    const s = GM_info.script;
    const mbUrl = location.href.replace(/\/edit-relationships$/, "");
    const homepage = s.homepageURL || s.homepage || "https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/discogs_credits/README.md";
    const header = s.name + " v" + s.version + " by " + s.author + " - " + homepage;
    const lines = [
      header,
      "",
      "Release URL: " + mbUrl,
      "Discogs URL: " + discogsUrl
    ];
    if (opts) lines.push("Options: " + opts);
    if (extraLines) lines.push(...Array.isArray(extraLines) ? extraLines : [extraLines]);
    return lines.join("\n");
  }

  // src/data/work-only-rels.js
  var WORK_ONLY_ARTIST_RELS = [
    "writer",
    "composer",
    "lyricist",
    "librettist",
    "revised by",
    "translator",
    "reconstructed by",
    // 'arranger',
    // 'instruments arranger',
    "orchestrator",
    // 'vocals arranger',
    "previously attributed to",
    "miscellaneous support",
    "dedicated to",
    "premiered by",
    "was commissioned by",
    "publisher",
    "inspired the name of"
  ];

  // src/dispatch.js
  async function dispatchAllRelationships(companies, artistRoles, tracklistRels, applyToTracks, createWorksMode, discogsTracklist, processTracklist, resolvedEntityTypes, confirmedMap, discogsUrl, dedupOpts) {
    resolvedEntityTypes = resolvedEntityTypes || /* @__PURE__ */ new Map();
    confirmedMap = confirmedMap || /* @__PURE__ */ new Map();
    dedupOpts = dedupOpts || {};
    const dedupeEquivalenceSets = dedupOpts.dedupeEquivalenceSets !== false;
    const dedupeDuplicateRoles = dedupOpts.dedupeDuplicateRoles !== false;
    const creditOverrides = dedupOpts.creditOverrides || /* @__PURE__ */ new Map();
    const re = await waitForMBEditor();
    if (!re) return;
    const MB = pageWindow.MB;
    const equivalenceLookup = (() => {
      const m = /* @__PURE__ */ new Map();
      if (!dedupeEquivalenceSets || !MB?.linkedEntities?.link_type) return m;
      for (const set of EQUIVALENCE_SETS) {
        const byPair = /* @__PURE__ */ new Map();
        for (const [id, lt] of Object.entries(MB.linkedEntities.link_type)) {
          if (!lt?.name) continue;
          if (!set.includes(String(lt.name).toLowerCase())) continue;
          const key = `${lt.type0}|${lt.type1}`;
          if (!byPair.has(key)) byPair.set(key, []);
          byPair.get(key).push(Number(id));
        }
        for (const ids of byPair.values()) {
          if (ids.length < 2) continue;
          const sibSet = new Set(ids);
          for (const id of ids) m.set(id, sibSet);
        }
      }
      return m;
    })();
    const releaseEntity = re.state.entity;
    let added = 0, existedInMb = 0, dedupedThisSession = 0, skipped = 0, failed = 0;
    const dispatchedThisSession = /* @__PURE__ */ new Set();
    const RECORDING_LINK_TYPES = /* @__PURE__ */ new Set([
      "performer",
      "instrument",
      "vocal",
      "vocals",
      "orchestra",
      "conductor",
      "concertmaster",
      "chorus master",
      "producer",
      "engineer",
      "mix",
      "recording",
      "remixer",
      "DJ-mixer",
      "additional",
      "guest",
      "programming"
      // NOT 'mastering' — MB deprecated artist→recording mastering (link type 136).
    ]);
    log.info(`Starting instant fill: ${companies.length} companies, ${artistRoles.length} release artist roles, ${tracklistRels.length} tracklist roles`);
    try {
      _showBar();
    } catch (_) {
    }
    const bar = document.querySelector(".discogs-bar");
    function tickProgress() {
      const done = added + skipped + failed;
      const est = Math.max(done + 1, companies.length + artistRoles.length + tracklistRels.length);
      const pct = Math.min(Math.round(done / est * 99), 99);
      const _pct = document.querySelector("#discogs-progress-pct");
      if (_pct) _pct.textContent = pct + "%";
      try {
        _setProgressPct(pct);
      } catch (_) {
      }
    }
    const recordingByGid = /* @__PURE__ */ new Map();
    const recordingByPosition = /* @__PURE__ */ new Map();
    const editorWorkByRecGid = /* @__PURE__ */ new Map();
    const positionByGid = /* @__PURE__ */ new Map();
    let trackCount = 0;
    try {
      let mediumIndex = 0;
      for (const [mediumKey, medium] of MB.tree.iterate(re.state.mediums)) {
        mediumIndex++;
        const tracks = medium?.tracks ?? medium;
        let trackIndex = 0;
        for (const rawTrack of MB.tree.iterate(tracks)) {
          const trackObj = Array.isArray(rawTrack) ? rawTrack[1] : rawTrack;
          const trackKey = Array.isArray(rawTrack) ? rawTrack[0] : null;
          const rec = trackObj?.recording ?? trackObj;
          if (!rec) continue;
          trackCount++;
          if (rec.gid) {
            recordingByGid.set(rec.gid, rec);
            positionByGid.set(rec.gid, `${mediumIndex}-${trackIndex + 1}`);
            const rw = trackObj?.relatedWorks;
            if (rw && rw.size > 0) {
              try {
                for (const entry of MB.tree.iterate(rw)) {
                  const raw = Array.isArray(entry) ? entry[1] : entry;
                  const work = raw?.work ?? raw;
                  if (work?.gid || work?.id) {
                    editorWorkByRecGid.set(rec.gid, work);
                    break;
                  }
                }
              } catch (e) {
              }
            }
          }
          const positions = new Set([
            trackObj?.position,
            trackObj?.number,
            rec?.position,
            rec?.number,
            trackKey,
            trackIndex + 1,
            // Compound keys: "mediumIndex-trackPosition"
            `${mediumIndex}-${trackIndex + 1}`,
            trackObj?.position != null ? `${mediumIndex}-${trackObj.position}` : null,
            trackObj?.number != null ? `${mediumIndex}-${trackObj.number}` : null
          ].filter((x) => x != null).map(String));
          for (const p of positions) recordingByPosition.set(p, rec);
          trackIndex++;
        }
      }
      log.info(`Found ${trackCount} track(s) in editor state (${recordingByGid.size} with GID, ${recordingByPosition.size} position entries: ${[...recordingByPosition.keys()].join(",")}). relatedWorks: ${editorWorkByRecGid.size} pre-linked`);
    } catch (e) {
      log.warn(`Iterating MB state: ${e.message}`);
    }
    const positionToGid = /* @__PURE__ */ new Map();
    try {
      const relMbid = releaseEntity.gid;
      log.info(`WS2: fetching recordings for release ${relMbid}\u2026`);
      const wsJson = await fetchWithRetry(`/ws/2/release/${relMbid}?inc=recordings&fmt=json`);
      log.info(`WS2: response received`);
      if (wsJson) {
        const mediaCount = wsJson.media?.length ?? 0;
        log.info(`WS2: ${mediaCount} medium/media in response`);
        const mediaArr = wsJson.media || [];
        const isMultiMedium2 = mediaArr.length > 1;
        for (const medium of mediaArr) {
          const medPos = medium.position;
          for (const track of medium.tracks || []) {
            const gid = track.recording?.id;
            if (!gid) continue;
            if (medPos != null && track.position != null) {
              positionToGid.set(`${medPos}-${track.position}`, gid);
            }
            if (medPos != null && track.number != null) {
              positionToGid.set(`${medPos}-${track.number}`, gid);
            }
            if (!isMultiMedium2) {
              if (track.position != null) positionToGid.set(String(track.position), gid);
              if (track.number != null) positionToGid.set(String(track.number), gid);
            }
          }
        }
        log.info(`WS2 position map: ${positionToGid.size} entries (${[...positionToGid.keys()].sort().join(", ")})`);
      }
    } catch (e) {
      log.warn(`WS2 recording fetch failed: ${e.message} \u2014 using editor state positions only`);
    }
    const isMultiMedium = positionToGid.size > 0 && [...positionToGid.keys()].some((k) => /^[2-9]-/.test(k));
    function inferDiscFromVinylSide(pos) {
      const m = String(pos || "").match(/^([A-Z])\d+$/i);
      if (!m) return null;
      return Math.floor((m[1].toUpperCase().charCodeAt(0) - 65) / 2) + 1;
    }
    function getRecordingEntity(track) {
      const stripPad = (s) => String(s).replace(/-0+(\d)/g, "-$1");
      const pos = track.position != null ? String(track.position) : "";
      const num = track.number != null ? String(track.number) : "";
      const compounds = /* @__PURE__ */ new Set();
      const plain = /* @__PURE__ */ new Set();
      if (/^\d+-/.test(pos)) {
        compounds.add(pos);
        const unpadded = stripPad(pos);
        if (unpadded !== pos) compounds.add(unpadded);
      } else if (pos) {
        plain.add(pos);
        const inferredDisc = inferDiscFromVinylSide(pos);
        if (inferredDisc != null) compounds.add(`${inferredDisc}-${pos}`);
        for (let m = 1; m <= 10; m++) compounds.add(`${m}-${pos}`);
      }
      if (num && num !== pos) {
        plain.add(num);
        for (let m = 1; m <= 10; m++) compounds.add(`${m}-${num}`);
      }
      const tryKeys = isMultiMedium ? [...compounds] : [...plain, ...compounds];
      for (const c of tryKeys) {
        const gid = positionToGid.get(c);
        if (gid) {
          const rec = recordingByGid.get(gid);
          if (rec) return rec;
          log.warn(`Recording ${gid} for track ${track.position} not in editor state`);
          return null;
        }
      }
      for (const c of tryKeys) {
        const rec = recordingByPosition.get(c);
        if (rec) return rec;
      }
      if (trackCount > 0) {
        const ws2Keys = positionToGid.size ? [...positionToGid.keys()].join(", ") : "(empty)";
        const stateKeys = recordingByPosition.size ? [...recordingByPosition.keys()].join(", ") : "(empty)";
        log.warn(`No recording for track ${track.position} "${track.title}". WS2 keys: ${ws2Keys} | State keys: ${stateKeys}`);
      }
      return null;
    }
    function confirmedMbUrl(entity) {
      if (!entity) return null;
      const direct = confirmedMap.get(entity.resource_url) || confirmedMap.get(entity._syntheticKey) || null;
      if (direct) return direct;
      if (entity.name) {
        return confirmedMap.get(`_nourl_${entity.name}`) || null;
      }
      return null;
    }
    function relAlreadyExists(sourceEntity, linkTypeID, targetGid, attrTree) {
      const rels = sourceEntity?.relationships;
      if (!Array.isArray(rels) || rels.length === 0) return null;
      const acceptableLinkTypes = equivalenceLookup.get(linkTypeID) || /* @__PURE__ */ new Set([linkTypeID]);
      const candSig = (() => {
        if (!attrTree) return "";
        try {
          return [...pageWindow.MB.tree.iterate(attrTree)].map((a) => `${a.typeID}:${a.text_value || ""}`).sort().join(",");
        } catch (e) {
          return "";
        }
      })();
      const lookupName = (id) => {
        try {
          return pageWindow.MB.linkedEntities.link_type[id]?.name || `#${id}`;
        } catch (e) {
          return `#${id}`;
        }
      };
      let dupMatch = null;
      for (const r of rels) {
        if (!acceptableLinkTypes.has(r.linkTypeID)) continue;
        const tgt = r.target?.gid || r.entity0?.gid || r.entity1?.gid;
        if (tgt !== targetGid) continue;
        const isEquivalent = r.linkTypeID !== linkTypeID;
        const existingSig = (r.attributes || []).map((a) => `${a.typeID}:${a.text_value || ""}`).sort().join(",");
        const exactMatch = existingSig === candSig;
        if (exactMatch) {
          return { kind: isEquivalent ? "equivalence" : "exact", existingLinkName: lookupName(r.linkTypeID) };
        }
        if (dedupeDuplicateRoles && !dupMatch) {
          dupMatch = { kind: isEquivalent ? "equivalence" : "duplicate-role", existingLinkName: lookupName(r.linkTypeID) };
        }
      }
      return dupMatch;
    }
    async function processOne(sourceEntity, entityType0, entityType1, linkTypeName, mbUrl, rawAttributes, credit, trackPos) {
      const overrideCredit = creditOverrides.get(mbUrl);
      if (overrideCredit && String(overrideCredit).trim()) {
        credit = String(overrideCredit).trim();
      }
      const mbid = mbUrl.replace(/.*\//, "").replace(/[^a-f0-9-]/gi, "").substring(0, 36);
      if (!mbid) {
        log.error(`Bad MBID URL: ${mbUrl}`);
        failed++;
        return;
      }
      const linkTypeID = resolveLinkTypeId(linkTypeName, entityType0, entityType1);
      if (!linkTypeID) {
        failed++;
        return;
      }
      const attrTree = buildAttributes(rawAttributes);
      const attrSig = attrTree ? (() => {
        try {
          return [...pageWindow.MB.tree.iterate(attrTree)].map((a) => a.typeID || "").join(",");
        } catch (e) {
          return "";
        }
      })() : "";
      const sessionKey = `${sourceEntity.gid}|${linkTypeID}|${mbid}|${attrSig}`;
      if (dispatchedThisSession.has(sessionKey)) {
        log.info(`Skipped duplicate dispatch of <strong>${linkTypeName}</strong>: ${sourceEntity.name} \u2194 ${credit || ""} \u2014 already queued earlier this run`);
        dedupedThisSession++;
        return;
      }
      dispatchedThisSession.add(sessionKey);
      let targetEntity;
      try {
        targetEntity = await fetchMBEntity(mbid);
      } catch (e) {
        log.error(`Entity fetch failed for ${mbid}: ${e.message}`);
        failed++;
        return;
      }
      const PLACE_TO_LABEL_LINK = {
        "glass mastered at": "glass mastered",
        "mastered at": "mastering",
        "pressed at": "pressed",
        "manufactured at": "manufactured",
        "recorded at": "engineer",
        "mixed at": "mix"
      };
      const LABEL_TO_PLACE_LINK = Object.fromEntries(
        Object.entries(PLACE_TO_LABEL_LINK).map(([k, v]) => [v, k])
      );
      let resolvedLinkTypeID = linkTypeID;
      if (targetEntity.entityType !== entityType1 && targetEntity.entityType !== entityType0) {
        const at = targetEntity.entityType;
        const [rt0, rt1] = at < sourceEntity.entityType ? [at, sourceEntity.entityType] : [sourceEntity.entityType, at];
        let reResolved = resolveLinkTypeId(linkTypeName, rt0, rt1);
        if (!reResolved) {
          const altName = at === "label" ? PLACE_TO_LABEL_LINK[linkTypeName] : LABEL_TO_PLACE_LINK[linkTypeName];
          if (altName) reResolved = resolveLinkTypeId(altName, rt0, rt1);
        }
        if (reResolved) {
          resolvedLinkTypeID = reResolved;
        } else {
          log.warn(`Entity "${targetEntity.name}" is a ${targetEntity.entityType} but expected ${entityType0}/${entityType1} \u2014 link type "${linkTypeName}" may not apply`);
        }
      }
      const dedupHit = relAlreadyExists(sourceEntity, resolvedLinkTypeID, targetEntity.gid, attrTree);
      if (dedupHit) {
        const pair = `${sourceEntity.name} \u2194 ${targetEntity.name}${credit && credit !== targetEntity.name ? ` (credited: ${credit})` : ""}`;
        const existing = dedupHit.existingLinkName;
        if (dedupHit.kind === "equivalence") {
          log.info(`Deduplication (equivalence sets): <strong>${linkTypeName}</strong> not added \u2014 equivalent <strong>${existing}</strong> already on ${pair}`);
        } else if (dedupHit.kind === "duplicate-role") {
          log.info(`Deduplication (duplicate roles): <strong>${linkTypeName}</strong> not added \u2014 same role already exists with different attributes on ${pair}`);
        } else {
          log.info(`Already in MB: <strong>${linkTypeName}</strong>: ${pair}`);
        }
        existedInMb++;
        return;
      }
      dispatchRelationship(re, sourceEntity, targetEntity, resolvedLinkTypeID, credit, attrTree, trackPos);
      added++;
    }
    async function dispatchCompanies() {
      for (const company of companies) {
        const details = ENTITY_TYPE_MAP[company.entity_type_name];
        if (!details) continue;
        const resolvedEt = resolvedEntityTypes.get(company.resource_url) || details.entityType;
        if (resolvedEt !== details.entityType) {
          if (details.entityType === "place" && resolvedEt === "label") {
            log.warn(`Skipped ${company.name}: MB has no "${details.linkType}" relationship for labels (only places). Add manually if needed.`);
            skipped++;
            tickProgress();
            continue;
          }
        }
        const mbUrl = confirmedMbUrl(company);
        if (!mbUrl) {
          log.warn(`Skipped ${company.name} \u2014 not resolved in review`);
          skipped++;
          tickProgress();
          continue;
        }
        const et = resolvedEt;
        const [t0, t1] = et <= "release" ? [et, "release"] : ["release", et];
        await processOne(releaseEntity, t0, t1, details.linkType, mbUrl, [], "");
        tickProgress();
      }
    }
    async function dispatchReleaseArtists() {
      for (const role of artistRoles) {
        if (applyToTracks && RECORDING_LINK_TYPES.has(role.linkType)) continue;
        if (WORK_ONLY_ARTIST_RELS.includes(role.linkType)) continue;
        const mbUrl = confirmedMbUrl(role.artist);
        if (!mbUrl) {
          log.warn(`Skipped ${role.artist.name} (${role.linkType}) \u2014 not resolved in review`);
          skipped++;
          tickProgress();
          continue;
        }
        const credit = role.artist.anv?.trim() || role.artist.name;
        await processOne(releaseEntity, "artist", "release", role.linkType, mbUrl, role.attributes || [], credit);
        tickProgress();
      }
    }
    async function dispatchTracklist() {
      if (applyToTracks && recordingByGid.size > 0) {
        const applicable = artistRoles.filter((role) => RECORDING_LINK_TYPES.has(role.linkType) && !WORK_ONLY_ARTIST_RELS.includes(role.linkType));
        if (applicable.length > 0) {
          log.info(`Applying ${applicable.length} release credit(s) to ${recordingByGid.size} recording(s)\u2026`);
          for (const role of applicable) {
            const mbUrl = confirmedMbUrl(role.artist);
            if (!mbUrl) {
              log.warn(`Skipped ${role.artist.name} (${role.linkType}) in applyToTracks \u2014 not resolved in review`);
              continue;
            }
            const credit = role.artist.anv?.trim() || role.artist.name;
            for (const recEntity of recordingByGid.values()) {
              await processOne(recEntity, "artist", "recording", role.linkType, mbUrl, role.attributes || [], credit, positionByGid.get(recEntity.gid) || "*");
            }
          }
        }
      }
    }
    async function dispatchWorks() {
      const recordingOfLinkTypeId = resolveLinkTypeId("performance", "recording", "work");
      const includeOnlyResolved = createWorksMode === "when-needed";
      const workOnlyByGid = /* @__PURE__ */ new Map();
      for (const role of tracklistRels) {
        if (!WORK_ONLY_ARTIST_RELS.includes(role.linkType)) continue;
        if (includeOnlyResolved && !confirmedMbUrl(role.artist)) continue;
        const recEntity = getRecordingEntity(role.track);
        if (!recEntity) {
          log.error(`Work-only rel for track ${role.track.position} "${role.track.title}" \u2014 no recording found, skipped`);
          failed++;
          continue;
        }
        if (!workOnlyByGid.has(recEntity.gid)) workOnlyByGid.set(recEntity.gid, []);
        workOnlyByGid.get(recEntity.gid).push({ role, recEntity });
      }
      for (const role of artistRoles) {
        if (!WORK_ONLY_ARTIST_RELS.includes(role.linkType)) continue;
        if (includeOnlyResolved && !confirmedMbUrl(role.artist)) continue;
        for (const recEntity of recordingByGid.values()) {
          const syntheticRole = { ...role, track: { position: "", title: recEntity.name || "" } };
          if (!workOnlyByGid.has(recEntity.gid)) workOnlyByGid.set(recEntity.gid, []);
          workOnlyByGid.get(recEntity.gid).push({ role: syntheticRole, recEntity });
        }
      }
      if (createWorksMode === "when-missing" && recordingOfLinkTypeId) {
        for (const recEntity of recordingByGid.values()) {
          if (!workOnlyByGid.has(recEntity.gid)) {
            workOnlyByGid.set(recEntity.gid, []);
          }
        }
      }
      if (workOnlyByGid.size === 0) return;
      if (!recordingOfLinkTypeId) {
        log.error('Could not resolve "performance" link type \u2014 work processing skipped');
        return;
      }
      log.info(`Processing work relationships for ${workOnlyByGid.size} recording(s)\u2026`);
      const existingWorkByRecGid = editorWorkByRecGid;
      log.info(`Editor state: ${existingWorkByRecGid.size} recording(s) already have a linked work`);
      function getWorkFromEditorState(recEntity) {
        try {
          for (const rel of MB.tree.iterate(recEntity.relationships)) {
            if (rel._status === 1 && rel.linkTypeID === recordingOfLinkTypeId) {
              return rel.entity0?.entityType === "work" ? rel.entity0 : rel.entity1;
            }
          }
        } catch (e) {
        }
        return null;
      }
      for (const [recGid, entries] of workOnlyByGid) {
        const recEntity = entries[0]?.recEntity ?? recordingByGid.get(recGid);
        const trackTitle = entries[0]?.role.track.title ?? recEntity?.name ?? recGid;
        const trackPos = entries[0]?.role.track.position ?? "";
        if (!recEntity) continue;
        const hasExistingWork = editorWorkByRecGid.has(recGid);
        let workEntity = null;
        if (hasExistingWork) {
          workEntity = editorWorkByRecGid.get(recGid);
          const wid = workEntity.gid || workEntity.id;
          log.info(`Track ${trackPos} "${trackTitle}": work already linked (${workEntity.name || wid || "existing"}) \u2014 skipping creation`);
          if (!workEntity.gid && !workEntity.id) continue;
        }
        if (!workEntity) workEntity = getWorkFromEditorState(recEntity);
        if (!workEntity && createWorksMode === "never") {
          for (const { role } of entries) {
            log.error(`Track ${trackPos} "${trackTitle}": no work exists for ${role.linkType} (${role.artist.name}) \u2014 "Create works" is set to "never". Add the work manually or change the mode.`);
            failed++;
          }
          continue;
        }
        if (!workEntity) {
          const newWorkId = re.getRelationshipStateId();
          workEntity = {
            _fromBatchCreateWorksDialog: true,
            attributes: [],
            comment: "",
            editsPending: false,
            entityType: "work",
            gid: null,
            id: newWorkId,
            iswcs: [],
            languages: [],
            name: trackTitle,
            typeID: null
          };
          if (MB.mergeLinkedEntities) {
            MB.mergeLinkedEntities({ work: { [newWorkId]: workEntity } });
          }
          re.dispatch({
            type: "update-relationship-state",
            sourceEntity: recEntity,
            batchSelectionCount: null,
            creditsToChangeForSource: "",
            creditsToChangeForTarget: "",
            oldRelationshipState: null,
            newRelationshipState: {
              _lineage: ["batch-created work"],
              _original: null,
              _status: 1,
              attributes: null,
              begin_date: null,
              editsPending: false,
              end_date: null,
              ended: false,
              entity0: recEntity,
              entity0_credit: "",
              entity1: workEntity,
              entity1_credit: "",
              id: re.getRelationshipStateId(),
              linkOrder: 0,
              linkTypeID: recordingOfLinkTypeId
            }
          });
          log.info(`Track ${trackPos} "${trackTitle}": created new work "${trackTitle}"`);
          added++;
          tickProgress();
          workEntity = getWorkFromEditorState(recEntity) || workEntity;
        }
        for (const { role } of entries) {
          const mbUrl = confirmedMbUrl(role.artist);
          if (!mbUrl) {
            log.warn(`Skipped ${role.artist.name} \u2014 not resolved in review (${role.linkType})`);
            continue;
          }
          const credit = role.artist.anv?.trim() || role.artist.name;
          if (workEntity.gid) {
            await processOne(workEntity, "artist", "work", role.linkType, mbUrl, role.attributes || [], credit, trackPos || entries[0]?.role?.track?.position);
          } else {
            const linkTypeID = resolveLinkTypeId(role.linkType, "artist", "work");
            if (linkTypeID) {
              const mbid = mbUrl.replace(/.*\//, "").replace(/[^a-f0-9-]/gi, "").substring(0, 36);
              try {
                const artistEntity = await fetchMBEntity(mbid);
                dispatchRelationship(re, workEntity, artistEntity, linkTypeID, credit, buildAttributes(role.attributes || []));
                added++;
              } catch (e) {
                log.error(`Failed to add ${role.linkType} for new work: ${e.message}`);
              }
            }
          }
        }
      }
    }
    async function dispatchTracklistArtists() {
      const seenTrackRels = /* @__PURE__ */ new Set();
      for (const role of tracklistRels) {
        if (WORK_ONLY_ARTIST_RELS.includes(role.linkType)) continue;
        const mbUrl = confirmedMbUrl(role.artist);
        if (!mbUrl) {
          log.warn(`Skipped ${role.artist.name} on track ${role.track.position} \u2014 not resolved in review`);
          continue;
        }
        const recEntity = getRecordingEntity(role.track);
        if (!recEntity) {
          log.warn(`No recording found for track ${role.track.position} "${role.track.title}" \u2014 skipped`);
          failed++;
          continue;
        }
        const credit = role.artist.anv?.trim() || role.artist.name;
        const attrKey = (role.attributes || []).map((a) => a.value || a._type || "").join(",");
        const trackRelKey = `${role.track.position}|${role.linkType}|${mbUrl}|${attrKey}`;
        if (seenTrackRels.has(trackRelKey)) continue;
        seenTrackRels.add(trackRelKey);
        log.info(`Track ${role.track.position} "${role.track.title}": adding <strong>${role.linkType}</strong> \u2014 ${credit}`);
        await processOne(recEntity, "artist", "recording", role.linkType, mbUrl, role.attributes || [], credit, role.track.position);
        tickProgress();
      }
    }
    await dispatchCompanies();
    await dispatchReleaseArtists();
    await dispatchTracklist();
    await dispatchWorks();
    await dispatchTracklistArtists();
    try {
      const opts = [
        processTracklist !== void 0 ? `per-track:${processTracklist ? "on" : "off"}` : null,
        applyToTracks !== void 0 ? `move-to-tracks:${applyToTracks ? "on" : "off"}` : null,
        createWorksMode !== void 0 ? `create-works:${createWorksMode}` : null
      ].filter(Boolean).join(", ");
      const trackCount2 = Array.isArray(discogsTracklist) ? discogsTracklist.length : 0;
      const inputStats = `Input: ${companies?.length || 0} companies, ${artistRoles?.length || 0} release credits, ${tracklistRels?.length || 0} tracklist credits on ${trackCount2} track${trackCount2 === 1 ? "" : "s"}`;
      const unresolvedCount = confirmedMap?.unresolvedCount || 0;
      const totalEntities = confirmedMap?.totalEntities || 0;
      const unresolvedLine = unresolvedCount > 0 ? `Unresolved: ${unresolvedCount} of ${totalEntities} entit${totalEntities === 1 ? "y" : "ies"} skipped in review` : null;
      const editNoteDedupPart = dedupedThisSession > 0 ? `, ${dedupedThisSession} dispatch duplicate${dedupedThisSession === 1 ? "" : "s"}` : "";
      const resultStats = `Result: ${added} added, ${existedInMb} already in MB${editNoteDedupPart}, ${skipped} skipped, ${failed} failed`;
      const note = buildEditNote(discogsUrl, opts, [inputStats, unresolvedLine, resultStats].filter(Boolean));
      re.dispatch({ type: "update-edit-note", editNote: note });
    } catch (e) {
    }
    const dedupPart = dedupedThisSession > 0 ? `, ${dedupedThisSession} dispatch duplicate${dedupedThisSession === 1 ? "" : "s"}` : "";
    log.info(`<strong>Done: ${added} added, ${existedInMb} already in MB${dedupPart}, ${skipped} skipped, ${failed} failed</strong>`);
  }

  // src/ui-bar.js
  var _logs2;
  var _summary;
  function insertDiscogsBar(discogsUrl) {
    const style = document.createElement("style");
    style.innerText = `
        .discogs-bar {
            font-family: inherit;
            background: #fff;
            border: 1px solid #e0c88a;
            border-left: 4px solid #e8771d;
            border-radius: 0.35rem;
            margin-bottom: 1rem;
            overflow: hidden;
        }
        .discogs-bar-row1 {
            display: flex;
            align-items: center;
            gap: 0.6rem;
            padding: 0.5rem 0.75rem;
            background: #fdf8f0;
            border-bottom: 1px solid #eeddb0;
        }
        .discogs-bar img.discogs-logo {
            height: 20px;
            width: auto;
            flex-shrink: 0;
            opacity: 0.85;
        }
        .discogs-bar .discogs-source {
            flex: 1;
            font-size: 0.82rem;
            color: #555;
            min-width: 0;
            overflow: hidden;
            text-overflow: ellipsis;
            white-space: nowrap;
        }
        .discogs-bar .discogs-source a {
            color: #e8771d;
            text-decoration: none;
            font-weight: bold;
        }
        .discogs-bar .discogs-source a:hover { text-decoration: underline; }
        .discogs-import-btn {
            flex-shrink: 0;
            padding: 0.3rem 1rem;
            background: #e8771d;
            color: #fff;
            border: none;
            border-radius: 0.25rem;
            cursor: pointer;
            font-size: 0.88rem;
            font-weight: bold;
            letter-spacing: 0.01em;
        }
        .discogs-import-btn:hover { background: #cf6618; }
        .discogs-import-btn:disabled { background: #c8a070; cursor: default; }
        .discogs-bar-row2 {
            display: flex;
            align-items: center;
            gap: 0.4rem;
            padding: 0.35rem 0.75rem;
            flex-wrap: wrap;
        }
        .discogs-bar-row2 .discogs-opts-label {
            font-size: 0.75rem;
            color: #999;
            text-transform: uppercase;
            letter-spacing: 0.05em;
            margin-right: 0.2rem;
            flex-shrink: 0;
        }
        .discogs-toggle {
            display: inline-flex;
            align-items: center;
            gap: 0.35rem;
            padding: 0.15rem 0.55rem 0.15rem 0.35rem;
            border: 1px solid #d8c8a0;
            border-radius: 2rem;
            background: #fffdf7;
            cursor: pointer;
            font-size: 0.8rem;
            color: #555;
            user-select: none;
            transition: background 0.12s, border-color 0.12s;
        }
        .discogs-toggle:hover { border-color: #e8771d; color: #333; }
        .discogs-toggle input[type=checkbox] { display: none; }
        .discogs-toggle .discogs-toggle-dot {
            width: 14px; height: 14px;
            border-radius: 50%;
            border: 2px solid #bbb;
            background: #fff;
            flex-shrink: 0;
            transition: border-color 0.12s, background 0.12s;
        }
        .discogs-toggle.active {
            background: #fff8ee;
            border-color: #e8771d;
            color: #333;
        }
        .discogs-toggle.active .discogs-toggle-dot {
            border-color: #e8771d;
            background: #e8771d;
        }
        .discogs-output { padding: 0.5rem 0.75rem 0.25rem; }
        .discogs-output .summary { margin: 0 0 0.25rem; font-size: 0.88rem; color: #555; }
        .discogs-output .logs { margin: 0; padding-left: 1.2rem; font-size: 0.83rem; }
        /* \u2500\u2500 Progress / sticky bar \u2500\u2500 */
        .discogs-bar.is-importing .discogs-bar-row1 {
            position: fixed;
            top: 0; left: 0; right: 0;
            z-index: 9000;
            background: #fdf8f0;
            border-bottom: 1px solid #eeddb0;
            box-shadow: 0 2px 8px rgba(0,0,0,0.15);
        }
        .discogs-progress-track {
            height: 5px;
            background: #eeddb0;
            border-radius: 3px;
            overflow: hidden;
        }
        .discogs-progress-fill {
            height: 100%;
            width: 0%;
            background: #e8771d;
            border-radius: 3px;
            transition: width 0.3s ease;
        }
        .discogs-progress-fill.indeterminate {
            width: 40%;
            animation: discogs-slide 1.4s ease-in-out infinite;
        }
        @keyframes discogs-slide {
            0%   { margin-left: -40%; }
            100% { margin-left: 100%; }
        }
        .discogs-progress-status {
            font-size: 0.8rem;
            color: #7a5000;
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
        }
        .discogs-recent-logs {
            font-size: 0.78rem;
            color: #888;
            max-height: 3.2rem;
            overflow: hidden;
            line-height: 1.4;
        }
        .discogs-recent-logs span {
            display: block;
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
        }
        .discogs-toggle { position: relative; }
        /* position:fixed so the tooltip escapes .discogs-bar's
           overflow:hidden (needed there to clip child backgrounds to
           the bar's rounded corners). Per-hover JS in makeCheckbox
           sets top/left from the toggle's viewport rect, so the
           tooltip renders outside any overflow-clipping ancestor.
           Issue #89. */
        .discogs-tooltip {
            display: none;
            position: fixed;
            background: #333;
            color: #fff;
            font-size: 0.78rem;
            line-height: 1.45;
            padding: 0.45rem 0.65rem;
            border-radius: 0.3rem;
            white-space: normal;
            width: 220px;
            box-shadow: 0 2px 8px rgba(0,0,0,0.25);
            pointer-events: none;
            z-index: 9999;
            text-align: left;
        }
        .discogs-tooltip::after {
            content: '';
            position: absolute;
            top: 100%;
            left: var(--arrow-x, 50%);
            transform: translateX(-50%);
            border: 5px solid transparent;
            border-top-color: #333;
        }
        /* When the tooltip flipped below the toggle (no room above),
           flip the arrow to point up from the tooltip's top edge. */
        .discogs-tooltip.below::after {
            top: auto;
            bottom: 100%;
            border-top-color: transparent;
            border-bottom-color: #333;
        }
        /* Tooltip shown by JS adding .discogs-tooltip-visible after a
           hover-intent delay (see makeCheckbox). Native browser title=
           tooltips have a ~1s delay by convention; the custom tooltips
           used to fire instantly and felt jumpy when sweeping across
           toggles. */
        .discogs-tooltip.discogs-tooltip-visible { display: block; }
    `;
    document.head.appendChild(style);
    const bar = document.createElement("div");
    bar.className = "discogs-bar";
    const row1 = document.createElement("div");
    row1.className = "discogs-bar-row1";
    const importBtn = document.createElement("button");
    importBtn.className = "discogs-import-btn";
    importBtn.textContent = "Import from Discogs";
    const progressPct = document.createElement("span");
    progressPct.id = "discogs-progress-pct";
    progressPct.style.cssText = "display:none; margin-left:0.5rem; font-size:0.85rem; color:#e8771d; font-weight:bold; min-width:3.5rem;";
    row1.appendChild(importBtn);
    row1.appendChild(progressPct);
    const logo = document.createElement("img");
    logo.src = DISCOGS_LOGO_URL;
    logo.className = "discogs-logo";
    logo.alt = "Discogs";
    row1.appendChild(logo);
    const sourceSpan = document.createElement("span");
    sourceSpan.className = "discogs-source";
    sourceSpan.innerHTML = `<a href="${discogsUrl}" target="_blank" rel="noopener noreferrer nofollow">${discogsUrl}</a>`;
    row1.appendChild(sourceSpan);
    const docsHref = typeof GM_info !== "undefined" && (GM_info?.script?.homepageURL || GM_info?.script?.homepage) || "https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/discogs_credits/README.md";
    const docsLink = document.createElement("a");
    docsLink.href = docsHref;
    docsLink.target = "_blank";
    docsLink.rel = "noopener noreferrer nofollow";
    docsLink.textContent = "\u{1F4D6} Documentation";
    docsLink.title = "Open the script's README in a new tab";
    docsLink.style.cssText = "flex-shrink:0;font-size:0.82rem;color:#7a5000;text-decoration:none;padding:0.1rem 0.45rem;border:1px solid #d4b800;border-radius:0.25rem;background:#fff8e6;";
    row1.appendChild(docsLink);
    bar.appendChild(row1);
    const row2 = document.createElement("div");
    row2.className = "discogs-bar-row2";
    const optsLabel = document.createElement("span");
    optsLabel.className = "discogs-opts-label";
    optsLabel.textContent = "Options:";
    row2.appendChild(optsLabel);
    function makeCheckbox(labelText, checkedByDefault, tooltipText) {
      const lbl = document.createElement("label");
      lbl.className = "discogs-toggle" + (checkedByDefault ? " active" : "");
      const cb = document.createElement("input");
      cb.type = "checkbox";
      cb.checked = checkedByDefault;
      const dot = document.createElement("span");
      dot.className = "discogs-toggle-dot";
      lbl.appendChild(cb);
      lbl.appendChild(dot);
      lbl.appendChild(document.createTextNode(labelText));
      if (tooltipText) {
        const tip = document.createElement("span");
        tip.className = "discogs-tooltip";
        tip.textContent = tooltipText;
        lbl.appendChild(tip);
        const TIP_W = 220, TIP_MARGIN = 6, EDGE_PAD = 8;
        const HOVER_DELAY_MS = 1e3;
        let _showTimer;
        lbl.addEventListener("mouseenter", () => {
          clearTimeout(_showTimer);
          _showTimer = setTimeout(() => {
            const r = lbl.getBoundingClientRect();
            const centerX = r.left + r.width / 2;
            let x = centerX - TIP_W / 2;
            x = Math.max(EDGE_PAD, Math.min(x, window.innerWidth - TIP_W - EDGE_PAD));
            tip.style.left = `${x}px`;
            tip.style.top = "-9999px";
            tip.classList.add("discogs-tooltip-visible");
            const h = tip.offsetHeight;
            const above = r.top - TIP_MARGIN - h;
            const fitsAbove = above >= EDGE_PAD;
            tip.style.top = fitsAbove ? `${above}px` : `${r.bottom + TIP_MARGIN}px`;
            tip.classList.toggle("below", !fitsAbove);
            tip.style.setProperty("--arrow-x", `${centerX - x}px`);
          }, HOVER_DELAY_MS);
        });
        lbl.addEventListener("mouseleave", () => {
          clearTimeout(_showTimer);
          tip.classList.remove("discogs-tooltip-visible");
        });
      }
      lbl.addEventListener("click", (e) => {
        e.preventDefault();
        cb.checked = !cb.checked;
        lbl.classList.toggle("active", cb.checked);
      });
      row2.appendChild(lbl);
      return cb;
    }
    function makeSelect(labelText, initialValue, options, tooltipText) {
      const wrap = document.createElement("span");
      wrap.className = "discogs-select-wrap";
      wrap.style.cssText = "display:inline-flex;align-items:center;gap:0.3rem;font-size:0.8rem;color:#555;padding:0.15rem 0.2rem 0.15rem 0.55rem;border:1px solid #d8c8a0;border-radius:2rem;background:#fffdf7;";
      const lbl = document.createElement("span");
      lbl.textContent = labelText + ":";
      wrap.appendChild(lbl);
      const sel = document.createElement("select");
      sel.style.cssText = "font-size:0.8rem;padding:0.05rem 0.3rem;border:1px solid #d8c8a0;border-radius:1rem;background:#fff8ee;cursor:pointer;color:#333;font-weight:600;";
      options.forEach((opt) => {
        const o = document.createElement("option");
        o.value = opt.value;
        o.textContent = opt.label;
        if (opt.value === initialValue) o.selected = true;
        sel.appendChild(o);
      });
      if (tooltipText) wrap.title = tooltipText;
      wrap.appendChild(sel);
      row2.appendChild(wrap);
      return sel;
    }
    const OPTS_KEY = "discogs-importer-opts";
    let savedOpts = {};
    try {
      savedOpts = JSON.parse(localStorage.getItem(OPTS_KEY) || "{}");
    } catch (e) {
    }
    const bv = (k, d) => k in savedOpts ? savedOpts[k] : d;
    const tracklistCb = makeCheckbox(
      "Per-track credits",
      bv("tracklist", true),
      "Import per-track artist credits from Discogs."
    );
    const applyTracksCb = makeCheckbox(
      "Move release credits to tracks",
      bv("applyTracks", true),
      "Move performance credits from the release down to every recording."
    );
    const _legacyCreateWorks = savedOpts.createWorks;
    const _initialCreateWorksMode = bv(
      "createWorksMode",
      _legacyCreateWorks === true ? "when-missing" : _legacyCreateWorks === false ? "never" : "when-needed"
    );
    const createWorksMode = makeSelect("Create works", _initialCreateWorksMode, [
      { value: "when-needed", label: "when needed" },
      { value: "when-missing", label: "when missing" },
      { value: "never", label: "never" }
    ], "when needed: create a work only when there is a composer/lyricist/writer credit to attach. when missing: create a work for every recording without one. never: do not create works \u2014 work-only credits with no existing work are logged and skipped.");
    const dedupSep = document.createElement("span");
    dedupSep.textContent = "Dedup:";
    dedupSep.style.cssText = "margin:0 0.2rem 0 0.6rem;color:#888;font-size:0.85rem;font-weight:600;";
    row2.appendChild(dedupSep);
    const dedupeEqCb = makeCheckbox(
      "Equivalence sets",
      bv("dedupeEquivalenceSets", true),
      "Skip a role when an equivalent role already exists on the target (writer \u2261 composer)."
    );
    const dedupeDupCb = makeCheckbox(
      "Duplicate roles",
      bv("dedupeDuplicateRoles", true),
      "Skip adding a role when the target already has the same role (regardless of task / dates / attributes)."
    );
    const saveOpts = () => {
      try {
        localStorage.setItem(OPTS_KEY, JSON.stringify({
          tracklist: tracklistCb.checked,
          applyTracks: applyTracksCb.checked,
          createWorksMode: createWorksMode.value,
          dedupeEquivalenceSets: dedupeEqCb.checked,
          dedupeDuplicateRoles: dedupeDupCb.checked
        }));
      } catch (e) {
      }
    };
    [tracklistCb, applyTracksCb, dedupeEqCb, dedupeDupCb].forEach((cb) => cb.closest("label").addEventListener("click", () => setTimeout(saveOpts, 0)));
    createWorksMode.addEventListener("change", saveOpts);
    bar.appendChild(row2);
    const outputDiv = document.createElement("div");
    outputDiv.className = "discogs-output";
    importBtn.addEventListener("click", () => {
      importBtn.disabled = true;
      importBtn.textContent = "Importing\u2026";
      progressPct.style.display = "inline";
      progressPct.textContent = "0%";
      bar.classList.add("is-importing");
      _showBar();
      bar.scrollIntoView({ behavior: "smooth", block: "start" });
      bar._showProgress = () => {
        _showBar();
      };
      requestAnimationFrame(bar._showProgress);
      _logs2 = document.createElement("ul");
      _logs2.className = "logs";
      setLogContainer(_logs2);
      _summary = document.createElement("p");
      _summary.className = "summary";
      outputDiv.innerHTML = "";
      outputDiv.appendChild(_summary);
      outputDiv.appendChild(_logs2);
      function buildCopyText({ skipDiscogsJson }) {
        function htmlToMd(el) {
          function nodeToMd(node) {
            if (node.nodeType === Node.TEXT_NODE) return node.textContent;
            const tag = node.tagName?.toLowerCase();
            const inner = [...node.childNodes].map(nodeToMd).join("");
            if (tag === "strong" || tag === "b") return `**${inner}**`;
            if (tag === "em" || tag === "i") return `_${inner}_`;
            if (tag === "a") return `[${inner}](${node.href})`;
            if (tag === "br") return "\n";
            if (tag === "pre") {
              return "\n```json\n" + node.textContent + "\n```\n";
            }
            if (tag === "details") {
              const sum = node.querySelector("summary");
              const sumText = sum ? [...sum.childNodes].map((n) => {
                if (n.nodeType === Node.TEXT_NODE) return n.textContent;
                const t = n.tagName?.toLowerCase();
                if (t === "button" || t === "input") return "";
                return n.textContent;
              }).join("").trim() : "";
              if (skipDiscogsJson && /raw Discogs JSON/i.test(sumText)) {
                return "";
              }
              const body = [...node.childNodes].filter((n) => n !== sum).map(nodeToMd).join("");
              return "\n\n<details><summary>" + sumText + "</summary>\n\n" + body + "\n</details>\n\n";
            }
            if (tag === "summary") return "";
            if (tag === "span") return inner;
            if (tag === "div") return inner + "\n";
            if (tag === "ul") return inner;
            if (tag === "li" && el !== node) return "- " + inner + "\n";
            if (tag === "table") {
              const rows = [...node.querySelectorAll("tr")];
              if (!rows.length) return "";
              const cells = rows.map((r) => [...r.querySelectorAll("th,td")].map((c) => c.innerText.trim().replace(/\|/g, "\\|")));
              const widths = cells[0]?.map((_, i) => Math.max(...cells.map((r) => (r[i] || "").length), 3));
              const pad = (s, w) => s + " ".repeat(Math.max(0, w - s.length));
              const mdRows = cells.map((row) => "| " + row.map((c, i) => pad(c, widths[i])).join(" | ") + " |");
              if (mdRows.length > 1) mdRows.splice(1, 0, "| " + widths.map((w) => "-".repeat(w)).join(" | ") + " |");
              return "\n\n" + mdRows.join("\n") + "\n\n";
            }
            return inner;
          }
          const _md = nodeToMd(el);
          return _md.startsWith("\n\n") || _md.endsWith("\n\n") ? _md : _md.replace(/^\n/, "").replace(/\n$/, "");
        }
        const lines = [..._logs2.querySelectorAll("li")].map((li) => {
          if (li.classList?.contains("discogs-review-panel-li") && typeof li._buildStaticTableLi === "function") {
            return htmlToMd(li._buildStaticTableLi());
          }
          const md = htmlToMd(li);
          if (!md) return "";
          if (md.startsWith("\n\n|") || md.startsWith("<details>")) return md;
          return md + "  ";
        }).filter(Boolean).join("\n");
        const releaseName = pageWindow?.MB?.relationshipEditor?.state?.entity?.name || document.title.replace(/ - MusicBrainz.*/, "").trim() || "Import log";
        return `<details><summary>${releaseName}</summary>

${lines}

</details>`;
      }
      function copyToClipboard(text, btn, restoreText) {
        const restore = () => {
          btn.textContent = "Copied!";
          setTimeout(() => {
            btn.textContent = restoreText;
          }, 1500);
        };
        const fallback = () => {
          const ta = Object.assign(document.createElement("textarea"), { value: text });
          document.body.appendChild(ta);
          ta.select();
          document.execCommand("copy");
          ta.remove();
          restore();
        };
        if (navigator.clipboard?.writeText) {
          navigator.clipboard.writeText(text).then(restore, fallback);
        } else {
          fallback();
        }
      }
      const copyLogBtn = document.createElement("button");
      copyLogBtn.textContent = "Copy log";
      copyLogBtn.title = "Copy the full import log (incl. raw Discogs JSON)";
      copyLogBtn.style.cssText = "font-size:0.78rem;padding:0.15rem 0.5rem;cursor:pointer;margin-left:auto;flex-shrink:0;";
      copyLogBtn.addEventListener("click", () => {
        copyToClipboard(buildCopyText({ skipDiscogsJson: false }), copyLogBtn, "Copy log");
      });
      row2.appendChild(copyLogBtn);
      const copyLogNoJsonBtn = document.createElement("button");
      copyLogNoJsonBtn.textContent = "Copy log (no JSON)";
      copyLogNoJsonBtn.title = "Copy the log without the raw Discogs JSON block \u2014 small enough to fit in a GitHub issue";
      copyLogNoJsonBtn.style.cssText = "font-size:0.78rem;padding:0.15rem 0.5rem;cursor:pointer;flex-shrink:0;";
      copyLogNoJsonBtn.addEventListener("click", () => {
        copyToClipboard(buildCopyText({ skipDiscogsJson: true }), copyLogNoJsonBtn, "Copy log (no JSON)");
      });
      row2.appendChild(copyLogNoJsonBtn);
      bar._setProgress = (pct) => {
        if (pct !== null && pct >= 100) _hideBar();
      };
      requestAnimationFrame(_showBar);
      const getOpts = () => ({
        processTracklist: tracklistCb.checked,
        applyToTracks: applyTracksCb.checked,
        createWorksMode: createWorksMode.value,
        dedupeEquivalenceSets: dedupeEqCb.checked,
        dedupeDuplicateRoles: dedupeDupCb.checked
      });
      const _click = getOpts();
      const opts = `per-track:${_click.processTracklist ? "on" : "off"}, move-to-tracks:${_click.applyToTracks ? "on" : "off"}, create-works:${_click.createWorksMode}`;
      const editNote = buildEditNote(discogsUrl, opts);
      editNote.split("\n").forEach((line) => {
        if (!line.trim()) return;
        const html = line.replace(/(https?:\/\/[^\s]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer nofollow">$1</a>');
        log.info(html);
      });
      runImport(discogsUrl, getOpts).finally(() => {
        importBtn.disabled = false;
        importBtn.textContent = "Import from Discogs";
        progressPct.textContent = "100%";
        setTimeout(() => {
          progressPct.style.display = "none";
        }, 2e3);
        setTimeout(() => {
          bar.classList.remove("is-importing");
          _hideBar();
        }, 2e3);
        delete bar._setProgress;
      });
    });
    bar.appendChild(outputDiv);
    function insertBar() {
      const anchor = document.querySelector(".release-rel-editor") || // MB React wrapper
      document.querySelector("#content > div") || // generic first content div
      document.querySelector("#content");
      if (!anchor) return setTimeout(insertBar, 300);
      anchor.insertBefore(bar, anchor.firstChild);
    }
    insertBar();
  }
  (function cleanupLocalStorage() {
    try {
      const keysToRemove = [];
      for (let i = 0; i < localStorage.length; i++) {
        const k = localStorage.key(i);
        if (!k) continue;
        if (k.startsWith("discogs-release-")) keysToRemove.push(k);
      }
      keysToRemove.forEach((k) => localStorage.removeItem(k));
    } catch (e) {
    }
  })();
  function runImport(discogsUrl, getOpts) {
    const initial = getOpts();
    const { processTracklist } = initial;
    return getDiscogsReleaseData(discogsUrl).then((json) => {
      let artistRoles = rolesFromDiscogsArtists(json.extraartists?.filter((artist) => !artist.tracks));
      if (!_logs2._releaseInfoAdded) {
        _logs2._releaseInfoAdded = true;
        const trackCount = (json.tracklist || []).filter((t) => t.type_ === "track").length;
        const summary = `${json.title || ""}${json.year ? " \xB7 " + json.year : ""} \xB7 ${trackCount} tracks`;
        const li = document.createElement("li");
        const pre = document.createElement("pre");
        pre.style.cssText = "max-height:400px;overflow:auto;font-size:0.72rem;background:#f8f8f8;padding:0.5rem;border:1px solid #ddd;border-radius:3px;margin:0.3rem 0 0 0;white-space:pre-wrap;word-break:break-all;";
        pre.textContent = JSON.stringify(json, null, 2);
        const copyJsonBtn = document.createElement("button");
        copyJsonBtn.textContent = "Copy JSON";
        copyJsonBtn.style.cssText = "font-size:0.75rem;padding:0.1rem 0.4rem;cursor:pointer;margin-left:0.5rem;vertical-align:middle;";
        copyJsonBtn.addEventListener("click", (e) => {
          e.stopPropagation();
          navigator.clipboard.writeText(JSON.stringify(json, null, 2)).catch(() => {
            const ta = Object.assign(document.createElement("textarea"), { value: JSON.stringify(json, null, 2) });
            document.body.appendChild(ta);
            ta.select();
            document.execCommand("copy");
            ta.remove();
          });
          copyJsonBtn.textContent = "Copied!";
          setTimeout(() => {
            copyJsonBtn.textContent = "Copy JSON";
          }, 1500);
        });
        li.innerHTML = `<details><summary style="cursor:pointer;user-select:none;"><strong>${summary} \u2014 raw Discogs JSON</strong></summary></details>`;
        li.querySelector("summary").appendChild(copyJsonBtn);
        li.querySelector("details").appendChild(pre);
        _logs2.appendChild(li);
      }
      log.info(`Found ${json.companies.length + artistRoles.length} release relationships`);
      artistRoles = artistRoles.concat(convertPotentialDJMixers(json));
      let tracklistRels = [];
      if (processTracklist) {
        tracklistRels = json.tracklist.filter((track) => track.type_ === "track").reduce((map, track) => {
          if (!track.extraartists || !Array.isArray(track.extraartists)) {
            return map;
          }
          return map.concat(
            rolesFromDiscogsArtists(track.extraartists).map((rel) => {
              return Object.assign({}, rel, {
                track
              });
            })
          );
        }, []);
        const releaseLevelTracklistRels = json.extraartists?.filter((artist) => artist.tracks && artist.tracks !== "") || [];
        if (releaseLevelTracklistRels.length > 0) {
          tracklistRels = tracklistRels.concat(
            releaseLevelTracklistRels.reduce((array, artist) => {
              return array.concat(
                getAllArtistTracks(json.tracklist, artist.tracks).reduce((array2, track) => {
                  return array2.concat(
                    getArtistRoles(artist).map((rel) => {
                      return Object.assign({}, rel, {
                        artist,
                        track
                      });
                    })
                  );
                }, [])
              );
            }, [])
          );
        }
        log.info(`Found ${tracklistRels.length} tracklist relationships`);
      }
      const allArtistRoles = artistRoles.concat(tracklistRels);
      const uniqueArtists = [];
      const seenResourceUrls = /* @__PURE__ */ new Set();
      const rolesMap = /* @__PURE__ */ new Map();
      allArtistRoles.forEach((role) => {
        const url = role.artist?.resource_url || `_nourl_${role.artist?.name || role.artist?.id}`;
        if (!rolesMap.has(url)) rolesMap.set(url, []);
        let displayLabel = role.linkType;
        if (role.attributes && role.attributes.length > 0) {
          const attr = role.attributes[0];
          if (attr._type === "instrument" && attr.value) displayLabel = attr.value;
          else if (attr._type === "vocal" && attr.value) displayLabel = attr.value;
          else if (typeof attr === "string") displayLabel = `${role.linkType} [${attr}]`;
        }
        rolesMap.get(url).push({
          linkType: role.linkType,
          displayLabel,
          trackPos: role.track?.position || "",
          trackTitle: role.track?.title || ""
        });
        if (!seenResourceUrls.has(url)) {
          seenResourceUrls.add(url);
          if (!role.artist?.resource_url && role.artist) role.artist._syntheticKey = url;
          uniqueArtists.push(role.artist);
        }
      });
      const companiesRolesMap = /* @__PURE__ */ new Map();
      json.companies.forEach((c) => {
        if (!c.resource_url) return;
        if (!companiesRolesMap.has(c.resource_url)) companiesRolesMap.set(c.resource_url, []);
        companiesRolesMap.get(c.resource_url).push({ linkType: c.entity_type_name || "" });
      });
      const uniqueCompanies = [];
      const seenCompanyUrls = /* @__PURE__ */ new Set();
      json.companies.forEach((c) => {
        if (c.resource_url && !seenCompanyUrls.has(c.resource_url) && ENTITY_TYPE_MAP[c.entity_type_name]) {
          seenCompanyUrls.add(c.resource_url);
          uniqueCompanies.push(c);
        }
      });
      function runPreflight(bypassIdb = false) {
        log.info(`Starting preflight: ${uniqueArtists.length} artist(s), ${uniqueCompanies.length} label(s)/place(s).`);
        const artistProgressLi = document.createElement("li");
        artistProgressLi.textContent = `Checking ${uniqueArtists.length} artist(s) against MusicBrainz\u2026`;
        _logs2.appendChild(artistProgressLi);
        const companyProgressLi = document.createElement("li");
        companyProgressLi.textContent = `Checking ${uniqueCompanies.length} label(s)/place(s) against MusicBrainz\u2026`;
        _logs2.appendChild(companyProgressLi);
        const t0 = performance.now();
        return (async () => {
          const artistResults = await resolveAll(uniqueArtists, {
            progressLi: artistProgressLi,
            progressLabel: "Checking artists against MusicBrainz",
            kindOf: ARTIST_KIND,
            bypassIdb
          });
          const companyResults = await resolveAll(uniqueCompanies, {
            progressLi: companyProgressLi,
            progressLabel: "Checking labels/places against MusicBrainz",
            kindOf: COMPANY_KIND,
            bypassIdb
          });
          const elapsed = (performance.now() - t0) / 1e3;
          log.info(`Preflight done in ${elapsed.toFixed(1)}s.`);
          return [...artistResults.allResults, ...companyResults.allResults].filter(Boolean);
        })();
      }
      function annotateRoles(allResults) {
        allResults.forEach((r) => {
          if (!r) return;
          const url = r.entity?.resource_url || r.entity?._syntheticKey;
          if (url) r._roles = rolesMap.get(url) || companiesRolesMap.get(url) || [];
        });
      }
      let capturedResults = null;
      let capturedConfirmedMap = null;
      return runPreflight().then((allResults) => {
        annotateRoles(allResults);
        capturedResults = allResults;
        return showReviewTable(capturedResults, rolesMap, companiesRolesMap, {
          // "🔄 Refresh from MB" — bypass the IDB cache and re-resolve
          // every entity via MB API. Used when a cached MBID is stale.
          onRefresh: () => runPreflight(true).then((freshResults) => {
            annotateRoles(freshResults);
            capturedResults = freshResults;
            return freshResults;
          })
        });
      }).then((confirmedMap) => {
        capturedConfirmedMap = confirmedMap;
        const cachePromises = [];
        confirmedMap.forEach((mbUrl, resourceUrl) => {
          const key = parseDiscogsUrl(resourceUrl)?.key;
          if (!key) return;
          const m = mbUrl.match(/\/(artist|label|place)\/([a-f0-9-]+)/);
          if (!m) return;
          cachePromises.push(writeIdbRecord(key, {
            mbid: m[2],
            entityType: m[1]
            // No resolvedVia change — the inline write owns it.
          }));
        });
        return Promise.all(cachePromises);
      }).then(() => {
        const resolvedEntityTypes = /* @__PURE__ */ new Map();
        (capturedResults || []).forEach((r) => {
          if (r.entity?.resource_url && r.mbUrl && r.entityType) {
            resolvedEntityTypes.set(r.entity.resource_url, r.entityType);
          }
        });
        const live = getOpts();
        if (live.processTracklist !== processTracklist) {
          log.warn(`"Per-track credits" toggled during review (preflight ran with "${processTracklist ? "on" : "off"}", import will follow preflight). To change, restart the import.`);
        }
        const dedupOpts = {
          dedupeEquivalenceSets: live.dedupeEquivalenceSets,
          dedupeDuplicateRoles: live.dedupeDuplicateRoles,
          creditOverrides: capturedConfirmedMap?.creditOverrides
        };
        return dispatchAllRelationships(json.companies, artistRoles, tracklistRels, live.applyToTracks, live.createWorksMode, json.tracklist, processTracklist, resolvedEntityTypes, capturedConfirmedMap, discogsUrl, dedupOpts);
      });
    }).then(() => {
    });
  }

  // src/hover-highlight.js
  var _installed = false;
  function installHoverHighlight() {
    if (_installed) return;
    _installed = true;
    if (!document.body) {
      document.addEventListener("DOMContentLoaded", () => {
        _installed = false;
        installHoverHighlight();
      }, { once: true });
      return;
    }
    const style = document.createElement("style");
    style.id = "discogs-hover-highlight-style";
    style.textContent = `
        ::highlight(discogs-hover-existing) { background-color: blue; color: white; }
        ::highlight(discogs-hover-new)      { background-color: blue; color: yellow; }
        .discogs-role-chip { padding: 0 2px; border-radius: 3px; transition: background 0.08s, color 0.08s; }
        .discogs-role-chip:hover { background: #ffe066; color: #222; cursor: default; }
    `;
    document.head.appendChild(style);
    document.body.addEventListener("mouseover", (ev) => {
      const needle = needleFor(ev.target);
      if (needle) highlightPageText(needle);
    });
    document.body.addEventListener("mouseout", (ev) => {
      const needle = needleFor(ev.target);
      if (needle) clearPageHighlight();
    });
  }
  function needleFor(target) {
    if (!target || !target.closest) return null;
    const chip = target.closest(".discogs-role-chip");
    if (chip) return chip.dataset.roleKey || "";
    const phraseTh = target.closest("th.link-phrase");
    if (phraseTh && !target.closest("button")) {
      const label = phraseTh.querySelector("label");
      if (label) {
        let text = (label.textContent || "").trim();
        if (text.endsWith(":")) text = text.slice(0, -1).trim();
        if (text) return text;
      }
    }
    const link = target.closest("a[href]");
    if (link) {
      const href = link.getAttribute("href") || "";
      if (/\/(artist|work|label|place|recording|series|release-group|event|instrument|area)\/[a-f0-9-]/.test(href)) {
        return (link.textContent || "").trim();
      }
    }
    const span = target.closest("span.discogs-entity-name");
    if (span) return (span.textContent || "").trim();
    return null;
  }
  function classifyRow(textNode) {
    const item = textNode.parentNode && textNode.parentNode.closest ? textNode.parentNode.closest('.relationship-item, [class*="relationship-item"]') : null;
    if (!item) return null;
    const cls = item.className || "";
    if (/(^|\s)(rel-add|relationship-add)(\s|$)/.test(cls) || /\badd(ed)?\b/i.test(cls)) {
      return "new";
    }
    const rm = item.querySelector('button.remove-item[id^="remove-relationship-"]');
    if (rm) {
      const tail = rm.id.split("-").pop();
      const segs = rm.id.split("-");
      const last = segs[segs.length - 1];
      const secondLast = segs[segs.length - 2];
      if (secondLast === "" && /^\d+$/.test(last)) return "new";
      if (/^-\d+$/.test(last)) return "new";
    }
    return "existing";
  }
  function highlightPageText(needle) {
    if (!needle || !window.CSS?.highlights || typeof Highlight === "undefined") return;
    const lower = needle.toLowerCase();
    if (lower.length < 2) return;
    const rangesExisting = [];
    const rangesNew = [];
    const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
      acceptNode(n) {
        const p = n.parentNode;
        if (!p) return NodeFilter.FILTER_REJECT;
        const tag = p.tagName;
        if (tag === "STYLE" || tag === "SCRIPT" || tag === "NOSCRIPT" || tag === "TEXTAREA" || tag === "INPUT") return NodeFilter.FILTER_REJECT;
        return NodeFilter.FILTER_ACCEPT;
      }
    });
    let node;
    while (node = walker.nextNode()) {
      const txt = node.nodeValue;
      if (txt.length < lower.length) continue;
      const lowerTxt = txt.toLowerCase();
      const bucket = classifyRow(node) === "new" ? rangesNew : rangesExisting;
      let i = 0;
      while ((i = lowerTxt.indexOf(lower, i)) !== -1) {
        const r = document.createRange();
        r.setStart(node, i);
        r.setEnd(node, i + lower.length);
        bucket.push(r);
        i += lower.length;
      }
    }
    try {
      window.CSS.highlights.set("discogs-hover-existing", new Highlight(...rangesExisting));
      window.CSS.highlights.set("discogs-hover-new", new Highlight(...rangesNew));
    } catch (e) {
    }
  }
  function clearPageHighlight() {
    try {
      window.CSS.highlights?.delete("discogs-hover-existing");
      window.CSS.highlights?.delete("discogs-hover-new");
    } catch (e) {
    }
  }

  // src/batch-remove.js
  var _installed2 = false;
  function installBatchRemove() {
    if (_installed2) return;
    _installed2 = true;
    if (!document.body) {
      document.addEventListener("DOMContentLoaded", () => {
        _installed2 = false;
        installBatchRemove();
      }, { once: true });
      return;
    }
    document.head.appendChild(buildStyle());
    document.body.addEventListener("click", onClick, true);
  }
  function onClick(ev) {
    if (!(ev.shiftKey || ev.ctrlKey || ev.metaKey)) return;
    const btn = ev.target.closest?.("button.icon.remove-item");
    if (!btn) return;
    const mode = modeFor(ev);
    if (!mode) return;
    ev.preventDefault();
    ev.stopPropagation();
    _positionByRelIdCache = buildRelIdToPositionMap();
    const group = collectGroup(btn, mode);
    if (group.items.length === 0) return;
    openConfirm(group, mode);
  }
  function modeFor(ev) {
    if ((ev.ctrlKey || ev.metaKey) && ev.shiftKey) return "role-and-target";
    if (ev.ctrlKey || ev.metaKey) return "target";
    if (ev.shiftKey) return "role";
    return null;
  }
  function collectGroup(seedBtn, mode) {
    const seedItem = seedBtn.closest(".relationship-item");
    const seedRow = seedBtn.closest("tr");
    if (!seedItem || !seedRow) return { items: [], roleClass: null, roleLabel: "", targetHref: null, targetLabel: "" };
    const roleClass = pickRoleClass(seedRow);
    const targetHref = pickTargetHref(seedItem);
    const targetLabel = pickTargetLabel(seedItem);
    const roleLabel = pickRoleLabel(seedRow);
    const allItems = Array.from(document.querySelectorAll(".relationship-item"));
    const matched = allItems.filter((item) => {
      if (mode === "role") {
        return rowHasClass(item.closest("tr"), roleClass);
      }
      if (mode === "target") {
        return hasTargetHref(item, targetHref);
      }
      return rowHasClass(item.closest("tr"), roleClass) && hasTargetHref(item, targetHref);
    });
    return {
      // Return raw items; modal will derive `buttons` / `locations`
      // depending on the "only this session" toggle state at confirm
      // time. Per #68 follow-up: pre-existing rels stay untouched
      // when the toggle is on.
      items: matched,
      roleClass,
      roleLabel,
      targetHref,
      targetLabel
    };
  }
  function isSessionRel(item) {
    const btn = item.querySelector('button.icon.remove-item[id^="remove-relationship-"]');
    const relId = parseRelIdFromButton(btn);
    if (relId == null) return false;
    return Number(relId) < 0;
  }
  function buttonsFor(items) {
    return items.map((it) => it.querySelector("button.icon.remove-item")).filter(Boolean);
  }
  function pickRoleClass(tr) {
    if (!tr) return null;
    const stop = /* @__PURE__ */ new Set(["odd", "even", "highlighted", "selected", "subrow", "rel-add", "rel-edit", "rel-remove"]);
    for (const c of tr.classList) {
      if (!stop.has(c) && /^[a-z][a-z0-9-]*$/.test(c)) return c;
    }
    return null;
  }
  function pickRoleLabel(tr) {
    if (!tr) return "";
    const lbl = tr.querySelector("th.link-phrase label");
    if (!lbl) return "";
    return (lbl.textContent || "").replace(/:\s*$/, "").trim();
  }
  function pickTargetHref(item) {
    if (!item) return null;
    const a = item.querySelector(
      'a[href^="/artist/"], a[href^="/work/"], a[href^="/label/"], a[href^="/place/"], a[href^="/recording/"], a[href^="/series/"], a[href^="/release-group/"], a[href^="/event/"], a[href^="/instrument/"], a[href^="/area/"]'
    );
    return a ? a.getAttribute("href") : null;
  }
  function pickTargetLabel(item) {
    if (!item) return "";
    const a = item.querySelector(
      'a[href^="/artist/"], a[href^="/work/"], a[href^="/label/"], a[href^="/place/"], a[href^="/recording/"], a[href^="/series/"], a[href^="/release-group/"], a[href^="/event/"], a[href^="/instrument/"], a[href^="/area/"]'
    );
    return a ? (a.textContent || "").trim() : "";
  }
  function rowHasClass(tr, cls) {
    if (!tr || !cls) return false;
    return tr.classList.contains(cls);
  }
  function hasTargetHref(item, href) {
    if (!item || !href) return false;
    return !!item.querySelector(`a[href="${cssEscape(href)}"]`);
  }
  function cssEscape(s) {
    return String(s).replace(/(["\\\\])/g, "\\$1");
  }
  function collectLocations(items) {
    const buckets = {
      release: { count: 0, positions: /* @__PURE__ */ new Set() },
      recording: { count: 0, positions: /* @__PURE__ */ new Set() },
      work: { count: 0, positions: /* @__PURE__ */ new Set() },
      other: { count: 0, positions: /* @__PURE__ */ new Set() }
    };
    for (const item of items) {
      const btn = item.querySelector('button.icon.remove-item[id^="remove-relationship-"]');
      const srcType = parseSourceTypeFromButton(btn);
      const key = srcType === "release" || srcType === "recording" || srcType === "work" ? srcType : "other";
      buckets[key].count++;
      if (key === "recording" || key === "work") {
        const pos = findRecordingPosition(item);
        if (pos) buckets[key].positions.add(pos);
      }
    }
    const order = [
      ["release", "release"],
      ["recording", "tracks"],
      ["work", "works"]
    ];
    const out = [];
    for (const [key, label] of order) {
      const b = buckets[key];
      const positions = sortPositions([...b.positions]);
      out.push({ key, label, count: b.count, positions });
    }
    if (buckets.other.count > 0) {
      out.push({ key: "other", label: "other", count: buckets.other.count, positions: [] });
    }
    return out;
  }
  function sortPositions(arr) {
    return arr.sort((a, b) => {
      const na = parseFloat(a), nb = parseFloat(b);
      if (!isNaN(na) && !isNaN(nb) && na !== nb) return na - nb;
      return String(a).localeCompare(String(b));
    });
  }
  function parseSourceTypeFromButton(btn) {
    if (!btn || !btn.id) return null;
    const segs = btn.id.split("-");
    let i = segs.length - 1;
    while (i >= 0 && (segs[i] === "" || /^-?\d+$/.test(segs[i]))) i--;
    return segs[i] || null;
  }
  var _positionByRelIdCache = null;
  function buildRelIdToPositionMap() {
    const map = /* @__PURE__ */ new Map();
    const win = typeof unsafeWindow !== "undefined" ? unsafeWindow : window;
    const MB = win.MB;
    const re = MB?.relationshipEditor;
    if (!MB || !re?.state) return map;
    const positionByRecGid = /* @__PURE__ */ new Map();
    try {
      let mediumIndex = 0;
      const mediums = re.state.mediums;
      if (!mediums) return map;
      const iter = MB.tree?.iterate ? MB.tree.iterate(mediums) : null;
      if (!iter) return map;
      for (const [mediumKey, medium] of iter) {
        mediumIndex++;
        const tracks = medium?.tracks ?? medium;
        let trackIndex = 0;
        for (const rawTrack of MB.tree.iterate(tracks)) {
          trackIndex++;
          const trackObj = Array.isArray(rawTrack) ? rawTrack[1] : rawTrack;
          const rec = trackObj?.recording ?? trackObj;
          if (!rec?.gid) continue;
          let pos = trackObj?.number || trackObj?.position;
          if (pos == null) pos = `${mediumIndex}.${String(trackIndex).padStart(2, "0")}`;
          positionByRecGid.set(rec.gid, String(pos));
        }
      }
    } catch (e) {
    }
    try {
      let walk = function(node, sourceGid) {
        if (!node) return;
        if (Array.isArray(node)) {
          for (const r of node) {
            if (r?.id != null) {
              const pos = positionByRecGid.get(sourceGid);
              if (pos) map.set(String(r.id), pos);
            }
          }
          return;
        }
        if (typeof node === "object") {
          for (const v of Object.values(node)) walk(v, sourceGid);
        }
      };
      const root = re.state.relationshipsBySource;
      if (!root) return map;
      for (const [gid, perSource] of Object.entries(root)) {
        walk(perSource, gid);
      }
    } catch (e) {
    }
    return map;
  }
  function parseRelIdFromButton(btn) {
    if (!btn || !btn.id) return null;
    const m = btn.id.match(/-(-?\d+)$/);
    return m ? m[1] : null;
  }
  function findRecordingPosition(item) {
    const btn = item.querySelector('button.icon.remove-item[id^="remove-relationship-"]');
    const relId = parseRelIdFromButton(btn);
    if (relId && _positionByRelIdCache && _positionByRelIdCache.has(relId)) {
      return _positionByRelIdCache.get(relId);
    }
    let el = item.closest(
      "[data-track-position], [data-position], [data-medium-track-position], [data-track-number]"
    );
    if (el) {
      const pos = el.getAttribute("data-track-position") || el.getAttribute("data-medium-track-position") || el.getAttribute("data-position") || el.getAttribute("data-track-number");
      if (pos) return String(pos).trim();
    }
    let scope = item.closest("table, tbody, .relationship-list-wrapper, .track-relationships, .track-rel");
    while (scope) {
      const candidates = scope.querySelectorAll?.(
        ".track-position, .position, .track-number, .medium-track-pos"
      );
      for (const c of candidates || []) {
        const txt = c.textContent?.trim();
        if (txt && /^[A-Z]?\d+([\-.]\d+|[A-Z]?\d*)?$/.test(txt)) return txt;
      }
      scope = scope.parentElement?.closest("table, .relationship-list-wrapper, .track-relationships, .track-rel");
    }
    return null;
  }
  function buildStyle() {
    const style = document.createElement("style");
    style.id = "discogs-batch-remove-style";
    style.textContent = `
        .discogs-batch-overlay {
            position: fixed; inset: 0; background: rgba(0,0,0,0.5);
            z-index: 100000; display: flex; align-items: center; justify-content: center;
            font-family: inherit; font-size: 14px;
        }
        .discogs-batch-modal {
            background: #fff; border-radius: 6px; box-shadow: 0 8px 24px rgba(0,0,0,0.2);
            max-width: 540px; width: 90%; padding: 1.2rem 1.4rem;
            box-sizing: border-box; color: #222;
        }
        .discogs-batch-modal * { box-sizing: border-box; }
        .discogs-batch-modal h2 {
            margin: 0 0 0.6rem; font-size: 1.05rem; line-height: 1.3;
        }
        .discogs-batch-modal .what { margin: 0 0 0.5rem; }
        .discogs-batch-modal .total {
            font-weight: 600; margin: 0.5rem 0 0.3rem; font-size: 0.95rem;
        }
        .discogs-batch-modal ul.locations {
            margin: 0 0 0.9rem; padding: 0.5rem 0.7rem 0.5rem 1.5rem;
            background: #f6f6f6; border-radius: 4px;
            font-size: 0.88rem; line-height: 1.6;
            max-height: 12rem; overflow-y: auto;
            list-style: disc;
        }
        .discogs-batch-modal ul.locations li.loc { margin: 0; padding: 0; }
        .discogs-batch-modal .actions {
            display: flex; flex-direction: row !important;
            justify-content: flex-end; align-items: center;
            gap: 0.5rem; margin-top: 1rem;
        }
        .discogs-batch-modal .actions button {
            flex: 0 0 auto;
            display: inline-block;
            box-sizing: border-box;
            margin: 0;
            padding: 0.45rem 1.1rem;
            min-width: 6rem; height: 2.2rem;
            border-radius: 4px;
            border: 1px solid #bbb;
            cursor: pointer;
            font-size: 0.9rem;
            font-family: inherit;
            font-weight: 500;
            line-height: 1; vertical-align: middle;
            text-align: center;
            white-space: nowrap;
        }
        .discogs-batch-modal .actions button.confirm {
            background: #c0392b; color: #fff; border-color: #962c20;
        }
        .discogs-batch-modal .actions button.confirm:hover { background: #a83426; }
        .discogs-batch-modal .actions button.cancel {
            background: #f5f5f5; color: #333;
        }
        .discogs-batch-modal .actions button.cancel:hover { background: #eaeaea; }
    `;
    return style;
  }
  function openConfirm(group, mode) {
    const sessionItems = group.items.filter(isSessionRel);
    const allItems = group.items;
    let onlySession = false;
    const overlay = document.createElement("div");
    overlay.className = "discogs-batch-overlay";
    const modal = document.createElement("div");
    modal.className = "discogs-batch-modal";
    const title = document.createElement("h2");
    modal.appendChild(title);
    const what = document.createElement("p");
    what.className = "what";
    what.innerHTML = describeAction(group, mode);
    modal.appendChild(what);
    let toggleCb = null;
    if (sessionItems.length > 0 && sessionItems.length < allItems.length) {
      const toggleWrap = document.createElement("label");
      toggleWrap.className = "session-toggle";
      toggleWrap.style.cssText = "display:flex;align-items:center;gap:0.4rem;margin:0.3rem 0 0.7rem;font-size:0.9rem;cursor:pointer;user-select:none;";
      toggleCb = document.createElement("input");
      toggleCb.type = "checkbox";
      toggleCb.checked = false;
      toggleWrap.appendChild(toggleCb);
      toggleWrap.appendChild(document.createTextNode("Only remove relationships added in this session"));
      modal.appendChild(toggleWrap);
    }
    const total = document.createElement("div");
    total.className = "total";
    modal.appendChild(total);
    const list = document.createElement("ul");
    list.className = "locations";
    modal.appendChild(list);
    function activeItems() {
      return onlySession ? sessionItems : allItems;
    }
    function render() {
      const items = activeItems();
      const buttons = buttonsFor(items);
      const locs = collectLocations(items);
      title.textContent = `Remove ${buttons.length} relationship${buttons.length === 1 ? "" : "s"}?`;
      total.textContent = `Total: ${buttons.length}`;
      list.innerHTML = "";
      for (const { label, count, positions } of locs) {
        const li = document.createElement("li");
        li.className = "loc";
        const noun = count === 1 ? "rel" : "rels";
        const tail = positions && positions.length ? `: ${positions.join(", ")}` : "";
        li.textContent = `${count} ${noun} from ${label}${tail}`;
        list.appendChild(li);
      }
      if (confirmBtn) {
        confirmBtn.disabled = buttons.length === 0;
        confirmBtn.style.setProperty("opacity", buttons.length === 0 ? "0.5" : "1", "important");
        confirmBtn.style.setProperty("cursor", buttons.length === 0 ? "default" : "pointer", "important");
      }
    }
    if (toggleCb) {
      toggleCb.addEventListener("change", () => {
        onlySession = toggleCb.checked;
        render();
      });
    }
    let confirmBtn;
    const actions = document.createElement("div");
    actions.className = "actions";
    const actionsCss = {
      "display": "flex",
      "flex-direction": "row",
      "justify-content": "flex-end",
      "align-items": "center",
      "gap": "0.5rem",
      "margin-top": "1rem",
      "padding": "0",
      "width": "100%"
    };
    for (const [k, v] of Object.entries(actionsCss)) actions.style.setProperty(k, v, "important");
    function styleBtn(b, isConfirm) {
      const css = {
        "flex": "0 0 auto",
        "display": "inline-block",
        "box-sizing": "border-box",
        "margin": "0",
        "padding": "0.45rem 1.1rem",
        "min-width": "6rem",
        "height": "2.2rem",
        "line-height": "1",
        "border-radius": "4px",
        "border": isConfirm ? "1px solid #962c20" : "1px solid #bbb",
        "background": isConfirm ? "#c0392b" : "#f5f5f5",
        "color": isConfirm ? "#fff" : "#333",
        "cursor": "pointer",
        "font-size": "0.9rem",
        "font-family": "inherit",
        "font-weight": "500",
        "text-align": "center",
        "vertical-align": "middle",
        "white-space": "nowrap"
      };
      for (const [k, v] of Object.entries(css)) b.style.setProperty(k, v, "important");
    }
    const cancel = document.createElement("button");
    cancel.type = "button";
    cancel.className = "cancel";
    cancel.textContent = "Cancel";
    styleBtn(cancel, false);
    confirmBtn = document.createElement("button");
    confirmBtn.type = "button";
    confirmBtn.className = "confirm";
    confirmBtn.textContent = "Remove";
    styleBtn(confirmBtn, true);
    actions.appendChild(cancel);
    actions.appendChild(confirmBtn);
    modal.appendChild(actions);
    render();
    overlay.appendChild(modal);
    function close() {
      overlay.remove();
      document.removeEventListener("keydown", onKey, true);
    }
    function doRemove() {
      const buttons = buttonsFor(activeItems());
      if (buttons.length === 0) return;
      for (const b of buttons) b.click();
    }
    function onKey(ev) {
      if (ev.key === "Escape") {
        close();
        ev.preventDefault();
      }
      if (ev.key === "Enter") {
        doRemove();
        close();
        ev.preventDefault();
      }
    }
    cancel.addEventListener("click", close);
    confirmBtn.addEventListener("click", () => {
      doRemove();
      close();
    });
    overlay.addEventListener("click", (e) => {
      if (e.target === overlay) close();
    });
    document.addEventListener("keydown", onKey, true);
    document.body.appendChild(overlay);
    confirmBtn.focus();
  }
  function describeAction(group, mode) {
    const role = group.roleLabel ? `<b>${escapeHtml(group.roleLabel)}</b>` : "<i>(unknown role)</i>";
    const target = group.targetLabel ? `<b>${escapeHtml(group.targetLabel)}</b>` : "<i>(unknown entity)</i>";
    if (mode === "role") return `Remove role ${role} from every relationship on this release.`;
    if (mode === "target") return `Remove entity ${target} from every relationship on this release, regardless of role.`;
    if (mode === "role-and-target") return `Remove entity ${target} on role ${role}.`;
    return "Remove these relationships.";
  }
  function escapeHtml(s) {
    return String(s).replace(/[&<>"']/g, (c) => c === "&" ? "&amp;" : c === "<" ? "&lt;" : c === ">" ? "&gt;" : c === '"' ? "&quot;" : "&#39;");
  }

  // src/discogs_credits.user.js
  (function handleEntityPageIfNeeded() {
    const entityMatch = location.href.match(
      /musicbrainz\.org\/(artist|label|place)\/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})(?:[^/]|$)/i
    );
    if (!entityMatch) return;
    const entityType = entityMatch[1];
    const mbid = entityMatch[2];
    const pendingKey = "discogs-importer-pending-artist";
    const pending = sessionStorage.getItem(pendingKey);
    if (!pending) return;
    sessionStorage.removeItem(pendingKey);
    const NAME_FETCH_TIMEOUT_MS = 1e3;
    const CLOSE_DELAY_MS = 50;
    const ctrl = new AbortController();
    const timer = setTimeout(() => ctrl.abort(), NAME_FETCH_TIMEOUT_MS);
    fetch(`//musicbrainz.org/ws/2/${entityType}/${mbid}?fmt=json`, { signal: ctrl.signal }).then((r) => r.json()).then((json) => ({ name: json.name || "", disambiguation: json.disambiguation || "" })).catch(() => ({ name: "", disambiguation: "" })).then(({ name, disambiguation }) => {
      clearTimeout(timer);
      DISCOGS_CHANNEL.postMessage({
        type: "artist-created",
        // keep same message type for compatibility
        id: mbid,
        name,
        disambiguation,
        resourceUrl: pending
      });
      setTimeout(() => window.close(), CLOSE_DELAY_MS);
    });
  })();
  $(document).ready(function() {
    const re = /musicbrainz\.org\/release\/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})\/edit-relationships/i;
    const m = window.location.href.match(re);
    if (!m) return;
    installHoverHighlight();
    installBatchRemove();
    getDiscogsUrlForRelease(m[1]).then((discogsUrl) => {
      if (discogsUrl) {
        insertDiscogsBar(discogsUrl);
      }
    });
  });
})();