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

164
tests/test_keeps.py Normal file
View file

@ -0,0 +1,164 @@
"""Keep marks (§6.6).
The single most important test in the suite is
test_marks_survive_every_rating_key_being_reassigned. Its failure mode in v2 is
deleting content someone explicitly protected.
"""
import pytest
from mediashelf import keeps
from mediashelf.keeps import KeepError
def kept_ids(db):
return {r["id"] for r in db.query("SELECT id FROM media_item WHERE kept=1")}
def a_movie(db):
return db.one("SELECT * FROM media_item WHERE kind='movie' "
"AND guid IS NOT NULL ORDER BY size_bytes DESC LIMIT 1")
def a_season(db):
return db.one("SELECT * FROM media_item WHERE kind='season' "
"AND show_guid IS NOT NULL ORDER BY size_bytes DESC LIMIT 1")
def test_marks_survive_every_rating_key_being_reassigned(scanned):
"""Plex reassigns ratingKeys on library rebuilds. Marks must not detach."""
movie, season = a_movie(scanned), a_season(scanned)
keeps.create_from_item(scanned, movie["id"], "keep", "expensive to re-acquire")
keeps.create_from_item(scanned, season["id"], "keep", "might watch someday")
keeps.resolve_all(scanned)
before = kept_ids(scanned)
assert len(before) == 2
for table in ("media_item", "episode", "watch_event"):
scanned.execute(
f"UPDATE {table} SET provider_item_id = 'REBUILD-' || provider_item_id")
keeps.resolve_all(scanned)
assert kept_ids(scanned) == before, "keeps detached when rating keys changed"
def test_same_guid_in_two_libraries_marks_independently(scanned):
"""Movies and 4K Movies share GUIDs; an unscoped mark would keep both."""
dupe_guid = scanned.scalar(
"SELECT guid FROM media_item WHERE kind='movie' AND guid IS NOT NULL "
"GROUP BY guid HAVING COUNT(*) > 1 LIMIT 1")
assert dupe_guid, "fixture has no cross-library duplicates"
copies = scanned.query(
"SELECT * FROM media_item WHERE guid=? AND kind='movie' ORDER BY library_id",
(dupe_guid,))
assert len(copies) == 2
keeps.create_from_item(scanned, copies[0]["id"], "keep")
keeps.resolve_all(scanned)
assert scanned.scalar("SELECT kept FROM media_item WHERE id=?", (copies[0]["id"],)) == 1
assert scanned.scalar("SELECT kept FROM media_item WHERE id=?", (copies[1]["id"],)) == 0
def test_library_rule_keeps_everything_in_it(scanned):
lib_id = scanned.scalar("SELECT id FROM library WHERE kind='movie' LIMIT 1")
n = scanned.scalar("SELECT COUNT(*) FROM media_item WHERE library_id=?", (lib_id,))
keeps.set_library_keep_all(scanned, lib_id, True)
keeps.resolve_all(scanned)
assert scanned.scalar(
"SELECT COUNT(*) FROM media_item WHERE library_id=? AND kept=1", (lib_id,)) == n
def test_explicit_exclude_overrides_a_library_rule(scanned):
"""Without this, 'keep all of X except one' is a dead end."""
lib_id = scanned.scalar("SELECT id FROM library WHERE kind='movie' LIMIT 1")
keeps.set_library_keep_all(scanned, lib_id, True)
keeps.resolve_all(scanned)
victim = scanned.one(
"SELECT * FROM media_item WHERE library_id=? AND kind='movie' "
"AND guid IS NOT NULL LIMIT 1", (lib_id,))
assert scanned.scalar("SELECT kept FROM media_item WHERE id=?", (victim["id"],)) == 1
keeps.create_from_item(scanned, victim["id"], "exclude", "actually don't want this")
keeps.resolve_all(scanned)
assert scanned.scalar("SELECT kept FROM media_item WHERE id=?", (victim["id"],)) == 0
def test_show_mark_keeps_all_its_seasons(scanned):
show = scanned.one(
"SELECT * FROM media_item WHERE kind='show' AND guid IS NOT NULL "
"AND (SELECT COUNT(*) FROM media_item s WHERE s.parent_id = media_item.id) > 1 "
"LIMIT 1")
assert show, "fixture has no multi-season show"
keeps.create_from_item(scanned, show["id"], "keep", "whole series")
keeps.resolve_all(scanned)
unkept = scanned.scalar(
"SELECT COUNT(*) FROM media_item WHERE parent_id=? AND kept=0", (show["id"],))
assert unkept == 0
def test_season_mark_keeps_only_that_season(scanned):
season = a_season(scanned)
keeps.create_from_item(scanned, season["id"], "keep")
keeps.resolve_all(scanned)
siblings = scanned.query(
"SELECT id, kept FROM media_item WHERE parent_id=? AND id != ?",
(season["parent_id"], season["id"]))
assert scanned.scalar("SELECT kept FROM media_item WHERE id=?", (season["id"],)) == 1
assert all(s["kept"] == 0 for s in siblings)
def test_orphan_detection(scanned):
movie = a_movie(scanned)
keeps.create_from_item(scanned, movie["id"], "keep")
keeps.resolve_all(scanned)
assert keeps.stamp_matches(scanned, 1) == 0
# content leaves the library entirely
scanned.execute("DELETE FROM media_item WHERE id=?", (movie["id"],))
keeps.resolve_all(scanned)
assert keeps.stamp_matches(scanned, 2) == 1, "orphaned mark was not detected"
assert scanned.scalar("SELECT COUNT(*) FROM keep_mark") == 1, \
"an orphaned mark must never be auto-deleted"
def test_export_import_round_trip(scanned):
movie, season = a_movie(scanned), a_season(scanned)
keeps.create_from_item(scanned, movie["id"], "keep", "note one")
keeps.create_from_item(scanned, season["id"], "keep", "note two")
lib_id = scanned.scalar("SELECT id FROM library WHERE kind='movie' LIMIT 1")
keeps.set_library_keep_all(scanned, lib_id, True)
keeps.resolve_all(scanned)
before = kept_ids(scanned)
payload = keeps.export(scanned)
assert payload["version"] == 1
assert len(payload["marks"]) == 2
assert payload["library_keep_all"]
scanned.execute("DELETE FROM keep_mark")
scanned.execute("UPDATE library SET keep_all=0")
keeps.resolve_all(scanned)
assert kept_ids(scanned) == set()
keeps.import_(scanned, payload)
assert kept_ids(scanned) == before, "restore did not reproduce the keep state"
def test_refuses_to_mark_an_item_with_no_guid(scanned):
"""A mark that cannot survive a rebuild is worse than no mark."""
movie = a_movie(scanned)
scanned.execute("UPDATE media_item SET guid=NULL WHERE id=?", (movie["id"],))
with pytest.raises(KeepError, match="GUID"):
keeps.create_from_item(scanned, movie["id"], "keep")
def test_kept_items_are_hidden_from_the_grid_by_default(client, scanned):
total = client.get("/api/v1/items?page_size=1").get_json()["total"]
movie = a_movie(scanned)
r = client.post("/api/v1/keeps", json={"item_id": movie["id"], "note": "x"})
assert r.status_code == 201
assert client.get("/api/v1/items?page_size=1").get_json()["total"] == total - 1
assert client.get(
"/api/v1/items?page_size=1&include_kept=1").get_json()["total"] == total