FLAM IMDB Plugin

Send IMDB lists

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey, Greasemonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да инсталирате разширение, като например Tampermonkey .

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Userscripts.

За да инсталирате скрипта, трябва да инсталирате разширение като Tampermonkey.

За да инсталирате този скрипт, трябва да имате инсталиран скриптов мениджър.

(Вече имам скриптов мениджър, искам да го инсталирам!)

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

(Вече имам инсталиран мениджър на стиловете, искам да го инсталирам!)

// ==UserScript==
// @name         FLAM IMDB Plugin
// @namespace    http://tampermonkey.net/
// @version      1.0.0
// @description  Send IMDB lists
// @match        https://*.imdb.com/list/ls*
// @match        https://*.imdb.com/list/ls*/*
// @match        https://*.imdb.com/user/*/watchlist*
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @run-at       document-idle
// @connect      *
// @connect      api.themoviedb.org
// @connect      api.github.com
// @connect      raw.githubusercontent.com
// ==/UserScript==

;(async function () {
  'use strict';

  // ────────────────────────────────────────────────────────────────────────
  // Config
  // ────────────────────────────────────────────────────────────────────────
  const TMDB_API_KEY = 'f090bb54758cabf231fb605d3e3e0468';

  // Base cache (gzipped u32 pairs: imdbNumeric → packed)
  const BASE_GZ_URL = 'https://raw.githubusercontent.com/hcgiub001/letterboxd-tmdb-cache/main/imdb_tmdb_pairs_u32.bin.gz';

  // Resolver & fetch limits
  const CONCURRENCY_TMDB_FIND = 50;
  const CONCURRENCY_PAGE_FETCH = 3;   // keep the 3-thread pool for pagination
  const MAX_LIST_ITEMS = 5000;
  const URL_LIMIT_BYTES = 65536;
  const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
  const NEGATIVE_TTL_MS = THIRTY_DAYS_MS; // 30 days

  // IndexedDB stores/keys
  const CACHE_DB_NAME = 'FenlightCacheDB';
  const CACHE_DB_VERSION = 2;
  const OVERRIDES_STORE = 'tmdbCache';
  const BASE_STORE = 'tmdbBase';

  // Overrides & negative caches
  const OVERRIDES_KEY = 'imdb_tmdb_cache_overrides';          // { 'tt…': packed }
  const OVERRIDES_BACKUP_KEY = 'imdb_tmdb_cache_backup';
  const NEGATIVE_KEY = 'imdb_tmdb_cache_negative';            // { 'tt…': lastFailedTs }
  const NEGATIVE_BACKUP_KEY = 'imdb_tmdb_cache_negative_bak';

  // Base cache metadata keys
  const BASE_BIN_KEY  = 'imdb_base_bin';     // ArrayBuffer: unzipped .bin (u32 pairs)
  const BASE_ETAG_KEY = 'imdb_base_gz_etag'; // last ETag of gz
  const BASE_COUNT_KEY = 'imdb_base_count';  // pair count (info only)

  // URL/UX defaults
  const MEDIA_TYPE_DEFAULT_FIXED = 'm';
  const DEFAULT_INDICATOR = 'progress';

  // Artwork defaults (match Letterboxd)
  const POSTER_ENABLE_DEFAULT = true;
  const POSTER_STRATEGY_DEFAULT = 'first_4';
  const FANART_ENABLE_DEFAULT = true;
  const FANART_STRATEGY_DEFAULT = 'first_4';
  const FANART_FALLBACK_DEFAULT = 'first_4';

  // Info button + last stats keys
  const INFO_BTN_ENABLE_KEY = 'showInfoButton';
  const LAST_STATS_KEY = 'lastSendStats';

  // Description defaults
  const DEFAULT_DESCRIPTION = '';

  // ────────────────────────────────────────────────────────────────────────
  // Tiny utils & text helpers (match LB)
  // ────────────────────────────────────────────────────────────────────────
  const sleep = (ms) => new Promise(r => setTimeout(r, ms));
  const utf8ByteLen = (s) => new TextEncoder().encode(s).length;
  const gmGetOrSet = (k, d) => (typeof GM_getValue(k) === 'undefined' ? d : GM_getValue(k));

  // Basic page-type detection
  function isWatchlistPath() {
    // /user/{id or name}/watchlist/ (with or without query)
    return /^\/user\/[^/]+\/watchlist\/?$/i.test(location.pathname) || /^\/user\/[^/]+\/watchlist\/\S*/i.test(location.pathname);
  }

  function htmlToTextWithBreaks(html) {
    if (!html) return '';
    let t = html;
    t = t.replace(/<br\s*\/?>/gi, '\n');
    t = t.replace(/<\/p>\s*<p[^>]*>/gi, '\n\n');
    t = t.replace(/<\/?p[^>]*>/gi, '');
    t = t.replace(/<li[^>]*>\s*/gi, '• ');
    t = t.replace(/<\/li>\s*/gi, '\n');
    t = t.replace(/<\/?ul[^>]*>/gi, '');
    t = t.replace(/<\/?ol[^>]*>/gi, '');
    t = t.replace(/<[^>]+>/g, '');
    const ta = document.createElement('textarea');
    ta.innerHTML = t.replace(/&nbsp;/gi, '\u00A0');
    t = ta.value;
    t = t.replace(/\u00A0/g, ' ');
    t = t.replace(/\r\n/g, '\n').replace(/^\s+/, '').replace(/\s+$/, '');
    return t;
  }
  function sanitizeDescPreservingBreaks(s) {
    if (!s) return '';
    return s.replace(/\u00A0/g, ' ')
            .replace(/\r\n/g, '\n')
            .replace(/\t/g, '  ')
            .trim();
  }

  // ────────────────────────────────────────────────────────────────────────
  // IMDb scraping (pagination + metadata) — LISTS + WATCHLISTS
  // ────────────────────────────────────────────────────────────────────────

  // Shared helpers
  function parseNextDataDoc(doc = document) {
    const el = doc.getElementById('__NEXT_DATA__');
    if (!el || !el.textContent) return null;
    try { return JSON.parse(el.textContent); } catch { return null; }
  }
  function parseNextDataFromHTML(html) {
    // fast/robust: find the script tag content
    const m = html.match(/<script[^>]*id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/i);
    if (!m) return null;
    try { return JSON.parse(m[1]); } catch { return null; }
  }

  // ----- METADATA (name/author/desc) -----
  function getListName() {
    // LISTS: as before
    let el = document.querySelector('span.hero__primary-text[data-testid="hero__primary-text"]');
    if (el && el.textContent) return el.textContent.trim();

    // WATCHLISTS: try generic h1 / header
    const h1 = document.querySelector('h1, [data-testid="list-page-atf-title-block"] h1, [data-testid="list-page-atf-title-block"] [data-testid="hero__primary-text"]');
    if (h1 && h1.textContent) return h1.textContent.trim();

    // Fallback: __NEXT_DATA__
    const nd = parseNextDataDoc();
    const nameFromND =
      nd?.props?.pageProps?.pageTitle ||
      nd?.props?.pageProps?.listTitle ||
      nd?.props?.pageProps?.seo?.metadata?.title ||
      nd?.props?.pageProps?.seo?.meta?.title ||
      '';
    if (nameFromND) return String(nameFromND).replace(/\s*-\s*IMDb\s*$/i, '').trim();

    // Final fallback: document.title
    return (document.title || 'IMDb List').replace(/\s*-\s*IMDb\s*$/i, '').trim();
  }

  function getAuthor() {
    // LISTS: as before
    const a = document.querySelector('[data-testid="list-author-link"]');
    if (a && a.textContent) return a.textContent.trim();

    // WATCHLISTS: attempt to locate an author/owner link
    // Common patterns: link to /user/{id}/ …
    const owner =
      document.querySelector('a[href^="/user/"][data-testid*="author"], a[href^="/user/"][data-testid*="owner"]') ||
      document.querySelector('[data-testid="list-owner"] a[href^="/user/"]') ||
      document.querySelector('.ipc-title__subtitle a[href^="/user/"]') ||
      document.querySelector('a[href^="/user/"]:not([href*="watchlist"])');
    if (owner && owner.textContent) return owner.textContent.trim();

    // Fallback: __NEXT_DATA__ (if any)
    const nd = parseNextDataDoc();
    const ownerName =
      nd?.props?.pageProps?.owner?.name ||
      nd?.props?.pageProps?.user?.name ||
      nd?.props?.pageProps?.userName ||
      '';
    if (ownerName) return String(ownerName).trim();

    return '';
  }

  function getDescriptionRawHTML() {
    // LISTS: current selector (keep)
    const el = document.querySelector('.list-description-content .ipc-html-content-inner-div');
    if (el && el.innerHTML) return el.innerHTML;

    // WATCHLISTS: often no description; try generic content area
    const w = document.querySelector('[data-testid="list-page-description"], [data-testid="list-page-atf-title-block"] .ipc-html-content-inner-div');
    if (w && w.innerHTML) return w.innerHTML;

    return '';
  }
  function getDescriptionText() {
    return htmlToTextWithBreaks(getDescriptionRawHTML() || '');
  }

  // ----- LIST PAGES (unchanged logic) -----
  function extractItemList(html) {
    // robustly parse the item list from <script type="application/ld+json">
    const re = /<script\s+type="application\/ld\+json">([\s\S]*?)<\/script>/g;
    let m;
    while ((m = re.exec(html))) {
      try {
        const j = JSON.parse(m[1]);
        if (j['@type'] === 'ItemList') return j.itemListElement;
      } catch {}
    }
    return [];
  }

  async function gatherItemsFromListPages() {
    const origin = location.origin;
    const basePath = location.pathname.replace(/\?.*$/,'');
    const sel = document.getElementById('listPagination');
    const total = sel ? sel.options.length : 1;

    // page 1
    const scripts = Array.from(document.querySelectorAll('script[type="application/ld+json"]'));
    let page1 = null;
    for (const s of scripts) {
      try {
        const j = JSON.parse(s.textContent);
        if (j['@type'] === 'ItemList') { page1 = j.itemListElement; break; }
      } catch {}
    }
    if (!page1) { alert('⚠️ Failed to parse page 1'); return null; }

    // rest
    const urls = [];
    for (let p = 2; p <= total; p++) urls.push(`${origin+basePath}?page=${p}`);

    const rest = await (async function limitedFetch(urls, limit = CONCURRENCY_PAGE_FETCH) {
      const out = new Array(urls.length); let next = 0, active = 0;
      return new Promise((resolve) => {
        const launch = () => {
          while (active < limit && next < urls.length) {
            const i = next++, u = urls[i]; active++;
            fetch(u, { credentials: 'include' })
              .then(r => r.ok ? r.text() : Promise.reject(new Error('HTTP '+r.status)))
              .then(html => { out[i] = extractItemList(html); })
              .catch(() => { out[i] = []; })
              .finally(() => { active--; (next < urls.length) ? launch() : (active === 0 && resolve(out)); });
          }
        };
        urls.length ? launch() : resolve(out);
      });
    })(urls, CONCURRENCY_PAGE_FETCH);

    const all = page1.concat(...rest);

    return all
      .map(e => {
        const idMatch = (e.item && e.item.url) ? e.item.url.match(/tt\d+/) : null;
        const t = (e.item && e.item['@type']) ? String(e.item['@type']) : '';
        const mtHint = (t === 'Movie') ? 'm' : (/^TV/i.test(t) ? 'tv' : 'm');
        return idMatch ? { imdbId: idMatch[0], mtHint, listIndex: e.position || 0 } : null;
      })
      .filter(Boolean);
  }

  // ----- WATCHLIST PAGES (new) -----
  function extractWatchlistEdgesFromND(nd) {
    const mcd = nd?.props?.pageProps?.mainColumnData;
    const container = mcd?.predefinedList || mcd?.list || mcd?.titleList || null;
    const edges = container?.titleListItemSearch?.edges;
    return Array.isArray(edges) ? edges : [];
  }
  function edgesToItems(edges) {
    const items = [];
    for (const e of edges) {
      const imdbId = e?.listItem?.id; // "tt…"
      if (!imdbId) continue;
      const pos = e?.node?.absolutePosition || e?.node?.rank || 0;
      const tt = (e?.listItem?.titleType?.id || e?.listItem?.titleType?.text || '').toString().toLowerCase();
      const mtHint = tt.startsWith('tv') ? 'tv' : 'm';
      items.push({ imdbId, mtHint, listIndex: pos });
    }
    return items;
  }
  function getWatchlistPageCountFromDoc(doc) {
    // Try dropdown first
    const sel = doc.querySelector('#listPagination');
    if (sel?.options?.length) return sel.options.length;

    // Try __NEXT_DATA__ totalItems
    const nd = parseNextDataDoc(doc);
    const totalItems = nd?.props?.pageProps?.totalItems;
    if (Number.isFinite(totalItems)) return Math.max(1, Math.ceil(totalItems / 250));

    // Fallback: 1
    return 1;
  }

  async function gatherItemsFromWatchlist() {
    const base = new URL(location.href);
    base.searchParams.set('page', '1');

    // Use current DOM for page 1
    const firstND = parseNextDataDoc(document);
    if (!firstND) { alert('⚠️ Could not read watchlist data on page 1'); return null; }
    const maxPages = getWatchlistPageCountFromDoc(document);

    const resultsByPage = new Map();
    resultsByPage.set(1, edgesToItems(extractWatchlistEdgesFromND(firstND)));

    // Build remaining page URLs
    const urls = [];
    for (let p = 2; p <= maxPages; p++) {
      const u = new URL(base.toString());
      u.searchParams.set('page', String(p));
      urls.push({ p, url: u.toString() });
    }

    // Pool=3 fetch of pages 2..N, parse __NEXT_DATA__, convert to items
    await (async function poolFetchWatchlist(pairs, limit = CONCURRENCY_PAGE_FETCH) {
      let i = 0, active = 0;
      return new Promise((resolve) => {
        const launch = () => {
          while (active < limit && i < pairs.length) {
            const idx = i++; active++;
            const { p, url } = pairs[idx];
            fetch(url, { credentials: 'include' })
              .then(r => r.ok ? r.text() : Promise.reject(new Error('HTTP '+r.status)))
              .then(html => {
                const nd = parseNextDataFromHTML(html);
                const edges = extractWatchlistEdgesFromND(nd || {});
                resultsByPage.set(p, edgesToItems(edges));
              })
              .catch(() => { resultsByPage.set(p, []); })
              .finally(() => {
                active--;
                (i < pairs.length) ? launch() : (active === 0 && resolve());
              });
          }
        };
        pairs.length ? launch() : resolve();
      });
    })(urls, CONCURRENCY_PAGE_FETCH);

    // Combine in order
    const all = [];
    for (let p = 1; p <= maxPages; p++) {
      const arr = resultsByPage.get(p);
      if (Array.isArray(arr)) all.push(...arr);
    }
    return all;
  }

  // Unified entry (keeps all prior behavior, just adds watchlists)
  async function gatherItems() {
    if (isWatchlistPath()) {
      const items = await gatherItemsFromWatchlist();
      return items || [];
    }
    // default: list pages
    const items = await gatherItemsFromListPages();
    return items || [];
  }

  // Try to provide an "author_fanart" equivalent on IMDb: use og:image if present
  function getAuthorFanartUrl_IMDb() {
    const og = document.querySelector('meta[property="og:image"]')?.getAttribute('content') || '';
    return og || '';
  }

  // ────────────────────────────────────────────────────────────────────────
  // TMDB resolver (/find/{imdb_id}) with 50-concurrency (kept)
  // ────────────────────────────────────────────────────────────────────────
  async function tmdbFindByImdb(ttid) {
    const url = `https://api.themoviedb.org/3/find/${ttid}?` + new URLSearchParams({
      api_key: TMDB_API_KEY,
      external_source: 'imdb_id'
    });
    for (let i = 0; i < 3; i++) {
      try {
        const r = await fetch(url);
        if (r.ok) {
          const j = await r.json();
          const mov = Array.isArray(j.movie_results) ? j.movie_results : [];
          const tv  = Array.isArray(j.tv_results) ? j.tv_results : [];
          const ep  = Array.isArray(j.tv_episode_results) ? j.tv_episode_results : [];
          if (mov.length) return { tmdbId: mov[0].id, isTv: false };
          if (tv.length)  return { tmdbId: tv[0].id,  isTv: true  };
          if (ep.length) {
            const showId = ep[0].show_id || ep[0].id || null;
            if (showId) return { tmdbId: showId, isTv: true };
          }
          return null;
        }
        if (r.status === 429) await sleep(250 * (i + 1));
        else return null;
      } catch {
        await sleep(200 * (i + 1));
      }
    }
    return null;
  }

  // ────────────────────────────────────────────────────────────────────────
  // IndexedDB helpers + base cache load/refresh (kept & aligned)
  // ────────────────────────────────────────────────────────────────────────
  function openDB() {
    return new Promise((resolve, reject) => {
      const rq = indexedDB.open(CACHE_DB_NAME, CACHE_DB_VERSION);
      rq.onupgradeneeded = () => {
        const db = rq.result;
        if (!db.objectStoreNames.contains(OVERRIDES_STORE)) db.createObjectStore(OVERRIDES_STORE);
        if (!db.objectStoreNames.contains(BASE_STORE)) db.createObjectStore(BASE_STORE);
      };
      rq.onsuccess = () => resolve(rq.result);
      rq.onerror = () => reject(rq.error);
    });
  }
  async function idbGet(store, key) {
    const db = await openDB();
    return new Promise((res, rej) => {
      const tx = db.transaction(store, 'readonly');
      const rq = tx.objectStore(store).get(key);
      rq.onsuccess = () => res(rq.result);
      rq.onerror = () => rej(rq.error);
    });
  }
  async function idbPut(store, key, val) {
    const db = await openDB();
    return new Promise((res, rej) => {
      const tx = db.transaction(store, 'readwrite');
      const rq = tx.objectStore(store).put(val, key);
      rq.onsuccess = () => res();
      rq.onerror = () => rej(rq.error);
    });
  }
  async function idbDel(store, key) {
    const db = await openDB();
    return new Promise((res, rej) => {
      const tx = db.transaction(store, 'readwrite');
      const rq = tx.objectStore(store).delete(key);
      rq.onsuccess = () => res();
      rq.onerror = () => rej(rq.error);
    });
  }

  let BASE_PAIRS_U32 = null;   // Uint32Array (k0, v0, k1, v1, ...)
  let BASE_PAIR_COUNT = 0;

  function isLittleEndian() {
    const b = new ArrayBuffer(4);
    new DataView(b).setUint32(0, 0x11223344, true);
    return new Uint8Array(b)[0] === 0x44;
  }
  const HOST_LE = isLittleEndian();

  function parseBaseBinToU32(buf) {
    if (!buf || !buf.byteLength) return false;
    if (buf.byteLength % 8 !== 0) return false;
    if (HOST_LE) {
      BASE_PAIRS_U32 = new Uint32Array(buf);
    } else {
      const view = new DataView(buf);
      const out = new Uint32Array(buf.byteLength / 4);
      for (let i = 0; i < out.length; i++) out[i] = view.getUint32(i * 4, true);
      BASE_PAIRS_U32 = out;
    }
    if (BASE_PAIRS_U32.length % 2 !== 0) return false;
    BASE_PAIR_COUNT = BASE_PAIRS_U32.length >>> 1;
    return true;
  }

  function baseLookupPackedByNumericImdb(imdbNumeric) {
    if (!BASE_PAIRS_U32) return undefined;
    let lo = 0, hi = BASE_PAIR_COUNT - 1;
    while (lo <= hi) {
      const mid = (lo + hi) >>> 1;
      const k = BASE_PAIRS_U32[mid << 1];
      if (k === imdbNumeric) return BASE_PAIRS_U32[(mid << 1) + 1];
      if (k < imdbNumeric) lo = mid + 1; else hi = mid - 1;
    }
    return undefined;
  }

  function unpackPacked(packed) {
    return { isTv: !!(packed & 1), tmdbId: packed >>> 1 };
  }

  function gmFetchArrayBuffer(url, headers = {}) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: 'GET',
        url,
        headers,
        responseType: 'arraybuffer',
        onload: (res) => {
          if (res.status >= 200 && res.status < 300) resolve({ buf: res.response, headers: res.responseHeaders || '', status: res.status });
          else if (res.status === 304) resolve({ buf: null, headers: res.responseHeaders || '', status: 304 });
          else reject(new Error(`HTTP ${res.status}`));
        },
        onerror: (e) => reject(e.error || e),
        ontimeout: () => reject(new Error('Timeout')),
      });
    });
  }

  async function unzipGzipToArrayBuffer(gzBuf) {
    if (typeof DecompressionStream !== 'function') {
      throw new Error('DecompressionStream(gzip) not supported by this browser.');
    }
    const ds = new DecompressionStream('gzip');
    return await new Response(new Blob([gzBuf]).stream().pipeThrough(ds)).arrayBuffer();
  }

  async function loadBaseFromIDB() {
    const buf = await idbGet(BASE_STORE, BASE_BIN_KEY);
    const count = await idbGet(BASE_STORE, BASE_COUNT_KEY);
    if (!buf || !buf.byteLength) return false;
    if (!parseBaseBinToU32(buf)) return false;
    if (typeof count === 'number') BASE_PAIR_COUNT = count;
    return true;
  }

  async function saveBaseToIDB(buf, etag) {
    await idbPut(BASE_STORE, BASE_BIN_KEY, buf);
    await idbPut(BASE_STORE, BASE_ETAG_KEY, etag || '');
    await idbPut(BASE_STORE, BASE_COUNT_KEY, BASE_PAIR_COUNT);
  }

  async function refreshBaseCacheNow(showAlerts = true) {
    try {
      const prevEtag = (await idbGet(BASE_STORE, BASE_ETAG_KEY)) || '';
      const headers = prevEtag ? { 'If-None-Match': prevEtag } : {};
      const { buf: gzBuf, headers: respHeaders, status } = await gmFetchArrayBuffer(BASE_GZ_URL, headers);
      if (status === 304) {
        GM_setValue('baseLastRevalidateTs', Date.now());
        if (showAlerts) alert('✅ Base cache already up to date.');
        return true;
      }

      const m = String(respHeaders || '').match(/etag:\s*([^\r\n]+)/i);
      const etag = m ? m[1].trim() : '';

      const bin = await unzipGzipToArrayBuffer(gzBuf);
      if (!parseBaseBinToU32(bin)) throw new Error('Invalid base cache (not u32 pairs).');

      await saveBaseToIDB(bin, etag);
      GM_setValue('baseLastRevalidateTs', Date.now());
      if (showAlerts) alert(`✅ Base cache refreshed (${BASE_PAIR_COUNT.toLocaleString()} pairs).`);
      return true;
    } catch (e) {
      console.error('[Base cache] refresh failed:', e);
      if (showAlerts) alert('❌ Base cache refresh failed: ' + e.message);
      return false;
    }
  }

  async function maybeRevalidateBaseMonthly() {
    try {
      const last = gmGetOrSet('baseLastRevalidateTs', 0);
      const now = Date.now();
      if (now - last >= THIRTY_DAYS_MS) {
        await refreshBaseCacheNow(false);
        GM_setValue('baseLastRevalidateTs', Date.now());
      }
    } catch {}
  }

  async function ensureBaseLoadedOnce() {
    if (await loadBaseFromIDB()) return true;
    await refreshBaseCacheNow(false); // first-time fetch
    return await loadBaseFromIDB();
  }

  // ────────────────────────────────────────────────────────────────────────
  // Overrides + Negative caches (kept)
  // ────────────────────────────────────────────────────────────────────────
  let LOCAL_OVERRIDES = null;
  let DIRTY_OVERRIDES = false;

  let LOCAL_NEGATIVE = null;
  let DIRTY_NEGATIVE = false;

  async function overridesLoadOnce() {
    if (!LOCAL_OVERRIDES) LOCAL_OVERRIDES = (await idbGet(OVERRIDES_STORE, OVERRIDES_KEY)) || {};
  }
  async function overridesPersist() {
    if (!DIRTY_OVERRIDES) return;
    await idbPut(OVERRIDES_STORE, OVERRIDES_KEY, LOCAL_OVERRIDES);
    try { await idbPut(OVERRIDES_STORE, OVERRIDES_BACKUP_KEY, JSON.parse(JSON.stringify(LOCAL_OVERRIDES))); } catch {}
    DIRTY_OVERRIDES = false;
  }
  async function overridesClear() {
    await idbDel(OVERRIDES_STORE, OVERRIDES_KEY);
    await idbDel(OVERRIDES_STORE, OVERRIDES_BACKUP_KEY);
    LOCAL_OVERRIDES = {};
    DIRTY_OVERRIDES = false;
  }

  async function negativeLoadOnce() {
    if (!LOCAL_NEGATIVE) LOCAL_NEGATIVE = (await idbGet(OVERRIDES_STORE, NEGATIVE_KEY)) || {};
  }
  function negativeGetTs(ttid) { return LOCAL_NEGATIVE ? LOCAL_NEGATIVE[ttid] : undefined; }
  function negativeIsFresh(ttid, now = Date.now()) {
    const ts = negativeGetTs(ttid);
    if (!Number.isFinite(ts)) return false;
    return (now - ts) < NEGATIVE_TTL_MS;
  }
  function negativeSetNow(ttid, now = Date.now()) {
    if (!LOCAL_NEGATIVE) LOCAL_NEGATIVE = {};
    LOCAL_NEGATIVE[ttid] = now;
    DIRTY_NEGATIVE = true;
  }
  async function negativePersist() {
    if (!DIRTY_NEGATIVE) return;
    await idbPut(OVERRIDES_STORE, NEGATIVE_KEY, LOCAL_NEGATIVE);
    try { await idbPut(OVERRIDES_STORE, NEGATIVE_BACKUP_KEY, JSON.parse(JSON.stringify(LOCAL_NEGATIVE))); } catch {}
    DIRTY_NEGATIVE = false;
  }
  async function negativeClear() {
    await idbDel(OVERRIDES_STORE, NEGATIVE_KEY);
    await idbDel(OVERRIDES_STORE, NEGATIVE_BACKUP_KEY);
    LOCAL_NEGATIVE = {};
    DIRTY_NEGATIVE = false;
  }

  function overrideGetPacked(ttid) {
    const v = LOCAL_OVERRIDES ? LOCAL_OVERRIDES[ttid] : undefined;
    return Number.isFinite(v) ? v : undefined;
  }
  function overrideSetPacked(ttid, tmdbId, isTv) {
    if (!LOCAL_OVERRIDES) LOCAL_OVERRIDES = {};
    const packed = (tmdbId << 1) | (isTv ? 1 : 0);
    if (LOCAL_OVERRIDES[ttid] !== packed) {
      LOCAL_OVERRIDES[ttid] = packed;
      DIRTY_OVERRIDES = true;
    }
  }
  function getFromAnyCachePacked(ttid) {
    // 1) overrides by 'tt…'
    const ov = overrideGetPacked(ttid);
    if (ov !== undefined) return ov;

    // 2) base by numeric key
    const m = ttid.match(/^tt(\d+)$/);
    if (m) {
      const num = Number(m[1]);
      if (Number.isFinite(num)) {
        const b = baseLookupPackedByNumericImdb(num);
        if (b !== undefined) return b;
      }
    }
    return undefined;
  }

  // ────────────────────────────────────────────────────────────────────────
  // Gzip+Base64 builder (B-style)
  // ────────────────────────────────────────────────────────────────────────
  async function gzipBase64(str) {
    try {
      if (typeof CompressionStream !== 'function') return null;
      const enc = new TextEncoder().encode(str);
      const cs = new CompressionStream('gzip');
      const gzStream = new Blob([enc]).stream().pipeThrough(cs);
      const ab = await new Response(gzStream).arrayBuffer();
      const u8 = new Uint8Array(ab);
      let bin = '';
      const chunk = 0x8000;
      for (let i = 0; i < u8.length; i += chunk) {
        const sub = u8.subarray(i, i + chunk);
        bin += String.fromCharCode.apply(null, sub);
      }
      return btoa(bin);
    } catch {
      return null;
    }
  }

  // ────────────────────────────────────────────────────────────────────────
  // Build Fenlight URL — Artwork omitted when action === 'view'
  // ────────────────────────────────────────────────────────────────────────
  async function buildFenUrlTMDB_Art(resolvedItems, opts) {
    const {
      listName, author, description, action = '',
      posterEnabled, posterStrategy,
      fanartEnabled, fanartStrategy, fanartFallback
    } = opts;

    const listItems = resolvedItems.map(it => it.isTv ? ({ id: it.tmdbId, mt: 'tv' }) : ({ id: it.tmdbId }));

    const base = 'plugin://plugin.video.fenlight/?';
    const common = [
      ['mode', 'personal_lists.external'],
      ...(action ? [['action', action]] : []),
      ['list_type', 'tmdb'],
      ['list_name', listName || 'IMDb List'],
      ['author', author || ''],
      ['media_type_default', MEDIA_TYPE_DEFAULT_FIXED],
      ['busy_indicator', gmGetOrSet('indicator', DEFAULT_INDICATOR)],
      ...(description ? [['description', description]] : []),
    ];

    // IMPORTANT CHANGE: If action is 'view', do NOT include poster/fanart keys
    const artworkAllowed = (String(action).toLowerCase() !== 'view');

    // Artwork (match LB exactly, but only if allowed)
    if (artworkAllowed && posterEnabled) {
      common.push(['poster', posterStrategy]); // 'first_4' or 'random'
    }
    if (artworkAllowed && fanartEnabled) {
      if (fanartStrategy === 'author_fanart') {
        let url = getAuthorFanartUrl_IMDb();
        if (url) {
          common.push(['fanart', url]);
        } else {
          if (fanartFallback === 'first_4' || fanartFallback === 'random') {
            common.push(['fanart', fanartFallback]);
          }
          // 'none' ⇒ omit fanart key
        }
      } else {
        // direct strategy: 'first_4' or 'random'
        common.push(['fanart', fanartStrategy]);
      }
    }

    const rawJson = JSON.stringify(listItems);
    const encode = (arr) => arr.map(([k,v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&');

    // Raw
    const rawParts = [...common, ['list_items', rawJson]];
    const rawUrl = base + encode(rawParts);
    const rawBytes = utf8ByteLen(rawUrl);

    // Gzip+Base64 (if supported)
    let gzB64 = await gzipBase64(rawJson);
    let gzUrl = null, gzBytes = null;
    if (gzB64) {
      const gzParts = [...common, ['base64_items', gzB64]];
      gzUrl = base + encode(gzParts);
      gzBytes = utf8ByteLen(gzUrl);
    }

    // Choose
    const url = gzUrl || rawUrl;
    const urlBytes = (gzBytes != null) ? gzBytes : rawBytes;

    return { url, urlBytes, rawUrl, rawBytes, gzUrl, gzBytes };
  }

  // ────────────────────────────────────────────────────────────────────────
  // Kodi JSON-RPC + trust preflight (kept)
  // ────────────────────────────────────────────────────────────────────────
  function preflightKodiPermission() {
    const ip   = GM_getValue('kodiIp','').trim();
    const port = GM_getValue('kodiPort','').trim();
    const user = GM_getValue('kodiUser','');
    const pass = gmGetOrSet('kodiPass','');

    if (!ip || !port) return; // nothing to preflight

    try {
      GM_xmlhttpRequest({
        method: 'POST',
        url:    `http://${ip}:${port}/jsonrpc`,
        headers: { 'Content-Type':'application/json', 'Authorization':'Basic ' + btoa(`${user}:${pass}`) },
        data: JSON.stringify({ jsonrpc:'2.0', id:0, method:'JSONRPC.Ping' }),
        timeout: 7000,
        onload: () => {}, onerror: () => {}, ontimeout: () => {}
      });
    } catch {}
  }

  function sendToKodi(url) {
    const ip   = GM_getValue('kodiIp','').trim();
    const port = GM_getValue('kodiPort','').trim();
    const user = GM_getValue('kodiUser','');
    const pass = gmGetOrSet('kodiPass','');
    return new Promise((resolve) => {
      if (!ip || !port) { alert('⚠️ Please configure Kodi IP & port in settings.'); resolve(false); return; }
      GM_xmlhttpRequest({
        method: 'POST',
        url:    `http://${ip}:${port}/jsonrpc`,
        headers: { 'Content-Type':'application/json', 'Authorization':'Basic ' + btoa(`${user}:${pass}`) },
        data: JSON.stringify({ jsonrpc:'2.0', id:1, method:'Player.Open', params:{ item:{ file:url } } }),
        timeout: 15000,
        onload: (res) => resolve(res.status >= 200 && res.status < 300),
        onerror: () => resolve(false),
        ontimeout: () => resolve(false),
      });
    });
  }

  // ────────────────────────────────────────────────────────────────────────
  // UI: bubbles, overlays, and ASK — EXACT same visual as Letterboxd
  // ────────────────────────────────────────────────────────────────────────
  function showSideInfoNearEl(el, lines, ms = 5000) {
    try { const prev = document.getElementById('kodi-send-info'); if (prev) prev.remove(); } catch {}
    const box = document.createElement('div');
    box.id = 'kodi-send-info';
    Object.assign(box.style, {
      position:'fixed', top:'0px', right:'130px',
      background:'#1b1b1b', color:'#eee', border:'1px solid #333',
      borderRadius:'6px', padding:'8px 10px', fontSize:'12px',
      lineHeight:'1.4', zIndex:2147483647, boxShadow:'0 2px 10px rgba(0,0,0,.35)',
      maxWidth:'360px', whiteSpace:'pre-wrap', pointerEvents:'none'
    });
    box.innerText = lines.join('\n');
    document.body.append(box);
    let topPx = 150;
    try {
      const rect = el.getBoundingClientRect();
      const boxH = box.offsetHeight;
      topPx = Math.max(10, Math.min(window.innerHeight - boxH - 10, Math.round(rect.top + 40)));
    } catch {}
    box.style.top = `${topPx}px`;
    setTimeout(() => { try { box.remove(); } catch {} }, ms);
  }

  let PROCESS_BUBBLE_EL = null;
  function showOrUpdateProcessingBubble(targetEl, lines, persist = false) {
    const GAP = 10;
    if (!PROCESS_BUBBLE_EL) {
      PROCESS_BUBBLE_EL = document.createElement('div');
      PROCESS_BUBBLE_EL.id = 'kodi-processing-bubble';
      Object.assign(PROCESS_BUBBLE_EL.style, {
        position:'fixed', background:'#1b1b1b', color:'#eee',
        border:'1px solid #333', borderRadius:'8px', padding:'8px 10px',
        fontSize:'12px', lineHeight:'1.4', zIndex:2147483647,
        boxShadow:'0 2px 10px rgba(0,0,0,.35)', maxWidth:'360px', whiteSpace:'pre-wrap'
      });
      document.body.append(PROCESS_BUBBLE_EL);
    }
    PROCESS_BUBBLE_EL.textContent = lines.join('\n');

    const rect = targetEl.getBoundingClientRect();
    const topPx = Math.max(0, Math.round(rect.top));
    const rightPx = Math.max(0, Math.round(window.innerWidth - rect.left + GAP));
    PROCESS_BUBBLE_EL.style.top = `${topPx}px`;
    PROCESS_BUBBLE_EL.style.right = `${rightPx}px`;
    PROCESS_BUBBLE_EL.dataset.persist = persist ? '1' : '0';
    PROCESS_BUBBLE_EL.style.display = 'block';
  }
  function hideProcessingBubble(force = false) {
    if (PROCESS_BUBBLE_EL) {
      if (force || PROCESS_BUBBLE_EL.dataset.persist !== '1') {
        PROCESS_BUBBLE_EL.remove();
        PROCESS_BUBBLE_EL = null;
      }
    }
  }

  // Action chooser — FLAM-styled (MOBILE-SAFE) — same as LB
  function askForActionOverlay() {
    return new Promise((resolve) => {
      const overlay = document.createElement('div');
      overlay.id = 'flam-action-overlay';
      const SAFE_INSET = 16;
      Object.assign(overlay.style, {
        position: 'fixed',
        inset: 0,
        background: 'rgba(0,0,0,0.66)',
        display: 'grid',
        placeItems: 'center',
        zIndex: 2147483647,
        padding: `${SAFE_INSET}px`,
        boxSizing: 'border-box'
      });

      const panel = document.createElement('div');
      panel.className = 'flam-action-panel';
      Object.assign(panel.style, {
        position: 'relative',
        width: `min(760px, calc(100vw - ${SAFE_INSET * 2}px))`,
        maxWidth: `calc(100vw - ${SAFE_INSET * 2}px)`,
        maxHeight: `calc(100vh - ${SAFE_INSET * 2}px)`,
        overflow: 'auto',
        background: 'linear-gradient(180deg,#2a2a2a 0,#1f1f1f 100%)',
        color: '#e8e8e8',
        border: '1px solid rgba(255,255,255,.85)',
        borderRadius: '22px',
        boxShadow: '0 18px 44px rgba(0,0,0,.55)',
        padding: '28px 22px 20px',
        boxSizing: 'border-box'
      });

      const css = document.createElement('style');
      css.textContent = `
        #flam-action-overlay .flam-title {
          text-transform: uppercase;
          letter-spacing: .08em;
          text-align: center;
          font-weight: 700;
          color: #dcdcdc;
          font-size: 18px;
          margin: 0 0 12px;
        }
        #flam-action-overlay .flam-sub {
          text-align: center;
          color: #bdbdbd;
          font-size: 14px;
          margin-bottom: 18px;
        }
        #flam-action-overlay .flam-logo {
          position: absolute;
          top: 12px;
          left: 12px;
          width: 46px;
          height: 46px;
          border-radius: 12px;
          object-fit: contain;
          background: #111;
          border: 1px solid #2b2b2b;
          box-shadow: 0 2px 10px rgba(0,0,0,.35);
        }
        #flam-action-overlay .choices {
          display: grid;
          grid-template-columns: repeat(3, minmax(0,1fr));
          gap: 14px;
        }
        @media (max-width: 720px) {
          #flam-action-overlay .choices { grid-template-columns: 1fr; }
        }
        #flam-action-overlay .choice-btn {
          appearance: none;
          border: 1px solid rgba(255,255,255,.08);
          border-radius: 20px;
          padding: 18px 12px;
          background: #4b444b;
          color: #ffffff;
          font-size: 16px;
          font-weight: 700;
          box-shadow: inset 0 -2px 0 rgba(0,0,0,.12), 0 2px 8px rgba(0,0,0,.25);
          cursor: pointer;
          transition: background .12s ease, color .12s ease, transform .06s ease, outline-color .12s ease;
          outline: none;
          width: 100%;
        }
        #flam-action-overlay .choice-btn:focus-visible {
          background: #4b444b;
          color: #ffffff;
          outline: 2px solid rgba(255,255,255,.9);
          outline-offset: 2px;
        }
        #flam-action-overlay .choice-btn:hover,
        #flam-action-overlay .choice-btn.touching {
          background: #ffffff;
          color: #0b0b0b;
        }
        #flam-action-overlay .choice-btn:active { transform: translateY(1px); }
        #flam-action-overlay .footer { display:flex; justify-content:flex-end; margin-top:16px; }
        #flam-action-overlay .cancel-btn {
          background:#3a3a3a; color:#fff; border:none; border-radius:10px; padding:8px 12px; cursor:pointer;
        }
      `;
      panel.append(css);

      const logo = document.createElement('img');
      logo.className = 'flam-logo';
      try { if (typeof mainIcon !== 'undefined' && mainIcon && mainIcon.src) logo.src = mainIcon.src; } catch {}
      panel.append(logo);

      const title = document.createElement('div');
      title.className = 'flam-title';
      title.textContent = 'Send to FLAM';

      const sub = document.createElement('div');
      sub.className = 'flam-sub';
      sub.textContent = 'Choose what you want to do with this list';

      const choicesWrap = document.createElement('div');
      choicesWrap.className = 'choices';

      function mkBtn(label, value) {
        const b = document.createElement('button');
        b.type = 'button';
        b.className = 'choice-btn';
        b.textContent = label;
        b.addEventListener('pointerdown', () => b.classList.add('touching'), { passive: true });
        b.addEventListener('pointerup', () => b.classList.remove('touching'));
        b.addEventListener('pointercancel', () => b.classList.remove('touching'));
        b.addEventListener('click', () => cleanup(value));
        return b;
      }

      const btnView = mkBtn('View', 'view');
      const btnImport = mkBtn('Import', 'import');
      const btnBoth = mkBtn('Import + View', 'import_view');
      choicesWrap.append(btnView, btnImport, btnBoth);

      const footer = document.createElement('div');
      footer.className = 'footer';
      const cancel = document.createElement('button');
      cancel.className = 'cancel-btn';
      cancel.textContent = 'Cancel';
      cancel.addEventListener('click', () => cleanup(null));
      footer.append(cancel);

      panel.append(title, sub, choicesWrap, footer);
      overlay.append(panel);
      document.body.append(overlay);

      function onKey(e) { if (e.key === 'Escape') cleanup(null); }
      function onBackdrop(e) { if (e.target === overlay) cleanup(null); }
      overlay.addEventListener('click', onBackdrop);
      document.addEventListener('keydown', onKey);

      btnView.focus();

      function cleanup(val) {
        document.removeEventListener('keydown', onKey);
        try { overlay.remove(); } catch {}
        resolve(val);
      }
    });
  }

  function showTooManyItemsOverlay(totalCount) {
    const existing = document.getElementById('kodi-too-many-items'); if (existing) try { existing.remove(); } catch {}
    const overlay = document.createElement('div');
    overlay.id = 'kodi-too-many-items';
    Object.assign(overlay.style, {
      position:'fixed', inset:0, background:'rgba(0,0,0,0.7)',
      display:'flex', alignItems:'center', justifyContent:'center',
      zIndex:2147483647, padding:'20px', boxSizing:'border-box'
    });
    const panel = document.createElement('div');
    Object.assign(panel.style, {
      background:'#222', color:'#eee', borderRadius:'10px',
      width:'560px', maxWidth:'95vw', padding:'20px',
      boxShadow:'0 10px 30px rgba(0,0,0,0.45)', border:'1px solid #333'
    });
    panel.innerHTML = `
      <div style="display:flex;align-items:center;gap:10px;margin-bottom:8px">
        <div style="background:#e50914;width:28px;height:28px;border-radius:6px;display:flex;align-items:center;justify-content:center;font-weight:800">!</div>
        <h2 style="margin:0;font-size:18px;">This list is a little too epic to send in one go</h2>
      </div>
      <div style="font-size:13px; color:#ccc; line-height:1.55;">
        Your list contains <strong>${totalCount.toLocaleString()}</strong> items. For reliability, sending lists over
        <strong>${MAX_LIST_ITEMS.toLocaleString()}</strong> items isn’t supported in a single run.
        <br><br>
        Try sending it in smaller parts (for example, a few pages at a time) or filter the list and send again.
      </div>
      <div style="margin-top:14px;text-align:right;">
        <button id="kodi-too-many-items-ok" style="background:#e50914;color:#fff;border:none;border-radius:6px;padding:8px 12px;cursor:pointer">Got it</button>
      </div>
    `;
    overlay.append(panel);
    panel.querySelector('#kodi-too-many-items-ok').onclick = () => { try { overlay.remove(); } catch {} };
    document.body.append(overlay);
  }

  // Description editor (same as LB)
  function editDescriptionOverlay(defaultDesc) {
    return new Promise(resolve => {
      const overlay = document.createElement('div');
      overlay.id = 'kodieditdesc';
      Object.assign(overlay.style, {
        position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh',
        background: 'rgba(0,0,0,0.7)', display: 'flex', alignItems: 'center',
        justifyContent: 'center', zIndex: 2147483647,
        padding: '20px', boxSizing: 'border-box'
      });
      const panel = document.createElement('div');
      panel.innerHTML = `
        <h2 style="margin:0 0 12px; color:#fff; font-size:18px;">Edit Description</h2>
        <textarea id="kodieditdesc_text" style="width:600px; max-width:95vw; height:300px; font-size:14px; box-sizing:border-box; white-space:pre-wrap;">${defaultDesc}</textarea>
        <div style="margin-top:12px; text-align:right;">
          <button id="kodieditdesc_save" style="margin-right:8px; padding:6px 12px; font-size:14px;">Save</button>
          <button id="kodieditdesc_cancel" style="padding:6px 12px; font-size:14px;">Cancel</button>
        </div>
      `;
      Object.assign(panel.style, { background: '#222', padding: '20px', borderRadius: '6px' });
      overlay.append(panel);
      document.body.append(overlay);

      panel.querySelector('#kodieditdesc_save').onclick = () => {
        const val = panel.querySelector('#kodieditdesc_text').value;
        overlay.remove();
        resolve(val);
      };
      panel.querySelector('#kodieditdesc_cancel').onclick = () => {
        overlay.remove();
        resolve(null);
      };
    });
  }

  // ────────────────────────────────────────────────────────────────────────
  // Processing core (partition, resolve, persist)
  // ────────────────────────────────────────────────────────────────────────
  async function processAll(ttItems) {
    const baseHits = [];
    const overrideHits = [];
    const negativeHits = [];
    const toResolve = [];

    const now = Date.now();

    // Partition by caches
    for (const it of ttItems) {
      const packed = getFromAnyCachePacked(it.imdbId);
      if (packed !== undefined) {
        const { tmdbId, isTv } = unpackPacked(packed);
        if (overrideGetPacked(it.imdbId) !== undefined) overrideHits.push({ ...it, tmdbId, isTv });
        else baseHits.push({ ...it, tmdbId, isTv });
        continue;
      }
      if (negativeIsFresh(it.imdbId, now)) {
        negativeHits.push({ ...it });
      } else {
        toResolve.push(it);
      }
    }

    const preCounts = {
      base: baseHits.length,
      overrides: overrideHits.length,
      cachedNoId: negativeHits.length,
      uncached: toResolve.length
    };

    // Resolve uncached via TMDB (pool)
    if (toResolve.length) {
      const launchEl = mainIcon || document.body;
      showOrUpdateProcessingBubble(launchEl, [
        'Resolving via TMDB…',
        `Uncached: ${toResolve.length}`,
        `Pool: ${CONCURRENCY_TMDB_FIND}`
      ], true);

      const results = await (async function resolveUncachedWithPool(ttids, onProgress) {
        let idx = 0, active = 0, done = 0;
        const results = Object.create(null);

        return new Promise((resolve) => {
          const launch = () => {
            while (active < CONCURRENCY_TMDB_FIND && idx < ttids.length) {
              const ttid = ttids[idx++]; active++;
              (async () => {
                let tries = 0, res = null;
                while (tries < 3 && res === null) {
                  tries++;
                  try { res = await tmdbFindByImdb(ttid); }
                  catch { res = null; await sleep(300 * tries); }
                }
                results[ttid] = res;
              })().finally(() => {
                active--; done++;
                if (typeof onProgress === 'function') onProgress(done, ttids.length);
                if (idx < ttids.length) launch(); else if (active === 0) resolve(results);
              });
            }
          };
          ttids.length ? launch() : resolve(results);
        });
      })(toResolve.map(x => x.imdbId), (d, total) => {
        showOrUpdateProcessingBubble(launchEl, [
          'Resolving via TMDB…',
          `Progress: ${d} / ${total}`,
          `Pool: ${CONCURRENCY_TMDB_FIND}`
        ], true);
      });

      hideProcessingBubble(true);

      for (const it of toResolve) {
        const r = results[it.imdbId];
        if (r && r.tmdbId) {
          overrideSetPacked(it.imdbId, r.tmdbId, !!r.isTv);
          overrideHits.push({ ...it, tmdbId: r.tmdbId, isTv: !!r.isTv });
        } else {
          negativeSetNow(it.imdbId, now);
          negativeHits.push({ ...it });
        }
      }
      await overridesPersist();
      await negativePersist();
    }

    const resolved = baseHits.concat(overrideHits);
    return {
      resolved,
      counts: preCounts
    };
  }

  // ────────────────────────────────────────────────────────────────────────
  // Launcher ICON + Settings + optional INFO button (LETTERBOXD-STYLE)
  // ────────────────────────────────────────────────────────────────────────
  GM_addStyle(`
    #lb-tmdb-icon-wrap {
      position: fixed; bottom: 10px; right: 10px; display: flex; gap: 8px; align-items: stretch;
      z-index: 2147483647;
      font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial;
    }
    #lb-tmdb-main-icon {
      width: var(--lb-size, 64px); height: var(--lb-size, 64px);
      object-fit: contain; border-radius: 10px; background: rgba(0,0,0,0.03);
      box-shadow: 0 2px 10px rgba(0,0,0,0.12); cursor: pointer; user-select: none;
    }
    #lb-tmdb-main-icon:active { transform: translateY(1px); }

    /* Right column that matches the icon's full height */
    #lb-side-buttons {
      display: flex; flex-direction: column; gap: 8px;
      height: var(--lb-size, 64px);
      width: 36px;
    }

    #lb-settings-btn, #lb-info-btn {
      flex: 1; width: 36px; display: grid; place-items: center;
      background: #444; color: #fff; border: none; border-radius: 10px; cursor: pointer;
      box-shadow: 0 2px 10px rgba(0,0,0,0.2); padding: 0;
    }
    #lb-settings-btn:active, #lb-info-btn:active { transform: translateY(1px); }

    #lb-settings-btn svg, #lb-info-btn svg { display:block }

    @media (prefers-color-scheme: dark) {
      #lb-tmdb-main-icon { background: rgba(255,255,255,0.06); }
      #lb-settings-btn, #lb-info-btn { background: #333; }
    }
  `);

  const topRightBar = document.createElement('div');
  Object.assign(topRightBar.style, { position: 'fixed', top: '10px', right: '10px', display: 'flex', gap: '8px', zIndex: 2147483647 });
  document.body.append(topRightBar);

  const iconWrap = document.createElement('div');
  iconWrap.id = 'lb-tmdb-icon-wrap';
  iconWrap.style.setProperty('--lb-size', `${gmGetOrSet('lb_icon_size', 64)}px`);

  const mainIcon = document.createElement('img');
  mainIcon.id = 'lb-tmdb-main-icon';
  mainIcon.alt = 'FLAM Launcher';
  mainIcon.src = '';

  // Right-side vertical buttons column
  const sideButtons = document.createElement('div');
  sideButtons.id = 'lb-side-buttons';

  const settingsBtn = document.createElement('button');
  settingsBtn.id = 'lb-settings-btn';
  settingsBtn.title = 'Settings';
  settingsBtn.innerHTML = `
    <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none">
      <path d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z" fill="#fff"/>
      <path d="M20 13.1v-2.2l-2.02-.62a6.93 6.93 0 0 0-.52-1.25l1.1-1.84-1.56-1.56-1.84 1.1c-.4-.2-.82-.37-1.25-.52L13.1 4h-2.2l-.61 2.02c-.43.14-.85.31-1.25.52l-1.84-1.1L5.64 7 6.74 8.84c-.2.4-.37.82-.52 1.25L4 10.9v2.2l2.02.62c.14.43.31.85.52 1.25L5.43 16.8l1.56 1.56 1.84-1.1c.4.2.82.37 1.25.52l.62 2.02h2.2l.62-2.02c.43-.14.85-.31 1.25-.52l1.84 1.1 1.56-1.56-1.1-1.84c.2-.4.37-.82.52-1.25L20 13.1Z" fill="#fff"/>
    </svg>
  `;

  // NEW: Info button (simple white "i" silhouette) — toggled by setting
  const infoBtn = document.createElement('button');
  infoBtn.id = 'lb-info-btn';
  infoBtn.title = 'Last Send Info';
  infoBtn.innerHTML = `
    <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
      <path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Z" fill="#fff" opacity=".12"/>
      <path d="M12 6.75a1.25 1.25 0 1 1 0 2.5 1.25 1.25 0 0 1 0-2.5Zm-1.25 4.25a1 1 0 0 0-1 1v5.25a1 1 0 1 0 2 0V12h1.25a1 1 0 1 0 0-2H10.75Z" fill="#fff"/>
      <circle cx="12" cy="12" r="9" stroke="#fff" stroke-width="1.5" opacity=".85"/>
    </svg>
  `;

  sideButtons.append(settingsBtn, infoBtn);
  iconWrap.append(mainIcon, sideButtons);
  topRightBar.append(iconWrap);

  // Icon DB + Picker (same as LB)
  const ICONS_API_URL = 'https://api.github.com/repos/hcgiub001/letterboxd-tmdb-cache/contents/addon_icons';
  const ICON_DB_KEY = 'lb_tmdb_icon_db_v1';
  const ICON_SELECTED_NAME_KEY = 'lb_tmdb_icon_selected_name';
  const ICON_SIZE_KEY = 'lb_icon_size';

  function gmFetchJSON(url) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: 'GET', url,
        headers: { 'Accept': 'application/vnd.github+json' },
        onload: (res) => {
          if (res.status >= 200 && res.status < 300) {
            try { resolve(JSON.parse(res.responseText)); } catch (e) { reject(e); }
          } else reject(new Error(`HTTP ${res.status}`));
        },
        onerror: reject
      });
    });
  }
  function gmFetchArrayBuffer2(url) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: 'GET', url, responseType: 'arraybuffer',
        onload: (res) => {
          if (res.status >= 200 && res.status < 300) resolve(res.response);
          else reject(new Error(`HTTP ${res.status}`));
        },
        onerror: reject
      });
    });
  }
  function arrayBufferToDataURL(buf, mime = 'image/png') {
    const bytes = new Uint8Array(buf);
    const chunk = 0x8000;
    let binary = '';
    for (let i = 0; i < bytes.length; i += chunk) {
      const sub = bytes.subarray(i, i + chunk);
      binary += String.fromCharCode.apply(null, sub);
    }
    return `data:${mime};base64,` + btoa(binary);
  }
  async function buildIconDBFromGitHub() {
    const list = await gmFetchJSON(ICONS_API_URL);
    const files = (list || [])
      .filter(x => x && x.type === 'file' && /\.png$/i.test(x.name) && x.download_url)
      .sort((a, b) => a.name.localeCompare(b.name));
    if (!files.length) throw new Error('No PNG files found in addon_icons.');
    const downloads = await Promise.all(files.map(async (f) => {
      const buf = await gmFetchArrayBuffer2(f.download_url);
      const dataUrl = arrayBufferToDataURL(buf, 'image/png');
      return { name: f.name, dataUrl };
    }));
    const newDb = { icons: downloads, lastSync: new Date().toISOString() };
    GM_setValue(ICON_DB_KEY, newDb);
    return newDb;
  }

  let ICON_DB = GM_getValue(ICON_DB_KEY, null);
  let ICON_SELECTED_NAME = gmGetOrSet(ICON_SELECTED_NAME_KEY, '');
  let ICON_SIZE = parseInt(gmGetOrSet(ICON_SIZE_KEY, 64), 10) || 64;

  async function ensureIconDB() {
    if (!ICON_DB || !Array.isArray(ICON_DB.icons) || !ICON_DB.icons.length) {
      try { ICON_DB = await buildIconDBFromGitHub(); }
      catch (e) {
        console.error('[Icon DB] Initial build failed:', e);
        ICON_DB = { icons: [{ name: 'fallback.png', dataUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII=' }], lastSync: new Date().toISOString() };
        GM_setValue(ICON_DB_KEY, ICON_DB);
      }
    }
  }
  function setIconSize(px) {
    ICON_SIZE = Math.max(16, Math.min(512, +px || 64));
    iconWrap.style.setProperty('--lb-size', `${ICON_SIZE}px`);
    GM_setValue(ICON_SIZE_KEY, ICON_SIZE);
  }
  function setSelectedIconByName(name) {
    ICON_SELECTED_NAME = name || '';
    GM_setValue(ICON_SELECTED_NAME_KEY, ICON_SELECTED_NAME);
    const entry = ICON_DB?.icons?.find(i => i.name === ICON_SELECTED_NAME) || ICON_DB?.icons?.[0];
    if (entry) mainIcon.src = entry.dataUrl;
  }
  await ensureIconDB();
  if (!ICON_SELECTED_NAME && ICON_DB?.icons?.length) {
    ICON_SELECTED_NAME = ICON_DB.icons[0].name;
    GM_setValue(ICON_SELECTED_NAME_KEY, ICON_SELECTED_NAME);
  }
  setIconSize(ICON_SIZE);
  setSelectedIconByName(ICON_SELECTED_NAME);

  // NEW: Info button visibility control
  function updateInfoButtonVisibility() {
    const show = gmGetOrSet(INFO_BTN_ENABLE_KEY, true);
    infoBtn.style.display = show ? 'grid' : 'none';
    // If hidden, let settings fill full height (already flex:1) — done via CSS layout.
  }
  updateInfoButtonVisibility();

  // ────────────────────────────────────────────────────────────────────────
  // Run control
  // ────────────────────────────────────────────────────────────────────────
  async function startRun() {
    await ensureBaseLoadedOnce();
    await overridesLoadOnce();
    await negativeLoadOnce();
    setTimeout(() => { maybeRevalidateBaseMonthly(); }, 0);
  }

  async function resolveActionForThisRun() {
    const stored = GM_getValue('kodiAction', 'ask');
    if (stored === 'ask') return await askForActionOverlay();
    return stored;
  }

  // ────────────────────────────────────────────────────────────────────────
  // Show URL (Tools) — LB-style panel and bytes/savings readout
  // ────────────────────────────────────────────────────────────────────────
  async function handleShowUrl(btnEl) {
    if (btnEl) btnEl.disabled = true;

    await startRun();

    let items = await gatherItems();
    if (!items || !items.length) { alert('No items'); if (btnEl) btnEl.disabled = false; return; }
    if (items.length > MAX_LIST_ITEMS) { showTooManyItemsOverlay(items.length); if (btnEl) btnEl.disabled = false; return; }

    let how = prompt('How many items? number or "all":', 'all');
    if (how === null) { if (btnEl) btnEl.disabled = false; return; }
    how = how.trim().toLowerCase();
    if (how !== 'all') {
      const n = parseInt(how, 10);
      if (isNaN(n) || n < 1) { alert('Invalid number'); if (btnEl) btnEl.disabled = false; return; }
      items = items.slice(0, n);
    }

    const { resolved, counts } = await processAll(items);

    const listName = getListName();
    const author = getAuthor();

    // Description (same behavior as Letterboxd = optional edit)
    let descriptionText = '';
    if (gmGetOrSet('descEnable', true)) {
      const pageDesc = getDescriptionText() || DEFAULT_DESCRIPTION;
      if (gmGetOrSet('descMode', 'send') === 'edit') {
        const edited = await editDescriptionOverlay(pageDesc);
        if (edited === null) { if (btnEl) btnEl.disabled = false; return; }
        descriptionText = sanitizeDescPreservingBreaks(edited);
      } else {
        descriptionText = pageDesc;
      }
    }

    // Action (ASK matches LB)
    let action = GM_getValue('kodiAction', 'ask');
    if (action === 'ask') {
      const chosen = await askForActionOverlay();
      if (!chosen && chosen !== '') { if (btnEl) btnEl.disabled = false; return; } // cancel
      action = chosen || '';
    }

    // Artwork options (same keys & behavior as LB)
    const posterEnabled = gmGetOrSet('posterEnable', POSTER_ENABLE_DEFAULT);
    const posterStrategy = gmGetOrSet('posterStrategy', POSTER_STRATEGY_DEFAULT);
    const fanartEnabled = gmGetOrSet('fanartEnable', FANART_ENABLE_DEFAULT);
    const fanartStrategy = gmGetOrSet('fanartStrategy', FANART_STRATEGY_DEFAULT);
    const fanartFallback = gmGetOrSet('fanartFallback', FANART_FALLBACK_DEFAULT);

    const fen = await buildFenUrlTMDB_Art(resolved, {
      listName, author, description: descriptionText, action,
      posterEnabled, posterStrategy,
      fanartEnabled, fanartStrategy, fanartFallback
    });

    const pct = Math.min(100, Math.round((fen.urlBytes / URL_LIMIT_BYTES) * 1000) / 10);
    const savings = (typeof fen.gzBytes === 'number') ? (fen.rawBytes - fen.gzBytes) : 0;

    const existing = document.getElementById('kodishowurl'); if (existing) existing.remove();
    const overlay = document.createElement('div');
    overlay.id = 'kodishowurl';
    Object.assign(overlay.style, {
      position:'fixed', top:0, left:0, width:'100vw', height:'100vh',
      background:'rgba(0,0,0,0.7)', display:'flex', alignItems:'center', justifyContent:'center',
      zIndex:2147483647, padding:'20px', boxSizing:'border-box'
    });
    const panel = document.createElement('div');
    Object.assign(panel.style, { background:'#222', padding:'20px', borderRadius:'6px', maxWidth:'95vw' });
    panel.innerHTML = `
      <h2 style="margin:0 0 12px; color:#fff; font-size:18px;">URL (${fen.gzUrl ? 'gzip+base64' : 'raw JSON'})</h2>
      <textarea id="kodishowurl_text" style="width:600px; max-width:95vw; height:200px; font-size:14px; box-sizing:border-box; white-space:pre-wrap;">${fen.url}</textarea>
      <div style="margin-top:10px; padding:8px; background:#1b1b1b; border:1px solid #333; border-radius:6px; color:#ddd; font-size:12px; line-height:1.5">
        <div><strong>Raw URL:</strong> ${fen.rawBytes} bytes</div>
        <div><strong>Gzip+Base64 URL:</strong> ${typeof fen.gzBytes === 'number' ? fen.gzBytes + ' bytes' : 'n/a (compression unavailable)'}</div>
        <div><strong>Savings:</strong> ${typeof fen.gzBytes === 'number' ? (savings + ' bytes (' + (fen.rawBytes ? Math.round((savings / fen.rawBytes) * 100) : 0) + '%)') : '—'}</div>
        <div><strong>Using:</strong> ${fen.gzUrl ? 'base64_items (gzip+base64)' : 'list_items (raw JSON)'}</div>
        <div><strong>Limit usage:</strong> ${fen.urlBytes} / ${URL_LIMIT_BYTES} (${pct}%)</div>
        <div><strong>Cache:</strong> base ${counts.base} • overrides ${counts.overrides} • cached_no_id ${counts.cachedNoId} • uncached ${counts.uncached} (Σ=${counts.base+counts.overrides+counts.cachedNoId+counts.uncached} / ${items.length})</div>
      </div>
      <div style="margin-top:12px; text-align:right;">
        <button id="kodishowurl_copy" style="margin-right:8px; padding:6px 12px; font-size:14px;">Copy URL</button>
        <button id="kodishowurl_close" style="padding:6px 12px; font-size:14px;">Close</button>
      </div>
    `;
    overlay.append(panel);
    document.body.append(overlay);
    panel.querySelector('#kodishowurl_copy').onclick = () => {
      const ta = panel.querySelector('#kodishowurl_text'); ta.select(); document.execCommand('copy');
      panel.querySelector('#kodishowurl_copy').textContent = 'Copied!';
    };
    panel.querySelector('#kodishowurl_close').onclick = () => overlay.remove();

    if (btnEl) btnEl.disabled = false;
  }

  // ────────────────────────────────────────────────────────────────────────
  // Main icon click → EARLY ASK + TRUST PREFLIGHT + full run (LB styling)
  // ────────────────────────────────────────────────────────────────────────
  let ICON_BUSY = false;
  mainIcon.addEventListener('click', async function () {
    if (ICON_BUSY) return;
    ICON_BUSY = true;

    const startTime = performance.now();

    // Trigger TM trust prompt immediately (harmless ping)
    preflightKodiPermission();

    showOrUpdateProcessingBubble(mainIcon, ['Processing…'], true);

    await startRun();

    // Show ASK immediately (if configured) while processing runs
    const stored = GM_getValue('kodiAction', 'ask');
    const actionPromise = (stored === 'ask') ? askForActionOverlay() : Promise.resolve(stored);

    // Process in background
    const processingPromise = (async () => {
      let items;
      try { items = await gatherItems(); }
      catch { alert('Scrape failed'); hideProcessingBubble(true); ICON_BUSY = false; return null; }
      if (!items || !items.length) { alert('No items'); hideProcessingBubble(true); ICON_BUSY = false; return null; }

      if (items.length > MAX_LIST_ITEMS) {
        showTooManyItemsOverlay(items.length);
        hideProcessingBubble(true);
        ICON_BUSY = false;
        return null;
      }

      // Pre-cache stats
      const overrides0 = (await idbGet(OVERRIDES_STORE, OVERRIDES_KEY)) || {};
      let baseHitCount = 0, overrideHitCount = 0, uncachedCount = 0;
      for (const it of items) {
        const fid = String(it.imdbId || '');
        const inOverrides = Number.isFinite(overrides0[fid]);
        if (inOverrides) { overrideHitCount++; continue; }
        const m = fid.match(/^tt(\d+)$/);
        const basePacked = m ? baseLookupPackedByNumericImdb(Number(m[1])) : undefined;
        if (basePacked !== undefined) baseHitCount++;
        else uncachedCount++;
      }
      const totalPlanned = baseHitCount + overrideHitCount + uncachedCount;

      showOrUpdateProcessingBubble(mainIcon, [
        'Processing…',
        `Items selected: ${items.length}`,
        `Cache — base: ${baseHitCount} • overrides: ${overrideHitCount} • uncached: ${uncachedCount}`,
        `Sum check: ${totalPlanned} / ${items.length}`
      ], true);

      const { resolved, counts } = await processAll(items);

      // Description (same as LB)
      let descriptionText = '';
      if (gmGetOrSet('descEnable', true)) {
        const pageDesc = getDescriptionText() || DEFAULT_DESCRIPTION;
        if (gmGetOrSet('descMode', 'send') === 'edit') {
          const edited = await editDescriptionOverlay(pageDesc);
          if (edited === null) { hideProcessingBubble(true); ICON_BUSY = false; return null; }
          descriptionText = sanitizeDescPreservingBreaks(edited);
        } else {
          descriptionText = pageDesc;
        }
      }

      const listName = getListName();
      const author = getAuthor();

      return { resolved, counts, listName, author, descriptionText, totalItems: items.length };
    })();

    const action = await actionPromise;
    const proc = await processingPromise;
    if (!proc) { ICON_BUSY = false; return; }

    if (stored === 'ask' && !action) { hideProcessingBubble(true); ICON_BUSY = false; return; } // canceled

    // Artwork options pulled from settings (LB parity)
    const posterEnabled = gmGetOrSet('posterEnable', POSTER_ENABLE_DEFAULT);
    const posterStrategy = gmGetOrSet('posterStrategy', POSTER_STRATEGY_DEFAULT);
    const fanartEnabled = gmGetOrSet('fanartEnable', FANART_ENABLE_DEFAULT);
    const fanartStrategy = gmGetOrSet('fanartStrategy', FANART_STRATEGY_DEFAULT);
    const fanartFallback = gmGetOrSet('fanartFallback', FANART_FALLBACK_DEFAULT);

    showOrUpdateProcessingBubble(mainIcon, [
      'Building URL…',
      (String(action).toLowerCase() === 'view') ? `Artwork disabled for 'view'` :
      (posterEnabled ? `Poster: ${posterStrategy}` : `Poster: disabled`),
      (String(action).toLowerCase() === 'view') ? `` :
      (fanartEnabled ? `Fanart: ${fanartStrategy}${fanartStrategy==='author_fanart' ? ` (fallback: ${fanartFallback})` : ''}` : `Fanart: disabled`)
    ].filter(Boolean), true);

    const fen = await buildFenUrlTMDB_Art(proc.resolved, {
      listName: proc.listName,
      author: proc.author,
      description: proc.descriptionText,
      action: action || '',
      posterEnabled, posterStrategy,
      fanartEnabled, fanartStrategy, fanartFallback
    });

    showOrUpdateProcessingBubble(mainIcon, [
      'Sending to Kodi…',
      `URL bytes: ${fen.urlBytes} ${fen.gzUrl ? '(gzip+base64)' : '(raw JSON)'}`
    ], true);

    const ok = await sendToKodi(fen.url);
    const elapsedMs = Math.max(0, Math.round(performance.now() - startTime));

    hideProcessingBubble(true);

    const pct = Math.min(100, Math.round((fen.urlBytes / URL_LIMIT_BYTES) * 1000) / 10);
    const lines = [
      `Items sent: ${proc.resolved.length}`,
      `📦 Cache — base: ${proc.counts.base} • overrides: ${proc.counts.overrides} • cached_no_id: ${proc.counts.cachedNoId} • uncached: ${proc.counts.uncached}`,
      `Σ = ${proc.counts.base + proc.counts.overrides + proc.counts.cachedNoId + proc.counts.uncached} / ${proc.totalItems}`,
      `📨 Bytes: ${fen.urlBytes} / ${URL_LIMIT_BYTES} (${pct}%) ${fen.gzUrl ? '[gzip+base64]' : '[raw JSON]'}`,
      `⏱ Elapsed: ${(elapsedMs/1000).toFixed(1)}s`,
      ok ? '✅ Sent' : '❌ Failed'
    ];
    showSideInfoNearEl(mainIcon, lines, 7000);

    // NEW: persist last send stats for Info button
    try {
      GM_setValue(LAST_STATS_KEY, { lines, when: Date.now() });
    } catch {}

    ICON_BUSY = false;
  });

  // NEW: Info button handler — show last saved stats
  infoBtn.addEventListener('click', () => {
    const last = gmGetOrSet(LAST_STATS_KEY, null);
    if (!last || !Array.isArray(last.lines)) {
      showSideInfoNearEl(mainIcon, ['No previous send stats found.', 'Send a list to populate this.'], 5000);
      return;
    }
    const when = new Date(last.when || Date.now());
    const whenStr = `${when.toLocaleDateString()} ${when.toLocaleTimeString()}`;
    const lines = [...last.lines, `🕒 Last sent: ${whenStr}`];
    showSideInfoNearEl(mainIcon, lines, 8000);
  });

  // ────────────────────────────────────────────────────────────────────────
  // Settings (Letterboxd-style): General + Tools tabs (kept, plus info toggle)
  // ────────────────────────────────────────────────────────────────────────
  settingsBtn.addEventListener('click', showSettings);

  function showSettings() {
    if (document.getElementById('kodisettings')) return;

    const overlay = document.createElement('div');
    overlay.id = 'kodisettings';
    Object.assign(overlay.style, {
      position:'fixed', top:0, left:0, width:'100vw', height:'100vh',
      background:'rgba(0,0,0,0.7)', display:'flex', alignItems:'center',
      justifyContent:'center', zIndex:2147483647, padding:'20px', boxSizing:'border-box'
    });

    const panel = document.createElement('div');

    panel.innerHTML = `
      <div style="display:flex; gap:8px; align-items:center; justify-content:space-between; margin-bottom:8px;">
        <h2 style="color:#fff;margin:0">Kodi Settings</h2>
        <div style="display:flex; gap:6px;">
          <button class="kodi-tab-btn" data-tab="general" style="background:#e50914;color:#fff;border:none;border-radius:4px;padding:6px 10px;cursor:pointer">General</button>
          <button class="kodi-tab-btn" data-tab="tools" style="background:#444;color:#fff;border:none;border-radius:4px;padding:6px 10px;cursor:pointer">Tools</button>
        </div>
      </div>

      <div id="kodi-tab-general" class="kodi-tab" style="display:block">

        <h3 style="color:#fff;margin:10px 0 6px;">Launcher Icon</h3>
        <div style="background:#1b1b1b;border:1px solid #333;border-radius:10px;padding:10px;margin-bottom:12px;color:#ddd">
          <div style="display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px">
            <div style="display:flex;align-items:center;gap:10px">
              <img id="iconPreview" alt="Icon preview" style="width:48px;height:48px;border-radius:10px;border:1px solid #2a2a2a;object-fit:contain; background:#111"/>
              <div style="font-size:12px;line-height:1.4">
                <div><strong>Selected:</strong> <span id="iconSelectedName">—</span></div>
                <div id="iconMeta" style="opacity:.8">Cached icons: — • Last refresh: —</div>
              </div>
            </div>
            <button id="refreshIconsBtn" style="font-size:12px;padding:6px 10px;border-radius:8px;background:#000;color:#fff;border:none;cursor:pointer">Refresh from GitHub</button>
          </div>

          <div id="iconGrid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(48px,1fr));gap:8px;max-height:220px;overflow:auto;padding-right:2px"></div>

          <div style="display:grid;grid-template-columns:1fr auto;gap:8px;align-items:center;margin-top:10px">
            <div>
              <label style="font-size:13px">Length (px):</label>
              <input id="iconSizeRange" type="range" min="16" max="512" step="1">
            </div>
            <input id="iconSizeNumber" type="number" min="16" max="512" step="1" style="width:90px;padding:6px 8px;border-radius:8px;border:1px solid #444;background:#111;color:#eee">
          </div>
        </div>

        <div style="background:#1b1b1b;border:1px solid #333;border-radius:10px;padding:10px;margin-bottom:12px;color:#ddd">
          <label style="display:flex;align-items:center;gap:8px;">
            <input type="checkbox" id="showInfoBtn">
            <span>Show Info Button (shows last send stats)</span>
          </label>
          <div style="font-size:12px;color:#bbb;margin-top:6px;">When enabled, an Info button appears below the settings button. Clicking it shows the last stats bubble from your most recent send.</div>
        </div>

        <label style="color:#fff">Kodi IP:</label>
        <input id="kodiIp" style="width:100%;margin-bottom:8px"/>

        <label style="color:#fff">Kodi Port:</label>
        <input id="kodiPort" style="width:100%;margin-bottom:8px"/>

        <label style="color:#fff">Kodi User:</label>
        <input id="kodiUser" style="width:100%;margin-bottom:8px"/>

        <label style="color:#fff">Kodi Pass:</label>
        <input id="kodiPass" type="password" style="width:100%;margin-bottom:12px"/>

        <label style="color:#fff">Default Action:</label>
        <select id="kodiAction" style="width:100%;margin-bottom:16px">
          <option value="">Omit Action key</option>
          <option value="view">view</option>
          <option value="import">import</option>
          <option value="import_view">import_view</option>
          <option value="ask">Ask each time</option>
        </select>

        <label style="color:#fff">Busy indicator:</label>
        <select id="indicator" style="width:100%;margin-bottom:16px">
          <option value="none">none</option>
          <option value="busy">busy</option>
          <option value="progress">progress</option>
        </select>

        <label style="color:#fff"><input type="checkbox" id="descEnable"/> Add Description</label>
        <div id="descOptions" style="margin:8px 0;display:none;color:#fff">
          <label>Edit mode:</label>
          <select id="descMode" style="width:100%;margin-bottom:8px">
            <option value="send">Send without editing</option>
            <option value="edit">Edit before sending</option>
          </select>
        </div>

        <hr style="border-color:#444;margin:14px 0">

        <div>
          <h3 style="color:#fff;margin:0 0 8px;">Artwork Options</h3>
          <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:10px">
            <div style="border:1px solid #333;border-radius:6px;padding:10px;">
              <label style="color:#fff"><input type="checkbox" id="posterEnable"/> Enable Poster</label>
              <div style="color:#bbb;font-size:12px;margin:6px 0 8px">Include the <code>poster</code> key in the URL.<br>(Ignored when action=<code>view</code>)</div>
              <label style="color:#fff">Poster selection:</label>
              <select id="posterStrategy" style="width:100%;margin-top:4px">
                <option value="first_4">first_4</option>
                <option value="random">random</option>
              </select>
            </div>
            <div style="border:1px solid #333;border-radius:6px;padding:10px;">
              <label style="color:#fff"><input type="checkbox" id="fanartEnable"/> Enable Fanart</label>
              <div style="color:#bbb;font-size:12px;margin:6px 0 8px">The <code>fanart</code> key accepts either a strategy or a direct URL.<br>(Ignored when action=<code>view</code>)</div>
              <label style="color:#fff">Fanart selection:</label>
              <select id="fanartStrategy" style="width:100%;margin-top:4px">
                <option value="author_fanart">author_fanart (use page og:image)</option>
                <option value="first_4">first_4</option>
                <option value="random">random</option>
              </select>
              <div id="fanartFallbackBox" style="margin-top:8px; display:none;">
                <label style="color:#fff">If author_fanart missing, fallback to:</label>
                <select id="fanartFallback" style="width:100%;margin-top:4px">
                  <option value="none">none</option>
                  <option value="first_4">first_4</option>
                  <option value="random">random</option>
                </select>
              </div>
              <div style="color:#888; font-size:12px; margin-top:6px;">
                On IMDb, “author fanart” uses the page’s OpenGraph image if present; otherwise the chosen fallback strategy.
              </div>
            </div>
          </div>
        </div>

        <hr style="border-color:#444;margin:14px 0">

        <div>
          <h3 style="color:#fff;margin:0 0 8px;">Cache Tools</h3>
          <div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:6px">
            <button id="refreshBaseCacheBtn">Refresh Base Cache</button>
          </div>
          <div id="cacheToolMsg" style="color:#bbb;font-size:12px;margin-top:8px;"></div>
        </div>
      </div>

      <div id="kodi-tab-tools" class="kodi-tab" style="display:none">
        <div style="display:flex; flex-wrap:wrap; gap:8px; margin-bottom:12px;">
          <button id="btnShowUrl" title="Build & view plugin URL">Show URL</button>
          <button id="btnClearOverrides" title="Clear overrides">Clear Overrides</button>
        </div>
        <div style="color:#bbb; font-size:12px;">
          Use the bottom-right icon to send. Tools let you preview the URL or clear positive overrides.
        </div>
      </div>

      <div style="text-align:right;margin-top:12px; position:sticky; bottom:0; background:#222; padding-top:8px;">
        <button id="kodisave" style="margin-right:8px">Save</button>
        <button id="kodicancel">Close</button>
      </div>
    `;

    Object.assign(panel.style, {
      background: '#222', padding: '16px', borderRadius: '8px',
      width: '720px', maxWidth: '95vw', maxHeight: '90vh',
      overflowY: 'auto', boxSizing: 'border-box', color:'#fff'
    });

    overlay.append(panel);
    document.body.append(overlay);

    const tabBtns = panel.querySelectorAll('.kodi-tab-btn');
    function activateTab(name) {
      panel.querySelectorAll('.kodi-tab').forEach(el => el.style.display = 'none');
      const btns = panel.querySelectorAll('.kodi-tab-btn');
      btns.forEach(b => { b.style.background = (b.dataset.tab === name) ? '#e50914' : '#444'; });
      const shown = panel.querySelector(`#kodi-tab-${name}`);
      if (shown) shown.style.display = 'block';
    }
    tabBtns.forEach(b => b.addEventListener('click', () => activateTab(b.dataset.tab)));
    activateTab('general');

    // hydrate
    panel.querySelector('#iconPreview').src = mainIcon.src;

    panel.querySelector('#kodiIp').value = GM_getValue('kodiIp','');
    panel.querySelector('#kodiPort').value = GM_getValue('kodiPort','');
    panel.querySelector('#kodiUser').value = GM_getValue('kodiUser','');
    panel.querySelector('#kodiPass').value = gmGetOrSet('kodiPass','');
    panel.querySelector('#kodiAction').value = GM_getValue('kodiAction','ask');
    panel.querySelector('#indicator').value = gmGetOrSet('indicator', DEFAULT_INDICATOR);

    panel.querySelector('#descEnable').checked = gmGetOrSet('descEnable', true);
    panel.querySelector('#descMode').value = gmGetOrSet('descMode', 'send');
    const descOpts = panel.querySelector('#descOptions');
    panel.querySelector('#descEnable').addEventListener('change', e => {
      descOpts.style.display = e.target.checked ? 'block' : 'none';
    });
    if (panel.querySelector('#descEnable').checked) descOpts.style.display = 'block';

    // Info toggle
    panel.querySelector('#showInfoBtn').checked = gmGetOrSet(INFO_BTN_ENABLE_KEY, true);

    panel.querySelector('#posterEnable').checked = gmGetOrSet('posterEnable', POSTER_ENABLE_DEFAULT);
    panel.querySelector('#posterStrategy').value = gmGetOrSet('posterStrategy', POSTER_STRATEGY_DEFAULT);
    panel.querySelector('#fanartEnable').checked = gmGetOrSet('fanartEnable', FANART_ENABLE_DEFAULT);
    panel.querySelector('#fanartStrategy').value = gmGetOrSet('fanartStrategy', FANART_STRATEGY_DEFAULT);
    panel.querySelector('#fanartFallback').value = gmGetOrSet('fanartFallback', FANART_FALLBACK_DEFAULT);
    function refreshFanartFallbackVisibility() {
      const strat = panel.querySelector('#fanartStrategy').value;
      panel.querySelector('#fanartFallbackBox').style.display = (strat === 'author_fanart') ? 'block' : 'none';
    }
    panel.querySelector('#fanartStrategy').addEventListener('change', refreshFanartFallbackVisibility);
    refreshFanartFallbackVisibility();

    // Icon controls
    const iconNameEl = panel.querySelector('#iconSelectedName');
    const iconMeta = panel.querySelector('#iconMeta');
    const iconGrid = panel.querySelector('#iconGrid');
    const sizeRange = panel.querySelector('#iconSizeRange');
    const sizeNumber = panel.querySelector('#iconSizeNumber');
    const refreshIconsBtn = panel.querySelector('#refreshIconsBtn');

    function updateIconMeta() {
      const count = ICON_DB?.icons?.length || 0;
      const when = ICON_DB?.lastSync ? new Date(ICON_DB.lastSync).toLocaleString() : 'never';
      iconMeta.textContent = `Cached icons: ${count} • Last refresh: ${when}`;
    }
    function populateIconGrid() {
      iconGrid.innerHTML = '';
      if (!ICON_DB?.icons?.length) {
        iconGrid.textContent = 'No cached icons. Click “Refresh from GitHub”.';
        return;
      }
      for (const it of ICON_DB.icons) {
        const img = document.createElement('img');
        img.src = it.dataUrl;
        img.title = it.name;
        img.alt = it.name;
        Object.assign(img.style, {
          width: '100%', aspectRatio: '1 / 1', objectFit: 'contain',
          background: '#111', border: '1px solid #2a2a2a', borderRadius: '10px', cursor: 'pointer'
        });
        if (it.name === ICON_SELECTED_NAME) {
          img.style.outline = '2px solid #fff';
          img.style.outlineOffset = '2px';
        }
        img.addEventListener('click', () => {
          ICON_SELECTED_NAME = it.name;
          setSelectedIconByName(ICON_SELECTED_NAME);
          iconNameEl.textContent = ICON_SELECTED_NAME;
          iconGrid.querySelectorAll('img').forEach(g => { g.style.outline = 'none'; g.style.outlineOffset = '0'; });
          img.style.outline = '2px solid #fff';
          img.style.outlineOffset = '2px';
          panel.querySelector('#iconPreview').src = mainIcon.src;
        });
        iconGrid.appendChild(img);
      }
    }
    function syncSizeInputs() {
      sizeRange.value = String(ICON_SIZE);
      sizeNumber.value = String(ICON_SIZE);
    }

    iconNameEl.textContent = ICON_SELECTED_NAME || '—';
    updateIconMeta();
    populateIconGrid();
    syncSizeInputs();

    sizeRange.addEventListener('input', (e) => { setIconSize(e.target.value); syncSizeInputs(); });
    sizeNumber.addEventListener('input', (e) => { setIconSize(e.target.value); syncSizeInputs(); });

    refreshIconsBtn.addEventListener('click', async () => {
      const prev = refreshIconsBtn.textContent;
      refreshIconsBtn.disabled = true; refreshIconsBtn.textContent = 'Refreshing…';
      try {
        ICON_DB = await buildIconDBFromGitHub();
        if (!ICON_DB.icons.find(i => i.name === ICON_SELECTED_NAME)) {
          ICON_SELECTED_NAME = ICON_DB.icons[0].name;
          GM_setValue(ICON_SELECTED_NAME_KEY, ICON_SELECTED_NAME);
        }
        setSelectedIconByName(ICON_SELECTED_NAME);
        panel.querySelector('#iconPreview').src = mainIcon.src;
        iconNameEl.textContent = ICON_SELECTED_NAME;
        updateIconMeta();
        populateIconGrid();
        refreshIconsBtn.textContent = 'Refreshed ✓';
        await sleep(700);
      } catch (e) {
        console.error('[Icon DB] Refresh failed:', e);
        refreshIconsBtn.textContent = 'Refresh failed';
        await sleep(1200);
      } finally {
        refreshIconsBtn.textContent = prev;
        refreshIconsBtn.disabled = false;
      }
    });

    // Cache tools (base)
    const cacheMsg = panel.querySelector('#cacheToolMsg');
    const setCacheMsg = (txt, ok=false) => { cacheMsg.textContent = txt || ''; cacheMsg.style.color = ok ? '#8ee6a4' : '#bbb'; };
    panel.querySelector('#refreshBaseCacheBtn').onclick = async () => {
      setCacheMsg('Refreshing base cache…');
      const ok = await refreshBaseCacheNow(true);
      setCacheMsg(ok ? `Base cache ready (${BASE_PAIR_COUNT.toLocaleString()} pairs).` : 'Base cache refresh failed.', ok);
    };

    // Tools tab buttons
    panel.querySelector('#btnShowUrl').onclick = function () { handleShowUrl(this); };
    panel.querySelector('#btnClearOverrides').onclick = async () => { await overridesClear(); alert('Overrides cleared'); };

    // Save/Close
    panel.querySelector('#kodisave').onclick = () => {
      GM_setValue('kodiIp', panel.querySelector('#kodiIp').value.trim());
      GM_setValue('kodiPort', panel.querySelector('#kodiPort').value.trim());
      GM_setValue('kodiUser', panel.querySelector('#kodiUser').value);
      GM_setValue('kodiPass', panel.querySelector('#kodiPass').value);
      GM_setValue('kodiAction', panel.querySelector('#kodiAction').value);
      GM_setValue('indicator', panel.querySelector('#indicator').value);

      GM_setValue('descEnable', panel.querySelector('#descEnable').checked);
      GM_setValue('descMode', panel.querySelector('#descMode').value);

      GM_setValue('posterEnable', panel.querySelector('#posterEnable').checked);
      GM_setValue('posterStrategy', panel.querySelector('#posterStrategy').value);
      GM_setValue('fanartEnable', panel.querySelector('#fanartEnable').checked);
      GM_setValue('fanartStrategy', panel.querySelector('#fanartStrategy').value);
      GM_setValue('fanartFallback', panel.querySelector('#fanartFallback').value);

      GM_setValue(INFO_BTN_ENABLE_KEY, panel.querySelector('#showInfoBtn').checked);
      updateInfoButtonVisibility();

      overlay.remove();
      alert('✅ Settings saved');
    };
    panel.querySelector('#kodicancel').onclick = () => overlay.remove();
  }

})();