Implement MediaShelf v1
The application the design describes: Flask + SQLite, Plex for library data, Tautulli for watch history, report-only. Structure follows the design's seams. providers/ splits MediaProvider from HistoryProvider, because on this network library data and watch data live on different machines and Jellyfin later will have no Tautulli equivalent. scoring.py implements the reclaim score twice - as a SQL expression for the live grid (weights change on every slider drag, so storing it would mean rewriting thousands of rows per drag) and in Python for CSV export and tests, with a property test over 500 generated rows asserting the two agree. rules.py compiles saved views to parameterized SQL through a field/operator whitelist; nothing user-supplied is ever interpolated. Three properties are enforced by test rather than asserted in prose: - Ingest is idempotent. Three consecutive full scans leave every count and every byte total unchanged. A scanner that double-counts produces a report that looks plausible and is wrong. - Keep marks survive Plex reassigning every rating key in the library. They are keyed on content GUID, scoped per library so the Movies and 4K Movies copies of the same film mark independently. - Every config variable the app reads is declared in docker-compose.yml, so a variable set in Portainer can never silently do nothing. Also found and fixed while verifying against a fake Plex+Tautulli pair: executescript() commits the pending transaction, so migrations needed their BEGIN/COMMIT inside the script; replaceChildren() renders null as the literal text "null"; a hash-only URL change does not reload the document, so deep links needed a hashchange listener; and SQLite ROUND rounds half away from zero where Python rounds half to even. 73 tests, no live server required. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GVbG48GAXfCZatcmX123Ra
This commit is contained in:
parent
58c2883492
commit
6a557bcdd9
37 changed files with 6486 additions and 85 deletions
753
mediashelf/static/app.js
Normal file
753
mediashelf/static/app.js
Normal file
|
|
@ -0,0 +1,753 @@
|
|||
'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: `<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;
|
||||
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 (_) {}
|
||||
})();
|
||||
Loading…
Add table
Add a link
Reference in a new issue