您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
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(); })();