TIDAL Availability

TIDAL track/album availability across regions. Qobuz-style UI/UX: auto-start, search, copy icons, progress bar, floating+minimize (remembered), preferred countries (global highlight). No login.

Du musst eine Erweiterung wie Tampermonkey, Greasemonkey oder Violentmonkey installieren, um dieses Skript zu installieren.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

Sie müssten eine Skript Manager Erweiterung installieren damit sie dieses Skript installieren können

(Ich habe schon ein Skript Manager, Lass mich es installieren!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         TIDAL Availability
// @icon         https://www.google.com/s2/favicons?sz=64&domain=tidal.com
// @namespace    https://tidal.com/
// @version      2.3.1
// @author       Zxcvr
// @description  TIDAL track/album availability across regions. Qobuz-style UI/UX: auto-start, search, copy icons, progress bar, floating+minimize (remembered), preferred countries (global highlight). No login.
// @match        https://tidal.com/*
// @match        https://www.tidal.com/*
// @match        https://listen.tidal.com/*
// @run-at       document-start
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @grant        GM_setClipboard
// @connect      api.tidal.com
// ==/UserScript==

(() => {
  "use strict";

  // ---------- CONFIG ----------
  const API_BASE = "https://api.tidal.com/v1";
  const FALLBACK_X_TIDAL_TOKEN = "gsFXkJqGrUNoYMQPZe4k3WKwijnrp8iGSwn3bApe";
  const CONCURRENCY = 8;

  const AUTO_DELAY_MS = 550;
  const AUTO_MAX_TRIES = 6;

  // Persist globally (TIDAL-only keys)
  const STORE_KEY_FLOAT = "ta_av_floating_v1";
  const STORE_KEY_MIN   = "ta_av_minimized_v1";
  const STORE_KEY_PREF  = "ta_av_prefCountries_v1";

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

  // TIDAL supported countries list (fallback / union)
  const TIDAL_MARKETS = [
    "NG","ZA","UG",
    "HK","IL","MY","SG","TH","AE",
    "AL","AD","AT","BE","BA","BG","HR","CY","CZ","DK","EE","FI","FR","DE","GR","HU","IS","IE","IT","LV","LI","LT","LU","MT","MC","ME","NL","MK","NO","PL","PT","RO","RS","SK","SI","ES","SE","CH","GB",
    "CA","DO","JM","MX","PR","US",
    "AR","BR","CL","CO","PE",
    "AU","NZ"
  ].sort();

  // ---------- TOKEN CAPTURE (guest-friendly) ----------
  let observedXToken = null;
  hookFetchAndXHR();

  function hookFetchAndXHR() {
    const ofetch = window.fetch;
    window.fetch = function (input, init) {
      try {
        const headers = new Headers((init && init.headers) || (input && input.headers) || undefined);
        const xt = headers.get("x-tidal-token") || headers.get("X-Tidal-Token");
        if (xt && !observedXToken) observedXToken = xt;
      } catch {}
      return ofetch.apply(this, arguments);
    };

    const oset = XMLHttpRequest.prototype.setRequestHeader;
    XMLHttpRequest.prototype.setRequestHeader = function (name, value) {
      try {
        if (!observedXToken && String(name).toLowerCase() === "x-tidal-token") observedXToken = value;
      } catch {}
      return oset.apply(this, arguments);
    };
  }

  function getXToken() {
    return observedXToken || FALLBACK_X_TIDAL_TOKEN;
  }

  // ---------- COUNTRY NAMES (fallback to Intl) ----------
  const regionNames = (() => {
    try { return new Intl.DisplayNames(["en"], { type: "region" }); } catch { return null; }
  })();

  function fallbackName(cc) {
    const c = String(cc || "").toUpperCase();
    if (regionNames) {
      try { return regionNames.of(c) || c; } catch { return c; }
    }
    return c;
  }

  // ---------- FLAGS (Twemoji SVG) ----------
  function flagSvgUrl(cc) {
    cc = String(cc || "").toUpperCase();
    if (!/^[A-Z]{2}$/.test(cc)) return null;
    const base = 0x1F1E6;
    const a = base + (cc.charCodeAt(0) - 65);
    const b = base + (cc.charCodeAt(1) - 65);
    const hex = (n) => n.toString(16);
    return `https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/svg/${hex(a)}-${hex(b)}.svg`;
  }

  // ---------- RESOURCE PARSE ----------
  function parseResource() {
    const m = location.pathname.match(/\/(?:browse\/)?(album|track)\/(\d+)/i);
    if (!m) return null;
    return { type: m[1].toLowerCase(), id: m[2] };
  }

  // ---------- NETWORK ----------
  function gmJson(url, headers) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: "GET",
        url,
        headers,
        responseType: "json",
        timeout: 25000,
        onload: (r) => resolve({ status: r.status, data: r.response }),
        onerror: (e) => reject(e),
        ontimeout: () => reject(new Error("Request timed out")),
      });
    });
  }

  async function getMarketsAndNames() {
    const headers = { "x-tidal-token": getXToken(), "accept": "application/json" };
    const nameMap = new Map();

    try {
      const r = await gmJson(`${API_BASE}/countries`, headers);
      if (r.status === 200 && Array.isArray(r.data)) {
        for (const x of r.data) {
          const code = String(x?.countryCode || "").toUpperCase();
          if (!/^[A-Z]{2}$/.test(code)) continue;
          const nm = String(x?.name || x?.countryName || x?.country || "").trim();
          if (nm) nameMap.set(code, nm);
        }
      }
    } catch { /* ignore */ }

    const codes = new Set(TIDAL_MARKETS);
    for (const k of nameMap.keys()) codes.add(k);

    for (const c of codes) {
      if (!nameMap.has(c)) nameMap.set(c, fallbackName(c));
    }

    return { codes: Array.from(codes).sort(), names: nameMap };
  }

  async function checkOne(resource, cc) {
    const url = `${API_BASE}/${resource.type}s/${resource.id}?countryCode=${encodeURIComponent(cc)}`;
    const headers = { "x-tidal-token": getXToken(), "accept": "application/json" };

    let r = await gmJson(url, headers);
    if (r.status === 429) { await sleep(800); r = await gmJson(url, headers); }

    if (r.status === 200) return true;
    if (r.status === 404 || r.status === 400) return false;

    if (r.status === 401 || r.status === 403) throw new Error(`Token rejected (${r.status}).`);
    return null;
  }

  function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }

  // ---------- PREF (global highlight) ----------
  const PREF = {
    set: new Set(),
    allowed: new Set(TIDAL_MARKETS),
    load() {
      try {
        const raw = lsGet(STORE_KEY_PREF);
        if (!raw) return;
        const arr = JSON.parse(raw);
        if (!Array.isArray(arr)) return;
        this.set = new Set(
          arr.map(x => String(x || "").toUpperCase()).filter(x => this.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();

  // ---------- PLACEMENT (copied from your original TIDAL logic) ----------
  function findHeadingAnchor(root, texts) {
    const els = Array.from(root.querySelectorAll("h1,h2,h3,h4,[role='heading']"));
    for (const el of els) {
      const t = (el.textContent || "").trim().toLowerCase();
      if (!t) continue;
      if (texts.some(x => t === x.toLowerCase())) return el.closest("section,div") || el;
    }
    return null;
  }

  function findTracklist(root) {
    const footer = root.querySelector("footer") || document.querySelector("footer");
    const lists = Array.from(root.querySelectorAll("ol,ul")).filter(L => !(footer && footer.contains(L)));
    const timeRe = /\b\d{1,2}:\d{2}\b/;

    let best = null, bestScore = -1;
    for (const L of lists) {
      const items = Array.from(L.querySelectorAll("li"));
      if (items.length < 3) continue;
      let hits = 0;
      for (const li of items.slice(0, 30)) if (timeRe.test(li.textContent || "")) hits++;
      const score = hits * 100 + items.length;
      if (score > bestScore) { bestScore = score; best = L; }
    }
    return best;
  }

  function mountBox(node) {
    if (!node) return;

    // floating: keep in <body> to avoid weird scroll containers/transform parents
    if (node.classList.contains("ta-floating")) {
      if (node.parentElement !== document.body) document.body.appendChild(node);
      return;
    }

    const main =
      document.querySelector("main,[role='main']") ||
      document.querySelector("#__next") ||
      document.body;

    const anchor = findHeadingAnchor(main, ["Related Artists","You might also like","More by","Appears On","Related Albums"]);
    if (anchor && anchor.parentElement) {
      if (node.parentElement !== anchor.parentElement || node.nextSibling !== anchor) {
        anchor.parentElement.insertBefore(node, anchor);
      }
      return;
    }

    const trackList = findTracklist(main);
    if (trackList && trackList.parentElement) {
      const parent = trackList.parentElement;
      const ref = trackList.nextSibling;
      if (node.parentElement !== parent || node.nextSibling !== ref) parent.insertBefore(node, ref);
      return;
    }

    if (node.parentElement !== main) main.prepend(node);
  }

  // ---------- UI (Qobuz-style panel) ----------
  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;
  let cancelRequested = false;
  let autoRunTimer = null;
  let autoTries = 0;

  const STATE = {
    codes: TIDAL_MARKETS.slice(),
    names: new Map(TIDAL_MARKETS.map(cc => [cc, fallbackName(cc)])),
    filter: "",
    availableLive: [],
    unavailableLive: [],
    unknownLive: []
  };

  GM_addStyle(`
    .ta-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;
    }

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

    .ta-titlebtns{display:flex;gap:8px;align-items:center}
    .ta-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;
    }
    .ta-iconbtn:hover{background:rgba(255,255,255,.12)}
    .ta-av-wrap:not(.ta-floating) .ta-iconbtn.minbtn{display:none;}

    .ta-toprow{display:flex; gap:10px; flex-wrap:wrap; align-items:center}
    .ta-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;
    }
    .ta-btns{display:flex;gap:8px;flex-wrap:wrap}
    .ta-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
    }
    .ta-btns button:hover{background:rgba(255,255,255,.12)}

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

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

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

    .ta-section-head{display:flex; align-items:center; gap:8px; margin:10px 0 7px; font-weight:900; font-size:15px;}
    .ta-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;
    }
    .ta-copybtn:hover{background:rgba(255,255,255,.12)}
    .ta-copybtn svg{width:18px;height:18px;opacity:.95}

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

    .ta-chip{
      position:relative;
      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; color:#fff; user-select:none;
    }
    .ta-chip:hover{background:rgba(255,255,255,.10)}
    .ta-chip.ta-unav{opacity:.55;background:rgba(255,255,255,.04)}
    .ta-chip.ta-hidden{display:none}

    .ta-chip.ta-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);
    }

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

    .ta-chip:hover::after{
      content: attr(data-name);
      position:absolute; left:0; top:calc(100% + 8px);
      background: rgba(10,10,10,0.98);
      border: 1px solid rgba(255,255,255,.22);
      padding: 6px 10px;
      border-radius: 10px;
      white-space: nowrap;
      z-index: 999999;
      font-weight: 800;
      pointer-events:none;
    }

    .ta-floating{
      position:fixed;right:14px;bottom:14px;
      width:min(560px, calc(100vw - 28px));
      max-width:none;
      max-height:75vh;overflow:auto;
      margin:0;
    }
    .ta-floating.ta-minimized{
      width:360px; max-height:none; overflow:hidden; padding:10px 10px 10px;
    }
    .ta-minimized .ta-toprow,
    .ta-minimized #ta_out,
    .ta-minimized #ta_progress,
    .ta-minimized #ta_bar,
    .ta-minimized #ta_adv{display:none;}

    #ta_adv{margin-top:10px;padding-top:8px;border-top:1px solid rgba(255,255,255,.12);}
    #ta_adv summary{cursor:pointer;list-style:none;font-weight:900;opacity:.9;user-select:none;}
    #ta_adv summary::-webkit-details-marker{display:none;}
    #ta_pref_list{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px;max-height:180px;overflow:auto;padding-right:6px;}
  `);

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

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

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

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

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

    updateMinBtn();
  }

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

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

  function applyFilter(q) {
    STATE.filter = (q || "").trim().toLowerCase();
    if (!box) return;
    const chips = box.querySelectorAll(".ta-chip[data-code]");
    chips.forEach(ch => {
      const cc = (ch.dataset.code || "").toLowerCase();
      const name = (ch.dataset.name || "").toLowerCase();
      const ok = !STATE.filter || cc.includes(STATE.filter) || name.includes(STATE.filter);
      ch.classList.toggle("ta-hidden", !ok);
    });
  }

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

    host.innerHTML = "";
    const codes = (STATE.codes && STATE.codes.length ? STATE.codes : TIDAL_MARKETS).slice().sort();
    PREF.allowed = new Set(codes);

    for (const cc of codes) {
      const name = STATE.names.get(cc) || fallbackName(cc);

      const el = document.createElement("span");
      el.className = "ta-chip";
      el.dataset.cc = cc;
      el.dataset.name = name;
      el.title = name;

      const svg = flagSvgUrl(cc);
      if (svg) {
        const img = document.createElement("img");
        img.className = "ta-flag";
        img.alt = "";
        img.src = svg;
        img.addEventListener("error", () => img.remove(), { once: true });
        setTimeout(() => { if (!img.complete || img.naturalWidth === 0) img.remove(); }, 2500);
        el.appendChild(img);
      }

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

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

      host.appendChild(el);
    }

    applyPrefsToRendered();
  }

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

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

      <div class="ta-toprow">
        <input id="ta_filter" placeholder="Search (JP / Japan / United...)" />
        <div class="ta-btns">
          <button id="ta_float">Toggle Floating</button>
        </div>
      </div>

      <div class="ta-progress" id="ta_progress"></div>
      <progress id="ta_bar" max="1" value="0"></progress>
      <div id="ta_out"></div>

      <details id="ta_adv">
        <summary>Advanced</summary>
        <div style="font-weight:900; opacity:.9; margin-top:8px;">Choose default country(s)</div>
        <div id="ta_pref_list"></div>
      </details>
    `;

    applySavedFloatMinState();
    mountBox(box);

    box.querySelector("#ta_min").onclick = () => setMinimized(!box.classList.contains("ta-minimized"));

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

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

    box.querySelector("#ta_filter").oninput = (e) => applyFilter(e.target.value);

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

  function makeChip(cc, name, cls) {
    const el = document.createElement("span");
    el.className = `ta-chip ${cls || ""}`.trim();
    el.dataset.code = cc;
    el.dataset.name = name;
    el.title = name;

    const svg = flagSvgUrl(cc);
    if (svg) {
      const img = document.createElement("img");
      img.className = "ta-flag";
      img.alt = "";
      img.src = svg;
      img.addEventListener("error", () => img.remove(), { once: true });
      setTimeout(() => { if (!img.complete || img.naturalWidth === 0) img.remove(); }, 2500);
      el.appendChild(img);
    }

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

    if (PREF.has(cc)) el.classList.add("ta-pref");
    return el;
  }

  function renderLiveShell(kind) {
    const out = box.querySelector("#ta_out");
    out.innerHTML = `
      <div class="ta-block">
        <div class="ta-h">${kind} Availability</div>

        <div class="ta-section-head">
          <span id="ta_av_count">Available in (0):</span>
          <button class="ta-copybtn" id="ta_copy_av" title="Copy">${COPY_SVG}</button>
        </div>
        <div class="ta-row" id="ta_av_list"></div>

        <div class="ta-section-head" style="margin-top:12px;">
          <span id="ta_un_count">Unavailable in (0):</span>
          <button class="ta-copybtn" id="ta_copy_un" title="Copy">${COPY_SVG}</button>
        </div>
        <div class="ta-row" id="ta_un_list"></div>

        <div class="ta-section-head" id="ta_uk_head" style="margin-top:12px; display:none;">
          <span id="ta_uk_count">Unknown in (0):</span>
          <button class="ta-copybtn" id="ta_copy_uk" title="Copy">${COPY_SVG}</button>
        </div>
        <div class="ta-row" id="ta_uk_list"></div>
      </div>
    `;

    out.querySelector("#ta_copy_av").onclick = () => GM_setClipboard(STATE.availableLive.map(x => x.cc).join(", "));
    out.querySelector("#ta_copy_un").onclick = () => GM_setClipboard(STATE.unavailableLive.map(x => x.cc).join(", "));
    out.querySelector("#ta_copy_uk").onclick = () => GM_setClipboard(STATE.unknownLive.map(x => x.cc).join(", "));
  }

  function renderFinal(kind) {
    const out = box.querySelector("#ta_out");

    const available = STATE.availableLive.slice().sort((a, b) => a.cc.localeCompare(b.cc));
    const unavailable = STATE.unavailableLive.slice().sort((a, b) => a.cc.localeCompare(b.cc));
    const unknown = STATE.unknownLive.slice().sort((a, b) => a.cc.localeCompare(b.cc));

    const mkRow = (title, arr, btnId, cls = "") => `
      <div class="ta-section-head">
        <span>${title} (${arr.length}):</span>
        <button class="ta-copybtn" id="${btnId}" title="Copy">${COPY_SVG}</button>
      </div>
      <div class="ta-row">${arr.map(x => {
        const svg = flagSvgUrl(x.cc);
        const img = svg ? `<img class="ta-flag" data-flag="1" alt="" src="${svg}">` : "";
        const pref = PREF.has(x.cc) ? " ta-pref" : "";
        return `<span class="ta-chip ${cls}${pref}" data-code="${x.cc}" data-name="${x.name}" title="${x.name}">${img}<span>${x.cc}</span></span>`;
      }).join("")}</div>
    `;

    out.innerHTML = `
      <div class="ta-block">
        <div class="ta-h">${kind} Availability</div>
        ${mkRow("Available in", available, "ta_copy_av2")}
        ${mkRow("Unavailable in", unavailable, "ta_copy_un2", "ta-unav")}
        ${unknown.length ? mkRow("Unknown in", unknown, "ta_copy_uk2", "ta-unav") : ""}
      </div>
    `;

    out.querySelectorAll('img[data-flag="1"]').forEach(img => {
      img.addEventListener("error", () => img.remove(), { once: true });
      setTimeout(() => { if (!img.complete || img.naturalWidth === 0) img.remove(); }, 2500);
    });

    out.querySelector("#ta_copy_av2").onclick = () => GM_setClipboard(available.map(x => x.cc).join(", "));
    out.querySelector("#ta_copy_un2").onclick = () => GM_setClipboard(unavailable.map(x => x.cc).join(", "));
    if (unknown.length) out.querySelector("#ta_copy_uk2").onclick = () => GM_setClipboard(unknown.map(x => x.cc).join(", "));

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

  async function run(isAuto = false) {
    const res = parseResource();
    if (!res) {
      if (isAuto && autoTries < AUTO_MAX_TRIES) scheduleAutoRun(AUTO_DELAY_MS, true);
      return;
    }

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

    const kind = res.type === "album" ? "Album" : "Track";

    STATE.availableLive = [];
    STATE.unavailableLive = [];
    STATE.unknownLive = [];

    setProgress("Loading region list…");
    showBar(1);
    setBar(0, 1);

    let markets;
    try {
      markets = await getMarketsAndNames();
    } catch (e) {
      setProgress(e?.message ? `Failed: ${e.message}` : "Failed to load regions.");
      hideBar();
      return;
    }

    STATE.codes = markets.codes;
    STATE.names = markets.names;

    renderPrefChooser();
    renderLiveShell(kind);

    const total = STATE.codes.length;
    const avList = box.querySelector("#ta_av_list");
    const unList = box.querySelector("#ta_un_list");
    const ukList = box.querySelector("#ta_uk_list");
    const avCount = box.querySelector("#ta_av_count");
    const unCount = box.querySelector("#ta_un_count");
    const ukHead = box.querySelector("#ta_uk_head");
    const ukCount = box.querySelector("#ta_uk_count");

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

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

    const queue = STATE.codes.slice();
    let lastUi = 0;

    const worker = async () => {
      while (queue.length && !cancelRequested) {
        const cc = queue.shift();
        if (!cc) return;

        let ok = null;
        try {
          ok = await checkOne(res, cc);
        } catch (e) {
          setProgress(e?.message || String(e));
          cancelRequested = true;
          return;
        }

        if (cancelRequested) return;

        const name = STATE.names.get(cc) || fallbackName(cc);

        if (ok === true) {
          STATE.availableLive.push({ cc, name });
          avList.appendChild(makeChip(cc, name, ""));
          avCount.textContent = `Available in (${STATE.availableLive.length}):`;
        } else if (ok === false) {
          STATE.unavailableLive.push({ cc, name });
          unList.appendChild(makeChip(cc, name, "ta-unav"));
          unCount.textContent = `Unavailable in (${STATE.unavailableLive.length}):`;
        } else {
          STATE.unknownLive.push({ cc, name });
          ukHead.style.display = "";
          ukList.appendChild(makeChip(cc, name, "ta-unav"));
          ukCount.textContent = `Unknown in (${STATE.unknownLive.length}):`;
        }

        done++;
        setBar(done, total);

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

        await sleep(25);
      }
    };

    await Promise.allSettled(Array.from({ length: CONCURRENCY }, worker));

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

    renderFinal(kind);
    setProgress("");
    hideBar();
    mountBox(box);
  }

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

  function wire() {
    ensureBox();
    mountBox(box);
    scheduleAutoRun(AUTO_DELAY_MS, false);
  }

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

    clearTimeout(autoRunTimer);
    cancelRequested = true;

    // keep UI (don’t nuke), just re-mount + re-run
    setTimeout(() => {
      cancelRequested = false;
      ensureBox();
      mountBox(box);
      scheduleAutoRun(AUTO_DELAY_MS, false);
    }, 200);
  }

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

    // SPA keep-alive: re-mount if TIDAL moves/rebuilds DOM
    const mo = new MutationObserver(() => {
      if (!document.body) return;
      if (!box || !document.contains(box)) {
        box = null;
        if (parseResource()) {
          ensureBox();
          mountBox(box);
        }
      } else {
        // keep it pinned near “Related Albums/Artists” etc when NOT floating
        mountBox(box);
      }
    });
    mo.observe(document.documentElement, { childList: true, subtree: true });
  }

  async function boot() {
    for (let i = 0; i < 120; i++) { if (document.body) break; await sleep(50); }
    wire();
    installNavHooks();
  }

  boot();
})();