#!/usr/bin/env python3
"""Fetch subtitles for library titles this addon did not ingest.
`_run_ingest` fetches subtitles for anything it copies, including the
already-in-library path -- but its scope is the INGEST QUEUE, and the queue is
~20 titles against a library of 226. The rest arrived by other means and has
none: measured 2026-09-05, exactly ONE item in the whole library had an external
subtitle, and that was the one fetched an hour earlier.
A script rather than a route, for one reason: the ids. The library naming
convention carries `[tmdbid-...]` for only 6 of 81 movie folders, so the id has
to come from Jellyfin, which has already identified this library and holds
Tmdb/Imdb for 66 movies and every episode's series. Teaching the daemon to
authenticate against Jellyfin -- for a job that is essentially one-time -- would
be a permanent dependency bought for a temporary need. New content does not need
this: it comes in through _run_ingest, which already fetches subtitles.
It imports addon rather than reimplementing anything. _wyzie_tracks and
_write_sidecars are the tested path; a second copy of that logic here would be
the thing that drifts.
Idempotent and resumable: _write_sidecars skips any sidecar already on disk, so
a re-run costs one lookup per title and no downloads.
The work list is built separately (Jellyfin has the ids, and the token lives on
the Pi) and passed in as JSON:
[{"kind": "movie"|"series", "name": ..., "path": "/mnt/usb/...",
"ident": "639988", "season": 0, "episode": 0}]
Usage:
.venv/bin/python3 backfill_subtitles.py work.json [--dry-run] [--limit N]
"""
import argparse
import json
import os
import sys
import time
import addon
# Politeness. The per-title fetch already runs 8 downloads in parallel; this is
# the gap BETWEEN titles, so a 225-title run does not arrive as one burst on a
# service doing us a favour for free.
PAUSE_BETWEEN_TITLES = 0.5
def load_work(path):
with open(path) as f:
work = json.load(f)
if not isinstance(work, list):
raise SystemExit("work list must be a JSON array")
out = []
for w in work:
if not w.get("path") or not w.get("ident"):
continue
out.append(w)
return out
def needs_work(item):
"""True if the media file exists and has no sidecar beside it yet.
Checked on DISK rather than trusting the work list: the list is a snapshot,
and a re-run should see what actually happened.
"""
path = item["path"]
if not os.path.exists(path):
return False
stem = os.path.splitext(os.path.basename(path))[0]
folder = os.path.dirname(path)
try:
siblings = os.listdir(folder)
except OSError:
return False
return not any(f.startswith(stem + ".") and f.endswith(".srt") for f in siblings)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("work")
ap.add_argument("--dry-run", action="store_true",
help="look up tracks and report, write nothing")
ap.add_argument("--limit", type=int, default=0)
args = ap.parse_args()
work = load_work(args.work)
todo = [w for w in work if needs_work(w)]
skipped = len(work) - len(todo)
if args.limit:
todo = todo[:args.limit]
print("work list: %d already done or missing: %d to process: %d"
% (len(work), skipped, len(todo)))
if args.dry_run:
print("DRY RUN -- nothing will be written")
started = time.time()
hits = misses = failures = written = 0
for i, item in enumerate(todo, 1):
name = item.get("name") or os.path.basename(item["path"])
try:
tracks = addon._wyzie_tracks(item["ident"],
season=item.get("season") or 0,
episode=item.get("episode") or 0)
except Exception as exc:
failures += 1
print(" [%3d/%d] %-52s LOOKUP FAILED: %s" % (i, len(todo), name[:52], exc))
continue
if not tracks:
misses += 1
print(" [%3d/%d] %-52s no subtitles" % (i, len(todo), name[:52]))
time.sleep(PAUSE_BETWEEN_TITLES)
continue
hits += 1
if args.dry_run:
print(" [%3d/%d] %-52s %2d tracks (dry run)" % (i, len(todo), name[:52], len(tracks)))
else:
paths = addon._write_sidecars(item["path"], tracks)
written += len(paths)
print(" [%3d/%d] %-52s %2d tracks -> %2d files"
% (i, len(todo), name[:52], len(tracks), len(paths)))
time.sleep(PAUSE_BETWEEN_TITLES)
print()
print("done in %.0fs: %d with subtitles, %d without, %d lookup failures, %d files written"
% (time.time() - started, hits, misses, failures, written))
return 0 if failures == 0 else 1
if __name__ == "__main__":
sys.exit(main())