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:
+107
-16
@@ -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)")
|
||||
|
||||
Reference in New Issue
Block a user