Files
dtourolle 96b1c22194 feat(cameo): detect recognised actors not credited in a title
Add two cameo hunters that flag actors recognised in a title but absent from
its cast:
  - cameo_jellyfin.py — pure-Jellyfin cast-membership check (no id cross-walk)
  - cameo_hunt.py     — TMDB filmography check (actor's combined_credits)

run_from_jellyfin.py now stamps the analysed title's Jellyfin item GUID into
the output JSON as top-level 'jellyfin_item_id' (scene_analyze can't know it),
which cameo_jellyfin.py uses to look up the cast in Jellyfin's own id space.
Document that field in the result-sink output schema header.
2026-07-04 20:39:35 +02:00

239 lines
9.9 KiB
Python

#!/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=<id>``). 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()