Qobuz Availability

Qobuz album/track availability across 26 storefront countries. Auto-start, compact, floating minimize.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Qobuz Availability
// @icon         https://www.google.com/s2/favicons?sz=64&domain=qobuz.com
// @namespace    https://example.com/
// @version      1.0.5
// @author       Zxcvr
// @description  Qobuz album/track availability across 26 storefront countries. Auto-start, compact, floating minimize.
// @match        https://qobuz.com/*
// @match        https://www.qobuz.com/*
// @match        https://open.qobuz.com/*
// @match        https://play.qobuz.com/*
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @grant        GM_setClipboard
// @connect      qobuz.com
// @connect      www.qobuz.com
// ==/UserScript==

(() => {
  "use strict";

  const MARKETS = [
    { cc: "AR", name: "Argentina", locales: ["ar-es", "ar-en"] },
    { cc: "AU", name: "Australia", locales: ["au-en"] },
    { cc: "AT", name: "Austria", locales: ["at-de", "at-en"] },
    { cc: "BE", name: "Belgium", locales: ["be-fr", "be-nl", "be-en"] },
    { cc: "BR", name: "Brazil", locales: ["br-pt", "br-en"] },
    { cc: "CA", name: "Canada", locales: ["ca-en", "ca-fr"] },
    { cc: "CL", name: "Chile", locales: ["cl-es", "cl-en"] },
    { cc: "CO", name: "Colombia", locales: ["co-es", "co-en"] },
    { cc: "DK", name: "Denmark", locales: ["dk-en", "dk-da"] },
    { cc: "FI", name: "Finland", locales: ["fi-en", "fi-fi"] },
    { cc: "FR", name: "France", locales: ["fr-fr", "fr-en"] },
    { cc: "DE", name: "Germany", locales: ["de-de", "de-en"] },
    { cc: "IE", name: "Ireland", locales: ["ie-en"] },
    { cc: "IT", name: "Italy", locales: ["it-it", "it-en"] },
    { cc: "JP", name: "Japan", locales: ["jp-ja", "jp-en"] },
    { cc: "LU", name: "Luxembourg", locales: ["lu-fr", "lu-de", "lu-en"] },
    { cc: "NL", name: "Netherlands", locales: ["nl-nl", "nl-en"] },
    { cc: "MX", name: "Mexico", locales: ["mx-es", "mx-en"] },
    { cc: "NZ", name: "New Zealand", locales: ["nz-en"] },
    { cc: "NO", name: "Norway", locales: ["no-en", "no-nb"] },
    { cc: "PT", name: "Portugal", locales: ["pt-pt", "pt-en"] },
    { cc: "ES", name: "Spain", locales: ["es-es", "es-en"] },
    { cc: "SE", name: "Sweden", locales: ["se-en", "se-sv"] },
    { cc: "CH", name: "Switzerland", locales: ["ch-de", "ch-fr", "ch-it", "ch-en"] },
    { cc: "GB", name: "United Kingdom", locales: ["gb-en"] },
    { cc: "US", name: "United States", locales: ["us-en"] },
  ];

  const STORE_BASES = ["https://qobuz.com", "https://www.qobuz.com"];

  const CONCURRENCY = 6;
  const LOCALE_DELAY_MS = 70;

  const sleep = (ms) => new Promise(r => setTimeout(r, ms));

  let cancelRequested = false;
  let autoRunTimer = null;
  let autoTries = 0;
  const AUTO_MAX_TRIES = 6;

  // Persist globally
  const STORE_KEY_FLOAT = "qz_av_floating_v1";
  const STORE_KEY_MIN   = "qz_av_minimized_v1";
  const STORE_KEY_PREF  = "qz_av_prefCountries_v1";

  const lsGet = (k) => { try { return localStorage.getItem(k); } catch { return null; } };
  const lsSet = (k, v) => { try { localStorage.setItem(k, v); } catch {} };

  // ---- preferred countries (global) ----
  const PREF = {
    set: new Set(),
    load() {
      try {
        const raw = lsGet(STORE_KEY_PREF);
        if (!raw) return;
        const arr = JSON.parse(raw);
        if (!Array.isArray(arr)) return;
        const allowed = new Set(MARKETS.map(m => m.cc));
        this.set = new Set(arr.map(x => String(x || "").toUpperCase()).filter(x => allowed.has(x)));
      } catch {}
    },
    save() {
      try { lsSet(STORE_KEY_PREF, JSON.stringify(Array.from(this.set).sort())); } catch {}
    },
    has(cc) { return this.set.has(String(cc || "").toUpperCase()); },
    toggle(cc) {
      cc = String(cc || "").toUpperCase();
      if (!cc) return;
      if (this.set.has(cc)) this.set.delete(cc);
      else this.set.add(cc);
      this.save();
    }
  };
  PREF.load();

  GM_addStyle(`
    .qz-av-wrap{
      margin:10px auto;
      padding:10px 10px 8px;
      max-width: 920px;
      width: calc(100% - 24px);
      border:1px solid rgba(255,255,255,.18);
      border-radius:14px;
      background:rgba(0,0,0,.35);
      color:#fff;
      font: 13px/1.35 system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;
      backdrop-filter: blur(6px);
      box-shadow: 0 12px 34px rgba(0,0,0,.20);
      z-index:999999;
    }

    .qz-titlebar{
      display:flex; align-items:center; justify-content:space-between;
      gap:10px;
      margin-bottom:8px;
    }
    .qz-av-title{font-size:18px;font-weight:900;margin:0; display:flex; align-items:baseline; gap:10px;}
    .qz-credit{font-size:12px; font-weight:900; opacity:.7; letter-spacing:.2px;}

    .qz-titlebtns{display:flex;gap:8px;align-items:center}
    .qz-iconbtn{
      width:34px;height:34px;
      border-radius:10px;
      border:1px solid rgba(255,255,255,.18);
      background:rgba(255,255,255,.08);
      color:#fff; cursor:pointer;
      display:inline-flex;align-items:center;justify-content:center;
      font-weight:900;
    }
    .qz-iconbtn:hover{background:rgba(255,255,255,.12)}
    .qz-av-wrap:not(.qz-av-floating) .qz-iconbtn.minbtn{display:none;}

    .qz-av-toprow{display:flex; gap:10px; flex-wrap:wrap; align-items:center}
    .qz-av-toprow input{
      width: 260px; max-width: 65vw;
      padding:7px 10px;
      border-radius:9999px;
      border:1px solid rgba(255,255,255,.18);
      background:rgba(255,255,255,.06);
      color:#fff;
      outline:none;
      font-weight: 700;
    }
    .qz-av-btns{display:flex;gap:8px;flex-wrap:wrap}
    .qz-av-btns button{
      padding:7px 10px;
      border-radius:10px;
      border:1px solid rgba(255,255,255,.18);
      background:rgba(255,255,255,.08);
      color:#fff; cursor:pointer; font-weight:700
    }
    .qz-av-btns button:hover{background:rgba(255,255,255,.12)}

    .qz-av-progress{
      font-size:12px;
      opacity:.9;
      margin:6px 0 6px;
      min-height: 14px;
    }

    /* scanning bar */
    #qz_bar{
      width: 100%;
      height: 8px;
      display: none;
      margin: 6px 0 0;
      border-radius: 9999px;
      overflow: hidden;
      accent-color: #18c1c1;
    }
    #qz_bar::-webkit-progress-bar{
      background: rgba(255,255,255,.14);
      border-radius: 9999px;
    }
    #qz_bar::-webkit-progress-value{
      background: rgba(24,193,193,.90);
      border-radius: 9999px;
    }
    #qz_bar::-moz-progress-bar{
      background: rgba(24,193,193,.90);
      border-radius: 9999px;
    }

    .qz-av-block{
      background:rgba(0,0,0,.30);
      border:1px solid rgba(255,255,255,.12);
      padding:10px;
      border-radius:12px;
      margin-top:10px;
    }
    .qz-av-h{font-weight:900;margin:0 0 8px;font-size:16px}

    .qz-section-head{
      display:flex; align-items:center; gap:8px;
      margin:10px 0 7px;
      font-weight:900;
      font-size:15px;
    }

    .qz-copybtn{
      width:34px; height:34px;
      border-radius:10px;
      border:1px solid rgba(255,255,255,.18);
      background:rgba(255,255,255,.08);
      display:inline-flex; align-items:center; justify-content:center;
      cursor:pointer;
    }
    .qz-copybtn:hover{background:rgba(255,255,255,.12)}
    .qz-copybtn svg{width:18px;height:18px;opacity:.95}

    .qz-av-row{display:flex;flex-wrap:wrap;gap:8px}

    .qz-chip{
      display:inline-flex; align-items:center; gap:8px;
      padding:6px 10px;
      border-radius:9999px;
      border:1px solid rgba(255,255,255,.18);
      background:rgba(255,255,255,.06);
      font-weight:900;
      text-decoration:none;
      color:#fff;
      user-select:none;
    }
    .qz-chip:hover{background:rgba(255,255,255,.10)}
    .qz-chip.unav{opacity:.55;background:rgba(255,255,255,.04)}
    .qz-chip.hidden{display:none}

    /* preferred highlight */
    .qz-chip.qz-pref{
      border-color: rgba(24,193,193,.75);
      box-shadow: 0 0 0 1px rgba(24,193,193,.35) inset;
      background: rgba(24,193,193,.10);
    }

    .qz-flag{
      width:18px; height:12px; border-radius:2px;
      object-fit:cover; display:block;
      box-shadow: 0 0 0 1px rgba(255,255,255,.12) inset;
    }

    .qz-av-floating{
      position:fixed;right:14px;bottom:14px;
      width:min(560px, calc(100vw - 28px));
      max-width:none;
      max-height:75vh;overflow:auto;
      margin:0;
    }

    .qz-av-floating.qz-minimized{
      width: 360px;
      max-height: none;
      overflow: hidden;
      padding:10px 10px 10px;
    }
    .qz-minimized .qz-av-toprow,
    .qz-minimized #qz_out,
    .qz-minimized #qz_progress,
    .qz-minimized #qz_bar,
    .qz-minimized #qz_adv { display:none; }

    /* advanced */
    #qz_adv{
      margin-top: 10px;
      padding-top: 8px;
      border-top: 1px solid rgba(255,255,255,.12);
    }
    #qz_adv summary{
      cursor:pointer;
      list-style:none;
      font-weight: 900;
      opacity: .9;
      user-select:none;
    }
    #qz_adv summary::-webkit-details-marker{ display:none; }
    #qz_adv .qz-adv-body{
      margin-top: 8px;
    }
    #qz_pref_list{
      display:flex; flex-wrap:wrap; gap:8px;
      margin-top: 6px;
    }
    .qz-prefopt{
      cursor:pointer;
    }
  `);

  function gmGet(url) {
    return new Promise((resolve) => {
      GM_xmlhttpRequest({
        method: "GET",
        url,
        timeout: 25000,
        anonymous: true,
        withCredentials: false,
        onload: (resp) => resolve(resp),
        onerror: () => resolve({ status: 0, responseText: "" }),
        ontimeout: () => resolve({ status: 0, responseText: "" }),
      });
    });
  }

  function stripLocalePrefix(pathname) {
    return pathname.replace(/^\/[a-z]{2}-[a-z]{2}\//, "/");
  }

  function findTypeIdAndStorePath() {
    const u = new URL(location.href);
    const parts = u.pathname.split("/").filter(Boolean);
    const hasLocale = parts[0] && /^[a-z]{2}-[a-z]{2}$/.test(parts[0]);
    const idx = hasLocale ? 1 : 0;
    const type = parts[idx];
    if (type !== "album" && type !== "track") return null;
    const id = parts[parts.length - 1] || "";
    const storePath = stripLocalePrefix(u.pathname);
    return { type, id, storePath };
  }

  function looksUnavailable(html) {
    const t = (html || "").toLowerCase();
    return (
      t.includes("page not found") ||
      t.includes("not found") ||
      t.includes("error 404") ||
      (t.includes("not available") && (t.includes("country") || t.includes("region"))) ||
      t.includes("n'est pas disponible") ||
      t.includes("nicht verfügbar") ||
      t.includes("non è disponibile") ||
      t.includes("no está disponible") ||
      t.includes("não está disponível")
    );
  }

  function looksLikeProductPage(html, type) {
    const t = (html || "");
    if (type === "album") return t.includes("MusicAlbum") || /og:type"\s+content="music\.album"/i.test(t);
    if (type === "track") return t.includes("MusicRecording") || /og:type"\s+content="music\.song"/i.test(t);
    return false;
  }

  async function checkCountry(country, storePath, type) {
    for (const base of STORE_BASES) {
      for (const locale of country.locales) {
        if (cancelRequested) return { ok: false, cancelled: true };
        const url = `${base}/${locale}${storePath}`;
        const resp = await gmGet(url);
        if (resp.status >= 200 && resp.status < 300) {
          const html = resp.responseText || "";
          if (!looksUnavailable(html) && looksLikeProductPage(html, type)) {
            return { ok: true, locale, url };
          }
        }
        await sleep(LOCALE_DELAY_MS);
      }
    }
    return { ok: false, locale: null, url: null };
  }

  async function promisePool(items, worker, concurrency) {
    let i = 0;
    const results = new Array(items.length);

    const runners = new Array(concurrency).fill(0).map(async () => {
      while (i < items.length && !cancelRequested) {
        const idx = i++;
        results[idx] = await worker(items[idx], idx);
      }
    });

    await Promise.all(runners);
    return results;
  }

  function flagImgUrl(cc) {
    return `https://flagcdn.com/h20/${cc.toLowerCase()}.png`;
  }

  const COPY_SVG = `
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
      <path d="M9 9h10v10H9V9Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
      <path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
    </svg>
  `;

  let box = null;
  let lastHref = location.href;

  function bestMount() {
    return document.querySelector("main") ||
           document.querySelector("#app") ||
           document.querySelector("#root") ||
           document.body;
  }

  function setProgress(msg) {
    const el = box?.querySelector("#qz_progress");
    if (el) el.textContent = msg || "";
  }

  function showBar(total) {
    const bar = box?.querySelector("#qz_bar");
    if (!bar) return;
    bar.max = total || 1;
    bar.value = 0;
    bar.style.display = "block";
  }
  function setBar(done, total) {
    const bar = box?.querySelector("#qz_bar");
    if (!bar) return;
    if (typeof total === "number") bar.max = total || 1;
    bar.value = done || 0;
  }
  function hideBar() {
    const bar = box?.querySelector("#qz_bar");
    if (!bar) return;
    bar.style.display = "none";
    bar.value = 0;
  }

  function applyFilter(q) {
    q = (q || "").trim().toLowerCase();
    const chips = box.querySelectorAll(".qz-chip");
    chips.forEach(ch => {
      const cc = (ch.dataset.cc || "").toLowerCase();
      const name = (ch.dataset.name || "").toLowerCase();
      const ok = !q || cc.includes(q) || name.includes(q);
      ch.classList.toggle("hidden", !ok);
    });
  }

  function applyPrefsToRendered() {
    if (!box) return;
    for (const el of box.querySelectorAll(".qz-chip[data-cc]")) {
      const cc = (el.dataset.cc || "").toUpperCase();
      el.classList.toggle("qz-pref", PREF.has(cc));
    }
    for (const el of box.querySelectorAll(".qz-prefopt[data-cc]")) {
      const cc = (el.dataset.cc || "").toUpperCase();
      el.classList.toggle("qz-pref", PREF.has(cc));
    }
  }

  function updateMinBtn() {
    const btn = box?.querySelector("#qz_min");
    if (!btn) return;
    const minimized = box.classList.contains("qz-minimized");
    btn.textContent = minimized ? "▢" : "—";
    btn.title = minimized ? "Expand" : "Minimize";
  }

  function applySavedFloatMinState() {
    const floatOn = lsGet(STORE_KEY_FLOAT) === "1";
    box.classList.toggle("qz-av-floating", floatOn);

    const minOn = lsGet(STORE_KEY_MIN) === "1";
    box.classList.toggle("qz-minimized", floatOn && minOn);

    updateMinBtn();
  }

  function setMinimized(on) {
    box.classList.toggle("qz-minimized", !!on);
    lsSet(STORE_KEY_MIN, on ? "1" : "0");
    updateMinBtn();
  }

  function renderPrefChooser() {
    const host = box?.querySelector("#qz_pref_list");
    if (!host) return;

    host.innerHTML = "";
    for (const m of MARKETS.slice().sort((a, b) => a.cc.localeCompare(b.cc))) {
      const el = document.createElement("span");
      el.className = "qz-chip qz-prefopt";
      el.dataset.cc = m.cc;
      el.dataset.name = m.name;
      el.title = m.name;

      const flag = document.createElement("img");
      flag.className = "qz-flag";
      flag.alt = "";
      flag.src = flagImgUrl(m.cc);
      flag.addEventListener("error", () => flag.remove(), { once: true });

      const label = document.createElement("span");
      label.textContent = m.cc;

      el.append(flag, label);

      el.addEventListener("click", () => {
        PREF.toggle(m.cc);
        applyPrefsToRendered();
      });

      host.appendChild(el);
    }

    applyPrefsToRendered();
  }

  function ensureBox() {
    if (box && document.contains(box)) return box;

    box = document.createElement("div");
    box.className = "qz-av-wrap";
    box.innerHTML = `
      <div class="qz-titlebar">
        <div class="qz-av-title">Qobuz Availability <span class="qz-credit">by Zxcvr</span></div>
        <div class="qz-titlebtns">
          <button class="qz-iconbtn minbtn" id="qz_min" title="Minimize">—</button>
        </div>
      </div>

      <div class="qz-av-toprow">
        <input id="qz_filter" placeholder="Search (JP / Japan / United...)" />
        <div class="qz-av-btns">
          <button id="qz_float">Toggle Floating</button>
        </div>
      </div>

      <div class="qz-av-progress" id="qz_progress"></div>
      <progress id="qz_bar" max="1" value="0"></progress>
      <div id="qz_out"></div>

      <details id="qz_adv">
        <summary>Advanced</summary>
        <div class="qz-adv-body">
          <div style="font-weight:900; opacity:.9;">Choose default country(s)</div>
          <div id="qz_pref_list"></div>
        </div>
      </details>
    `;

    bestMount().prepend(box);

    applySavedFloatMinState();

    box.querySelector("#qz_min").onclick = () => {
      setMinimized(!box.classList.contains("qz-minimized"));
    };

    box.querySelector("#qz_float").onclick = () => {
      const nowOn = !box.classList.contains("qz-av-floating");
      box.classList.toggle("qz-av-floating", nowOn);
      lsSet(STORE_KEY_FLOAT, nowOn ? "1" : "0");

      if (nowOn) {
        const savedMin = lsGet(STORE_KEY_MIN) === "1";
        box.classList.toggle("qz-minimized", savedMin);
      } else {
        box.classList.remove("qz-minimized");
      }
      updateMinBtn();
    };

    updateMinBtn();
    renderPrefChooser();
    return box;
  }

  function createChipEl(obj, cls) {
    const title = obj.locale ? `${obj.name} — ${obj.locale}` : obj.name;

    const flag = document.createElement("img");
    flag.className = "qz-flag";
    flag.alt = "";
    flag.src = flagImgUrl(obj.cc);
    flag.addEventListener("error", () => flag.remove(), { once: true });

    const label = document.createElement("span");
    label.textContent = obj.cc;

    let el;
    if (obj.url) {
      el = document.createElement("a");
      el.href = obj.url;
      el.target = "_blank";
      el.rel = "noreferrer";
    } else {
      el = document.createElement("span");
    }

    el.className = `qz-chip ${cls || ""} ${PREF.has(obj.cc) ? "qz-pref" : ""}`.trim();
    el.dataset.cc = obj.cc;
    el.dataset.name = obj.name;
    el.title = title;
    el.append(flag, label);
    return el;
  }

  function renderLiveShell(kind, availableArrRef, unavailableArrRef) {
    const out = box.querySelector("#qz_out");
    out.innerHTML = `
      <div class="qz-av-block">
        <div class="qz-av-h">${kind} Availability</div>

        <div class="qz-section-head">
          <span id="qz_av_count">Available in (0):</span>
          <button class="qz-copybtn" id="qz_copy_av" title="Copy">${COPY_SVG}</button>
        </div>
        <div class="qz-av-row" id="qz_av_list"></div>

        <div class="qz-section-head" style="margin-top:12px;">
          <span id="qz_un_count">Unavailable in (0):</span>
          <button class="qz-copybtn" id="qz_copy_unav" title="Copy">${COPY_SVG}</button>
        </div>
        <div class="qz-av-row" id="qz_un_list"></div>
      </div>
    `;

    out.querySelector("#qz_copy_av").onclick = () => GM_setClipboard(availableArrRef.map(x => x.cc).join(", "));
    out.querySelector("#qz_copy_unav").onclick = () => GM_setClipboard(unavailableArrRef.map(x => x.cc).join(", "));
  }

  function render(kind, availableObjs, unavailableObjs) {
    const out = box.querySelector("#qz_out");

    const chipHtml = (obj, cls) => {
      const title = obj.locale ? `${obj.name} — ${obj.locale}` : obj.name;
      const flag = `<img class="qz-flag" data-flag="1" alt="" src="${flagImgUrl(obj.cc)}">`;
      const label = `<span>${obj.cc}</span>`;
      const prefClass = PREF.has(obj.cc) ? " qz-pref" : "";

      if (obj.url) {
        return `<a class="qz-chip ${cls || ""}${prefClass}" data-cc="${obj.cc}" data-name="${obj.name}"
                  href="${obj.url}" target="_blank" rel="noreferrer" title="${title}">
                  ${flag}${label}
                </a>`;
      }
      return `<span class="qz-chip ${cls || ""}${prefClass}" data-cc="${obj.cc}" data-name="${obj.name}" title="${title}">
                ${flag}${label}
              </span>`;
    };

    out.innerHTML = `
      <div class="qz-av-block">
        <div class="qz-av-h">${kind} Availability</div>

        <div class="qz-section-head">
          <span>Available in (${availableObjs.length}):</span>
          <button class="qz-copybtn" id="qz_copy_av" title="Copy">${COPY_SVG}</button>
        </div>
        <div class="qz-av-row">
          ${availableObjs.map(o => chipHtml(o, "")).join("")}
        </div>

        <div class="qz-section-head" style="margin-top:12px;">
          <span>Unavailable in (${unavailableObjs.length}):</span>
          <button class="qz-copybtn" id="qz_copy_unav" title="Copy">${COPY_SVG}</button>
        </div>
        <div class="qz-av-row">
          ${unavailableObjs.map(o => chipHtml(o, "unav")).join("")}
        </div>
      </div>
    `;

    out.querySelectorAll('img[data-flag="1"]').forEach(img => {
      img.addEventListener("error", () => img.remove(), { once: true });
    });

    out.querySelector("#qz_copy_av").onclick = () => GM_setClipboard(availableObjs.map(x => x.cc).join(", "));
    out.querySelector("#qz_copy_unav").onclick = () => GM_setClipboard(unavailableObjs.map(x => x.cc).join(", "));

    applyFilter(box.querySelector("#qz_filter").value);
    applyPrefsToRendered();
  }

  async function run(isAuto = false) {
    const info = findTypeIdAndStorePath();
    if (!info) {
      if (isAuto && autoTries < AUTO_MAX_TRIES) scheduleAutoRun(550, true);
      return;
    }

    autoTries = 0;
    cancelRequested = false;
    ensureBox();

    const total = MARKETS.length;
    const kind = info.type === "album" ? "Album" : "Track";

    const availableLive = [];
    const unavailableLive = [];

    renderLiveShell(kind, availableLive, unavailableLive);

    const avList = box.querySelector("#qz_av_list");
    const unList = box.querySelector("#qz_un_list");
    const avCount = box.querySelector("#qz_av_count");
    const unCount = box.querySelector("#qz_un_count");

    showBar(total);
    setBar(0, total);

    let done = 0;
    setProgress(`Scanning… ${done}/${total}`);

    let lastUi = 0;

    const worker = async (c) => {
      const r = await checkCountry(c, info.storePath, info.type);

      if (cancelRequested) return r;

      const obj = r?.ok
        ? { cc: c.cc, name: c.name, url: r.url, locale: r.locale }
        : { cc: c.cc, name: c.name, url: null, locale: null };

      if (r?.ok) {
        availableLive.push(obj);
        avList.appendChild(createChipEl(obj, ""));
        avCount.textContent = `Available in (${availableLive.length}):`;
      } else {
        unavailableLive.push(obj);
        unList.appendChild(createChipEl(obj, "unav"));
        unCount.textContent = `Unavailable in (${unavailableLive.length}):`;
      }

      done++;
      setBar(done, total);

      const now = performance.now();
      if (now - lastUi > 120 || done === total) {
        lastUi = now;
        setProgress(`Scanning… ${done}/${total}`);
        applyFilter(box.querySelector("#qz_filter").value);
      }

      return r;
    };

    await promisePool(MARKETS, worker, CONCURRENCY);

    if (cancelRequested) { setProgress(""); hideBar(); return; }

    const availableSorted = availableLive.slice().sort((a, b) => a.cc.localeCompare(b.cc));
    const unavailableSorted = unavailableLive.slice().sort((a, b) => a.cc.localeCompare(b.cc));

    render(kind, availableSorted, unavailableSorted);
    setProgress("");
    hideBar();
  }

  function scheduleAutoRun(delayMs = 550, isRetry = false) {
    clearTimeout(autoRunTimer);
    autoRunTimer = setTimeout(() => {
      if (cancelRequested) return;
      if (!isRetry) autoTries = 0;
      autoTries++;
      run(true).catch(() => {});
    }, delayMs);
  }

  function wire() {
    ensureBox();
    const filter = box.querySelector("#qz_filter");
    filter.oninput = () => applyFilter(filter.value);
    scheduleAutoRun(550, false);
  }

  function onNavMaybe() {
    if (location.href === lastHref) return;
    lastHref = location.href;

    clearTimeout(autoRunTimer);
    cancelRequested = true;

    if (box) box.remove();
    box = null;

    setTimeout(() => {
      cancelRequested = false;
      wire();
    }, 250);
  }

  function installNavHooks() {
    const trigger = () => setTimeout(onNavMaybe, 50);

    const ps = history.pushState;
    history.pushState = function () { ps.apply(this, arguments); trigger(); };

    const rs = history.replaceState;
    history.replaceState = function () { rs.apply(this, arguments); trigger(); };

    window.addEventListener("popstate", trigger);
    window.addEventListener("hashchange", trigger);
  }

  wire();
  installNavHooks();
})();