您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Letterboxd-style launcher
// ==UserScript== // @name IMDb List/Watchlist → FLAM Launcher // @namespace http://tampermonkey.net/ // @version 3.2.0 // @description Letterboxd-style launcher // @match https://*.imdb.com/list/ls* // @match https://*.imdb.com/list/ls*/* // @match https://*.imdb.com/user/*/watchlist* // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_addStyle // @run-at document-idle // @connect * // @connect api.themoviedb.org // @connect api.github.com // @connect raw.githubusercontent.com // ==/UserScript== ;(async function () { 'use strict'; // ──────────────────────────────────────────────────────────────────────── // Config // ──────────────────────────────────────────────────────────────────────── const TMDB_API_KEY = 'f090bb54758cabf231fb605d3e3e0468'; // Base cache (gzipped u32 pairs: imdbNumeric → packed) const BASE_GZ_URL = 'https://raw.githubusercontent.com/hcgiub001/letterboxd-tmdb-cache/main/imdb_tmdb_pairs_u32.bin.gz'; // Resolver & fetch limits const CONCURRENCY_TMDB_FIND = 50; const CONCURRENCY_PAGE_FETCH = 3; // keep the 3-thread pool for pagination const MAX_LIST_ITEMS = 5000; const URL_LIMIT_BYTES = 65536; const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; const NEGATIVE_TTL_MS = THIRTY_DAYS_MS; // 30 days // IndexedDB stores/keys const CACHE_DB_NAME = 'FenlightCacheDB'; const CACHE_DB_VERSION = 2; const OVERRIDES_STORE = 'tmdbCache'; const BASE_STORE = 'tmdbBase'; // Overrides & negative caches const OVERRIDES_KEY = 'imdb_tmdb_cache_overrides'; // { 'tt…': packed } const OVERRIDES_BACKUP_KEY = 'imdb_tmdb_cache_backup'; const NEGATIVE_KEY = 'imdb_tmdb_cache_negative'; // { 'tt…': lastFailedTs } const NEGATIVE_BACKUP_KEY = 'imdb_tmdb_cache_negative_bak'; // Base cache metadata keys const BASE_BIN_KEY = 'imdb_base_bin'; // ArrayBuffer: unzipped .bin (u32 pairs) const BASE_ETAG_KEY = 'imdb_base_gz_etag'; // last ETag of gz const BASE_COUNT_KEY = 'imdb_base_count'; // pair count (info only) // URL/UX defaults const MEDIA_TYPE_DEFAULT_FIXED = 'm'; const DEFAULT_INDICATOR = 'progress'; // Artwork defaults (match Letterboxd) const POSTER_ENABLE_DEFAULT = true; const POSTER_STRATEGY_DEFAULT = 'first_4'; const FANART_ENABLE_DEFAULT = true; const FANART_STRATEGY_DEFAULT = 'first_4'; const FANART_FALLBACK_DEFAULT = 'first_4'; // Info button + last stats keys const INFO_BTN_ENABLE_KEY = 'showInfoButton'; const LAST_STATS_KEY = 'lastSendStats'; // Description defaults const DEFAULT_DESCRIPTION = ''; // ──────────────────────────────────────────────────────────────────────── // Tiny utils & text helpers (match LB) // ──────────────────────────────────────────────────────────────────────── const sleep = (ms) => new Promise(r => setTimeout(r, ms)); const utf8ByteLen = (s) => new TextEncoder().encode(s).length; const gmGetOrSet = (k, d) => (typeof GM_getValue(k) === 'undefined' ? d : GM_getValue(k)); // Basic page-type detection function isWatchlistPath() { // /user/{id or name}/watchlist/ (with or without query) return /^\/user\/[^/]+\/watchlist\/?$/i.test(location.pathname) || /^\/user\/[^/]+\/watchlist\/\S*/i.test(location.pathname); } function htmlToTextWithBreaks(html) { if (!html) return ''; let t = html; t = t.replace(/<br\s*\/?>/gi, '\n'); t = t.replace(/<\/p>\s*<p[^>]*>/gi, '\n\n'); t = t.replace(/<\/?p[^>]*>/gi, ''); t = t.replace(/<li[^>]*>\s*/gi, '• '); t = t.replace(/<\/li>\s*/gi, '\n'); t = t.replace(/<\/?ul[^>]*>/gi, ''); t = t.replace(/<\/?ol[^>]*>/gi, ''); t = t.replace(/<[^>]+>/g, ''); const ta = document.createElement('textarea'); ta.innerHTML = t.replace(/ /gi, '\u00A0'); t = ta.value; t = t.replace(/\u00A0/g, ' '); t = t.replace(/\r\n/g, '\n').replace(/^\s+/, '').replace(/\s+$/, ''); return t; } function sanitizeDescPreservingBreaks(s) { if (!s) return ''; return s.replace(/\u00A0/g, ' ') .replace(/\r\n/g, '\n') .replace(/\t/g, ' ') .trim(); } // ──────────────────────────────────────────────────────────────────────── // IMDb scraping (pagination + metadata) — LISTS + WATCHLISTS // ──────────────────────────────────────────────────────────────────────── // Shared helpers function parseNextDataDoc(doc = document) { const el = doc.getElementById('__NEXT_DATA__'); if (!el || !el.textContent) return null; try { return JSON.parse(el.textContent); } catch { return null; } } function parseNextDataFromHTML(html) { // fast/robust: find the script tag content const m = html.match(/<script[^>]*id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/i); if (!m) return null; try { return JSON.parse(m[1]); } catch { return null; } } // ----- METADATA (name/author/desc) ----- function getListName() { // LISTS: as before let el = document.querySelector('span.hero__primary-text[data-testid="hero__primary-text"]'); if (el && el.textContent) return el.textContent.trim(); // WATCHLISTS: try generic h1 / header const h1 = document.querySelector('h1, [data-testid="list-page-atf-title-block"] h1, [data-testid="list-page-atf-title-block"] [data-testid="hero__primary-text"]'); if (h1 && h1.textContent) return h1.textContent.trim(); // Fallback: __NEXT_DATA__ const nd = parseNextDataDoc(); const nameFromND = nd?.props?.pageProps?.pageTitle || nd?.props?.pageProps?.listTitle || nd?.props?.pageProps?.seo?.metadata?.title || nd?.props?.pageProps?.seo?.meta?.title || ''; if (nameFromND) return String(nameFromND).replace(/\s*-\s*IMDb\s*$/i, '').trim(); // Final fallback: document.title return (document.title || 'IMDb List').replace(/\s*-\s*IMDb\s*$/i, '').trim(); } function getAuthor() { // LISTS: as before const a = document.querySelector('[data-testid="list-author-link"]'); if (a && a.textContent) return a.textContent.trim(); // WATCHLISTS: attempt to locate an author/owner link // Common patterns: link to /user/{id}/ … const owner = document.querySelector('a[href^="/user/"][data-testid*="author"], a[href^="/user/"][data-testid*="owner"]') || document.querySelector('[data-testid="list-owner"] a[href^="/user/"]') || document.querySelector('.ipc-title__subtitle a[href^="/user/"]') || document.querySelector('a[href^="/user/"]:not([href*="watchlist"])'); if (owner && owner.textContent) return owner.textContent.trim(); // Fallback: __NEXT_DATA__ (if any) const nd = parseNextDataDoc(); const ownerName = nd?.props?.pageProps?.owner?.name || nd?.props?.pageProps?.user?.name || nd?.props?.pageProps?.userName || ''; if (ownerName) return String(ownerName).trim(); return ''; } function getDescriptionRawHTML() { // LISTS: current selector (keep) const el = document.querySelector('.list-description-content .ipc-html-content-inner-div'); if (el && el.innerHTML) return el.innerHTML; // WATCHLISTS: often no description; try generic content area const w = document.querySelector('[data-testid="list-page-description"], [data-testid="list-page-atf-title-block"] .ipc-html-content-inner-div'); if (w && w.innerHTML) return w.innerHTML; return ''; } function getDescriptionText() { return htmlToTextWithBreaks(getDescriptionRawHTML() || ''); } // ----- LIST PAGES (unchanged logic) ----- function extractItemList(html) { // robustly parse the item list from <script type="application/ld+json"> const re = /<script\s+type="application\/ld\+json">([\s\S]*?)<\/script>/g; let m; while ((m = re.exec(html))) { try { const j = JSON.parse(m[1]); if (j['@type'] === 'ItemList') return j.itemListElement; } catch {} } return []; } async function gatherItemsFromListPages() { const origin = location.origin; const basePath = location.pathname.replace(/\?.*$/,''); const sel = document.getElementById('listPagination'); const total = sel ? sel.options.length : 1; // page 1 const scripts = Array.from(document.querySelectorAll('script[type="application/ld+json"]')); let page1 = null; for (const s of scripts) { try { const j = JSON.parse(s.textContent); if (j['@type'] === 'ItemList') { page1 = j.itemListElement; break; } } catch {} } if (!page1) { alert('⚠️ Failed to parse page 1'); return null; } // rest const urls = []; for (let p = 2; p <= total; p++) urls.push(`${origin+basePath}?page=${p}`); const rest = await (async function limitedFetch(urls, limit = CONCURRENCY_PAGE_FETCH) { const out = new Array(urls.length); let next = 0, active = 0; return new Promise((resolve) => { const launch = () => { while (active < limit && next < urls.length) { const i = next++, u = urls[i]; active++; fetch(u, { credentials: 'include' }) .then(r => r.ok ? r.text() : Promise.reject(new Error('HTTP '+r.status))) .then(html => { out[i] = extractItemList(html); }) .catch(() => { out[i] = []; }) .finally(() => { active--; (next < urls.length) ? launch() : (active === 0 && resolve(out)); }); } }; urls.length ? launch() : resolve(out); }); })(urls, CONCURRENCY_PAGE_FETCH); const all = page1.concat(...rest); return all .map(e => { const idMatch = (e.item && e.item.url) ? e.item.url.match(/tt\d+/) : null; const t = (e.item && e.item['@type']) ? String(e.item['@type']) : ''; const mtHint = (t === 'Movie') ? 'm' : (/^TV/i.test(t) ? 'tv' : 'm'); return idMatch ? { imdbId: idMatch[0], mtHint, listIndex: e.position || 0 } : null; }) .filter(Boolean); } // ----- WATCHLIST PAGES (new) ----- function extractWatchlistEdgesFromND(nd) { const mcd = nd?.props?.pageProps?.mainColumnData; const container = mcd?.predefinedList || mcd?.list || mcd?.titleList || null; const edges = container?.titleListItemSearch?.edges; return Array.isArray(edges) ? edges : []; } function edgesToItems(edges) { const items = []; for (const e of edges) { const imdbId = e?.listItem?.id; // "tt…" if (!imdbId) continue; const pos = e?.node?.absolutePosition || e?.node?.rank || 0; const tt = (e?.listItem?.titleType?.id || e?.listItem?.titleType?.text || '').toString().toLowerCase(); const mtHint = tt.startsWith('tv') ? 'tv' : 'm'; items.push({ imdbId, mtHint, listIndex: pos }); } return items; } function getWatchlistPageCountFromDoc(doc) { // Try dropdown first const sel = doc.querySelector('#listPagination'); if (sel?.options?.length) return sel.options.length; // Try __NEXT_DATA__ totalItems const nd = parseNextDataDoc(doc); const totalItems = nd?.props?.pageProps?.totalItems; if (Number.isFinite(totalItems)) return Math.max(1, Math.ceil(totalItems / 250)); // Fallback: 1 return 1; } async function gatherItemsFromWatchlist() { const base = new URL(location.href); base.searchParams.set('page', '1'); // Use current DOM for page 1 const firstND = parseNextDataDoc(document); if (!firstND) { alert('⚠️ Could not read watchlist data on page 1'); return null; } const maxPages = getWatchlistPageCountFromDoc(document); const resultsByPage = new Map(); resultsByPage.set(1, edgesToItems(extractWatchlistEdgesFromND(firstND))); // Build remaining page URLs const urls = []; for (let p = 2; p <= maxPages; p++) { const u = new URL(base.toString()); u.searchParams.set('page', String(p)); urls.push({ p, url: u.toString() }); } // Pool=3 fetch of pages 2..N, parse __NEXT_DATA__, convert to items await (async function poolFetchWatchlist(pairs, limit = CONCURRENCY_PAGE_FETCH) { let i = 0, active = 0; return new Promise((resolve) => { const launch = () => { while (active < limit && i < pairs.length) { const idx = i++; active++; const { p, url } = pairs[idx]; fetch(url, { credentials: 'include' }) .then(r => r.ok ? r.text() : Promise.reject(new Error('HTTP '+r.status))) .then(html => { const nd = parseNextDataFromHTML(html); const edges = extractWatchlistEdgesFromND(nd || {}); resultsByPage.set(p, edgesToItems(edges)); }) .catch(() => { resultsByPage.set(p, []); }) .finally(() => { active--; (i < pairs.length) ? launch() : (active === 0 && resolve()); }); } }; pairs.length ? launch() : resolve(); }); })(urls, CONCURRENCY_PAGE_FETCH); // Combine in order const all = []; for (let p = 1; p <= maxPages; p++) { const arr = resultsByPage.get(p); if (Array.isArray(arr)) all.push(...arr); } return all; } // Unified entry (keeps all prior behavior, just adds watchlists) async function gatherItems() { if (isWatchlistPath()) { const items = await gatherItemsFromWatchlist(); return items || []; } // default: list pages const items = await gatherItemsFromListPages(); return items || []; } // Try to provide an "author_fanart" equivalent on IMDb: use og:image if present function getAuthorFanartUrl_IMDb() { const og = document.querySelector('meta[property="og:image"]')?.getAttribute('content') || ''; return og || ''; } // ──────────────────────────────────────────────────────────────────────── // TMDB resolver (/find/{imdb_id}) with 50-concurrency (kept) // ──────────────────────────────────────────────────────────────────────── async function tmdbFindByImdb(ttid) { const url = `https://api.themoviedb.org/3/find/${ttid}?` + new URLSearchParams({ api_key: TMDB_API_KEY, external_source: 'imdb_id' }); for (let i = 0; i < 3; i++) { try { const r = await fetch(url); if (r.ok) { const j = await r.json(); const mov = Array.isArray(j.movie_results) ? j.movie_results : []; const tv = Array.isArray(j.tv_results) ? j.tv_results : []; const ep = Array.isArray(j.tv_episode_results) ? j.tv_episode_results : []; if (mov.length) return { tmdbId: mov[0].id, isTv: false }; if (tv.length) return { tmdbId: tv[0].id, isTv: true }; if (ep.length) { const showId = ep[0].show_id || ep[0].id || null; if (showId) return { tmdbId: showId, isTv: true }; } return null; } if (r.status === 429) await sleep(250 * (i + 1)); else return null; } catch { await sleep(200 * (i + 1)); } } return null; } // ──────────────────────────────────────────────────────────────────────── // IndexedDB helpers + base cache load/refresh (kept & aligned) // ──────────────────────────────────────────────────────────────────────── function openDB() { return new Promise((resolve, reject) => { const rq = indexedDB.open(CACHE_DB_NAME, CACHE_DB_VERSION); rq.onupgradeneeded = () => { const db = rq.result; if (!db.objectStoreNames.contains(OVERRIDES_STORE)) db.createObjectStore(OVERRIDES_STORE); if (!db.objectStoreNames.contains(BASE_STORE)) db.createObjectStore(BASE_STORE); }; rq.onsuccess = () => resolve(rq.result); rq.onerror = () => reject(rq.error); }); } async function idbGet(store, key) { const db = await openDB(); return new Promise((res, rej) => { const tx = db.transaction(store, 'readonly'); const rq = tx.objectStore(store).get(key); rq.onsuccess = () => res(rq.result); rq.onerror = () => rej(rq.error); }); } async function idbPut(store, key, val) { const db = await openDB(); return new Promise((res, rej) => { const tx = db.transaction(store, 'readwrite'); const rq = tx.objectStore(store).put(val, key); rq.onsuccess = () => res(); rq.onerror = () => rej(rq.error); }); } async function idbDel(store, key) { const db = await openDB(); return new Promise((res, rej) => { const tx = db.transaction(store, 'readwrite'); const rq = tx.objectStore(store).delete(key); rq.onsuccess = () => res(); rq.onerror = () => rej(rq.error); }); } let BASE_PAIRS_U32 = null; // Uint32Array (k0, v0, k1, v1, ...) let BASE_PAIR_COUNT = 0; function isLittleEndian() { const b = new ArrayBuffer(4); new DataView(b).setUint32(0, 0x11223344, true); return new Uint8Array(b)[0] === 0x44; } const HOST_LE = isLittleEndian(); function parseBaseBinToU32(buf) { if (!buf || !buf.byteLength) return false; if (buf.byteLength % 8 !== 0) return false; if (HOST_LE) { BASE_PAIRS_U32 = new Uint32Array(buf); } else { const view = new DataView(buf); const out = new Uint32Array(buf.byteLength / 4); for (let i = 0; i < out.length; i++) out[i] = view.getUint32(i * 4, true); BASE_PAIRS_U32 = out; } if (BASE_PAIRS_U32.length % 2 !== 0) return false; BASE_PAIR_COUNT = BASE_PAIRS_U32.length >>> 1; return true; } function baseLookupPackedByNumericImdb(imdbNumeric) { if (!BASE_PAIRS_U32) return undefined; let lo = 0, hi = BASE_PAIR_COUNT - 1; while (lo <= hi) { const mid = (lo + hi) >>> 1; const k = BASE_PAIRS_U32[mid << 1]; if (k === imdbNumeric) return BASE_PAIRS_U32[(mid << 1) + 1]; if (k < imdbNumeric) lo = mid + 1; else hi = mid - 1; } return undefined; } function unpackPacked(packed) { return { isTv: !!(packed & 1), tmdbId: packed >>> 1 }; } function gmFetchArrayBuffer(url, headers = {}) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, headers, responseType: 'arraybuffer', onload: (res) => { if (res.status >= 200 && res.status < 300) resolve({ buf: res.response, headers: res.responseHeaders || '', status: res.status }); else if (res.status === 304) resolve({ buf: null, headers: res.responseHeaders || '', status: 304 }); else reject(new Error(`HTTP ${res.status}`)); }, onerror: (e) => reject(e.error || e), ontimeout: () => reject(new Error('Timeout')), }); }); } async function unzipGzipToArrayBuffer(gzBuf) { if (typeof DecompressionStream !== 'function') { throw new Error('DecompressionStream(gzip) not supported by this browser.'); } const ds = new DecompressionStream('gzip'); return await new Response(new Blob([gzBuf]).stream().pipeThrough(ds)).arrayBuffer(); } async function loadBaseFromIDB() { const buf = await idbGet(BASE_STORE, BASE_BIN_KEY); const count = await idbGet(BASE_STORE, BASE_COUNT_KEY); if (!buf || !buf.byteLength) return false; if (!parseBaseBinToU32(buf)) return false; if (typeof count === 'number') BASE_PAIR_COUNT = count; return true; } async function saveBaseToIDB(buf, etag) { await idbPut(BASE_STORE, BASE_BIN_KEY, buf); await idbPut(BASE_STORE, BASE_ETAG_KEY, etag || ''); await idbPut(BASE_STORE, BASE_COUNT_KEY, BASE_PAIR_COUNT); } async function refreshBaseCacheNow(showAlerts = true) { try { const prevEtag = (await idbGet(BASE_STORE, BASE_ETAG_KEY)) || ''; const headers = prevEtag ? { 'If-None-Match': prevEtag } : {}; const { buf: gzBuf, headers: respHeaders, status } = await gmFetchArrayBuffer(BASE_GZ_URL, headers); if (status === 304) { GM_setValue('baseLastRevalidateTs', Date.now()); if (showAlerts) alert('✅ Base cache already up to date.'); return true; } const m = String(respHeaders || '').match(/etag:\s*([^\r\n]+)/i); const etag = m ? m[1].trim() : ''; const bin = await unzipGzipToArrayBuffer(gzBuf); if (!parseBaseBinToU32(bin)) throw new Error('Invalid base cache (not u32 pairs).'); await saveBaseToIDB(bin, etag); GM_setValue('baseLastRevalidateTs', Date.now()); if (showAlerts) alert(`✅ Base cache refreshed (${BASE_PAIR_COUNT.toLocaleString()} pairs).`); return true; } catch (e) { console.error('[Base cache] refresh failed:', e); if (showAlerts) alert('❌ Base cache refresh failed: ' + e.message); return false; } } async function maybeRevalidateBaseMonthly() { try { const last = gmGetOrSet('baseLastRevalidateTs', 0); const now = Date.now(); if (now - last >= THIRTY_DAYS_MS) { await refreshBaseCacheNow(false); GM_setValue('baseLastRevalidateTs', Date.now()); } } catch {} } async function ensureBaseLoadedOnce() { if (await loadBaseFromIDB()) return true; await refreshBaseCacheNow(false); // first-time fetch return await loadBaseFromIDB(); } // ──────────────────────────────────────────────────────────────────────── // Overrides + Negative caches (kept) // ──────────────────────────────────────────────────────────────────────── let LOCAL_OVERRIDES = null; let DIRTY_OVERRIDES = false; let LOCAL_NEGATIVE = null; let DIRTY_NEGATIVE = false; async function overridesLoadOnce() { if (!LOCAL_OVERRIDES) LOCAL_OVERRIDES = (await idbGet(OVERRIDES_STORE, OVERRIDES_KEY)) || {}; } async function overridesPersist() { if (!DIRTY_OVERRIDES) return; await idbPut(OVERRIDES_STORE, OVERRIDES_KEY, LOCAL_OVERRIDES); try { await idbPut(OVERRIDES_STORE, OVERRIDES_BACKUP_KEY, JSON.parse(JSON.stringify(LOCAL_OVERRIDES))); } catch {} DIRTY_OVERRIDES = false; } async function overridesClear() { await idbDel(OVERRIDES_STORE, OVERRIDES_KEY); await idbDel(OVERRIDES_STORE, OVERRIDES_BACKUP_KEY); LOCAL_OVERRIDES = {}; DIRTY_OVERRIDES = false; } async function negativeLoadOnce() { if (!LOCAL_NEGATIVE) LOCAL_NEGATIVE = (await idbGet(OVERRIDES_STORE, NEGATIVE_KEY)) || {}; } function negativeGetTs(ttid) { return LOCAL_NEGATIVE ? LOCAL_NEGATIVE[ttid] : undefined; } function negativeIsFresh(ttid, now = Date.now()) { const ts = negativeGetTs(ttid); if (!Number.isFinite(ts)) return false; return (now - ts) < NEGATIVE_TTL_MS; } function negativeSetNow(ttid, now = Date.now()) { if (!LOCAL_NEGATIVE) LOCAL_NEGATIVE = {}; LOCAL_NEGATIVE[ttid] = now; DIRTY_NEGATIVE = true; } async function negativePersist() { if (!DIRTY_NEGATIVE) return; await idbPut(OVERRIDES_STORE, NEGATIVE_KEY, LOCAL_NEGATIVE); try { await idbPut(OVERRIDES_STORE, NEGATIVE_BACKUP_KEY, JSON.parse(JSON.stringify(LOCAL_NEGATIVE))); } catch {} DIRTY_NEGATIVE = false; } async function negativeClear() { await idbDel(OVERRIDES_STORE, NEGATIVE_KEY); await idbDel(OVERRIDES_STORE, NEGATIVE_BACKUP_KEY); LOCAL_NEGATIVE = {}; DIRTY_NEGATIVE = false; } function overrideGetPacked(ttid) { const v = LOCAL_OVERRIDES ? LOCAL_OVERRIDES[ttid] : undefined; return Number.isFinite(v) ? v : undefined; } function overrideSetPacked(ttid, tmdbId, isTv) { if (!LOCAL_OVERRIDES) LOCAL_OVERRIDES = {}; const packed = (tmdbId << 1) | (isTv ? 1 : 0); if (LOCAL_OVERRIDES[ttid] !== packed) { LOCAL_OVERRIDES[ttid] = packed; DIRTY_OVERRIDES = true; } } function getFromAnyCachePacked(ttid) { // 1) overrides by 'tt…' const ov = overrideGetPacked(ttid); if (ov !== undefined) return ov; // 2) base by numeric key const m = ttid.match(/^tt(\d+)$/); if (m) { const num = Number(m[1]); if (Number.isFinite(num)) { const b = baseLookupPackedByNumericImdb(num); if (b !== undefined) return b; } } return undefined; } // ──────────────────────────────────────────────────────────────────────── // Gzip+Base64 builder (B-style) // ──────────────────────────────────────────────────────────────────────── async function gzipBase64(str) { try { if (typeof CompressionStream !== 'function') return null; const enc = new TextEncoder().encode(str); const cs = new CompressionStream('gzip'); const gzStream = new Blob([enc]).stream().pipeThrough(cs); const ab = await new Response(gzStream).arrayBuffer(); const u8 = new Uint8Array(ab); let bin = ''; const chunk = 0x8000; for (let i = 0; i < u8.length; i += chunk) { const sub = u8.subarray(i, i + chunk); bin += String.fromCharCode.apply(null, sub); } return btoa(bin); } catch { return null; } } // ──────────────────────────────────────────────────────────────────────── // Build Fenlight URL — Artwork omitted when action === 'view' // ──────────────────────────────────────────────────────────────────────── async function buildFenUrlTMDB_Art(resolvedItems, opts) { const { listName, author, description, action = '', posterEnabled, posterStrategy, fanartEnabled, fanartStrategy, fanartFallback } = opts; const listItems = resolvedItems.map(it => it.isTv ? ({ id: it.tmdbId, mt: 'tv' }) : ({ id: it.tmdbId })); const base = 'plugin://plugin.video.fenlight/?'; const common = [ ['mode', 'personal_lists.external'], ...(action ? [['action', action]] : []), ['list_type', 'tmdb'], ['list_name', listName || 'IMDb List'], ['author', author || ''], ['media_type_default', MEDIA_TYPE_DEFAULT_FIXED], ['busy_indicator', gmGetOrSet('indicator', DEFAULT_INDICATOR)], ...(description ? [['description', description]] : []), ]; // IMPORTANT CHANGE: If action is 'view', do NOT include poster/fanart keys const artworkAllowed = (String(action).toLowerCase() !== 'view'); // Artwork (match LB exactly, but only if allowed) if (artworkAllowed && posterEnabled) { common.push(['poster', posterStrategy]); // 'first_4' or 'random' } if (artworkAllowed && fanartEnabled) { if (fanartStrategy === 'author_fanart') { let url = getAuthorFanartUrl_IMDb(); if (url) { common.push(['fanart', url]); } else { if (fanartFallback === 'first_4' || fanartFallback === 'random') { common.push(['fanart', fanartFallback]); } // 'none' ⇒ omit fanart key } } else { // direct strategy: 'first_4' or 'random' common.push(['fanart', fanartStrategy]); } } const rawJson = JSON.stringify(listItems); const encode = (arr) => arr.map(([k,v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&'); // Raw const rawParts = [...common, ['list_items', rawJson]]; const rawUrl = base + encode(rawParts); const rawBytes = utf8ByteLen(rawUrl); // Gzip+Base64 (if supported) let gzB64 = await gzipBase64(rawJson); let gzUrl = null, gzBytes = null; if (gzB64) { const gzParts = [...common, ['base64_items', gzB64]]; gzUrl = base + encode(gzParts); gzBytes = utf8ByteLen(gzUrl); } // Choose const url = gzUrl || rawUrl; const urlBytes = (gzBytes != null) ? gzBytes : rawBytes; return { url, urlBytes, rawUrl, rawBytes, gzUrl, gzBytes }; } // ──────────────────────────────────────────────────────────────────────── // Kodi JSON-RPC + trust preflight (kept) // ──────────────────────────────────────────────────────────────────────── function preflightKodiPermission() { const ip = GM_getValue('kodiIp','').trim(); const port = GM_getValue('kodiPort','').trim(); const user = GM_getValue('kodiUser',''); const pass = gmGetOrSet('kodiPass',''); if (!ip || !port) return; // nothing to preflight try { GM_xmlhttpRequest({ method: 'POST', url: `http://${ip}:${port}/jsonrpc`, headers: { 'Content-Type':'application/json', 'Authorization':'Basic ' + btoa(`${user}:${pass}`) }, data: JSON.stringify({ jsonrpc:'2.0', id:0, method:'JSONRPC.Ping' }), timeout: 7000, onload: () => {}, onerror: () => {}, ontimeout: () => {} }); } catch {} } function sendToKodi(url) { const ip = GM_getValue('kodiIp','').trim(); const port = GM_getValue('kodiPort','').trim(); const user = GM_getValue('kodiUser',''); const pass = gmGetOrSet('kodiPass',''); return new Promise((resolve) => { if (!ip || !port) { alert('⚠️ Please configure Kodi IP & port in settings.'); resolve(false); return; } GM_xmlhttpRequest({ method: 'POST', url: `http://${ip}:${port}/jsonrpc`, headers: { 'Content-Type':'application/json', 'Authorization':'Basic ' + btoa(`${user}:${pass}`) }, data: JSON.stringify({ jsonrpc:'2.0', id:1, method:'Player.Open', params:{ item:{ file:url } } }), timeout: 15000, onload: (res) => resolve(res.status >= 200 && res.status < 300), onerror: () => resolve(false), ontimeout: () => resolve(false), }); }); } // ──────────────────────────────────────────────────────────────────────── // UI: bubbles, overlays, and ASK — EXACT same visual as Letterboxd // ──────────────────────────────────────────────────────────────────────── function showSideInfoNearEl(el, lines, ms = 5000) { try { const prev = document.getElementById('kodi-send-info'); if (prev) prev.remove(); } catch {} const box = document.createElement('div'); box.id = 'kodi-send-info'; Object.assign(box.style, { position:'fixed', top:'0px', right:'130px', background:'#1b1b1b', color:'#eee', border:'1px solid #333', borderRadius:'6px', padding:'8px 10px', fontSize:'12px', lineHeight:'1.4', zIndex:2147483647, boxShadow:'0 2px 10px rgba(0,0,0,.35)', maxWidth:'360px', whiteSpace:'pre-wrap', pointerEvents:'none' }); box.innerText = lines.join('\n'); document.body.append(box); let topPx = 150; try { const rect = el.getBoundingClientRect(); const boxH = box.offsetHeight; topPx = Math.max(10, Math.min(window.innerHeight - boxH - 10, Math.round(rect.top + 40))); } catch {} box.style.top = `${topPx}px`; setTimeout(() => { try { box.remove(); } catch {} }, ms); } let PROCESS_BUBBLE_EL = null; function showOrUpdateProcessingBubble(targetEl, lines, persist = false) { const GAP = 10; if (!PROCESS_BUBBLE_EL) { PROCESS_BUBBLE_EL = document.createElement('div'); PROCESS_BUBBLE_EL.id = 'kodi-processing-bubble'; Object.assign(PROCESS_BUBBLE_EL.style, { position:'fixed', background:'#1b1b1b', color:'#eee', border:'1px solid #333', borderRadius:'8px', padding:'8px 10px', fontSize:'12px', lineHeight:'1.4', zIndex:2147483647, boxShadow:'0 2px 10px rgba(0,0,0,.35)', maxWidth:'360px', whiteSpace:'pre-wrap' }); document.body.append(PROCESS_BUBBLE_EL); } PROCESS_BUBBLE_EL.textContent = lines.join('\n'); const rect = targetEl.getBoundingClientRect(); const topPx = Math.max(0, Math.round(rect.top)); const rightPx = Math.max(0, Math.round(window.innerWidth - rect.left + GAP)); PROCESS_BUBBLE_EL.style.top = `${topPx}px`; PROCESS_BUBBLE_EL.style.right = `${rightPx}px`; PROCESS_BUBBLE_EL.dataset.persist = persist ? '1' : '0'; PROCESS_BUBBLE_EL.style.display = 'block'; } function hideProcessingBubble(force = false) { if (PROCESS_BUBBLE_EL) { if (force || PROCESS_BUBBLE_EL.dataset.persist !== '1') { PROCESS_BUBBLE_EL.remove(); PROCESS_BUBBLE_EL = null; } } } // Action chooser — FLAM-styled (MOBILE-SAFE) — same as LB function askForActionOverlay() { return new Promise((resolve) => { const overlay = document.createElement('div'); overlay.id = 'flam-action-overlay'; const SAFE_INSET = 16; Object.assign(overlay.style, { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.66)', display: 'grid', placeItems: 'center', zIndex: 2147483647, padding: `${SAFE_INSET}px`, boxSizing: 'border-box' }); const panel = document.createElement('div'); panel.className = 'flam-action-panel'; Object.assign(panel.style, { position: 'relative', width: `min(760px, calc(100vw - ${SAFE_INSET * 2}px))`, maxWidth: `calc(100vw - ${SAFE_INSET * 2}px)`, maxHeight: `calc(100vh - ${SAFE_INSET * 2}px)`, overflow: 'auto', background: 'linear-gradient(180deg,#2a2a2a 0,#1f1f1f 100%)', color: '#e8e8e8', border: '1px solid rgba(255,255,255,.85)', borderRadius: '22px', boxShadow: '0 18px 44px rgba(0,0,0,.55)', padding: '28px 22px 20px', boxSizing: 'border-box' }); const css = document.createElement('style'); css.textContent = ` #flam-action-overlay .flam-title { text-transform: uppercase; letter-spacing: .08em; text-align: center; font-weight: 700; color: #dcdcdc; font-size: 18px; margin: 0 0 12px; } #flam-action-overlay .flam-sub { text-align: center; color: #bdbdbd; font-size: 14px; margin-bottom: 18px; } #flam-action-overlay .flam-logo { position: absolute; top: 12px; left: 12px; width: 46px; height: 46px; border-radius: 12px; object-fit: contain; background: #111; border: 1px solid #2b2b2b; box-shadow: 0 2px 10px rgba(0,0,0,.35); } #flam-action-overlay .choices { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 14px; } @media (max-width: 720px) { #flam-action-overlay .choices { grid-template-columns: 1fr; } } #flam-action-overlay .choice-btn { appearance: none; border: 1px solid rgba(255,255,255,.08); border-radius: 20px; padding: 18px 12px; background: #4b444b; color: #ffffff; font-size: 16px; font-weight: 700; box-shadow: inset 0 -2px 0 rgba(0,0,0,.12), 0 2px 8px rgba(0,0,0,.25); cursor: pointer; transition: background .12s ease, color .12s ease, transform .06s ease, outline-color .12s ease; outline: none; width: 100%; } #flam-action-overlay .choice-btn:focus-visible { background: #4b444b; color: #ffffff; outline: 2px solid rgba(255,255,255,.9); outline-offset: 2px; } #flam-action-overlay .choice-btn:hover, #flam-action-overlay .choice-btn.touching { background: #ffffff; color: #0b0b0b; } #flam-action-overlay .choice-btn:active { transform: translateY(1px); } #flam-action-overlay .footer { display:flex; justify-content:flex-end; margin-top:16px; } #flam-action-overlay .cancel-btn { background:#3a3a3a; color:#fff; border:none; border-radius:10px; padding:8px 12px; cursor:pointer; } `; panel.append(css); const logo = document.createElement('img'); logo.className = 'flam-logo'; try { if (typeof mainIcon !== 'undefined' && mainIcon && mainIcon.src) logo.src = mainIcon.src; } catch {} panel.append(logo); const title = document.createElement('div'); title.className = 'flam-title'; title.textContent = 'Send to FLAM'; const sub = document.createElement('div'); sub.className = 'flam-sub'; sub.textContent = 'Choose what you want to do with this list'; const choicesWrap = document.createElement('div'); choicesWrap.className = 'choices'; function mkBtn(label, value) { const b = document.createElement('button'); b.type = 'button'; b.className = 'choice-btn'; b.textContent = label; b.addEventListener('pointerdown', () => b.classList.add('touching'), { passive: true }); b.addEventListener('pointerup', () => b.classList.remove('touching')); b.addEventListener('pointercancel', () => b.classList.remove('touching')); b.addEventListener('click', () => cleanup(value)); return b; } const btnView = mkBtn('View', 'view'); const btnImport = mkBtn('Import', 'import'); const btnBoth = mkBtn('Import + View', 'import_view'); choicesWrap.append(btnView, btnImport, btnBoth); const footer = document.createElement('div'); footer.className = 'footer'; const cancel = document.createElement('button'); cancel.className = 'cancel-btn'; cancel.textContent = 'Cancel'; cancel.addEventListener('click', () => cleanup(null)); footer.append(cancel); panel.append(title, sub, choicesWrap, footer); overlay.append(panel); document.body.append(overlay); function onKey(e) { if (e.key === 'Escape') cleanup(null); } function onBackdrop(e) { if (e.target === overlay) cleanup(null); } overlay.addEventListener('click', onBackdrop); document.addEventListener('keydown', onKey); btnView.focus(); function cleanup(val) { document.removeEventListener('keydown', onKey); try { overlay.remove(); } catch {} resolve(val); } }); } function showTooManyItemsOverlay(totalCount) { const existing = document.getElementById('kodi-too-many-items'); if (existing) try { existing.remove(); } catch {} const overlay = document.createElement('div'); overlay.id = 'kodi-too-many-items'; Object.assign(overlay.style, { position:'fixed', inset:0, background:'rgba(0,0,0,0.7)', display:'flex', alignItems:'center', justifyContent:'center', zIndex:2147483647, padding:'20px', boxSizing:'border-box' }); const panel = document.createElement('div'); Object.assign(panel.style, { background:'#222', color:'#eee', borderRadius:'10px', width:'560px', maxWidth:'95vw', padding:'20px', boxShadow:'0 10px 30px rgba(0,0,0,0.45)', border:'1px solid #333' }); panel.innerHTML = ` <div style="display:flex;align-items:center;gap:10px;margin-bottom:8px"> <div style="background:#e50914;width:28px;height:28px;border-radius:6px;display:flex;align-items:center;justify-content:center;font-weight:800">!</div> <h2 style="margin:0;font-size:18px;">This list is a little too epic to send in one go</h2> </div> <div style="font-size:13px; color:#ccc; line-height:1.55;"> Your list contains <strong>${totalCount.toLocaleString()}</strong> items. For reliability, sending lists over <strong>${MAX_LIST_ITEMS.toLocaleString()}</strong> items isn’t supported in a single run. <br><br> Try sending it in smaller parts (for example, a few pages at a time) or filter the list and send again. </div> <div style="margin-top:14px;text-align:right;"> <button id="kodi-too-many-items-ok" style="background:#e50914;color:#fff;border:none;border-radius:6px;padding:8px 12px;cursor:pointer">Got it</button> </div> `; overlay.append(panel); panel.querySelector('#kodi-too-many-items-ok').onclick = () => { try { overlay.remove(); } catch {} }; document.body.append(overlay); } // Description editor (same as LB) function editDescriptionOverlay(defaultDesc) { return new Promise(resolve => { const overlay = document.createElement('div'); overlay.id = 'kodieditdesc'; Object.assign(overlay.style, { position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh', background: 'rgba(0,0,0,0.7)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 2147483647, padding: '20px', boxSizing: 'border-box' }); const panel = document.createElement('div'); panel.innerHTML = ` <h2 style="margin:0 0 12px; color:#fff; font-size:18px;">Edit Description</h2> <textarea id="kodieditdesc_text" style="width:600px; max-width:95vw; height:300px; font-size:14px; box-sizing:border-box; white-space:pre-wrap;">${defaultDesc}</textarea> <div style="margin-top:12px; text-align:right;"> <button id="kodieditdesc_save" style="margin-right:8px; padding:6px 12px; font-size:14px;">Save</button> <button id="kodieditdesc_cancel" style="padding:6px 12px; font-size:14px;">Cancel</button> </div> `; Object.assign(panel.style, { background: '#222', padding: '20px', borderRadius: '6px' }); overlay.append(panel); document.body.append(overlay); panel.querySelector('#kodieditdesc_save').onclick = () => { const val = panel.querySelector('#kodieditdesc_text').value; overlay.remove(); resolve(val); }; panel.querySelector('#kodieditdesc_cancel').onclick = () => { overlay.remove(); resolve(null); }; }); } // ──────────────────────────────────────────────────────────────────────── // Processing core (partition, resolve, persist) // ──────────────────────────────────────────────────────────────────────── async function processAll(ttItems) { const baseHits = []; const overrideHits = []; const negativeHits = []; const toResolve = []; const now = Date.now(); // Partition by caches for (const it of ttItems) { const packed = getFromAnyCachePacked(it.imdbId); if (packed !== undefined) { const { tmdbId, isTv } = unpackPacked(packed); if (overrideGetPacked(it.imdbId) !== undefined) overrideHits.push({ ...it, tmdbId, isTv }); else baseHits.push({ ...it, tmdbId, isTv }); continue; } if (negativeIsFresh(it.imdbId, now)) { negativeHits.push({ ...it }); } else { toResolve.push(it); } } const preCounts = { base: baseHits.length, overrides: overrideHits.length, cachedNoId: negativeHits.length, uncached: toResolve.length }; // Resolve uncached via TMDB (pool) if (toResolve.length) { const launchEl = mainIcon || document.body; showOrUpdateProcessingBubble(launchEl, [ 'Resolving via TMDB…', `Uncached: ${toResolve.length}`, `Pool: ${CONCURRENCY_TMDB_FIND}` ], true); const results = await (async function resolveUncachedWithPool(ttids, onProgress) { let idx = 0, active = 0, done = 0; const results = Object.create(null); return new Promise((resolve) => { const launch = () => { while (active < CONCURRENCY_TMDB_FIND && idx < ttids.length) { const ttid = ttids[idx++]; active++; (async () => { let tries = 0, res = null; while (tries < 3 && res === null) { tries++; try { res = await tmdbFindByImdb(ttid); } catch { res = null; await sleep(300 * tries); } } results[ttid] = res; })().finally(() => { active--; done++; if (typeof onProgress === 'function') onProgress(done, ttids.length); if (idx < ttids.length) launch(); else if (active === 0) resolve(results); }); } }; ttids.length ? launch() : resolve(results); }); })(toResolve.map(x => x.imdbId), (d, total) => { showOrUpdateProcessingBubble(launchEl, [ 'Resolving via TMDB…', `Progress: ${d} / ${total}`, `Pool: ${CONCURRENCY_TMDB_FIND}` ], true); }); hideProcessingBubble(true); for (const it of toResolve) { const r = results[it.imdbId]; if (r && r.tmdbId) { overrideSetPacked(it.imdbId, r.tmdbId, !!r.isTv); overrideHits.push({ ...it, tmdbId: r.tmdbId, isTv: !!r.isTv }); } else { negativeSetNow(it.imdbId, now); negativeHits.push({ ...it }); } } await overridesPersist(); await negativePersist(); } const resolved = baseHits.concat(overrideHits); return { resolved, counts: preCounts }; } // ──────────────────────────────────────────────────────────────────────── // Launcher ICON + Settings + optional INFO button (LETTERBOXD-STYLE) // ──────────────────────────────────────────────────────────────────────── GM_addStyle(` #lb-tmdb-icon-wrap { position: fixed; bottom: 10px; right: 10px; display: flex; gap: 8px; align-items: stretch; z-index: 2147483647; font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial; } #lb-tmdb-main-icon { width: var(--lb-size, 64px); height: var(--lb-size, 64px); object-fit: contain; border-radius: 10px; background: rgba(0,0,0,0.03); box-shadow: 0 2px 10px rgba(0,0,0,0.12); cursor: pointer; user-select: none; } #lb-tmdb-main-icon:active { transform: translateY(1px); } /* Right column that matches the icon's full height */ #lb-side-buttons { display: flex; flex-direction: column; gap: 8px; height: var(--lb-size, 64px); width: 36px; } #lb-settings-btn, #lb-info-btn { flex: 1; width: 36px; display: grid; place-items: center; background: #444; color: #fff; border: none; border-radius: 10px; cursor: pointer; box-shadow: 0 2px 10px rgba(0,0,0,0.2); padding: 0; } #lb-settings-btn:active, #lb-info-btn:active { transform: translateY(1px); } #lb-settings-btn svg, #lb-info-btn svg { display:block } @media (prefers-color-scheme: dark) { #lb-tmdb-main-icon { background: rgba(255,255,255,0.06); } #lb-settings-btn, #lb-info-btn { background: #333; } } `); const topRightBar = document.createElement('div'); Object.assign(topRightBar.style, { position: 'fixed', top: '10px', right: '10px', display: 'flex', gap: '8px', zIndex: 2147483647 }); document.body.append(topRightBar); const iconWrap = document.createElement('div'); iconWrap.id = 'lb-tmdb-icon-wrap'; iconWrap.style.setProperty('--lb-size', `${gmGetOrSet('lb_icon_size', 64)}px`); const mainIcon = document.createElement('img'); mainIcon.id = 'lb-tmdb-main-icon'; mainIcon.alt = 'FLAM Launcher'; mainIcon.src = ''; // Right-side vertical buttons column const sideButtons = document.createElement('div'); sideButtons.id = 'lb-side-buttons'; const settingsBtn = document.createElement('button'); settingsBtn.id = 'lb-settings-btn'; settingsBtn.title = 'Settings'; settingsBtn.innerHTML = ` <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none"> <path d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z" fill="#fff"/> <path d="M20 13.1v-2.2l-2.02-.62a6.93 6.93 0 0 0-.52-1.25l1.1-1.84-1.56-1.56-1.84 1.1c-.4-.2-.82-.37-1.25-.52L13.1 4h-2.2l-.61 2.02c-.43.14-.85.31-1.25.52l-1.84-1.1L5.64 7 6.74 8.84c-.2.4-.37.82-.52 1.25L4 10.9v2.2l2.02.62c.14.43.31.85.52 1.25L5.43 16.8l1.56 1.56 1.84-1.1c.4.2.82.37 1.25.52l.62 2.02h2.2l.62-2.02c.43-.14.85-.31 1.25-.52l1.84 1.1 1.56-1.56-1.1-1.84c.2-.4.37-.82.52-1.25L20 13.1Z" fill="#fff"/> </svg> `; // NEW: Info button (simple white "i" silhouette) — toggled by setting const infoBtn = document.createElement('button'); infoBtn.id = 'lb-info-btn'; infoBtn.title = 'Last Send Info'; infoBtn.innerHTML = ` <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true"> <path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Z" fill="#fff" opacity=".12"/> <path d="M12 6.75a1.25 1.25 0 1 1 0 2.5 1.25 1.25 0 0 1 0-2.5Zm-1.25 4.25a1 1 0 0 0-1 1v5.25a1 1 0 1 0 2 0V12h1.25a1 1 0 1 0 0-2H10.75Z" fill="#fff"/> <circle cx="12" cy="12" r="9" stroke="#fff" stroke-width="1.5" opacity=".85"/> </svg> `; sideButtons.append(settingsBtn, infoBtn); iconWrap.append(mainIcon, sideButtons); topRightBar.append(iconWrap); // Icon DB + Picker (same as LB) const ICONS_API_URL = 'https://api.github.com/repos/hcgiub001/letterboxd-tmdb-cache/contents/addon_icons'; const ICON_DB_KEY = 'lb_tmdb_icon_db_v1'; const ICON_SELECTED_NAME_KEY = 'lb_tmdb_icon_selected_name'; const ICON_SIZE_KEY = 'lb_icon_size'; function gmFetchJSON(url) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, headers: { 'Accept': 'application/vnd.github+json' }, onload: (res) => { if (res.status >= 200 && res.status < 300) { try { resolve(JSON.parse(res.responseText)); } catch (e) { reject(e); } } else reject(new Error(`HTTP ${res.status}`)); }, onerror: reject }); }); } function gmFetchArrayBuffer2(url) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, responseType: 'arraybuffer', onload: (res) => { if (res.status >= 200 && res.status < 300) resolve(res.response); else reject(new Error(`HTTP ${res.status}`)); }, onerror: reject }); }); } function arrayBufferToDataURL(buf, mime = 'image/png') { const bytes = new Uint8Array(buf); const chunk = 0x8000; let binary = ''; for (let i = 0; i < bytes.length; i += chunk) { const sub = bytes.subarray(i, i + chunk); binary += String.fromCharCode.apply(null, sub); } return `data:${mime};base64,` + btoa(binary); } async function buildIconDBFromGitHub() { const list = await gmFetchJSON(ICONS_API_URL); const files = (list || []) .filter(x => x && x.type === 'file' && /\.png$/i.test(x.name) && x.download_url) .sort((a, b) => a.name.localeCompare(b.name)); if (!files.length) throw new Error('No PNG files found in addon_icons.'); const downloads = await Promise.all(files.map(async (f) => { const buf = await gmFetchArrayBuffer2(f.download_url); const dataUrl = arrayBufferToDataURL(buf, 'image/png'); return { name: f.name, dataUrl }; })); const newDb = { icons: downloads, lastSync: new Date().toISOString() }; GM_setValue(ICON_DB_KEY, newDb); return newDb; } let ICON_DB = GM_getValue(ICON_DB_KEY, null); let ICON_SELECTED_NAME = gmGetOrSet(ICON_SELECTED_NAME_KEY, ''); let ICON_SIZE = parseInt(gmGetOrSet(ICON_SIZE_KEY, 64), 10) || 64; async function ensureIconDB() { if (!ICON_DB || !Array.isArray(ICON_DB.icons) || !ICON_DB.icons.length) { try { ICON_DB = await buildIconDBFromGitHub(); } catch (e) { console.error('[Icon DB] Initial build failed:', e); ICON_DB = { icons: [{ name: 'fallback.png', dataUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII=' }], lastSync: new Date().toISOString() }; GM_setValue(ICON_DB_KEY, ICON_DB); } } } function setIconSize(px) { ICON_SIZE = Math.max(16, Math.min(512, +px || 64)); iconWrap.style.setProperty('--lb-size', `${ICON_SIZE}px`); GM_setValue(ICON_SIZE_KEY, ICON_SIZE); } function setSelectedIconByName(name) { ICON_SELECTED_NAME = name || ''; GM_setValue(ICON_SELECTED_NAME_KEY, ICON_SELECTED_NAME); const entry = ICON_DB?.icons?.find(i => i.name === ICON_SELECTED_NAME) || ICON_DB?.icons?.[0]; if (entry) mainIcon.src = entry.dataUrl; } await ensureIconDB(); if (!ICON_SELECTED_NAME && ICON_DB?.icons?.length) { ICON_SELECTED_NAME = ICON_DB.icons[0].name; GM_setValue(ICON_SELECTED_NAME_KEY, ICON_SELECTED_NAME); } setIconSize(ICON_SIZE); setSelectedIconByName(ICON_SELECTED_NAME); // NEW: Info button visibility control function updateInfoButtonVisibility() { const show = gmGetOrSet(INFO_BTN_ENABLE_KEY, true); infoBtn.style.display = show ? 'grid' : 'none'; // If hidden, let settings fill full height (already flex:1) — done via CSS layout. } updateInfoButtonVisibility(); // ──────────────────────────────────────────────────────────────────────── // Run control // ──────────────────────────────────────────────────────────────────────── async function startRun() { await ensureBaseLoadedOnce(); await overridesLoadOnce(); await negativeLoadOnce(); setTimeout(() => { maybeRevalidateBaseMonthly(); }, 0); } async function resolveActionForThisRun() { const stored = GM_getValue('kodiAction', 'ask'); if (stored === 'ask') return await askForActionOverlay(); return stored; } // ──────────────────────────────────────────────────────────────────────── // Show URL (Tools) — LB-style panel and bytes/savings readout // ──────────────────────────────────────────────────────────────────────── async function handleShowUrl(btnEl) { if (btnEl) btnEl.disabled = true; await startRun(); let items = await gatherItems(); if (!items || !items.length) { alert('No items'); if (btnEl) btnEl.disabled = false; return; } if (items.length > MAX_LIST_ITEMS) { showTooManyItemsOverlay(items.length); if (btnEl) btnEl.disabled = false; return; } let how = prompt('How many items? number or "all":', 'all'); if (how === null) { if (btnEl) btnEl.disabled = false; return; } how = how.trim().toLowerCase(); if (how !== 'all') { const n = parseInt(how, 10); if (isNaN(n) || n < 1) { alert('Invalid number'); if (btnEl) btnEl.disabled = false; return; } items = items.slice(0, n); } const { resolved, counts } = await processAll(items); const listName = getListName(); const author = getAuthor(); // Description (same behavior as Letterboxd = optional edit) let descriptionText = ''; if (gmGetOrSet('descEnable', true)) { const pageDesc = getDescriptionText() || DEFAULT_DESCRIPTION; if (gmGetOrSet('descMode', 'send') === 'edit') { const edited = await editDescriptionOverlay(pageDesc); if (edited === null) { if (btnEl) btnEl.disabled = false; return; } descriptionText = sanitizeDescPreservingBreaks(edited); } else { descriptionText = pageDesc; } } // Action (ASK matches LB) let action = GM_getValue('kodiAction', 'ask'); if (action === 'ask') { const chosen = await askForActionOverlay(); if (!chosen && chosen !== '') { if (btnEl) btnEl.disabled = false; return; } // cancel action = chosen || ''; } // Artwork options (same keys & behavior as LB) const posterEnabled = gmGetOrSet('posterEnable', POSTER_ENABLE_DEFAULT); const posterStrategy = gmGetOrSet('posterStrategy', POSTER_STRATEGY_DEFAULT); const fanartEnabled = gmGetOrSet('fanartEnable', FANART_ENABLE_DEFAULT); const fanartStrategy = gmGetOrSet('fanartStrategy', FANART_STRATEGY_DEFAULT); const fanartFallback = gmGetOrSet('fanartFallback', FANART_FALLBACK_DEFAULT); const fen = await buildFenUrlTMDB_Art(resolved, { listName, author, description: descriptionText, action, posterEnabled, posterStrategy, fanartEnabled, fanartStrategy, fanartFallback }); const pct = Math.min(100, Math.round((fen.urlBytes / URL_LIMIT_BYTES) * 1000) / 10); const savings = (typeof fen.gzBytes === 'number') ? (fen.rawBytes - fen.gzBytes) : 0; const existing = document.getElementById('kodishowurl'); if (existing) existing.remove(); const overlay = document.createElement('div'); overlay.id = 'kodishowurl'; Object.assign(overlay.style, { position:'fixed', top:0, left:0, width:'100vw', height:'100vh', background:'rgba(0,0,0,0.7)', display:'flex', alignItems:'center', justifyContent:'center', zIndex:2147483647, padding:'20px', boxSizing:'border-box' }); const panel = document.createElement('div'); Object.assign(panel.style, { background:'#222', padding:'20px', borderRadius:'6px', maxWidth:'95vw' }); panel.innerHTML = ` <h2 style="margin:0 0 12px; color:#fff; font-size:18px;">URL (${fen.gzUrl ? 'gzip+base64' : 'raw JSON'})</h2> <textarea id="kodishowurl_text" style="width:600px; max-width:95vw; height:200px; font-size:14px; box-sizing:border-box; white-space:pre-wrap;">${fen.url}</textarea> <div style="margin-top:10px; padding:8px; background:#1b1b1b; border:1px solid #333; border-radius:6px; color:#ddd; font-size:12px; line-height:1.5"> <div><strong>Raw URL:</strong> ${fen.rawBytes} bytes</div> <div><strong>Gzip+Base64 URL:</strong> ${typeof fen.gzBytes === 'number' ? fen.gzBytes + ' bytes' : 'n/a (compression unavailable)'}</div> <div><strong>Savings:</strong> ${typeof fen.gzBytes === 'number' ? (savings + ' bytes (' + (fen.rawBytes ? Math.round((savings / fen.rawBytes) * 100) : 0) + '%)') : '—'}</div> <div><strong>Using:</strong> ${fen.gzUrl ? 'base64_items (gzip+base64)' : 'list_items (raw JSON)'}</div> <div><strong>Limit usage:</strong> ${fen.urlBytes} / ${URL_LIMIT_BYTES} (${pct}%)</div> <div><strong>Cache:</strong> base ${counts.base} • overrides ${counts.overrides} • cached_no_id ${counts.cachedNoId} • uncached ${counts.uncached} (Σ=${counts.base+counts.overrides+counts.cachedNoId+counts.uncached} / ${items.length})</div> </div> <div style="margin-top:12px; text-align:right;"> <button id="kodishowurl_copy" style="margin-right:8px; padding:6px 12px; font-size:14px;">Copy URL</button> <button id="kodishowurl_close" style="padding:6px 12px; font-size:14px;">Close</button> </div> `; overlay.append(panel); document.body.append(overlay); panel.querySelector('#kodishowurl_copy').onclick = () => { const ta = panel.querySelector('#kodishowurl_text'); ta.select(); document.execCommand('copy'); panel.querySelector('#kodishowurl_copy').textContent = 'Copied!'; }; panel.querySelector('#kodishowurl_close').onclick = () => overlay.remove(); if (btnEl) btnEl.disabled = false; } // ──────────────────────────────────────────────────────────────────────── // Main icon click → EARLY ASK + TRUST PREFLIGHT + full run (LB styling) // ──────────────────────────────────────────────────────────────────────── let ICON_BUSY = false; mainIcon.addEventListener('click', async function () { if (ICON_BUSY) return; ICON_BUSY = true; const startTime = performance.now(); // Trigger TM trust prompt immediately (harmless ping) preflightKodiPermission(); showOrUpdateProcessingBubble(mainIcon, ['Processing…'], true); await startRun(); // Show ASK immediately (if configured) while processing runs const stored = GM_getValue('kodiAction', 'ask'); const actionPromise = (stored === 'ask') ? askForActionOverlay() : Promise.resolve(stored); // Process in background const processingPromise = (async () => { let items; try { items = await gatherItems(); } catch { alert('Scrape failed'); hideProcessingBubble(true); ICON_BUSY = false; return null; } if (!items || !items.length) { alert('No items'); hideProcessingBubble(true); ICON_BUSY = false; return null; } if (items.length > MAX_LIST_ITEMS) { showTooManyItemsOverlay(items.length); hideProcessingBubble(true); ICON_BUSY = false; return null; } // Pre-cache stats const overrides0 = (await idbGet(OVERRIDES_STORE, OVERRIDES_KEY)) || {}; let baseHitCount = 0, overrideHitCount = 0, uncachedCount = 0; for (const it of items) { const fid = String(it.imdbId || ''); const inOverrides = Number.isFinite(overrides0[fid]); if (inOverrides) { overrideHitCount++; continue; } const m = fid.match(/^tt(\d+)$/); const basePacked = m ? baseLookupPackedByNumericImdb(Number(m[1])) : undefined; if (basePacked !== undefined) baseHitCount++; else uncachedCount++; } const totalPlanned = baseHitCount + overrideHitCount + uncachedCount; showOrUpdateProcessingBubble(mainIcon, [ 'Processing…', `Items selected: ${items.length}`, `Cache — base: ${baseHitCount} • overrides: ${overrideHitCount} • uncached: ${uncachedCount}`, `Sum check: ${totalPlanned} / ${items.length}` ], true); const { resolved, counts } = await processAll(items); // Description (same as LB) let descriptionText = ''; if (gmGetOrSet('descEnable', true)) { const pageDesc = getDescriptionText() || DEFAULT_DESCRIPTION; if (gmGetOrSet('descMode', 'send') === 'edit') { const edited = await editDescriptionOverlay(pageDesc); if (edited === null) { hideProcessingBubble(true); ICON_BUSY = false; return null; } descriptionText = sanitizeDescPreservingBreaks(edited); } else { descriptionText = pageDesc; } } const listName = getListName(); const author = getAuthor(); return { resolved, counts, listName, author, descriptionText, totalItems: items.length }; })(); const action = await actionPromise; const proc = await processingPromise; if (!proc) { ICON_BUSY = false; return; } if (stored === 'ask' && !action) { hideProcessingBubble(true); ICON_BUSY = false; return; } // canceled // Artwork options pulled from settings (LB parity) const posterEnabled = gmGetOrSet('posterEnable', POSTER_ENABLE_DEFAULT); const posterStrategy = gmGetOrSet('posterStrategy', POSTER_STRATEGY_DEFAULT); const fanartEnabled = gmGetOrSet('fanartEnable', FANART_ENABLE_DEFAULT); const fanartStrategy = gmGetOrSet('fanartStrategy', FANART_STRATEGY_DEFAULT); const fanartFallback = gmGetOrSet('fanartFallback', FANART_FALLBACK_DEFAULT); showOrUpdateProcessingBubble(mainIcon, [ 'Building URL…', (String(action).toLowerCase() === 'view') ? `Artwork disabled for 'view'` : (posterEnabled ? `Poster: ${posterStrategy}` : `Poster: disabled`), (String(action).toLowerCase() === 'view') ? `` : (fanartEnabled ? `Fanart: ${fanartStrategy}${fanartStrategy==='author_fanart' ? ` (fallback: ${fanartFallback})` : ''}` : `Fanart: disabled`) ].filter(Boolean), true); const fen = await buildFenUrlTMDB_Art(proc.resolved, { listName: proc.listName, author: proc.author, description: proc.descriptionText, action: action || '', posterEnabled, posterStrategy, fanartEnabled, fanartStrategy, fanartFallback }); showOrUpdateProcessingBubble(mainIcon, [ 'Sending to Kodi…', `URL bytes: ${fen.urlBytes} ${fen.gzUrl ? '(gzip+base64)' : '(raw JSON)'}` ], true); const ok = await sendToKodi(fen.url); const elapsedMs = Math.max(0, Math.round(performance.now() - startTime)); hideProcessingBubble(true); const pct = Math.min(100, Math.round((fen.urlBytes / URL_LIMIT_BYTES) * 1000) / 10); const lines = [ `Items sent: ${proc.resolved.length}`, `📦 Cache — base: ${proc.counts.base} • overrides: ${proc.counts.overrides} • cached_no_id: ${proc.counts.cachedNoId} • uncached: ${proc.counts.uncached}`, `Σ = ${proc.counts.base + proc.counts.overrides + proc.counts.cachedNoId + proc.counts.uncached} / ${proc.totalItems}`, `📨 Bytes: ${fen.urlBytes} / ${URL_LIMIT_BYTES} (${pct}%) ${fen.gzUrl ? '[gzip+base64]' : '[raw JSON]'}`, `⏱ Elapsed: ${(elapsedMs/1000).toFixed(1)}s`, ok ? '✅ Sent' : '❌ Failed' ]; showSideInfoNearEl(mainIcon, lines, 7000); // NEW: persist last send stats for Info button try { GM_setValue(LAST_STATS_KEY, { lines, when: Date.now() }); } catch {} ICON_BUSY = false; }); // NEW: Info button handler — show last saved stats infoBtn.addEventListener('click', () => { const last = gmGetOrSet(LAST_STATS_KEY, null); if (!last || !Array.isArray(last.lines)) { showSideInfoNearEl(mainIcon, ['No previous send stats found.', 'Send a list to populate this.'], 5000); return; } const when = new Date(last.when || Date.now()); const whenStr = `${when.toLocaleDateString()} ${when.toLocaleTimeString()}`; const lines = [...last.lines, `🕒 Last sent: ${whenStr}`]; showSideInfoNearEl(mainIcon, lines, 8000); }); // ──────────────────────────────────────────────────────────────────────── // Settings (Letterboxd-style): General + Tools tabs (kept, plus info toggle) // ──────────────────────────────────────────────────────────────────────── settingsBtn.addEventListener('click', showSettings); function showSettings() { if (document.getElementById('kodisettings')) return; const overlay = document.createElement('div'); overlay.id = 'kodisettings'; Object.assign(overlay.style, { position:'fixed', top:0, left:0, width:'100vw', height:'100vh', background:'rgba(0,0,0,0.7)', display:'flex', alignItems:'center', justifyContent:'center', zIndex:2147483647, padding:'20px', boxSizing:'border-box' }); const panel = document.createElement('div'); panel.innerHTML = ` <div style="display:flex; gap:8px; align-items:center; justify-content:space-between; margin-bottom:8px;"> <h2 style="color:#fff;margin:0">Kodi Settings</h2> <div style="display:flex; gap:6px;"> <button class="kodi-tab-btn" data-tab="general" style="background:#e50914;color:#fff;border:none;border-radius:4px;padding:6px 10px;cursor:pointer">General</button> <button class="kodi-tab-btn" data-tab="tools" style="background:#444;color:#fff;border:none;border-radius:4px;padding:6px 10px;cursor:pointer">Tools</button> </div> </div> <div id="kodi-tab-general" class="kodi-tab" style="display:block"> <h3 style="color:#fff;margin:10px 0 6px;">Launcher Icon</h3> <div style="background:#1b1b1b;border:1px solid #333;border-radius:10px;padding:10px;margin-bottom:12px;color:#ddd"> <div style="display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px"> <div style="display:flex;align-items:center;gap:10px"> <img id="iconPreview" alt="Icon preview" style="width:48px;height:48px;border-radius:10px;border:1px solid #2a2a2a;object-fit:contain; background:#111"/> <div style="font-size:12px;line-height:1.4"> <div><strong>Selected:</strong> <span id="iconSelectedName">—</span></div> <div id="iconMeta" style="opacity:.8">Cached icons: — • Last refresh: —</div> </div> </div> <button id="refreshIconsBtn" style="font-size:12px;padding:6px 10px;border-radius:8px;background:#000;color:#fff;border:none;cursor:pointer">Refresh from GitHub</button> </div> <div id="iconGrid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(48px,1fr));gap:8px;max-height:220px;overflow:auto;padding-right:2px"></div> <div style="display:grid;grid-template-columns:1fr auto;gap:8px;align-items:center;margin-top:10px"> <div> <label style="font-size:13px">Length (px):</label> <input id="iconSizeRange" type="range" min="16" max="512" step="1"> </div> <input id="iconSizeNumber" type="number" min="16" max="512" step="1" style="width:90px;padding:6px 8px;border-radius:8px;border:1px solid #444;background:#111;color:#eee"> </div> </div> <div style="background:#1b1b1b;border:1px solid #333;border-radius:10px;padding:10px;margin-bottom:12px;color:#ddd"> <label style="display:flex;align-items:center;gap:8px;"> <input type="checkbox" id="showInfoBtn"> <span>Show Info Button (shows last send stats)</span> </label> <div style="font-size:12px;color:#bbb;margin-top:6px;">When enabled, an Info button appears below the settings button. Clicking it shows the last stats bubble from your most recent send.</div> </div> <label style="color:#fff">Kodi IP:</label> <input id="kodiIp" style="width:100%;margin-bottom:8px"/> <label style="color:#fff">Kodi Port:</label> <input id="kodiPort" style="width:100%;margin-bottom:8px"/> <label style="color:#fff">Kodi User:</label> <input id="kodiUser" style="width:100%;margin-bottom:8px"/> <label style="color:#fff">Kodi Pass:</label> <input id="kodiPass" type="password" style="width:100%;margin-bottom:12px"/> <label style="color:#fff">Default Action:</label> <select id="kodiAction" style="width:100%;margin-bottom:16px"> <option value="">Omit Action key</option> <option value="view">view</option> <option value="import">import</option> <option value="import_view">import_view</option> <option value="ask">Ask each time</option> </select> <label style="color:#fff">Busy indicator:</label> <select id="indicator" style="width:100%;margin-bottom:16px"> <option value="none">none</option> <option value="busy">busy</option> <option value="progress">progress</option> </select> <label style="color:#fff"><input type="checkbox" id="descEnable"/> Add Description</label> <div id="descOptions" style="margin:8px 0;display:none;color:#fff"> <label>Edit mode:</label> <select id="descMode" style="width:100%;margin-bottom:8px"> <option value="send">Send without editing</option> <option value="edit">Edit before sending</option> </select> </div> <hr style="border-color:#444;margin:14px 0"> <div> <h3 style="color:#fff;margin:0 0 8px;">Artwork Options</h3> <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:10px"> <div style="border:1px solid #333;border-radius:6px;padding:10px;"> <label style="color:#fff"><input type="checkbox" id="posterEnable"/> Enable Poster</label> <div style="color:#bbb;font-size:12px;margin:6px 0 8px">Include the <code>poster</code> key in the URL.<br>(Ignored when action=<code>view</code>)</div> <label style="color:#fff">Poster selection:</label> <select id="posterStrategy" style="width:100%;margin-top:4px"> <option value="first_4">first_4</option> <option value="random">random</option> </select> </div> <div style="border:1px solid #333;border-radius:6px;padding:10px;"> <label style="color:#fff"><input type="checkbox" id="fanartEnable"/> Enable Fanart</label> <div style="color:#bbb;font-size:12px;margin:6px 0 8px">The <code>fanart</code> key accepts either a strategy or a direct URL.<br>(Ignored when action=<code>view</code>)</div> <label style="color:#fff">Fanart selection:</label> <select id="fanartStrategy" style="width:100%;margin-top:4px"> <option value="author_fanart">author_fanart (use page og:image)</option> <option value="first_4">first_4</option> <option value="random">random</option> </select> <div id="fanartFallbackBox" style="margin-top:8px; display:none;"> <label style="color:#fff">If author_fanart missing, fallback to:</label> <select id="fanartFallback" style="width:100%;margin-top:4px"> <option value="none">none</option> <option value="first_4">first_4</option> <option value="random">random</option> </select> </div> <div style="color:#888; font-size:12px; margin-top:6px;"> On IMDb, “author fanart” uses the page’s OpenGraph image if present; otherwise the chosen fallback strategy. </div> </div> </div> </div> <hr style="border-color:#444;margin:14px 0"> <div> <h3 style="color:#fff;margin:0 0 8px;">Cache Tools</h3> <div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:6px"> <button id="refreshBaseCacheBtn">Refresh Base Cache</button> </div> <div id="cacheToolMsg" style="color:#bbb;font-size:12px;margin-top:8px;"></div> </div> </div> <div id="kodi-tab-tools" class="kodi-tab" style="display:none"> <div style="display:flex; flex-wrap:wrap; gap:8px; margin-bottom:12px;"> <button id="btnShowUrl" title="Build & view plugin URL">Show URL</button> <button id="btnClearOverrides" title="Clear overrides">Clear Overrides</button> </div> <div style="color:#bbb; font-size:12px;"> Use the bottom-right icon to send. Tools let you preview the URL or clear positive overrides. </div> </div> <div style="text-align:right;margin-top:12px; position:sticky; bottom:0; background:#222; padding-top:8px;"> <button id="kodisave" style="margin-right:8px">Save</button> <button id="kodicancel">Close</button> </div> `; Object.assign(panel.style, { background: '#222', padding: '16px', borderRadius: '8px', width: '720px', maxWidth: '95vw', maxHeight: '90vh', overflowY: 'auto', boxSizing: 'border-box', color:'#fff' }); overlay.append(panel); document.body.append(overlay); const tabBtns = panel.querySelectorAll('.kodi-tab-btn'); function activateTab(name) { panel.querySelectorAll('.kodi-tab').forEach(el => el.style.display = 'none'); const btns = panel.querySelectorAll('.kodi-tab-btn'); btns.forEach(b => { b.style.background = (b.dataset.tab === name) ? '#e50914' : '#444'; }); const shown = panel.querySelector(`#kodi-tab-${name}`); if (shown) shown.style.display = 'block'; } tabBtns.forEach(b => b.addEventListener('click', () => activateTab(b.dataset.tab))); activateTab('general'); // hydrate panel.querySelector('#iconPreview').src = mainIcon.src; panel.querySelector('#kodiIp').value = GM_getValue('kodiIp',''); panel.querySelector('#kodiPort').value = GM_getValue('kodiPort',''); panel.querySelector('#kodiUser').value = GM_getValue('kodiUser',''); panel.querySelector('#kodiPass').value = gmGetOrSet('kodiPass',''); panel.querySelector('#kodiAction').value = GM_getValue('kodiAction','ask'); panel.querySelector('#indicator').value = gmGetOrSet('indicator', DEFAULT_INDICATOR); panel.querySelector('#descEnable').checked = gmGetOrSet('descEnable', true); panel.querySelector('#descMode').value = gmGetOrSet('descMode', 'send'); const descOpts = panel.querySelector('#descOptions'); panel.querySelector('#descEnable').addEventListener('change', e => { descOpts.style.display = e.target.checked ? 'block' : 'none'; }); if (panel.querySelector('#descEnable').checked) descOpts.style.display = 'block'; // Info toggle panel.querySelector('#showInfoBtn').checked = gmGetOrSet(INFO_BTN_ENABLE_KEY, true); panel.querySelector('#posterEnable').checked = gmGetOrSet('posterEnable', POSTER_ENABLE_DEFAULT); panel.querySelector('#posterStrategy').value = gmGetOrSet('posterStrategy', POSTER_STRATEGY_DEFAULT); panel.querySelector('#fanartEnable').checked = gmGetOrSet('fanartEnable', FANART_ENABLE_DEFAULT); panel.querySelector('#fanartStrategy').value = gmGetOrSet('fanartStrategy', FANART_STRATEGY_DEFAULT); panel.querySelector('#fanartFallback').value = gmGetOrSet('fanartFallback', FANART_FALLBACK_DEFAULT); function refreshFanartFallbackVisibility() { const strat = panel.querySelector('#fanartStrategy').value; panel.querySelector('#fanartFallbackBox').style.display = (strat === 'author_fanart') ? 'block' : 'none'; } panel.querySelector('#fanartStrategy').addEventListener('change', refreshFanartFallbackVisibility); refreshFanartFallbackVisibility(); // Icon controls const iconNameEl = panel.querySelector('#iconSelectedName'); const iconMeta = panel.querySelector('#iconMeta'); const iconGrid = panel.querySelector('#iconGrid'); const sizeRange = panel.querySelector('#iconSizeRange'); const sizeNumber = panel.querySelector('#iconSizeNumber'); const refreshIconsBtn = panel.querySelector('#refreshIconsBtn'); function updateIconMeta() { const count = ICON_DB?.icons?.length || 0; const when = ICON_DB?.lastSync ? new Date(ICON_DB.lastSync).toLocaleString() : 'never'; iconMeta.textContent = `Cached icons: ${count} • Last refresh: ${when}`; } function populateIconGrid() { iconGrid.innerHTML = ''; if (!ICON_DB?.icons?.length) { iconGrid.textContent = 'No cached icons. Click “Refresh from GitHub”.'; return; } for (const it of ICON_DB.icons) { const img = document.createElement('img'); img.src = it.dataUrl; img.title = it.name; img.alt = it.name; Object.assign(img.style, { width: '100%', aspectRatio: '1 / 1', objectFit: 'contain', background: '#111', border: '1px solid #2a2a2a', borderRadius: '10px', cursor: 'pointer' }); if (it.name === ICON_SELECTED_NAME) { img.style.outline = '2px solid #fff'; img.style.outlineOffset = '2px'; } img.addEventListener('click', () => { ICON_SELECTED_NAME = it.name; setSelectedIconByName(ICON_SELECTED_NAME); iconNameEl.textContent = ICON_SELECTED_NAME; iconGrid.querySelectorAll('img').forEach(g => { g.style.outline = 'none'; g.style.outlineOffset = '0'; }); img.style.outline = '2px solid #fff'; img.style.outlineOffset = '2px'; panel.querySelector('#iconPreview').src = mainIcon.src; }); iconGrid.appendChild(img); } } function syncSizeInputs() { sizeRange.value = String(ICON_SIZE); sizeNumber.value = String(ICON_SIZE); } iconNameEl.textContent = ICON_SELECTED_NAME || '—'; updateIconMeta(); populateIconGrid(); syncSizeInputs(); sizeRange.addEventListener('input', (e) => { setIconSize(e.target.value); syncSizeInputs(); }); sizeNumber.addEventListener('input', (e) => { setIconSize(e.target.value); syncSizeInputs(); }); refreshIconsBtn.addEventListener('click', async () => { const prev = refreshIconsBtn.textContent; refreshIconsBtn.disabled = true; refreshIconsBtn.textContent = 'Refreshing…'; try { ICON_DB = await buildIconDBFromGitHub(); if (!ICON_DB.icons.find(i => i.name === ICON_SELECTED_NAME)) { ICON_SELECTED_NAME = ICON_DB.icons[0].name; GM_setValue(ICON_SELECTED_NAME_KEY, ICON_SELECTED_NAME); } setSelectedIconByName(ICON_SELECTED_NAME); panel.querySelector('#iconPreview').src = mainIcon.src; iconNameEl.textContent = ICON_SELECTED_NAME; updateIconMeta(); populateIconGrid(); refreshIconsBtn.textContent = 'Refreshed ✓'; await sleep(700); } catch (e) { console.error('[Icon DB] Refresh failed:', e); refreshIconsBtn.textContent = 'Refresh failed'; await sleep(1200); } finally { refreshIconsBtn.textContent = prev; refreshIconsBtn.disabled = false; } }); // Cache tools (base) const cacheMsg = panel.querySelector('#cacheToolMsg'); const setCacheMsg = (txt, ok=false) => { cacheMsg.textContent = txt || ''; cacheMsg.style.color = ok ? '#8ee6a4' : '#bbb'; }; panel.querySelector('#refreshBaseCacheBtn').onclick = async () => { setCacheMsg('Refreshing base cache…'); const ok = await refreshBaseCacheNow(true); setCacheMsg(ok ? `Base cache ready (${BASE_PAIR_COUNT.toLocaleString()} pairs).` : 'Base cache refresh failed.', ok); }; // Tools tab buttons panel.querySelector('#btnShowUrl').onclick = function () { handleShowUrl(this); }; panel.querySelector('#btnClearOverrides').onclick = async () => { await overridesClear(); alert('Overrides cleared'); }; // Save/Close panel.querySelector('#kodisave').onclick = () => { GM_setValue('kodiIp', panel.querySelector('#kodiIp').value.trim()); GM_setValue('kodiPort', panel.querySelector('#kodiPort').value.trim()); GM_setValue('kodiUser', panel.querySelector('#kodiUser').value); GM_setValue('kodiPass', panel.querySelector('#kodiPass').value); GM_setValue('kodiAction', panel.querySelector('#kodiAction').value); GM_setValue('indicator', panel.querySelector('#indicator').value); GM_setValue('descEnable', panel.querySelector('#descEnable').checked); GM_setValue('descMode', panel.querySelector('#descMode').value); GM_setValue('posterEnable', panel.querySelector('#posterEnable').checked); GM_setValue('posterStrategy', panel.querySelector('#posterStrategy').value); GM_setValue('fanartEnable', panel.querySelector('#fanartEnable').checked); GM_setValue('fanartStrategy', panel.querySelector('#fanartStrategy').value); GM_setValue('fanartFallback', panel.querySelector('#fanartFallback').value); GM_setValue(INFO_BTN_ENABLE_KEY, panel.querySelector('#showInfoBtn').checked); updateInfoButtonVisibility(); overlay.remove(); alert('✅ Settings saved'); }; panel.querySelector('#kodicancel').onclick = () => overlay.remove(); } })();