diff --git a/mediashelf/providers/plex.py b/mediashelf/providers/plex.py index 527d4f1..182f2ea 100644 --- a/mediashelf/providers/plex.py +++ b/mediashelf/providers/plex.py @@ -197,6 +197,17 @@ class PlexProvider: )) 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( provider_item_id=str(rk), kind=kind, @@ -215,7 +226,7 @@ class PlexProvider: parts=parts, show_id=str(row["grandparentRatingKey"]) if row.get("grandparentRatingKey") is not None else None, 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")), episode_number=_opt_int(row.get("index")), ) diff --git a/mediashelf/queries.py b/mediashelf/queries.py index f330134..3779870 100644 --- a/mediashelf/queries.py +++ b/mediashelf/queries.py @@ -200,7 +200,11 @@ class Query: flags.append("duplicate") if (d.get("provider_view_count") or 0) > 0 and not d.get("watch_count"): 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") return { diff --git a/tests/test_ingest.py b/tests/test_ingest.py index 573343c..295d0a8 100644 --- a/tests/test_ingest.py +++ b/tests/test_ingest.py @@ -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") 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] diff --git a/tests/test_rules_and_api.py b/tests/test_rules_and_api.py index bf8fba2..37b5509 100644 --- a/tests/test_rules_and_api.py +++ b/tests/test_rules_and_api.py @@ -207,3 +207,14 @@ def test_completion_flag_is_false_for_plex_only_history(scanned): from mediashelf import queries scanned.execute("UPDATE watch_event SET percent_complete = NULL") 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" diff --git a/tools/mockserver.py b/tools/mockserver.py index 8cc2c4b..ff2e382 100644 --- a/tools/mockserver.py +++ b/tools/mockserver.py @@ -96,6 +96,25 @@ def _build(seed=7): }) 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 ─────────────────────────────────────────────── watchable = [m["ratingKey"] for m in movies] + [e["ratingKey"] for e in episodes] row_id = 1