Letterboxd → Plex opener

Adds a Plex button on Letterboxd film pages that opens the movie in Plex Web. Authenticates via Plex OAuth PIN.

// ==UserScript==
// @name         Letterboxd → Plex opener
// @namespace    lbxd-plex-opener
// @version      2.1.0
// @description  Adds a Plex button on Letterboxd film pages that opens the movie in Plex Web. Authenticates via Plex OAuth PIN.
// @author       vigrid
// @license      MIT
// @match        https://letterboxd.com/film/*
// @match        https://letterboxd.com/imdb/*
// @match        https://letterboxd.com/tmdb/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @grant        GM_xmlhttpRequest
// @grant        GM_openInTab
// @grant        GM_addStyle
// @connect      *
// @run-at       document-idle
// ==/UserScript==

(function () {
    'use strict';

    /**********************
   * Basic configuration
   **********************/
    const APP = {
        product: 'Letterboxd Plex Opener',
        clientId: getOrMakeClientId(),
        cacheTTLms: 7 * 24 * 60 * 60 * 1000,
        warmRecentlyAdded: true,
        warmCount: 250,
    };

    /**********************
   * Menu commands
   **********************/
    GM_registerMenuCommand('Plex: Configure', openSettings);
    GM_registerMenuCommand('Plex: Clear cache', clearCache);
    GM_registerMenuCommand('Plex: Sign out', signOut);

    /**********************
   * Utilities
   **********************/
    function getOrMakeClientId() {
        let id = GM_getValue('clientId');
        if (!id) {
            id = 'lbxd-' + Math.random().toString(36).slice(2) + Date.now().toString(36);
            GM_setValue('clientId', id);
        }
        return id;
    }

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

    function xml(text) {
        return new window.DOMParser().parseFromString(text, 'application/xml');
    }

    function $$(selector, root = document) {
        return Array.from(root.querySelectorAll(selector));
    }

    

    function normalizeTitleYear(t, y) {
        return (t || '').trim().toLowerCase().replace(/\s+/g, ' ') + ' (' + (y || '') + ')';
    }

    // Extract normalized external IDs (IMDb/TMDb) from a list of Plex <Guid> values
    // Returns { imdbId: 'tt1234567' | null, tmdbId: '12345' | null }
    function extractIdsFromGuidStrings(guidStrings) {
        let imdbId = null;
        let tmdbId = null;
        for (let raw of guidStrings || []) {
            const g = String(raw || '').toLowerCase();
            if (!imdbId) {
                const m = g.match(/imdb:\/\/(tt\d+)/) || g.match(/com\.plexapp\.agents\.imdb:\/\/(tt\d+)/);
                if (m) imdbId = m[1];
            }
            if (!tmdbId) {
                const m = g.match(/(?:tmdb|themoviedb):\/\/(\d+)/) || g.match(/com\.plexapp\.agents\.(?:tmdb|themoviedb):\/\/(\d+)/);
                if (m) tmdbId = m[1];
            }
            if (imdbId && tmdbId) break;
        }
        return { imdbId, tmdbId };
    }

    function tmFetch(opts) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: opts.method || 'GET',
                url: opts.url,
                headers: opts.headers || {},
                data: opts.data,
                responseType: opts.responseType || 'text',
                timeout: opts.timeout || 15000,
                onload: (resp) => {
                    if (resp.status >= 200 && resp.status < 400) return resolve(resp);
                    const err = new Error(`HTTP ${resp.status} while requesting ${opts.url}`);
                    err.response = resp;
                    reject(err);
                },
                onerror: (resp) => {
                    const err = new Error(`Network error while requesting ${opts.url}`);
                    err.response = resp;
                    reject(err);
                },
                ontimeout: (resp) => {
                    const err = new Error(`Timeout while requesting ${opts.url}`);
                    err.response = resp;
                    reject(err);
                },
            });
        });
    }

    /**********************
   * Settings UI & storage
   **********************/
    function getSettings() {
        const s = GM_getValue('settings') || {
            token: null,                // Plex token (from OAuth PIN or manual)
            // legacy single-server fields (migrated to servers[])
            serverId: null,
            serverName: null,
            connectionUri: null,
            includeLibraries: null,
            // multi-server
            servers: [],                // [{ id, name, uri, enabled, includeLibraries, token }]
            lastAuthCheck: 0,
            lastUpdatedAtBySection: {},
            lastCacheBuild: 0,
            useColoredRows: true,
        };
        // Migration: move legacy single-server into servers[] if present
        if ((!s.servers || !Array.isArray(s.servers)) && (s.serverId || s.connectionUri)) {
            s.servers = [];
        }
        if (Array.isArray(s.servers) && s.servers.length === 0 && (s.serverId || s.connectionUri)) {
            const legacy = {
                id: s.serverId || 'unknown',
                name: s.serverName || 'Plex',
                uri: s.connectionUri || null,
                enabled: true,
                includeLibraries: s.includeLibraries || null,
                token: null,
            };
            s.servers.push(legacy);
            // clear legacy fields to avoid confusion
            s.serverId = null;
            s.serverName = null;
            s.connectionUri = null;
            s.includeLibraries = null;
            GM_setValue('settings', s);
        }
        if (!Array.isArray(s.servers)) s.servers = [];
        if (typeof s.useColoredRows !== 'boolean') s.useColoredRows = true;
        return s;
    }

    function setSettings(next) {
        // Capture previous state to detect auth/server transitions
        let prev = null;
        try { prev = GM_getValue('settings') || {}; } catch (_) { prev = {}; }

        GM_setValue('settings', next);

        // If we just linked Plex or enabled a server, try to resolve current page immediately
        try {
            const prevToken = prev && prev.token ? String(prev.token) : '';
            const nextToken = next && next.token ? String(next.token) : '';
            const prevEnabled = Array.isArray(prev?.servers) ? prev.servers.filter(x => x && x.enabled !== false && x.uri).length : 0;
            const nextEnabled = Array.isArray(next?.servers) ? next.servers.filter(x => x && x.enabled !== false && x.uri).length : 0;
            const tokenBecameValid = (!prevToken && !!nextToken) || (prevToken && nextToken && prevToken !== nextToken);
            const serversBecameUsable = prevEnabled === 0 && nextEnabled > 0;
            const onFilm = /\/letterboxd\.com\/(film|imdb|tmdb)\//.test(location.href);
            if (onFilm && (tokenBecameValid || serversBecameUsable)) {
                try { removeOnboardingCta(); } catch (_) { /* noop */ }
                // Let UI settle, then kick off a fresh resolve without requiring a refresh
                setTimeout(() => { try { main(); } catch (_) { /* noop */ } }, 0);
            }
        } catch (_) { /* noop */ }
    }

    async function openSettings() {
        showSettingsModal();
    }

    function showSettingsModal() {
        ensureSettingsModal();
        const modal = document.getElementById('lbxd-plex-settings');
        if (!modal) return;
        refreshSettingsModal();
        document.documentElement.classList.add('lbxd-plex-settings-open');
    }

    function hideSettingsModal() {
        const modal = document.getElementById('lbxd-plex-settings');
        if (!modal) return;
        document.documentElement.classList.remove('lbxd-plex-settings-open');
    }

    function injectStyles() {
        GM_addStyle(`
    p.service.-plex {
      position: relative;
      margin-left: 0 !important;
      padding-left: 40px !important;
    }
    /* Apply brand color only when enabled globally */
    html.lbxd-plex-colored p.service.-plex { background-color:#CF8E00 !important; }
    p.service.-plex .title .name { color:#fff !important; font-weight: 700; }
    p.service.-plex .brand { margin-left: 10px !important; }
    /* Hover settings gear — white and link-like */
    .lbxd-plex-gear { position:absolute; right:8px; top:50%; transform:translateY(-50%);
      display:inline-flex; align-items:center; justify-content:center;
      width:24px; height:24px; color:#fff; text-decoration:none; font-size:18px; line-height:1;
      font-variant-emoji: text; /* prefer monochrome glyphs when supported */
      font-family: "Segoe UI Symbol", "Noto Sans Symbols 2", "Apple Symbols", system-ui, sans-serif;
      opacity:0; transition:opacity .15s ease; pointer-events:none; z-index:1; }
    p.service.-plex:hover .lbxd-plex-gear,
    p.service.-plex:focus-within .lbxd-plex-gear { opacity:1; pointer-events:auto; }
    /* Settings modal */
    #lbxd-plex-settings { display:none; position:fixed; inset:0; z-index:99999; }
    html.lbxd-plex-settings-open #lbxd-plex-settings { display:block; }
    #lbxd-plex-settings .lbxd-plex-modal-backdrop { position:absolute; inset:0; background:rgba(0,0,0,.5); }
    #lbxd-plex-settings .lbxd-plex-modal { position:relative; max-width:680px; width:92%; margin:8vh auto; background:#121212; color:#eee; border:1px solid #333; border-radius:8px; box-shadow:0 8px 32px rgba(0,0,0,.6); }
    #lbxd-plex-settings .lbxd-plex-modal-header { display:flex; align-items:center; justify-content:space-between; padding:12px 14px; border-bottom:1px solid #2a2a2a; font-size:16px; }
    #lbxd-plex-settings .lbxd-plex-close { background:none; color:#ccc; border:0; font-size:18px; cursor:pointer; }
    #lbxd-plex-settings .lbxd-plex-close:hover { color:#fff; }
    #lbxd-plex-settings .lbxd-plex-modal-body { padding:12px 14px 16px; }
    #lbxd-plex-settings .lbxd-plex-row { display:flex; gap:8px; padding:6px 0; }
    #lbxd-plex-settings .lbxd-plex-row .k { width:110px; opacity:.8; }
    #lbxd-plex-settings .lbxd-plex-field { margin:10px 0; }
    #lbxd-plex-settings .lbxd-plex-field label { display:block; margin-bottom:6px; opacity:.85; }
    #lbxd-plex-settings .lbxd-hint { opacity:.8; margin-top:6px; }
    #lbxd-plex-settings .field-line { display:flex; gap:8px; align-items:center; margin: 8px 0; }
    #lbxd-plex-settings input[type="text"] { flex:1; min-width:120px; background:#1b1b1b; color:#eee; border:1px solid #333; border-radius:4px; padding:6px 8px; }
    #lbxd-plex-settings button { background:#2e6bdc; color:#fff; border:0; border-radius:4px; padding:6px 10px; cursor:pointer; }
    #lbxd-plex-settings button:hover { filter:brightness(1.05); }
    #lbxd-plex-settings button:disabled { opacity:.7; cursor:not-allowed; filter:none; }
    #lbxd-plex-settings .lbxd-primary { background:#ff9800; color:#111; font-weight:700; padding:10px 14px; font-size:15px; border-radius:6px; }
    #lbxd-plex-settings .lbxd-primary:hover { filter:brightness(1.08); }
    #lbxd-plex-settings .lbxd-primary:disabled { background:#555; color:#bbb; filter:none; }
    #lbxd-plex-settings .lbxd-plex-actions { display:flex; gap:8px; margin-top:8px; }
    /* Auth row: center Link button and place note underneath */
    #lbxd-plex-settings .lbxd-auth-center { display:flex; flex-direction:column; align-items:center; gap:8px; margin:10px 0; }
    #lbxd-plex-settings .lbxd-auth-center .lbxd-inline-note { text-align:center; opacity:.85; }
    #lbxd-plex-settings .lbxd-auth-status-row { display:flex; align-items:center; gap:8px; }
    #lbxd-plex-settings .lbxd-primary-lg { font-size:17px; padding:12px 18px; border-radius:8px; }

    /* Server block + layout */
    .lbxd-card { border:1px solid #333; border-radius:6px; padding:8px; margin:8px 0; }
    #lbxd-plex-settings .lbxd-server-block:hover { border-color:#484848; }
    #lbxd-plex-settings .lbxd-server-header { display:flex; align-items:center; justify-content:space-between; gap:8px; }
    #lbxd-plex-settings .lbxd-server-header-left { display:flex; align-items:center; gap:8px; }
    #lbxd-plex-settings .lbxd-server-header-right { display:flex; align-items:center; gap:8px; }
    .lbxd-gap-6 { gap:6px; }
    .lbxd-server-cache { margin-top:6px; font-size:.95em; opacity:.85; }
    /* Consistent checkbox color for lists */
    #lbxd-plex-settings input[type="checkbox"] { accent-color:#ff9800; width:16px; height:16px; }
    /* Server enabled checkbox alignment */
    #lbxd-plex-settings .lbxd-enabled-checkbox { transform: translateY(1px); margin-right:2px; }
    /* Libraries: standard checkbox + label, consistent spacing */
    #lbxd-plex-settings .lbxd-libs-list { display:flex; flex-wrap:wrap; gap:8px 14px; padding:6px 0; }
    #lbxd-plex-settings .lbxd-lib-item { display:inline-flex; align-items:center; gap:8px; margin:0; cursor:pointer; user-select:none; }
    #lbxd-plex-settings .lbxd-lib-item input { position:static; opacity:1; width:auto; height:auto; }
    #lbxd-plex-settings .lbxd-lib-item span { color:#eee; }
    .lbxd-mt-6 { margin-top:6px; } .lbxd-mt-8 { margin-top:8px; } .lbxd-mb-8 { margin-bottom:8px; }
    .lbxd-muted { opacity:.8; }
    .lbxd-subtle { opacity:.75; }
    .lbxd-error { color:#f66; }

    /* (no per-server color controls) */

    /* Cache viewer modal shares styling */
    #lbxd-cache-viewer { display:none; position:fixed; inset:0; z-index:99999; }
    html.lbxd-cache-viewer-open #lbxd-cache-viewer { display:block; }
    #lbxd-cache-viewer .lbxd-plex-modal-backdrop { position:absolute; inset:0; background:rgba(0,0,0,.5); }
    #lbxd-cache-viewer .lbxd-plex-modal { position:relative; max-width:780px; width:94%; margin:8vh auto; background:#121212; color:#eee; border:1px solid #333; border-radius:8px; box-shadow:0 8px 32px rgba(0,0,0,.6); }
    #lbxd-cache-viewer .lbxd-plex-modal-header { display:flex; align-items:center; justify-content:space-between; padding:12px 14px; border-bottom:1px solid #2a2a2a; font-size:16px; }
    #lbxd-cache-viewer .lbxd-plex-close { background:none; color:#ccc; border:0; font-size:18px; cursor:pointer; }
    #lbxd-cache-viewer .lbxd-plex-close:hover { color:#fff; }
    #lbxd-cache-viewer .lbxd-plex-modal-body { padding:12px 14px 16px; }
    #lbxd-cache-viewer .lbxd-plex-actions { display:flex; gap:8px; margin-top:8px; }
    .lbxd-cache-content { max-height:65vh; overflow:auto; white-space:normal; }
    .lbxd-flex-between { display:flex; justify-content:space-between; align-items:center; }

    /* Mask token input without using type=password to avoid save prompts */
    #lbxd-plex-settings input#lbxd-input-token[data-mask="1"] {
      -webkit-text-security: disc; /* Chrome, Safari */
      text-security: disc; /* non-standard, fallback */
      filter: none;
    }
    #lbxd-plex-settings input#lbxd-input-token[data-mask="0"] {
      -webkit-text-security: none;
      text-security: none;
    }
    
    /* Onboarding CTA (shown when not logged in) */
    #lbxd-plex-setup-cta { display:flex; justify-content:center; margin:12px 0 6px; }
    #lbxd-plex-setup-cta .lbxd-plex-setup-btn {
      background:#CF8E00; color:#111; border:0; border-radius:6px; font-weight:700; padding:10px 14px; cursor:pointer;
      box-shadow:0 2px 0 rgba(0,0,0,.2);
    }
    #lbxd-plex-setup-cta .lbxd-plex-setup-btn:hover { filter:brightness(1.08); }
  `);
    }

    function ensureSettingsModal() {
        if (document.getElementById('lbxd-plex-settings')) return;
        const wrap = document.createElement('div');
        wrap.id = 'lbxd-plex-settings';
        wrap.innerHTML = `
  <div class="lbxd-plex-modal-backdrop" data-close="1"></div>
  <div class="lbxd-plex-modal">
    <div class="lbxd-plex-modal-header">
      <strong>Letterboxd → Plex Settings</strong>
      <button class="lbxd-plex-close" title="Close" aria-label="Close">✕</button>
    </div>
    <div class="lbxd-plex-modal-body">
      <div class="lbxd-plex-field">
        <div class="lbxd-auth-center">
          <button id="lbxd-btn-link" class="lbxd-primary lbxd-primary-lg">Link Plex (OAuth)</button>
          <div class="lbxd-auth-status-row">
            <div class="lbxd-inline-note" id="lbxd-auth-note"></div>
            <button id="lbxd-btn-signout-inline" class="lbxd-inline-action" style="display:none">Sign out</button>
          </div>
        </div>
      </div>

      <div class="lbxd-plex-field">
        <div class="field-line">
          <button id="lbxd-btn-refresh-servers">Refresh servers</button>
          <button id="lbxd-btn-test-all">Test all</button>
          <button id="lbxd-btn-preload-all">Preload all</button>
        </div>
        <div id="lbxd-servers" class="lbxd-servers">Loading…</div>
        <div class="field-line">
          <button id="lbxd-btn-view-cache">View cache</button>
          <button id="lbxd-btn-clear-cache">Clear cache</button>
        </div>
        <div class="field-line">
          <label><input type="checkbox" id="lbxd-chk-colored-rows"> Highlight Plex rows in Watch box</label>
        </div>
      </div>
    </div>
  </div>`;
        document.body.appendChild(wrap);

        // Wiring
        wrap.querySelector('.lbxd-plex-close')?.addEventListener('click', hideSettingsModal);
        wrap.querySelector('.lbxd-plex-modal-backdrop')?.addEventListener('click', (e) => {
            if (e.target && e.target.getAttribute('data-close')) hideSettingsModal();
        });
        document.addEventListener('keydown', (e) => {
            if (e.key === 'Escape') hideSettingsModal();
        });

        wrap.querySelector('#lbxd-btn-save-token')?.addEventListener('click', () => {
            const v = String(document.getElementById('lbxd-input-token').value || '').trim();
            if (!v) return;
            const ns = getSettings();
            ns.token = v;
            setSettings(ns);
            alert('Token saved.');
            refreshSettingsModal();
        });

        wrap.querySelector('#lbxd-btn-link')?.addEventListener('click', async () => {
            await oauthLink();
            refreshSettingsModal();
        });

        wrap.querySelector('#lbxd-btn-signout-inline')?.addEventListener('click', () => {
            signOut();
            refreshSettingsModal();
        });

        wrap.querySelector('#lbxd-btn-toggle-token')?.addEventListener('click', () => {
            const inp = document.getElementById('lbxd-input-token');
            const btn = document.getElementById('lbxd-btn-toggle-token');
            if (!inp || !btn) return;
            const showing = btn.getAttribute('data-show') === '1';
            if (showing) {
                inp.setAttribute('data-mask', '1');
                btn.textContent = 'Show';
                btn.setAttribute('data-show', '0');
            } else {
                inp.setAttribute('data-mask', '0');
                btn.textContent = 'Hide';
                btn.setAttribute('data-show', '1');
            }
        });

        wrap.querySelector('#lbxd-btn-test-all')?.addEventListener('click', async () => {
            await testAllConnections();
        });

        wrap.querySelector('#lbxd-btn-refresh-servers')?.addEventListener('click', async () => {
            await renderServersInModal(true);
        });

        wrap.querySelector('#lbxd-btn-preload-all')?.addEventListener('click', async () => {
            await preloadAll();
        });

        // Global colored rows toggle
        wrap.querySelector('#lbxd-chk-colored-rows')?.addEventListener('change', () => {
            const ns = getSettings();
            const el = document.getElementById('lbxd-chk-colored-rows');
            ns.useColoredRows = !!(el && el.checked);
            setSettings(ns);
            try { applyGlobalColorSetting(); } catch (_) {}
        });

        wrap.querySelector('#lbxd-btn-view-cache')?.addEventListener('click', () => {
            openCacheViewer();
        });

        wrap.querySelector('#lbxd-btn-clear-cache')?.addEventListener('click', () => {
            clearCache();
            refreshSettingsModal();
        });

        wrap.querySelector('#lbxd-btn-signout')?.addEventListener('click', () => {
            signOut();
            refreshSettingsModal();
        });
    }

    function refreshSettingsModal() {
        const s = getSettings();
        const el = (id) => document.getElementById(id);
        const inTok = el('lbxd-input-token'); if (inTok) inTok.value = s.token || '';
        const note = el('lbxd-auth-note');
        if (note) note.textContent = s.token ? 'Account linked' : 'Not linked';
        const linkBtn = el('lbxd-btn-link');
        if (linkBtn) linkBtn.disabled = !!s.token;
        const signoutInline = el('lbxd-btn-signout-inline');
        if (signoutInline) signoutInline.style.display = s.token ? '' : 'none';
        const chk = el('lbxd-chk-colored-rows');
        if (chk) chk.checked = !!s.useColoredRows;
        try { applyGlobalColorSetting(); } catch (_) {}

        renderServersInModal();
    }

    async function renderServersInModal(forceRefresh = false) {
        const container = document.getElementById('lbxd-servers');
        if (!container) return;
        const s = getSettings();
        if (!s.token) {
            container.innerHTML = '<span class="lbxd-muted">Link your Plex account first.</span>';
            return;
        }

        container.textContent = 'Loading…';
        const frag = document.createDocumentFragment();

        // Unified list: all account servers, showing enabled ones first in configured order
        let accountDevices = [];
        try { accountDevices = await fetchAccountServers(forceRefresh); } catch (_) { accountDevices = []; }
        const configured = Array.isArray(s.servers) ? s.servers.slice() : [];
        const byId = new Map(accountDevices.map(d => [d.id, d]));

        // Build combined list: configured in order, then remaining account devices
        const seen = new Set();
        const items = [];
        for (const srv of configured) {
            seen.add(srv.id);
            items.push({
                source: 'configured',
                id: srv.id,
                name: srv.name || (byId.get(srv.id)?.name) || srv.id,
                uri: srv.uri,
                enabled: srv.enabled !== false,
                token: srv.token || null,
                _cfg: srv,
            });
        }
        for (const dev of accountDevices) {
            if (seen.has(dev.id)) continue;
            items.push({
                source: 'account',
                id: dev.id,
                name: dev.name || dev.id,
                uri: null,
                enabled: false,
                token: null,
                _dev: dev,
            });
        }

        if (!items.length) {
            const none = document.createElement('div');
            none.className = 'lbxd-muted';
            none.textContent = 'No servers found on this Plex account.';
            frag.appendChild(none);
        }

        items.forEach((it, idx) => {
            const block = document.createElement('div');
            block.className = 'lbxd-server-block lbxd-card';

            const header = document.createElement('div');
            header.className = 'lbxd-server-header';

            const left = document.createElement('div');
            left.className = 'lbxd-server-header-left';
            // Enabled checkbox (keep as checkmark)
            const en = document.createElement('input');
            en.type = 'checkbox';
            en.checked = !!it.enabled;
            en.title = 'Enabled';
            en.className = 'lbxd-enabled-checkbox';
            en.addEventListener('change', async () => {
                if (it.source === 'configured') {
                    const ns = getSettings();
                    const me = (ns.servers || []).find(x => x.id === it.id);
                    if (me) { me.enabled = !!en.checked; setSettings(ns); }
                    renderServersInModal();
                } else {
                    if (en.checked) {
                        // enabling new device
                        const btn = block.querySelector('button[data-role="enable"]');
                        if (btn) { btn.disabled = true; btn.textContent = 'Enabling…'; }
                        try { await enableDeviceServer(it._dev); }
                        finally { await renderServersInModal(); }
                    }
                }
            });
            const name = document.createElement('strong'); name.textContent = it.name;
            // Show ID and URL as tooltip over the name
            const tooltipParts = [`ID: ${it.id}`];
            tooltipParts.push(`URL: ${it.uri ? it.uri : (it.source === 'configured' ? 'no connection' : 'not configured')}`);
            name.title = tooltipParts.join('\n');
            left.append(en, name);

            const right = document.createElement('div');
            right.className = 'lbxd-server-header-right lbxd-gap-6';

            // Reorder only for enabled configured servers
            if (it.source === 'configured' && it.enabled) {
                const btnUp = document.createElement('button'); btnUp.textContent = '↑'; btnUp.title = 'Move up'; btnUp.disabled = idx === 0;
                btnUp.addEventListener('click', () => { moveServerUp(it.id); renderServersInModal(); });
                const btnDown = document.createElement('button'); btnDown.textContent = '↓'; btnDown.title = 'Move down'; btnDown.disabled = idx === configured.length - 1; // reorder space among configured
                btnDown.addEventListener('click', () => { moveServerDown(it.id); renderServersInModal(); });
                right.append(btnUp, btnDown);
            }

            if (it.source === 'configured') {
                const btnTest = document.createElement('button'); btnTest.textContent = 'Test';
                btnTest.addEventListener('click', async () => {
                    const ns = getSettings();
                    const me = (ns.servers || []).find(x => x.id === it.id);
                    if (me) await testConnectionForServer(me);
                });
                const btnPreload = document.createElement('button'); btnPreload.textContent = 'Preload';
                btnPreload.addEventListener('click', async () => {
                    btnPreload.disabled = true; btnPreload.textContent = 'Preloading…';
                    try { await preloadAllForServer(it._cfg || it, (prog) => { libsWrap.textContent = `Indexing ${prog.phase || 'library'}… ${prog.done}/${prog.total || '?'}`; }); }
                    finally { btnPreload.disabled = false; btnPreload.textContent = 'Preload'; renderServersInModal(); }
                });
                const btnRemove = document.createElement('button'); btnRemove.textContent = 'Remove';
                btnRemove.addEventListener('click', () => {
                    const ns = getSettings();
                    ns.servers = (ns.servers || []).filter(x => x.id !== it.id);
                    setSettings(ns);
                    renderServersInModal();
                });
                right.append(btnTest, btnPreload, btnRemove);
            } else {
                // account-only row: show explicit Enable button as well
                const btn = document.createElement('button'); btn.textContent = 'Enable'; btn.setAttribute('data-role', 'enable');
                btn.addEventListener('click', async () => {
                    btn.disabled = true; btn.textContent = 'Enabling…';
                    try { await enableDeviceServer(it._dev); }
                    finally { await renderServersInModal(); }
                });
                right.append(btn);
            }

            header.append(left, right);

            // Cache stats line
            const cacheLine = document.createElement('div');
            cacheLine.className = 'lbxd-server-cache';
            if (it.source === 'configured') {
                const sIx = getServerIndex(it.id);
                const preloaded = uniqueRatingKeyCount(sIx.byImdb, sIx.byTmdb);
                const matched = uniqueRatingKeyCount(sIx.bySlug);
                cacheLine.textContent = `Cache — Preloaded: ${preloaded} • Matched: ${matched}`;
            } else {
                cacheLine.textContent = '';
            }

            const libsWrap = document.createElement('div');
            libsWrap.className = 'libs lbxd-libs-list lbxd-mt-8';
            if (it.source === 'configured') {
                libsWrap.textContent = 'Loading libraries…';
            } else {
                libsWrap.classList.add('lbxd-muted');
                libsWrap.textContent = 'Enable to configure libraries.';
            }

            block.append(header, cacheLine, libsWrap);
            frag.appendChild(block);

            // Preload libraries for configured servers
            if (it.source === 'configured') {
                loadLibrariesForServer(it._cfg || it, libsWrap).then(() => {
                    libsWrap.setAttribute('data-loaded', '1');
                }).catch(() => {
                    /* handled in loader */
                });
            }
        });

        container.innerHTML = '';
        container.appendChild(frag);
    }

    // Cache for fetched account servers
    let __lbxd_cachedAccountServers = null;
    let __lbxd_cachedAt = 0;
    async function fetchAccountServers(force = false) {
        const s = getSettings();
        if (!s.token) return [];
        if (!force && __lbxd_cachedAccountServers && Date.now() - __lbxd_cachedAt < 60 * 1000) {
            return __lbxd_cachedAccountServers;
        }
        const res = await tmFetch({
            url: `https://plex.tv/api/resources?includeHttps=1&includeRelay=1&X-Plex-Token=${encodeURIComponent(s.token)}`,
            headers: { 'Accept': 'application/xml', 'X-Plex-Product': APP.product, 'X-Plex-Client-Identifier': APP.clientId }
        });
        const doc = xml(res.responseText);
        const devices = Array.from(doc.querySelectorAll('Device[provides*="server"]')).map(d => ({
            id: d.getAttribute('clientIdentifier'),
            name: d.getAttribute('name'),
            accessToken: d.getAttribute('accessToken') || null,
            connections: Array.from(d.querySelectorAll('Connection')),
        }));
        __lbxd_cachedAccountServers = devices;
        __lbxd_cachedAt = Date.now();
        return devices;
    }

    async function enableDeviceServer(dev) {
        const s = getSettings();
        const tokenForConn = dev.accessToken || s.token;
        const uri = await chooseBestConnection(dev.connections, tokenForConn);
        if (!uri) {
            alert('Could not reach any connection for this server. Ensure Remote Access or Relay is enabled.');
            return false;
        }
        const ns = getSettings();
        if (!Array.isArray(ns.servers)) ns.servers = [];
        if (ns.servers.some(x => x.id === dev.id)) return true;
        ns.servers.push({ id: dev.id, name: dev.name, uri, enabled: true, includeLibraries: null, token: dev.accessToken || null });
        ns.lastAuthCheck = Date.now();
        setSettings(ns);
        return true;
    }

    function moveServerUp(id) {
        const ns = getSettings();
        const arr = Array.isArray(ns.servers) ? ns.servers : [];
        const i = arr.findIndex(x => x.id === id);
        if (i > 0) {
            const [it] = arr.splice(i, 1);
            arr.splice(i - 1, 0, it);
            ns.servers = arr;
            setSettings(ns);
        }
    }
    function moveServerDown(id) {
        const ns = getSettings();
        const arr = Array.isArray(ns.servers) ? ns.servers : [];
        const i = arr.findIndex(x => x.id === id);
        if (i !== -1 && i < arr.length - 1) {
            const [it] = arr.splice(i, 1);
            arr.splice(i + 1, 0, it);
            ns.servers = arr;
            setSettings(ns);
        }
    }
    

    async function loadLibrariesForServer(srv, container) {
        if (!container) return;
        try { container.classList.add('lbxd-libs-list'); } catch (_) { /* noop */ }
        const s = getSettings();
        const token = srv?.token || s.token;
        if (!token || !srv.uri) {
            container.innerHTML = '<span class="lbxd-muted">Set token and connection for this server.</span>';
            return;
        }
        container.textContent = 'Loading…';
        try {
            const r = await tmFetch({
                url: `${srv.uri}/library/sections?X-Plex-Token=${encodeURIComponent(token)}`,
                headers: plexHeaders(),
                timeout: 10000,
            });
            const doc = xml(r.responseText);
            const dirs = Array.from(doc.querySelectorAll('MediaContainer > Directory'))
                .filter(d => {
                    const t = (d.getAttribute('type') || '').toLowerCase();
                    return t === 'movie' || t === 'show' || t === '1' || t === '2';
                });
            const sel = new Set(String(srv.includeLibraries || '').split(',').map(x => x.trim()).filter(Boolean));
            if (!dirs.length) {
                container.innerHTML = '<span class="lbxd-muted">No movie/TV libraries found for this server.</span>';
                return;
            }
            const allSelected = sel.size === 0 || sel.size === dirs.length;
            const frag = document.createDocumentFragment();
            dirs.forEach(d => {
                const id = d.getAttribute('key');
                const name = d.getAttribute('title') || `Section ${id}`;
                const line = document.createElement('label');
                line.className = 'lbxd-lib-item';
                const cb = document.createElement('input');
                cb.type = 'checkbox';
                cb.setAttribute('data-lib', id);
                cb.checked = allSelected || sel.has(id);
                // Save immediately on toggle
                cb.addEventListener('change', () => {
                    const cbs = Array.from(container.querySelectorAll('input[type="checkbox"][data-lib]'));
                    const selected = cbs.filter(x => x.checked).map(x => x.getAttribute('data-lib'));
                    const ns = getSettings();
                    const me = (ns.servers || []).find(x => x.id === srv.id);
                    if (me) {
                        me.includeLibraries = (selected.length && selected.length !== cbs.length) ? selected.join(',') : null;
                        setSettings(ns);
                    }
                });
                const span = document.createElement('span');
                span.textContent = name;
                span.title = `Section ID: ${id}`;
                line.append(cb, span);
                frag.appendChild(line);
            });
            container.innerHTML = '';
            container.appendChild(frag);
        } catch (e) {
            console.error('[Plex opener] loadLibrariesForServer failed:', e);
            container.innerHTML = '<span class="lbxd-error">Failed to load libraries. Check connection.</span>';
        }
    }

    async function signOut() {
        const ns = getSettings();
        ns.token = null;
        setSettings(ns);
        alert('Signed out from Plex in this userscript.');
        try { ensureOnboardingCta(); } catch (_) { /* noop */ }
    }

    function clearCache() {
        GM_setValue('indexCache', null);
        const s = getSettings();
        s.lastCacheBuild = 0;
        s.lastUpdatedAtBySection = {};
        setSettings(s);
        alert('Plex index cache cleared.');
    }

    /**********************
   * Plex OAuth PIN flow (auth token)
   * Docs & examples:
   *  - Request PIN + complete via app.plex.tv/auth (PIN flow)
   **********************/
    async function oauthLink() {
        const clientId = APP.clientId;
        // 1) Create a PIN
        const resp = await tmFetch({
            method: 'POST',
            url: 'https://plex.tv/api/v2/pins?strong=true',
            headers: {
                'Accept': 'application/json',
                'Content-Type': 'application/json',
                'X-Plex-Product': APP.product,
                'X-Plex-Client-Identifier': clientId,
            },
            data: JSON.stringify({ strong: true })
        });
        const pin = JSON.parse(resp.responseText);
        const pinId = pin.id;
        const code = pin.code;

        // 2) Send user to Plex to approve
        const authUrl = `https://app.plex.tv/auth#?clientID=${encodeURIComponent(clientId)}&code=${encodeURIComponent(code)}&context%5Bdevice%5D%5Bproduct%5D=${encodeURIComponent(APP.product)}`;
        GM_openInTab(authUrl, { active: true, insert: true });

        // 3) Poll for token (up to ~2 minutes)
        let token = null;
        for (let i = 0; i < 120; i++) {
            await sleep(1000);
            const status = await tmFetch({
                method: 'GET',
                url: `https://plex.tv/api/v2/pins/${pinId}`,
                headers: {
                    'Accept': 'application/json',
                    'X-Plex-Client-Identifier': clientId,
                }
            });
            const obj = JSON.parse(status.responseText);
            if (obj.authToken) {
                token = obj.authToken;
                break;
            }
            if (obj.expiresAt && Date.now() / 1000 > obj.expiresAt) break;
        }

        if (!token) {
            alert('Plex authorization timed out. Try again.');
            return;
        }

        const ns = getSettings();
        ns.token = token;
        setSettings(ns);
        alert('Plex account linked! Now pick a server.');
        try { removeOnboardingCta(); } catch (_) { /* noop */ }
    }

    function isRfc1918Host(hostname) {
        // Host may look like "108-39-42-168.hash.plex.direct" or "192-168-1-10.hash.plex.direct"
        const m = String(hostname).match(/^(\d{1,3}(?:-\d{1,3}){3})\./);
        const ip = m ? m[1].replace(/-/g, '.') : hostname;
        if (/^10\.\d+\.\d+\.\d+$/.test(ip)) return true;
        if (/^172\.(1[6-9]|2\d|3[0-1])\.\d+\.\d+$/.test(ip)) return true;
        if (/^192\.168\.\d+\.\d+$/.test(ip)) return true;
        if (/^127\.\d+\.\d+\.\d+$/.test(ip)) return true;
        return false;
    }

    async function testConn(uri, token) {
        try {
            const r = await tmFetch({
                url: `${uri}/identity?X-Plex-Token=${encodeURIComponent(token)}`,
                headers: plexHeaders(),
                timeout: 7000,
            });
            return r.status >= 200 && r.status < 400 && /<MediaContainer/.test(r.responseText);
        } catch {
            return false;
        }
    }

    /**
 * Build a priority-sorted list and **only** return a URI that passes /identity.
 * If nothing passes, return null (don’t hand back a known-bad URI).
 * Priority:
 *  1) https + relay
 *  2) https + plex.direct + remote-friendly
 *  3) https + other remote-friendly
 *  4) http + remote-friendly
 *  5) local-only (RFC1918) — last resort and only if it actually passes test
 */
    async function chooseBestConnection(conns, token) {
        const scored = conns.map(c => {
            const uri = c.getAttribute('uri');
            const u = new URL(uri);
            const protocol = c.getAttribute('protocol');         // "http"|"https"
            const relay = c.getAttribute('relay') === '1';
            const isPlexDirect = /\.plex\.direct$/i.test(u.hostname);
            const remoteFriendly = !isRfc1918Host(u.hostname);

            let score = 0;
            if (protocol === 'https' && relay) score = 100;
            else if (protocol === 'https' && isPlexDirect && remoteFriendly) score = 90;
            else if (protocol === 'https' && remoteFriendly) score = 80;
            else if (protocol === 'http' && remoteFriendly) score = 60;
            else score = 10; // local-only/LAN

            return { uri, score, relay, remoteFriendly };
        }).sort((a, b) => b.score - a.score);

        for (const c of scored) {
            if (await testConn(c.uri, token)) return c.uri;
        }
        return null;
    }

    /**********************
   * Choose server + connection (plex.tv/resources)
   *    – verifies you actually have access to the server
   *    – chooses a good https *.plex.direct URI
   * Docs:
   *  - /api/resources listing servers
   **********************/
    

    /**********************
   * Build Plex links for a ratingKey
   *  - Plex Web: https://app.plex.tv/desktop/#!/server/{serverId}/details?key=/library/metadata/{ratingKey}
   **********************/
    function plexLinks(ratingKey, serverId) {
        const key = `/library/metadata/${ratingKey}`;
        const web = `https://app.plex.tv/desktop#!/server/${encodeURIComponent(serverId)}/details?key=${encodeURIComponent(key)}`;
        return { web };
    }

    async function fetchLibraryInfo(ctx, ratingKey) {
        try {
            const s = getSettings();
            const token = ctx?.token || s.token;
            if (!token || !ctx?.uri) return { sectionId: null, sectionTitle: null };
            const url = `${ctx.uri}/library/metadata/${encodeURIComponent(ratingKey)}?X-Plex-Token=${encodeURIComponent(token)}`;
            const r = await tmFetch({ url, headers: plexHeaders(), timeout: 8000 });
            const doc = xml(r.responseText);
            const mc = doc.querySelector('MediaContainer');
            const sectionId = mc?.getAttribute('librarySectionID') || doc.querySelector('[librarySectionID]')?.getAttribute('librarySectionID') || null;
            const sectionTitle = mc?.getAttribute('librarySectionTitle') || doc.querySelector('[librarySectionTitle]')?.getAttribute('librarySectionTitle') || null;
            return { sectionId, sectionTitle };
        } catch {
            return { sectionId: null, sectionTitle: null };
        }
    }

    /**********************
   * Fast item lookup + caching
   *  - Prefer GUID matches (IMDb/TMDb) when available
   *  - Fallback to title+year
   *  - Cache each resolution and (optionally) warm with Recently Added
   * Relevant endpoints:
   *  - /library/sections (to check updatedAt)
   *  - /search?query=... (cross-library search)
   **********************/
    function getIndex() {
        return GM_getValue('indexCache') || { servers: {} };
    }
    function saveIndex(ix) { GM_setValue('indexCache', ix); }
    function getServerIndex(serverId) {
        const ix = getIndex();
        if (!ix.servers) ix.servers = {};
        if (!ix.servers[serverId]) ix.servers[serverId] = { byImdb: {}, byTmdb: {}, byTitleYear: {}, bySlug: {}, builtAt: 0 };
        return ix.servers[serverId];
    }
    function setServerIndex(serverId, serverIx) {
        const ix = getIndex();
        if (!ix.servers) ix.servers = {};
        ix.servers[serverId] = serverIx;
        saveIndex(ix);
    }
    function shouldRebuildIndex(serverId) {
        const sIx = getServerIndex(serverId);
        return (Date.now() - (sIx.builtAt || 0)) > APP.cacheTTLms;
    }

    async function ensureWarmIndex() {
        try {
            const s = getSettings();
            if (!s.token) return;
            const servers = (s.servers || []).filter(x => x.enabled !== false && x.uri);
            if (!servers.length) return;
            if (!APP.warmRecentlyAdded) return;

            for (const srv of servers) {
                if (!shouldRebuildIndex(srv.id)) continue;
                try {
                    const token = srv?.token || s.token;
                    const url = `${srv.uri}/hubs/home/recentlyAdded?count=${APP.warmCount}&includeGuids=1&X-Plex-Token=${encodeURIComponent(token)}`;
                    const r = await tmFetch({ url, headers: plexHeaders() });
                    const doc = xml(r.responseText);
                    const sIx = getServerIndex(srv.id);
                    doc.querySelectorAll('Metadata[type="movie"], Metadata[type="show"]').forEach(item => {
                        const rk = item.getAttribute('ratingKey');
                        const guids = Array.from(item.querySelectorAll('Guid')).map(g => g.getAttribute('id') || '');
                        // include fallback to `guid` attribute on item
                        const guidAttr = item.getAttribute('guid');
                        if (guidAttr) guids.push(guidAttr);
                        for (const g of guids) {
                            const lower = g.toLowerCase();
                            const imdb = lower.match(/imdb:\/\/(tt\d+)/) || lower.match(/com\.plexapp\.agents\.imdb:\/\/(tt\d+)/);
                            if (imdb) sIx.byImdb[imdb[1]] = rk;
                            const tmdb = lower.match(/tmdb:\/\/(\d+)/) || lower.match(/com\.plexapp\.agents\.tmdb:\/\/(\d+)/) || lower.match(/com\.plexapp\.agents\.themoviedb:\/\/(\d+)/);
                            if (tmdb) sIx.byTmdb[tmdb[1]] = rk;
                        }
                    });
                    sIx.builtAt = Date.now();
                    setServerIndex(srv.id, sIx);
                } catch (_) { /* ignore per-server failure */ }
            }
        } catch (_) {
            /* non-fatal */
        }
    }

    function plexHeaders() {
        const s = getSettings();
        return {
            'Accept': 'application/xml',
            'X-Plex-Product': APP.product,
            'X-Plex-Version': '1.0',
            'X-Plex-Client-Identifier': APP.clientId,
            // token also in query param; many PMS endpoints accept either
        };
    }

    

    // Count unique ratingKeys across one or more key→ratingKey maps
    function uniqueRatingKeyCount(...maps) {
        const set = new Set();
        for (const m of maps) {
            if (!m) continue;
            for (const v of Object.values(m)) {
                if (v != null && v !== '') set.add(String(v));
            }
        }
        return set.size;
    }

    async function preloadMoviesForServer(srv, onProgress) {
        const s = getSettings();
        const token = srv?.token || s.token;
        if (!token || !srv?.uri) { alert(`Set token and connection for server ${srv?.name || srv?.id || ''}.`); return false; }

        // Discover movie sections, respecting includeLibraries if set
        let sections = [];
        try {
            const r = await tmFetch({ url: `${srv.uri}/library/sections?X-Plex-Token=${encodeURIComponent(token)}`, headers: plexHeaders(), timeout: 15000 });
            const doc = xml(r.responseText);
            const dirs = Array.from(doc.querySelectorAll('MediaContainer > Directory'))
                .filter(d => {
                    const t = (d.getAttribute('type') || '').toLowerCase();
                    return t === 'movie' || t === '1';
                })
                .map(d => ({ id: d.getAttribute('key'), title: d.getAttribute('title') || d.getAttribute('key') }));
            const whitelist = String(srv.includeLibraries || '').split(',').map(x => x.trim()).filter(Boolean);
            sections = whitelist.length ? dirs.filter(d => whitelist.includes(d.id)) : dirs;
        } catch (e) {
            alert(`Failed to list libraries for ${srv.name || srv.id}.`);
            return false;
        }

        if (!sections.length) { alert(`No movie libraries to preload on ${srv.name || srv.id}.`); return false; }

        const sIx = getServerIndex(srv.id);
        const pageSize = 200;
        let totalAcross = 0, doneAcross = 0;

        for (const sec of sections) {
            let start = 0; let total = null; let keepGoing = true;
            while (keepGoing) {
                try {
                    const url = `${srv.uri}/library/sections/${encodeURIComponent(sec.id)}/all?type=1&includeGuids=1&X-Plex-Container-Start=${start}&X-Plex-Container-Size=${pageSize}&X-Plex-Token=${encodeURIComponent(token)}`;
                    const r = await tmFetch({ url, headers: plexHeaders(), timeout: 20000 });
                    const doc = xml(r.responseText);
                    const mc = doc.querySelector('MediaContainer');
                    const items = Array.from(doc.querySelectorAll('Video[type="movie"], Video')); // some servers omit type attr; search all Video
                    if (total == null) {
                        const ts = parseInt(mc?.getAttribute('totalSize') || mc?.getAttribute('size') || items.length, 10);
                        total = isNaN(ts) ? null : ts;
                        if (total != null) totalAcross += total;
                    }

                    for (const item of items) {
                        const rk = item.getAttribute('ratingKey');
                        const guids = Array.from(item.querySelectorAll('Guid')).map(g => (g.getAttribute('id') || '')).filter(Boolean);
                        const guidAttr = item.getAttribute('guid');
                        if (guidAttr) guids.push(guidAttr);
                        const ids = extractIdsFromGuidStrings(guids);
                        if (ids.imdbId) sIx.byImdb[ids.imdbId] = rk;
                        if (ids.tmdbId) sIx.byTmdb[ids.tmdbId] = rk;
                    }
                    doneAcross += items.length;
                    if (typeof onProgress === 'function') onProgress({ section: sec.id, done: doneAcross, total: totalAcross || undefined });
                    // Persist progress incrementally
                    setServerIndex(srv.id, sIx);

                    if (!items.length) { keepGoing = false; break; }
                    start += items.length;
                    if (total != null && start >= total) { keepGoing = false; }
                } catch (e) {
                    // Stop this section on errors but keep others
                    break;
                }
            }
        }

        sIx.builtAt = Date.now();
        setServerIndex(srv.id, sIx);
        return true;
    }

    async function preloadShowsForServer(srv, onProgress) {
        const s = getSettings();
        const token = srv?.token || s.token;
        if (!token || !srv?.uri) { alert(`Set token and connection for server ${srv?.name || srv?.id || ''}.`); return false; }

        // Discover show sections, respecting includeLibraries if set
        let sections = [];
        try {
            const r = await tmFetch({ url: `${srv.uri}/library/sections?X-Plex-Token=${encodeURIComponent(token)}`, headers: plexHeaders(), timeout: 15000 });
            const doc = xml(r.responseText);
            const dirs = Array.from(doc.querySelectorAll('MediaContainer > Directory'))
                .filter(d => {
                    const t = (d.getAttribute('type') || '').toLowerCase();
                    return t === 'show' || t === '2';
                })
                .map(d => ({ id: d.getAttribute('key'), title: d.getAttribute('title') || d.getAttribute('key') }));
            const whitelist = String(srv.includeLibraries || '').split(',').map(x => x.trim()).filter(Boolean);
            sections = whitelist.length ? dirs.filter(d => whitelist.includes(d.id)) : dirs;
        } catch (e) {
            alert(`Failed to list libraries for ${srv.name || srv.id}.`);
            return false;
        }

        if (!sections.length) { alert(`No TV libraries to preload on ${srv.name || srv.id}.`); return false; }

        const sIx = getServerIndex(srv.id);
        const pageSize = 200;
        let totalAcross = 0, doneAcross = 0;

        for (const sec of sections) {
            let start = 0; let total = null; let keepGoing = true;
            while (keepGoing) {
                try {
                    const url = `${srv.uri}/library/sections/${encodeURIComponent(sec.id)}/all?type=2&includeGuids=1&X-Plex-Container-Start=${start}&X-Plex-Container-Size=${pageSize}&X-Plex-Token=${encodeURIComponent(token)}`;
                    const r = await tmFetch({ url, headers: plexHeaders(), timeout: 20000 });
                    const doc = xml(r.responseText);
                    const mc = doc.querySelector('MediaContainer');
                    // For shows, PMS usually returns Directory nodes; some servers omit type attr
                    const items = Array.from(doc.querySelectorAll('Directory[type="show"], Directory'));
                    if (total == null) {
                        const ts = parseInt(mc?.getAttribute('totalSize') || mc?.getAttribute('size') || items.length, 10);
                        total = isNaN(ts) ? null : ts;
                        if (total != null) totalAcross += total;
                    }

                    for (const item of items) {
                        const rk = item.getAttribute('ratingKey');
                        const guids = Array.from(item.querySelectorAll('Guid')).map(g => (g.getAttribute('id') || '')).filter(Boolean);
                        const guidAttr = item.getAttribute('guid');
                        if (guidAttr) guids.push(guidAttr);
                        const ids = extractIdsFromGuidStrings(guids);
                        if (ids.imdbId) sIx.byImdb[ids.imdbId] = rk;
                        if (ids.tmdbId) sIx.byTmdb[ids.tmdbId] = rk;
                    }
                    doneAcross += items.length;
                    if (typeof onProgress === 'function') onProgress({ section: sec.id, done: doneAcross, total: totalAcross || undefined });
                    // Persist progress incrementally
                    setServerIndex(srv.id, sIx);

                    if (!items.length) { keepGoing = false; break; }
                    start += items.length;
                    if (total != null && start >= total) { keepGoing = false; }
                } catch (e) {
                    // Stop this section on errors but keep others
                    break;
                }
            }
        }

        sIx.builtAt = Date.now();
        setServerIndex(srv.id, sIx);
        return true;
    }

    async function preloadAllForServer(srv, onProgress) {
        // Movies first, then shows. Pass phase to onProgress for clear UI.
        await preloadMoviesForServer(srv, (p) => {
            if (typeof onProgress === 'function') onProgress(Object.assign({}, p, { phase: 'movies' }));
        });
        await preloadShowsForServer(srv, (p) => {
            if (typeof onProgress === 'function') onProgress(Object.assign({}, p, { phase: 'shows' }));
        });
    }

    async function preloadAll() {
        const s = getSettings();
        if (!s.token) { alert('Link Plex first.'); return; }
        const servers = (s.servers || []).filter(x => x.enabled !== false && x.uri);
        if (!servers.length) { alert('No servers configured.'); return; }
        for (const srv of servers) {
            try {
                await preloadAllForServer(srv);
            } catch (_) { /* continue next */ }
        }
        alert('Preload complete. Open View cache to inspect.');
    }

    async function preloadAllShows() {
        const s = getSettings();
        if (!s.token) { alert('Link Plex first.'); return; }
        const servers = (s.servers || []).filter(x => x.enabled !== false && x.uri);
        if (!servers.length) { alert('No servers configured.'); return; }
        for (const srv of servers) {
            try {
                await preloadShowsForServer(srv);
            } catch (_) { /* continue next */ }
        }
        alert('Preload complete. Open View cache to inspect.');
    }

    async function preloadAllMovies() {
        const s = getSettings();
        if (!s.token) { alert('Link Plex first.'); return; }
        const servers = (s.servers || []).filter(x => x.enabled !== false && x.uri);
        if (!servers.length) { alert('No servers configured.'); return; }
        for (const srv of servers) {
            try {
                await preloadMoviesForServer(srv);
            } catch (_) { /* continue next */ }
        }
        alert('Preload complete. Open View cache to inspect.');
    }

    function openCacheViewer() { ensureCacheViewer(); renderCacheViewer(); const m = document.getElementById('lbxd-cache-viewer'); if (m) { document.documentElement.classList.add('lbxd-cache-viewer-open'); } }
    function hideCacheViewer() { const m = document.getElementById('lbxd-cache-viewer'); if (m) { document.documentElement.classList.remove('lbxd-cache-viewer-open'); } }

    function ensureCacheViewer() {
        if (document.getElementById('lbxd-cache-viewer')) return;
        const wrap = document.createElement('div');
        wrap.id = 'lbxd-cache-viewer';
        wrap.innerHTML = `
  <div class="lbxd-plex-modal-backdrop" data-close="1"></div>
  <div class="lbxd-plex-modal">
    <div class="lbxd-plex-modal-header">
      <strong>Plex Cache</strong>
      <button class="lbxd-plex-close" title="Close" aria-label="Close">✕</button>
    </div>
    <div class="lbxd-plex-modal-body">
      <div id="lbxd-cache-content" class="lbxd-cache-content">
        Loading…
      </div>
      <div class="lbxd-plex-actions">
        <button id="lbxd-btn-export-cache">Export all (JSON)</button>
      </div>
    </div>
  </div>`;
        document.body.appendChild(wrap);
        wrap.querySelector('.lbxd-plex-close')?.addEventListener('click', hideCacheViewer);
        wrap.querySelector('.lbxd-plex-modal-backdrop')?.addEventListener('click', (e) => { if (e.target && e.target.getAttribute('data-close')) hideCacheViewer(); });
        document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideCacheViewer(); });
        wrap.querySelector('#lbxd-btn-export-cache')?.addEventListener('click', () => {
            try {
                const ix = getIndex();
                exportIndexAsJson(ix, 'plex_letterboxd_cache.json');
            } catch (_) { /* noop */ }
        });
    }

    function renderCacheViewer() {
        const el = document.getElementById('lbxd-cache-content'); if (!el) return;
        const ix = getIndex();
        const s = getSettings();
        const frag = document.createDocumentFragment();
        const title = document.createElement('div'); title.className = 'lbxd-mb-8';
        const totalServers = ix && ix.servers ? Object.keys(ix.servers).length : 0;
        title.textContent = `Servers cached: ${totalServers}`;
        frag.appendChild(title);

        const list = document.createElement('div');
        for (const [sid, sIx] of Object.entries(ix.servers || {})) {
            const row = document.createElement('div'); row.className = 'lbxd-card';
            const name = (s.servers || []).find(x => x.id === sid)?.name || sid;
            const h = document.createElement('div'); h.className = 'lbxd-flex-between';
            const left = document.createElement('div'); left.innerHTML = `<strong>${name}</strong> <span class="lbxd-subtle">(${sid})</span>`;
            const right = document.createElement('div');
            const btn = document.createElement('button'); btn.textContent = 'Export'; btn.addEventListener('click', () => {
                const small = { [sid]: sIx };
                exportIndexAsJson(small, `plex_cache_${sid}.json`);
            });
            right.appendChild(btn);
            h.append(left, right);
            const stats = document.createElement('div'); stats.className = 'lbxd-server-cache';
            const preloaded = uniqueRatingKeyCount(sIx.byImdb, sIx.byTmdb);
            const matched = uniqueRatingKeyCount(sIx.bySlug);
            stats.textContent = `Preloaded: ${preloaded} • Matched: ${matched}`;
            row.append(h, stats);
            list.appendChild(row);
        }
        frag.appendChild(list);
        el.innerHTML = '';
        el.appendChild(frag);
    }

    function exportIndexAsJson(obj, filename) {
        try {
            const json = JSON.stringify(obj, null, 2);
            const blob = new Blob([json], { type: 'application/json' });
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url;
            a.download = filename || 'plex_cache.json';
            document.body.appendChild(a);
            a.click();
            setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 1000);
        } catch (e) {
            try { alert('Failed to export cache.'); } catch (_) {}
        }
    }

    async function plexSearchMovieByQuery(ctx, query) {
        const s = getSettings();
        const token = ctx?.token || s.token;
        const url = `${ctx.uri}/search?query=${encodeURIComponent(query)}&includeGuids=1&X-Plex-Token=${encodeURIComponent(token)}`;
        const r = await tmFetch({ url, headers: plexHeaders() });
        return xml(r.responseText);
    }

    // Prefer exact GUID lookups (IMDb/TMDb) before doing broad text search
    async function plexFindByGuid(ctx, { imdbId, tmdbId, kind = 'movie' }) {
        const s = getSettings();
        const token = ctx?.token || s.token;
        if (!token || !ctx?.uri) return null;
        const searchType = kind === 'movie' ? 1 : 2;

        const guidVariants = [];
        if (imdbId) {
            guidVariants.push(`imdb://${imdbId}`);
            guidVariants.push(`com.plexapp.agents.imdb://${imdbId}`);
        }
        if (tmdbId) {
            guidVariants.push(`tmdb://${tmdbId}`);
            guidVariants.push(`com.plexapp.agents.tmdb://${tmdbId}`);
            guidVariants.push(`com.plexapp.agents.themoviedb://${tmdbId}`);
        }
        if (!guidVariants.length) return null;

        const libs = String(ctx.includeLibraries || '').split(',').map(x => x.trim()).filter(Boolean);
        const tryDoc = (doc) => {
            const first = doc.querySelector('MediaContainer > Video');
            return first ? first.getAttribute('ratingKey') || null : null;
        };

        for (const guid of guidVariants) {
            // Library-scoped exact GUID filters
            if (libs.length) {
                for (const sec of libs) {
                    try {
                        const url = `${ctx.uri}/library/sections/${encodeURIComponent(sec)}/search?type=${searchType}&guid=${encodeURIComponent(guid)}&includeGuids=1&X-Plex-Token=${encodeURIComponent(token)}`;
                        const r = await tmFetch({ url, headers: plexHeaders(), timeout: 12000 });
                        const doc = xml(r.responseText);
                        const rk = tryDoc(doc);
                        if (rk) return rk;
                    } catch (_) { /* continue */ }
                }
            }
            // Cross-library search endpoints that support guid filter on many servers
            try {
                const url = `${ctx.uri}/library/search?guid=${encodeURIComponent(guid)}&includeGuids=1&X-Plex-Token=${encodeURIComponent(token)}`;
                const r = await tmFetch({ url, headers: plexHeaders(), timeout: 12000 });
                const doc = xml(r.responseText);
                const rk = tryDoc(doc);
                if (rk) return rk;
            } catch (_) { /* keep trying next variant */ }
            // Fallback: some servers expose non-library search path
            try {
                const url = `${ctx.uri}/search?guid=${encodeURIComponent(guid)}&includeGuids=1&X-Plex-Token=${encodeURIComponent(token)}`;
                const r = await tmFetch({ url, headers: plexHeaders(), timeout: 12000 });
                const doc = xml(r.responseText);
                const rk = tryDoc(doc);
                if (rk) return rk;
            } catch (_) { /* keep trying next variant */ }
        }
        return null;
    }

    async function resolveRatingKeyOnServer(ctx, { imdbId, tmdbId, title, year, kind = 'movie', slug = null }) {
        // Normalize ID cases for consistent cache keys
        imdbId = imdbId ? String(imdbId).toLowerCase() : null;
        tmdbId = tmdbId ? String(tmdbId) : null;
        const sIx = getServerIndex(ctx.id);
        // 1) Slug cache (Matched)
        if (slug && sIx.bySlug && sIx.bySlug[slug]) return sIx.bySlug[slug];
        // 2) Preloaded cache (IMDb/TMDb)
        if (imdbId && sIx.byImdb[imdbId]) {
            const rk = sIx.byImdb[imdbId];
            if (slug) {
                if (!sIx.bySlug) sIx.bySlug = {};
                sIx.bySlug[slug] = rk;
                sIx.builtAt = Date.now();
                setServerIndex(ctx.id, sIx);
            }
            return rk;
        }
        if (tmdbId && sIx.byTmdb[tmdbId]) {
            const rk = sIx.byTmdb[tmdbId];
            if (slug) {
                if (!sIx.bySlug) sIx.bySlug = {};
                sIx.bySlug[slug] = rk;
                sIx.builtAt = Date.now();
                setServerIndex(ctx.id, sIx);
            }
            return rk;
        }
        // Try exact GUID matches via Plex API (preferred over text search)
        if (imdbId || tmdbId) {
            try {
                const rk = await plexFindByGuid(ctx, { imdbId, tmdbId, kind });
                if (rk) {
                    if (imdbId) sIx.byImdb[imdbId] = rk;
                    if (tmdbId) sIx.byTmdb[tmdbId] = rk;
                    if (slug) { if (!sIx.bySlug) sIx.bySlug = {}; sIx.bySlug[slug] = rk; }
                    sIx.builtAt = Date.now();
                    setServerIndex(ctx.id, sIx);
                    return rk;
                }
            } catch (_) { /* continue to text search */ }
        }
        // Note: do not rely on Title+Year cache; only use it as a heuristic when selecting a search result

        const s = getSettings();
        let best = null;
        // Prefer title for text-based search; IDs are handled via guid path above
        const query = (title || '').toString();
        const tryDoc = (doc) => {
            const typeStr = kind === 'movie' ? 'movie' : 'show';
            const candidates = Array.from(doc.querySelectorAll(
                `SearchResult > Hub[type="${typeStr}"] > Directory, ` +
                `MediaContainer > Video[type="${typeStr}"], ` +
                `MediaContainer > Directory[type="${typeStr}"], ` +
                `Hub > Video[type="${typeStr}"]`
            ));
            for (const item of candidates) {
                const rk = item.getAttribute('ratingKey');
                if (!rk) continue;
                const guids = Array.from(item.querySelectorAll('Guid')).map(g => (g.getAttribute('id') || ''));
                const guidAttr = item.getAttribute('guid');
                if (guidAttr) guids.push(guidAttr);
                const ids = extractIdsFromGuidStrings(guids);
                const hasImdb = imdbId && ids.imdbId === String(imdbId).toLowerCase();
                const hasTmdb = tmdbId && ids.tmdbId === String(tmdbId);
                if (hasImdb || hasTmdb) return rk;
                // If neither ID is available yet, fall back to exact title+year match
                if (!imdbId && !tmdbId && title) {
                    const candTitle = (item.getAttribute('title') || item.getAttribute('name') || '').trim();
                    const candYear = (item.getAttribute('year') || '').trim();
                    if (candTitle && candYear && year && normalizeTitleYear(candTitle, candYear) === normalizeTitleYear(title, year)) {
                        return rk;
                    }
                }
            }
            return null;
        };

        const libs = String(ctx.includeLibraries || '').split(',').map(x => x.trim()).filter(Boolean);
        if (libs.length) {
            for (const sec of libs) {
                try {
                    const searchType = kind === 'movie' ? 1 : 2;
                    const token = ctx?.token || s.token;
                    const url = `${ctx.uri}/library/sections/${encodeURIComponent(sec)}/search?type=${searchType}&query=${encodeURIComponent(query)}${year ? `&year=${encodeURIComponent(year)}` : ''}&includeGuids=1&X-Plex-Token=${encodeURIComponent(token)}`;
                    const r = await tmFetch({ url, headers: plexHeaders() });
                    const doc = xml(r.responseText);
                    best = tryDoc(doc);
                    if (best) break;
                } catch (_) { /* keep trying next */ }
            }
            if (!best) {
                const doc = await plexSearchMovieByQuery(ctx, query);
                best = tryDoc(doc);
            }
        } else {
            const doc = await plexSearchMovieByQuery(ctx, query);
            best = tryDoc(doc);
        }
        if (!best) return null;

        if (imdbId) sIx.byImdb[imdbId] = best;
        if (tmdbId) sIx.byTmdb[tmdbId] = best;
        if (slug) {
            if (!sIx.bySlug) sIx.bySlug = {};
            sIx.bySlug[slug] = best;
        }
        sIx.builtAt = Date.now();
        setServerIndex(ctx.id, sIx);
        return best;
    }

    /**********************
   * Authorization guard
   **********************/
    async function ensureAuthorized() {
        const s = getSettings();
        if (!s.token) return { ok: false, reason: 'No token. Open settings.' };
        const enabled = (s.servers || []).filter(x => x.enabled !== false && x.uri);
        if (!enabled.length) return { ok: false, reason: 'No servers set. Open settings.' };

        if (Date.now() - (s.lastAuthCheck || 0) < 6 * 60 * 60 * 1000) return { ok: true };

        const res = await tmFetch({
            url: `https://plex.tv/api/resources?includeHttps=1&X-Plex-Token=${encodeURIComponent(s.token)}`,
            headers: { 'Accept': 'application/xml', 'X-Plex-Product': APP.product, 'X-Plex-Client-Identifier': APP.clientId }
        });
        const doc = xml(res.responseText);
        // all enabled servers must be visible; if any is missing, still allow but warn next time
        const ok = enabled.some(srv => !!doc.querySelector(`Device[clientIdentifier="${CSS.escape(srv.id)}"][provides*="server"]`));
        const ns = getSettings(); ns.lastAuthCheck = Date.now(); setSettings(ns);
        return ok ? { ok: true } : { ok: false, reason: 'Your Plex account cannot see the configured servers.' };
    }

    /**********************
   * Letterboxd parsers
   **********************/
    function getLetterboxdSlug() {
        try {
            // Prefer canonical URL when available so it also works on /imdb/* or /tmdb/* subpages
            const canon = document.querySelector('link[rel="canonical"]')?.getAttribute('href') || '';
            if (canon) {
                try {
                    const u = new URL(canon, location.origin);
                    const mc = u.pathname.match(/\/film\/([^\/]+)\//) || u.pathname.match(/\/film\/([^\/]+)$/);
                    if (mc && mc[1]) return mc[1];
                } catch (_) { /* ignore parse errors */ }
            }
            // Fallback to current location
            const m = location.pathname.match(/\/film\/([^\/]+)\//);
            if (m && m[1]) return m[1];
            const m2 = location.pathname.match(/\/film\/([^\/]+)$/);
            if (m2 && m2[1]) return m2[1];
        } catch (_) { /* noop */ }
        return null;
    }

    // Wait for IMDb/TMDb IDs to be available in the DOM (JSON-LD or external buttons)
    async function waitForFilmIds(timeoutMs = 6000, pollMs = 250) {
        const start = Date.now();
        let last = parseFilmInfo();
        while (Date.now() - start < timeoutMs) {
            const info = parseFilmInfo();
            last = info || last;
            if ((info && (info.imdbId || info.tmdbId))) return info;
            await sleep(pollMs);
        }
        return last; // best-effort fallback
    }
    function parseFilmInfo() {
        // try JSON-LD first
        for (const s of $$('script[type="application/ld+json"]')) {
            try {
                const obj = JSON.parse(s.textContent);
                const arr = Array.isArray(obj) ? obj : [obj];
                for (const o of arr) {
                    const typ = o['@type'];
                    if ((typ === 'Movie' || typ === 'TVSeries' || typ === 'TVEpisode') && o.name) {
                        let imdbId = null, tmdbId = null;
                        const sameAs = [].concat(o.sameAs || []);
                        for (const url of sameAs) {
                            const m1 = String(url).match(/imdb\.com\/title\/(tt\d+)/i);
                            if (m1) imdbId = m1[1];
                            const m2 = String(url).match(/themoviedb\.org\/(movie|tv)\/(\d+)/i);
                            if (m2) tmdbId = m2[2];
                        }
                        const title = o.name;
                        const year = (o.datePublished || o.dateCreated || '').slice(0, 4) || null;
                        const kind = (typ === 'TVSeries' || typ === 'TVEpisode') ? 'show' : 'movie';
                        return { title, year, imdbId, tmdbId, kind };
                    }
                }
            } catch (_) { /* ignore */ }
        }
        // fallback: find IMDb/TMDb links in the Details box
        const imdbA = $$('a[href*="imdb.com/title/tt"]').find(Boolean);
        const imdbId = imdbA ? (imdbA.href.match(/(tt\d+)/i) || [])[1] : null;
        const tmdbLink = $$('a[href*="themoviedb.org/movie/"]').find(Boolean) || $$('a[href*="themoviedb.org/tv/"]').find(Boolean);
        const tmdbMovie = tmdbLink ? (tmdbLink.href.match(/movie\/(\d+)/i) || [])[1] : null;
        const tmdbTv = tmdbLink ? (tmdbLink.href.match(/tv\/(\d+)/i) || [])[1] : null;
        const tmdbId = tmdbMovie || tmdbTv || null;

        // title + year from header
        const h1 = document.querySelector('h1.headline-1') || document.querySelector('h1');
        const yearLink = $$('a[href^="/films/year/"]').find(Boolean) || $$('small, .releaseyear').find(el => /\d{4}/.test(el.textContent));
        const title = h1 ? h1.textContent.trim() : null;
        const year = yearLink ? (yearLink.textContent.match(/\d{4}/) || [])[0] : null;

        const kind = tmdbTv ? 'show' : 'movie';
        return { title, year, imdbId, tmdbId, kind };
    }

    /**********************
   * UI
   **********************/

    // Apply or remove the global colored-row class
    function applyGlobalColorSetting() {
        try {
            const s = getSettings();
            const root = document.documentElement;
            if (s.useColoredRows) root.classList.add('lbxd-plex-colored');
            else root.classList.remove('lbxd-plex-colored');
        } catch (_) { /* noop */ }
    }

    // Create/remove onboarding CTA after section.watch-panel when not logged in
    function removeOnboardingCta() {
        try { document.getElementById('lbxd-plex-setup-cta')?.remove(); } catch (_) { /* noop */ }
    }
    function ensureOnboardingCta() {
        const s = getSettings();
        const hasToken = !!s.token;
        const existing = document.getElementById('lbxd-plex-setup-cta');
        if (hasToken) { if (existing) existing.remove(); return; }
        // Only show CTA when not logged in
        let panel = document.querySelector('section.watch-panel');
        if (!panel) return;
        if (existing) return;
        const wrap = document.createElement('div');
        wrap.id = 'lbxd-plex-setup-cta';
        const btn = document.createElement('button');
        btn.type = 'button';
        btn.className = 'lbxd-plex-setup-btn';
        btn.textContent = 'Letterboxd → Plex setup';
        btn.addEventListener('click', (e) => { e.preventDefault(); openSettings(); });
        wrap.appendChild(btn);
        panel.insertAdjacentElement('afterend', wrap);
    }


    function getOrCreateServicesSection() {
        // Prefer existing services section under #watch
        let services = document.querySelector('#watch section.services');
        const watch = document.getElementById('watch');
        if (services && watch && watch.contains(services)) return services;

        if (watch) {
            // Create a services section and insert before the last direct child div of #watch
            services = document.createElement('section');
            services.className = 'services';
            const children = Array.from(watch.children);
            const lastDiv = [...children].reverse().find(el => el.tagName === 'DIV');
            if (lastDiv) watch.insertBefore(services, lastDiv);
            else watch.appendChild(services);
            return services;
        }

        // Fallbacks: if #watch missing, use existing section.services anywhere or body
        services = document.querySelector('section.services');
        if (services) return services;
        const s = document.createElement('section');
        s.className = 'services';
        (document.body || document.documentElement).appendChild(s);
        return s;
    }

    // Keep the Plex buttons alive if Letterboxd re-renders sections
    let __lbxd_lastState = null;
    let __lbxd_keepAliveTimer = null;
    let __lbxd_domObserver = null;

    function ensureKeepAlive() {
        if (__lbxd_domObserver) return;
        __lbxd_domObserver = new MutationObserver(() => {
            if (__lbxd_keepAliveTimer) return;
            __lbxd_keepAliveTimer = setTimeout(() => {
                __lbxd_keepAliveTimer = null;
                const rows = Array.from(document.querySelectorAll('[id^="source-plex-"]'));
                const onFilm = /\/letterboxd\.com\/(film|imdb|tmdb)\//.test(location.href);
                const want = Array.isArray(__lbxd_lastState) ? __lbxd_lastState.length : (__lbxd_lastState ? 1 : 0);
                const have = rows.length;
                if (onFilm && want > 0 && have < want) {
                    try { renderAllButtons(__lbxd_lastState); } catch (_) { /* noop */ }
                }
                // Ensure onboarding CTA remains visible if not logged in
                try { ensureOnboardingCta(); } catch (_) { /* noop */ }
            }, 60);
        });
        __lbxd_domObserver.observe(document, { subtree: true, childList: true });
        window.addEventListener('pageshow', () => {
            const rows = Array.from(document.querySelectorAll('[id^="source-plex-"]'));
            const want = Array.isArray(__lbxd_lastState) ? __lbxd_lastState.length : (__lbxd_lastState ? 1 : 0);
            if (want > 0 && rows.length < want) {
                try { renderAllButtons(__lbxd_lastState); } catch (_) { /* noop */ }
            }
            try { ensureOnboardingCta(); } catch (_) { /* noop */ }
        });
    }

    function makePlexBrand() {
        const brand = document.createElement('span');
        brand.className = 'brand';
        const maskId = 'maskID' + Date.now().toString(36) + Math.floor(Math.random()*1e6);
        brand.innerHTML = `
          <svg version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
            <defs>
              <clipPath id="${maskId}"><path d="M12,24 C2.372583,24 0,21.627417 0,12 C0,2.372583 2.372583,0 12,0 C21.627417,0 24,2.372583 24,12 C24,21.627417 21.627417,24 12,24 Z"></path></clipPath>
            </defs>
            <title></title>
            <desc>Plex</desc>
            <image clip-path="url(#${maskId})" width="24" height="24" xlink:href="https://images.justwatch.com/icon/301832745/s100"></image>
            <path d="M12,23.5 C21.2262746,23.5 23.5,21.2262746 23.5,12 C23.5,2.77372538 21.2262746,0.5 12,0.5 C2.77372538,0.5 0.5,2.77372538 0.5,12 C0.5,21.2262746 2.77372538,23.5 12,23.5 Z" class="overlay" stroke-opacity="0.35" stroke="#FFFFFF" fill="rgba(0,0,0,0)"></path>
          </svg>`;
        return brand;
    }

    function renderServiceButton(state) {
        const p = document.createElement('p');
        p.id = `source-plex-${state.serverId || 'auth'}`;
        p.className = 'service -plex';
        if (state.status === 'ready') {
            const a = document.createElement('a');
            a.href = state.web;
            a.target = '_blank';
            a.rel = 'nofollow noopener noreferrer';
            a.className = 'label track-event js-watch-plex-label tooltip';
            a.title = 'Open in Plex (Web)';
            const brand = makePlexBrand();
            const title = document.createElement('span');
            title.className = 'title';
            title.innerHTML = `<span class="name">${state.serverName ? String(state.serverName) : 'Plex'}</span>`;
            a.append(brand, title);
            p.appendChild(a);

            const opts = document.createElement('span');
            opts.className = 'options js-film-availability-options';
            const linkLib = document.createElement('a');
            linkLib.className = 'link';
            linkLib.textContent = state.libraryName || 'Plex Web';
            linkLib.href = state.web;
            linkLib.target = '_blank';
            linkLib.rel = 'nofollow noopener noreferrer';
            opts.append(linkLib);
            p.appendChild(opts);
        } else if (state.status === 'auth') {
            const a = document.createElement('a');
            a.href = '#';
            a.className = 'label';
            a.title = 'Configure Plex';
            a.addEventListener('click', (e) => { e.preventDefault(); openSettings(); });
            const brand = makePlexBrand();
            const title = document.createElement('span');
            title.className = 'title';
            title.innerHTML = `<span class="name">Configure Plex</span>`;
            a.append(brand, title);
            p.appendChild(a);
            if (state.note) {
                const note = document.createElement('span');
                note.className = 'options js-film-availability-options';
                note.textContent = state.note;
                p.appendChild(note);
            }
        }
        // Settings gear (always present, shown on hover)
        try {
            const gear = document.createElement('a');
            gear.className = 'lbxd-plex-gear';
            gear.title = 'Open settings';
            gear.setAttribute('aria-label', 'Open settings');
            gear.href = '#';
            gear.textContent = '\u2699\uFE0E';
            gear.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); openSettings(); });
            p.appendChild(gear);
        } catch (_) { /* noop */ }
        // Return the element so the caller can place it appropriately.
        return p;
    }

    function renderAllButtons(states) {
        // remove existing Plex services
        document.querySelectorAll('[id^="source-plex-"]').forEach(n => n.remove());
        // Remove onboarding CTA if present
        try { removeOnboardingCta(); } catch (_) { /* noop */ }
        if (!states || !states.length) return;
        const services = getOrCreateServicesSection();
        // If we found at least one playable Plex link, remove Letterboxd's not-available notice
        if (states.some(s => s && s.status === 'ready')) {
            try {
                const root = document.getElementById('watch') || services || document;
                root.querySelectorAll('.js-not-streaming').forEach(el => el.remove());
            } catch (_) { /* ignore */ }
        }
        const frag = document.createDocumentFragment();
        for (const st of states) {
            const el = renderServiceButton(st);
            if (el) frag.appendChild(el);
        }
        // Insert all Plex service rows at the top of the services section
        if (services.firstChild) services.insertBefore(frag, services.firstChild);
        else services.appendChild(frag);
        __lbxd_lastState = states.slice();
        ensureKeepAlive();
    }


    function explainPlexError(e) {
        // Default, then refine by common cases
        let msg = 'Could not query your Plex server.';
        const r = e && e.response;

        // 0/undefined often means blocked by @connect or DNS/Adblock
        if (!r || r.status === 0) {
            msg = 'Network blocked. Ensure @connect includes *.plex.direct (or use @connect *), then reload.';
        }
        // Unauthorized token
        else if (r.status === 401) {
            msg = 'Plex token expired/invalid. Re-run “Plex: Configure” → Link Plex.';
        }
        // Forbidden or remote access disabled
        else if (r.status === 403) {
            msg = 'Access forbidden to this connection. Try “Pick server” again (choose an https plex.direct URI).';
        }
        // Not found (rare for /search)
        else if (r.status === 404) {
            msg = 'Endpoint not found on this server. Verify the connection URI (https plex.direct).';
        }
        // TLS/cert or mixed-content oddities still show as 0 in many cases
        return msg;
    }


    async function testConnectionForServer(srv) {
        const s = getSettings();
        const token = srv?.token || s.token;
        if (!token || !srv?.uri) { alert(`Set token and connection for server ${srv?.name || srv?.id || ''}.`); return false; }
        try {
            const r = await tmFetch({ url: `${srv.uri}/identity?X-Plex-Token=${encodeURIComponent(token)}`, headers: plexHeaders() });
            const ok = /<MediaContainer/.test(r.responseText);
            alert(`${srv.name || srv.id}: ${ok ? 'OK ✅' : 'Unexpected response'}`);
            return ok;
        } catch (e) {
            alert(`${srv.name || srv.id}: ${explainPlexError(e)}`);
            console.error('[Plex opener] testConnection failed:', e);
            return false;
        }
    }

    async function testAllConnections() {
        const s = getSettings();
        if (!s.token) return alert('Set token first.');
        const servers = (s.servers || []).filter(x => x.enabled !== false);
        if (!servers.length) return alert('No servers configured.');
        let okCount = 0, total = 0;
        for (const srv of servers) {
            total++;
            try {
                const token = srv?.token || s.token;
                const r = await tmFetch({ url: `${srv.uri}/identity?X-Plex-Token=${encodeURIComponent(token)}`, headers: plexHeaders() });
                const ok = /<MediaContainer/.test(r.responseText);
                if (ok) okCount++;
            } catch (_) { /* ignore per-server error here; individual alerts are noisy */ }
        }
        alert(`Servers OK: ${okCount}/${total}`);
    }

    async function tryRecoverConnectionForServer(srv) {
        const s = getSettings();
        if (!s.token || !srv?.id) return false;
        try {
            const res = await tmFetch({
                url: `https://plex.tv/api/resources?includeHttps=1&includeRelay=1&X-Plex-Token=${encodeURIComponent(s.token)}`,
                headers: { 'Accept': 'application/xml', 'X-Plex-Product': APP.product, 'X-Plex-Client-Identifier': APP.clientId },
                timeout: 10000,
            });
            const doc = xml(res.responseText);
            const dev = Array.from(doc.querySelectorAll('Device[provides*="server"]'))
                .find(d => d.getAttribute('clientIdentifier') === srv.id);
            if (!dev) return false;
            const conns = Array.from(dev.querySelectorAll('Connection'));
            const newToken = dev.getAttribute('accessToken') || srv.token || s.token;
            const newUri = await chooseBestConnection(conns, newToken);
            if (!newUri || newUri === srv.uri) return false;
            const ns = getSettings();
            const me = (ns.servers || []).find(x => x.id === srv.id);
            if (!me) return false;
            me.uri = newUri;
            if (newToken) me.token = newToken;
            setSettings(ns);
            console.info('[Plex opener] switched server uri →', newUri);
            return true;
        } catch (_) {
            return false;
        }
    }

    /**********************
   * Main
   **********************/
    async function main() {
        injectStyles();
        // Ensure the keep-alive observer is always active so the
        // settings CTA/entry persists even when logged out
        try { ensureKeepAlive(); } catch (_) { /* noop */ }
        try { applyGlobalColorSetting(); } catch (_) {}
        // Ensure onboarding CTA reflects current login state early
        try { ensureOnboardingCta(); } catch (_) { /* noop */ }
        const slug = getLetterboxdSlug();

        // Instant render from cache by slug if available
        try {
            const s0 = getSettings();
            const servers0 = (s0.servers || []).filter(x => x.enabled !== false && x.uri);
            const instantStates = [];
            if (slug) {
                for (const srv of servers0) {
                    const sIx = getServerIndex(srv.id);
                    const rk = sIx.bySlug && sIx.bySlug[slug];
                    if (!rk) continue;
                    const { web } = plexLinks(rk, srv.id);
                    instantStates.push({
                        status: 'ready',
                        web,
                        serverId: srv.id,
                        serverName: srv.name || 'Plex',
                        libraryName: null,
                    });
                }
            }
            if (instantStates.length) {
                renderAllButtons(instantStates);
            }
        } catch (_) { /* non-fatal */ }

        // Continue with normal flow to find or verify entries and backfill cache
        // Prefer using IMDb/TMDb IDs; wait briefly for LBXD to render them
        const info = await waitForFilmIds(7000, 250);
        const auth = await ensureAuthorized();
        if (!auth.ok) {
            const sAuth = getSettings();
            const notLoggedIn = !sAuth.token;
            if (notLoggedIn) {
                // Show onboarding CTA near watch panel instead of services entry
                try { ensureOnboardingCta(); } catch (_) { /* noop */ }
                return;
            }
            // Otherwise, keep prior behavior (render a service row with guidance)
            const hadAny = Array.from(document.querySelectorAll('[id^="source-plex-"]')).length > 0;
            if (!hadAny) renderAllButtons([{ status: 'auth', note: auth.reason }]);
            return;
        }

        await ensureWarmIndex();
        const s = getSettings();
        // Remove CTA if we are logged in and can proceed
        try { removeOnboardingCta(); } catch (_) { /* noop */ }
        const servers = (s.servers || []).filter(x => x.enabled !== false && x.uri);
        const states = [];
        for (const srv of servers) {
            let ratingKey = null;
            try {
                ratingKey = await resolveRatingKeyOnServer(srv, Object.assign({}, info, { slug }));
            } catch (e) {
                console.error('[Plex opener] lookup failed:', e);
                const isNet0 = !e.response || e.response.status === 0 || /Network error/i.test(e.message);
                if (isNet0) {
                    const recovered = await tryRecoverConnectionForServer(srv);
                    if (recovered) {
                        try { ratingKey = await resolveRatingKeyOnServer(srv, Object.assign({}, info, { slug })); }
                        catch (e2) { console.error('[Plex opener] retry failed:', e2); }
                    }
                }
            }
            if (!ratingKey) continue;
            const { web } = plexLinks(ratingKey, srv.id);
            let libName = null;
            try { const libInfo = await fetchLibraryInfo(srv, ratingKey); libName = libInfo.sectionTitle || null; } catch (_) { /* ignore */ }
            states.push({
                status: 'ready',
                web,
                serverId: srv.id,
                serverName: srv.name || 'Plex',
                libraryName: libName,
            });
        }
        if (states.length) renderAllButtons(states);
    }

    /**********************
   * Handle navigation changes (LBXD loads full pages, but be safe)
   **********************/
    let lastUrl = location.href;
    const observe = new MutationObserver(() => {
        if (location.href !== lastUrl) {
            lastUrl = location.href;
            document.querySelectorAll('[id^="source-plex-"]').forEach(n => n.remove());
            try { __lbxd_lastState = null; } catch (_) { /* noop */ }
            main();
        }
    });
    observe.observe(document, { subtree: true, childList: true });
    main();
})();