| … | … | @@ -0,0 +1,141 @@ |
| 1 | """The library copy's OWN subtitles, for players that cannot see its folder. |
| 2 | |
| 3 | 2026-10-18: No Other Choice's English drifted in Kodi although the sync had |
| 4 | corrected it on 10-04. The corrected file only ever reached Jellyfin, which |
| 5 | reads the folder. Kodi plays the copy over HTTP through /mp4/ and Jacktook |
| 6 | fetched wyzie itself into a per-TMDB cache on the Pi -- proven by md5: the Pi's |
| 7 | English.srt (025fc304...) was byte-identical to the UNSYNCED original in |
| 8 | subs-original/, not the corrected sidecar (37840b79...). |
| 9 | |
| 10 | So the subtitles that belong to a file are served by the same id the file is: |
| 11 | /subs/<kind>/<ident>.json lists them (Stremio's subtitle object: id, url, |
| 12 | lang), and each url serves the sidecar itself. Live, not baked into the stream |
| 13 | row, because Jacktook caches the row for 24h and a sync can land in between. |
| 14 | |
| 15 | Like /web/file/, this must never fetch anything: /mp4/ queues the next episode |
| 16 | and starts an ingest, a subtitle lookup must not. |
| 17 | |
| 18 | Run: .venv/bin/python3 -m pytest test_subtitle_route.py -q |
| 19 | """ |
| 20 | |
| 21 | import os |
| 22 | |
| 23 | import pytest |
| 24 | |
| 25 | import addon |
| 26 | |
| 27 | |
| 28 | TMDB = { |
| 29 | "tv/1": {"name": "Show", "first_air_date": "2020-01-01", "seasons": []}, |
| 30 | "tv/1/season/2/episode/6": {"name": "Six", "runtime": 30}, |
| 31 | "tv/1/season/2/episode/7": {"name": "Seven", "runtime": 30}, |
| 32 | "movie/2": {"title": "Film", "release_date": "2021-05-05", "runtime": 30}, |
| 33 | } |
| 34 | |
| 35 | |
| 36 | @pytest.fixture(autouse=True) |
| 37 | def isolated(tmp_path, monkeypatch): |
| 38 | monkeypatch.setattr(addon, "LIBRARY_ROOT", str(tmp_path)) |
| 39 | monkeypatch.setattr(addon, "INGEST_QUEUE_FILE", str(tmp_path / "queue.json")) |
| 40 | monkeypatch.setattr(addon, "LIBRARY_VERIFIED_FILE", str(tmp_path / "verified.json")) |
| 41 | monkeypatch.setattr(addon, "MP4_DIR", str(tmp_path / "mp4-cache")) |
| 42 | monkeypatch.setattr(addon, "_duration_cache", {}) |
| 43 | monkeypatch.setattr(addon, "_library_meta_cache", {}) |
| 44 | monkeypatch.setattr(addon, "_mp4_jobs", {}) |
| 45 | monkeypatch.setattr(addon, "_tmdb_get", lambda path, params=None: TMDB[path]) |
| 46 | monkeypatch.setattr(addon, "_media_duration", lambda p: 1800.0) |
| 47 | |
| 48 | def refuse(*a, **k): |
| 49 | raise AssertionError("a subtitle lookup started a download") |
| 50 | monkeypatch.setattr(addon, "_start_mp4_job", refuse) |
| 51 | monkeypatch.setattr(addon, "_queue_next_episode", refuse) |
| 52 | monkeypatch.setattr(addon, "_ensure_ingest", refuse) |
| 53 | monkeypatch.setattr(addon, "_resolve_vidcore", refuse) |
| 54 | yield |
| 55 | |
| 56 | |
| 57 | @pytest.fixture |
| 58 | def client(): |
| 59 | return addon.app.test_client() |
| 60 | |
| 61 | |
| 62 | def _hold(kind, season=0, episode=0, tmdb="1", subs=("en",)): |
| 63 | meta = addon._library_meta(kind, f"tmdb:{tmdb}", season, episode) |
| 64 | path = addon._library_path(kind, meta["title"], meta["year"], meta["tmdb_id"], |
| 65 | season, episode, meta["episode_title"]) |
| 66 | os.makedirs(os.path.dirname(path), exist_ok=True) |
| 67 | with open(path, "wb") as f: |
| 68 | f.write(b"0123456789" * 100) |
| 69 | stem = os.path.splitext(path)[0] |
| 70 | for code in subs: |
| 71 | with open(f"{stem}.{code}.srt", "w") as f: |
| 72 | f.write(f"1\n00:00:01,000 --> 00:00:02,000\n{code}\n") |
| 73 | return path |
| 74 | |
| 75 | |
| 76 | def test_a_held_movie_lists_its_sidecars(client): |
| 77 | _hold("movie", tmdb="2", subs=("en", "ko")) |
| 78 | r = client.get("/subs/movie/tmdb:2.json") |
| 79 | assert r.status_code == 200 |
| 80 | subs = r.get_json()["subtitles"] |
| 81 | assert sorted(s["lang"] for s in subs) == ["en", "ko"] |
| 82 | for s in subs: |
| 83 | assert s["id"] and s["url"].startswith("http://") |
| 84 | |
| 85 | |
| 86 | def test_each_listed_url_serves_that_sidecar_byte_for_byte(client): |
| 87 | path = _hold("movie", tmdb="2", subs=("en", "pt-BR")) |
| 88 | for s in client.get("/subs/movie/tmdb:2.json").get_json()["subtitles"]: |
| 89 | body = client.get(s["url"].split("localhost", 1)[1]).get_data() |
| 90 | with open(f"{os.path.splitext(path)[0]}.{s['lang']}.srt", "rb") as f: |
| 91 | assert body == f.read() |
| 92 | |
| 93 | |
| 94 | def test_a_held_episode_lists_its_sidecars(client): |
| 95 | _hold("series", 2, 6, subs=("en",)) |
| 96 | subs = client.get("/subs/series/tmdb:1:2:6.json").get_json()["subtitles"] |
| 97 | assert [s["lang"] for s in subs] == ["en"] |
| 98 | |
| 99 | |
| 100 | def test_a_second_track_in_one_language_keeps_its_own_id(client): |
| 101 | """`<stem>.en.2.srt` is a different file from `<stem>.en.srt`; both are |
| 102 | offered, and each url serves its own.""" |
| 103 | path = _hold("movie", tmdb="2", subs=("en", "en.2")) |
| 104 | subs = client.get("/subs/movie/tmdb:2.json").get_json()["subtitles"] |
| 105 | assert sorted(s["id"] for s in subs) == ["en", "en.2"] |
| 106 | assert all(s["lang"] == "en" for s in subs) |
| 107 | two = next(s for s in subs if s["id"] == "en.2") |
| 108 | body = client.get(two["url"].split("localhost", 1)[1]).get_data() |
| 109 | with open(f"{os.path.splitext(path)[0]}.en.2.srt", "rb") as f: |
| 110 | assert body == f.read() |
| 111 | |
| 112 | |
| 113 | def test_the_list_carries_each_file_identity(client): |
| 114 | """So a client can tell a re-synced file from the one it cached.""" |
| 115 | path = _hold("movie", tmdb="2", subs=("en",)) |
| 116 | (s,) = client.get("/subs/movie/tmdb:2.json").get_json()["subtitles"] |
| 117 | st = os.stat(f"{os.path.splitext(path)[0]}.en.srt") |
| 118 | assert s["identity"] == [st.st_size, int(st.st_mtime)] |
| 119 | |
| 120 | |
| 121 | def test_not_held_is_an_empty_list_and_fetches_nothing(client): |
| 122 | r = client.get("/subs/series/tmdb:1:2:7.json") |
| 123 | assert r.status_code == 200 |
| 124 | assert r.get_json() == {"subtitles": []} |
| 125 | |
| 126 | |
| 127 | def test_held_without_sidecars_is_an_empty_list(client): |
| 128 | _hold("movie", tmdb="2", subs=()) |
| 129 | assert client.get("/subs/movie/tmdb:2.json").get_json() == {"subtitles": []} |
| 130 | |
| 131 | |
| 132 | def test_a_name_that_is_not_one_of_its_sidecars_is_404(client): |
| 133 | _hold("movie", tmdb="2", subs=("en",)) |
| 134 | assert client.get("/subs/movie/tmdb:2/de.srt").status_code == 404 |
| 135 | assert client.get("/subs/movie/tmdb:2/..%2F..%2Fverified.json.srt").status_code == 404 |
| 136 | |
| 137 | |
| 138 | def test_bad_ids_are_404(client): |
| 139 | assert client.get("/subs/movie/x.json").status_code == 404 |
| 140 | assert client.get("/subs/series/tmdb:1.json").status_code == 404 |
| 141 | assert client.get("/subs/anime/tmdb:1.json").status_code == 404 |