The parser reads a tag up to end of line, so `# TRACES: GR-004 | SR-001 — prose` swallowed the prose into the tag and the row went unmatched. Splitting the comment leaves the tag greppable by the same pattern as the code tags and the commit trailers, which is the point of the house format. Mechanical throughout; no logic touched. The regenerated report reflects this session's new tags: 137 -> 148 found, and one more tagged-but-unexecuted, which is the SuperHero accuracy assertion that is documented but not yet a test.
496 lines
23 KiB
Python
496 lines
23 KiB
Python
#!/usr/bin/env python3
|
|
"""make_jellyfin_gallery.py — build a gallery.h5 spanning an entire Jellyfin library.
|
|
|
|
TRACES: GR-001, GR-002 | SR-001, SR-005
|
|
|
|
Queries the Jellyfin API for every Movie/Series, collects the unique cast
|
|
across the whole library, downloads each actor's headshot directly from
|
|
Jellyfin (no TMDB key needed), embeds them with the sae_embed module (SCRFD +
|
|
ArcFace, loaded once), and writes one global gallery.h5.
|
|
|
|
Because identity_matcher scores every detected face against the whole
|
|
gallery, scene_analyze can then recognise any actor in your library in any
|
|
film — not just the cast TMDB lists for that one title.
|
|
|
|
For a single-title run, use scripts/filter_gallery.py afterwards to restrict
|
|
matching to that title's credited cast (faster, fewer look-alike mismatches).
|
|
|
|
Requirements:
|
|
pip install requests Pillow
|
|
|
|
Usage:
|
|
python scripts/make_jellyfin_gallery.py \\
|
|
--jellyfin-url http://jellyfin.local:8096 \\
|
|
--api-key YOUR_API_KEY \\
|
|
--output gallery.h5
|
|
|
|
# Re-run later to pick up newly added titles without re-embedding
|
|
# actors already in the gallery:
|
|
python scripts/make_jellyfin_gallery.py \\
|
|
--jellyfin-url http://jellyfin.local:8096 \\
|
|
--api-key YOUR_API_KEY \\
|
|
--output gallery.h5 --merge
|
|
|
|
# Fall back to TMDB profile images for actors with no usable Jellyfin image:
|
|
python scripts/make_jellyfin_gallery.py \\
|
|
--jellyfin-url http://jellyfin.local:8096 \\
|
|
--api-key YOUR_API_KEY \\
|
|
--tmdb-key YOUR_TMDB_KEY \\
|
|
--output gallery.h5
|
|
|
|
Get a Jellyfin API key from Dashboard → Advanced → API Keys.
|
|
Get a free TMDB API key at: https://www.themoviedb.org/settings/api
|
|
"""
|
|
|
|
import argparse
|
|
import concurrent.futures
|
|
import os
|
|
import json
|
|
import sys
|
|
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_embed_loader import load_embedder, resolve_arcface
|
|
from sae_gallery import (download_image, download_images, embedder_stamp,
|
|
enforce_embedder_stamp, load_gallery_hdf5,
|
|
save_gallery, wikidata_image_urls)
|
|
from sae_jellyfin import actor_jellyfin_id, jf_get, normalize_jellyfin_url
|
|
from sae_tmdb import (
|
|
tmdb_person_by_name,
|
|
tmdb_person_for_imdb,
|
|
)
|
|
|
|
|
|
# ── Jellyfin API helpers ────────────────────────────────────────────────────
|
|
|
|
def fetch_library_items(base_url: str, api_key: str, item_types: list[str]):
|
|
"""Yield every Movie/Series item dict (with its People field)."""
|
|
start = 0
|
|
limit = 100
|
|
while True:
|
|
data = jf_get(
|
|
base_url, api_key, "/Items",
|
|
Recursive="true",
|
|
IncludeItemTypes=",".join(item_types),
|
|
Fields="People",
|
|
StartIndex=start,
|
|
Limit=limit,
|
|
)
|
|
items = data.get("Items", [])
|
|
for item in items:
|
|
yield item
|
|
start += limit
|
|
if start >= data.get("TotalRecordCount", 0) or not items:
|
|
break
|
|
|
|
|
|
def collect_actors(base_url: str, api_key: str, item_types: list[str]) -> dict:
|
|
"""Return {person_id: {name, primary_image_tag, appearances: [...]}}."""
|
|
actors: dict[str, dict] = {}
|
|
n_items = 0
|
|
for item in fetch_library_items(base_url, api_key, item_types):
|
|
n_items += 1
|
|
for person in item.get("People", []):
|
|
if person.get("Type") != "Actor":
|
|
continue
|
|
pid = person["Id"]
|
|
entry = actors.setdefault(pid, {
|
|
"name": person["Name"],
|
|
"primary_image_tag": person.get("PrimaryImageTag"),
|
|
"appearances": [],
|
|
})
|
|
if not entry["primary_image_tag"]:
|
|
entry["primary_image_tag"] = person.get("PrimaryImageTag")
|
|
entry["appearances"].append({
|
|
"item_id": item["Id"],
|
|
"title": item.get("Name", ""),
|
|
"type": item.get("Type", ""),
|
|
})
|
|
if n_items % 50 == 0:
|
|
print(f" scanned {n_items} items, {len(actors)} unique actors so far…", file=sys.stderr)
|
|
print(f"Scanned {n_items} items, found {len(actors)} unique actors", file=sys.stderr)
|
|
return actors
|
|
|
|
|
|
def fetch_imdb_id(base_url: str, api_key: str, person_id: str) -> str | None:
|
|
try:
|
|
data = jf_get(base_url, api_key, f"/Items/{person_id}", Fields="ProviderIds")
|
|
except requests.RequestException:
|
|
return None
|
|
return data.get("ProviderIds", {}).get("Imdb")
|
|
|
|
|
|
# ── Image download ──────────────────────────────────────────────────────────
|
|
|
|
def download_person_images(base_url: str, api_key: str, person_id: str,
|
|
dest_dir: Path, n: int) -> list[Path]:
|
|
"""Download up to n images for a Jellyfin person item into dest_dir.
|
|
|
|
Jellyfin's per-index Primary image endpoint 404s once you run past the
|
|
number of images it has, so stop at the first index that fails rather than
|
|
probing all n.
|
|
"""
|
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
headers = {"X-Emby-Token": api_key}
|
|
paths = []
|
|
for i in range(n):
|
|
out = dest_dir / f"{i:02d}.jpg"
|
|
url = base_url.rstrip("/") + f"/Items/{person_id}/Images/Primary/{i}"
|
|
path = download_image(url, out, headers=headers, params={"api_key": api_key})
|
|
if path is None:
|
|
break
|
|
paths.append(path)
|
|
return paths
|
|
|
|
|
|
# ── Per-actor pipeline ───────────────────────────────────────────────────────
|
|
|
|
def fetch_actor_images(base_url: str, api_key: str, pid: str, info: dict,
|
|
images_per_actor: int, actor_dir: Path,
|
|
fetch_imdb_ids: bool, tmdb_key: str | None,
|
|
fetch_overfetch: float = 1.0
|
|
) -> tuple[list[Path], str | None, str | None]:
|
|
"""Network-bound: download Jellyfin image(s), then fall back to TMDB if short."""
|
|
name = info["name"]
|
|
print(f"{name} ({pid}) — in {len(info['appearances'])} title(s)", file=sys.stderr)
|
|
# Over-fetch target: see the TMDB block below.
|
|
tmdb_budget = int(images_per_actor * fetch_overfetch)
|
|
image_paths = download_person_images(base_url, api_key, pid, actor_dir, images_per_actor)
|
|
|
|
# Need the IMDB id for the TMDB /find lookup, to persist it (--fetch-imdb-ids),
|
|
# and for the Wikidata fallback below. Fetch it once if Jellyfin's own image
|
|
# came up short (so Wikidata can fire) or whenever it's otherwise wanted.
|
|
short = len(image_paths) < images_per_actor
|
|
imdb_id = None
|
|
if fetch_imdb_ids or tmdb_key or short:
|
|
imdb_id = fetch_imdb_id(base_url, api_key, pid)
|
|
|
|
# Resolve the TMDB person id whenever possible — independent of whether
|
|
# Jellyfin already gave us enough images, so tmdb_id is always populated
|
|
# when --tmdb-key is set. Jellyfin Person items rarely have an IMDB
|
|
# ProviderId, so fall back to a name search when imdb_id is unknown.
|
|
tmdb_id = None
|
|
tmdb_urls: list[str] = []
|
|
if tmdb_key:
|
|
try:
|
|
if imdb_id:
|
|
tmdb_id, tmdb_urls = tmdb_person_for_imdb(imdb_id, tmdb_key)
|
|
if tmdb_id is None:
|
|
tmdb_id, tmdb_urls = tmdb_person_by_name(name, tmdb_key)
|
|
except requests.RequestException as e:
|
|
print(f" [warn] {name}: TMDB lookup failed: {e}", file=sys.stderr)
|
|
|
|
# Over-fetch from TMDB: near-duplicate stills (the same photo at different
|
|
# crops/resolutions) are dropped after embedding, so downloading exactly
|
|
# images_per_actor would leave the actor short of that many *distinct*
|
|
# embeddings. Pulling extra candidates lets the dedup filter discard
|
|
# duplicates while still reaching the target.
|
|
if len(image_paths) < tmdb_budget and tmdb_urls:
|
|
needed = tmdb_budget - len(image_paths)
|
|
print(f" {name}: Jellyfin image missing/incomplete, falling back to TMDB "
|
|
f"({len(tmdb_urls)} image(s) available)…", file=sys.stderr)
|
|
image_paths += download_images(tmdb_urls, actor_dir, needed, start_index=len(image_paths))
|
|
|
|
# Last resort: a CC-licensed Commons headshot via Wikidata, keyed by the
|
|
# actor's IMDB id. Catches actors TMDB has no usable image for (or that the
|
|
# name search missed entirely).
|
|
if len(image_paths) < tmdb_budget and imdb_id:
|
|
wiki_urls = wikidata_image_urls(imdb_id)
|
|
if wiki_urls:
|
|
needed = tmdb_budget - len(image_paths)
|
|
print(f" {name}: still short, falling back to Wikidata/Commons "
|
|
f"({len(wiki_urls)} image(s) available)…", file=sys.stderr)
|
|
image_paths += download_images(wiki_urls, actor_dir, needed,
|
|
start_index=len(image_paths))
|
|
|
|
return image_paths, imdb_id, tmdb_id
|
|
|
|
|
|
# Default cosine-distance tolerance below which two embeddings of the same
|
|
# actor are treated as the same image. Embeddings are L2-normalised by the
|
|
# backend, so cosine similarity is a plain dot product and the distance is
|
|
# 1 - dot. Expanding an actor's photo set via TMDB frequently returns the same
|
|
# still at different crops/resolutions; those embed to nearly identical vectors
|
|
# and add gallery size and match cost without adding information.
|
|
DEDUP_TOL = 1e-3
|
|
|
|
|
|
def _cosine(a, b) -> float:
|
|
"""Cosine similarity of two L2-normalised embeddings."""
|
|
return float(sum(x * y for x, y in zip(a, b)))
|
|
|
|
|
|
def _near_duplicate(emb, existing, tol: float) -> int | None:
|
|
"""Index of the first embedding within `tol` cosine distance of `emb`.
|
|
|
|
Returns None when `emb` is sufficiently distinct from everything in
|
|
`existing`. tol <= 0 disables the check.
|
|
"""
|
|
if tol <= 0:
|
|
return None
|
|
for i, prev in enumerate(existing):
|
|
if 1.0 - _cosine(emb, prev) < tol:
|
|
return i
|
|
return None
|
|
|
|
|
|
def embed_actor(pid: str, info: dict, image_paths: list[Path],
|
|
imdb_id: str | None, tmdb_id: str | None, embedder, fetch_imdb_ids: bool,
|
|
embed_executor: concurrent.futures.ThreadPoolExecutor,
|
|
dedup_tol: float = DEDUP_TOL,
|
|
max_embeddings: int = 0) -> tuple[dict | None, str | None]:
|
|
"""GPU-bound: run sae_embed, always on embed_executor's single dedicated thread.
|
|
|
|
onnxruntime's CUDA EP / cudnn_frontend execution plans are not safe to run
|
|
from arbitrary threads — calling Run() from a different OS thread than the
|
|
one that last used the session corrupts the cudnn graph (CUDNN_FE failure
|
|
11 / CUDNN_BACKEND_API_FAILED). Routing every embed() call through one
|
|
persistent thread avoids that regardless of how many worker threads are
|
|
fetching images concurrently.
|
|
"""
|
|
name = info["name"]
|
|
if not image_paths:
|
|
print(f" {name}: no image available, skipping", file=sys.stderr)
|
|
return None, "no image available"
|
|
|
|
embeddings = []
|
|
source_images = []
|
|
n_dup = 0
|
|
print(f" {name}: embedding {len(image_paths)} image(s)…", file=sys.stderr)
|
|
for path in image_paths:
|
|
res = embed_executor.submit(embedder.embed, str(path)).result()
|
|
if not res.ok:
|
|
print(f" [skip] {name}/{path.name}: {res.error}", file=sys.stderr)
|
|
continue
|
|
emb = res.embedding
|
|
dup = _near_duplicate(emb, embeddings, dedup_tol)
|
|
if dup is not None:
|
|
n_dup += 1
|
|
print(f" [dup] {name}/{path.name}: matches {source_images[dup]} "
|
|
f"(cos={_cosine(emb, embeddings[dup]):.6f}), not stored",
|
|
file=sys.stderr)
|
|
continue
|
|
embeddings.append(emb)
|
|
source_images.append(path.name)
|
|
# Stop once we have the requested number of *distinct* embeddings; the
|
|
# extra candidates were only fetched to absorb duplicates.
|
|
if max_embeddings and len(embeddings) >= max_embeddings:
|
|
break
|
|
|
|
if not embeddings:
|
|
print(f" {name}: no valid embeddings, skipping actor", file=sys.stderr)
|
|
return None, "no valid embeddings"
|
|
|
|
dup_note = f" ({n_dup} near-duplicate(s) dropped)" if n_dup else ""
|
|
print(f" {name}: → {len(embeddings)} embedding(s) stored{dup_note}",
|
|
file=sys.stderr)
|
|
return {
|
|
"imdb_id": imdb_id if (fetch_imdb_ids and imdb_id) else "",
|
|
"tmdb_id": tmdb_id or "",
|
|
"jellyfin_id": pid,
|
|
"name": name,
|
|
"source_images": source_images,
|
|
"embeddings": embeddings,
|
|
"appearances": info["appearances"],
|
|
}, None
|
|
|
|
|
|
def process_actor(pid: str, info: dict, base_url: str, api_key: str,
|
|
embedder, images_per_actor: int, image_root: Path,
|
|
fetch_imdb_ids: bool, tmdb_key: str | None,
|
|
embed_executor: concurrent.futures.ThreadPoolExecutor,
|
|
dedup_tol: float = DEDUP_TOL,
|
|
fetch_overfetch: float = 1.0) -> tuple[dict | None, str | None]:
|
|
safe_name = info["name"].replace(" ", "_")
|
|
actor_dir = image_root / f"{pid}_{safe_name}"
|
|
image_paths, imdb_id, tmdb_id = fetch_actor_images(
|
|
base_url, api_key, pid, info, images_per_actor, actor_dir, fetch_imdb_ids,
|
|
tmdb_key, fetch_overfetch)
|
|
return embed_actor(pid, info, image_paths, imdb_id, tmdb_id, embedder, fetch_imdb_ids,
|
|
embed_executor, dedup_tol, images_per_actor)
|
|
|
|
|
|
# ── Gallery assembly ─────────────────────────────────────────────────────────
|
|
|
|
def build_gallery(base_url: str, api_key: str, embedder, item_types: list[str],
|
|
images_per_actor: int, image_root: Path,
|
|
fetch_imdb_ids: bool, existing_actors: dict,
|
|
tmdb_key: str | None = None, workers: int = 8,
|
|
dedup_tol: float = DEDUP_TOL,
|
|
fetch_overfetch: float = 1.0) -> tuple[dict, list[dict]]:
|
|
actors = collect_actors(base_url, api_key, item_types)
|
|
|
|
gallery_actors = []
|
|
todo = []
|
|
n_retry = 0
|
|
n_topup = 0
|
|
for pid, info in actors.items():
|
|
existing = existing_actors.get(pid)
|
|
n_existing = len(existing.get("embeddings", [])) if existing is not None else 0
|
|
# Keep an existing actor as-is only once they're at the image target.
|
|
# Fewer than that (including zero) → re-process to top up / retry, so a
|
|
# merge run can upgrade 1-image actors to images_per_actor without
|
|
# re-embedding actors already at the target.
|
|
if n_existing >= images_per_actor:
|
|
gallery_actors.append(existing)
|
|
else:
|
|
if n_existing > 0:
|
|
n_topup += 1
|
|
elif existing is not None:
|
|
n_retry += 1
|
|
todo.append((pid, info))
|
|
|
|
if len(gallery_actors):
|
|
print(f"Skipping {len(gallery_actors)} actor(s) already at {images_per_actor} "
|
|
f"image(s) in existing gallery", file=sys.stderr)
|
|
if n_topup:
|
|
print(f"Topping up {n_topup} actor(s) with fewer than {images_per_actor} "
|
|
f"embedding(s)", file=sys.stderr)
|
|
if n_retry:
|
|
print(f"Retrying {n_retry} actor(s) with no embeddings in existing gallery", file=sys.stderr)
|
|
print(f"Processing {len(todo)} new actor(s) with {workers} worker(s)…", file=sys.stderr)
|
|
|
|
missing = []
|
|
n_done = 0
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor, \
|
|
concurrent.futures.ThreadPoolExecutor(max_workers=1, thread_name_prefix="embed") as embed_executor:
|
|
futures = {
|
|
executor.submit(process_actor, pid, info, base_url, api_key, embedder,
|
|
images_per_actor, image_root,
|
|
fetch_imdb_ids, tmdb_key, embed_executor,
|
|
dedup_tol, fetch_overfetch): (pid, info["name"])
|
|
for pid, info in todo
|
|
}
|
|
for future in concurrent.futures.as_completed(futures):
|
|
n_done += 1
|
|
pid, name = futures[future]
|
|
try:
|
|
actor, reason = future.result()
|
|
except Exception as e:
|
|
print(f" [error] {name}: {e}", file=sys.stderr)
|
|
missing.append({"jellyfin_id": pid, "name": name, "reason": str(e)})
|
|
continue
|
|
if actor:
|
|
gallery_actors.append(actor)
|
|
else:
|
|
missing.append({"jellyfin_id": pid, "name": name, "reason": reason})
|
|
if n_done % 25 == 0:
|
|
print(f" progress: {n_done}/{len(todo)} actors processed", file=sys.stderr)
|
|
|
|
return {"actors": gallery_actors}, missing
|
|
|
|
|
|
# ── Entry point ───────────────────────────────────────────────────────────────
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Build a gallery.h5 spanning an entire Jellyfin library",
|
|
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 server URL only, e.g. http://jellyfin.local:8096 "
|
|
"(no /Items or other API path). 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 (Dashboard → Advanced → API Keys). Env: JELLYFIN_API_KEY")
|
|
parser.add_argument("--output", required=True, help="Output gallery.h5 path")
|
|
parser.add_argument("--item-types", default="Movie,Series",
|
|
help="Comma-separated Jellyfin item types to scan (default: Movie,Series)")
|
|
parser.add_argument("--build-dir", default="build",
|
|
help="Build directory containing the sae_embed module (default: build)")
|
|
parser.add_argument("--models-dir", default="models",
|
|
help="Directory containing ONNX models (default: models/)")
|
|
parser.add_argument("--arcface", default=None,
|
|
help="Path to ArcFace ONNX model (overrides --models-dir selection)")
|
|
parser.add_argument("--dedup-tol", type=float, default=DEDUP_TOL,
|
|
help=f"Cosine-distance threshold below which a new embedding is treated "
|
|
f"as a duplicate of one already stored for that actor and dropped "
|
|
f"(default: {DEDUP_TOL}). TMDB often returns the same still at "
|
|
f"different crops. Set 0 to keep every embedding.")
|
|
parser.add_argument("--overfetch", type=float, default=2.0,
|
|
help="Download this multiple of --images-per-actor as candidates, then "
|
|
"keep the first N that survive dedup (default: 2.0). Raise it for "
|
|
"actors whose TMDB galleries are mostly duplicates; 1.0 disables "
|
|
"over-fetching.")
|
|
parser.add_argument("--images-per-actor", type=int, default=10,
|
|
help="Images to download per actor (default: 10). Jellyfin usually has "
|
|
"only 1, so the rest come from the TMDB/Wikidata fallbacks; more "
|
|
"images per actor means more embeddings and more robust matching.")
|
|
parser.add_argument("--image-dir", default=None,
|
|
help="Where to store downloaded images (default: <output_dir>/images)")
|
|
parser.add_argument("--fetch-imdb-ids", action="store_true",
|
|
help="Resolve each actor's real IMDB id via Jellyfin ProviderIds "
|
|
"(one extra API call per new actor; otherwise imdb_id is left empty)")
|
|
parser.add_argument("--tmdb-key", default=os.environ.get("TMDB_API_KEY"),
|
|
help="TMDB API key/bearer token. If set, actors with no usable "
|
|
"Jellyfin image fall back to TMDB profile images (looked up "
|
|
"via the actor's IMDB id, requires one extra Jellyfin call per actor). "
|
|
"Env: TMDB_API_KEY")
|
|
parser.add_argument("--merge", action="store_true",
|
|
help="If --output already exists, keep its actors and only embed "
|
|
"actors not already present (matched by jellyfin_id)")
|
|
parser.add_argument("--workers", type=int, default=8,
|
|
help="Concurrent worker threads for Jellyfin/TMDB lookups and "
|
|
"image downloads (default: 8). Embedding itself always runs "
|
|
"on a single dedicated thread regardless of this value.")
|
|
args = parser.parse_args()
|
|
|
|
jellyfin_url = normalize_jellyfin_url(args.jellyfin_url)
|
|
|
|
output = Path(args.output)
|
|
image_root = Path(args.image_dir) if args.image_dir else output.parent / "images"
|
|
item_types = [t.strip() for t in args.item_types.split(",") if t.strip()]
|
|
|
|
# TRACES: GR-004 | SR-001
|
|
arcface_path = resolve_arcface(args.models_dir, args.arcface)
|
|
embedder = load_embedder(args.build_dir, args.models_dir, args.arcface)
|
|
stamp = embedder_stamp(arcface_path)
|
|
|
|
existing_actors = {}
|
|
if args.merge and output.is_file():
|
|
existing = load_gallery_hdf5(output)
|
|
# TRACES: GR-004 | SR-001
|
|
# --merge keeps the existing actors' vectors and
|
|
# embeds the new ones with THIS model. If they disagree, the result is one
|
|
# gallery holding two incompatible embedding spaces, which is worse than a
|
|
# mismatched gallery: no later check can separate them again.
|
|
enforce_embedder_stamp(existing.get("embedder"), stamp, str(output),
|
|
arcface_path)
|
|
for actor in existing.get("actors", []):
|
|
pid = actor_jellyfin_id(actor)
|
|
if pid:
|
|
existing_actors[pid] = actor
|
|
print(f"Loaded {len(existing_actors)} actor(s) from existing gallery for merge", file=sys.stderr)
|
|
|
|
gallery, missing = build_gallery(
|
|
base_url=jellyfin_url,
|
|
api_key=args.api_key,
|
|
embedder=embedder,
|
|
item_types=item_types,
|
|
images_per_actor=args.images_per_actor,
|
|
image_root=image_root,
|
|
fetch_imdb_ids=args.fetch_imdb_ids,
|
|
existing_actors=existing_actors,
|
|
dedup_tol=args.dedup_tol,
|
|
fetch_overfetch=args.overfetch,
|
|
tmdb_key=args.tmdb_key,
|
|
workers=args.workers,
|
|
)
|
|
|
|
n_actors = len(gallery["actors"])
|
|
n_embeddings = sum(len(a["embeddings"]) for a in gallery["actors"])
|
|
print(f"\nGallery: {n_actors} actors, {n_embeddings} total embeddings", file=sys.stderr)
|
|
|
|
if n_actors == 0:
|
|
sys.exit("No actors could be processed — check Jellyfin URL/API key and models.")
|
|
|
|
save_gallery(gallery, missing, output, embedder=stamp)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|