From 1ba03dca7058a1608b1b266ef1769f92122741b5 Mon Sep 17 00:00:00 2001 From: Jess Hallsworth Date: Mon, 7 Sep 2026 05:38:05 +0000 Subject: [PATCH] Generalise protected libraries into keep marks Watch data cannot tell you what is valuable, only what is unwatched. Some content is held deliberately - home video, 4K copies that are expensive to re-acquire, shows kept in case someone wants them later - and that judgement has to be stated by a human and be un-overridable by the score. One 'keep' concept, applied at four levels: library (a rule, so future additions inherit it), show, season, movie. Explicit marks below the library can point either way, so 'keep all of 4K Movies except this one' is expressible rather than a dead end. The important detail is identity. Plex reassigns ratingKeys on library rebuilds and rematches, so marks are keyed on content guid instead - (library_id, guid) for movies and shows, (library_id, show_guid, season_number) for seasons. A keep list that silently detaches from its content would, in v2, mean deleting something explicitly protected. That is now the single most important test in the suite. Marks are scoped per library on purpose: Movies and 4K Movies share guids, so an unscoped mark would protect both copies at once. Also: kept items hidden from the grid by default with a toggle; a Kept items screen that surfaces orphaned marks rather than carrying them silently; the dashboard always shows never-played / kept / available as three numbers so a growing keep list cannot quietly hollow out the report; nightly export of keeps and views to JSON, since they are the only data in the database not reconstructible from Plex and Tautulli; and v2 deletion refuses kept items ahead of every other guardrail. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GVbG48GAXfCZatcmX123Ra --- .env.example | 5 +- README.md | 4 + docs/design.md | 304 +++++++++++++++++++++++++++++++++++++++++-------- 3 files changed, 262 insertions(+), 51 deletions(-) diff --git a/.env.example b/.env.example index 319a505..c861b5b 100644 --- a/.env.example +++ b/.env.example @@ -43,6 +43,7 @@ SCORE_REJECTED_AT=2 SCORE_SOLITUDE_AT=3 SCORE_GRACE_DAYS=30 -# Libraries that must never be scored as deletion candidates (comma-separated). -PROTECTED_LIBRARIES=Family Videos +# Libraries kept wholesale, seeded on first run only (comma-separated). +# Editable in the UI afterwards; this value is not re-applied on later starts. +KEEP_ALL_LIBRARIES=Family Videos SCORE_RECENT_DAYS=90 diff --git a/README.md b/README.md index 4cd71f2..ace30a7 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,10 @@ used to validate it. The design has been checked against the live servers: 65.7 something you added last week - Named, re-runnable saved views — *"unwatched, older than 2 years, over 10 GB"*, *"two people started it and nobody finished it"* +- **Keep marks** at library, series, season or movie level, for the things you are holding + on purpose — home video, 4K copies, shows you might want someday. Kept items are hidden + from the working grid and refused outright by deletion. Marks are keyed on Plex content + GUIDs rather than rating keys, so they survive a library rebuild - CSV export of any view ## Planned stack diff --git a/docs/design.md b/docs/design.md index 6ce493a..7f07034 100644 --- a/docs/design.md +++ b/docs/design.md @@ -1,16 +1,21 @@ # MediaShelf — Software Design -**Status:** Draft v1.2 · **Author:** Claude (spec) for Jess · **Date:** 2026-09-07 +**Status:** Draft v1.3 · **Author:** Claude (spec) for Jess · **Date:** 2026-09-07 *Revision 1.1 — Tautulli (192.168.1.100:8181) confirmed present and promoted to the primary watch-history source; Plex's own history demoted to fallback. Deployment path settled: image built in CI/locally and pushed to Nox's Portainer.* +*Revision 1.3 — keep marks (§6.6) generalised from the earlier protected-libraries idea: +one `keep` concept applied at library, show, season or movie level, keyed on content guid +rather than Plex rating key so marks survive library rebuilds, kept items hidden from the +grid by default, and refused outright by v2 deletion.* + *Revision 1.2 — **validated against the live servers.** The measured library (§2.1) is 65.7 TB across 24 libraries with 60 users and 87,640 logged plays. Five corrections followed: Tautulli's per-item table cannot supply TV sizes (§4.11), both Tautulli-derived score weights recalibrated against real distributions (§6.1), cross-library duplicates -promoted to v1 (§6.5), protected libraries added (§6.6), and confident-vs-uncertain +promoted to v1 (§6.5), keep marks added (§6.6), and confident-vs-uncertain reclaim separated as distinct seed views (§7.2).* --- @@ -126,9 +131,11 @@ Comedy, War, Superhero, Classic), and *Movies* / *4K Movies* frequently hold the film twice. Cross-library duplicate detection moves from "later candidate" to a real v1 concern (§6.5). -**Some libraries must never be deletion candidates.** *Family Videos* is 63 items and -38 GB of irreplaceable home video, 86% of it "never played". A naive reclaim score ranks -it highly. Protected libraries are now a first-class concept (§6.6). +**Some content must never be a deletion candidate, and no data can tell you which.** +*Family Videos* is 63 items and 38 GB of irreplaceable home video, 86% of it "never +played", which a naive reclaim score ranks highly. The same is true for reasons the tool +cannot see: content kept deliberately in case someone wants it later, or held at 4K +because re-acquiring it is expensive. Keep marks are a first-class concept (§6.6). --- @@ -519,11 +526,30 @@ CREATE TABLE library ( title TEXT NOT NULL, kind TEXT NOT NULL, -- 'movie' | 'show' locations TEXT, -- JSON array of on-disk roots - protected INTEGER NOT NULL DEFAULT 0, -- never a delete candidate (§6.6) + keep_all INTEGER NOT NULL DEFAULT 0, -- library-level keep rule (§6.6) scanned_at INTEGER, UNIQUE (provider_id, provider_key) ); +-- Explicit human judgements that the reclaim score must not override (§6.6). +-- Keyed on CONTENT identity, never on rating_key, which Plex reassigns. +CREATE TABLE keep_mark ( + id INTEGER PRIMARY KEY, + scope TEXT NOT NULL, -- 'show' | 'season' | 'movie' + mode TEXT NOT NULL, -- 'keep' | 'exclude' + library_id INTEGER NOT NULL REFERENCES library(id), + guid TEXT NOT NULL, -- movie guid, or the SHOW's guid + season_number INTEGER, -- set only for scope='season' + -- convenience only; allowed to go stale, never matched on + provider_item_id TEXT, + label TEXT NOT NULL, -- human-readable title at marking time + note TEXT, -- "might watch someday", "expensive to re-rip" + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + last_matched_scan_id INTEGER, -- NULL after a scan => orphaned + UNIQUE (scope, library_id, guid, season_number) +); + -- The unit of analysis. One row per movie, and one row per SEASON. -- Shows themselves get a row too (kind='show') for grouping and display, -- but carry no size of their own. @@ -561,6 +587,10 @@ CREATE TABLE media_item ( distinct_watcher_count INTEGER NOT NULL DEFAULT 0, avg_percent_complete REAL, pre_history INTEGER NOT NULL DEFAULT 0, -- added before history coverage + -- keep state, recomputed each scan and on every keep change (§6.6) + kept INTEGER NOT NULL DEFAULT 0, + kept_via TEXT, -- 'library' | 'show' | 'season' | 'movie' + kept_mark_id INTEGER REFERENCES keep_mark(id), provider_view_count INTEGER NOT NULL DEFAULT 0, -- advisory, token-scoped -- lifecycle status TEXT NOT NULL DEFAULT 'present', -- 'present' | 'missing' @@ -693,6 +723,8 @@ CREATE INDEX ix_item_size ON media_item(size_bytes); CREATE INDEX ix_item_watchcount ON media_item(watch_count); CREATE INDEX ix_item_parent ON media_item(parent_id); CREATE INDEX ix_item_guid ON media_item(guid); -- duplicate grouping, §6.5 +CREATE INDEX ix_item_kept ON media_item(kept, library_id); +CREATE INDEX ix_keep_lookup ON keep_mark(library_id, guid, season_number); CREATE INDEX ix_episode_season ON episode(season_item_id); CREATE INDEX ix_part_item ON media_part(media_item_id); CREATE INDEX ix_part_episode ON media_part(episode_id); @@ -858,31 +890,119 @@ has no way to know. It surfaces the pairs and their sizes and lets a human choos A `duplicates` flag on the item and a *Duplicate groups* view are the v1 surface. This was originally deferred to §14.3; the measured library shape moved it forward. -### 6.6 Protected libraries +### 6.6 Keep marks -*Family Videos* is 63 items, 38 GB, and 86% "never played". Every component of the reclaim -score ranks it as an excellent deletion candidate. It is also irreplaceable home video — -the one category in the entire library where a wrong deletion cannot be undone by -re-acquiring the file. +**A reclaim score measures what is unwatched, not what is valuable.** Those two things +correlate for ordinary purchased or downloaded content and are close to unrelated +everywhere else. *Family Videos* is 38 GB of irreplaceable home video that is 86% never +played and scores as a perfect deletion target. *4K Movies* is 72.9% never played and is +the most expensive content in the library to re-acquire. And of the 1,337 never-played +seasons, an unknown number are simply things kept deliberately, on purpose, in case +someone wants them later. -So libraries can be marked **protected**, and MediaShelf applies that at three levels: +No amount of watch data can infer any of that. It has to be stated by a human, and once +stated the score must not be able to override it. That is what a **keep mark** is. -1. Protected items are **excluded from the reclaim score entirely** — they show a `—` - rather than a number, so they can never sort to the top of a delete list. -2. They are excluded from CSV exports of candidate views by default. -3. In v2, when deletion exists, they are refused outright: the path allowlist (§14.1) - will not include a protected library's roots, so a deletion request against one fails - at the guardrail rather than at the confirmation dialog. +#### Scopes -`Family Videos` ships protected by default, based on the measured library. The setting is -per-library and editable; `Sports` and any other personal-capture library are worth the -same treatment. +One kind of mark, applied at four levels: -The general principle is worth stating, because it applies beyond this one library: **a -reclaim score measures what is unwatched, not what is valuable.** Those correlate for -purchased and downloaded content and are close to unrelated for anything the household -made itself. The score cannot tell the difference, so a human marks the boundary once and -the tool respects it permanently. +| Scope | Granularity | +|---|---| +| `library` | a whole section — *keep everything in 4K Movies* | +| `show` | a series and all its seasons, present and future | +| `season` | one season of one series | +| `movie` | one film | + +Libraries are kept by a **rule** (`library.keep_all`), so anything added to that library +later is kept automatically. Everything below the library level is marked **explicitly**, +per item — deliberate acts on specific things, each with a timestamp and an optional note. + +#### Precedence + +Most specific wins, and an explicit mark can point in either direction: + +``` +movie/season explicit 'exclude' → NOT kept (overrides an inherited library keep) +movie/season explicit 'keep' → kept +show 'keep' → kept +library keep_all → kept +otherwise → not kept +``` + +The `exclude` direction exists so that *"keep all of 4K Movies except this one"* is +expressible. Without it a library rule is a trap: the only way out would be to turn the +whole rule off and re-mark 313 films by hand. + +Each item carries a computed `kept` flag and a `kept_via` field naming which mark applied, +recomputed at the end of every scan and immediately on any keep change. `kept_via` matters +in the UI — "kept because the whole library is kept" and "kept because you marked this +season in March" are different facts and lead to different actions. + +#### Identity, and why it is not the rating key + +Plex `ratingKey` values are **not stable**. A library rebuild, an unmatch-and-rematch, or +a section removed and re-added can reassign them. A keep list stored against rating keys +would silently detach from the content it was protecting, and in v2 the consequence of +that is deleting something explicitly marked keep. That is the worst failure this +application could have. + +So keep marks are stored against **content identity** instead: + +| Scope | Key | +|---|---| +| `library` | the library row | +| `movie` | `(library_id, guid)` | +| `show` | `(library_id, guid)` | +| `season` | `(library_id, show_guid, season_number)` | + +Plex's `guid` identifies the *content*, not the file, and survives rebuilds. Seasons are +keyed by their show's guid plus the season number rather than a season guid, so the scheme +does not depend on Plex exposing season-level guids at all. + +**Marks are scoped per library on purpose.** Because *Movies* and *4K Movies* hold the +same films under the same `guid` (§6.5), an unscoped mark would keep both copies at once — +so keeping the 4K version would silently protect the 1080p one you were trying to remove. +Scoping by `(library_id, guid)` keeps the two copies independently markable, which is +exactly what the duplicate-groups view needs. + +`provider_item_id` is stored alongside as a convenience for linking, and is allowed to go +stale. It is never the thing a mark is matched on. + +#### Orphans + +If a scan completes and a keep mark matched nothing, it is flagged **orphaned** rather +than deleted. The content may have been removed from Plex, renamed, rematched to a +different guid, or moved to another library. The Kept view lists orphans separately with +their label and note as recorded at marking time, so a keep that has come adrift is +visible rather than silent. Nothing removes a keep mark except an explicit human action. + +#### Effect + +- Kept items are **hidden from the grid by default**, with a *Show kept* toggle. +- They are excluded from every candidate view, from CSV exports of those views, and from + the reclaim totals — which is why those totals are always reported three ways (§9.1). +- Their score is still computed and shown when kept items are displayed, so *"this scores + 91 and I'm keeping it anyway"* stays a visible, revisitable decision. +- In v2, a kept item **cannot be quarantined**. The deletion path refuses it at the + guardrail, before any confirmation dialog. Removing the keep mark is a separate, + deliberate act — deletion never offers to do it for you as part of the same click. + +Seeded from the measured library, `Family Videos` ships with `keep_all` set. `Sports` and +anything else self-recorded is worth the same treatment (§15). + +#### The number that keeps this honest + +A keep list that grows quietly will eventually swallow the library and leave the reclaim +report meaningless without anyone noticing. So the dashboard always shows the split: + +``` +27.4 TB never played · 8.1 TB kept · 19.3 TB available +``` + +If the middle number climbs toward the first, the tool is telling you it has stopped +being useful — which is worth knowing, and is invisible if kept items are simply +subtracted out. --- @@ -917,7 +1037,8 @@ fixed set: `eq`, `ne`, `lt`, `lte`, `gt`, `gte`, `in`, `not_in`, `contains`, `starts_with`, `is_null`, `is_not_null`, `older_than_days`, `newer_than_days`, `never` (for watch fields). The whitelisted fields include the completion metrics — `watch_count`, `partial_count`, `abandoned_count`, `avg_percent_complete`, -`last_touched_at`, `pre_history`. Anything outside the whitelist is a 400. Values are always +`last_touched_at`, `pre_history` — plus `kept` and `kept_via`. Anything outside the +whitelist is a 400. Values are always bound parameters. There is no SQL string interpolation anywhere in this path. ### 7.2 Seed views shipped by default @@ -935,6 +1056,10 @@ bound parameters. There is no SQL string interpolation anywhere in this path. | **Confident reclaim** | `watch_count = 0 AND pre_history = false` — added while Tautulli was watching and never played. On the measured library this is 6.5 TB and is the list to act on first | | **Uncertain reclaim** | `watch_count = 0 AND pre_history = true` — 20.9 TB that *might* have been watched before 2025-03-08. Needs human judgement, not a bulk action | | Duplicate groups | items sharing a `guid` across libraries (§6.5) | +| Kept | `kept = true` — what you have decided to keep, and what it costs (§6.6) | + +Every view except *Kept* excludes kept items implicitly; `kept` is a whitelisted rule +field, so a view can opt back in explicitly when that is the point. --- @@ -960,6 +1085,7 @@ Query parameters: | `sort` | e.g. `reclaim_score:desc`, `size_bytes:desc` | | `weights` | URL-encoded JSON weight profile | | `include_missing` | default `false` | +| `include_kept` | default `false` — kept items are hidden unless asked for (§6.6) | | `page`, `page_size` | page_size max 500 | Response: @@ -969,7 +1095,12 @@ Response: "total": 4821, "page": 1, "page_size": 100, - "aggregate": { "total_size_bytes": 41234567890123, "item_count": 4821 }, + "aggregate": { + "total_size_bytes": 41234567890123, + "item_count": 4821, + "kept_bytes": 8906044184985, + "available_bytes": 21228456789012 + }, "items": [ { "id": 1183, @@ -991,6 +1122,8 @@ Response: "primary_path": "/mnt/vault2/movies/Example Film (2011)/Example Film (2011).mkv", "part_count": 1, "resolution": "4k", + "kept": false, + "kept_via": null, "reclaim_score": 91.4, "reclaim_components": { "size": 0.97, "staleness": 1.0, "unpopularity": 1.0, @@ -1019,7 +1152,28 @@ GET /api/v1/sources per-source status: reachable?, coverage event count, active history source ``` -### 8.2 Saved views +### 8.2 Keep marks + +``` +GET /api/v1/keeps all marks, with resolved item counts and bytes +GET /api/v1/keeps/orphans marks that matched nothing on the last scan +POST /api/v1/keeps { scope, library_id, item_id | guid, + season_number?, mode, note? } +PATCH /api/v1/keeps/{id} edit the note +DELETE /api/v1/keeps/{id} un-keep (the only way a mark is ever removed) +POST /api/v1/keeps/bulk { item_ids: [...], mode, note? } +PUT /api/v1/libraries/{id}/keep_all { keep_all: true|false } +``` + +`POST /keeps` accepts either an `item_id` (the UI's normal path — the server resolves it +to the durable `(library_id, guid, season_number)` key) or those key parts directly. +`POST /keeps/bulk` is what makes marking tractable: with 1,337 never-played seasons, +keeping a batch has to be one action on a multi-selection, not 1,337 clicks. + +Every keep response includes `resolved_items` and `resolved_bytes`, so the cost of a mark +is visible at the moment it is made rather than discovered later in a total. + +### 8.3 Saved views ``` GET /api/v1/views @@ -1029,7 +1183,7 @@ PUT /api/v1/views/{id} DELETE /api/v1/views/{id} ``` -### 8.3 Scans +### 8.4 Scans ``` GET /api/v1/scans history, newest first @@ -1040,7 +1194,7 @@ POST /api/v1/scans { "mode": "full" | "incremental" } → 202 or Scan progress is polled at `/api/v1/scans/current` every 2 s while one is running. No websockets — polling one row is cheaper than the complexity. -### 8.4 Export +### 8.5 Export ``` GET /api/v1/export.csv? @@ -1050,7 +1204,7 @@ Streams the full result set — not just the current page — as CSV with the cu selected columns plus the reclaim score and its components. This is the v1 deliverable: the artifact you take away and act on. -### 8.5 Health +### 8.6 Health ``` GET /healthz → { "status": "ok", "db": "ok", @@ -1072,7 +1226,14 @@ to the Mythica pattern. ### 9.1 Screens **Dashboard** — the landing page. Headline tiles (total items, total size, size of -never-watched content, size of content untouched for 2+ years, last scan time), plus: +never-watched content, size of content untouched for 2+ years, last scan time), plus the +three-way reclaim split that keeps the keep list honest (§6.6): + +``` +NEVER PLAYED 27.4 TB · KEPT 8.1 TB · AVAILABLE 19.3 TB +``` + +and the charts: - *Size by library* — horizontal bar. - *Added over time* — monthly bars, stacked by library. Shows acquisition habits. @@ -1089,12 +1250,25 @@ never-watched content, size of content untouched for 2+ years, last scan time), watch-count range, resolution, and a free-text title search. - A weight panel with five sliders and a live-updating `reclaim_score` column. - Multi-select via checkboxes, shift-click ranges, and select-all-matching-filter. - In v1 selection drives the aggregate footer ("**312 items selected · 4.1 TB**") and - the CSV export. The buttons that will eventually delete are present but disabled, with - a tooltip explaining v1 is report-only — so the workflow is proven before it's armed. + In v1 selection drives the aggregate footer ("**312 items selected · 4.1 TB**"), the + CSV export, and **bulk keep**. The buttons that will eventually delete are present but + disabled, with a tooltip explaining v1 is report-only — so the workflow is proven + before it's armed. +- A **keep toggle** on every row, and *Keep selected* on a multi-selection with an + optional shared note. Kept rows are hidden by default; a *Show kept* switch in the + filter rail brings them back, badged with why they are kept (`library` / `show` / + `season` / `movie`) and still showing their score. - Row expansion: a season expands to its episodes; any row expands to its file parts with full paths. +**Kept items** — the audit surface for §6.6. Every mark with its scope, label, note, when +it was made, and how many items and bytes it currently resolves to, sorted by size so the +expensive keeps are obvious. Library-level rules are listed separately from explicit +marks, and **orphaned marks get their own section** — a keep that no longer matches +anything is a signal that content was removed, renamed, or rematched, and it should be +seen rather than silently carried forever. Un-keeping is done from here or from the grid; +nothing else ever removes a mark. + **Saved views** — list, create, edit, duplicate, run. Creating one captures the current filter, sort, columns, and weights, so the flow is *explore, then name what you found*. @@ -1116,6 +1290,9 @@ progress. - Filters live in the URL query string, so any view is linkable and back/forward works. - Nothing in v1 is destructive, and the UI says so plainly in the header rather than leaving the user to wonder. +- Keeping something is one click and un-keeping is one click, but neither ever happens as + a side effect of another action. A keep mark is a stated human judgement, so only a + human statement creates or removes it. --- @@ -1150,7 +1327,7 @@ All via environment variables, so the Portainer stack is the single source of tr | `SCORE_POPULAR_AT` | `3` | | | `SCORE_REJECTED_AT` | `2` | abandoned views for a full `rejection` score | | `SCORE_SOLITUDE_AT` | `3` | distinct watchers before `solitude` reaches zero | -| `PROTECTED_LIBRARIES` | `Family Videos` | comma-separated; never scored (§6.6) | +| `KEEP_ALL_LIBRARIES` | `Family Videos` | comma-separated; seeds `library.keep_all` on first run only, editable in the UI afterwards (§6.6) | | `SCORE_GRACE_DAYS` | `30` | | | `SCORE_RECENT_DAYS` | `90` | | | `TZ` | `America/Regina` | so cron times mean what they look like | @@ -1241,10 +1418,20 @@ alone has a race window. ### 11.5 Backup The database is one file. A weekly `sqlite3 .backup` to the volume, keeping four, is -enough — everything in it is reconstructible from Plex and Tautulli, so the real value -is avoiding a multi-hour re-ingest rather than protecting irreplaceable data. The one -genuinely irreplaceable thing is the saved-view definitions, which are small and worth -exporting to the repo as JSON once they stabilise. +enough for the bulk of it — items, parts and watch events are all reconstructible from +Plex and Tautulli, so backing those up only saves a re-ingest. + +Two things in the database are **not** reconstructible from anything: the saved-view +definitions and, far more importantly, the **keep marks**. Those encode human judgements +that exist nowhere else, and losing them means either re-marking hundreds of items by +hand or — worse, in v2 — deleting something that was explicitly protected. + +So keeps and views get their own treatment beyond the database backup: a nightly export +to `/data/keeps.json` and `/data/views.json`, human-readable, containing the durable keys +(`library`, `guid`, `season_number`) rather than rating keys, plus labels and notes. +These are small, diffable, and worth committing to the repo. Restoring them into an empty +database is an import of one file, and because they key on content identity rather than +rating keys, they restore correctly even into a rebuilt Plex library. --- @@ -1282,6 +1469,7 @@ a misconfigured proxy rule. |---|---| | Provider | Recorded JSON fixtures (`responses`/`vcr.py`) for both sources: Plex covering a movie section, a show section with multi-season shows, multi-version movies, split parts; Tautulli covering `get_history` pages, `get_users`, `get_library_media_info`, and an error-shaped 200 response. No live server needed in CI. | | History semantics | Disposition classification at threshold boundaries; session merging across the window; a `pre_history` item scoring differently from a truly-never-watched one; Plex-fallback rows renormalizing the weights correctly. | +| Keep marks | Every precedence combination in §6.6, especially `exclude` overriding a library rule. **Rating-key churn: a mark must still resolve after every `ratingKey` in the library is reassigned** — the single most important test in the suite, since its failure mode in v2 is deleting protected content. Same-guid items in two libraries mark independently. Orphan detection fires when content disappears and does not fire on a rescan that merely renumbers. Export/import round-trips into an empty database. | | Normalization | Table-driven tests: odd cases like an item with no `Part`, a season with a missing episode, unicode titles, zero-byte parts. | | Ingest | In-memory SQLite; assert idempotency (running the same scan twice changes nothing), assert `missing` transitions, assert history watermarking doesn't skip or duplicate events. | | Scoring | Unit tests on known inputs; **property test asserting the SQL and Python implementations agree** (§6.4); tests that grace rules clamp correctly. | @@ -1347,10 +1535,26 @@ delete**: This requires the one thing v1 deliberately avoids: filesystem access to the media shares. The v2 container mounts them read-write and gains a path-mapping layer -translating Plex's paths (`/mnt/vault2/...` as Loki sees them) into container paths. Two -guardrails: a configured allowlist of path prefixes MediaShelf may touch, and a refusal -to act on any path outside them. Plus an immutable audit log of every quarantine and -purge — what, when, how big, and what the score was that recommended it. +translating Plex's paths (`/mnt/titan4/...` as Loki sees them) into container paths. +Guardrails, in the order they fire: + +1. **Keep marks are refused first** (§6.6). A kept item cannot be quarantined at all — + not with a confirmation, not with a force flag. Removing the mark is a separate, + deliberate act taken beforehand, and the deletion flow never offers to do it inline. + This is the guardrail that matters most, because it is the one protecting decisions a + human actually made. +2. A configured **allowlist of path prefixes** MediaShelf may touch, and a refusal to act + on any path outside them. +3. A **dry run** on every destructive operation, listing exactly what would happen, with + explicit confirmation required. +4. An **immutable audit log** of every quarantine and purge — what, when, how big, what + the score was that recommended it, and whether a keep mark was removed in the days + before. That last field is worth having: "un-kept on Tuesday, deleted on Wednesday" is + the pattern you want to be able to find after the fact. + +A scan that runs between marking and deleting must not be able to change a keep +resolution underneath an in-flight operation, so quarantine re-resolves the keep state at +the moment of execution rather than trusting what the UI displayed. Deletion also makes authentication mandatory (§12) and makes a dry-run mode non-negotiable: every destructive operation gets a preview that lists exactly what would @@ -1425,7 +1629,7 @@ door is open to a combined view across Plex and Jellyfin without a migration. Questions 1–4 were answered by running `tools/probe.py` and `tools/reclaim_preview.py` against the live servers. The findings are in §2.1 and changed the design in five places: the Tautulli size cross-check (§4.11), both Tautulli-dependent score weights (§6.1), -cross-library duplicates (§6.5), protected libraries (§6.6), and two new seed views (§7.2). +cross-library duplicates (§6.5), keep marks (§6.6), and two new seed views (§7.2). - ~~When was Tautulli installed?~~ **2025-03-08**, 547 days of coverage — but the library goes back to 2000, so 80% of it predates that. `pre_history` is the majority state. @@ -1438,9 +1642,11 @@ cross-library duplicates (§6.5), protected libraries (§6.6), and two new seed 1. **Registry or image upload?** (§11.2) A registry is the better long-term answer but is infrastructure that doesn't exist yet. A preference, not a measurement. -2. **Which other libraries should be protected?** *Family Videos* is unambiguous. *Sports* - (2 UFC items) is probably personal capture too. Anything else self-recorded needs - marking before the first real report is trusted (§6.6). +2. **Which other libraries should be kept wholesale?** *Family Videos* is unambiguous and + ships with `keep_all` set. *4K Movies* is the obvious next candidate — 72.9% never + played and the most expensive content in the library to re-acquire — but that is a + judgement call, not a measurement. *Sports* (2 UFC items) looks like personal capture. + Worth settling before the first real report is trusted (§6.6). 3. **What should the 18.8% partial-view band mean?** Four times the abandoned share and genuinely ambiguous (§6.1). v1 stores and displays it without scoring it; a month of looking at real rows should settle whether it earns a component.