From 96b1c22194fdb2cb016268cd26b9c80d26608f46 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 4 Jul 2026 20:39:35 +0200 Subject: [PATCH] feat(cameo): detect recognised actors not credited in a title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- scripts/cameo_hunt.py | 318 +++++++++++++++++++++++++++++++++ scripts/cameo_jellyfin.py | 238 ++++++++++++++++++++++++ scripts/run_from_jellyfin.py | 123 +++++++++++-- src/nodes/result_sink_node.hpp | 5 + 4 files changed, 668 insertions(+), 16 deletions(-) create mode 100644 scripts/cameo_hunt.py create mode 100644 scripts/cameo_jellyfin.py diff --git a/scripts/cameo_hunt.py b/scripts/cameo_hunt.py new file mode 100644 index 0000000..25a4628 --- /dev/null +++ b/scripts/cameo_hunt.py @@ -0,0 +1,318 @@ +#!/usr/bin/env python3 +"""cameo_hunt.py — find actors recognized in titles that aren't on their TMDB resumé. + +For each scene_analyze output JSON, every recognized actor is checked against +their own TMDB filmography (combined_credits). If the title an actor was +recognized in is NOT among the series/movies TMDB lists for them, it's flagged +as a cameo candidate — an uncredited/surprise appearance (or, with no +confidence gating, possibly a face misidentification: the two look identical in +this data, so treat the list as leads, not proof). + +Resolution is all via TMDB (key from .env), no per-file Jellyfin calls: + * each title's series/movie is resolved by name (+year) parsed from the + JSON's "movie" path, cached per unique title; + * each actor's resumé is fetched once via /person/{id}/combined_credits, + cached per unique tmdb_id. + +Usage: + python scripts/cameo_hunt.py *.json + python scripts/cameo_hunt.py --min-seconds 5 --json out.json *.json +""" + +import argparse +import json +import os +import re +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 (TMDB_API_KEY, JELLYFIN_*) +from sae_tmdb import tmdb_get +from sae_jellyfin import jf_get, normalize_jellyfin_url + +import requests + +# Strip episode markers, a "(year)", and release/quality/codec tags to recover a +# searchable series/movie name from a media path. +# Episode markers: "S01E13", "1x03". +_SXXEXX = re.compile(r"\b([sS]\d{1,2}[eE]\d{1,3}|\d{1,2}x\d{1,3})\b") +_YEAR = re.compile(r"[.\s(](\d{4})[.\s)]") +# Common scene-release tags; everything from the first one onward is noise. Match +# as whole tokens so we can also truncate the name at the first tag hit. +_TAG_WORDS = ( + r"\d{3,4}p|2160p|4k|uhd|hdr(10)?(plus)?|web[\s.\-]?dl|web[\s.\-]?rip|" + r"bluray|blu[\s.\-]?ray|brrip|bdrip|hdtv|dvdrip|remux|" + r"x26[45]|h\.?26[45]|hevc|xvid|divx|aac|ac3|dts|ddp?5\.1|" + r"internal|theatrical|extended|uncut|repack|proper" +) +_TAG = re.compile(rf"(?i)\b({_TAG_WORDS})\b") + + +def parse_title(movie_path: str) -> tuple[str, int | None, bool]: + """Return (search_name, year, is_tv) parsed from a media file path. + + TV episodes look like ".../ (year)/Season N/ SxxExx ...". + For those we want the *series* directory name; for movies, the filename. + """ + p = Path(movie_path) + parts = p.parts + has_season_dir = any(re.match(r"(?i)season\s*\d+", x) for x in parts) + has_ep_marker = bool(_SXXEXX.search(p.stem)) + is_tv = has_season_dir or has_ep_marker + + if has_season_dir: + # The series folder is the part above the "Season N" dir (…/Series/Season N/file). + season_idx = next(i for i, x in enumerate(parts) if re.match(r"(?i)season\s*\d+", x)) + raw = parts[season_idx - 1] if season_idx > 0 else p.stem + else: + raw = p.stem + + # Pull a 4-digit year (1900–2099) before stripping, if present. + year = None + for m in _YEAR.finditer(" " + raw + " "): + y = int(m.group(1)) + if 1900 <= y <= 2099: + year = y + break + + # Truncate at the first release tag (everything after is noise), then strip + # episode markers / year / separators from what remains. + tag = _TAG.search(raw) + if tag: + raw = raw[: tag.start()] + name = _SXXEXX.sub(" ", raw) + name = re.sub(r"[.\s(]\d{4}[.\s)]?", " ", " " + name + " ") # drop the year token + name = name.replace(".", " ").replace("_", " ") + name = re.sub(r"\s+", " ", name).strip(" -") + return name, year, is_tv + + +def jf_title_tmdb_id(movie_path: str, jf_url: str, jf_key: str, is_tv: bool, + timeout: float = 8.0) -> int | None: + """Resolve a media path to its (series, for TV) TMDB id via Jellyfin, or None. + + Looks the item up by Path, then reads ProviderIds.Tmdb off the parent Series + (TV) or the item itself (movie). Uses a short timeout so a slow Path query + degrades to the TMDB fallback instead of stalling the whole run. + """ + if not (jf_url and jf_key): + return None + base = jf_url.rstrip("/") + headers = {"X-Emby-Token": jf_key, "Accept": "application/json"} + try: + r = requests.get(base + "/Items", headers=headers, timeout=timeout, + params={"Path": movie_path, "Recursive": "true", + "IncludeItemTypes": "Episode,Movie", + "Fields": "ProviderIds", "Limit": 1}) + r.raise_for_status() + items = r.json().get("Items", []) + if not items: + return None + item = items[0] + if is_tv: + # The path says TV episode (SxxExx + Season dir). If Jellyfin matched + # it to a Movie item or an episode with no SeriesId, its Tmdb id is + # for the wrong title (Jellyfin mis-identified the file) — don't trust + # it; return None so the caller falls back to TMDB name search. + if item.get("Type") != "Episode" or not item.get("SeriesId"): + return None + target_id = item.get("SeriesId") if is_tv else item.get("Id") + providers = item.get("ProviderIds", {}) + if is_tv and target_id: + # ProviderIds on the episode are the episode's; fetch the series'. + r2 = requests.get(base + "/Items", headers=headers, timeout=timeout, + params={"Ids": target_id, "Fields": "ProviderIds", + "Recursive": "true", "Limit": 1}) + r2.raise_for_status() + sitems = r2.json().get("Items", []) + providers = sitems[0].get("ProviderIds", {}) if sitems else {} + tmdb = providers.get("Tmdb") + return int(tmdb) if tmdb else None + except (requests.RequestException, ValueError): + return None + + +def resolve_title_id(name: str, year: int | None, is_tv: bool, key: str, + cache: dict) -> int | None: + """Resolve a series/movie name to its TMDB id (cached per (name, is_tv)).""" + ck = (name, is_tv) + if ck in cache: + return cache[ck] + endpoint = "/search/tv" if is_tv else "/search/movie" + yparam = {"first_air_date_year": year} if (is_tv and year) else ( + {"year": year} if year else {}) + try: + data = tmdb_get(endpoint, key, query=name, **yparam) + except requests.RequestException as e: + print(f" [warn] TMDB search failed for {name!r}: {e}", file=sys.stderr) + cache[ck] = None + return None + results = data.get("results", []) + tid = results[0]["id"] if results else None + if tid is None: + print(f" [warn] no TMDB match for {name!r} ({'tv' if is_tv else 'movie'})", file=sys.stderr) + cache[ck] = tid + return tid + + +TMDB_ANIMATION_GENRE_ID = 16 # "Animation" — same id for /movie and /tv on TMDB + + +def is_animation(title_id: int, is_tv: bool, key: str, cache: dict) -> bool: + """Return True if the title is tagged with TMDB's Animation genre (cached). + + Fetches /movie/{id} or /tv/{id} details (which include `genres`). On a fetch + failure we return False (don't drop the title on a transient API error). + """ + ck = (title_id, is_tv) + if ck in cache: + return cache[ck] + endpoint = f"/tv/{title_id}" if is_tv else f"/movie/{title_id}" + try: + data = tmdb_get(endpoint, key) + except requests.RequestException as e: + print(f" [warn] genre fetch failed for title {title_id}: {e}", file=sys.stderr) + cache[ck] = False + return False + animated = any(g.get("id") == TMDB_ANIMATION_GENRE_ID for g in data.get("genres", [])) + cache[ck] = animated + return animated + + +def actor_resume(tmdb_id: str, key: str, cache: dict) -> set[int] | None: + """Return the set of TMDB title ids on an actor's resumé (cached), or None on failure.""" + 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] resumé fetch failed for tmdb person {tmdb_id}: {e}", file=sys.stderr) + cache[tmdb_id] = None + return None + ids = {c["id"] for c in data.get("cast", []) if "id" in c} + ids |= {c["id"] for c in data.get("crew", []) if "id" in c} + cache[tmdb_id] = ids + return ids + + +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 a cameo if the actor's total on-screen time in the " + "title is at least this many seconds (default: 0 — flag all)") + ap.add_argument("--json", default=None, help="also write findings to this JSON file") + ap.add_argument("--no-skip-animation", dest="skip_animation", action="store_false", + help="do not skip titles tagged with TMDB's Animation genre " + "(animated titles are skipped by default)") + args = ap.parse_args() + + key = os.environ.get("TMDB_API_KEY") + if not key: + sys.exit("TMDB_API_KEY not set (expected in .env)") + jf_url = normalize_jellyfin_url(os.environ.get("JELLYFIN_URL", "")) or "" + jf_key = os.environ.get("JELLYFIN_API_KEY", "") + + title_cache: dict = {} # (name, is_tv) -> tmdb id, for TMDB name search + jf_cache: dict = {} # series folder / movie path -> tmdb id, for Jellyfin + resume_cache: dict = {} + genre_cache: dict = {} # (title_id, is_tv) -> is-animation bool + cameos: list[dict] = [] + skipped_no_tmdb = 0 + skipped_animation = 0 + n_via_jf = 0 + + 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", "") + name, year, is_tv = parse_title(movie) + + # Jellyfin first (authoritative TMDB id), cached per series folder so a + # show is only looked up once across all its episodes; TMDB name search + # as fallback on timeout/miss. + title_id = None + if jf_url and jf_key: + mp = Path(movie) + ck = str(mp.parent.parent) if is_tv else movie # series folder vs movie file + if ck in jf_cache: + title_id = jf_cache[ck] + else: + title_id = jf_title_tmdb_id(movie, jf_url, jf_key, is_tv) + jf_cache[ck] = title_id + if title_id is not None: + n_via_jf += 1 + if title_id is None: + title_id = resolve_title_id(name, year, is_tv, key, title_cache) + if title_id is None: + continue # can't decide cameo without the title's id + + if args.skip_animation and is_animation(title_id, is_tv, key, genre_cache): + skipped_animation += 1 + continue + + for actor in data["actors"]: + tmdb_id = actor.get("tmdb_id") + if not tmdb_id: + skipped_no_tmdb += 1 + continue + secs = total_seconds(actor.get("scenes", [])) + if secs < args.min_seconds: + continue + resume = actor_resume(tmdb_id, key, resume_cache) + if resume is None: + continue + if title_id not in resume: + cameos.append({ + "actor": actor.get("name", ""), + "tmdb_id": tmdb_id, + "title": name, + "title_id": title_id, + "is_tv": is_tv, + "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) + + # Report, grouped by actor, most on-screen time first. + 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(resolved {n_via_jf} title lookup(s) via Jellyfin, rest via TMDB search)", + file=sys.stderr) + print(f"\n=== {len(cameos)} cameo candidate(s) across {len(by_actor)} actor(s) ===\n") + for actor in sorted(by_actor): + rows = by_actor[actor] + print(f"{actor}:") + for c in rows: + kind = "TV" if c["is_tv"] else "film" + print(f" {c['title']!r} ({kind}) {c['seconds']}s / {c['scenes']} scene(s) [{c['file']}]") + if skipped_animation: + print(f"\n({skipped_animation} animated title file(s) skipped)", file=sys.stderr) + if skipped_no_tmdb: + print(f"\n({skipped_no_tmdb} recognized actor(s) had no tmdb_id and were skipped)", + file=sys.stderr) + + 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() diff --git a/scripts/cameo_jellyfin.py b/scripts/cameo_jellyfin.py new file mode 100644 index 0000000..d6045a8 --- /dev/null +++ b/scripts/cameo_jellyfin.py @@ -0,0 +1,238 @@ +#!/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() diff --git a/scripts/run_from_jellyfin.py b/scripts/run_from_jellyfin.py index c4969f4..9cb4ba3 100644 --- a/scripts/run_from_jellyfin.py +++ b/scripts/run_from_jellyfin.py @@ -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: .json; " "ignored with --worker, which always uses <title>.json)") diff --git a/src/nodes/result_sink_node.hpp b/src/nodes/result_sink_node.hpp index 7afad22..278ef86 100644 --- a/src/nodes/result_sink_node.hpp +++ b/src/nodes/result_sink_node.hpp @@ -22,6 +22,11 @@ using json = nlohmann::json; // "schema_version": 1, "movie": "...", "sample_fps": ..., "anneal_sec": ..., // "actors": [{ "name", "imdb_id", "tmdb_id", "jellyfin_id", "scenes": [[t0,t1], ...] }] // } +// An optional top-level "jellyfin_item_id" (the analysed title's Jellyfin item +// GUID) may also be present: scene_analyze doesn't know it, so it's stamped in +// by run_from_jellyfin.py after analysis. Downstream tools (cameo detection) +// use it to check cast membership in Jellyfin's own id space — see +// scripts/cameo_jellyfin.py. // This is the spec consumed by the Jellyfin plugin: each actor carries every // identity key the gallery knows (empty string if not resolved). The plugin // should prefer "jellyfin_id" (direct Person item GUID) when non-empty, and