#!/usr/bin/env python3 """run_from_jellyfin.py — resolve a Jellyfin title to its media file and run scene_analyze. 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.json \\ -- --fps 5 --verbosity 2 Anything after "--" is passed through unchanged to scene_analyze. """ import argparse import json import subprocess import sys import tempfile from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) from filter_gallery import jf_get, find_item_id, fetch_cast_person_ids 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, f"/Items/{item_id}", Fields="Path") path = data.get("Path") if not path: raise ValueError(f"Item {item_id} has no Path (not a single media file?)") return data.get("Name", item_id), path def main(): parser = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument("--jellyfin-url", required=True) parser.add_argument("--api-key", required=True) group = parser.add_mutually_exclusive_group(required=True) group.add_argument("--item-id", help="Jellyfin item id of the title") group.add_argument("--title", help="Title to search for (uses first match)") 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.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("--output", default=None, help="scene_analyze output JSON (default: .json)") parser.add_argument("--bin", default="build/scene_analyze", help="Path to scene_analyze binary (default: build/scene_analyze)") parser.add_argument("--dry-run", action="store_true", help="Resolve and print the scene_analyze command without running it") args, extra = parser.parse_known_args() if extra and extra[0] == "--": extra = extra[1:] 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) 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(): sys.exit(f"Resolved path does not exist on this filesystem: {movie_path}\n" 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: 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) 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 cmd = [args.bin, "--movie", movie_path, "--gallery", gallery_path, "--output", output, *extra] print("Running:", " ".join(cmd), file=sys.stderr) if args.dry_run: return try: subprocess.run(cmd, check=True) finally: if filtered_file is not None: Path(filtered_file.name).unlink(missing_ok=True) if __name__ == "__main__": main()