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:
Jess Hallsworth 2026-09-07 14:59:03 +00:00
parent 58c2883492
commit 6a557bcdd9
No known key found for this signature in database
37 changed files with 6486 additions and 85 deletions

View file

@ -0,0 +1,200 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MediaShelf</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📚</text></svg>">
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="logo"></span>
<span>MediaShelf</span>
<span class="readonly-badge" title="v1 never deletes, moves, or modifies anything">report&nbsp;only</span>
</div>
<nav class="tabs">
<button class="tab active" data-view="dashboard">Dashboard</button>
<button class="tab" data-view="grid">Library</button>
<button class="tab" data-view="keeps">Kept</button>
<button class="tab" data-view="views">Views</button>
<button class="tab" data-view="duplicates">Duplicates</button>
<button class="tab" data-view="scans">Scans</button>
</nav>
<div class="topbar-right">
<span id="source-badge" class="source-badge"></span>
<button id="scan-now" class="btn">Scan now</button>
</div>
</header>
<div id="banner-area"></div>
<main>
<!-- ────────────────────────── dashboard ────────────────────────── -->
<section id="view-dashboard" class="view active">
<div class="split-hero" id="reclaim-split"></div>
<div class="tiles" id="tiles"></div>
<div class="chart-grid">
<div class="card">
<h3>Size by library</h3>
<div id="chart-libraries" class="chart"></div>
</div>
<div class="card">
<h3>Finished · started &amp; abandoned · never opened</h3>
<p class="hint">The middle band is invisible without completion data.</p>
<div id="chart-completion" class="chart"></div>
</div>
<div class="card wide">
<h3>Added over time</h3>
<div id="chart-added" class="chart"></div>
</div>
<div class="card wide">
<h3>Size vs. time since last watched</h3>
<p class="hint">Upper right is the reclaim target. Click a point to open it.</p>
<div id="chart-scatter" class="chart"></div>
</div>
</div>
</section>
<!-- ──────────────────────────── grid ───────────────────────────── -->
<section id="view-grid" class="view">
<div class="grid-layout">
<aside class="filter-rail">
<div class="rail-section">
<label class="rail-label">Search</label>
<input type="search" id="f-q" placeholder="title…">
</div>
<div class="rail-section">
<label class="rail-label">Saved view</label>
<select id="f-view"><option value="">— none —</option></select>
</div>
<div class="rail-section">
<label class="rail-label">Libraries</label>
<div id="f-libraries" class="checklist"></div>
</div>
<div class="rail-section">
<label class="rail-label">Type</label>
<div class="checklist">
<label><input type="checkbox" class="f-kind" value="movie" checked> Movies</label>
<label><input type="checkbox" class="f-kind" value="season" checked> Seasons</label>
</div>
</div>
<div class="rail-section">
<label class="rail-label">Show</label>
<label class="switch"><input type="checkbox" id="f-kept"> Kept items</label>
<label class="switch"><input type="checkbox" id="f-missing"> Missing items</label>
</div>
<div class="rail-section">
<label class="rail-label">Minimum size</label>
<select id="f-minsize">
<option value="0">any</option>
<option value="1073741824">1 GB</option>
<option value="4294967296">4 GB</option>
<option value="8589934592">8 GB</option>
<option value="21474836480">20 GB</option>
<option value="53687091200">50 GB</option>
</select>
</div>
<div class="rail-section">
<label class="rail-label">Watch state</label>
<select id="f-watch">
<option value="">any</option>
<option value="never">never played</option>
<option value="confident">never played · confident</option>
<option value="uncertain">never played · uncertain</option>
<option value="rejected">started and abandoned</option>
<option value="watched">played at least once</option>
</select>
</div>
<div class="rail-section weights">
<label class="rail-label">Reclaim weights</label>
<div id="weight-sliders"></div>
<button id="weights-reset" class="btn small ghost">Reset to defaults</button>
</div>
</aside>
<div class="grid-main">
<div class="grid-toolbar">
<div id="grid-summary" class="grid-summary"></div>
<div class="grid-actions">
<button id="btn-keep" class="btn" disabled>Keep selected</button>
<button id="btn-export" class="btn ghost">Export CSV</button>
<button class="btn danger" disabled
title="v1 is report-only — deletion arrives in v2, after the score has earned trust">Delete</button>
</div>
</div>
<div class="table-wrap">
<table id="grid">
<thead><tr id="grid-head"></tr></thead>
<tbody id="grid-body"></tbody>
</table>
</div>
<div class="pager">
<button id="page-prev" class="btn small">← Prev</button>
<span id="page-info"></span>
<button id="page-next" class="btn small">Next →</button>
</div>
</div>
</div>
</section>
<!-- ──────────────────────────── kept ───────────────────────────── -->
<section id="view-keeps" class="view">
<div class="card">
<h3>Library rules</h3>
<p class="hint">A whole library kept by rule — anything added to it later is kept too.
Nothing is set by default.</p>
<div id="keep-libraries" class="lib-rules"></div>
</div>
<div class="card">
<h3>Explicit marks</h3>
<div id="keep-marks"></div>
</div>
<div class="card" id="keep-orphans-card" hidden>
<h3>Orphaned marks</h3>
<p class="hint">These matched nothing on the last scan — the content may have been
removed, renamed, or rematched. Nothing is removed automatically.</p>
<div id="keep-orphans"></div>
</div>
</section>
<!-- ─────────────────────────── views ───────────────────────────── -->
<section id="view-views" class="view">
<div class="card"><h3>Saved views</h3><div id="views-list"></div></div>
</section>
<!-- ──────────────────────── duplicates ─────────────────────────── -->
<section id="view-duplicates" class="view">
<div class="card">
<h3>Duplicate groups</h3>
<p class="hint">The same film held more than once, matched on Plex GUID. Neither copy
looks like a candidate on its own because plays are split between them.
This is a report — which copy supersedes which depends on what plays back well
on your devices, which MediaShelf has no way to know.</p>
<div id="dupes-summary" class="hint"></div>
<div id="dupes-list"></div>
</div>
</section>
<!-- ─────────────────────────── scans ───────────────────────────── -->
<section id="view-scans" class="view">
<div class="card"><h3>Sources</h3><div id="sources-info"></div></div>
<div class="card"><h3>Scan history</h3><div id="scans-list"></div></div>
</section>
</main>
<div id="drawer" class="drawer" hidden>
<div class="drawer-head">
<h2 id="drawer-title"></h2>
<button id="drawer-close" class="btn ghost small">Close</button>
</div>
<div id="drawer-body"></div>
</div>
<div id="drawer-scrim" class="scrim" hidden></div>
<script src="/static/app.js"></script>
</body>
</html>