evie

evie / jellyfin-fetch-subs.py

Last active 1 month ago

Like 0

Fetch missing subtitles for a Jellyfin series/season via Jellyfin RemoteSearch/Subtitles API (OpenSubtitles-backed). Auto-heals subs stuck in Jellyfin internal storage vs real sidecar files.

jellyfin-fetch-subs.py Raw
1#!/usr/bin/env python3
2"""Fetch missing subtitles for a Jellyfin series/season via Jellyfin's own
3RemoteSearch/Subtitles API (backed by OpenSubtitles).
4
5Built 2026-08-31 after doing this by hand for Mission: Impossible (1988) S1 —
6generalized so any show/season can get the same treatment.
7
8Usage:
9 python3 bin/jellyfin-fetch-subs.py "Mission: Impossible" --season 1
10 python3 bin/jellyfin-fetch-subs.py "Mission: Impossible" --season 1 --year 1988
11 python3 bin/jellyfin-fetch-subs.py "Mission: Impossible" --season 1 --lang eng --force
12 python3 bin/jellyfin-fetch-subs.py --list-seasons "Mission: Impossible"
13
14Series lookup goes through /Search/Hints (Type=Series) rather than /Items —
15plain /Items search came back empty against this server for reasons not fully
16chased down; Search/Hints works reliably. When a title matches more than one
17series (e.g. two "Mission: Impossible" — 1966 and 1988), pass --year to
18disambiguate, or the script lists the candidates and exits.
19
20Credentials, in priority order: --jellyfin-url/--jellyfin-key flags, then
21JELLYFIN_URL/JELLYFIN_API_KEY env vars, then secrets/credentials.yaml (Evie's
22own default — needs the project .venv for PyYAML). The flag/env path needs
23nothing beyond stdlib, so anyone with their own Jellyfin API key can run this
24standalone with plain system python3:
25 JELLYFIN_URL=http://jellyfin.lan.1qaz.ca:8096 JELLYFIN_API_KEY=<key> \\
26 python3 bin/jellyfin-fetch-subs.py "Mission: Impossible" --season 1
27"""
28import argparse
29import json
30import os
31import sys
32import time
33import urllib.parse
34import urllib.request
35from pathlib import Path
36
37BASE = None
38HEADERS = None
39
40
41def _configure(url: str | None, key: str | None) -> None:
42 """Resolve BASE/HEADERS from (in order) explicit args, env vars, or
43 secrets/credentials.yaml. The credentials.yaml path is imported lazily,
44 right here, so anyone supplying their own url/key never needs PyYAML or
45 the project .venv at all — a plain system python3 works."""
46 global BASE, HEADERS
47 url = url or os.environ.get("JELLYFIN_URL")
48 key = key or os.environ.get("JELLYFIN_API_KEY")
49 if not url or not key:
50 sys.path.insert(0, str(Path(__file__).parent))
51 from credentials import service # noqa: E402
52 jf = service("jellyfin")
53 url = url or jf["url"]
54 key = key or jf["api_key"]
55 BASE = url.rstrip("/")
56 HEADERS = {"X-Emby-Token": key}
57
58
59def _get(path, params=None):
60 url = f"{BASE}{path}"
61 if params:
62 url += "?" + urllib.parse.urlencode(params)
63 req = urllib.request.Request(url, headers=HEADERS)
64 with urllib.request.urlopen(req) as resp:
65 body = resp.read()
66 return json.loads(body) if body else None
67
68
69def _post(path):
70 req = urllib.request.Request(url=f"{BASE}{path}", headers=HEADERS, method="POST")
71 with urllib.request.urlopen(req) as resp:
72 return resp.status
73
74
75def _first_user_id():
76 users = _get("/Users")
77 if not users:
78 raise RuntimeError("no Jellyfin users found — can't check subtitle state")
79 return users[0]["Id"]
80
81
82def find_series(title, year=None):
83 hints = _get("/Search/Hints", {"SearchTerm": title, "IncludeItemTypes": "Series"})
84 candidates = [h for h in hints.get("SearchHints", []) if h.get("Type") == "Series"]
85 if year:
86 candidates = [c for c in candidates if c.get("ProductionYear") == year]
87 if len(candidates) == 1:
88 return candidates[0]
89 if not candidates:
90 raise SystemExit(f"no series found matching '{title}'" + (f" ({year})" if year else ""))
91 print(f"multiple series match '{title}' — pass --year to disambiguate:", file=sys.stderr)
92 for c in candidates:
93 print(f" {c.get('ProductionYear', '?')} — {c['Name']} (id={c['Id']})", file=sys.stderr)
94 raise SystemExit(2)
95
96
97def list_seasons(series_id):
98 return _get(f"/Shows/{series_id}/Seasons")["Items"]
99
100
101def find_season(series_id, season_num):
102 seasons = list_seasons(series_id)
103 for s in seasons:
104 if s.get("IndexNumber") == season_num:
105 return s
106 available = ", ".join(str(s.get("IndexNumber")) for s in seasons)
107 raise SystemExit(f"season {season_num} not found — available: {available}")
108
109
110def list_episodes(series_id, season_id):
111 eps = _get(f"/Shows/{series_id}/Episodes", {"SeasonId": season_id})["Items"]
112 return sorted(eps, key=lambda e: e.get("IndexNumber") or 0)
113
114
115# Jellyfin falls back to writing subtitles here when it can't write next to
116# the media file (e.g. a permission problem on the share) — silently, no
117# error, just a stream Plex (or anything else reading the raw files) will
118# never see. Found 2026-08-31/09-01 the hard way: HTTP 204 on every download
119# the whole time, subtitle genuinely present in Jellyfin, Plex rescans forever
120# finding nothing, because the file was living in here instead of on the NAS.
121INTERNAL_SUB_PREFIX = "/var/lib/jellyfin/metadata"
122
123
124def subtitle_state(user_id, item_id):
125 """'external' (real sidecar file, visible to Plex etc), 'internal' (only in
126 Jellyfin's own store — needs refetching once write access is fixed), or
127 'none'."""
128 item = _get(f"/Users/{user_id}/Items/{item_id}")
129 subs = [s for s in item.get("MediaStreams", []) if s["Type"] == "Subtitle"]
130 if not subs:
131 return "none"
132 if any(not (s.get("Path") or "").startswith(INTERNAL_SUB_PREFIX) for s in subs):
133 return "external"
134 return "internal"
135
136
137def fetch_subtitle(item_id, lang):
138 results = _get(f"/Items/{item_id}/RemoteSearch/Subtitles/{lang}")
139 if not results:
140 return None, "no results"
141 hi = [r for r in results if r.get("HearingImpaired")]
142 pick = hi[0] if hi else results[0]
143 status = _post(f"/Items/{item_id}/RemoteSearch/Subtitles/{urllib.parse.quote(pick['Id'], safe='')}")
144 return pick, status
145
146
147def main():
148 ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
149 ap.add_argument("title", nargs="?", help="series title (fuzzy match via Jellyfin search)")
150 ap.add_argument("--season", type=int, help="season number")
151 ap.add_argument("--year", type=int, help="production year, to disambiguate same-titled series")
152 ap.add_argument("--lang", default="eng", help="ISO 639-2 language code (default: eng)")
153 ap.add_argument("--force", action="store_true", help="refetch even an existing external (real sidecar) subtitle")
154 ap.add_argument("--list-seasons", action="store_true", help="just list seasons for the matched series and exit")
155 ap.add_argument("--jellyfin-url", help="Jellyfin base URL (overrides JELLYFIN_URL env var and credentials.yaml)")
156 ap.add_argument("--jellyfin-key", help="Jellyfin API key (overrides JELLYFIN_API_KEY env var and credentials.yaml)")
157 args = ap.parse_args()
158
159 if not args.title:
160 ap.error("title is required")
161
162 _configure(args.jellyfin_url, args.jellyfin_key)
163
164 series = find_series(args.title, args.year)
165 print(f"series: {series['Name']} ({series.get('ProductionYear', '?')}) id={series['Id']}")
166
167 if args.list_seasons:
168 for s in list_seasons(series["Id"]):
169 print(f" season {s.get('IndexNumber')}: {s['Name']} id={s['Id']}")
170 return
171
172 if args.season is None:
173 ap.error("--season is required (or use --list-seasons)")
174
175 season = find_season(series["Id"], args.season)
176 episodes = list_episodes(series["Id"], season["Id"])
177 print(f"season {args.season}: {len(episodes)} episodes")
178
179 user_id = _first_user_id()
180
181 fetched, skipped, failed, healed = 0, 0, 0, 0
182 for ep in episodes:
183 idx = ep.get("IndexNumber")
184 name = ep.get("Name")
185 item_id = ep["Id"]
186
187 state = subtitle_state(user_id, item_id)
188 if state == "external" and not args.force:
189 print(f" ep{idx} '{name}': already has a real external sub, skipping")
190 skipped += 1
191 continue
192 was_internal_only = state == "internal"
193
194 pick, status = fetch_subtitle(item_id, args.lang)
195 if pick is None:
196 print(f" ep{idx} '{name}': {status}")
197 failed += 1
198 continue
199 ok = status == 204
200 if not ok:
201 print(f" ep{idx} '{name}': {pick['ProviderName']} '{pick['Name']}' -> HTTP {status} (FAILED)")
202 failed += 1
203 continue
204
205 # HTTP 204 alone isn't proof — Jellyfin can silently no-op a rate-limited
206 # download and still return 204 (found 2026-09-01). Re-check the real
207 # state, but the download+write is itself async on Jellyfin's side, so
208 # a check made immediately after the POST returns can race ahead of the
209 # write finishing (also found 2026-09-01 — every "failure" in a batch
210 # turned out to be a real success once re-checked a few seconds later).
211 # Poll briefly before calling it a failure.
212 new_state = "none"
213 for _ in range(5):
214 new_state = subtitle_state(user_id, item_id)
215 if new_state == "external":
216 break
217 time.sleep(1.5)
218 if new_state == "external":
219 tag = "healed (was internal-only)" if was_internal_only else "ok"
220 print(f" ep{idx} '{name}': {pick['ProviderName']} '{pick['Name']}' -> external sub confirmed ({tag})")
221 fetched += 1
222 if was_internal_only:
223 healed += 1
224 else:
225 print(f" ep{idx} '{name}': HTTP 204 but no external sub landed (state={new_state}) — likely a silent failure, not written to disk")
226 failed += 1
227
228 print(f"\ndone — fetched {fetched} (of which {healed} healed from internal-only), skipped {skipped}, failed {failed}")
229
230
231if __name__ == "__main__":
232 main()
233