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
+318
View File
@@ -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 ".../<Series> (year)/Season N/<Series> 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 (19002099) 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()
+238
View File
@@ -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=<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()
+107 -16
View File
@@ -39,7 +39,11 @@ from pathlib import Path
import requests import requests
sys.path.insert(0, str(Path(__file__).resolve().parent)) 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]: 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() 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: def process_item(args, item_id: str, extra: list[str]) -> None:
"""Resolve, analyze, and (unless --no-push) push truth for one Jellyfin item.""" """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) 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 gallery_path = args.gallery
filtered_file = None filtered_file = None
if not args.no_filter: 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()) gallery = json.loads(Path(args.gallery).read_text())
actors = [a for a in gallery.get("actors", []) actors = episode_cast_actors(args, item_id, gallery)
if (a.get("jellyfin_id") or a.get("jellyfin_person_id")) in cast_ids] if actors is None:
print(f"Filtered gallery to {len(actors)}/{len(gallery.get('actors', []))} " cast_ids = fetch_cast_person_ids(args.jellyfin_url, args.api_key, item_id)
f"actor(s) credited in {name!r}", file=sys.stderr) 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( filtered_file = tempfile.NamedTemporaryFile(
mode="w", suffix=".json", prefix="sae_gallery_", delete=False) mode="w", suffix=".json", prefix="sae_gallery_", delete=False)
json.dump({"actors": actors}, filtered_file) json.dump({"actors": actors}, filtered_file)
filtered_file.close() filtered_file.close()
gallery_path = filtered_file.name 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: 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) subprocess.run(cmd, check=True)
finally: finally:
if filtered_file is not None: if filtered_file is not None:
Path(filtered_file.name).unlink(missing_ok=True) 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: if not args.no_push:
push_truth(args.jellyfin_url, args.api_key, item_id, output) 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") help="Global gallery.json built by make_jellyfin_gallery.py")
parser.add_argument("--no-filter", action="store_true", parser.add_argument("--no-filter", action="store_true",
help="Skip per-title cast filtering and pass --gallery through as-is") 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, parser.add_argument("--output", default=None,
help="scene_analyze output JSON (default: <title>.json; " help="scene_analyze output JSON (default: <title>.json; "
"ignored with --worker, which always uses <title>.json)") "ignored with --worker, which always uses <title>.json)")
+5
View File
@@ -22,6 +22,11 @@ using json = nlohmann::json;
// "schema_version": 1, "movie": "...", "sample_fps": ..., "anneal_sec": ..., // "schema_version": 1, "movie": "...", "sample_fps": ..., "anneal_sec": ...,
// "actors": [{ "name", "imdb_id", "tmdb_id", "jellyfin_id", "scenes": [[t0,t1], ...] }] // "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 // 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 // identity key the gallery knows (empty string if not resolved). The plugin
// should prefer "jellyfin_id" (direct Person item GUID) when non-empty, and // should prefer "jellyfin_id" (direct Person item GUID) when non-empty, and