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.