MediaShelf/tests/test_ingest.py
Jess Hallsworth d12cbc62ee
Stop dropping seasons Plex reports without a parentRatingKey
The first real scan logged 15 "episode has no season; skipped" warnings. They
are Firefly S1 in TV Show Archive: Plex returns those episodes with
grandparentRatingKey and parentIndex set and parentGuid present, but
parentRatingKey null. Requiring parentRatingKey meant the entire season was
silently absent from the report - exactly the kind of quiet omission a reclaim
tool must not have.

The season key is now synthesized from show + season number when Plex omits it,
which is stable across scans. Keep marks are unaffected either way since they
key on GUIDs, not rating keys.

Also drops the multi_part flag from ordinary seasons. A season has one part per
episode, so part_count > 1 is normal there and the badge appeared on every TV
row; it now means what it says - a movie held more than once, or a season with
more files than episodes.

Both cases are in the fake server now, so the suite covers them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVbG48GAXfCZatcmX123Ra
2026-09-10 14:37:30 +00:00

140 lines
6 KiB
Python

"""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()
def test_episodes_without_a_parent_rating_key_still_build_a_season(scanned):
"""Plex omits parentRatingKey on some episodes (Firefly, live). Dropping
them silently lost a whole 15-episode season from the report."""
season = scanned.one(
"SELECT i.*, p.title AS show FROM media_item i "
"JOIN media_item p ON p.id = i.parent_id "
"WHERE i.kind='season' AND p.title='Orphan Show'")
assert season is not None, "season was dropped for want of a parentRatingKey"
assert season["episode_count"] == 5
assert season["size_bytes"] == 5 * 2 * 10**9
assert season["season_number"] == 1
assert ":s1" in season["provider_item_id"], "expected a synthesized season key"
def test_no_orphan_episode_warnings(scanned, rescan):
r = rescan("full")
assert not [w for w in (r.warnings or []) if "no season" in w]