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