您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Mobile-friendly build. Send Letterboxd lists & nanogenre pages to Kodi (Fenlight)
// ==UserScript== // @name Letterboxd → FLAM (Fenlight) [Mobile-Ready] // @namespace http://tampermonkey.net/ // @version 3.8.0-mobile // @description Mobile-friendly build. Send Letterboxd lists & nanogenre pages to Kodi (Fenlight) // @match https://letterboxd.com/*/list/* // @match https://letterboxd.com/* // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_addStyle // @run-at document-idle // @connect * // @connect raw.githubusercontent.com // @noframes // ==/UserScript== /* ────────────────────────────────────────────────────────────────────────────── MOBILE NOTES: - This build avoids window.open popups on Android/Firefox (where they're often blocked) by rendering Smart Log, Review Panel, Cache Editor, and "Show Cache" as full-screen overlays. Desktop browsers still get popups where appropriate. - Touch-friendly floating buttons ("FLAM" and "⚙") are pinned bottom-right on mobile, top-right on desktop. Both are always visible (but now only on real user list pages). - Clipboard: prefers navigator.clipboard.writeText with fallback to execCommand('copy'). - CompressionStream: used if available; falls back to raw JSON otherwise. AD / NON-LIST VISIBILITY CHANGE: - Added @noframes so nothing runs inside ad iframes. - Added strict page detection so the floating FLAM + Settings buttons only appear on canonical user list pages like /username/list/list-slug/ (and their /page/N/ pages). ────────────────────────────────────────────────────────────────────────────── */ (async function () { 'use strict'; // Extra hardening: if somehow running in a frame, bail early (redundant with @noframes) if (window.top !== window.self) return; // ─── Environment detection ────────────────────────────────────────────── const UA = navigator.userAgent || ''; const IS_ANDROID = /\bAndroid\b/i.test(UA); const IS_MOBILE = IS_ANDROID || /\bMobile\b/i.test(UA); const SUPPORTS_POPUPS = (() => { // We avoid auto-opening test popups (could be blocked). On mobile, force overlays. return !IS_MOBILE; })(); // NEW: Strict matcher for *real* user list pages only: // Accepts /username/list/list-slug/ and optional pagination /page/N/ function isUserListPageStrict() { const PATH = location.pathname.endsWith('/') ? location.pathname : (location.pathname + '/'); // ^/<username>/list/<list-slug>/(optional page/N/)?$ return /^\/[^/]+\/list\/[^/]+\/(?:page\/\d+\/)?$/.test(PATH); } // Central toggle: should we render the floating FAB on this page? function shouldShowFab() { if (window.top !== window.self) return false; // never in iframes (ads) return isUserListPageStrict(); } // ─── Config (defaults; user-tunable via Settings) ─────────────────────── 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 = 400; const LBD_MAX_RETRIES = 3; const LBD_BACKOFF_BASE = 200; const TMDB_MAX_RETRIES = 3; const TMDB_BACKOFF_BASE = 200; // UI toggles (code-only): set to true to expose advanced perf knobs in Settings const SHOW_ADVANCED_SETTINGS_UI = false; // ← CHANGE TO true TO UNHIDE ADVANCED FIELDS // Local overrides (packed) live here 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' // NEW: fanart supports 'author_fanart' (default) 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' const TMDB_IMG_BASE = 'https://image.tmdb.org/t/p/'; const TMDB_POSTER_SIZE = 'w185'; // 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; // Default media type for items without per-item mt (and top-level) const DEFAULT_MEDIA_TYPE_DEFAULT = 'm'; // 'm' | 'tv' // NEW: Soft limit for list size (friendly block for > 5000 items) const MAX_LIST_ITEMS = 5000; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // ─── Smart Log (session) ──────────────────────────────────────────────── let SMART_LOG = []; function smartLogEnabled() { return GM_getValue('smartLogEnable', true); } 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 (per the standard pseudocode) ─────────────────── async function gzipBase64(str) { try { // Firefox Android supports CompressionStream for gzip in recent versions. 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) ───────────────────────────────────────────────────── 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 GM_GetOrSet('baseBinUrl', BASE_BIN_URL_DEFAULT); } let BASE_PAIRS_U32 = null; // Uint32Array [filmId, packed, ...] let BASE_COUNT = 0; // IMPORTANT: default allowRevalidate = false (instant mode) async function loadBaseBinOnce({ allowRevalidate = false } = {}) { // 1) Load current .bin from IDB if not already in memory 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; } } } // 2) Optionally revalidate (ONLY when explicitly requested) 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(); }, }); }); } // On-demand full refresh (button). Also stamps last revalidate time. 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(); }, }); }); } // Monthly, non-blocking revalidation (never on FLAM run) 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 }; } // Track overrides + dirty state 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 (Overlay system for Mobile) ───────────────────────────── // Global CSS once GM_addStyle(` .lbdf-overlay { position: fixed; inset: 0; z-index: 2147483647; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,.65); padding: 12px; } .lbdf-panel { background: #1a1a1a; color: #eee; border: 1px solid #333; border-radius: 10px; width: min(980px, 96vw); max-height: 92vh; overflow: auto; box-shadow: 0 12px 30px rgba(0,0,0,.45); } .lbdf-head { position: sticky; top: 0; background: #222; display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border-bottom: 1px solid #333; z-index: 1; } .lbdf-title { font-size: 16px; font-weight: 600; } .lbdf-close { background: #444; color: #fff; border: none; border-radius: 6px; padding: 6px 10px; } .lbdf-body { padding: 12px; } .lbdf-actions { position: sticky; bottom: 0; display: flex; gap: 8px; justify-content: flex-end; background: #222; padding: 10px 12px; border-top: 1px solid #333; } .lbdf-btn { background: #e50914; color: #fff; border: none; border-radius: 6px; padding: 8px 12px; } .lbdf-btn.secondary { background: #444; } .lbdf-pre { white-space: pre-wrap; word-break: break-word; background: #141414; border: 1px solid #333; border-radius: 8px; padding: 10px; } .lbdf-input, .lbdf-textarea, .lbdf-select { width: 100%; box-sizing: border-box; background: #151515; color: #eee; border: 1px solid #333; border-radius: 6px; padding: 8px; } .lbdf-textarea { min-height: 220px; } .lbdf-grid { display: flex; gap: 10px; overflow-x: auto; padding-bottom: 6px; } .lbdf-card { width: 140px; border: 1px solid #333; border-radius: 6px; overflow: hidden; background: #1a1a1a; flex: 0 0 auto; position: relative; } .lbdf-card img { width: 100%; height: 210px; object-fit: cover; background: #222; } .lbdf-badge { position: absolute; top: 6px; left: 6px; background: #e50914; color: #fff; font-size: 10px; padding: 2px 6px; border-radius: 4px; } .lbdf-badge.alt { background: #2d7dff; } .lbdf-kbd { display:inline-block; font: 12px/1.1 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Courier New", monospace; background:#111; border:1px solid #333; color:#ddd; padding:2px 6px; border-radius:4px; } /* Floating bar positioning (mobile bottom-right, desktop top-right) */ .lbdf-fab { position: fixed; z-index: 2147483647; display: flex; gap: 8px; } .lbdf-fab button { padding: 8px 12px; border-radius: 8px; border: none; color: #fff; cursor: pointer; box-shadow: 0 6px 18px rgba(0,0,0,.25); } .lbdf-fab .flam { background: #e50914; text-transform: uppercase; letter-spacing: .6px; } .lbdf-fab .gear { background: #444; } @media (pointer:coarse) { .lbdf-fab { right: calc(env(safe-area-inset-right, 0) + 10px); bottom: calc(env(safe-area-inset-bottom, 0) + 10px); flex-direction: row; } .lbdf-fab button { font-size: 14px; } } @media (pointer:fine) { .lbdf-fab { right: 10px; top: 10px; flex-direction: row; } .lbdf-fab button { font-size: 12px; } } `); function createOverlay(titleText) { const overlay = document.createElement('div'); overlay.className = 'lbdf-overlay'; const panel = document.createElement('div'); panel.className = 'lbdf-panel'; const head = document.createElement('div'); head.className = 'lbdf-head'; const title = document.createElement('div'); title.className = 'lbdf-title'; title.textContent = titleText || ''; const closeBtn = document.createElement('button'); closeBtn.className = 'lbdf-close'; closeBtn.textContent = 'Close'; head.append(title, closeBtn); const body = document.createElement('div'); body.className = 'lbdf-body'; panel.append(head, body); overlay.append(panel); document.body.append(overlay); const close = () => { try { overlay.remove(); } catch {} }; closeBtn.addEventListener('click', close); return { overlay, panel, head, body, close, closeBtn, title }; } function showToast(message, onClick) { const t = document.createElement('div'); t.textContent = message; Object.assign(t.style, { position: 'fixed', left: '50%', bottom: '24px', transform: 'translateX(-50%)', background: '#222', color: '#fff', padding: '10px 14px', borderRadius: '6px', boxShadow: '0 2px 8px rgba(0,0,0,0.35)', zIndex: 2147483647, cursor: onClick ? 'pointer' : 'default' }); if (onClick) t.addEventListener('click', () => { onClick(); document.body.removeChild(t); }); document.body.append(t); setTimeout(() => { if (t.parentNode) t.parentNode.removeChild(t); }, 6000); } function showSideInfoNearButton(btn, 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'; let topPx = 0, rightPx = 10; if (IS_MOBILE) { // place above the FAB on mobile topPx = Math.max(0, Math.round((window.innerHeight - 120))); rightPx = 10; } else { try { const rect = btn.getBoundingClientRect(); topPx = Math.max(0, Math.round(rect.top + 40)); rightPx = Math.max(10, Math.round(window.innerWidth - rect.right + 10)); } catch { topPx = 150; rightPx = 130; } } Object.assign(box.style, { position: 'fixed', top: `${topPx}px`, right: `${rightPx}px`, 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: '92vw', pointerEvents: 'none', whiteSpace: 'pre-wrap' }); box.innerHTML = lines.join('\n'); document.body.append(box); setTimeout(() => { try { box.remove(); } catch {} }, ms); } // Editable textarea overlay (used by description editing) function editDescriptionOverlay(defaultDesc) { return new Promise(resolve => { const { overlay, body, close } = createOverlay('Edit Description'); const area = document.createElement('textarea'); area.className = 'lbdf-textarea'; area.value = defaultDesc || ''; const actions = document.createElement('div'); actions.className = 'lbdf-actions'; const saveBtn = document.createElement('button'); saveBtn.className = 'lbdf-btn'; saveBtn.textContent = 'Save'; const cancelBtn = document.createElement('button'); cancelBtn.className = 'lbdf-btn secondary'; cancelBtn.textContent = 'Cancel'; actions.append(saveBtn, cancelBtn); body.append(area, actions); saveBtn.onclick = () => { const val = area.value; close(); resolve(val); }; cancelBtn.onclick = () => { close(); resolve(null); }; }); } // NEW: Action chooser overlay when "Ask each time" is selected function askForActionOverlay() { return new Promise((resolve) => { const { overlay, body, close } = createOverlay('Choose Action'); const wrap = document.createElement('div'); wrap.innerHTML = ` <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 data-act="view" class="lbdf-btn" style="flex:1;background:#444">View</button> <button data-act="import" class="lbdf-btn" style="flex:1;background:#e50914">Import</button> <button data-act="import_view" class="lbdf-btn" style="flex:1;background:#2d7dff">Import + View</button> </div> `; body.append(wrap); const finish = (val) => { close(); resolve(val); }; body.querySelector('[data-act="view"]').onclick = () => finish('view'); body.querySelector('[data-act="import"]').onclick = () => finish('import'); body.querySelector('[data-act="import_view"]').onclick = () => finish('import_view'); }); } // ─── Smart Log window/overlay ─────────────────────────────────────────── function openSmartLogWindow(linesHeaderAndEntries) { const w = window.open('', '_blank', 'width=1000,height=720'); if (!w) return false; w.document.body.style.margin = '0'; const pre = document.createElement('pre'); pre.className = 'lbdf-pre'; pre.style.margin = '0'; pre.style.padding = '10px'; pre.textContent = linesHeaderAndEntries.join('\n'); w.document.body.append(pre); return true; } function openSmartLogOverlay(linesHeaderAndEntries) { const { body } = createOverlay('Smart-Match Log'); const pre = document.createElement('pre'); pre.className = 'lbdf-pre'; pre.textContent = linesHeaderAndEntries.join('\n'); body.append(pre); return true; } function openSmartLogUnified() { const listName = getListNameFromPage(); const autoChanges = SMART_LOG.filter(e => !e.reasons.includes('manual_override')).length; const manualChanges = SMART_LOG.filter(e => e.reasons.includes('manual_override')).length; const byReason = {}; SMART_LOG.forEach(e => e.reasons.forEach(r => byReason[r] = (byReason[r] || 0) + 1)); const header = [ `Smart-Match Log — ${new Date().toLocaleString()}`, `List: ${listName}`, `Auto changes: ${autoChanges} • Manual overrides: ${manualChanges}`, `Reasons: ${Object.keys(byReason).map(k => `${k}:${byReason[k]}`).join(' ') || '—'}`, '' ]; const lines = []; SMART_LOG .slice() .sort((a, b) => (a.listIndex || 999999) - (b.listIndex || 999999)) .forEach(e => { lines.push(`#${e.listIndex || '?'} film:${e.filmId} ${e.lbTitle} (${e.lbYear})`); const topLine = e.top ? ` TMDb top: ${e.top.title} (${e.top.release_date || 'n/a'}) [${e.top.id}]` : ' TMDb top: n/a'; const chLine = e.chosen ? ` Our choice: ${e.chosen.title} (${e.chosen.release_date || 'n/a'}) [${e.chosen.id}]` : ' Our choice: n/a'; lines.push(topLine); lines.push(chLine); lines.push(` Reasoning: ${reasonsToText(e)}`); if (e.matchedDirectors?.length) lines.push(` Match dir: ${e.matchedDirectors.join(', ')}`); if (typeof e.runtimeUsed === 'number') lines.push(` Runtime tol: ±${RUNTIME_TOL_MIN} min (LB ${e.lbRuntime}m)`); if (typeof e.candidates === 'number') lines.push(` Candidates: ${e.candidates}`); lines.push(''); }); const payload = header.concat(lines); if (SUPPORTS_POPUPS) { if (openSmartLogWindow(payload)) return; } openSmartLogOverlay(payload); } // ─── Smart Log / Review Panel (desktop popup version kept) ────────────── function reasonsToText(entry) { const r = entry.reasons || []; const parts = []; const has = (k) => r.includes(k); if (has('manual_override')) { parts.push('You manually selected a different TMDb title.'); } else if (has('exact_title_year') && has('director_match')) { parts.push('Exact title + year matched multiple candidates; we chose the one with a matching director.'); } else if (has('exact_title_year') && has('runtime_match')) { parts.push(`Exact title + year matched multiple candidates; we chose the one matching runtime (±${RUNTIME_TOL_MIN}m).`); } else if (has('exact_title_year') && has('fallback_tmdb_top')) { parts.push('Multiple exact title + year matches; no director/runtime tie-breaker, fell back to TMDb top.'); } else if (has('exact_title_year')) { parts.push('Chose an exact title + year match (TMDb top wasn’t an exact match).'); } else if (has('exact_title_only') && has('director_match')) { parts.push('Title matched exactly; we used director to break ties.'); } else if (has('exact_title_only') && has('runtime_match')) { parts.push(`Title matched exactly; used runtime to break ties (±${RUNTIME_TOL_MIN}m).`); } else if (has('exact_title_only') && has('fallback_tmdb_top')) { parts.push('Multiple exact title matches; no tie-breaker, fell back to TMDb top.'); } else if (has('no_results')) { parts.push('TMDb search returned no results.'); } else { parts.push('Chosen over TMDb top based on heuristics.'); } if (entry.matchedDirectors?.length) parts.push(`Matched director(s): ${entry.matchedDirectors.join(', ')}.`); if (typeof entry.runtimeUsed === 'number' && typeof entry.lbRuntime === 'number') parts.push(`Letterboxd runtime ${entry.lbRuntime} min used for comparison.`); if (typeof entry.candidates === 'number') parts.push(`${entry.candidates} candidate(s) considered.`); if (has('final_retry')) parts.push('Final retry pass.'); return parts.join(' '); } function alreadyLoggedSameChoice(filmId, chosenId) { return SMART_LOG.some(e => e.filmId === filmId && e.chosen?.id === chosenId && !e.reasons.includes('manual_override') && !e.reasons.includes('final_retry') ); } function updateSmartLogButtonState() { const btn = document.getElementById('btnSmartLog'); const enabled = smartLogEnabled(); if (btn) { btn.disabled = !enabled || SMART_LOG.length === 0; btn.title = enabled ? (SMART_LOG.length ? 'Open Smart-Match Log' : 'No changes logged yet') : 'Enable logging in settings'; } } // ─── Resolver & TMDB 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 searchTmdb(query, year) { const url = `https://api.themoviedb.org/3/search/movie?` + new URLSearchParams({ api_key: TMDB_API_KEY, query: query, year: year || '' }); for (let i = 0; i < TMDB_MAX_RETRIES; i++) { try { const r = await fetch(url); if (r.ok) return (await r.json()).results || []; if (r.status === 429) await new Promise(r => setTimeout(r, TMDB_BACKOFF_BASE * (i + 1))); else return []; } catch { await new Promise(r => setTimeout(r, TMDB_BACKOFF_BASE * (i + 1))); } } return []; } 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; } // Needed by resolver 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)); } // ─── Smart-match logger & resolver (restored) ─────────────────────────── function maybeLogChange(item, top, chosen, reasons, extra, isFinalRetry = false) { if (!smartLogEnabled()) return; const topId = top?.id || null; const chosenId = chosen?.id || null; const reasonsList = Array.isArray(reasons) ? reasons.slice() : []; if (isFinalRetry && !reasonsList.includes('final_retry')) reasonsList.push('final_retry'); if (isFinalRetry) { if (chosenId != null && alreadyLoggedSameChoice(item.filmId, chosenId)) return; if (!chosen && !top) { const hadNoResults = SMART_LOG.some(e => e.filmId === item.filmId && e.reasons.includes('no_results')); if (hadNoResults) return; } } const changed = reasonsList.includes('manual_override') ? true : (topId !== chosenId); if (!changed) return; SMART_LOG.push({ filmId: item.filmId, listIndex: item.listIndex || 0, lbTitle: item.title, lbYear: item.year, lbDirectors: item.directors || [], lbRuntime: item.runtime, top: top ? { id: top.id, title: top.title, release_date: top.release_date || '' } : null, chosen: chosen ? { id: chosen.id, title: chosen.title, release_date: chosen.release_date || '' } : null, reasons: reasonsList, candidates: extra?.candidates ?? null, matchedDirectors: extra?.matchedDirectors || [], runtimeUsed: typeof extra?.runtime === 'number' ? extra.runtime : null, timestamp: Date.now() }); } 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) { const chosen = exactTitleYear[0]; maybeLogChange(item, top, chosen, ['exact_title_year'], { candidates: 1 }, isFinalRetry); return chosen.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) { const chosen = exactTitleYear[dirMatches[0].idx]; maybeLogChange(item, top, chosen, ['exact_title_year', 'director_match'], { candidates: exactTitleYear.length, matchedDirectors: dirMatches[0].matchNames }, isFinalRetry); return chosen.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) { const chosen = exactTitleYear[rtMatches[0]]; maybeLogChange(item, top, chosen, ['exact_title_year', 'runtime_match'], { candidates: exactTitleYear.length, runtime: item.runtime }, isFinalRetry); return chosen.id || ''; } if (top) { const chosen = top; maybeLogChange(item, top, chosen, ['exact_title_year', 'fallback_tmdb_top'], { candidates: exactTitleYear.length }, isFinalRetry); return chosen.id || ''; } const chosen = exactTitleYear[0]; maybeLogChange(item, top, chosen, ['exact_title_year'], { candidates: exactTitleYear.length }, isFinalRetry); return chosen.id || ''; } const exactTitleOnly = results.filter(m => tmdbTitleMatches(m)); if (exactTitleOnly.length === 1) { const chosen = exactTitleOnly[0]; maybeLogChange(item, top, chosen, ['exact_title_only'], { candidates: 1 }, isFinalRetry); return chosen.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) { const chosen = exactTitleOnly[dirMatches[0].idx]; maybeLogChange(item, top, chosen, ['exact_title_only', 'director_match'], { candidates: exactTitleOnly.length, matchedDirectors: dirMatches[0].matchNames }, isFinalRetry); return chosen.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) { const chosen = exactTitleOnly[rtMatches[0]]; maybeLogChange(item, top, chosen, ['exact_title_only', 'runtime_match'], { candidates: exactTitleOnly.length, runtime: item.runtime }, isFinalRetry); return chosen.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; } } // NOTE: processItems no longer loads base; caller must ensure loaded once. 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; 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 (media_type -> media_type_default; gzip+base64 support) ─ async function buildUrlFromCache(items, descriptionText, actionOverride = null) { const listItems = []; const defaultMt = GM_GetOrSet('defaultMediaType', DEFAULT_MEDIA_TYPE_DEFAULT); // 'm' | 'tv' for (const it of items) { let packed = getFromAnyCachePacked(it.filmId); let tmdbId, isTv; if (packed !== undefined) { ({ tmdbId, isTv } = unpack(packed)); } else if (it.tmdbId) { tmdbId = Number(it.tmdbId); isTv = !!it.isTv; } else { tmdbId = ''; isTv = false; } const itemMt = isTv ? 'tv' : 'm'; if (itemMt === defaultMt) { listItems.push({ id: tmdbId }); } else { listItems.push({ id: tmdbId, mt: itemMt }); } } const rawJson = JSON.stringify(listItems); // Common metadata keys 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); // 'none' | 'first_4' | 'random' 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', defaultMt]); 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); } // Choose URL: if compression succeeded, use base64_items; else list_items const choice = b64 ? 'base64' : 'raw'; const url = (choice === 'base64') ? gzUrl : rawUrl; const urlBytes = (choice === 'base64') ? gzBytes : rawBytes; return { url, choice, // 'base64' | 'raw' urlBytes, // chosen URL bytes 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; } } // Helper to resolve action, handling "ask" by prompting user async function resolveActionForThisRun(contextLabel = 'FLAM') { const stored = GM_getValue('kodiAction', 'import_view'); if (stored === 'ask') { const chosen = await askForActionOverlay(); return chosen; // may be null (cancel) } return stored; // '', 'view', 'import', 'import_view' } // ─── Friendly soft-limit overlay ──────────────────────────────────────── function showTooManyItemsOverlay(totalCount) { const { body } = createOverlay('List Too Large'); const cap = document.createElement('div'); cap.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> `; body.append(cap); } // ─── Review Panel (overlay for mobile; popup kept for desktop) ────────── async function openReviewPanelOverlayPromise() { const changed = SMART_LOG.filter(e => !e.reasons.includes('no_results')); if (!changed.length) { alert('No changed picks to review.'); return { action: 'close' }; } return new Promise(async (resolve) => { const { body, close } = createOverlay('Review Changes'); const summary = document.createElement('div'); summary.style.cssText = 'font-size:12px;color:#bbb;margin-bottom:8px'; summary.textContent = 'Loading candidates…'; body.append(summary); const wrap = document.createElement('div'); body.append(wrap); let resolved = false; const safeResolve = (payload) => { if (!resolved) { resolved = true; resolve(payload); } }; const changedList = SMART_LOG.filter(e => !e.reasons.includes('no_results')); const candidateMap = {}; try { await Promise.all(changedList.map(async e => { const results = await searchTmdb(e.lbTitle, e.lbYear); const ensureIds = new Set([e.top?.id, e.chosen?.id].filter(Boolean)); const byId = new Map(results.map(r => [r.id, r])); ensureIds.forEach(id => { if (id && !byId.has(id)) byId.set(id, { id, title: '(from earlier result)', release_date: '', poster_path: null }); }); candidateMap[e.filmId] = Array.from(byId.values()).slice(0, 20); })); } catch (err) { summary.innerHTML = `<span style="color:#ff6b6b">Error loading candidates: ${String(err)}</span>`; } summary.textContent = `Items with changes: ${changedList.length}`; function posterUrl(p) { return p ? `${TMDB_IMG_BASE}${TMDB_POSTER_SIZE}${p}` : ''; } changedList .slice() .sort((a, b) => (a.listIndex || 999999) - (b.listIndex || 999999)) .forEach(e => { const entry = document.createElement('div'); entry.style.cssText = 'background:#141414;border:1px solid #2a2a2a;border-radius:8px;margin-bottom:12px;padding:10px'; entry.innerHTML = ` <div style="display:flex;gap:12px;align-items:flex-start;flex-wrap:wrap"> <div style="min-width:260px;max-width:340px"> <div style="font-weight:600;margin-bottom:4px;display:flex;align-items:center;gap:8px"><span style="font-size:20px;color:#ff3b3b;font-weight:800">#${e.listIndex || '?'}</span> <span>${e.lbTitle} (${e.lbYear})</span></div> <div style="color:#bbb;font-size:12px;margin-bottom:6px">film:${e.filmId}</div> <div style="color:#bbb;font-size:12px;margin-bottom:6px">Directors: ${(e.lbDirectors || []).join(', ') || '—'}</div> <div style="color:#bbb;font-size:12px;margin-bottom:6px">TMDb top: ${e.top ? `${e.top.title} (${e.top.release_date || 'n/a'}) [${e.top.id}]` : 'n/a'}</div> <div style="color:#bbb;font-size:12px;margin-bottom:6px">Our choice: ${e.chosen ? `${e.chosen.title} (${e.chosen.release_date || 'n/a'}) [${e.chosen.id}]` : 'n/a'}</div> <div style="color:#dcdcdc;font-size:12px;margin:6px 0 0 0">${reasonsToText(e)}</div> </div> <div class="lbdf-grid" id="grid-${e.filmId}"></div> </div> `; body.append(entry); const grid = entry.querySelector(`#grid-${e.filmId}`); const candidates = candidateMap[e.filmId] || []; const currentChosenId = e.chosen?.id || e.top?.id || null; if (candidates.length) { candidates.forEach(c => { const card = document.createElement('div'); card.className = 'lbdf-card'; card.innerHTML = ` ${c.id === e.top?.id ? '<div class="lbdf-badge alt">TMDb top</div>' : ''} ${c.id === currentChosenId ? '<div class="lbdf-badge">Chosen</div>' : ''} <img src="${posterUrl(c.poster_path)}" alt=""> <div style="padding:6px;font-size:12px"> <div style="font-weight:600">${c.title || '(no title)'}</div> <div style="font-size:11px;color:#bbb">${(c.release_date || 'n/a')} [${c.id}]</div> <label style="display:flex;align-items:center;gap:6px;margin-top:4px;font-size:12px"> <input type="radio" name="sel-${e.filmId}" value="${c.id}" ${c.id === currentChosenId ? 'checked' : ''}> Select </label> </div> `; grid.append(card); }); } else { const card = document.createElement('div'); card.className = 'lbdf-card'; card.innerHTML = ` <div style="padding:6px;font-size:12px"> <div style="font-weight:600">No TMDb results</div> <div style="font-size:11px;color:#bbb">Enter TMDb ID:</div> <input type="text" name="manual-${e.filmId}" class="lbdf-input"> </div> `; grid.append(card); } }); async function applySavesToCache() { await loadLocalOverridesOnce(); let changesApplied = 0; for (const e of changedList) { const radios = body.querySelectorAll(`input[name="sel-${e.filmId}"]`); let selectedId = null; radios.forEach(r => { if (r.checked) selectedId = parseInt(r.value, 10); }); if (!selectedId) { const manual = body.querySelector(`input[name="manual-${e.filmId}"]`); if (manual && manual.value.trim()) { const idNum = parseInt(manual.value.trim(), 10); if (Number.isFinite(idNum)) selectedId = idNum; } } if (!selectedId) continue; const beforePacked = overrideGetPacked(e.filmId); const beforeId = Number.isFinite(beforePacked) ? (beforePacked >>> 1) : (e.chosen?.id || e.top?.id || null); if (beforeId !== selectedId) { overrideSetPacked(e.filmId, selectedId, /*isTv=*/false); changesApplied++; const top = e.top ? { ...e.top } : null; const newChosen = { id: selectedId, title: '(manual)', release_date: '' }; SMART_LOG.push({ filmId: e.filmId, listIndex: e.listIndex || 0, lbTitle: e.lbTitle, lbYear: e.lbYear, lbDirectors: e.lbDirectors, lbRuntime: e.lbRuntime, top, chosen: newChosen, reasons: ['manual_override'], candidates: null, matchedDirectors: [], runtimeUsed: null, timestamp: Date.now() }); } } if (changesApplied > 0) await persistOverrides({ alsoBackup: true }); return changesApplied; } const actions = document.createElement('div'); actions.className = 'lbdf-actions'; const saveSendBtn = document.createElement('button'); saveSendBtn.className = 'lbdf-btn'; saveSendBtn.textContent = 'Save & Send'; const saveCloseBtn = document.createElement('button'); saveCloseBtn.className = 'lbdf-btn secondary'; saveCloseBtn.textContent = 'Save & Close'; const saveBtn = document.createElement('button'); saveBtn.className = 'lbdf-btn secondary'; saveBtn.textContent = 'Save'; const closeBtn = document.createElement('button'); closeBtn.className = 'lbdf-btn secondary'; closeBtn.textContent = 'Close'; actions.append(saveSendBtn, saveCloseBtn, saveBtn, closeBtn); body.append(actions); closeBtn.addEventListener('click', () => { safeResolve({ action: 'close' }); close(); }); saveBtn.addEventListener('click', async () => { try { const n = await applySavesToCache(); summary.textContent = n ? `Saved ${n} override(s).` : `No changes to save.`; summary.style.color = n ? '#7bd88f' : '#bbb'; } catch (err) { summary.textContent = `Save error: ${String(err)}`; summary.style.color = '#ff6b6b'; } }); saveCloseBtn.addEventListener('click', async () => { try { const n = await applySavesToCache(); summary.textContent = n ? `Saved ${n} override(s).` : `No changes to save.`; summary.style.color = n ? '#7bd88f' : '#bbb'; safeResolve({ action: 'save-close' }); close(); } catch (err) { summary.textContent = `Save error: ${String(err)}`; summary.style.color = '#ff6b6b'; } }); saveSendBtn.addEventListener('click', async () => { try { const n = await applySavesToCache(); summary.textContent = n ? `Saved ${n} override(s). Sending…` : `Sending…`; summary.style.color = '#7bd88f'; safeResolve({ action: 'save-send' }); close(); } catch (err) { summary.textContent = `Save error: ${String(err)}`; summary.style.color = '#ff6b6b'; } }); }); } // Desktop popup (original) — retained for desktop async function openReviewPanelPopupPromise() { // Reuse original implementation via an offscreen window if needed. // For brevity and reliability on desktop, we reuse the overlay (works well on desktop too). return openReviewPanelOverlayPromise(); } async function openReviewPanelPromiseUnified() { if (SUPPORTS_POPUPS) { return openReviewPanelPopupPromise(); } else { return openReviewPanelOverlayPromise(); } } // ─── Cache Editor (overlay for mobile) ────────────────────────────────── async function openCacheEditorOverlay() { await loadLocalOverridesOnce(); const currentPacked = LOCAL_OVERRIDES || {}; const backupPacked = await getCache(CACHE_BACKUP_KEY); const toFriendly = (packedMap) => { const obj = {}; for (const [k, v] of Object.entries(packedMap || {})) { if (!Number.isFinite(v)) continue; const { tmdbId, isTv } = unpack(v); obj[k] = { tmdbId, type: isTv ? 'tv' : 'm' }; } return obj; }; const fromFriendly = (friendly) => { const packed = {}; for (const [k, v] of Object.entries(friendly || {})) { const id = Number(k); if (!Number.isFinite(id)) continue; const tmdbId = Number(v?.tmdbId); if (!Number.isFinite(tmdbId)) continue; const type = String(v?.type || 'm').toLowerCase(); const isTv = (type === 'tv'); packed[k] = (tmdbId << 1) | (isTv ? 1 : 0); } return packed; }; const current = toFriendly(currentPacked); const backup = toFriendly(backupPacked); const statsHtml = (cur, bak) => { const curKeys = Object.keys(cur || {}).length; const bakKeys = Object.keys(bak || {}).length; let added = 0; if (bak && cur) { const bset = new Set(Object.keys(bak)); added = Object.keys(cur).filter(k => !bset.has(k)).length; } return `Current: ${curKeys} keys • Backup: ${bakKeys} keys • Added since backup: ${added}`; }; const { body } = createOverlay('Edit Overrides'); const stats = document.createElement('div'); stats.style.cssText = 'font-size:12px;color:#bbb;margin-bottom:8px'; stats.textContent = statsHtml(current, backup); const ta = document.createElement('textarea'); ta.className = 'lbdf-textarea'; ta.value = JSON.stringify(current, null, 2); const msg = document.createElement('div'); msg.style.cssText = 'margin-top:10px;color:#aaa;font-size:12px'; const actions = document.createElement('div'); actions.className = 'lbdf-actions'; const vBtn = document.createElement('button'); vBtn.className = 'lbdf-btn secondary'; vBtn.textContent = 'Validate'; const rBtn = document.createElement('button'); rBtn.className = 'lbdf-btn'; rBtn.style.background = '#8a2be2'; rBtn.textContent = 'Revert Additions'; const sBtn = document.createElement('button'); sBtn.className = 'lbdf-btn'; sBtn.textContent = 'Save'; actions.append(vBtn, rBtn, sBtn); body.append(stats, ta, actions, msg); function setMsg(text, ok = false) { msg.textContent = text; msg.style.color = ok ? '#8ee6a4' : '#aaa'; } function parseArea() { try { const obj = JSON.parse(ta.value); if (typeof obj !== 'object' || Array.isArray(obj) || obj === null) { setMsg('JSON must be an object mapping filmId → { tmdbId, type }.'); return null; } return obj; } catch (e) { setMsg('Invalid JSON: ' + e.message); return null; } } function validateShape(obj) { for (const [k, v] of Object.entries(obj)) { if (typeof v !== 'object' || v === null) { setMsg(`Value for key ${k} must be { tmdbId, type }`); return false; } const tmdbId = Number(v.tmdbId); if (!Number.isFinite(tmdbId)) { setMsg(`tmdbId for ${k} must be a number`); return false; } const type = String(v.type || 'm').toLowerCase(); if (!(type === 'm' || type === 'tv')) { setMsg(`type for ${k} must be "m" or "tv"`); return false; } } return true; } vBtn.onclick = () => { const obj = parseArea(); if (!obj) return; if (!validateShape(obj)) return; setMsg('Looks good ✔', true); }; rBtn.onclick = () => { const cur = parseArea(); if (!cur) return; const bak = backup || {}; const bakSet = new Set(Object.keys(bak)); let removed = 0; for (const k of Object.keys(cur)) { if (!bakSet.has(k)) { delete cur[k]; removed++; } } ta.value = JSON.stringify(cur, null, 2); stats.textContent = statsHtml(cur, bak); setMsg(removed ? `Removed ${removed} new key(s) added since last build. Review and click Save to persist.` : 'No new keys to remove.', removed ? true : false); }; sBtn.onclick = async () => { const obj = parseArea(); if (!obj) return; if (!validateShape(obj)) return; try { const packed = fromFriendly(obj); await setCache(packed, CACHE_KEY); LOCAL_OVERRIDES = packed; DIRTY_OVERRIDES = false; stats.textContent = statsHtml(obj, backup || {}); setMsg('Saved ✔', true); } catch (e) { setMsg('Save failed: ' + e.message); } }; } async function openCacheEditorUnified() { if (SUPPORTS_POPUPS) { // Popup version also works, but overlay is simpler and consistent. Use overlay. return openCacheEditorOverlay(); } else { return openCacheEditorOverlay(); } } function showCacheOverlay(obj) { const { body } = createOverlay('Overrides JSON'); const pre = document.createElement('pre'); pre.className = 'lbdf-pre'; pre.textContent = JSON.stringify(obj || {}, null, 2); body.append(pre); } // ─── Handlers (Show URL & FLAM) ───────────────────────────────────────── 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); } // DESCRIPTION (default ON now) 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; } } const startLen = SMART_LOG.length; await processItems(items, LOCAL_OVERRIDES); const changes = SMART_LOG.length - startLen; // Resolve action (ask if needed) const actionChoice = await resolveActionForThisRun('Show URL'); if (GM_getValue('kodiAction', 'import_view') === 'ask' && !actionChoice) { // cancelled if (btnEl) { btnEl.disabled = false; } return; } const info = await buildUrlFromCache(items, descriptionText, actionChoice); // Show URL overlay with byte meter const { body } = createOverlay('Plugin URL'); const area = document.createElement('textarea'); area.className = 'lbdf-textarea'; area.value = info.url; 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 meta = document.createElement('div'); meta.style.cssText = 'margin-top:10px; padding:8px; background:#1b1b1b; border:1px solid #333; border-radius:6px; color:#ddd; font-size:12px; line-height:1.5'; meta.innerHTML = ` <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> `; const actions = document.createElement('div'); actions.className = 'lbdf-actions'; const copyBtn = document.createElement('button'); copyBtn.className = 'lbdf-btn'; copyBtn.textContent = 'Copy URL'; actions.append(copyBtn); body.append(area, meta, actions); copyBtn.onclick = async () => { try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(area.value); } else { area.select(); document.execCommand('copy'); } copyBtn.textContent = 'Copied!'; setTimeout(() => copyBtn.textContent = 'Copy URL', 1200); } catch { alert('Copy failed. Select and copy manually.'); } }; if (smartLogEnabled() && changes > 0) { showToast(`Smart-match changed ${changes} pick${changes === 1 ? '' : 's'} — tap to review`, () => openReviewPanelPromiseUnified()); } updateSmartLogButtonState(); if (btnEl) btnEl.disabled = false; } // ─── Floating buttons (FLAM, Settings) — now ONLY on real user list pages ─ if (shouldShowFab()) { const topRightBar = document.createElement('div'); topRightBar.className = 'lbdf-fab'; document.body.append(topRightBar); function makeBarButton(label, title = '', className = '') { const b = document.createElement('button'); b.textContent = label; b.title = title || label; b.className = className; return b; } // FLAM (Send to Kodi) const flamBtn = makeBarButton('FLAM', 'Send to Kodi (Fenlight)', 'flam'); // NEW: Make FLAM label tuned for mobile flamBtn.style.fontWeight = '600'; flamBtn.style.lineHeight = '1.1'; flamBtn.addEventListener('click', async function () { const btn = this; btn.disabled = true; const startTime = performance.now(); btn.textContent = 'Processing…'; await startRun(); await ensureBaseLoadedOnceForRun(); await loadLocalOverridesOnce(); // Resolve action choice early (so user can cancel before heavy work) let actionChoice = await resolveActionForThisRun('FLAM'); if (GM_getValue('kodiAction', 'import_view') === 'ask' && !actionChoice) { btn.textContent = 'Cancelled'; setTimeout(() => { btn.textContent = 'FLAM'; btn.disabled = false; }, 1200); return; } let items; try { items = await scrapeItemsSmart(); } catch { alert('Scrape failed'); btn.disabled = false; btn.textContent = 'FLAM'; return; } if (!items.length) { alert('No items'); btn.disabled = false; btn.textContent = 'FLAM'; return; } // Friendly soft-limit guard for > 5,000 items (don’t send) if (items.length > MAX_LIST_ITEMS) { showTooManyItemsOverlay(items.length); btn.textContent = 'FLAM'; btn.disabled = 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++; } btn.textContent = `Processing… (${baseHitCount} base • ${uncachedCount} uncached)`; // DESCRIPTION (default ON now) 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) { btn.disabled = false; btn.textContent = 'FLAM'; return; } descriptionText = sanitizeDescPreservingBreaks(edited); } else { descriptionText = pageDesc; } } const startLen = SMART_LOG.length; await processItems(items, LOCAL_OVERRIDES); const changes = SMART_LOG.length - startLen; // Auto-open review BEFORE sending if enabled and there are changes if (smartLogEnabled() && GM_GetOrSet('smartReviewAutoOpen', false) && changes > 0) { const res = await openReviewPanelPromiseUnified(); if (!res || res.action !== 'save-send') { btn.textContent = 'Cancelled'; setTimeout(() => { btn.textContent = 'FLAM'; btn.disabled = false; }, 1200); updateSmartLogButtonState(); return; } } const info = await buildUrlFromCache(items, descriptionText, actionChoice); const ok = await sendToKodi(info.url); const elapsedMs = Math.max(0, Math.round(performance.now() - startTime)); btn.textContent = ok ? 'Sent!' : 'Failed'; setTimeout(() => { btn.textContent = 'FLAM'; }, 1200); btn.disabled = false; // Side info box: bytes sent + % of 64KB + existing cache stats const limitBytes = 65536; const pct = Math.min(100, Math.round((info.urlBytes / limitBytes) * 1000) / 10); const infoLines = [ `📦 Cache — binary: ${baseHitCount} • uncached: ${uncachedCount}`, `📨 Bytes sent: ${info.urlBytes} / ${limitBytes} (${pct}%) ${info.choice === 'base64' ? '[gzip+base64]' : '[raw JSON]'}`, `⏱ Elapsed: ${(elapsedMs / 1000).toFixed(1)}s` ]; showSideInfoNearButton(btn, infoLines, 5000); if (smartLogEnabled() && !GM_GetOrSet('smartReviewAutoOpen', false) && changes > 0) { showToast(`Smart-match changed ${changes} pick${changes === 1 ? '' : 's'} — tap to review`, () => openReviewPanelPromiseUnified()); } updateSmartLogButtonState(); }); // Settings const settingsBtn = makeBarButton('⚙', 'Settings', 'gear'); settingsBtn.addEventListener('click', showSettings); topRightBar.append(flamBtn, settingsBtn); } // ─── Cache Editor (used by Tools > Edit Cache) ────────────────────────── async function openCacheEditor() { return openCacheEditorUnified(); } // ─── Settings UI (Tabbed + Scrollable) ────────────────────────────────── function showSettings() { if (document.getElementById('kodisettings')) return; const { overlay, panel, body, close } = createOverlay('Kodi Settings'); // Tabs header row const tabHeader = document.createElement('div'); tabHeader.style.cssText = 'display:flex; gap:8px; align-items:center; justify-content:space-between; margin-bottom:8px;'; tabHeader.innerHTML = ` <div style="font-size:16px;font-weight:600">Kodi Settings</div> <div style="display:flex; gap:6px;"> <button class="kodi-tab-btn lbdf-btn" data-tab="general" style="background:#e50914">General</button> <button class="kodi-tab-btn lbdf-btn secondary" data-tab="tools">Tools</button> </div> `; body.append(tabHeader); // Containers const tabGeneral = document.createElement('div'); tabGeneral.id = 'kodi-tab-general'; const tabTools = document.createElement('div'); tabTools.id = 'kodi-tab-tools'; tabTools.style.display = 'none'; body.append(tabGeneral, tabTools); // General tab content tabGeneral.innerHTML = ` <label>Kodi IP:</label> <input id="kodiIp" class="lbdf-input" style="margin-bottom:8px"/> <label>Kodi Port:</label> <input id="kodiPort" class="lbdf-input" style="margin-bottom:8px"/> <label>Kodi User:</label> <input id="kodiUser" class="lbdf-input" style="margin-bottom:8px"/> <label>Kodi Pass:</label> <input id="kodiPass" type="password" class="lbdf-input" style="margin-bottom:12px"/> <label>Default Action:</label> <select id="kodiAction" class="lbdf-select" style="margin-bottom:12px"> <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>Busy indicator:</label> <select id="indicator" class="lbdf-select" style="margin-bottom:12px"> <option value="none">none</option> <option value="busy">busy</option> <option value="progress">progress</option> </select> <label><input type="checkbox" id="descEnable"/> Add Description</label> <div id="descOptions" style="margin:8px 0;display:none;"> <label>Edit mode:</label> <select id="descMode" class="lbdf-select" style="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="${SHOW_ADVANCED_SETTINGS_UI ? '' : 'display:none;'}"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:10px"> <div> <label>Batch size:</label> <input id="batchSize" type="number" min="1" step="1" class="lbdf-input"/> </div> <div> <label>Pause between batches (ms):</label> <input id="pauseMs" type="number" min="0" step="10" class="lbdf-input"/> </div> <div> <label>Final retry delay (ms):</label> <input id="finalRetryDelayMs" type="number" min="0" step="10" class="lbdf-input"/> </div> <div> <label>Final retry min list size:</label> <input id="finalRetryMinListSize" type="number" min="0" step="1" class="lbdf-input"/> </div> </div> <hr style="border-color:#444;margin:14px 0"> </div> <label><input type="checkbox" id="smartLogEnable"/> Enable Smart-Match Logging</label><br> <label><input type="checkbox" id="smartReviewAutoOpen"/> Auto-open Review Panel BEFORE sending when changes occur</label> <hr style="border-color:#444;margin:14px 0"> <div> <h3 style="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><input type="checkbox" id="posterEnable"/> Enable Poster</label> <div style="color:#bbb;font-size:12px;margin:6px 0 8px">Include the <span class="lbdf-kbd">poster</span> key in the URL.</div> <label>Poster selection:</label> <select id="posterStrategy" class="lbdf-select" style="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><input type="checkbox" id="fanartEnable"/> Enable Fanart</label> <div style="color:#bbb;font-size:12px;margin:6px 0 8px">The <span class="lbdf-kbd">fanart</span> key accepts either a strategy or a direct URL.</div> <label>Fanart selection:</label> <select id="fanartStrategy" class="lbdf-select" style="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>If author_fanart missing, fallback to:</label> <select id="fanartFallback" class="lbdf-select" style="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="margin:0 0 8px;">Media Type Defaults</h3> <label>Default media type (items w/o per-item <span class="lbdf-kbd">mt</span>):</label> <select id="defaultMediaType" class="lbdf-select" style="margin:6px 0 8px"> <option value="m">m (movies)</option> <option value="tv">tv (TV shows)</option> </select> <div style="color:#bbb;font-size:12px;margin-top:4px"> Top-level query key is <span class="lbdf-kbd">media_type_default</span>. Per-item <span class="lbdf-kbd">mt</span> is only added when it differs from this default. </div> </div> <hr style="border-color:#444;margin:14px 0"> <div> <h3 style="margin:0 0 8px;">Cache Tools</h3> <div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:6px"> <button id="downloadCacheBtn" class="lbdf-btn">Download Overrides</button> <button id="refreshBaseCacheBtn" class="lbdf-btn">Refresh Base Cache</button> </div> <div style="display:grid;grid-template-columns:1fr auto;gap:8px;align-items:end;"> <div> <label>Base cache URL (.bin):</label> <input id="baseBinUrl" class="lbdf-input" style="margin-top:4px"/> <div style="color:#bbb;font-size:12px;margin-top:4px">Raw GitHub URL to lbd_tmdb_pairs_u32.bin</div> </div> <div style="display:flex;gap:8px;"> <button id="resetBaseBinUrlBtn" class="lbdf-btn secondary">Reset URL</button> </div> </div> <div id="cacheToolMsg" style="color:#bbb;font-size:12px;margin-top:8px;"></div> </div> <div style="text-align:right;margin-top:12px; position:sticky; bottom:0; background:#222; padding-top:8px;"> <button id="kodisave" class="lbdf-btn" style="margin-right:8px">Save</button> <button id="kodicancel" class="lbdf-btn secondary">Close</button> </div> `; // Tools tab content tabTools.innerHTML = ` <div style="display:flex; flex-wrap:wrap; gap:8px; margin-bottom:12px;"> <button id="btnShowUrl" class="lbdf-btn" title="Build & view plugin URL">Show URL</button> <button id="btnShowCache" class="lbdf-btn" title="Open overrides as JSON">Show Cache</button> <button id="btnEditCache" class="lbdf-btn" title="Open editor for overrides">Edit Cache</button> <button id="btnClearCache" class="lbdf-btn" title="Clear overrides">Clear Cache</button> <button id="btnSmartLog" class="lbdf-btn" title="Open Smart-Match Log" disabled>Show Smart Log</button> <button id="btnReviewChanges" class="lbdf-btn" title="Open Review Panel">Review Changes</button> </div> <div style="color:#bbb; font-size:12px;"> Utilities have been moved here from the main page. Use FLAM for sending; use these tools for inspecting and tweaking. </div> `; // Tabs logic const tabBtns = body.querySelectorAll('.kodi-tab-btn'); function activateTab(name) { tabBtns.forEach(b => b.classList.toggle('secondary', b.dataset.tab !== name)); tabGeneral.style.display = name === 'general' ? 'block' : 'none'; tabTools.style.display = name === 'tools' ? 'block' : 'none'; } tabBtns.forEach(b => b.addEventListener('click', () => activateTab(b.dataset.tab))); // Populate (General) body.querySelector('#kodiIp').value = GM_getValue('kodiIp', ''); body.querySelector('#kodiPort').value = GM_getValue('kodiPort', ''); body.querySelector('#kodiUser').value = GM_getValue('kodiUser', ''); body.querySelector('#kodiPass').value = GM_GetOrSet('kodiPass', ''); // Default action is now 'import_view' body.querySelector('#kodiAction').value = GM_getValue('kodiAction', 'import_view'); body.querySelector('#indicator').value = GM_GetOrSet('indicator', 'busy'); // "Add Description" defaults to ON; mode defaults to 'send' body.querySelector('#descEnable').checked = GM_GetOrSet('descEnable', true); body.querySelector('#descMode').value = GM_GetOrSet('descMode', 'send'); body.querySelector('#smartLogEnable').checked = smartLogEnabled(); body.querySelector('#smartReviewAutoOpen').checked = GM_GetOrSet('smartReviewAutoOpen', false); // Advanced values (may be hidden but still populate) body.querySelector('#batchSize')?.setAttribute('value', GM_GetOrSet('batchSize', DEFAULT_BATCH_SIZE)); body.querySelector('#pauseMs')?.setAttribute('value', GM_GetOrSet('pauseMs', DEFAULT_PAUSE_MS)); body.querySelector('#finalRetryDelayMs')?.setAttribute('value', GM_GetOrSet('finalRetryDelayMs', DEFAULT_FINALRETRY_DELAY)); body.querySelector('#finalRetryMinListSize')?.setAttribute('value', GM_GetOrSet('finalRetryMinListSize', DEFAULT_FINALRETRY_MIN_LIST_SIZE)); body.querySelector('#posterEnable').checked = GM_GetOrSet('posterEnable', POSTER_ENABLE_DEFAULT); body.querySelector('#posterStrategy').value = GM_GetOrSet('posterStrategy', POSTER_STRATEGY_DEFAULT); body.querySelector('#fanartEnable').checked = GM_GetOrSet('fanartEnable', FANART_ENABLE_DEFAULT); body.querySelector('#fanartStrategy').value = GM_GetOrSet('fanartStrategy', FANART_STRATEGY_DEFAULT); body.querySelector('#fanartFallback').value = GM_GetOrSet('fanartFallback', FANART_FALLBACK_DEFAULT); function refreshFanartFallbackVisibility() { const strat = body.querySelector('#fanartStrategy').value; body.querySelector('#fanartFallbackBox').style.display = (strat === 'author_fanart') ? 'block' : 'none'; } body.querySelector('#fanartStrategy').addEventListener('change', refreshFanartFallbackVisibility); refreshFanartFallbackVisibility(); body.querySelector('#baseBinUrl').value = GM_GetOrSet('baseBinUrl', BASE_BIN_URL_DEFAULT); body.querySelector('#defaultMediaType').value = GM_GetOrSet('defaultMediaType', DEFAULT_MEDIA_TYPE_DEFAULT); const descOpts = body.querySelector('#descOptions'); body.querySelector('#descEnable').addEventListener('change', e => { descOpts.style.display = e.target.checked ? 'block' : 'none'; }); if (body.querySelector('#descEnable').checked) descOpts.style.display = 'block'; // Cache tools handlers const cacheMsg = body.querySelector('#cacheToolMsg'); function setCacheMsg(txt, ok = false) { cacheMsg.textContent = txt || ''; cacheMsg.style.color = ok ? '#8ee6a4' : '#bbb'; } body.querySelector('#downloadCacheBtn').onclick = async () => { try { await loadLocalOverridesOnce(); const nice = {}; for (const [k, packed] of Object.entries(LOCAL_OVERRIDES || {})) { if (!Number.isFinite(packed)) continue; const { tmdbId, isTv } = unpack(packed); nice[k] = { tmdbId, type: isTv ? 'tv' : 'm' }; } const blob = new Blob([JSON.stringify(nice, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'lbd_tmdb_overrides.json'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); setCacheMsg('Downloaded overrides.', true); } catch (e) { alert('❌ Failed to download overrides: ' + (e?.message || e)); } }; body.querySelector('#refreshBaseCacheBtn').onclick = async () => { setCacheMsg('Refreshing base cache…'); await refreshBaseBinNow(); setCacheMsg('Base cache refreshed.', true); }; body.querySelector('#resetBaseBinUrlBtn').onclick = () => { body.querySelector('#baseBinUrl').value = BASE_BIN_URL_DEFAULT; setCacheMsg('Base cache URL reset (unsaved).'); }; body.querySelector('#kodisave').onclick = () => { GM_setValue('kodiIp', body.querySelector('#kodiIp').value.trim()); GM_setValue('kodiPort', body.querySelector('#kodiPort').value.trim()); GM_setValue('kodiUser', body.querySelector('#kodiUser').value); GM_setValue('kodiPass', body.querySelector('#kodiPass').value); GM_setValue('kodiAction', body.querySelector('#kodiAction').value); GM_setValue('indicator', body.querySelector('#indicator').value); GM_setValue('descEnable', body.querySelector('#descEnable').checked); GM_setValue('descMode', body.querySelector('#descMode').value); GM_setValue('smartLogEnable', body.querySelector('#smartLogEnable').checked); GM_setValue('smartReviewAutoOpen', body.querySelector('#smartReviewAutoOpen').checked); GM_setValue('posterEnable', body.querySelector('#posterEnable').checked); GM_setValue('posterStrategy', body.querySelector('#posterStrategy').value); GM_setValue('fanartEnable', body.querySelector('#fanartEnable').checked); GM_setValue('fanartStrategy', body.querySelector('#fanartStrategy').value); GM_setValue('fanartFallback', body.querySelector('#fanartFallback').value); // Advanced fields may be hidden; still read them safely const bsEl = body.querySelector('#batchSize'); const pmEl = body.querySelector('#pauseMs'); const frdEl = body.querySelector('#finalRetryDelayMs'); const frsEl = body.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('baseBinUrl', body.querySelector('#baseBinUrl').value.trim() || BASE_BIN_URL_DEFAULT); GM_setValue('defaultMediaType', body.querySelector('#defaultMediaType').value); close(); alert('✅ Settings saved'); updateSmartLogButtonState(); }; body.querySelector('#kodicancel').onclick = () => close(); // Tools tab buttons body.querySelector('#btnShowUrl').onclick = function () { handleShowUrl(this); }; body.querySelector('#btnShowCache').onclick = async () => { await loadLocalOverridesOnce(); showCacheOverlay(LOCAL_OVERRIDES || {}); }; body.querySelector('#btnEditCache').onclick = openCacheEditor; body.querySelector('#btnClearCache').onclick = async () => { await clearCache(); LOCAL_OVERRIDES = {}; DIRTY_OVERRIDES = false; alert('Overrides cleared'); }; body.querySelector('#btnSmartLog').onclick = () => { if (!SMART_LOG.length) { alert(smartLogEnabled() ? 'No changes logged yet.' : 'Enable logging in settings.'); return; } openSmartLogUnified(); }; body.querySelector('#btnReviewChanges').onclick = () => openReviewPanelPromiseUnified(); // Initialize updateSmartLogButtonState(); activateTab('general'); } // Initialize state on load (FAB only appears on strict list pages now) // The FAB creation is gated above by shouldShowFab(). })();