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:
parent
58c2883492
commit
6a557bcdd9
37 changed files with 6486 additions and 85 deletions
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
79
tests/conftest.py
Normal file
79
tests/conftest.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
|
||||
import mockserver # noqa: E402
|
||||
|
||||
from mediashelf import ingest, providers # noqa: E402
|
||||
from mediashelf.app import create_app # noqa: E402
|
||||
from mediashelf.config import Config # noqa: E402
|
||||
from mediashelf.db import Database # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def fake_server():
|
||||
with mockserver.FakeServer() as srv:
|
||||
yield srv
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cfg(fake_server, tmp_path, monkeypatch):
|
||||
env = {
|
||||
"PLEX_BASE_URL": fake_server.url,
|
||||
"PLEX_TOKEN": "testtoken",
|
||||
"TAUTULLI_BASE_URL": fake_server.url,
|
||||
"TAUTULLI_API_KEY": "testkey",
|
||||
"DATABASE_PATH": str(tmp_path / "test.db"),
|
||||
"PLEX_PAGE_SIZE": "25",
|
||||
"TAUTULLI_PAGE_SIZE": "300",
|
||||
"PLEX_TIMEOUT_S": "10",
|
||||
"TAUTULLI_TIMEOUT_S": "10",
|
||||
"SCHEDULER_ENABLED": "0",
|
||||
"KEEP_ALL_LIBRARIES": "",
|
||||
}
|
||||
for k, v in env.items():
|
||||
monkeypatch.setenv(k, v)
|
||||
return Config.from_env()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(cfg):
|
||||
d = Database(cfg.database_path)
|
||||
d.migrate()
|
||||
from mediashelf.scoring import register_sqlite_functions
|
||||
register_sqlite_functions(d.conn)
|
||||
yield d
|
||||
d.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scanned(db, cfg):
|
||||
"""A database with one completed full scan against the fake server."""
|
||||
media = providers.build_media_provider(cfg)
|
||||
history, _ = providers.build_history_provider(cfg, media)
|
||||
result = ingest.Ingest(db, cfg, media, history).run("full", "manual")
|
||||
assert result.status == "succeeded", result.error
|
||||
return db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rescan(db, cfg):
|
||||
def _go(mode="full"):
|
||||
media = providers.build_media_provider(cfg)
|
||||
history, _ = providers.build_history_provider(cfg, media)
|
||||
return ingest.Ingest(db, cfg, media, history).run(mode, "manual")
|
||||
return _go
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(cfg, scanned):
|
||||
app = create_app(cfg, start_scheduler=False)
|
||||
app.config["TESTING"] = True
|
||||
with app.test_client() as c:
|
||||
yield c
|
||||
121
tests/test_ingest.py
Normal file
121
tests/test_ingest.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
"""Ingest correctness. The headline property is idempotency: a scanner that
|
||||
double-counts produces a report that looks plausible and is wrong, which is worse
|
||||
than one that crashes."""
|
||||
|
||||
import mockserver
|
||||
|
||||
|
||||
def snapshot(db):
|
||||
return {
|
||||
"items": db.scalar("SELECT COUNT(*) FROM media_item"),
|
||||
"unit_bytes": db.scalar(
|
||||
"SELECT COALESCE(SUM(size_bytes),0) FROM media_item "
|
||||
"WHERE kind IN ('movie','season')"),
|
||||
"parts": db.scalar("SELECT COUNT(*) FROM media_part"),
|
||||
"part_bytes": db.scalar("SELECT COALESCE(SUM(size_bytes),0) FROM media_part"),
|
||||
"episodes": db.scalar("SELECT COUNT(*) FROM episode"),
|
||||
"events": db.scalar("SELECT COUNT(*) FROM watch_event"),
|
||||
"watch_sum": db.scalar(
|
||||
"SELECT COALESCE(SUM(watch_count),0) FROM media_item WHERE kind='movie'"),
|
||||
"missing": db.scalar("SELECT COUNT(*) FROM media_item WHERE status='missing'"),
|
||||
}
|
||||
|
||||
|
||||
def test_scan_succeeds_and_totals_match_source(scanned):
|
||||
expected = mockserver.stats()
|
||||
assert scanned.scalar(
|
||||
"SELECT COALESCE(SUM(size_bytes),0) FROM media_item WHERE kind IN ('movie','season')"
|
||||
) == expected["total_bytes"]
|
||||
assert scanned.scalar("SELECT COUNT(*) FROM episode") == expected["episodes"]
|
||||
assert scanned.scalar("SELECT COUNT(*) FROM watch_event") == expected["history"]
|
||||
|
||||
|
||||
def test_scan_is_idempotent(scanned, rescan):
|
||||
first = snapshot(scanned)
|
||||
rescan("full")
|
||||
second = snapshot(scanned)
|
||||
rescan("full")
|
||||
third = snapshot(scanned)
|
||||
assert first == second == third, "a repeated scan changed the data"
|
||||
|
||||
|
||||
def test_incremental_adds_no_duplicate_events(scanned, rescan):
|
||||
before = scanned.scalar("SELECT COUNT(*) FROM watch_event")
|
||||
r = rescan("incremental")
|
||||
assert r.status == "succeeded"
|
||||
assert scanned.scalar("SELECT COUNT(*) FROM watch_event") == before
|
||||
|
||||
|
||||
def test_seasons_are_the_unit_for_tv(scanned):
|
||||
seasons = scanned.query(
|
||||
"SELECT * FROM media_item WHERE kind='season' AND episode_count > 0")
|
||||
assert seasons, "no seasons were built"
|
||||
for s in seasons:
|
||||
rolled = scanned.scalar(
|
||||
"SELECT COALESCE(SUM(size_bytes),0) FROM episode "
|
||||
"WHERE season_item_id=? AND status='present'", (s["id"],))
|
||||
assert s["size_bytes"] == rolled
|
||||
assert s["parent_id"] is not None, "season is not linked to its show"
|
||||
|
||||
|
||||
def test_multi_part_items_sum_all_parts(scanned):
|
||||
"""A movie held as two files must report the total, not the first part."""
|
||||
rows = scanned.query(
|
||||
"SELECT id, size_bytes, part_count FROM media_item "
|
||||
"WHERE kind='movie' AND part_count > 1")
|
||||
assert rows, "fixture has no multi-part movies"
|
||||
for r in rows:
|
||||
total = scanned.scalar(
|
||||
"SELECT SUM(size_bytes) FROM media_part WHERE media_item_id=?", (r["id"],))
|
||||
assert r["size_bytes"] == total
|
||||
|
||||
|
||||
def test_dispositions_are_classified(scanned, cfg):
|
||||
got = {r[0]: r[1] for r in scanned.query(
|
||||
"SELECT disposition, COUNT(*) FROM watch_event GROUP BY 1")}
|
||||
assert set(got) <= {"completed", "partial", "abandoned"}
|
||||
assert got.get("abandoned", 0) > 0, "fixture should produce abandoned plays"
|
||||
bad = scanned.scalar(
|
||||
"SELECT COUNT(*) FROM watch_event WHERE disposition='completed' "
|
||||
"AND percent_complete IS NOT NULL AND percent_complete < ?",
|
||||
(cfg.completion_threshold,))
|
||||
assert bad == 0
|
||||
|
||||
|
||||
def test_pre_history_flag_tracks_coverage(scanned):
|
||||
cov = scanned.one("SELECT * FROM history_coverage ORDER BY event_count DESC LIMIT 1")
|
||||
assert cov and cov["earliest_event_at"]
|
||||
wrong = scanned.scalar(
|
||||
"SELECT COUNT(*) FROM media_item WHERE pre_history=1 AND added_at >= ?",
|
||||
(cov["earliest_event_at"],))
|
||||
assert wrong == 0
|
||||
assert scanned.scalar("SELECT COUNT(*) FROM media_item WHERE pre_history=1") > 0
|
||||
|
||||
|
||||
def test_missing_items_are_flagged_not_deleted(scanned, rescan, monkeypatch):
|
||||
victim = scanned.one("SELECT * FROM media_item WHERE kind='movie' LIMIT 1")
|
||||
removed = [m for m in mockserver.MOVIES
|
||||
if m["ratingKey"] == victim["provider_item_id"]]
|
||||
assert removed
|
||||
monkeypatch.setattr(mockserver, "MOVIES",
|
||||
[m for m in mockserver.MOVIES
|
||||
if m["ratingKey"] != victim["provider_item_id"]])
|
||||
rescan("full")
|
||||
row = scanned.one("SELECT * FROM media_item WHERE id=?", (victim["id"],))
|
||||
assert row is not None, "a vanished item was deleted rather than flagged"
|
||||
assert row["status"] == "missing"
|
||||
|
||||
|
||||
def test_refuses_history_from_a_different_plex_server(db, cfg, monkeypatch):
|
||||
"""Joining another server's history would produce confident nonsense (§4.11)."""
|
||||
from mediashelf import ingest, providers
|
||||
from mediashelf.providers.base import ProviderError, ServerInfo
|
||||
|
||||
media = providers.build_media_provider(cfg)
|
||||
history, _ = providers.build_history_provider(cfg, media)
|
||||
monkeypatch.setattr(history, "server_info",
|
||||
lambda: ServerInfo(kind="tautulli", name="Other",
|
||||
server_id="TOTALLY-DIFFERENT"))
|
||||
result = ingest.Ingest(db, cfg, media, history).run("full", "manual")
|
||||
assert result.status == "failed"
|
||||
assert "different plex server" in (result.error or "").lower()
|
||||
164
tests/test_keeps.py
Normal file
164
tests/test_keeps.py
Normal 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
|
||||
77
tests/test_packaging.py
Normal file
77
tests/test_packaging.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"""Deployment-shape checks.
|
||||
|
||||
A variable set in Portainer's UI but missing from the compose file's
|
||||
`environment:` block silently does nothing. That cost real debugging time on the
|
||||
Mythica stack; this test makes it impossible to reintroduce here.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
yaml = pytest.importorskip("yaml")
|
||||
|
||||
|
||||
def declared_in_compose() -> set[str]:
|
||||
spec = yaml.safe_load((ROOT / "docker-compose.yml").read_text())
|
||||
env = spec["services"]["mediashelf"]["environment"]
|
||||
return {e.split("=", 1)[0] for e in env}
|
||||
|
||||
|
||||
def read_by_config() -> set[str]:
|
||||
src = (ROOT / "mediashelf" / "config.py").read_text()
|
||||
return (set(re.findall(r'_[bisf]\("([A-Z_][A-Z0-9_]*)"', src))
|
||||
| set(re.findall(r'_csv\("([A-Z_][A-Z0-9_]*)"\)', src)))
|
||||
|
||||
|
||||
def test_every_config_var_is_declared_in_the_stack():
|
||||
missing = read_by_config() - declared_in_compose()
|
||||
assert not missing, (
|
||||
"these are read by config.py but absent from docker-compose.yml's "
|
||||
"environment block, so setting them in Portainer would silently do "
|
||||
f"nothing: {sorted(missing)}")
|
||||
|
||||
|
||||
def test_compose_declares_nothing_the_app_ignores():
|
||||
extra = declared_in_compose() - read_by_config()
|
||||
assert not extra, f"compose declares unused variables: {sorted(extra)}"
|
||||
|
||||
|
||||
def test_keep_all_libraries_defaults_to_empty():
|
||||
"""Nothing ships kept (§6.6)."""
|
||||
spec = yaml.safe_load((ROOT / "docker-compose.yml").read_text())
|
||||
env = spec["services"]["mediashelf"]["environment"]
|
||||
line = next(e for e in env if e.startswith("KEEP_ALL_LIBRARIES="))
|
||||
assert line.endswith(":-}") or line.endswith("="), \
|
||||
f"a library is pre-kept in the shipped stack: {line}"
|
||||
|
||||
example = (ROOT / ".env.example").read_text()
|
||||
assert re.search(r"^KEEP_ALL_LIBRARIES=\s*$", example, re.M), \
|
||||
".env.example must not name a library"
|
||||
|
||||
|
||||
def test_image_is_not_pinned_to_bare_latest():
|
||||
spec = yaml.safe_load((ROOT / "docker-compose.yml").read_text())
|
||||
image = spec["services"]["mediashelf"]["image"]
|
||||
assert "latest" not in image, (
|
||||
"a bare :latest tag is what forced the Mythica stack to be recreated "
|
||||
"when a PUT update kept serving old code (§11.2)")
|
||||
|
||||
|
||||
def test_dockerfile_runs_as_non_root():
|
||||
df = (ROOT / "Dockerfile").read_text()
|
||||
user_lines = [l for l in df.splitlines() if l.startswith("USER ")]
|
||||
assert user_lines and not user_lines[-1].strip().endswith("root")
|
||||
|
||||
|
||||
def test_no_credentials_committed():
|
||||
"""Cheap guard against the SOAP-password-in-git-history problem."""
|
||||
suspicious = re.compile(
|
||||
r"(PLEX_TOKEN|TAUTULLI_API_KEY|MEDIASHELF_SECRET_KEY)\s*=\s*['\"]?[A-Za-z0-9]{16,}")
|
||||
for path in list(ROOT.glob("*.yml")) + list(ROOT.glob("*.py")) + \
|
||||
list((ROOT / "mediashelf").rglob("*.py")) + [ROOT / ".env.example"]:
|
||||
text = path.read_text()
|
||||
assert not suspicious.search(text), f"possible credential in {path.name}"
|
||||
184
tests/test_rules_and_api.py
Normal file
184
tests/test_rules_and_api.py
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
"""Rule grammar (must reject everything outside the whitelist) and API shapes."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from mediashelf.rules import RuleError, compile_rules, compile_sort
|
||||
|
||||
NOW = 1_757_000_000
|
||||
|
||||
|
||||
def test_basic_conditions_compile_to_bound_parameters():
|
||||
sql, params = compile_rules(
|
||||
{"op": "and", "rules": [
|
||||
{"field": "size_bytes", "op": "gte", "value": 1024},
|
||||
{"field": "watch_count", "op": "eq", "value": 0}]}, NOW)
|
||||
assert "i.size_bytes >=" in sql and "i.watch_count =" in sql
|
||||
assert set(params.values()) == {1024, 0}
|
||||
assert "1024" not in sql, "value was interpolated instead of bound"
|
||||
|
||||
|
||||
def test_nested_groups():
|
||||
sql, _ = compile_rules({"op": "or", "rules": [
|
||||
{"field": "kind", "op": "eq", "value": "movie"},
|
||||
{"op": "and", "rules": [
|
||||
{"field": "kind", "op": "eq", "value": "season"},
|
||||
{"field": "episode_count", "op": "gt", "value": 5}]}]}, NOW)
|
||||
assert sql.count("(") >= 2 and " OR " in sql and " AND " in sql
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", [
|
||||
"title; DROP TABLE media_item",
|
||||
"1=1",
|
||||
"i.size_bytes) OR (1",
|
||||
"nonexistent_column",
|
||||
"",
|
||||
])
|
||||
def test_unknown_fields_are_rejected(field):
|
||||
with pytest.raises(RuleError):
|
||||
compile_rules({"field": field, "op": "eq", "value": 1}, NOW)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("op", ["exec", "'; --", "regexp", ""])
|
||||
def test_unknown_operators_are_rejected(op):
|
||||
with pytest.raises(RuleError):
|
||||
compile_rules({"field": "title", "op": op, "value": "x"}, NOW)
|
||||
|
||||
|
||||
def test_like_wildcards_in_user_input_are_escaped():
|
||||
sql, params = compile_rules(
|
||||
{"field": "title", "op": "contains", "value": "100% _real_"}, NOW)
|
||||
assert "ESCAPE" in sql
|
||||
assert list(params.values())[0] == r"%100\% \_real\_%"
|
||||
|
||||
|
||||
def test_relative_date_operators():
|
||||
sql, params = compile_rules(
|
||||
{"field": "last_watched_at", "op": "older_than_days", "value": 730}, NOW)
|
||||
assert "IS NOT NULL" in sql
|
||||
assert list(params.values())[0] == NOW - 730 * 86400
|
||||
|
||||
|
||||
def test_never_operator_treats_zero_and_null_alike():
|
||||
sql, _ = compile_rules({"field": "watch_count", "op": "never"}, NOW)
|
||||
assert "IS NULL" in sql and "= 0" in sql
|
||||
|
||||
|
||||
def test_in_operator_bounds_list_length():
|
||||
with pytest.raises(RuleError):
|
||||
compile_rules({"field": "library_id", "op": "in",
|
||||
"value": list(range(600))}, NOW)
|
||||
with pytest.raises(RuleError):
|
||||
compile_rules({"field": "library_id", "op": "in", "value": []}, NOW)
|
||||
|
||||
|
||||
def test_deeply_nested_rules_are_rejected():
|
||||
node = {"field": "watch_count", "op": "eq", "value": 0}
|
||||
for _ in range(12):
|
||||
node = {"op": "and", "rules": [node]}
|
||||
with pytest.raises(RuleError):
|
||||
compile_rules(node, NOW)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("spec", ["size_bytes); DROP TABLE x--", "evil:desc", "1"])
|
||||
def test_sort_whitelist(spec):
|
||||
with pytest.raises(RuleError):
|
||||
compile_sort(spec)
|
||||
|
||||
|
||||
def test_sort_has_a_stable_tiebreak():
|
||||
assert compile_sort("size_bytes:desc").endswith("i.id ASC")
|
||||
|
||||
|
||||
# ── API ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_core_endpoints_respond(client):
|
||||
for path in ["/healthz", "/api/v1/stats/overview", "/api/v1/libraries",
|
||||
"/api/v1/views", "/api/v1/duplicates", "/api/v1/scans",
|
||||
"/api/v1/sources", "/api/v1/keeps", "/api/v1/accounts",
|
||||
"/api/v1/stats/completion", "/api/v1/stats/added-over-time",
|
||||
"/api/v1/stats/watch-distribution", "/"]:
|
||||
assert client.get(path).status_code == 200, path
|
||||
|
||||
|
||||
def test_items_shape(client):
|
||||
data = client.get("/api/v1/items?page_size=5").get_json()
|
||||
assert {"total", "page", "page_size", "aggregate", "items"} <= set(data)
|
||||
assert data["total"] > 0
|
||||
item = data["items"][0]
|
||||
for key in ("id", "kind", "title", "library", "size_bytes", "reclaim_score",
|
||||
"reclaim_components", "kept", "flags", "pre_history"):
|
||||
assert key in item, key
|
||||
assert set(item["reclaim_components"]) == {
|
||||
"size", "staleness", "unpopularity", "solitude", "age", "rejection"}
|
||||
|
||||
|
||||
def test_shows_are_excluded_from_the_grid_by_default(client):
|
||||
data = client.get("/api/v1/items?page_size=500").get_json()
|
||||
assert all(i["kind"] != "show" for i in data["items"])
|
||||
|
||||
|
||||
def test_injection_via_rules_is_a_400_not_a_500(client):
|
||||
bad = json.dumps({"op": "and", "rules": [
|
||||
{"field": "title; DROP TABLE media_item", "op": "eq", "value": "x"}]})
|
||||
r = client.get("/api/v1/items?rules=" + bad)
|
||||
assert r.status_code == 400
|
||||
assert r.get_json()["error"] == "invalid_rule"
|
||||
assert client.get("/api/v1/items?page_size=1").get_json()["total"] > 0
|
||||
|
||||
|
||||
def test_malformed_json_rules_is_a_400(client):
|
||||
assert client.get("/api/v1/items?rules=notjson").status_code == 400
|
||||
|
||||
|
||||
def test_search_handles_hostile_input(client):
|
||||
for q in ['"', 'a OR b', 'NEAR(', '*', "'; --"]:
|
||||
assert client.get("/api/v1/items?q=" + q).status_code == 200, q
|
||||
|
||||
|
||||
def test_csv_export_streams_every_row_not_just_a_page(client):
|
||||
total = client.get("/api/v1/items?page_size=1").get_json()["total"]
|
||||
body = client.get("/api/v1/export.csv?page_size=1").data.decode()
|
||||
lines = [l for l in body.splitlines() if l.strip()]
|
||||
assert len(lines) == total + 1
|
||||
|
||||
|
||||
def test_saved_views_are_seeded_and_runnable(client):
|
||||
views = client.get("/api/v1/views").get_json()["views"]
|
||||
names = {v["name"] for v in views}
|
||||
assert {"Confident reclaim", "Uncertain reclaim", "Kept"} <= names
|
||||
for v in views:
|
||||
r = client.get("/api/v1/items?page_size=1&view_id=%d" % v["id"])
|
||||
assert r.status_code == 200, v["name"]
|
||||
|
||||
|
||||
def test_builtin_views_cannot_be_deleted(client):
|
||||
v = next(v for v in client.get("/api/v1/views").get_json()["views"] if v["builtin"])
|
||||
assert client.delete("/api/v1/views/%d" % v["id"]).get_json()["deleted"] is False
|
||||
|
||||
|
||||
def test_settings_never_leak_credentials(client):
|
||||
body = client.get("/api/v1/settings").get_json()
|
||||
assert body["plex_token"] == "***"
|
||||
assert body["tautulli_api_key"] == "***"
|
||||
assert body["secret_key"] == "***"
|
||||
assert "testtoken" not in json.dumps(body)
|
||||
assert "testkey" not in json.dumps(body)
|
||||
|
||||
|
||||
def test_overview_reports_the_three_way_split(client):
|
||||
ov = client.get("/api/v1/stats/overview").get_json()
|
||||
for key in ("never_played_bytes", "kept_bytes", "available_bytes",
|
||||
"confident_bytes", "uncertain_bytes", "has_completion_data"):
|
||||
assert key in ov, key
|
||||
assert ov["confident_bytes"] + ov["uncertain_bytes"] <= ov["never_played_bytes"]
|
||||
|
||||
|
||||
def test_bulk_keep(client, scanned):
|
||||
ids = [i["id"] for i in
|
||||
client.get("/api/v1/items?page_size=5").get_json()["items"]]
|
||||
r = client.post("/api/v1/keeps/bulk", json={"item_ids": ids, "note": "batch"})
|
||||
assert r.status_code == 200
|
||||
assert r.get_json()["created"] == len(ids)
|
||||
assert client.get("/api/v1/keeps").get_json()["kept_items"] >= len(ids)
|
||||
159
tests/test_scoring.py
Normal file
159
tests/test_scoring.py
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
"""The score is implemented twice — SQL for the live grid, Python for export and
|
||||
tests. Two implementations of one formula is a real risk; this is the mitigation."""
|
||||
|
||||
import random
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from mediashelf import scoring
|
||||
|
||||
|
||||
def make_rows(n=500, seed=17):
|
||||
rnd = random.Random(seed)
|
||||
now = int(time.time())
|
||||
rows = []
|
||||
for i in range(n):
|
||||
kind = rnd.choice(["movie", "season"])
|
||||
rows.append({
|
||||
"provider_item_id": str(i),
|
||||
"kind": kind,
|
||||
"title": "T%d" % i,
|
||||
"size_bytes": rnd.choice([0, 1, 10**6, 2 * 10**9, 60 * 10**9, 213 * 10**9]),
|
||||
"added_at": rnd.choice([None, now - rnd.randint(1, 5000) * 86400]),
|
||||
"last_watched_at": rnd.choice([None, None, now - rnd.randint(1, 4000) * 86400]),
|
||||
"watch_count": rnd.choice([0, 0, 0, 1, 2, 5, 24]),
|
||||
"abandoned_count": rnd.randint(0, 5),
|
||||
"distinct_watcher_count": rnd.randint(0, 8),
|
||||
"episode_count": rnd.randint(1, 30) if kind == "season" else 0,
|
||||
"pre_history": rnd.choice([0, 1]),
|
||||
})
|
||||
return now, rows
|
||||
|
||||
|
||||
WEIGHT_PROFILES = [
|
||||
None,
|
||||
{"size": 1, "staleness": 0, "unpopularity": 0, "solitude": 0, "age": 0, "rejection": 0},
|
||||
{"size": 0, "staleness": 0, "unpopularity": 0, "solitude": 0, "age": 0, "rejection": 1},
|
||||
{"size": .1, "staleness": .5, "unpopularity": .1, "solitude": .1, "age": .1, "rejection": .1},
|
||||
{"size": -5, "staleness": "x", "unpopularity": .2}, # junk must be tolerated
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("has_completion", [True, False])
|
||||
@pytest.mark.parametrize("weights", WEIGHT_PROFILES)
|
||||
def test_sql_and_python_agree(db, has_completion, weights):
|
||||
now, rows = make_rows()
|
||||
db.execute("INSERT INTO provider (kind,name,base_url,created_at) "
|
||||
"VALUES ('plex','L','http://x',1)")
|
||||
db.execute("INSERT INTO library (provider_id,provider_key,title,kind) "
|
||||
"VALUES (1,'1','Movies','movie')")
|
||||
for r in rows:
|
||||
db.execute(
|
||||
"INSERT INTO media_item (provider_id,library_id,provider_item_id,kind,title,"
|
||||
"size_bytes,added_at,last_watched_at,watch_count,abandoned_count,"
|
||||
"distinct_watcher_count,episode_count,pre_history) "
|
||||
"VALUES (1,1,:provider_item_id,:kind,:title,:size_bytes,:added_at,"
|
||||
":last_watched_at,:watch_count,:abandoned_count,:distinct_watcher_count,"
|
||||
":episode_count,:pre_history)", r)
|
||||
|
||||
maxsize = db.scalar("SELECT MAX(size_bytes) FROM media_item") or 1
|
||||
ctx = scoring.ScoreContext(now=now, max_size_bytes=maxsize,
|
||||
has_completion_data=has_completion)
|
||||
|
||||
# Compare the raw formula, so a real divergence cannot hide behind rounding.
|
||||
expr, params = scoring.sql_expression(ctx, weights, rounded=False)
|
||||
params["now"] = now
|
||||
raw = {r["pid"]: r["score"] for r in db.query(
|
||||
"WITH s(maxsize) AS (SELECT MAX(size_bytes) FROM media_item) "
|
||||
f"SELECT i.provider_item_id AS pid, {expr} AS score FROM media_item i, s", params)}
|
||||
|
||||
rexpr, rparams = scoring.sql_expression(ctx, weights, rounded=True)
|
||||
rparams["now"] = now
|
||||
shown = {r["pid"]: r["score"] for r in db.query(
|
||||
"WITH s(maxsize) AS (SELECT MAX(size_bytes) FROM media_item) "
|
||||
f"SELECT i.provider_item_id AS pid, {rexpr} AS score FROM media_item i, s", rparams)}
|
||||
|
||||
for r in rows:
|
||||
out = scoring.score_row(r, ctx, weights)
|
||||
pid = r["provider_item_id"]
|
||||
assert abs(out["score_raw"] - raw[pid]) <= 1e-9, (
|
||||
f"formula diverges on {pid}: SQL {raw[pid]} vs Python {out['score_raw']}")
|
||||
# Displayed values must agree to the last displayed digit. Exact equality
|
||||
# is NOT assertable: SQLite and Python evaluate the same formula in a
|
||||
# different order, so a raw score sitting exactly on a .xx5 boundary can
|
||||
# land on either side of it while still agreeing to 1e-9 above.
|
||||
# Compared in integer hundredths: subtracting two 2dp floats does not
|
||||
# give exactly 0.01, so a float tolerance here fails on its own rounding.
|
||||
assert abs(round(out["score"] * 100) - round(shown[pid] * 100)) <= 1, (
|
||||
f"rounding diverges on {pid}: SQL {shown[pid]} vs Python {out['score']}")
|
||||
|
||||
|
||||
def test_new_arrival_grace_clamps_to_zero():
|
||||
now = int(time.time())
|
||||
ctx = scoring.ScoreContext(now=now, max_size_bytes=10**12)
|
||||
row = {"kind": "movie", "size_bytes": 10**12, "added_at": now - 5 * 86400,
|
||||
"last_watched_at": None, "watch_count": 0, "abandoned_count": 0,
|
||||
"distinct_watcher_count": 0, "episode_count": 0, "pre_history": 0}
|
||||
out = scoring.score_row(row, ctx)
|
||||
assert out["score"] == 0.0 and out["grace"] == "new"
|
||||
|
||||
|
||||
def test_recent_watch_grace_caps_the_score():
|
||||
now = int(time.time())
|
||||
ctx = scoring.ScoreContext(now=now, max_size_bytes=10**12)
|
||||
row = {"kind": "movie", "size_bytes": 10**12, "added_at": now - 3000 * 86400,
|
||||
"last_watched_at": now - 5 * 86400, "watch_count": 1, "abandoned_count": 0,
|
||||
"distinct_watcher_count": 1, "episode_count": 0, "pre_history": 0}
|
||||
out = scoring.score_row(row, ctx)
|
||||
assert out["score"] <= 25.0 and out["grace"] == "recent"
|
||||
|
||||
|
||||
def test_rejection_is_unavailable_not_zero_without_completion_data():
|
||||
"""A missing component must renormalize, not drag every score down (§6.2)."""
|
||||
now = int(time.time())
|
||||
row = {"kind": "movie", "size_bytes": 5 * 10**9, "added_at": now - 2000 * 86400,
|
||||
"last_watched_at": None, "watch_count": 0, "abandoned_count": 0,
|
||||
"distinct_watcher_count": 0, "episode_count": 0, "pre_history": 0}
|
||||
with_cd = scoring.score_row(
|
||||
row, scoring.ScoreContext(now=now, max_size_bytes=10**11, has_completion_data=True))
|
||||
without = scoring.score_row(
|
||||
row, scoring.ScoreContext(now=now, max_size_bytes=10**11, has_completion_data=False))
|
||||
assert without["components"]["rejection"] is None
|
||||
# rejection would have scored 0 here, so dropping it must RAISE the score
|
||||
assert without["score"] > with_cd["score"]
|
||||
|
||||
|
||||
def test_rejection_zeroes_once_something_is_finished():
|
||||
now = int(time.time())
|
||||
ctx = scoring.ScoreContext(now=now, max_size_bytes=10**11)
|
||||
base = {"kind": "movie", "size_bytes": 5 * 10**9, "added_at": now - 2000 * 86400,
|
||||
"last_watched_at": None, "abandoned_count": 4,
|
||||
"distinct_watcher_count": 2, "episode_count": 0, "pre_history": 0}
|
||||
assert scoring.components({**base, "watch_count": 0}, ctx)["rejection"] == 1.0
|
||||
assert scoring.components({**base, "watch_count": 1}, ctx)["rejection"] == 0.0
|
||||
|
||||
|
||||
def test_tv_watches_are_normalized_per_episode():
|
||||
"""A 24-episode season watched once must not look 24x more popular."""
|
||||
now = int(time.time())
|
||||
ctx = scoring.ScoreContext(now=now, max_size_bytes=10**11)
|
||||
season = {"kind": "season", "size_bytes": 10**10, "added_at": now - 1000 * 86400,
|
||||
"last_watched_at": now - 500 * 86400, "watch_count": 24,
|
||||
"abandoned_count": 0, "distinct_watcher_count": 1,
|
||||
"episode_count": 24, "pre_history": 0}
|
||||
movie = {**season, "kind": "movie", "watch_count": 1, "episode_count": 0}
|
||||
assert scoring.components(season, ctx)["unpopularity"] == \
|
||||
pytest.approx(scoring.components(movie, ctx)["unpopularity"])
|
||||
|
||||
|
||||
def test_pre_history_never_watched_is_capped_below_truly_never_watched():
|
||||
now = int(time.time())
|
||||
ctx = scoring.ScoreContext(now=now, max_size_bytes=10**11)
|
||||
base = {"kind": "movie", "size_bytes": 10**10, "added_at": now - 3000 * 86400,
|
||||
"last_watched_at": None, "watch_count": 0, "abandoned_count": 0,
|
||||
"distinct_watcher_count": 0, "episode_count": 0}
|
||||
known = scoring.components({**base, "pre_history": 0}, ctx)["staleness"]
|
||||
unknown = scoring.components({**base, "pre_history": 1}, ctx)["staleness"]
|
||||
assert known == 1.0
|
||||
assert unknown < known
|
||||
Loading…
Add table
Add a link
Reference in a new issue