OverviewFilesCommitsIssuesDocsReleasesPeers

stremio-vsembed commits / 4f675e6

Backfill subtitles for the library this addon did not fetch
_run_ingest gets subtitles for anything it copies, including titles already in the library. But its scope is the ingest queue -- about 20 titles against a library of 226. Everything else arrived by other means and had none: measured 2026-09-05, exactly ONE item in the whole library carried an external subtitle, and that was the one fetched an hour earlier. A script and not a route, because of where the ids are. The library naming convention carries [tmdbid-...] for 6 of 81 movie folders, so the id has to come from Jellyfin -- which has already identified this library and holds Tmdb or Imdb for 66 movies and for every episode's series. Teaching the daemon to authenticate against Jellyfin, for a job that is essentially one-time, would buy a permanent dependency for a temporary need. New content does not need it: it arrives through _run_ingest, which already fetches subtitles. It imports addon rather than reimplementing anything. _wyzie_tracks and _write_sidecars are the tested path, and a second copy of that logic here is exactly the thing that would drift out of step with them. Sampled 16 titles at random before running it: 16/16 had subtitles, mean 17.8 tracks. So the shape of the job is ~225 lookups and ~4000 files, which is worth pacing -- hence the half-second between titles, on top of the 8-way parallelism already inside a single title's fetch. Resumable by construction: it asks the DISK whether a sidecar exists, not the work list, so a re-run costs one lookup per title and no downloads. The stem match is per-file rather than per-directory, which matters for season folders -- "some .srt exists here" would skip every episode after the first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4f675e60fb3a01e9ad497bea19be9bb10cce6b23
by plankatron <fleeky@gmail.com> · 2026-09-05T01:34:20.000Z

Changed files (2) +215 -0

