'use strict'; /* MediaShelf UI. Vanilla JS, no build step, no vendored framework (§9). */ const $ = (s, r = document) => r.querySelector(s); const $$ = (s, r = document) => Array.from(r.querySelectorAll(s)); const state = { view: 'dashboard', page: 1, pageSize: 100, sort: 'reclaim_score:desc', selected: new Set(), weights: null, defaultWeights: { size: 0.28, staleness: 0.24, unpopularity: 0.22, solitude: 0.10, age: 0.10, rejection: 0.06, }, libraries: [], views: [], lastPage: null, hasCompletion: true, }; /* ── helpers ──────────────────────────────────────────────────────── */ function bytes(n) { n = Number(n || 0); const u = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; let i = 0; while (Math.abs(n) >= 1024 && i < u.length - 1) { n /= 1024; i++; } return `${n.toFixed(n >= 100 || i === 0 ? 0 : 1)} ${u[i]}`; } function exact(n) { return Number(n || 0).toLocaleString() + ' bytes'; } function date(ts) { if (!ts) return '—'; return new Date(ts * 1000).toISOString().slice(0, 10); } function ago(ts) { if (!ts) return 'never'; const d = Math.floor((Date.now() / 1000 - ts) / 86400); if (d < 1) return 'today'; if (d < 60) return `${d}d`; if (d < 730) return `${Math.floor(d / 30)}mo`; return `${(d / 365).toFixed(1)}y`; } /* "today" and "3 days ago" read differently — don't blindly suffix " ago". */ function agoPhrase(ts) { if (!ts) return 'never'; const a = ago(ts); return a === 'today' ? 'today' : a + ' ago'; } function el(tag, attrs = {}, ...kids) { const n = document.createElement(tag); for (const [k, v] of Object.entries(attrs)) { if (v === null || v === undefined || v === false) continue; if (k === 'class') n.className = v; else if (k === 'text') n.textContent = v; else if (k === 'html') n.innerHTML = v; else if (k.startsWith('on')) n.addEventListener(k.slice(2), v); else n.setAttribute(k, v); } for (const kid of kids.flat()) { if (kid === null || kid === undefined || kid === false) continue; n.append(kid.nodeType ? kid : document.createTextNode(kid)); } return n; } async function api(path, opts) { const r = await fetch('/api/v1' + path, opts); if (!r.ok) { let msg = r.statusText; try { msg = (await r.json()).message || msg; } catch (_) {} throw new Error(msg); } return r.json(); } function scoreColor(s) { if (s === null || s === undefined) return 'var(--faint)'; if (s >= 75) return 'var(--hot)'; if (s >= 50) return 'var(--warm)'; if (s >= 25) return 'var(--dim)'; return 'var(--good)'; } function banner(kind, text) { $('#banner-area').append(el('div', { class: `banner ${kind}`, text })); } /* ── navigation ───────────────────────────────────────────────────── */ $$('.tab').forEach(t => t.addEventListener('click', () => show(t.dataset.view))); const VIEWS = ['dashboard', 'grid', 'keeps', 'views', 'duplicates', 'scans']; function show(name) { if (!VIEWS.includes(name)) name = 'dashboard'; state.view = name; $$('.tab').forEach(t => t.classList.toggle('active', t.dataset.view === name)); $$('.view').forEach(v => v.classList.toggle('active', v.id === 'view-' + name)); const loader = { dashboard: loadDashboard, grid: loadGrid, keeps: loadKeeps, views: loadViews, duplicates: loadDuplicates, scans: loadScans }[name]; if (loader) loader(); if (location.hash.replace('#', '') !== name) { const url = new URL(location); url.hash = name; history.replaceState(null, '', url); } } /* A hash-only change does not reload the document, so without this a pasted #grid link (or the back button) leaves the dashboard on screen. */ window.addEventListener('hashchange', () => { const name = location.hash.replace('#', '') || 'dashboard'; if (name !== state.view) show(name); }); /* ── dashboard ────────────────────────────────────────────────────── */ async function loadDashboard() { const [ov, libs, comp, added, scatter] = await Promise.all([ api('/stats/overview'), api('/stats/size-by-library'), api('/stats/completion'), api('/stats/added-over-time'), api('/stats/size-vs-lastwatched'), ]); state.hasCompletion = ov.has_completion_data; // The three-way split that keeps the keep list honest (§6.6) $('#reclaim-split').replaceChildren( el('div', { class: 'split-cell' }, el('div', { class: 'label', text: 'Never played' }), el('div', { class: 'value', text: bytes(ov.never_played_bytes) }), el('div', { class: 'sub', text: `${bytes(ov.confident_bytes)} confident · ${bytes(ov.uncertain_bytes)} uncertain` })), el('div', { class: 'split-cell kept' }, el('div', { class: 'label', text: 'Kept' }), el('div', { class: 'value', text: bytes(ov.never_played_kept_bytes) }), el('div', { class: 'sub', text: `${ov.keep_marks} mark(s) · ${bytes(ov.kept_bytes)} kept overall` })), el('div', { class: 'split-cell available' }, el('div', { class: 'label', text: 'Available' }), el('div', { class: 'value', text: bytes(ov.available_bytes) }), el('div', { class: 'sub', text: 'never played and not kept' })), ); const pct = ov.never_played_bytes ? (ov.never_played_kept_bytes / ov.never_played_bytes) : 0; if (pct > 0.5) { banner('warn', `${Math.round(pct * 100)}% of never-played content is marked keep — ` + `the reclaim report is mostly reporting on things you have decided to keep.`); } $('#tiles').replaceChildren( tile('Library size', bytes(ov.total_bytes), `${ov.total_items.toLocaleString()} rows · ${ov.libraries} libraries`), tile('Episodes', ov.episodes.toLocaleString(), 'rolled up into seasons'), tile('Cold 2+ years', bytes(ov.cold_bytes), 'not played in two years'), tile('Watch history', ov.watch_events.toLocaleString() + ' plays', ov.history_since ? `${ov.accounts} users since ${date(ov.history_since)}` : 'no history'), tile('Last scan', agoPhrase(ov.last_scan_at), ov.history_source ? 'via ' + ov.history_source : ''), ); if (ov.history_since) { const badge = $('#source-badge'); badge.textContent = `${ov.history_source || 'no history'} · since ${date(ov.history_since)}`; badge.title = ov.has_completion_data ? 'Completion data available — the rejection component is active' : 'No completion data — running degraded, rejection component disabled'; } if (!ov.has_completion_data && ov.watch_events > 0) { banner('warn', 'Watch history has no completion data (Plex fallback). ' + 'The score is running degraded: the “rejection” component is disabled.'); } // size by library const maxL = Math.max(...libs.libraries.map(l => l.size_bytes), 1); $('#chart-libraries').replaceChildren(...libs.libraries.map(l => el('div', { class: 'bar-row' }, el('div', { class: 'bar-label', text: l.title, title: l.title }), el('div', { class: 'bar-track' }, el('div', { class: 'bar-fill', style: `width:${(l.size_bytes - l.never_bytes) / maxL * 100}%` }), el('div', { class: 'bar-fill never', style: `width:${l.never_bytes / maxL * 100}%` })), el('div', { class: 'bar-num', text: bytes(l.size_bytes), title: exact(l.size_bytes) })))); $('#chart-libraries').append(el('div', { class: 'legend' }, el('span', {}, el('i', { style: 'background:var(--accent)' }), 'played'), el('span', {}, el('i', { style: 'background:var(--warm)' }), 'never played'))); // completion split const total = comp.finished + comp.started + comp.never || 1; const seg = (v, c, label) => v / total > 0.04 ? el('div', { style: `width:${v / total * 100}%;background:${c}`, text: label, title: bytes(v) }) : el('div', { style: `width:${v / total * 100}%;background:${c}`, title: `${label}: ${bytes(v)}` }); $('#chart-completion').replaceChildren( el('div', { class: 'stack' }, seg(comp.finished, 'var(--good)', bytes(comp.finished)), seg(comp.started, 'var(--warm)', bytes(comp.started)), seg(comp.never, 'var(--hot)', bytes(comp.never))), el('div', { class: 'legend' }, el('span', {}, el('i', { style: 'background:var(--good)' }), 'finished'), el('span', {}, el('i', { style: 'background:var(--warm)' }), 'started, never finished'), el('span', {}, el('i', { style: 'background:var(--hot)' }), 'never opened'))); // added over time const maxA = Math.max(...added.buckets.map(b => b.size_bytes), 1); const recent = added.buckets.slice(-48); $('#chart-added').replaceChildren(el('div', { style: 'display:flex;align-items:flex-end;gap:2px;height:120px', }, ...recent.map(b => el('div', { style: `flex:1;min-width:3px;background:var(--accent);height:${Math.max(b.size_bytes / maxA * 100, 1)}%`, title: `${b.period}: ${bytes(b.size_bytes)} (${b.items} items)`, })))); if (recent.length) { $('#chart-added').append(el('div', { class: 'legend' }, el('span', { text: recent[0].period }), el('span', { text: '→' }), el('span', { text: recent[recent.length - 1].period }))); } drawScatter(scatter.points); } function tile(label, value, sub) { return el('div', { class: 'tile' }, el('div', { class: 'label', text: label }), el('div', { class: 'value', text: value }), sub ? el('div', { class: 'sub', text: sub }) : null); } function drawScatter(points) { const W = 900, H = 260, PAD = 34; const now = Date.now() / 1000; const maxSize = Math.max(...points.map(p => p.size_bytes), 1); const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('viewBox', `0 0 ${W} ${H}`); svg.setAttribute('class', 'scatter'); svg.setAttribute('preserveAspectRatio', 'none'); const mk = (t, a) => { const n = document.createElementNS('http://www.w3.org/2000/svg', t); for (const [k, v] of Object.entries(a)) n.setAttribute(k, v); return n; }; // axes svg.append(mk('line', { x1: PAD, y1: H - PAD, x2: W - 4, y2: H - PAD, stroke: 'var(--line)' })); svg.append(mk('line', { x1: PAD, y1: 4, x2: PAD, y2: H - PAD, stroke: 'var(--line)' })); const maxDays = 3650; for (const p of points) { const days = p.last_watched_at ? Math.min((now - p.last_watched_at) / 86400, maxDays) : maxDays; const x = PAD + (days / maxDays) * (W - PAD - 8); const y = (H - PAD) - (Math.log10(1 + p.size_bytes) / Math.log10(1 + maxSize)) * (H - PAD - 8); const c = mk('circle', { cx: x.toFixed(1), cy: y.toFixed(1), r: 2.4, fill: p.kept ? 'var(--kept)' : (p.watch_count ? 'var(--accent)' : 'var(--warm)'), 'fill-opacity': .55, }); const title = document.createElementNS('http://www.w3.org/2000/svg', 'title'); title.textContent = `${p.title} — ${bytes(p.size_bytes)}, ` + (p.last_watched_at ? `last played ${agoPhrase(p.last_watched_at)}` : 'never played'); c.append(title); c.addEventListener('click', () => openItem(p.id)); svg.append(c); } const t1 = mk('text', { x: PAD, y: H - 8, fill: 'var(--faint)', 'font-size': 10 }); t1.textContent = 'recently played'; const t2 = mk('text', { x: W - 90, y: H - 8, fill: 'var(--faint)', 'font-size': 10 }); t2.textContent = 'never / 10y+'; svg.append(t1, t2); $('#chart-scatter').replaceChildren(svg); } /* ── grid ─────────────────────────────────────────────────────────── */ const COLUMNS = [ { key: '_sel', label: '', sortable: false }, { key: 'title', label: 'Title' }, { key: 'library', label: 'Library' }, { key: 'size_bytes', label: 'Size', num: true }, { key: 'added_at', label: 'Added', num: true }, { key: 'last_watched_at', label: 'Last played', num: true }, { key: 'watch_count', label: 'Plays', num: true }, { key: 'abandoned_count', label: 'Bailed', num: true }, { key: 'distinct_watcher_count', label: 'Viewers', num: true }, { key: 'reclaim_score', label: 'Reclaim', num: true }, { key: '_keep', label: '', sortable: false }, ]; function gridParams() { const p = new URLSearchParams(); const q = $('#f-q').value.trim(); if (q) p.set('q', q); $$('#f-libraries input:checked').forEach(i => p.append('library_id', i.value)); $$('.f-kind:checked').forEach(i => p.append('kind', i.value)); if ($('#f-kept').checked) p.set('include_kept', '1'); if ($('#f-missing').checked) p.set('include_missing', '1'); const rules = { op: 'and', rules: [] }; const minSize = Number($('#f-minsize').value); if (minSize > 0) rules.rules.push({ field: 'size_bytes', op: 'gte', value: minSize }); switch ($('#f-watch').value) { case 'never': rules.rules.push({ field: 'watch_count', op: 'eq', value: 0 }); break; case 'confident': rules.rules.push({ field: 'watch_count', op: 'eq', value: 0 }, { field: 'pre_history', op: 'eq', value: false }); break; case 'uncertain': rules.rules.push({ field: 'watch_count', op: 'eq', value: 0 }, { field: 'pre_history', op: 'eq', value: true }); break; case 'rejected': rules.rules.push({ field: 'abandoned_count', op: 'gte', value: 1 }, { field: 'watch_count', op: 'eq', value: 0 }); break; case 'watched': rules.rules.push({ field: 'watch_count', op: 'gte', value: 1 }); break; } const viewId = $('#f-view').value; if (viewId) p.set('view_id', viewId); if (rules.rules.length) p.set('rules', JSON.stringify(rules)); if (state.weights) p.set('weights', JSON.stringify(state.weights)); p.set('sort', state.sort); p.set('page', state.page); p.set('page_size', state.pageSize); return p; } async function loadGrid() { if (!state.libraries.length) await loadFilterOptions(); let data; try { data = await api('/items?' + gridParams()); } catch (e) { $('#grid-body').replaceChildren(el('tr', {}, el('td', { colspan: COLUMNS.length, class: 'empty', text: 'Query failed: ' + e.message }))); return; } state.lastPage = data; renderHead(); renderRows(data); renderSummary(data); $('#page-info').textContent = `page ${data.page} of ${Math.max(1, Math.ceil(data.total / data.page_size))}`; $('#page-prev').disabled = data.page <= 1; $('#page-next').disabled = data.page * data.page_size >= data.total; } function selectedBytes() { if (!state.lastPage) return 0; return state.lastPage.items .filter(i => state.selected.has(i.id)) .reduce((s, i) => s + (i.size_bytes || 0), 0); } function renderHead() { $('#grid-head').replaceChildren(...COLUMNS.map(c => { if (c.key === '_sel') { return el('th', {}, el('input', { type: 'checkbox', title: 'select all on this page', onchange: (e) => { (state.lastPage?.items || []).forEach(i => e.target.checked ? state.selected.add(i.id) : state.selected.delete(i.id)); renderRows(state.lastPage); loadGridSummaryOnly(); }, })); } const active = state.sort.startsWith(c.key + ':'); const th = el('th', { class: active ? 'sorted' : '', text: c.label + (active ? (state.sort.endsWith('desc') ? ' ↓' : ' ↑') : ''), }); if (c.sortable !== false) { th.addEventListener('click', () => { const dir = state.sort === c.key + ':desc' ? 'asc' : 'desc'; state.sort = c.key + ':' + dir; state.page = 1; loadGrid(); }); } return th; })); } /* replaceChildren() stringifies null into the literal text "null" rather than skipping it, so the optional selection span is filtered out, not passed in. */ function renderSummary(data) { const parts = [el('span', { html: `${data.total.toLocaleString()} rows · ${bytes(data.aggregate.total_size_bytes)}`, })]; if (state.selected.size) { parts.push(el('span', { html: ` — ${state.selected.size} selected · ${bytes(selectedBytes())}`, })); } $('#grid-summary').replaceChildren(...parts); $('#btn-keep').disabled = state.selected.size === 0; } function loadGridSummaryOnly() { renderSummary(state.lastPage); } function renderRows(data) { if (!data.items.length) { $('#grid-body').replaceChildren(el('tr', {}, el('td', { colspan: COLUMNS.length, class: 'empty', text: 'Nothing matches. If everything here is kept, tick “Kept items” to see it.', }))); return; } $('#grid-body').replaceChildren(...data.items.map(it => { const label = it.kind === 'season' ? `${it.show_title || '?'} — Season ${it.season_number ?? '?'}` : it.title + (it.year ? ` (${it.year})` : ''); const flags = it.flags.map(f => el('span', { class: 'flag ' + f, text: f.replace('_', ' ') })); if (it.kept) flags.unshift(el('span', { class: 'flag kept', text: 'kept · ' + it.kept_via })); return el('tr', { class: it.kept ? 'kept-row' : '' }, el('td', {}, el('input', { type: 'checkbox', checked: state.selected.has(it.id), onchange: (e) => { e.target.checked ? state.selected.add(it.id) : state.selected.delete(it.id); loadGridSummaryOnly(); }, })), el('td', { class: 'title-cell' }, el('span', { class: 'title-link', text: label, onclick: () => openItem(it.id) }), it.kind === 'season' ? el('span', { class: 'sub-title', text: ` · ${it.episode_count} eps` }) : null, ...flags), el('td', { text: it.library.title }), el('td', { class: 'num', text: bytes(it.size_bytes), title: exact(it.size_bytes) }), el('td', { class: 'num', text: date(it.added_at) }), el('td', { class: 'num', text: it.last_watched_at ? ago(it.last_watched_at) : 'never' }), el('td', { class: 'num', text: it.watch_count }), el('td', { class: 'num', text: it.abandoned_count || '' }), el('td', { class: 'num', text: it.distinct_watcher_count || '' }), el('td', { class: 'num' }, el('span', { class: 'score-pill', style: `color:${scoreColor(it.reclaim_score)}`, text: it.kept ? '—' : (it.reclaim_score ?? '—'), title: componentTooltip(it), })), el('td', {}, el('button', { class: 'btn small ghost', text: it.kept ? 'Un-keep' : 'Keep', onclick: () => toggleKeep(it), }))); })); } function componentTooltip(it) { const c = it.reclaim_components || {}; const lines = Object.entries(c) .map(([k, v]) => `${k}: ${v === null ? 'n/a' : v.toFixed(2)}`); if (it.grace) lines.push(`grace: ${it.grace}`); return lines.join('\n'); } async function toggleKeep(it) { try { if (it.kept) { const marks = await api('/keeps'); const mine = marks.marks.find(m => m.id === it.kept_mark_id); if (!mine) { alert('This is kept by a library rule — turn that off on the Kept tab.'); return; } await api('/keeps/' + mine.id, { method: 'DELETE' }); } else { await api('/keeps', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ item_id: it.id, mode: 'keep' }), }); } loadGrid(); } catch (e) { alert(e.message); } } $('#btn-keep').addEventListener('click', async () => { const ids = Array.from(state.selected); const note = prompt(`Keep ${ids.length} item(s). Optional note — why are you keeping these?`, ''); if (note === null) return; try { const r = await api('/keeps/bulk', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ item_ids: ids, mode: 'keep', note: note || null }), }); state.selected.clear(); if (r.failed.length) { alert(`${r.created} kept. ${r.failed.length} could not be: ${r.failed[0].reason}`); } loadGrid(); } catch (e) { alert(e.message); } }); $('#btn-export').addEventListener('click', () => { location.href = '/api/v1/export.csv?' + gridParams(); }); $('#page-prev').addEventListener('click', () => { state.page--; loadGrid(); }); $('#page-next').addEventListener('click', () => { state.page++; loadGrid(); }); ['#f-q', '#f-minsize', '#f-watch', '#f-view', '#f-kept', '#f-missing'].forEach(sel => { const node = $(sel); const ev = node.tagName === 'INPUT' && node.type === 'search' ? 'input' : 'change'; let t; node.addEventListener(ev, () => { clearTimeout(t); t = setTimeout(() => { state.page = 1; loadGrid(); }, ev === 'input' ? 300 : 0); }); }); async function loadFilterOptions() { const [libs, views] = await Promise.all([api('/libraries'), api('/views')]); state.libraries = libs.libraries; state.views = views.views; $('#f-libraries').replaceChildren(...libs.libraries.map(l => el('label', {}, el('input', { type: 'checkbox', value: l.id, onchange: () => { state.page = 1; loadGrid(); }, }), `${l.title} (${bytes(l.size_bytes)})`))); $('#f-view').replaceChildren(el('option', { value: '', text: '— none —' }), ...views.views.map(v => el('option', { value: v.id, text: v.name, title: v.description || '' }))); buildWeightSliders(); $$('.f-kind').forEach(i => i.addEventListener('change', () => { state.page = 1; loadGrid(); })); } function buildWeightSliders() { const w = state.weights || state.defaultWeights; $('#weight-sliders').replaceChildren(...Object.keys(state.defaultWeights).map(k => el('label', {}, `${k} `, el('span', { id: 'wv-' + k, text: (w[k] ?? 0).toFixed(2) }), el('input', { type: 'range', min: 0, max: 1, step: 0.02, value: w[k] ?? 0, oninput: (e) => { state.weights = { ...(state.weights || state.defaultWeights) }; state.weights[k] = Number(e.target.value); $('#wv-' + k).textContent = Number(e.target.value).toFixed(2); clearTimeout(buildWeightSliders._t); buildWeightSliders._t = setTimeout(loadGrid, 250); }, })))); } $('#weights-reset').addEventListener('click', () => { state.weights = null; buildWeightSliders(); loadGrid(); }); /* ── item drawer ──────────────────────────────────────────────────── */ async function openItem(id) { const it = await api('/items/' + id); $('#drawer-title').textContent = it.kind === 'season' ? `${it.show_title} — Season ${it.season_number}` : it.title + (it.year ? ` (${it.year})` : ''); const body = $('#drawer-body'); body.replaceChildren(); const dl = el('dl', { class: 'kv' }); const add = (k, v) => { dl.append(el('dt', { text: k }), el('dd', { text: v })); }; add('Library', it.library.title); add('Size', `${bytes(it.size_bytes)} (${exact(it.size_bytes)})`); add('Added', date(it.added_at)); add('Last played', it.last_watched_at ? `${date(it.last_watched_at)} (${agoPhrase(it.last_watched_at)})` : 'never'); add('Plays', `${it.watch_count} finished · ${it.partial_count} partial · ${it.abandoned_count} abandoned`); add('Distinct viewers', it.distinct_watcher_count); if (it.avg_percent_complete !== null && it.avg_percent_complete !== undefined) { add('Average completion', it.avg_percent_complete.toFixed(0) + '%'); } if (it.kind === 'season') add('Episodes', it.episode_count); add('Files', it.part_count); if (it.pre_history) { add('Note', 'Added before watch history began — “never played” is unproven here.'); } add('Kept', it.kept ? `yes (via ${it.kept_via})` : 'no'); body.append(dl); body.append(el('h3', { text: 'Reclaim score' })); body.append(el('div', { class: 'hint', text: it.kept ? 'Not scored for deletion: this item is kept.' : `Score ${it.reclaim_score}${it.grace ? ` (clamped: ${it.grace})` : ''}` })); for (const [k, v] of Object.entries(it.reclaim_components || {})) { body.append(el('div', { class: 'comp-row' }, el('span', { text: k }), el('div', { class: 'comp-track' }, el('div', { class: 'comp-fill', style: `width:${(v ?? 0) * 100}%` })), el('span', { text: v === null ? 'n/a' : v.toFixed(2) }))); } if (it.duplicates?.length) { body.append(el('h3', { text: 'Other copies' })); it.duplicates.forEach(d => body.append(el('div', { class: 'hint' }, `${d.library_title}: ${bytes(d.size_bytes)} ${d.resolution || ''} · ${d.watch_count} plays`))); } body.append(el('h3', { text: 'Files' })); if (it.parts?.length) { it.parts.forEach(p => body.append(el('div', { class: 'hint', text: `${bytes(p.size_bytes)} — ${p.file_path}` }))); } else if (it.episodes?.length) { body.append(el('div', { class: 'hint', text: `${it.episodes.length} episodes` })); it.episodes.forEach(e => body.append(el('div', { class: 'hint' }, `E${String(e.episode_number).padStart(2, '0')} · ${bytes(e.size_bytes)} · ` + `${e.watch_count} plays · added ${date(e.added_at)}`))); } if (it.watch_history?.length) { body.append(el('h3', { text: 'Watch history' })); it.watch_history.slice(0, 40).forEach(h => body.append(el('div', { class: 'hint' }, `${date(h.viewed_at)} · ${h.who || 'unknown'} · ` + `${h.percent_complete === null ? 'completion unknown' : h.percent_complete + '%'} (${h.disposition})`))); } $('#drawer').hidden = false; $('#drawer-scrim').hidden = false; } const closeDrawer = () => { $('#drawer').hidden = true; $('#drawer-scrim').hidden = true; }; $('#drawer-close').addEventListener('click', closeDrawer); $('#drawer-scrim').addEventListener('click', closeDrawer); document.addEventListener('keydown', e => { if (e.key === 'Escape') closeDrawer(); }); /* ── kept ─────────────────────────────────────────────────────────── */ async function loadKeeps() { const [data, libs] = await Promise.all([api('/keeps'), api('/libraries')]); $('#keep-libraries').replaceChildren(...libs.libraries.map(l => el('label', {}, el('input', { type: 'checkbox', checked: !!l.keep_all, onchange: async (e) => { await api(`/libraries/${l.id}/keep_all`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ keep_all: e.target.checked }), }); loadKeeps(); }, }), `${l.title} — ${bytes(l.size_bytes)}`))); const live = data.marks.filter(m => !m.orphaned); const orphans = data.marks.filter(m => m.orphaned); $('#keep-marks').replaceChildren( el('div', { class: 'hint', text: `${data.kept_items.toLocaleString()} items kept, totalling ${bytes(data.kept_bytes)}.` }), ...(live.length ? live.map(markRow) : [el('div', { class: 'empty', text: 'Nothing marked yet. Keep something from the Library tab.' })])); $('#keep-orphans-card').hidden = orphans.length === 0; $('#keep-orphans').replaceChildren(...orphans.map(markRow)); } function markRow(m) { return el('div', { class: 'mark' }, el('div', {}, el('div', { text: m.label }), el('div', { class: 'meta', text: `${m.scope} · ${m.library_title} · ${m.mode}` + ` · ${m.resolved_items} item(s), ${bytes(m.resolved_bytes)}` + (m.note ? ` · “${m.note}”` : '') + ` · marked ${date(m.created_at)}` })), el('button', { class: 'btn small ghost', text: 'Remove', onclick: async () => { if (!confirm(`Remove the keep on “${m.label}”?`)) return; await api('/keeps/' + m.id, { method: 'DELETE' }); loadKeeps(); }, })); } /* ── views / duplicates / scans ───────────────────────────────────── */ async function loadViews() { const data = await api('/views'); $('#views-list').replaceChildren(...data.views.map(v => el('div', { class: 'mark' }, el('div', {}, el('div', { text: v.name + (v.builtin ? ' · built in' : '') }), el('div', { class: 'meta', text: v.description || '' })), el('button', { class: 'btn small', text: 'Open', onclick: () => { $('#f-view').value = v.id; state.page = 1; show('grid'); }, })))); } async function loadDuplicates() { const data = await api('/duplicates'); $('#dupes-summary').textContent = `${data.group_count} group(s) · ${bytes(data.total_redundant_bytes)} redundant.`; $('#dupes-list').replaceChildren(...(data.groups.length ? data.groups.slice(0, 200).map(g => el('div', { class: 'dupe-group' }, el('div', {}, el('b', { text: g.title }), g.year ? ` (${g.year})` : '', ` — ${bytes(g.redundant_bytes)} redundant`), el('div', { class: 'dupe-copies' }, ...g.copies.map(c => el('span', { class: 'title-link', onclick: () => openItem(c.id) }, `${c.library_title}: ${bytes(c.size_bytes)} ${c.resolution || ''} · ${c.watch_count} plays`))))) : [el('div', { class: 'empty', text: 'No duplicate GUIDs across libraries.' })])); } async function loadScans() { const [scans, sources] = await Promise.all([api('/scans'), api('/sources')]); const cov = sources.coverage[0]; const info = [ el('div', { class: 'hint', text: `Plex: ${sources.plex.base_url || 'not configured'}` }), el('div', { class: 'hint', text: `Tautulli: ${sources.tautulli.base_url || 'not configured'}` }), el('div', { class: 'hint', text: `History source: ${sources.history_source || 'none'}` + (sources.has_completion_data ? ' (completion data available)' : ' (no completion data — degraded)') }), ]; if (cov) { info.push(el('div', { class: 'hint', text: `Coverage: ${date(cov.earliest_event_at)} → ${date(cov.latest_event_at)}, ` + `${cov.event_count.toLocaleString()} events` })); } $('#sources-info').replaceChildren(...info); $('#scans-list').replaceChildren(...(scans.scans.length ? scans.scans.map(s => el('div', { class: 'mark' }, el('div', {}, el('div', { text: `${s.mode} · ${s.status}` + (s.error ? ` — ${s.error}` : '') }), el('div', { class: 'meta', text: `${date(s.started_at)} · seen ${s.items_seen} · added ${s.items_added} · ` + `events ${s.events_added} · missing ${s.items_missing} · warnings ${s.warning_count}` + (s.finished_at ? ` · took ${s.finished_at - s.started_at}s` : '') })), el('span', { class: 'meta', text: s.history_source || '' }))) : [el('div', { class: 'empty', text: 'No scans yet.' })])); } /* ── scan control ─────────────────────────────────────────────────── */ $('#scan-now').addEventListener('click', async () => { try { await api('/scans', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'incremental' }), }); pollScan(); } catch (e) { alert(e.message); } }); async function pollScan() { const btn = $('#scan-now'); btn.disabled = true; const tick = async () => { const s = await api('/scans/current'); if (s) { btn.textContent = s.progress ? `Scanning: ${s.progress}` : 'Scanning…'; setTimeout(tick, 2000); } else { btn.textContent = 'Scan now'; btn.disabled = false; $('#banner-area').replaceChildren(); show(state.view); } }; tick(); } /* ── boot ─────────────────────────────────────────────────────────── */ (async function boot() { show(location.hash.replace('#', '') || 'dashboard'); try { const s = await api('/scans/current'); if (s) pollScan(); } catch (_) {} })();