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.
239 lines
9.9 KiB
Python
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()
|