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.
This commit is contained in:
2026-07-04 20:39:35 +02:00
parent 3700c763dd
commit 96b1c22194
4 changed files with 668 additions and 16 deletions
+107 -16
View File
@@ -39,7 +39,11 @@ from pathlib import Path
import requests
sys.path.insert(0, str(Path(__file__).resolve().parent))
from filter_gallery import jf_get, find_item_id, fetch_cast_person_ids
import sae_env # noqa: F401 — loads .env into os.environ on import
from sae_jellyfin import (
jf_get, find_item_id, fetch_cast_person_ids, actor_jellyfin_id, fetch_episode_info,
)
from sae_tmdb import tmdb_episode_cast, tmdb_tv_id_from_imdb
def fetch_item_path(base_url: str, api_key: str, item_id: str) -> tuple[str, str]:
@@ -91,6 +95,69 @@ def push_truth(base_url: str, api_key: str, item_id: str, output_path: str) -> N
r.raise_for_status()
def _norm_name(name: str) -> str:
"""Casefold + collapse whitespace so 'Robert Downey Jr.' matches 'robert downey jr.'."""
return " ".join((name or "").split()).casefold()
def episode_cast_actors(args, item_id: str, gallery: dict) -> list[dict] | None:
"""Filter the gallery to one episode's TMDB cast, or None to fall back to series-wide.
Returns None (so the caller uses the existing series-wide jellyfin_id
filter) whenever the item isn't an episode, the series TMDB id or
season/episode numbers can't be resolved, TMDB returns no cast, or the
match against the gallery comes up empty. Matches TMDB people to gallery
actors by tmdb_id first, then by normalized name for actors whose gallery
entry has no tmdb_id.
"""
if args.episode_cast == "series":
return None
if not args.tmdb_key:
return None
info = fetch_episode_info(args.jellyfin_url, args.api_key, item_id)
if not info or info["season"] is None or info["episode"] is None:
return None
tv_id = info["series_tmdb"]
if not tv_id and info["series_imdb"]:
try:
tv_id = tmdb_tv_id_from_imdb(info["series_imdb"], args.tmdb_key)
except requests.RequestException as e:
print(f" [warn] TMDB series lookup failed: {e}", file=sys.stderr)
if not tv_id:
print(" No TMDB series id for this episode; using series-wide cast", file=sys.stderr)
return None
try:
cast = tmdb_episode_cast(tv_id, info["season"], info["episode"], args.tmdb_key)
except requests.RequestException as e:
print(f" [warn] TMDB episode credits failed (S{info['season']}E{info['episode']}): {e}; "
f"using series-wide cast", file=sys.stderr)
return None
if not cast:
return None
# The episode-credits endpoint gives a TMDB person id + name per cast
# member (no imdb id), so match on the gallery's tmdb_id first — populated
# by make_jellyfin_gallery.py when built with a TMDB key — then fall back
# to normalized name for actors whose gallery entry has no tmdb_id.
cast_tmdb = {str(p["id"]) for p in cast if p.get("id") is not None}
cast_names = {_norm_name(p.get("name", "")) for p in cast}
actors = [a for a in gallery.get("actors", [])
if (a.get("tmdb_id") and str(a["tmdb_id"]) in cast_tmdb)
or _norm_name(a.get("name", "")) in cast_names]
if not actors:
print(" TMDB episode cast matched no gallery actors; using series-wide cast",
file=sys.stderr)
return None
print(f"Filtered gallery to {len(actors)}/{len(gallery.get('actors', []))} "
f"actor(s) in TMDB S{info['season']}E{info['episode']} cast", file=sys.stderr)
return actors
def process_item(args, item_id: str, extra: list[str]) -> None:
"""Resolve, analyze, and (unless --no-push) push truth for one Jellyfin item."""
name, movie_path = fetch_item_path(args.jellyfin_url, args.api_key, item_id)
@@ -104,34 +171,50 @@ def process_item(args, item_id: str, extra: list[str]) -> None:
gallery_path = args.gallery
filtered_file = None
if not args.no_filter:
cast_ids = fetch_cast_person_ids(args.jellyfin_url, args.api_key, item_id)
gallery = json.loads(Path(args.gallery).read_text())
actors = [a for a in gallery.get("actors", [])
if (a.get("jellyfin_id") or a.get("jellyfin_person_id")) in cast_ids]
print(f"Filtered gallery to {len(actors)}/{len(gallery.get('actors', []))} "
f"actor(s) credited in {name!r}", file=sys.stderr)
actors = episode_cast_actors(args, item_id, gallery)
if actors is None:
cast_ids = fetch_cast_person_ids(args.jellyfin_url, args.api_key, item_id)
actors = [a for a in gallery.get("actors", [])
if actor_jellyfin_id(a) in cast_ids]
print(f"Filtered gallery to {len(actors)}/{len(gallery.get('actors', []))} "
f"actor(s) credited in {name!r}", file=sys.stderr)
filtered_file = tempfile.NamedTemporaryFile(
mode="w", suffix=".json", prefix="sae_gallery_", delete=False)
json.dump({"actors": actors}, filtered_file)
filtered_file.close()
gallery_path = filtered_file.name
# Default to minimal output (just actor/scene windows, no per-frame
# data) — that's all the Truth push needs. Placed before *extra so a
# debug run can override with `-- --verbosity 1` (last flag wins).
cmd = [args.bin, "--movie", movie_path, "--gallery", gallery_path,
"--output", output, "--verbosity", "0", *extra]
print("Running:", " ".join(cmd), file=sys.stderr)
if args.dry_run:
return
try:
# Default to minimal output (just actor/scene windows, no per-frame
# data) — that's all the Truth push needs. Placed before *extra so a
# debug run can override with `-- --verbosity 1` (last flag wins).
cmd = [args.bin, "--movie", movie_path, "--gallery", gallery_path,
"--output", output, "--verbosity", "0", *extra]
print("Running:", " ".join(cmd), file=sys.stderr)
if args.dry_run:
return
subprocess.run(cmd, check=True)
finally:
if filtered_file is not None:
Path(filtered_file.name).unlink(missing_ok=True)
# Stamp the title's Jellyfin item-id into the output so downstream tools
# (e.g. cameo_hunt) can check cast membership in Jellyfin's own id space
# without an unreliable Path lookup. scene_analyze doesn't know the id, so
# patch the JSON it just wrote.
if not args.dry_run:
try:
out = json.loads(Path(output).read_text())
if isinstance(out, dict):
out["jellyfin_item_id"] = item_id
Path(output).write_text(json.dumps(out))
except (OSError, ValueError) as e:
print(f" [warn] could not stamp jellyfin_item_id into {output}: {e}",
file=sys.stderr)
if not args.no_push:
push_truth(args.jellyfin_url, args.api_key, item_id, output)
@@ -201,6 +284,14 @@ def main():
help="Global gallery.json built by make_jellyfin_gallery.py")
parser.add_argument("--no-filter", action="store_true",
help="Skip per-title cast filtering and pass --gallery through as-is")
parser.add_argument("--episode-cast", choices=("tmdb", "series"), default="tmdb",
help="For Episode items, how to pick the cast to filter the gallery: "
"'tmdb' uses TMDB per-episode credits (cast + guest stars), "
"falling back to series-wide if unavailable; 'series' always uses "
"Jellyfin's series-wide cast (default: tmdb)")
parser.add_argument("--tmdb-key", default=os.environ.get("TMDB_API_KEY"),
help="TMDB API key/bearer token, used for --episode-cast tmdb. "
"Env: TMDB_API_KEY (also read from .env)")
parser.add_argument("--output", default=None,
help="scene_analyze output JSON (default: <title>.json; "
"ignored with --worker, which always uses <title>.json)")