#!/usr/bin/env python3 """run_from_jellyfin.py — resolve a Jellyfin title to its media file and run scene_analyze. TRACES: IR-006 | SR-001 Looks up a Movie/Episode in Jellyfin, reads its on-disk Path (Jellyfin and this tool must share the same media mount), filters the gallery down to that title's credited cast (via filter_gallery's logic, fewer look-alike mismatches), and runs scene_analyze against the resolved file. Usage: python scripts/run_from_jellyfin.py \\ --jellyfin-url http://jellyfin.local:8096 \\ --api-key YOUR_API_KEY \\ --title "The Matrix" \\ --gallery whole_gallery.h5 \\ -- --fps 5 --verbosity 2 Anything after "--" is passed through unchanged to scene_analyze. Add --preview to open the live OpenCV display window: it resolves the media path from Jellyfin exactly as normal, then launches build/scene_preview instead of the headless binary (implies --no-push). Works with --worker or a single title: python scripts/run_from_jellyfin.py ... --worker --preview -- --fps 5 Worker mode (--worker) polls the JRay plugin's Tasks/Pending endpoint for a random batch of items with no truth data yet, processing each in turn: python scripts/run_from_jellyfin.py \\ --jellyfin-url http://jellyfin.local:8096 \\ --api-key YOUR_API_KEY \\ --gallery whole_gallery.h5 \\ --worker \\ -- --fps 5 """ import argparse import json import os import subprocess import sys import tempfile import time from pathlib import Path import requests sys.path.insert(0, str(Path(__file__).resolve().parent)) import sae_env # noqa: F401 — loads .env into os.environ on import from sae_gallery import load_gallery_hdf5, save_gallery_hdf5 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]: """Return (Name, Path) for a Jellyfin item.""" data = jf_get(base_url, api_key, "/Items", Ids=item_id, Fields="Path", Recursive="true") items = data.get("Items", []) if not items: raise ValueError(f"Item {item_id} not found") path = items[0].get("Path") if not path: raise ValueError(f"Item {item_id} has no Path (not a single media file?)") return items[0].get("Name", item_id), path def fetch_pending_tasks(base_url: str, api_key: str, limit: int = 10) -> list[dict]: """GET /Plugins/JRay/Tasks/Pending — a random batch of items with no truth data yet. Returns a list of {"item_id", "path", "name"}; an empty list means there's nothing left to do (for now). """ return jf_get(base_url, api_key, "/Plugins/JRay/Tasks/Pending", limit=limit) def push_truth(base_url: str, api_key: str, item_id: str, output_path: str) -> None: """PUT the scene_analyze output JSON to the JRay plugin's Truth endpoint. Requires an Administrator API key (reuses --api-key). """ url = base_url.rstrip("/") + f"/Plugins/JRay/Items/{item_id}/Truth" headers = {"X-Emby-Token": api_key, "Content-Type": "application/json"} # --verbosity 1 (standard) adds a "frames" array with per-frame bbox/ # similarity data for local debugging — the Truth schema only needs # schema_version/movie/sample_fps/anneal_sec/actors, and "frames" can # be tens of MB for a full episode, well past typical proxy body limits. payload = json.loads(Path(output_path).read_text()) if payload.pop("frames", None) is not None: print(f"Stripped per-frame data before push ({output_path} keeps it locally)", file=sys.stderr) body = json.dumps(payload) r = requests.put(url, headers=headers, data=body, timeout=30) if r.status_code == 204: print(f"Pushed {output_path} -> {url}", file=sys.stderr) return if r.status_code in (401, 403): raise RuntimeError(f"Push failed ({r.status_code}): API key needs Administrator rights for {url}") if r.status_code == 400: raise RuntimeError(f"Push failed (400 Bad Request): {r.text}") 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) print(f"Resolved {name!r} -> {movie_path}", file=sys.stderr) if not Path(movie_path).is_file(): raise RuntimeError(f"Resolved path does not exist on this filesystem: {movie_path} " f"(this tool must share Jellyfin's media mount)") output = args.output or f"{name}.json" gallery_path = args.gallery filtered_file = None if not args.no_filter: gallery = load_gallery_hdf5(Path(args.gallery)) 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( suffix=".h5", prefix="sae_gallery_", delete=False) filtered_file.close() save_gallery_hdf5({"actors": actors}, Path(filtered_file.name)) gallery_path = filtered_file.name 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) def run_worker(args, extra: list[str]) -> None: """Poll /Plugins/JRay/Tasks/Pending and process items until the backlog is empty. The endpoint returns a random sample, so repeated polling naturally spreads work across the backlog without server-side task tracking. An empty array means there's nothing left to do (for now). """ while True: try: tasks = fetch_pending_tasks(args.jellyfin_url, args.api_key, args.poll_limit) except requests.HTTPError as e: print(f"Failed to fetch pending tasks: {e}", file=sys.stderr) tasks = [] if not tasks: print("No pending tasks.", file=sys.stderr) if args.once: return time.sleep(args.poll_interval) continue for task in tasks: item_id = task["item_id"] label = task.get("name") or task.get("path") or item_id print(f"=== {label} ({item_id}) ===", file=sys.stderr) try: process_item(args, item_id, extra) except Exception as e: print(f"Failed to process {label!r} ({item_id}): {e}", file=sys.stderr) if args.once: return def main(): parser = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument("--jellyfin-url", default=os.environ.get("JELLYFIN_URL"), required=not os.environ.get("JELLYFIN_URL"), help="Jellyfin base URL. Env: JELLYFIN_URL") parser.add_argument("--api-key", default=os.environ.get("JELLYFIN_API_KEY"), required=not os.environ.get("JELLYFIN_API_KEY"), help="Jellyfin API key. Env: JELLYFIN_API_KEY") group = parser.add_mutually_exclusive_group() group.add_argument("--item-id", help="Jellyfin item id of the title") group.add_argument("--title", help="Title to search for (uses first match)") group.add_argument("--worker", action="store_true", help="Poll /Plugins/JRay/Tasks/Pending for work instead of " "processing a single --item-id/--title") parser.add_argument("--poll-limit", type=int, default=10, help="Tasks/Pending batch size for --worker (default: 10, max 100)") parser.add_argument("--poll-interval", type=float, default=60, help="Seconds to wait between polls when --worker finds nothing " "(default: 60)") parser.add_argument("--once", action="store_true", help="With --worker, process one batch and exit instead of " "looping forever") parser.add_argument("--item-types", default="Movie,Episode", help="Item types to search when using --title (default: Movie,Episode)") parser.add_argument("--gallery", required=True, help="Global gallery.h5 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)") parser.add_argument("--bin", default="build/scene_analyze", help="Path to scene_analyze binary (default: build/scene_analyze)") parser.add_argument("--preview", action="store_true", help="Launch the scene_preview binary (live OpenCV display window) " "instead of headless scene_analyze. Implies --no-push. Use " "--preview-bin to override its path. Press q/Esc to close.") parser.add_argument("--preview-bin", default="build/scene_preview", help="Path to scene_preview binary (default: build/scene_preview)") parser.add_argument("--dry-run", action="store_true", help="Resolve and print the scene_analyze command without running it") parser.add_argument("--no-push", action="store_true", help="Don't push results to the JRay plugin after the run " "(local-only/debug; --output is still written)") args, extra = parser.parse_known_args() if extra and extra[0] == "--": extra = extra[1:] # --preview swaps in the display binary and disables pushing truth (a preview # run is interactive/debug, not a truth-producing analysis). if args.preview: args.bin = args.preview_bin args.no_push = True if args.worker: if args.output: sys.exit("--output is incompatible with --worker (each item needs its own file)") run_worker(args, extra) return if not args.item_id and not args.title: sys.exit("one of --item-id, --title, or --worker is required") item_id = args.item_id if item_id is None: item_id = find_item_id(args.jellyfin_url, args.api_key, args.title, args.item_types.split(",")) print(f"Resolved title to item id {item_id}", file=sys.stderr) try: process_item(args, item_id, extra) except RuntimeError as e: sys.exit(str(e)) if __name__ == "__main__": main()