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
This commit is contained in:
parent
71cfa31f09
commit
d12cbc62ee
5 changed files with 66 additions and 2 deletions
|
|
@ -197,6 +197,17 @@ class PlexProvider:
|
||||||
))
|
))
|
||||||
|
|
||||||
kind = "movie" if library.kind == "movie" else "episode"
|
kind = "movie" if library.kind == "movie" else "episode"
|
||||||
|
|
||||||
|
# Plex sometimes returns an episode with no parentRatingKey even though
|
||||||
|
# it knows the show and the season number (seen on Firefly in TV Show
|
||||||
|
# Archive: 15 episodes, parentGuid present, parentRatingKey null).
|
||||||
|
# Without a season id the whole season used to be dropped from the
|
||||||
|
# report, so synthesize a stable one from show + season number.
|
||||||
|
season_id = row.get("parentRatingKey")
|
||||||
|
if season_id is None and row.get("grandparentRatingKey") is not None \
|
||||||
|
and row.get("parentIndex") is not None:
|
||||||
|
season_id = "%s:s%s" % (row["grandparentRatingKey"], row["parentIndex"])
|
||||||
|
|
||||||
return Item(
|
return Item(
|
||||||
provider_item_id=str(rk),
|
provider_item_id=str(rk),
|
||||||
kind=kind,
|
kind=kind,
|
||||||
|
|
@ -215,7 +226,7 @@ class PlexProvider:
|
||||||
parts=parts,
|
parts=parts,
|
||||||
show_id=str(row["grandparentRatingKey"]) if row.get("grandparentRatingKey") is not None else None,
|
show_id=str(row["grandparentRatingKey"]) if row.get("grandparentRatingKey") is not None else None,
|
||||||
show_title=row.get("grandparentTitle"),
|
show_title=row.get("grandparentTitle"),
|
||||||
season_id=str(row["parentRatingKey"]) if row.get("parentRatingKey") is not None else None,
|
season_id=str(season_id) if season_id is not None else None,
|
||||||
season_number=_opt_int(row.get("parentIndex")),
|
season_number=_opt_int(row.get("parentIndex")),
|
||||||
episode_number=_opt_int(row.get("index")),
|
episode_number=_opt_int(row.get("index")),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -200,7 +200,11 @@ class Query:
|
||||||
flags.append("duplicate")
|
flags.append("duplicate")
|
||||||
if (d.get("provider_view_count") or 0) > 0 and not d.get("watch_count"):
|
if (d.get("provider_view_count") or 0) > 0 and not d.get("watch_count"):
|
||||||
flags.append("history_gap")
|
flags.append("history_gap")
|
||||||
if (d.get("part_count") or 0) > 1:
|
# A season has one part per episode, so part_count > 1 is normal there;
|
||||||
|
# only flag genuinely extra files (a movie held twice, or a split episode).
|
||||||
|
parts = d.get("part_count") or 0
|
||||||
|
if (d["kind"] == "movie" and parts > 1) or \
|
||||||
|
(d["kind"] == "season" and parts > (d.get("episode_count") or 0)):
|
||||||
flags.append("multi_part")
|
flags.append("multi_part")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -119,3 +119,22 @@ def test_refuses_history_from_a_different_plex_server(db, cfg, monkeypatch):
|
||||||
result = ingest.Ingest(db, cfg, media, history).run("full", "manual")
|
result = ingest.Ingest(db, cfg, media, history).run("full", "manual")
|
||||||
assert result.status == "failed"
|
assert result.status == "failed"
|
||||||
assert "different plex server" in (result.error or "").lower()
|
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]
|
||||||
|
|
|
||||||
|
|
@ -207,3 +207,14 @@ def test_completion_flag_is_false_for_plex_only_history(scanned):
|
||||||
from mediashelf import queries
|
from mediashelf import queries
|
||||||
scanned.execute("UPDATE watch_event SET percent_complete = NULL")
|
scanned.execute("UPDATE watch_event SET percent_complete = NULL")
|
||||||
assert queries.history_has_completion(scanned) is False
|
assert queries.history_has_completion(scanned) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_multi_part_flag_is_not_set_on_ordinary_seasons(client):
|
||||||
|
"""A season has one part per episode; flagging that as multi_part put a
|
||||||
|
meaningless badge on every TV row in the grid."""
|
||||||
|
data = client.get("/api/v1/items?page_size=500&kind=season").get_json()
|
||||||
|
seasons = [i for i in data["items"] if i["kind"] == "season"]
|
||||||
|
assert seasons
|
||||||
|
bogus = [s for s in seasons
|
||||||
|
if "multi_part" in s["flags"] and s["part_count"] <= (s["episode_count"] or 0)]
|
||||||
|
assert not bogus, f"{len(bogus)} seasons flagged multi_part with no extra files"
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,25 @@ def _build(seed=7):
|
||||||
})
|
})
|
||||||
ep_rk += 1
|
ep_rk += 1
|
||||||
|
|
||||||
|
# A show whose episodes carry no parentRatingKey — Plex really does this
|
||||||
|
# (Firefly, in the live TV Show Archive). The season must still be built.
|
||||||
|
orphan_show = "990"
|
||||||
|
shows.append({"sectionKey": "3", "ratingKey": orphan_show,
|
||||||
|
"guid": "plex://show/orphan", "type": "show", "title": "Orphan Show"})
|
||||||
|
for ep in range(1, 6):
|
||||||
|
episodes.append({
|
||||||
|
"sectionKey": "3", "ratingKey": str(ep_rk), "guid": "plex://episode/%d" % ep_rk,
|
||||||
|
"type": "episode", "title": "Orphan Ep %d" % ep, "index": ep,
|
||||||
|
"grandparentRatingKey": orphan_show, "grandparentTitle": "Orphan Show",
|
||||||
|
"parentRatingKey": None, "parentIndex": 1,
|
||||||
|
"addedAt": NOW - 900 * DAY, "duration": 45 * 60000, "viewCount": 0,
|
||||||
|
"Media": [{"videoResolution": "1080", "Part": [
|
||||||
|
{"id": 70000 + ep_rk,
|
||||||
|
"file": "/mnt/titan4/TVArchive/Orphan Show/S01/E%02d.mkv" % ep,
|
||||||
|
"size": 2 * 10**9}]}],
|
||||||
|
})
|
||||||
|
ep_rk += 1
|
||||||
|
|
||||||
# ── Tautulli history ───────────────────────────────────────────────
|
# ── Tautulli history ───────────────────────────────────────────────
|
||||||
watchable = [m["ratingKey"] for m in movies] + [e["ratingKey"] for e in episodes]
|
watchable = [m["ratingKey"] for m in movies] + [e["ratingKey"] for e in episodes]
|
||||||
row_id = 1
|
row_id = 1
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue