MediaShelf/mediashelf/static/app.js
Jess Hallsworth 71cfa31f09
Fix false "Plex fallback" banner during the first scan
history_coverage is only written at the end of an ingest, but
has_completion_data was reading from it. During the first scan the coverage
table is empty while watch_event already holds tens of thousands of Tautulli
rows, so the dashboard announced a fallback that had not happened and claimed
the rejection component was disabled when it was not.

The flag now comes from the events themselves — does any row carry a
percent_complete — which is true the moment Tautulli rows land and false for
Plex-only history. history_source falls back to the scan record when coverage
is absent, so it reads "tautulli" mid-scan instead of null.

Also: the banner named Plex as the source without checking, and now reports
whichever source is actually active, and stays quiet while a scan is running
since the counts are still moving.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVbG48GAXfCZatcmX123Ra
2026-09-10 14:30:09 +00:00

758 lines
32 KiB
JavaScript

'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,
scanning: false,
};
/* ── 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';
}
// Only a finished scan can tell us this; mid-scan the counts are still moving.
if (!ov.has_completion_data && ov.watch_events > 0 && !state.scanning) {
banner('warn', `Watch history has no completion data (source: ` +
`${ov.history_source || 'unknown'}). 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: `<b>${data.total.toLocaleString()}</b> rows · <b>${bytes(data.aggregate.total_size_bytes)}</b>`,
})];
if (state.selected.size) {
parts.push(el('span', {
html: ` — <b>${state.selected.size}</b> selected · <b>${bytes(selectedBytes())}</b>`,
}));
}
$('#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;
state.scanning = 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;
state.scanning = 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 (_) {}
})();