Jess spotted that dates looked like file dates rather than library-add dates. He is right, and MediaShelf was not the culprit: it reproduces Plex's addedAt exactly (verified 500/500 identical to the second). Plex's own field is what follows the file — replace or re-encode one and Date Added resets while the item, its ratingKey and its watch history all survive. Measured on the live library, comparing addedAt against lastViewedAt where both exist: 55 of 509 movies (10.8%) and 306 of 1,393 TV Show Archive items (22.0%) were watched BEFORE they were "added" — 19% overall. 2001: A Space Odyssey reports added 2026-07-31, last watched 2017-08-26. That is not cosmetic. pre_history is derived from added_at, so an old item whose file was replaced looks post-coverage and gets promoted into the CONFIDENT reclaim pool, which is the one pool meant to be trustworthy. A completed play proves the item already existed, so added_at is now MIN(provider_added_at, first_watched_at). Plex's raw value is kept in provider_added_at, added_at_source records which applied, and the item drawer explains the substitution instead of quietly disagreeing with Plex. Unwatched items keep Plex's value since nothing contradicts it. first_seen_at is also recorded now and is authoritative for anything added from here on. Plex's API has no better field; the true insert time is only in Plex's own metadata_items.created_at on Loki. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GVbG48GAXfCZatcmX123Ra
766 lines
32 KiB
JavaScript
766 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)})`);
|
|
if (it.added_at_source === 'first_watch') {
|
|
add('Added', `${date(it.added_at)} (corrected)`);
|
|
add('', `Plex reports ${date(it.provider_added_at)}, but it was already being ` +
|
|
`watched before then — Plex's Date Added follows the file, so replacing ` +
|
|
`or re-encoding one resets it. The first play is used as a lower bound.`);
|
|
} else {
|
|
add('Added', date(it.added_at));
|
|
}
|
|
if (it.first_seen_at) add('First seen by MediaShelf', date(it.first_seen_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 (_) {}
|
|
})();
|