#!/usr/bin/env python3 """Fetch missing subtitles for a Jellyfin series/season via Jellyfin's own RemoteSearch/Subtitles API (backed by OpenSubtitles). Built 2026-08-31 after doing this by hand for Mission: Impossible (1988) S1 — generalized so any show/season can get the same treatment. Usage: python3 bin/jellyfin-fetch-subs.py "Mission: Impossible" --season 1 python3 bin/jellyfin-fetch-subs.py "Mission: Impossible" --season 1 --year 1988 python3 bin/jellyfin-fetch-subs.py "Mission: Impossible" --season 1 --lang eng --force python3 bin/jellyfin-fetch-subs.py --list-seasons "Mission: Impossible" Series lookup goes through /Search/Hints (Type=Series) rather than /Items — plain /Items search came back empty against this server for reasons not fully chased down; Search/Hints works reliably. When a title matches more than one series (e.g. two "Mission: Impossible" — 1966 and 1988), pass --year to disambiguate, or the script lists the candidates and exits. Credentials, in priority order: --jellyfin-url/--jellyfin-key flags, then JELLYFIN_URL/JELLYFIN_API_KEY env vars, then secrets/credentials.yaml (Evie's own default — needs the project .venv for PyYAML). The flag/env path needs nothing beyond stdlib, so anyone with their own Jellyfin API key can run this standalone with plain system python3: JELLYFIN_URL=http://jellyfin.lan.1qaz.ca:8096 JELLYFIN_API_KEY= \\ python3 bin/jellyfin-fetch-subs.py "Mission: Impossible" --season 1 """ import argparse import json import os import sys import time import urllib.parse import urllib.request from pathlib import Path BASE = None HEADERS = None def _configure(url: str | None, key: str | None) -> None: """Resolve BASE/HEADERS from (in order) explicit args, env vars, or secrets/credentials.yaml. The credentials.yaml path is imported lazily, right here, so anyone supplying their own url/key never needs PyYAML or the project .venv at all — a plain system python3 works.""" global BASE, HEADERS url = url or os.environ.get("JELLYFIN_URL") key = key or os.environ.get("JELLYFIN_API_KEY") if not url or not key: sys.path.insert(0, str(Path(__file__).parent)) from credentials import service # noqa: E402 jf = service("jellyfin") url = url or jf["url"] key = key or jf["api_key"] BASE = url.rstrip("/") HEADERS = {"X-Emby-Token": key} def _get(path, params=None): url = f"{BASE}{path}" if params: url += "?" + urllib.parse.urlencode(params) req = urllib.request.Request(url, headers=HEADERS) with urllib.request.urlopen(req) as resp: body = resp.read() return json.loads(body) if body else None def _post(path): req = urllib.request.Request(url=f"{BASE}{path}", headers=HEADERS, method="POST") with urllib.request.urlopen(req) as resp: return resp.status def _first_user_id(): users = _get("/Users") if not users: raise RuntimeError("no Jellyfin users found — can't check subtitle state") return users[0]["Id"] def find_series(title, year=None): hints = _get("/Search/Hints", {"SearchTerm": title, "IncludeItemTypes": "Series"}) candidates = [h for h in hints.get("SearchHints", []) if h.get("Type") == "Series"] if year: candidates = [c for c in candidates if c.get("ProductionYear") == year] if len(candidates) == 1: return candidates[0] if not candidates: raise SystemExit(f"no series found matching '{title}'" + (f" ({year})" if year else "")) print(f"multiple series match '{title}' — pass --year to disambiguate:", file=sys.stderr) for c in candidates: print(f" {c.get('ProductionYear', '?')} — {c['Name']} (id={c['Id']})", file=sys.stderr) raise SystemExit(2) def list_seasons(series_id): return _get(f"/Shows/{series_id}/Seasons")["Items"] def find_season(series_id, season_num): seasons = list_seasons(series_id) for s in seasons: if s.get("IndexNumber") == season_num: return s available = ", ".join(str(s.get("IndexNumber")) for s in seasons) raise SystemExit(f"season {season_num} not found — available: {available}") def list_episodes(series_id, season_id): eps = _get(f"/Shows/{series_id}/Episodes", {"SeasonId": season_id})["Items"] return sorted(eps, key=lambda e: e.get("IndexNumber") or 0) # Jellyfin falls back to writing subtitles here when it can't write next to # the media file (e.g. a permission problem on the share) — silently, no # error, just a stream Plex (or anything else reading the raw files) will # never see. Found 2026-08-31/09-01 the hard way: HTTP 204 on every download # the whole time, subtitle genuinely present in Jellyfin, Plex rescans forever # finding nothing, because the file was living in here instead of on the NAS. INTERNAL_SUB_PREFIX = "/var/lib/jellyfin/metadata" def subtitle_state(user_id, item_id): """'external' (real sidecar file, visible to Plex etc), 'internal' (only in Jellyfin's own store — needs refetching once write access is fixed), or 'none'.""" item = _get(f"/Users/{user_id}/Items/{item_id}") subs = [s for s in item.get("MediaStreams", []) if s["Type"] == "Subtitle"] if not subs: return "none" if any(not (s.get("Path") or "").startswith(INTERNAL_SUB_PREFIX) for s in subs): return "external" return "internal" def fetch_subtitle(item_id, lang): results = _get(f"/Items/{item_id}/RemoteSearch/Subtitles/{lang}") if not results: return None, "no results" hi = [r for r in results if r.get("HearingImpaired")] pick = hi[0] if hi else results[0] status = _post(f"/Items/{item_id}/RemoteSearch/Subtitles/{urllib.parse.quote(pick['Id'], safe='')}") return pick, status def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("title", nargs="?", help="series title (fuzzy match via Jellyfin search)") ap.add_argument("--season", type=int, help="season number") ap.add_argument("--year", type=int, help="production year, to disambiguate same-titled series") ap.add_argument("--lang", default="eng", help="ISO 639-2 language code (default: eng)") ap.add_argument("--force", action="store_true", help="refetch even an existing external (real sidecar) subtitle") ap.add_argument("--list-seasons", action="store_true", help="just list seasons for the matched series and exit") ap.add_argument("--jellyfin-url", help="Jellyfin base URL (overrides JELLYFIN_URL env var and credentials.yaml)") ap.add_argument("--jellyfin-key", help="Jellyfin API key (overrides JELLYFIN_API_KEY env var and credentials.yaml)") args = ap.parse_args() if not args.title: ap.error("title is required") _configure(args.jellyfin_url, args.jellyfin_key) series = find_series(args.title, args.year) print(f"series: {series['Name']} ({series.get('ProductionYear', '?')}) id={series['Id']}") if args.list_seasons: for s in list_seasons(series["Id"]): print(f" season {s.get('IndexNumber')}: {s['Name']} id={s['Id']}") return if args.season is None: ap.error("--season is required (or use --list-seasons)") season = find_season(series["Id"], args.season) episodes = list_episodes(series["Id"], season["Id"]) print(f"season {args.season}: {len(episodes)} episodes") user_id = _first_user_id() fetched, skipped, failed, healed = 0, 0, 0, 0 for ep in episodes: idx = ep.get("IndexNumber") name = ep.get("Name") item_id = ep["Id"] state = subtitle_state(user_id, item_id) if state == "external" and not args.force: print(f" ep{idx} '{name}': already has a real external sub, skipping") skipped += 1 continue was_internal_only = state == "internal" pick, status = fetch_subtitle(item_id, args.lang) if pick is None: print(f" ep{idx} '{name}': {status}") failed += 1 continue ok = status == 204 if not ok: print(f" ep{idx} '{name}': {pick['ProviderName']} '{pick['Name']}' -> HTTP {status} (FAILED)") failed += 1 continue # HTTP 204 alone isn't proof — Jellyfin can silently no-op a rate-limited # download and still return 204 (found 2026-09-01). Re-check the real # state, but the download+write is itself async on Jellyfin's side, so # a check made immediately after the POST returns can race ahead of the # write finishing (also found 2026-09-01 — every "failure" in a batch # turned out to be a real success once re-checked a few seconds later). # Poll briefly before calling it a failure. new_state = "none" for _ in range(5): new_state = subtitle_state(user_id, item_id) if new_state == "external": break time.sleep(1.5) if new_state == "external": tag = "healed (was internal-only)" if was_internal_only else "ok" print(f" ep{idx} '{name}': {pick['ProviderName']} '{pick['Name']}' -> external sub confirmed ({tag})") fetched += 1 if was_internal_only: healed += 1 else: print(f" ep{idx} '{name}': HTTP 204 but no external sub landed (state={new_state}) — likely a silent failure, not written to disk") failed += 1 print(f"\ndone — fetched {fetched} (of which {healed} healed from internal-only), skipped {skipped}, failed {failed}") if __name__ == "__main__": main()