您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Send Letterboxd lists and nanogenre pages to Kodi (Fenlight)
// ==UserScript== // @name Letterboxd → FLAM (Fenlight) // @namespace http://tampermonkey.net/ // @version 5.5.2 // @description Send Letterboxd lists and nanogenre pages to Kodi (Fenlight) // @match https://letterboxd.com/*/list/* // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_addStyle // @run-at document-idle // @connect * // @connect api.github.com // @connect raw.githubusercontent.com // @noframes // ==/UserScript== (async function () { 'use strict'; // ──────────────────────────────────────────────────────────────────────── // NEW: Hard guards so we only run on real user list pages and never in frames // ──────────────────────────────────────────────────────────────────────── if (window.top !== window.self) return; // extra safety even with @noframes // Accept URLs like: // /{user}/list/{slug}/ // /{user}/list/{slug}/page/2/ function isTargetListPage() { const p = location.pathname.replace(/\/+$/, '/'); // normalize trailing slash return /^\/[^/]+\/list\/[^/]+\/(?:page\/\d+\/)?$/.test(p); } if (!isTargetListPage()) return; // ──────────────────────────────────────────────────────────────────────── // Config (defaults) // ──────────────────────────────────────────────────────────────────────── const TMDB_API_KEY = 'f090bb54758cabf231fb605d3e3e0468'; const DEFAULT_BATCH_SIZE = 5; const DEFAULT_PAUSE_MS = 150; const DEFAULT_FINALRETRY_DELAY = 300; const DEFAULT_FINALRETRY_MIN_LIST_SIZE = 5001; const LBD_MAX_RETRIES = 3; const LBD_BACKOFF_BASE = 200; const TMDB_MAX_RETRIES = 3; const TMDB_BACKOFF_BASE = 200; const CACHE_KEY = 'lbd_tmdb_cache_overrides'; const CACHE_BACKUP_KEY = 'lbd_tmdb_cache_backup'; const DEFAULT_DESCRIPTION = ''; const RUNTIME_TOL_MIN = 3; // Artwork settings defaults const POSTER_ENABLE_DEFAULT = true; const POSTER_STRATEGY_DEFAULT = 'first_4'; // 'first_4' | 'random' // Fanart supports 'author_fanart' and fallback when missing const FANART_ENABLE_DEFAULT = true; const FANART_STRATEGY_DEFAULT = 'author_fanart'; // 'author_fanart' | 'first_4' | 'random' const FANART_FALLBACK_DEFAULT = 'first_4'; // 'none' | 'first_4' | 'random' // Binary base cache (packed pairs) hosted on GitHub Raw const BASE_BIN_URL_DEFAULT = 'https://raw.githubusercontent.com/hcgiub001/letterboxd-tmdb-cache/main/lbd_tmdb_pairs_u32.bin'; const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; // Fixed media type default to movies const MEDIA_TYPE_DEFAULT_FIXED = 'm'; // Soft limit for list size (friendly block for > 5000 items) const MAX_LIST_ITEMS = 5000; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); function GM_GetOrSet(key, def) { const v = GM_getValue(key); return (typeof v === 'undefined') ? def : v; } // ──────────────────────────────────────────────────────────────────────── // Text helpers // ──────────────────────────────────────────────────────────────────────── const normalizeText = (s) => (s || '').replace(/\u00A0/g, ' ').replace(/\s+/g, ' ').trim(); function stripDiacritics(s) { return (s || '').normalize('NFD').replace(/[\u0300-\u036f]/g, ''); } function normalizeTitle(s) { return stripDiacritics(s).toLowerCase().replace(/[^\w\s]/g, '').replace(/\s+/g, ' ').trim(); } function normalizeName(s) { return stripDiacritics(s).toLowerCase().replace(/\s+/g, ' ').trim(); } function utf8ByteLen(str) { try { return new TextEncoder().encode(str).length; } catch { return unescape(encodeURIComponent(str)).length; } } // ──────────────────────────────────────────────────────────────────────── // Gzip+Base64 helper // ──────────────────────────────────────────────────────────────────────── async function gzipBase64(str) { try { const enc = new TextEncoder().encode(str); const cs = new CompressionStream('gzip'); const compressed = new Blob([enc]).stream().pipeThrough(cs); const ab = await new Response(compressed).arrayBuffer(); const u8 = new Uint8Array(ab); let bin = ''; for (let i = 0; i < u8.length; i++) bin += String.fromCharCode(u8[i]); return btoa(bin); } catch { return null; // compression not available / failed } } // ──────────────────────────────────────────────────────────────────────── // Description & author helpers // ──────────────────────────────────────────────────────────────────────── function htmlToTextWithBreaks(html) { if (!html) return ''; let t = html; t = t.replace(/<br\s*\/?>/gi, '\n'); t = t.replace(/<\/p>\s*<p[^>]*>/gi, '\n\n'); t = t.replace(/<\/?p[^>]*>/gi, ''); t = t.replace(/<li[^>]*>\s*/gi, '• '); t = t.replace(/<\/li>\s*/gi, '\n'); t = t.replace(/<\/?ul[^>]*>/gi, ''); t = t.replace(/<\/?ol[^>]*>/gi, ''); t = t.replace(/<[^>]+>/g, ''); const ta = document.createElement('textarea'); ta.innerHTML = t.replace(/ /gi, '\u00A0'); t = ta.value; t = t.replace(/\u00A0/g, ' '); t = t.replace(/\r\n/g, '\n').replace(/^\s+/, '').replace(/\s+$/, ''); return t; } function getListNameFromPage() { const h1Preferred = document.querySelector('h1.title-1.prettify'); const prefer = normalizeText(h1Preferred?.textContent); if (prefer) return prefer; const h1Alt = document.querySelector('h1.headline-1') || document.querySelector('h1'); return normalizeText(h1Alt?.textContent) || 'Letterboxd List'; } function getListDescriptionFromPage() { const div = document.querySelector('div.body-text.-prose.-hero.clear.js-collapsible-text'); if (!div) return ''; return htmlToTextWithBreaks(div.innerHTML); } function sanitizeDescPreservingBreaks(s) { if (!s) return ''; return s.replace(/\u00A0/g, ' ') .replace(/\r\n/g, '\n') .replace(/\t/g, ' ') .trim(); } function getListAuthorFromPage() { const span = document.querySelector('span[itemprop="name"]'); return normalizeText(span?.textContent) || ''; } // ──────────────────────────────────────────────────────────────────────── // Page-kind helpers // ──────────────────────────────────────────────────────────────────────── function isListPage() { return /\/list\//.test(location.pathname); } function isNanogenreLike() { return !!document.querySelector('section.genre-group ul.poster-list li[data-film-id], section.-themes ul.poster-list li[data-film-id], main ul.poster-list li[data-film-id][data-film-slug]'); } // ──────────────────────────────────────────────────────────────────────── // Backdrop (author fanart) extractor // ──────────────────────────────────────────────────────────────────────── function getAuthorFanartUrl() { const el = document.querySelector('div.backdropimage.js-backdrop-image'); if (!el) return ''; const bg = (getComputedStyle(el).backgroundImage || el.style.backgroundImage || '').trim(); const m = bg.match(/url\(["']?([^"')]+)["']?\)/i); return m ? m[1] : ''; } // ──────────────────────────────────────────────────────────────────────── // IndexedDB (v2) – overrides + base // ──────────────────────────────────────────────────────────────────────── function openDB() { return new Promise((resolve, reject) => { const rq = indexedDB.open('FenlightCacheDB', 2); rq.onupgradeneeded = () => { const db = rq.result; if (!db.objectStoreNames.contains('tmdbCache')) db.createObjectStore('tmdbCache'); if (!db.objectStoreNames.contains('tmdbBase')) db.createObjectStore('tmdbBase'); }; rq.onsuccess = () => resolve(rq.result); rq.onerror = () => reject(rq.error); }); } async function getCache(key = CACHE_KEY) { const db = await openDB(); return new Promise((res, rej) => { const tx = db.transaction('tmdbCache', 'readonly'); const rq = tx.objectStore('tmdbCache').get(key); rq.onsuccess = () => res(rq.result || {}); rq.onerror = () => rej(rq.error); }); } async function setCache(obj, key = CACHE_KEY) { const db = await openDB(); return new Promise((res, rej) => { const tx = db.transaction('tmdbCache', 'readwrite'); const rq = tx.objectStore('tmdbCache').put(obj, key); rq.onsuccess = () => res(); rq.onerror = () => rej(rq.error); }); } async function clearCache() { const db = await openDB(); return new Promise((res, rej) => { const tx = db.transaction('tmdbCache', 'readwrite'); const rq = tx.objectStore('tmdbCache').delete(CACHE_KEY); rq.onsuccess = () => res(); rq.onerror = () => rej(rq.error); }); } async function getBaseFromIDB() { const db = await openDB(); const tx = db.transaction('tmdbBase', 'readonly'); const store = tx.objectStore('tmdbBase'); const bufP = new Promise((res, rej) => { const r = store.get('base_bin'); r.onsuccess = () => res(r.result || null); r.onerror = () => rej(r.error); }); const etgP = new Promise((res, rej) => { const r = store.get('base_etag'); r.onsuccess = () => res(r.result || ''); r.onerror = () => rej(r.error); }); const [buf, etag] = await Promise.all([bufP, etgP]); return { buf, etag }; } async function putBaseToIDB(buf, etag) { const db = await openDB(); const tx = db.transaction('tmdbBase', 'readwrite'); await Promise.all([ new Promise((res, rej) => { const r = tx.objectStore('tmdbBase').put(buf, 'base_bin'); r.onsuccess = res; r.onerror = () => rej(r.error); }), new Promise((res, rej) => { const r = tx.objectStore('tmdbBase').put(etag || '', 'base_etag'); r.onsuccess = res; r.onerror = () => rej(r.error); }), ]); } // ──────────────────────────────────────────────────────────────────────── // Binary base cache loader (packed) // ──────────────────────────────────────────────────────────────────────── function baseBinUrl() { return BASE_BIN_URL_DEFAULT; } let BASE_PAIRS_U32 = null; // Uint32Array [filmId, packed, ...] let BASE_COUNT = 0; async function loadBaseBinOnce({ allowRevalidate = false } = {}) { if (!BASE_PAIRS_U32) { const { buf } = await getBaseFromIDB(); if (buf && buf.byteLength) { const u32 = new Uint32Array(buf); if (u32.length % 2 === 0) { BASE_PAIRS_U32 = u32; BASE_COUNT = u32.length >>> 1; } } } if (!allowRevalidate) return; const { etag } = await getBaseFromIDB(); await new Promise((resolve) => { GM_xmlhttpRequest({ method: 'GET', url: baseBinUrl(), headers: etag ? { 'If-None-Match': etag } : {}, responseType: 'arraybuffer', onload: async (res) => { try { if (res.status === 304) { GM_setValue('baseLastRevalidateTs', Date.now()); resolve(); return; } if (res.status >= 200 && res.status < 300 && res.response) { const newBuf = res.response; const u32 = new Uint32Array(newBuf); if (u32.length % 2 === 0) { BASE_PAIRS_U32 = u32; BASE_COUNT = u32.length >>> 1; const m = String(res.responseHeaders || '').match(/etag:\s*(.+)/i); const newEtag = m ? m[1].trim() : ''; try { await putBaseToIDB(newBuf, newEtag); } catch {} } GM_setValue('baseLastRevalidateTs', Date.now()); } } finally { resolve(); } }, onerror: () => { resolve(); }, ontimeout: () => { resolve(); }, }); }); } async function refreshBaseBinNow() { await new Promise((resolve) => { GM_xmlhttpRequest({ method: 'GET', url: baseBinUrl(), responseType: 'arraybuffer', onload: async (res) => { try { if (res.status >= 200 && res.status < 300 && res.response) { const newBuf = res.response; const u32 = new Uint32Array(newBuf); if (u32.length % 2 === 0) { BASE_PAIRS_U32 = u32; BASE_COUNT = u32.length >>> 1; const m = String(res.responseHeaders || '').match(/etag:\s*(.+)/i); const newEtag = m ? m[1].trim() : ''; try { await putBaseToIDB(newBuf, newEtag); } catch {} GM_setValue('baseLastRevalidateTs', Date.now()); alert('✅ Base cache refreshed.'); } else { alert('❌ Base cache corrupt (odd length).'); } } else { alert(`❌ Base cache HTTP ${res.status}.`); } } finally { resolve(); } }, onerror: () => { alert('❌ Base cache network error.'); resolve(); }, ontimeout: () => { alert('❌ Base cache request timed out.'); resolve(); }, }); }); } async function maybeRevalidateBaseMonthly() { try { const last = GM_GetOrSet('baseLastRevalidateTs', 0); const now = Date.now(); if (now - last >= THIRTY_DAYS_MS) { await loadBaseBinOnce({ allowRevalidate: true }); GM_setValue('baseLastRevalidateTs', Date.now()); } } catch {} } setTimeout(() => { maybeRevalidateBaseMonthly(); }, 0); // ──────────────────────────────────────────────────────────────────────── // Packed helpers & two-tier access // ──────────────────────────────────────────────────────────────────────── function baseLookupPacked(idNum) { if (!BASE_PAIRS_U32) return undefined; let lo = 0, hi = BASE_COUNT - 1; while (lo <= hi) { const mid = (lo + hi) >>> 1; const k = BASE_PAIRS_U32[mid << 1]; if (k === idNum) return BASE_PAIRS_U32[(mid << 1) + 1]; if (k < idNum) lo = mid + 1; else hi = mid - 1; } return undefined; } function unpack(packed) { return { isTv: (packed & 1) === 1, tmdbId: packed >>> 1 }; } let LOCAL_OVERRIDES = null; let DIRTY_OVERRIDES = false; async function loadLocalOverridesOnce() { if (!LOCAL_OVERRIDES) LOCAL_OVERRIDES = await getCache(CACHE_KEY); } function overrideGetPacked(filmIdStr) { const v = LOCAL_OVERRIDES ? LOCAL_OVERRIDES[filmIdStr] : undefined; return Number.isFinite(v) ? v : undefined; } function overrideSetPacked(filmIdStr, tmdbId, isTv) { if (!LOCAL_OVERRIDES) LOCAL_OVERRIDES = {}; const newPacked = (tmdbId << 1) | (isTv ? 1 : 0); const prev = LOCAL_OVERRIDES[filmIdStr]; if (!Number.isFinite(prev) || prev !== newPacked) { LOCAL_OVERRIDES[filmIdStr] = newPacked; DIRTY_OVERRIDES = true; } } async function persistOverrides({ alsoBackup = true } = {}) { if (!DIRTY_OVERRIDES) return false; await setCache(LOCAL_OVERRIDES, CACHE_KEY); if (alsoBackup) { try { const snap = JSON.parse(JSON.stringify(LOCAL_OVERRIDES || {})); await setCache(snap, CACHE_BACKUP_KEY); } catch {} } DIRTY_OVERRIDES = false; return true; } function getFromAnyCachePacked(filmIdStr) { const vLocal = overrideGetPacked(filmIdStr); if (vLocal !== undefined) return vLocal; const idNum = Number(filmIdStr); if (Number.isFinite(idNum)) return baseLookupPacked(idNum); return undefined; } // ──────────────────────────────────────────────────────────────────────── // Kodi sender (awaitable) // ──────────────────────────────────────────────────────────────────────── function sendToKodi(url) { const ip = GM_getValue('kodiIp', '').trim(); const port = GM_getValue('kodiPort', '').trim(); const user = GM_getValue('kodiUser', ''); const pass = GM_getValue('kodiPass', ''); return new Promise((resolve) => { if (!ip || !port) { alert('⚠️ Please configure Kodi IP & port in settings.'); resolve(false); return; } GM_xmlhttpRequest({ method: 'POST', url: `http://${ip}:${port}/jsonrpc`, headers: { 'Content-Type': 'application/json', 'Authorization': 'Basic ' + btoa(`${user}:${pass}`) }, data: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'Player.Open', params: { item: { file: url } } }), timeout: 15000, onload: (res) => { if (res.status >= 200 && res.status < 300) { resolve(true); } else { alert(`❌ Kodi responded with status ${res.status}.`); resolve(false); } }, onerror: () => { alert('❌ Failed to contact Kodi.'); resolve(false); }, ontimeout: () => { alert('❌ Kodi request timed out.'); resolve(false); } }); }); } // ──────────────────────────────────────────────────────────────────────── // UI helpers (side info bubble, processing bubble) // ──────────────────────────────────────────────────────────────────────── function showSideInfoNearEl(el, lines, ms = 5000) { try { const prev = document.getElementById('kodi-send-info'); if (prev) prev.remove(); } catch {} const box = document.createElement('div'); box.id = 'kodi-send-info'; const MARGIN = 10; Object.assign(box.style, { position: 'fixed', top: '0px', // set then adjust after measuring right: '130px', background: '#1b1b1b', color: '#eee', border: '1px solid #333', borderRadius: '6px', padding: '8px 10px', fontSize: '12px', lineHeight: '1.4', zIndex: 2147483647, boxShadow: '0 2px 10px rgba(0,0,0,0.35)', maxWidth: '360px', pointerEvents: 'none', whiteSpace: 'pre-wrap' }); box.innerHTML = lines.join('\n'); document.body.append(box); let topPx = 150; try { const rect = el.getBoundingClientRect(); // Try placing below the icon first. topPx = rect.top + 40; // Measure and clamp. const boxH = box.offsetHeight; const maxTop = window.innerHeight - boxH - MARGIN; // If "below" would overflow, place above the icon. if (topPx > maxTop) topPx = rect.top - boxH - 10; // Final clamp to viewport. topPx = Math.max(MARGIN, Math.min(maxTop, Math.round(topPx))); } catch {} box.style.top = `${topPx}px`; setTimeout(() => { try { box.remove(); } catch {} }, ms); } let PROCESS_BUBBLE_EL = null; function showOrUpdateProcessingBubble(targetEl, lines, persist = false) { const GAP = 10; if (!PROCESS_BUBBLE_EL) { PROCESS_BUBBLE_EL = document.createElement('div'); PROCESS_BUBBLE_EL.id = 'kodi-processing-bubble'; Object.assign(PROCESS_BUBBLE_EL.style, { position: 'fixed', background: '#1b1b1b', color: '#eee', border: '1px solid #333', borderRadius: '8px', padding: '8px 10px', fontSize: '12px', lineHeight: '1.4', zIndex: 2147483647, boxShadow: '0 2px 10px rgba(0,0,0,0.35)', maxWidth: '360px', whiteSpace: 'pre-wrap', }); document.body.append(PROCESS_BUBBLE_EL); } PROCESS_BUBBLE_EL.textContent = lines.join('\n'); // Position to the LEFT of icon const rect = targetEl.getBoundingClientRect(); const topPx = Math.max(0, Math.round(rect.top)); const rightPx = Math.max(0, Math.round(window.innerWidth - rect.left + GAP)); PROCESS_BUBBLE_EL.style.top = `${topPx}px`; PROCESS_BUBBLE_EL.style.right = `${rightPx}px`; PROCESS_BUBBLE_EL.dataset.persist = persist ? '1' : '0'; PROCESS_BUBBLE_EL.style.display = 'block'; } function hideProcessingBubble(force = false) { if (PROCESS_BUBBLE_EL) { if (force || PROCESS_BUBBLE_EL.dataset.persist !== '1') { PROCESS_BUBBLE_EL.remove(); PROCESS_BUBBLE_EL = null; } } } // ──────────────────────────────────────────────────────────────────────── // Action chooser overlay (Ask each time) // ──────────────────────────────────────────────────────────────────────── function askForActionOverlay() { return new Promise((resolve) => { const overlay = document.createElement('div'); overlay.id = 'kodiactionask'; Object.assign(overlay.style, { position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh', background: 'rgba(0,0,0,0.65)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 2147483647, padding: '20px', boxSizing: 'border-box' }); const panel = document.createElement('div'); Object.assign(panel.style, { background: '#222', padding: '20px', borderRadius: '8px', width: '420px', maxWidth: '95vw', color: '#eee' }); panel.innerHTML = ` <h2 style="margin:0 0 12px; font-size:18px;">Choose Action</h2> <div style="font-size:13px; color:#bbb; margin:0 0 14px;"> Which Kodi action do you want to use for this run? </div> <div style="display:flex; gap:8px; flex-wrap:wrap;"> <button id="actView" style="flex:1; background:#444; color:#fff; border:none; border-radius:6px; padding:10px 12px; cursor:pointer">View</button> <button id="actImport" style="flex:1; background:#e50914; color:#fff; border:none; border-radius:6px; padding:10px 12px; cursor:pointer">Import</button> <button id="actImportView" style="flex:1; background:#2d7dff; color:#fff; border:none; border-radius:6px; padding:10px 12px; cursor:pointer">Import + View</button> </div> <div style="text-align:right; margin-top:12px;"> <button id="actCancel" style="background:#333; color:#fff; border:none; border-radius:6px; padding:8px 12px; cursor:pointer">Cancel</button> </div> `; overlay.append(panel); const finish = (val) => { try { overlay.remove(); } catch {} resolve(val); }; panel.querySelector('#actView').onclick = () => finish('view'); panel.querySelector('#actImport').onclick = () => finish('import'); panel.querySelector('#actImportView').onclick = () => finish('import_view'); panel.querySelector('#actCancel').onclick = () => finish(null); document.body.append(overlay); }); } // ──────────────────────────────────────────────────────────────────────── // TMDB & Letterboxd helpers // ──────────────────────────────────────────────────────────────────────── async function fetchLbdMetadata(item) { if (!item?.detailsEndpoint || item.title) return; for (let i = 0; i < LBD_MAX_RETRIES; i++) { try { const r = await fetch(item.detailsEndpoint, { credentials: 'include' }); if (r.ok) { const j = await r.json(); item.title = j.name || ''; item.year = j.releaseYear || ''; item.originalName = j.originalName || ''; item.runtime = (typeof j.runTime === 'number') ? j.runTime : null; item.directors = Array.isArray(j.directors) ? j.directors.map(d => d.name).filter(Boolean) : []; return; } if (r.status === 429) await new Promise(r => setTimeout(r, LBD_BACKOFF_BASE * (i + 1))); else return; } catch { await new Promise(r => setTimeout(r, LBD_BACKOFF_BASE * (i + 1))); } } } async function fetchTmdbSearchResults(item) { if (!item?.title) { item.matches = []; return; } const url = `https://api.themoviedb.org/3/search/movie?` + new URLSearchParams({ api_key: TMDB_API_KEY, query: item.title, year: item.year }); for (let i = 0; i < TMDB_MAX_RETRIES; i++) { try { const r = await fetch(url); if (r.ok) { const d = await r.json(); item.matches = Array.isArray(d.results) ? d.results : []; return; } if (r.status === 429) await new Promise(r => setTimeout(r, TMDB_BACKOFF_BASE * (i + 1))); else { item.matches = []; return; } } catch { await new Promise(r => setTimeout(r, TMDB_BACKOFF_BASE * (i + 1))); } } item.matches = []; } async function fetchTmdbCredits(id) { const url = `https://api.themoviedb.org/3/movie/${id}/credits?` + new URLSearchParams({ api_key: TMDB_API_KEY }); for (let i = 0; i < TMDB_MAX_RETRIES; i++) { try { const r = await fetch(url); if (r.ok) return r.json(); if (r.status === 429) await new Promise(r => setTimeout(r, TMDB_BACKOFF_BASE * (i + 1))); else return null; } catch { await new Promise(r => setTimeout(r, TMDB_BACKOFF_BASE * (i + 1))); } } return null; } async function fetchTmdbDetails(id) { const url = `https://api.themoviedb.org/3/movie/${id}?` + new URLSearchParams({ api_key: TMDB_API_KEY }); for (let i = 0; i < TMDB_MAX_RETRIES; i++) { try { const r = await fetch(url); if (r.ok) return r.json(); if (r.status === 429) await new Promise(r => setTimeout(r, TMDB_BACKOFF_BASE * (i + 1))); else return null; } catch { await new Promise(r => setTimeout(r, TMDB_BACKOFF_BASE * (i + 1))); } } return null; } function extractDirectorNames(cred) { if (!cred || !Array.isArray(cred.crew)) return []; const out = []; for (const c of cred.crew) { const job = (c.job || '').toLowerCase(); const dept = (c.department || '').toLowerCase(); const looksDirector = dept === 'directing' || job.includes('director'); if (looksDirector && c.name) out.push(normalizeName(c.name)); } return Array.from(new Set(out)); } function maybeLogChange() {} async function resolveBestTmdbForItem(item, isFinalRetry = false) { const results = item.matches || []; const top = results[0] || null; const lbTitleNorm = normalizeTitle(item.title); const lbOrigNorm = normalizeTitle(item.originalName || ''); const lbYearStr = String(item.year || ''); const lbDirNormSet = new Set((item.directors || []).map(normalizeName)); function tmdbYear(m) { const d = (m.release_date || '').slice(0, 4); return d || ''; } function tmdbTitleMatches(m) { const cand = [m.title, m.original_title].filter(Boolean); return cand.some(t => { const nt = normalizeTitle(t); return nt === lbTitleNorm || (lbOrigNorm && nt === lbOrigNorm); }); } if (top && tmdbTitleMatches(top) && tmdbYear(top) !== lbYearStr) { const topCreds = await fetchTmdbCredits(top.id); const topDirs = extractDirectorNames(topCreds); const dirMatch = topDirs.some(n => lbDirNormSet.has(n)); if (dirMatch) return top.id || ''; } const exactTitleYear = results.filter(m => tmdbTitleMatches(m) && tmdbYear(m) === lbYearStr); if (exactTitleYear.length === 1) return exactTitleYear[0].id || ''; if (exactTitleYear.length > 1) { const creditsList = await Promise.all(exactTitleYear.map(m => fetchTmdbCredits(m.id))); const dirMatches = []; for (let i = 0; i < exactTitleYear.length; i++) { const tmDirNames = extractDirectorNames(creditsList[i]); const matchNames = tmDirNames.filter(n => lbDirNormSet.has(n)); if (matchNames.length) dirMatches.push({ idx: i, matchNames }); } if (dirMatches.length === 1) return exactTitleYear[dirMatches[0].idx].id || ''; const detailsList = await Promise.all(exactTitleYear.map(m => fetchTmdbDetails(m.id))); const rtMatches = []; for (let i = 0; i < exactTitleYear.length; i++) { const det = detailsList[i]; if (!det || typeof det.runtime !== 'number' || typeof item.runtime !== 'number') continue; if (Math.abs(det.runtime - item.runtime) <= RUNTIME_TOL_MIN) rtMatches.push(i); } if (rtMatches.length === 1) return exactTitleYear[rtMatches[0]].id || ''; if (top) return top.id || ''; return exactTitleYear[0].id || ''; } const exactTitleOnly = results.filter(m => tmdbTitleMatches(m)); if (exactTitleOnly.length === 1) return exactTitleOnly[0].id || ''; if (exactTitleOnly.length > 1) { const creditsList = await Promise.all(exactTitleOnly.map(m => fetchTmdbCredits(m.id))); const dirMatches = []; for (let i = 0; i < exactTitleOnly.length; i++) { const tmDirNames = extractDirectorNames(creditsList[i]); const matchNames = tmDirNames.filter(n => lbDirNormSet.has(n)); if (matchNames.length) dirMatches.push({ idx: i, matchNames }); } if (dirMatches.length === 1) return exactTitleOnly[dirMatches[0].idx].id || ''; const detailsList = await Promise.all(exactTitleOnly.map(m => fetchTmdbDetails(m.id))); const rtMatches = []; for (let i = 0; i < exactTitleOnly.length; i++) { const det = detailsList[i]; if (!det || typeof det.runtime !== 'number' || typeof item.runtime !== 'number') continue; if (Math.abs(det.runtime - item.runtime) <= RUNTIME_TOL_MIN) rtMatches.push(i); } if (rtMatches.length === 1) return exactTitleOnly[rtMatches[0]].id || ''; if (results[0]) return results[0].id || ''; if (exactTitleOnly.length) return exactTitleOnly[0].id || ''; } if (results[0]) return results[0].id || ''; return ''; } // ──────────────────────────────────────────────────────────────────────── // Scrape & process items // ──────────────────────────────────────────────────────────────────────── function scrapeFrom(doc) { return Array.from(doc.querySelectorAll('ul.poster-list li')) .map(li => { const ed = li.querySelector('[data-details-endpoint]'); if (!ed) return null; return { filmId: ed.dataset.filmId, detailsEndpoint: location.origin + ed.dataset.detailsEndpoint, title: '', year: '', originalName: '', directors: [], runtime: null, matches: [], tmdbId: '', isTv: false, listIndex: 0 }; }) .filter(x => x); } async function scrapeAllItems_ListPages() { const pages = Array.from(document.querySelectorAll('li.paginate-page')) .map(li => parseInt(li.textContent, 10)) .filter(n => n); const count = pages.length ? Math.max(...pages) : 1; const urls = []; const base = location.pathname.replace(/\/page\/\d+\/?$/, '').replace(/\/?$/, '/'); for (let p = 1; p <= count; p++) { urls.push(location.origin + base + (p > 1 ? `page/${p}/` : '')); } const docs = await Promise.all(urls.map(u => fetch(u, { credentials: 'include' }) .then(r => r.ok ? r.text() : Promise.reject()) .then(t => new DOMParser().parseFromString(t, 'text/html')) )); const items = docs.flatMap(scrapeFrom); items.forEach((it, idx) => { it.listIndex = idx + 1; }); return items; } function scrapeNanogenreItems_CurrentPage() { const lis = document.querySelectorAll('section.genre-group ul.poster-list li[data-film-id], section.-themes ul.poster-list li[data-film-id], main ul.poster-list li[data-film-id][data-film-slug]'); const items = []; let idx = 0; lis.forEach(li => { const filmId = li.getAttribute('data-film-id') || ''; if (!filmId) return; const ed = li.querySelector('[data-details-endpoint]'); const detailsEndpoint = ed ? (location.origin + ed.getAttribute('data-details-endpoint')) : ''; const img = li.querySelector('img'); const title = (img?.getAttribute('alt') || '').trim(); const year = li.getAttribute('data-film-release-year') || ''; items.push({ filmId, detailsEndpoint, title, year, originalName: '', directors: [], runtime: null, matches: [], tmdbId: '', isTv: false, listIndex: ++idx }); }); return items; } async function scrapeItemsSmart() { if (isListPage()) { return await scrapeAllItems_ListPages(); } else if (isNanogenreLike()) { return scrapeNanogenreItems_CurrentPage(); } else { const items = scrapeFrom(document); items.forEach((it, i) => it.listIndex = i + 1); return items; } } async function processItems(items, providedOverrides = null) { await loadLocalOverridesOnce(); if (providedOverrides) LOCAL_OVERRIDES = providedOverrides; const batchSize = parseInt(GM_GetOrSet('batchSize', DEFAULT_BATCH_SIZE), 10) || DEFAULT_BATCH_SIZE; const pauseMs = parseInt(GM_GetOrSet('pauseMs', DEFAULT_PAUSE_MS), 10) || DEFAULT_PAUSE_MS; const toFetch = []; items.forEach(it => { const packed = getFromAnyCachePacked(it.filmId); if (packed !== undefined) { const { tmdbId, isTv } = unpack(packed); it.tmdbId = tmdbId; it.isTv = isTv; } else { toFetch.push(it); } }); const batches = []; for (let i = 0; i < toFetch.length; i += batchSize) { const batch = toFetch.slice(i, i + batchSize); batches.push((async () => { await Promise.all(batch.map(fetchLbdMetadata)); await Promise.all(batch.map(fetchTmdbSearchResults)); await Promise.all(batch.map(async it => { try { it.tmdbId = await resolveBestTmdbForItem(it, /*isFinalRetry=*/false); } catch (e) { console.error('resolveBestTmdbForItem failed (first pass):', it, e); it.tmdbId = ''; } if (it.tmdbId) { it.isTv = false; // movie search only; unknown TV stays handled by base cache overrideSetPacked(it.filmId, it.tmdbId, it.isTv); } })); })()); await sleep(pauseMs); } await Promise.all(batches); await persistOverrides({ alsoBackup: true }); // Final retry pass (optional) const minSize = parseInt(GM_GetOrSet('finalRetryMinListSize', DEFAULT_FINALRETRY_MIN_LIST_SIZE), 10) || 0; const unresolved = items.filter(it => !(it.tmdbId)); if (items.length >= minSize && unresolved.length) { const delay = parseInt(GM_GetOrSet('finalRetryDelayMs', DEFAULT_FINALRETRY_DELAY), 10) || 0; if (delay > 0) await sleep(delay); const frBatches = []; for (let i = 0; i < unresolved.length; i += batchSize) { const batch = unresolved.slice(i, i + batchSize); frBatches.push((async () => { await Promise.all(batch.map(fetchLbdMetadata)); await Promise.all(batch.map(async it => { const hasMatches = Array.isArray(it.matches) && it.matches.length > 0; if (!hasMatches && it.title) { await fetchTmdbSearchResults(it); } })); await Promise.all(batch.map(async it => { try { it.tmdbId = await resolveBestTmdbForItem(it, /*isFinalRetry=*/true); } catch (e) { console.error('resolveBestTmdbForItem failed (final pass):', it, e); it.tmdbId = ''; } if (it.tmdbId) { it.isTv = false; overrideSetPacked(it.filmId, it.tmdbId, it.isTv); } })); })()); await sleep(pauseMs); } await Promise.all(frBatches); await persistOverrides({ alsoBackup: true }); } return LOCAL_OVERRIDES || {}; } // ──────────────────────────────────────────────────────────────────────── // URL builder (fixed media_type_default="m"; gzip+base64 support) // ──────────────────────────────────────────────────────────────────────── async function buildUrlFromCache(items, descriptionText, actionOverride = null) { // IMPORTANT FIX: include per-item mt:"tv" when the item is TV, // while keeping media_type_default fixed to "m". const listItems = []; for (const it of items) { let packed = getFromAnyCachePacked(it.filmId); let tmdbId, isTv = false; if (packed !== undefined) { ({ tmdbId, isTv } = unpack(packed)); } else if (it.tmdbId) { tmdbId = Number(it.tmdbId); isTv = !!it.isTv; } else { tmdbId = ''; } if (isTv) { listItems.push({ id: tmdbId, mt: 'tv' }); } else { listItems.push({ id: tmdbId }); } } const rawJson = JSON.stringify(listItems); const listName = getListNameFromPage(); const author = getListAuthorFromPage(); let action = (typeof actionOverride === 'string') ? actionOverride : GM_getValue('kodiAction', 'import_view'); if (action === 'ask') action = ''; const busyIndicator = GM_GetOrSet('indicator', 'busy'); const description = (typeof descriptionText === 'string' && descriptionText !== '') ? descriptionText : null; const posterEnabled = GM_GetOrSet('posterEnable', POSTER_ENABLE_DEFAULT); const posterStrategy = GM_GetOrSet('posterStrategy', POSTER_STRATEGY_DEFAULT); const fanartEnabled = GM_GetOrSet('fanartEnable', FANART_ENABLE_DEFAULT); const fanartStrategy = GM_GetOrSet('fanartStrategy', FANART_STRATEGY_DEFAULT); const fanartFallback = GM_GetOrSet('fanartFallback', FANART_FALLBACK_DEFAULT); function buildCommonParts() { const parts = []; parts.push(['mode', 'personal_lists.external']); if (action) parts.push(['action', action]); parts.push(['list_type', 'tmdb']); parts.push(['list_name', listName]); parts.push(['author', author]); parts.push(['media_type_default', MEDIA_TYPE_DEFAULT_FIXED]); parts.push(['busy_indicator', busyIndicator]); if (description != null) parts.push(['description', description]); if (posterEnabled) parts.push(['poster', posterStrategy]); if (fanartEnabled) { if (fanartStrategy === 'author_fanart') { const url = getAuthorFanartUrl(); if (url) { parts.push(['fanart', url]); } else { if (fanartFallback === 'first_4' || fanartFallback === 'random') { parts.push(['fanart', fanartFallback]); } } } else { parts.push(['fanart', fanartStrategy]); } } return parts; } const commonParts = buildCommonParts(); // Try gzip+base64 const b64 = await gzipBase64(rawJson); // null if not supported/failed const encodeParts = (arr) => arr.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&'); const base = 'plugin://plugin.video.fenlight/?'; const rawParts = [...commonParts, ['list_items', rawJson]]; const rawUrl = base + encodeParts(rawParts); const rawBytes = utf8ByteLen(rawUrl); let gzUrl = null, gzBytes = null; if (b64) { const gzParts = [...commonParts, ['base64_items', b64]]; gzUrl = base + encodeParts(gzParts); gzBytes = utf8ByteLen(gzUrl); } const choice = b64 ? 'base64' : 'raw'; const url = (choice === 'base64') ? gzUrl : rawUrl; const urlBytes = (choice === 'base64') ? gzBytes : rawBytes; return { url, choice, urlBytes, rawUrl, rawBytes, gzUrl, gzBytes }; } // ──────────────────────────────────────────────────────────────────────── // Run control to enforce single base-load per run // ──────────────────────────────────────────────────────────────────────── let RUN_BASE_LOADED = false; async function startRun() { RUN_BASE_LOADED = false; DIRTY_OVERRIDES = false; } async function ensureBaseLoadedOnceForRun() { if (!RUN_BASE_LOADED) { await loadBaseBinOnce({ allowRevalidate: false }); RUN_BASE_LOADED = true; } } async function resolveActionForThisRun() { const stored = GM_getValue('kodiAction', 'import_view'); if (stored === 'ask') return await askForActionOverlay(); return stored; } // ──────────────────────────────────────────────────────────────────────── // Friendly soft-limit overlay // ──────────────────────────────────────────────────────────────────────── function showTooManyItemsOverlay(totalCount) { const existing = document.getElementById('kodi-too-many-items'); if (existing) try { existing.remove(); } catch {} const overlay = document.createElement('div'); overlay.id = 'kodi-too-many-items'; Object.assign(overlay.style, { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 2147483647, padding: '20px', boxSizing: 'border-box' }); const panel = document.createElement('div'); Object.assign(panel.style, { background: '#222', color: '#eee', borderRadius: '10px', width: '560px', maxWidth: '95vw', padding: '20px', boxShadow: '0 10px 30px rgba(0,0,0,0.45)', border: '1px solid #333' }); panel.innerHTML = ` <div style="display:flex;align-items:center;gap:10px;margin-bottom:8px"> <div style="background:#e50914;width:28px;height:28px;border-radius:6px;display:flex;align-items:center;justify-content:center;font-weight:800">!</div> <h2 style="margin:0;font-size:18px;">This list is a little too epic to send in one go</h2> </div> <div style="font-size:13px; color:#ccc; line-height:1.55;"> Your list contains <strong>${totalCount.toLocaleString()}</strong> items. For reliability, sending lists over <strong>${MAX_LIST_ITEMS.toLocaleString()}</strong> items isn’t supported in a single run. <br><br> Try sending it in smaller parts (for example, a few pages at a time or the first 5,000), or filter the list and send again. </div> <div style="margin-top:14px;text-align:right;"> <button id="kodi-too-many-items-ok" style="background:#e50914;color:#fff;border:none;border-radius:6px;padding:8px 12px;cursor:pointer">Got it</button> </div> `; overlay.append(panel); panel.querySelector('#kodi-too-many-items-ok').onclick = () => { try { overlay.remove(); } catch {} }; document.body.append(overlay); } // ──────────────────────────────────────────────────────────────────────── // Show URL handler // ──────────────────────────────────────────────────────────────────────── async function handleShowUrl(btnEl) { if (btnEl) btnEl.disabled = true; await startRun(); await ensureBaseLoadedOnceForRun(); await loadLocalOverridesOnce(); let items; try { items = await scrapeItemsSmart(); } catch { alert('Scrape failed'); if (btnEl) btnEl.disabled = false; return; } if (!items.length) { alert('No items'); if (btnEl) btnEl.disabled = false; return; } if (items.length > MAX_LIST_ITEMS) { showTooManyItemsOverlay(items.length); if (btnEl) { btnEl.disabled = false; } return; } if (isListPage()) { const how = prompt('How many items? number or "all":', 'all'); if (how === null) { if (btnEl) btnEl.disabled = false; return; } const all = how.trim().toLowerCase() === 'all'; const count = all ? Infinity : parseInt(how, 10); if (!all && (isNaN(count) || count < 1)) { alert('Invalid number'); if (btnEl) btnEl.disabled = false; return; } if (!all) items = items.slice(0, count); } let descriptionText = null; if (GM_GetOrSet('descEnable', true)) { const pageDesc = getListDescriptionFromPage() || DEFAULT_DESCRIPTION; if (GM_GetOrSet('descMode', 'send') === 'edit') { const edited = await editDescriptionOverlay(pageDesc); if (edited === null) { if (btnEl) btnEl.disabled = false; return; } descriptionText = sanitizeDescPreservingBreaks(edited); } else { descriptionText = pageDesc; } } await processItems(items, LOCAL_OVERRIDES); const actionChoice = await resolveActionForThisRun(); if (GM_getValue('kodiAction', 'import_view') === 'ask' && !actionChoice) { if (btnEl) { btnEl.disabled = false; } return; } const info = await buildUrlFromCache(items, descriptionText, actionChoice); const existing = document.getElementById('kodishowurl'); if (existing) existing.remove(); const overlay = document.createElement('div'); overlay.id = 'kodishowurl'; Object.assign(overlay.style, { position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh', background: 'rgba(0,0,0,0.7)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 2147483647, padding: '20px', boxSizing: 'border-box' }); const limitBytes = 65536; const pctUsed = Math.min(100, Math.round((info.urlBytes / limitBytes) * 1000) / 10); const savings = (typeof info.gzBytes === 'number') ? (info.rawBytes - info.gzBytes) : 0; const panel = document.createElement('div'); panel.innerHTML = ` <h2 style="margin:0 0 12px; color:#fff; font-size:18px;">URL</h2> <textarea id="kodishowurl_text" style="width:600px; height:200px; font-size:14px; box-sizing:border-box; white-space:pre-wrap;">${info.url}</textarea> <div style="margin-top:10px; padding:8px; background:#1b1b1b; border:1px solid #333; border-radius:6px; color:#ddd; font-size:12px; line-height:1.5"> <div><strong>Raw URL:</strong> ${info.rawBytes} bytes</div> <div><strong>Gzip+Base64 URL:</strong> ${typeof info.gzBytes === 'number' ? info.gzBytes + ' bytes' : 'n/a (compression unavailable)'}</div> <div><strong>Savings:</strong> ${typeof info.gzBytes === 'number' ? (savings + ' bytes (' + (info.rawBytes ? Math.round((savings / info.rawBytes) * 100) : 0) + '%)') : '—'}</div> <div><strong>Using:</strong> ${info.choice === 'base64' ? 'base64_items (gzip+base64)' : 'list_items (raw JSON)'}</div> <div><strong>Limit usage:</strong> ${info.urlBytes} / ${limitBytes} bytes (${pctUsed}%)</div> </div> <div style="margin-top:12px; text-align:right;"> <button id="kodishowurl_copy" style="margin-right:8px; padding:6px 12px; font-size:14px;">Copy URL</button> <button id="kodishowurl_close" style="padding:6px 12px; font-size:14px;">Close</button> </div> `; Object.assign(panel.style, { background: '#222', padding: '20px', borderRadius: '6px' }); overlay.append(panel); document.body.append(overlay); panel.querySelector('#kodishowurl_copy').onclick = () => { const ta = panel.querySelector('#kodishowurl_text'); if (ta.select) { ta.select(); document.execCommand('copy'); } panel.querySelector('#kodishowurl_copy').textContent = 'Copied!'; }; panel.querySelector('#kodishowurl_close').onclick = () => overlay.remove(); if (btnEl) btnEl.disabled = false; } // ──────────────────────────────────────────────────────────────────────── // Floating UI: ICON (launcher) + Settings button // ──────────────────────────────────────────────────────────────────────── GM_addStyle(` #lb-tmdb-icon-wrap { position: fixed; bottom: 10px; right: 10px; display: flex; gap: 8px; align-items: center; z-index: 2147483647; font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial; } #lb-tmdb-main-icon { width: var(--lb-size, 64px); height: var(--lb-size, 64px); object-fit: contain; border-radius: 10px; background: rgba(0,0,0,0.03); box-shadow: 0 2px 10px rgba(0,0,0,0.12); cursor: pointer; user-select: none; } #lb-tmdb-main-icon:active { transform: translateY(1px); } #lb-settings-btn { width: 36px; height: 36px; display: grid; place-items: center; background: #444; color: #fff; border: none; border-radius: 10px; cursor: pointer; box-shadow: 0 2px 10px rgba(0,0,0,0.2); padding: 0; } #lb-settings-btn svg { display:block } #lb-settings-btn:active { transform: translateY(1px); } @media (prefers-color-scheme: dark) { #lb-tmdb-main-icon { background: rgba(255,255,255,0.06); } #lb-settings-btn { background: #333; } } `); const topRightBar = document.createElement('div'); Object.assign(topRightBar.style, { position: 'fixed', top: '10px', right: '10px', display: 'flex', gap: '8px', zIndex: 2147483647 }); document.body.append(topRightBar); const iconWrap = document.createElement('div'); iconWrap.id = 'lb-tmdb-icon-wrap'; iconWrap.style.setProperty('--lb-size', `${GM_GetOrSet('lb_icon_size', 64)}px`); const mainIcon = document.createElement('img'); mainIcon.id = 'lb-tmdb-main-icon'; mainIcon.alt = 'FLAM Launcher'; mainIcon.src = ''; const settingsBtn = document.createElement('button'); settingsBtn.id = 'lb-settings-btn'; settingsBtn.title = 'Settings'; settingsBtn.innerHTML = ` <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none"> <path d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z" fill="#fff"/> <path d="M20 13.1v-2.2l-2.02-.62a6.93 6.93 0 0 0-.52-1.25l1.1-1.84-1.56-1.56-1.84 1.1c-.4-.2-.82-.37-1.25-.52L13.1 4h-2.2l-.61 2.02c-.43.14-.85.31-1.25.52l-1.84-1.1L5.64 7 6.74 8.84c-.2.4-.37.82-.52 1.25L4 10.9v2.2l2.02.62c.14.43.31.85.52 1.25L5.43 16.8l1.56 1.56 1.84-1.1c.4.2.82.37 1.25.52l.62 2.02h2.2l.62-2.02c.43-.14.85-.31 1.25-.52l1.84 1.1 1.56-1.56-1.1-1.84c.2-.4.37-.82.52-1.25L20 13.1Z" fill="#fff"/> </svg> `; iconWrap.append(mainIcon, settingsBtn); topRightBar.append(iconWrap); // ──────────────────────────────────────────────────────────────────────── // Icon DB + Picker (cached, first-run fetch from GitHub) // ──────────────────────────────────────────────────────────────────────── const ICONS_API_URL = 'https://api.github.com/repos/hcgiub001/letterboxd-tmdb-cache/contents/addon_icons'; const ICON_DB_KEY = 'lb_tmdb_icon_db_v1'; // { icons: [{name,dataUrl}], lastSync } const ICON_SELECTED_NAME_KEY = 'lb_tmdb_icon_selected_name'; const ICON_SIZE_KEY = 'lb_icon_size'; function gmFetchJSON(url) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, headers: { 'Accept': 'application/vnd.github+json' }, onload: (res) => { if (res.status >= 200 && res.status < 300) { try { resolve(JSON.parse(res.responseText)); } catch (e) { reject(e); } } else reject(new Error(`HTTP ${res.status}: ${res.responseText?.slice(0, 200) || ''}`)); }, onerror: reject }); }); } function gmFetchArrayBuffer(url) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, responseType: 'arraybuffer', onload: (res) => { if (res.status >= 200 && res.status < 300) resolve(res.response); else reject(new Error(`HTTP ${res.status}`)); }, onerror: reject }); }); } function arrayBufferToDataURL(buf, mime = 'image/png') { const bytes = new Uint8Array(buf); const chunk = 0x8000; let binary = ''; for (let i = 0; i < bytes.length; i += chunk) { const sub = bytes.subarray(i, i + chunk); binary += String.fromCharCode.apply(null, sub); } return `data:${mime};base64,` + btoa(binary); } async function buildIconDBFromGitHub() { const list = await gmFetchJSON(ICONS_API_URL); const files = (list || []) .filter(x => x && x.type === 'file' && /\.png$/i.test(x.name) && x.download_url) .sort((a, b) => a.name.localeCompare(b.name)); if (!files.length) throw new Error('No PNG files found in addon_icons.'); const downloads = await Promise.all(files.map(async (f) => { const buf = await gmFetchArrayBuffer(f.download_url); const dataUrl = arrayBufferToDataURL(buf, 'image/png'); return { name: f.name, dataUrl }; })); const newDb = { icons: downloads, lastSync: new Date().toISOString() }; GM_setValue(ICON_DB_KEY, newDb); return newDb; } let ICON_DB = GM_getValue(ICON_DB_KEY, null); let ICON_SELECTED_NAME = GM_GetOrSet(ICON_SELECTED_NAME_KEY, ''); let ICON_SIZE = parseInt(GM_GetOrSet(ICON_SIZE_KEY, 64), 10) || 64; async function ensureIconDB() { if (!ICON_DB || !Array.isArray(ICON_DB.icons) || !ICON_DB.icons.length) { try { ICON_DB = await buildIconDBFromGitHub(); } catch (e) { console.error('[Icon DB] Initial build failed:', e); ICON_DB = { icons: [{ name: 'fallback.png', dataUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII=' }], lastSync: new Date().toISOString() }; GM_setValue(ICON_DB_KEY, ICON_DB); } } } function setIconSize(px) { ICON_SIZE = Math.max(16, Math.min(512, +px || 64)); iconWrap.style.setProperty('--lb-size', `${ICON_SIZE}px`); GM_setValue(ICON_SIZE_KEY, ICON_SIZE); } function setSelectedIconByName(name) { ICON_SELECTED_NAME = name || ''; GM_setValue(ICON_SELECTED_NAME_KEY, ICON_SELECTED_NAME); const entry = ICON_DB?.icons?.find(i => i.name === ICON_SELECTED_NAME) || ICON_DB?.icons?.[0]; if (entry) mainIcon.src = entry.dataUrl; } await ensureIconDB(); if (!ICON_SELECTED_NAME && ICON_DB?.icons?.length) { ICON_SELECTED_NAME = ICON_DB.icons[0].name; GM_setValue(ICON_SELECTED_NAME_KEY, ICON_SELECTED_NAME); } setIconSize(ICON_SIZE); setSelectedIconByName(ICON_SELECTED_NAME); // ──────────────────────────────────────────────────────────────────────── // Description editor // ──────────────────────────────────────────────────────────────────────── function editDescriptionOverlay(defaultDesc) { return new Promise(resolve => { const overlay = document.createElement('div'); overlay.id = 'kodieditdesc'; Object.assign(overlay.style, { position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh', background: 'rgba(0,0,0,0.7)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 2147483647, padding: '20px', boxSizing: 'border-box' }); const panel = document.createElement('div'); panel.innerHTML = ` <h2 style="margin:0 0 12px; color:#fff; font-size:18px;">Edit Description</h2> <textarea id="kodieditdesc_text" style="width:600px; height:300px; font-size:14px; box-sizing:border-box; white-space:pre-wrap;">${defaultDesc}</textarea> <div style="margin-top:12px; text-align:right;"> <button id="kodieditdesc_save" style="margin-right:8px; padding:6px 12px; font-size:14px;">Save</button> <button id="kodieditdesc_cancel" style="padding:6px 12px; font-size:14px;">Cancel</button> </div> `; Object.assign(panel.style, { background: '#222', padding: '20px', borderRadius: '6px' }); overlay.append(panel); document.body.append(overlay); panel.querySelector('#kodieditdesc_save').onclick = () => { const val = panel.querySelector('#kodieditdesc_text').value; overlay.remove(); resolve(val); }; panel.querySelector('#kodieditdesc_cancel').onclick = () => { overlay.remove(); resolve(null); }; }); } // ──────────────────────────────────────────────────────────────────────── // ICON CLICK → FLAM run (processing bubble only during work; hide at end) // ──────────────────────────────────────────────────────────────────────── let ICON_BUSY = false; mainIcon.addEventListener('click', async function () { if (ICON_BUSY) return; ICON_BUSY = true; const startTime = performance.now(); showOrUpdateProcessingBubble(mainIcon, ['Processing…'], true); await startRun(); await ensureBaseLoadedOnceForRun(); await loadLocalOverridesOnce(); // Resolve action early let actionChoice = await resolveActionForThisRun(); if (GM_getValue('kodiAction', 'import_view') === 'ask' && !actionChoice) { hideProcessingBubble(true); // hide processing bubble; no extra messages ICON_BUSY = false; return; } let items; try { items = await scrapeItemsSmart(); } catch { alert('Scrape failed'); hideProcessingBubble(true); ICON_BUSY = false; return; } if (!items.length) { alert('No items'); hideProcessingBubble(true); ICON_BUSY = false; return; } if (items.length > MAX_LIST_ITEMS) { showTooManyItemsOverlay(items.length); hideProcessingBubble(true); ICON_BUSY = false; return; } // Cache hit stats BEFORE processing const overrides0 = await getCache(CACHE_KEY); let baseHitCount = 0; let uncachedCount = 0; for (const it of items) { const hasOverride = Number.isFinite(overrides0[it.filmId]); if (hasOverride) continue; const basePacked = baseLookupPacked(Number(it.filmId)); if (basePacked !== undefined) baseHitCount++; else uncachedCount++; } showOrUpdateProcessingBubble(mainIcon, [ 'Processing…', `Cache — binary: ${baseHitCount} • uncached: ${uncachedCount}` ], true); // DESCRIPTION (default ON) let descriptionText = null; if (GM_GetOrSet('descEnable', true)) { const pageDesc = getListDescriptionFromPage() || DEFAULT_DESCRIPTION; if (GM_GetOrSet('descMode', 'send') === 'edit') { const edited = await editDescriptionOverlay(pageDesc); if (edited === null) { hideProcessingBubble(true); ICON_BUSY = false; return; } descriptionText = sanitizeDescPreservingBreaks(edited); } else { descriptionText = pageDesc; } } await processItems(items, LOCAL_OVERRIDES); const info = await buildUrlFromCache(items, descriptionText, actionChoice); showOrUpdateProcessingBubble(mainIcon, [ 'Sending to Kodi…', `URL bytes: ${info.urlBytes}${info.choice === 'base64' ? ' (gzip+base64)' : ' (raw JSON)'}` ], true); const ok = await sendToKodi(info.url); const elapsedMs = Math.max(0, Math.round(performance.now() - startTime)); // IMPORTANT: Hide our processing bubble immediately — do NOT show any extra "Sent!" message. hideProcessingBubble(true); // Show ONLY your original end-of-run info bubble const limitBytes = 65536; const pct = Math.min(100, Math.round((info.urlBytes / limitBytes) * 1000) / 10); const infoLines = [ `📦 Cache — binary: ${baseHitCount} • uncached: ${uncachedCount}`, `📨 Bytes: ${info.urlBytes} / ${limitBytes} (${pct}%) ${info.choice === 'base64' ? '[gzip+base64]' : '[raw JSON]'}`, `⏱ Elapsed: ${(elapsedMs / 1000).toFixed(1)}s`, ok ? '✅ Sent' : '❌ Failed' ]; showSideInfoNearEl(mainIcon, infoLines, 5000); ICON_BUSY = false; }); // ──────────────────────────────────────────────────────────────────────── // Settings UI (Tabbed + Scrollable) — with Icon Picker section // ──────────────────────────────────────────────────────────────────────── settingsBtn.addEventListener('click', showSettings); function showSettings() { if (document.getElementById('kodisettings')) return; const overlay = document.createElement('div'); overlay.id = 'kodisettings'; Object.assign(overlay.style, { position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh', background: 'rgba(0,0,0,0.7)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 2147483647, padding: '20px', boxSizing: 'border-box' }); const panel = document.createElement('div'); panel.innerHTML = ` <div style="display:flex; gap:8px; align-items:center; justify-content:space-between; margin-bottom:8px;"> <h2 style="color:#fff;margin:0">Kodi Settings</h2> <div style="display:flex; gap:6px;"> <button class="kodi-tab-btn" data-tab="general" style="background:#e50914;color:#fff;border:none;border-radius:4px;padding:6px 10px;cursor:pointer">General</button> <button class="kodi-tab-btn" data-tab="tools" style="background:#444;color:#fff;border:none;border-radius:4px;padding:6px 10px;cursor:pointer">Tools</button> </div> </div> <div id="kodi-tab-general" class="kodi-tab" style="display:block"> <h3 style="color:#fff;margin:10px 0 6px;">Launcher Icon</h3> <div style="background:#1b1b1b;border:1px solid #333;border-radius:10px;padding:10px;margin-bottom:12px;color:#ddd"> <div style="display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px"> <div style="display:flex;align-items:center;gap:10px"> <img id="iconPreview" alt="Icon preview" style="width:48px;height:48px;border-radius:10px;border:1px solid #2a2a2a;object-fit:contain; background:#111"/> <div style="font-size:12px;line-height:1.4"> <div><strong>Selected:</strong> <span id="iconSelectedName">—</span></div> <div id="iconMeta" style="opacity:.8">Cached icons: — • Last refresh: —</div> </div> </div> <button id="refreshIconsBtn" style="font-size:12px;padding:6px 10px;border-radius:8px;background:#000;color:#fff;border:none;cursor:pointer">Refresh from GitHub</button> </div> <div id="iconGrid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(48px,1fr));gap:8px;max-height:220px;overflow:auto;padding-right:2px"></div> <div style="display:grid;grid-template-columns:1fr auto;gap:8px;align-items:center;margin-top:10px"> <div> <label style="font-size:13px">Length (px):</label> <input id="iconSizeRange" type="range" min="16" max="512" step="1"> </div> <input id="iconSizeNumber" type="number" min="16" max="512" step="1" style="width:90px;padding:6px 8px;border-radius:8px;border:1px solid #444;background:#111;color:#eee"> </div> </div> <label style="color:#fff">Kodi IP:</label> <input id="kodiIp" style="width:100%;margin-bottom:8px"/> <label style="color:#fff">Kodi Port:</label> <input id="kodiPort" style="width:100%;margin-bottom:8px"/> <label style="color:#fff">Kodi User:</label> <input id="kodiUser" style="width:100%;margin-bottom:8px"/> <label style="color:#fff">Kodi Pass:</label> <input id="kodiPass" type="password" style="width:100%;margin-bottom:12px"/> <label style="color:#fff">Default Action:</label> <select id="kodiAction" style="width:100%;margin-bottom:16px"> <option value="">Omit Action key</option> <option value="view">view</option> <option value="import">import</option> <option value="import_view">import_view</option> <option value="ask">Ask each time</option> </select> <label style="color:#fff">Busy indicator:</label> <select id="indicator" style="width:100%;margin-bottom:16px"> <option value="none">none</option> <option value="busy">busy</option> <option value="progress">progress</option> </select> <label style="color:#fff"><input type="checkbox" id="descEnable"/> Add Description</label> <div id="descOptions" style="margin:8px 0;display:none;color:#fff"> <label>Edit mode:</label> <select id="descMode" style="width:100%;margin-bottom:8px"> <option value="send">Send without editing</option> <option value="edit">Edit before sending</option> </select> </div> <hr style="border-color:#444;margin:14px 0"> <div id="advancedGroup" style="display:none;"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:10px"> <div> <label style="color:#fff">Batch size:</label> <input id="batchSize" type="number" min="1" step="1" style="width:100%"/> </div> <div> <label style="color:#fff">Pause between batches (ms):</label> <input id="pauseMs" type="number" min="0" step="10" style="width:100%"/> </div> <div> <label style="color:#fff">Final retry delay (ms):</label> <input id="finalRetryDelayMs" type="number" min="0" step="10" style="width:100%"/> </div> <div> <label style="color:#fff">Final retry min list size:</label> <input id="finalRetryMinListSize" type="number" min="0" step="1" style="width:100%"/> </div> </div> <hr style="border-color:#444;margin:14px 0"> </div> <div> <h3 style="color:#fff;margin:0 0 8px;">Artwork Options</h3> <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:10px"> <div style="border:1px solid #333;border-radius:6px;padding:10px;"> <label style="color:#fff"><input type="checkbox" id="posterEnable"/> Enable Poster</label> <div style="color:#bbb;font-size:12px;margin:6px 0 8px">Include the <code>poster</code> key in the URL.</div> <label style="color:#fff">Poster selection:</label> <select id="posterStrategy" style="width:100%;margin-top:4px"> <option value="first_4">first_4</option> <option value="random">random</option> </select> </div> <div style="border:1px solid #333;border-radius:6px;padding:10px;"> <label style="color:#fff"><input type="checkbox" id="fanartEnable"/> Enable Fanart</label> <div style="color:#bbb;font-size:12px;margin:6px 0 8px">The <code>fanart</code> key accepts either a strategy or a direct URL.</div> <label style="color:#fff">Fanart selection:</label> <select id="fanartStrategy" style="width:100%;margin-top:4px"> <option value="author_fanart">author_fanart (use page backdrop)</option> <option value="first_4">first_4</option> <option value="random">random</option> </select> <div id="fanartFallbackBox" style="margin-top:8px; display:none;"> <label style="color:#fff">If author_fanart missing, fallback to:</label> <select id="fanartFallback" style="width:100%;margin-top:4px"> <option value="none">none</option> <option value="first_4">first_4</option> <option value="random">random</option> </select> </div> </div> </div> </div> <hr style="border-color:#444;margin:14px 0"> <div> <h3 style="color:#fff;margin:0 0 8px;">Cache Tools</h3> <div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:6px"> <button id="refreshBaseCacheBtn">Refresh Base Cache</button> </div> <div id="cacheToolMsg" style="color:#bbb;font-size:12px;margin-top:8px;"></div> </div> </div> <div id="kodi-tab-tools" class="kodi-tab" style="display:none"> <div style="display:flex; flex-wrap:wrap; gap:8px; margin-bottom:12px;"> <button id="btnShowUrl" title="Build & view plugin URL">Show URL</button> <button id="btnClearCache" title="Clear overrides">Clear Cache</button> </div> <div style="color:#bbb; font-size:12px;"> Use the top-right icon to send. These tools let you preview the URL or clear local overrides. </div> </div> <div style="text-align:right;margin-top:12px; position:sticky; bottom:0; background:#222; padding-top:8px;"> <button id="kodisave" style="margin-right:8px">Save</button> <button id="kodicancel">Close</button> </div> `; Object.assign(panel.style, { background: '#222', padding: '16px', borderRadius: '8px', width: '720px', maxWidth: '95vw', maxHeight: '90vh', overflowY: 'auto', boxSizing: 'border-box' }); overlay.append(panel); document.body.append(overlay); // Tabs const tabBtns = panel.querySelectorAll('.kodi-tab-btn'); function activateTab(name) { panel.querySelectorAll('.kodi-tab').forEach(el => el.style.display = 'none'); const btns = panel.querySelectorAll('.kodi-tab-btn'); btns.forEach(b => { b.style.background = (b.dataset.tab === name) ? '#e50914' : '#444'; }); const shown = panel.querySelector(`#kodi-tab-${name}`); if (shown) shown.style.display = 'block'; } tabBtns.forEach(b => b.addEventListener('click', () => activateTab(b.dataset.tab))); activateTab('general'); // Populate (General) panel.querySelector('#kodiIp').value = GM_getValue('kodiIp', ''); panel.querySelector('#kodiPort').value = GM_getValue('kodiPort', ''); panel.querySelector('#kodiUser').value = GM_getValue('kodiUser', ''); panel.querySelector('#kodiPass').value = GM_GetOrSet('kodiPass', ''); panel.querySelector('#kodiAction').value = GM_getValue('kodiAction', 'import_view'); panel.querySelector('#indicator').value = GM_GetOrSet('indicator', 'busy'); panel.querySelector('#descEnable').checked = GM_GetOrSet('descEnable', true); panel.querySelector('#descMode').value = GM_GetOrSet('descMode', 'send'); const descOpts = panel.querySelector('#descOptions'); panel.querySelector('#descEnable').addEventListener('change', e => { descOpts.style.display = e.target.checked ? 'block' : 'none'; }); if (panel.querySelector('#descEnable').checked) descOpts.style.display = 'block'; // Advanced (hidden but saved) panel.querySelector('#batchSize')?.setAttribute('value', GM_GetOrSet('batchSize', DEFAULT_BATCH_SIZE)); panel.querySelector('#pauseMs')?.setAttribute('value', GM_GetOrSet('pauseMs', DEFAULT_PAUSE_MS)); panel.querySelector('#finalRetryDelayMs')?.setAttribute('value', GM_GetOrSet('finalRetryDelayMs', DEFAULT_FINALRETRY_DELAY)); panel.querySelector('#finalRetryMinListSize')?.setAttribute('value', GM_GetOrSet('finalRetryMinListSize', DEFAULT_FINALRETRY_MIN_LIST_SIZE)); panel.querySelector('#posterEnable').checked = GM_GetOrSet('posterEnable', POSTER_ENABLE_DEFAULT); panel.querySelector('#posterStrategy').value = GM_GetOrSet('posterStrategy', POSTER_STRATEGY_DEFAULT); panel.querySelector('#fanartEnable').checked = GM_GetOrSet('fanartEnable', FANART_ENABLE_DEFAULT); panel.querySelector('#fanartStrategy').value = GM_GetOrSet('fanartStrategy', FANART_STRATEGY_DEFAULT); panel.querySelector('#fanartFallback').value = GM_GetOrSet('fanartFallback', FANART_FALLBACK_DEFAULT); function refreshFanartFallbackVisibility() { const strat = panel.querySelector('#fanartStrategy').value; panel.querySelector('#fanartFallbackBox').style.display = (strat === 'author_fanart') ? 'block' : 'none'; } panel.querySelector('#fanartStrategy').addEventListener('change', refreshFanartFallbackVisibility); refreshFanartFallbackVisibility(); // Icon section population const iconPreview = panel.querySelector('#iconPreview'); const iconNameEl = panel.querySelector('#iconSelectedName'); const iconMeta = panel.querySelector('#iconMeta'); const iconGrid = panel.querySelector('#iconGrid'); const sizeRange = panel.querySelector('#iconSizeRange'); const sizeNumber = panel.querySelector('#iconSizeNumber'); const refreshIconsBtn = panel.querySelector('#refreshIconsBtn'); function updateIconMeta() { const count = ICON_DB?.icons?.length || 0; const when = ICON_DB?.lastSync ? new Date(ICON_DB.lastSync).toLocaleString() : 'never'; iconMeta.textContent = `Cached icons: ${count} • Last refresh: ${when}`; } function populateIconGrid() { iconGrid.innerHTML = ''; if (!ICON_DB?.icons?.length) { iconGrid.textContent = 'No cached icons. Click “Refresh from GitHub”.'; return; } for (const it of ICON_DB.icons) { const img = document.createElement('img'); img.src = it.dataUrl; img.title = it.name; img.alt = it.name; Object.assign(img.style, { width: '100%', aspectRatio: '1 / 1', objectFit: 'contain', background: '#111', border: '1px solid #2a2a2a', borderRadius: '10px', cursor: 'pointer' }); if (it.name === ICON_SELECTED_NAME) { img.style.outline = '2px solid #fff'; img.style.outlineOffset = '2px'; } img.addEventListener('click', () => { ICON_SELECTED_NAME = it.name; setSelectedIconByName(ICON_SELECTED_NAME); iconNameEl.textContent = ICON_SELECTED_NAME; iconGrid.querySelectorAll('img').forEach(g => { g.style.outline = 'none'; g.style.outlineOffset = '0'; }); img.style.outline = '2px solid #fff'; img.style.outlineOffset = '2px'; iconPreview.src = mainIcon.src; }); iconGrid.appendChild(img); } } function syncSizeInputs() { sizeRange.value = String(ICON_SIZE); sizeNumber.value = String(ICON_SIZE); } iconPreview.src = mainIcon.src; iconNameEl.textContent = ICON_SELECTED_NAME || '—'; updateIconMeta(); populateIconGrid(); syncSizeInputs(); sizeRange.addEventListener('input', (e) => { setIconSize(e.target.value); syncSizeInputs(); }); sizeNumber.addEventListener('input', (e) => { setIconSize(e.target.value); syncSizeInputs(); }); refreshIconsBtn.addEventListener('click', async () => { const prev = refreshIconsBtn.textContent; refreshIconsBtn.disabled = true; refreshIconsBtn.textContent = 'Refreshing…'; try { ICON_DB = await buildIconDBFromGitHub(); if (!ICON_DB.icons.find(i => i.name === ICON_SELECTED_NAME)) { ICON_SELECTED_NAME = ICON_DB.icons[0].name; GM_setValue(ICON_SELECTED_NAME_KEY, ICON_SELECTED_NAME); } setSelectedIconByName(ICON_SELECTED_NAME); iconPreview.src = mainIcon.src; iconNameEl.textContent = ICON_SELECTED_NAME; updateIconMeta(); populateIconGrid(); refreshIconsBtn.textContent = 'Refreshed ✓'; await sleep(700); } catch (e) { console.error('[Icon DB] Refresh failed:', e); refreshIconsBtn.textContent = 'Refresh failed'; await sleep(1200); } finally { refreshIconsBtn.textContent = prev; refreshIconsBtn.disabled = false; } }); // Cache tools handlers const cacheMsg = panel.querySelector('#cacheToolMsg'); function setCacheMsg(txt, ok = false) { cacheMsg.textContent = txt || ''; cacheMsg.style.color = ok ? '#8ee6a4' : '#bbb'; } panel.querySelector('#refreshBaseCacheBtn').onclick = async () => { setCacheMsg('Refreshing base cache…'); await refreshBaseBinNow(); setCacheMsg('Base cache refreshed.', true); }; // Tools tab buttons panel.querySelector('#btnShowUrl').onclick = function () { handleShowUrl(this); }; panel.querySelector('#btnClearCache').onclick = async () => { await clearCache(); LOCAL_OVERRIDES = {}; DIRTY_OVERRIDES = false; alert('Overrides cleared'); }; // Save/Close panel.querySelector('#kodisave').onclick = () => { GM_setValue('kodiIp', panel.querySelector('#kodiIp').value.trim()); GM_setValue('kodiPort', panel.querySelector('#kodiPort').value.trim()); GM_setValue('kodiUser', panel.querySelector('#kodiUser').value); GM_setValue('kodiPass', panel.querySelector('#kodiPass').value); GM_setValue('kodiAction', panel.querySelector('#kodiAction').value); GM_setValue('indicator', panel.querySelector('#indicator').value); GM_setValue('descEnable', panel.querySelector('#descEnable').checked); GM_setValue('descMode', panel.querySelector('#descMode').value); const bsEl = panel.querySelector('#batchSize'); const pmEl = panel.querySelector('#pauseMs'); const frdEl = panel.querySelector('#finalRetryDelayMs'); const frsEl = panel.querySelector('#finalRetryMinListSize'); if (bsEl) GM_setValue('batchSize', Math.max(1, parseInt(bsEl.value, 10) || DEFAULT_BATCH_SIZE)); if (pmEl) GM_setValue('pauseMs', Math.max(0, parseInt(pmEl.value, 10) || DEFAULT_PAUSE_MS)); if (frdEl) GM_setValue('finalRetryDelayMs', Math.max(0, parseInt(frdEl.value, 10) || DEFAULT_FINALRETRY_DELAY)); if (frsEl) GM_setValue('finalRetryMinListSize', Math.max(0, parseInt(frsEl.value, 10) || DEFAULT_FINALRETRY_MIN_LIST_SIZE)); GM_setValue('posterEnable', panel.querySelector('#posterEnable').checked); GM_setValue('posterStrategy', panel.querySelector('#posterStrategy').value); GM_setValue('fanartEnable', panel.querySelector('#fanartEnable').checked); GM_setValue('fanartStrategy', panel.querySelector('#fanartStrategy').value); GM_setValue('fanartFallback', panel.querySelector('#fanartFallback').value); overlay.remove(); alert('✅ Settings saved'); }; panel.querySelector('#kodicancel').onclick = () => overlay.remove(); } })();