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
140 lines
6 KiB
Python
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]
|