#!/usr/bin/env python3 """cameo_jellyfin.py — find recognised actors not credited in a title, via Jellyfin. Pure-Jellyfin cameo detection: no TMDB, no id-space cross-walk. For each scene_analyze output JSON we need two things, both in Jellyfin's own id space: * the title's Jellyfin item-id — taken from the JSON's ``jellyfin_item_id`` field if present (stamped by run_from_jellyfin.py), otherwise resolved by shared-cast voting: the item most of the recognised actors are credited in is the title we analysed. (The raw ``Path`` lookup and filename name-search are both unreliable in this library — see resolve_by_cast.); * each recognised actor's ``jellyfin_id`` (already in the JSON). An actor is a cameo candidate iff the title's item-id is NOT among the library items Jellyfin credits that person in (``GET /Items?PersonIds=``). Because both sides come from Jellyfin, a lead role is never flagged — the whole class of id-mismatch false positives disappears. Usage: python scripts/cameo_jellyfin.py *.json python scripts/cameo_jellyfin.py --min-seconds 5 --json out.json *.json """ import argparse import json import os import sys from collections import defaultdict from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import sae_env # noqa: F401 — loads .env (JELLYFIN_URL, JELLYFIN_API_KEY, TMDB_API_KEY) from sae_jellyfin import normalize_jellyfin_url from sae_tmdb import tmdb_get import re import requests def norm_title(s: str) -> str: """Normalise a title for cross-source name matching (drop punctuation/case).""" s = s.lower() s = re.sub(r"[^a-z0-9]+", " ", s) return re.sub(r"\s+", " ", s).strip() def tmdb_resume_titles(tmdb_id, key: str, cache: dict) -> set[str] | None: """Normalised set of title names on an actor's TMDB resumé (cached), or None. Used as an independent second opinion: Jellyfin's People metadata sometimes omits even a lead (e.g. Damian Lewis absent from Billions), so we only flag a cameo when TMDB *also* lacks the title. Matching by normalised name (not id) sidesteps the TMDB title-id mismatch that plagues the pure-TMDB approach. """ if tmdb_id in cache: return cache[tmdb_id] try: data = tmdb_get(f"/person/{tmdb_id}/combined_credits", key) except requests.RequestException as e: print(f" [warn] TMDB resumé fetch failed for {tmdb_id}: {e}", file=sys.stderr) cache[tmdb_id] = None return None titles = set() for c in data.get("cast", []) + data.get("crew", []): t = c.get("title") or c.get("name") or c.get("original_title") or c.get("original_name") if t: titles.add(norm_title(t)) cache[tmdb_id] = titles return titles class Jellyfin: def __init__(self, base: str, key: str, timeout: float = 10.0): self.base = base.rstrip("/") self.h = {"X-Emby-Token": key, "Accept": "application/json"} self.timeout = timeout self._person_items: dict[str, set[str]] = {} # jellyfin_id -> {item ids} self._item_name: dict[str, str] = {} # item id -> Name def _get(self, path: str, **params) -> dict: r = requests.get(self.base + path, headers=self.h, params=params, timeout=self.timeout) r.raise_for_status() return r.json() def item_name(self, item_id: str) -> str: """Display name of a library item (cached), or "" on failure.""" if item_id in self._item_name: return self._item_name[item_id] try: data = self._get("/Items", Ids=item_id, Recursive="true", Limit=1) items = data.get("Items", []) name = items[0].get("Name", "") if items else "" except requests.RequestException: name = "" self._item_name[item_id] = name return name def person_item_ids(self, jellyfin_id: str) -> set[str]: """Set of library item-ids Jellyfin credits this person in (cached).""" if jellyfin_id in self._person_items: return self._person_items[jellyfin_id] try: data = self._get("/Items", PersonIds=jellyfin_id, Recursive="true", IncludeItemTypes="Movie,Series", Limit=500) ids = {it["Id"] for it in data.get("Items", []) if "Id" in it} except requests.RequestException as e: print(f" [warn] PersonIds lookup failed for {jellyfin_id}: {e}", file=sys.stderr) ids = set() self._person_items[jellyfin_id] = ids return ids def resolve_by_cast(self, actor_ids: set[str]) -> str | None: """Resolve the analysed title's item-id by shared-cast voting. No filename parsing, no name search (both unreliable — see the module docstring / [[jellyfin-path-lookup-unreliable]]). Each recognised actor's PersonIds filmography is a set of Movie/Series item-ids; the title we analysed is the item the most of them share. Reuses the person_item_ids cache, so this is ~free beyond the per-actor lookups we already do. Works identically for film and TV (a TV title resolves to its Series item). Returns None if fewer than two actors agree — one actor alone isn't enough to trust (they could be a genuine cross-title match). """ votes: dict[str, int] = {} for jid in actor_ids: for iid in self.person_item_ids(jid): votes[iid] = votes.get(iid, 0) + 1 if not votes: return None best_id, best_n = max(votes.items(), key=lambda kv: kv[1]) return best_id if best_n >= 2 else None def total_seconds(scenes: list) -> float: return sum(e - s for s, e in (x for x in scenes if len(x) == 2)) def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("files", nargs="+", help="scene_analyze output JSON files") ap.add_argument("--min-seconds", type=float, default=0.0, help="only flag if the actor's on-screen time is >= this (default 0)") ap.add_argument("--json", default=None, help="also write findings to this JSON file") ap.add_argument("--no-tmdb-confirm", dest="tmdb_confirm", action="store_false", help="skip the TMDB second-opinion cross-check (flag on Jellyfin " "credits alone; more false positives from Jellyfin metadata gaps)") args = ap.parse_args() jf_url = normalize_jellyfin_url(os.environ.get("JELLYFIN_URL", "")) or "" jf_key = os.environ.get("JELLYFIN_API_KEY", "") if not (jf_url and jf_key): sys.exit("JELLYFIN_URL / JELLYFIN_API_KEY not set (expected in .env)") jf = Jellyfin(jf_url, jf_key) tmdb_key = os.environ.get("TMDB_API_KEY", "") if args.tmdb_confirm else "" if args.tmdb_confirm and not tmdb_key: sys.exit("TMDB_API_KEY not set (needed for cross-check; pass --no-tmdb-confirm to skip)") resume_cache: dict = {} cameos: list[dict] = [] n_unresolved = 0 n_via_stamp = 0 n_tmdb_saved = 0 # candidates rejected because TMDB *does* credit the actor paths = [Path(f) for f in args.files] for i, path in enumerate(paths, 1): try: data = json.loads(path.read_text()) except (OSError, ValueError): continue if not isinstance(data, dict) or "actors" not in data: continue movie = data.get("movie", "") actor_ids = {a["jellyfin_id"] for a in data["actors"] if a.get("jellyfin_id")} title_id = data.get("jellyfin_item_id") if title_id: n_via_stamp += 1 else: title_id = jf.resolve_by_cast(actor_ids) if not title_id: n_unresolved += 1 continue title_name = norm_title(jf.item_name(title_id)) if args.tmdb_confirm else "" for actor in data["actors"]: jid = actor.get("jellyfin_id") if not jid: continue secs = total_seconds(actor.get("scenes", [])) if secs < args.min_seconds: continue if title_id in jf.person_item_ids(jid): continue # Jellyfin credits them → not a cameo # Jellyfin says not-credited. Confirm with TMDB (independent gaps): # only flag if the actor's TMDB resumé also lacks this title name. if args.tmdb_confirm: tmdb_id = actor.get("tmdb_id") titles = tmdb_resume_titles(tmdb_id, tmdb_key, resume_cache) if tmdb_id else None if titles and title_name and title_name in titles: n_tmdb_saved += 1 continue cameos.append({ "actor": actor.get("name", ""), "jellyfin_id": jid, "title": Path(movie).stem, "title_id": title_id, "file": path.name, "seconds": round(secs, 1), "scenes": len(actor.get("scenes", [])), }) if i % 50 == 0: print(f" …{i}/{len(paths)} files, {len(cameos)} candidates so far", file=sys.stderr) cameos.sort(key=lambda c: (c["actor"], -c["seconds"])) by_actor = defaultdict(list) for c in cameos: by_actor[c["actor"]].append(c) print(f"\n({n_via_stamp} title(s) via stamped id, {n_unresolved} unresolvable, " f"{n_tmdb_saved} rejected by TMDB cross-check)", file=sys.stderr) print(f"\n=== {len(cameos)} cameo candidate(s) across {len(by_actor)} actor(s) ===\n") for actor in sorted(by_actor): print(f"{actor}:") for c in by_actor[actor]: print(f" {c['title']!r} {c['seconds']}s / {c['scenes']} scene(s) [{c['file']}]") if args.json: Path(args.json).write_text(json.dumps(cameos, indent=2) + "\n") print(f"\nWrote {len(cameos)} candidate(s) to {args.json}", file=sys.stderr) if __name__ == "__main__": main()