backfill_subtitles.py +135 -0
……@@ -0,0 +1,135 @@
1#!/usr/bin/env python3
2"""Fetch subtitles for library titles this addon did not ingest.
3
4`_run_ingest` fetches subtitles for anything it copies, including the
5already-in-library path -- but its scope is the INGEST QUEUE, and the queue is
6~20 titles against a library of 226. The rest arrived by other means and has
7none: measured 2026-09-05, exactly ONE item in the whole library had an external
8subtitle, and that was the one fetched an hour earlier.
9
10A script rather than a route, for one reason: the ids. The library naming
11convention carries `[tmdbid-...]` for only 6 of 81 movie folders, so the id has
12to come from Jellyfin, which has already identified this library and holds
13Tmdb/Imdb for 66 movies and every episode's series. Teaching the daemon to
14authenticate against Jellyfin -- for a job that is essentially one-time -- would
15be a permanent dependency bought for a temporary need. New content does not need
16this: it comes in through _run_ingest, which already fetches subtitles.
17
18It imports addon rather than reimplementing anything. _wyzie_tracks and
19_write_sidecars are the tested path; a second copy of that logic here would be
20the thing that drifts.
21
22Idempotent and resumable: _write_sidecars skips any sidecar already on disk, so
23a re-run costs one lookup per title and no downloads.
24
25The work list is built separately (Jellyfin has the ids, and the token lives on
26the Pi) and passed in as JSON:
27
28 [{"kind": "movie"|"series", "name": ..., "path": "/mnt/usb/...",
29 "ident": "639988", "season": 0, "episode": 0}]
30
31Usage:
32 .venv/bin/python3 backfill_subtitles.py work.json [--dry-run] [--limit N]
33"""
34
35import argparse
36import json
37import os
38import sys
39import time
40
41import addon
42
43# Politeness. The per-title fetch already runs 8 downloads in parallel; this is
44# the gap BETWEEN titles, so a 225-title run does not arrive as one burst on a
45# service doing us a favour for free.
46PAUSE_BETWEEN_TITLES = 0.5
47
48
49def load_work(path):
50 with open(path) as f:
51 work = json.load(f)
52 if not isinstance(work, list):
53 raise SystemExit("work list must be a JSON array")
54 out = []
55 for w in work:
56 if not w.get("path") or not w.get("ident"):
57 continue
58 out.append(w)
59 return out
60
61
62def needs_work(item):
63 """True if the media file exists and has no sidecar beside it yet.
64
65 Checked on DISK rather than trusting the work list: the list is a snapshot,
66 and a re-run should see what actually happened.
67 """
68 path = item["path"]
69 if not os.path.exists(path):
70 return False
71 stem = os.path.splitext(os.path.basename(path))[0]
72 folder = os.path.dirname(path)
73 try:
74 siblings = os.listdir(folder)
75 except OSError:
76 return False
77 return not any(f.startswith(stem + ".") and f.endswith(".srt") for f in siblings)
78
79
80def main():
81 ap = argparse.ArgumentParser()
82 ap.add_argument("work")
83 ap.add_argument("--dry-run", action="store_true",
84 help="look up tracks and report, write nothing")
85 ap.add_argument("--limit", type=int, default=0)
86 args = ap.parse_args()
87
88 work = load_work(args.work)
89 todo = [w for w in work if needs_work(w)]
90 skipped = len(work) - len(todo)
91 if args.limit:
92 todo = todo[:args.limit]
93
94 print("work list: %d already done or missing: %d to process: %d"
95 % (len(work), skipped, len(todo)))
96 if args.dry_run:
97 print("DRY RUN -- nothing will be written")
98
99 started = time.time()
100 hits = misses = failures = written = 0
101 for i, item in enumerate(todo, 1):
102 name = item.get("name") or os.path.basename(item["path"])
103 try:
104 tracks = addon._wyzie_tracks(item["ident"],
105 season=item.get("season") or 0,
106 episode=item.get("episode") or 0)
107 except Exception as exc:
108 failures += 1
109 print(" [%3d/%d] %-52s LOOKUP FAILED: %s" % (i, len(todo), name[:52], exc))
110 continue
111
112 if not tracks:
113 misses += 1
114 print(" [%3d/%d] %-52s no subtitles" % (i, len(todo), name[:52]))
115 time.sleep(PAUSE_BETWEEN_TITLES)
116 continue
117
118 hits += 1
119 if args.dry_run:
120 print(" [%3d/%d] %-52s %2d tracks (dry run)" % (i, len(todo), name[:52], len(tracks)))
121 else:
122 paths = addon._write_sidecars(item["path"], tracks)
123 written += len(paths)
124 print(" [%3d/%d] %-52s %2d tracks -> %2d files"
125 % (i, len(todo), name[:52], len(tracks), len(paths)))
126 time.sleep(PAUSE_BETWEEN_TITLES)
127
128 print()
129 print("done in %.0fs: %d with subtitles, %d without, %d lookup failures, %d files written"
130 % (time.time() - started, hits, misses, failures, written))
131 return 0 if failures == 0 else 1
132
133
134if __name__ == "__main__":
135 sys.exit(main())
test_backfill.py +80 -0
……@@ -0,0 +1,80 @@
1"""The backfill's own logic: what still needs doing, and what to skip.
2
3Everything that actually fetches or writes is addon's, already covered by
4test_subtitles.py. What is new here is deciding which of 226 library items still
5need work -- and that decision is made against DISK, not against the work list,
6because the list is a snapshot and a re-run must see what really happened.
7
8Run: .venv/bin/python3 -m pytest test_backfill.py -q
9"""
10
11import json
12
13import pytest
14
15import backfill_subtitles as bf
16
17
18@pytest.fixture
19def movie(tmp_path):
20 d = tmp_path / "Some Film (1999) [tmdbid-1]"
21 d.mkdir()
22 f = d / "Some Film (1999).mkv"
23 f.write_bytes(b"video")
24 return {"kind": "movie", "name": "Some Film", "path": str(f),
25 "ident": "1", "season": 0, "episode": 0}
26
27
28class TestNeedsWork:
29 def test_a_film_with_no_sidecar_needs_work(self, movie):
30 assert bf.needs_work(movie) is True
31
32 def test_a_film_that_already_has_one_does_not(self, movie, tmp_path):
33 d = tmp_path / "Some Film (1999) [tmdbid-1]"
34 (d / "Some Film (1999).en.srt").write_text("1\n")
35 assert bf.needs_work(movie) is False
36
37 def test_a_missing_media_file_is_not_work(self, movie):
38 movie["path"] = movie["path"].replace("Some Film", "Absent Film")
39 assert bf.needs_work(movie) is False
40
41 def test_a_subtitle_for_a_DIFFERENT_film_does_not_count(self, movie, tmp_path):
42 """Season folders hold many episodes side by side, so 'some .srt exists
43 in this directory' is the wrong question -- it would skip every episode
44 after the first one that got subtitles."""
45 d = tmp_path / "Some Film (1999) [tmdbid-1]"
46 (d / "Another Film (2001).en.srt").write_text("1\n")
47 assert bf.needs_work(movie) is True
48
49 def test_an_episode_is_matched_on_its_own_stem(self, tmp_path):
50 season = tmp_path / "Season 01"
51 season.mkdir()
52 (season / "Show S01E01.mkv").write_bytes(b"v")
53 (season / "Show S01E02.mkv").write_bytes(b"v")
54 (season / "Show S01E01.en.srt").write_text("1\n")
55 e1 = {"path": str(season / "Show S01E01.mkv"), "ident": "9", "season": 1, "episode": 1}
56 e2 = {"path": str(season / "Show S01E02.mkv"), "ident": "9", "season": 1, "episode": 2}
57 assert bf.needs_work(e1) is False
58 assert bf.needs_work(e2) is True
59
60 def test_an_unreadable_folder_is_skipped_not_crashed(self, movie):
61 movie["path"] = "/definitely/not/here/film.mkv"
62 assert bf.needs_work(movie) is False
63
64
65class TestLoadWork:
66 def test_entries_without_an_id_or_path_are_dropped(self, tmp_path):
67 p = tmp_path / "w.json"
68 p.write_text(json.dumps([
69 {"path": "/a.mkv", "ident": "1"},
70 {"path": "/b.mkv"},
71 {"ident": "3"},
72 {},
73 ]))
74 assert len(bf.load_work(str(p))) == 1
75
76 def test_a_non_list_is_refused_rather_than_half_processed(self, tmp_path):
77 p = tmp_path / "w.json"
78 p.write_text(json.dumps({"not": "a list"}))
79 with pytest.raises(SystemExit):
80 bf.load_work(str(p))