| … | … | @@ -0,0 +1,223 @@ |
| 1 | """The sync reference must be on the PLAYER's timeline (2026-10-18). |
| 2 | |
| 3 | ffsubsync extracts speech by piping raw PCM out of ffmpeg, and raw PCM has no |
| 4 | timestamps: a gap in the audio is simply dropped. No Other Choice's audio |
| 5 | stream starts at 9.254 s (video at 0), so the 10-04 reference ran ~9.3 s early |
| 6 | and every measurement inherited it: |
| 7 | - the English "corrected" by -2.25 s actually needed +7.01 s (scale 1.001) |
| 8 | - the "-8.9 s family" of 23 tracks was IN SYNC (fr re-measures +0.33 s) -- |
| 9 | the sync moved them 9 s off |
| 10 | Two independent references agree on the right answer: audio padded to its |
| 11 | timestamps (aresample=async=1:first_pts=0) gave +7.01 s, and three 5-minute |
| 12 | windows seeked by timestamp gave +7.03 s. |
| 13 | |
| 14 | So the reference is built from gap-padded audio, results carry the reference |
| 15 | version they were measured with, and a result from the old reference is |
| 16 | measured again from the track's ORIGINAL -- the old correction is kept in the |
| 17 | store, never deleted. A track the user kept stays theirs. |
| 18 | |
| 19 | Run: .venv/bin/python3 -m pytest test_subsync_reference.py -q |
| 20 | """ |
| 21 | |
| 22 | import json |
| 23 | import os |
| 24 | import subprocess |
| 25 | |
| 26 | import pytest |
| 27 | |
| 28 | import addon |
| 29 | |
| 30 | REAL_REFERENCE = addon._subsync_reference |
| 31 | |
| 32 | |
| 33 | @pytest.fixture |
| 34 | def lib(tmp_path, monkeypatch): |
| 35 | monkeypatch.setattr(addon, "LIBRARY_ROOT", str(tmp_path / "lib")) |
| 36 | monkeypatch.setattr(addon, "SUBS_ORIGINAL_DIR", str(tmp_path / "subs-original")) |
| 37 | monkeypatch.setattr(addon, "SUBTITLE_SYNC_FILE", str(tmp_path / "sync.json")) |
| 38 | monkeypatch.setattr(addon, "_subsync_available", lambda: True) |
| 39 | monkeypatch.setattr(addon, "SUBSYNC_LANGUAGES", ("en",)) |
| 40 | monkeypatch.setattr(addon, "_notify_jellyfin", lambda p, t: None) |
| 41 | monkeypatch.setattr(addon, "_subsync_reference", |
| 42 | lambda dest, workdir: os.path.join(workdir, "ref.npz")) |
| 43 | dest = tmp_path / "lib" / "Movies" / "Film (2025) [tmdbid-1]" / "Film (2025).mp4" |
| 44 | dest.parent.mkdir(parents=True) |
| 45 | dest.write_bytes(b"video") |
| 46 | return str(dest) |
| 47 | |
| 48 | |
| 49 | def _write(path, body): |
| 50 | os.makedirs(os.path.dirname(path), exist_ok=True) |
| 51 | with open(path, "w") as f: |
| 52 | f.write(body) |
| 53 | return path |
| 54 | |
| 55 | |
| 56 | def _sidecar(dest, lang): |
| 57 | return os.path.splitext(dest)[0] + f".{lang}.srt" |
| 58 | |
| 59 | |
| 60 | def _record(records): |
| 61 | with open(addon.SUBTITLE_SYNC_FILE, "w") as f: |
| 62 | json.dump(records, f) |
| 63 | |
| 64 | |
| 65 | def _records(): |
| 66 | with open(addon.SUBTITLE_SYNC_FILE) as f: |
| 67 | return json.load(f) |
| 68 | |
| 69 | |
| 70 | def _measure_by_body(table): |
| 71 | """(offset, scale) chosen by the track's CONTENT, so a test can tell the |
| 72 | original from the old correction; writes the corrected output.""" |
| 73 | seen = [] |
| 74 | |
| 75 | def measure(ref, srt_in, srt_out): |
| 76 | with open(srt_in) as f: |
| 77 | body = f.read() |
| 78 | seen.append(body) |
| 79 | offset, scale = table.get(body, (0.0, 1.0)) |
| 80 | if srt_out: |
| 81 | with open(srt_out, "w") as f: |
| 82 | f.write("fixed") |
| 83 | return {"offset": offset, "scale": scale, "score": 1.0} |
| 84 | return measure, seen |
| 85 | |
| 86 | |
| 87 | # ------------------------------------------------------------ the reference |
| 88 | |
| 89 | def test_the_reference_is_built_from_audio_padded_to_its_timestamps(lib, tmp_path, |
| 90 | monkeypatch): |
| 91 | _write(_sidecar(lib, "en"), "x") |
| 92 | monkeypatch.setattr(addon, "_subsync_reference", REAL_REFERENCE) |
| 93 | calls = [] |
| 94 | |
| 95 | def fake_run(cmd, **kw): |
| 96 | calls.append(cmd) |
| 97 | if os.path.basename(cmd[0]) == "ffsubsync": |
| 98 | np_path = os.path.join(kw["cwd"], "ref.npz") |
| 99 | open(np_path, "wb").close() |
| 100 | return subprocess.CompletedProcess(cmd, 0, "", "") |
| 101 | monkeypatch.setattr(addon.subprocess, "run", fake_run) |
| 102 | work = tmp_path / "work" |
| 103 | work.mkdir() |
| 104 | ref = addon._subsync_reference(lib, str(work)) |
| 105 | assert ref == str(work / "ref.npz") |
| 106 | ffmpeg, subsync = calls |
| 107 | assert ffmpeg[0] == "ffmpeg" and lib in ffmpeg |
| 108 | assert "aresample=async=1:first_pts=0" in ffmpeg |
| 109 | wav = ffmpeg[-1] |
| 110 | assert wav.endswith(".wav") and os.path.dirname(wav) == str(work) |
| 111 | assert subsync[1] == wav and "--serialize-speech" in subsync |
| 112 | |
| 113 | |
| 114 | def test_an_audio_extraction_failure_is_no_reference(lib, tmp_path, monkeypatch): |
| 115 | _write(_sidecar(lib, "en"), "x") |
| 116 | monkeypatch.setattr(addon, "_subsync_reference", REAL_REFERENCE) |
| 117 | |
| 118 | def boom(cmd, **kw): |
| 119 | raise subprocess.CalledProcessError(1, cmd) |
| 120 | monkeypatch.setattr(addon.subprocess, "run", boom) |
| 121 | assert addon._subsync_reference(lib, str(tmp_path)) is None |
| 122 | |
| 123 | |
| 124 | # ------------------------------------------- results from the old reference |
| 125 | |
| 126 | def test_new_results_carry_the_reference_version(lib, monkeypatch): |
| 127 | en = _write(_sidecar(lib, "en"), "orig") |
| 128 | measure, _ = _measure_by_body({}) |
| 129 | monkeypatch.setattr(addon, "_subsync_measure", measure) |
| 130 | addon._sync_subtitles(lib) |
| 131 | assert _records()[os.path.abspath(en)]["ref"] == addon.SUBSYNC_REF_VERSION |
| 132 | |
| 133 | |
| 134 | def test_a_correction_from_the_old_reference_is_redone_from_the_original( |
| 135 | lib, tmp_path, monkeypatch): |
| 136 | en = _write(_sidecar(lib, "en"), "old-fix") |
| 137 | orig = _write(str(tmp_path / "subs-original" / "en.srt.2"), "orig") |
| 138 | _record({os.path.abspath(en): {"status": "corrected", "offset": -2.25, |
| 139 | "scale": 1.001, "original": orig, |
| 140 | "identity": addon._file_identity(en)}}) |
| 141 | measure, seen = _measure_by_body({"orig": (7.01, 1.001), "fixed": (0.0, 1.0)}) |
| 142 | monkeypatch.setattr(addon, "_subsync_measure", measure) |
| 143 | addon._sync_subtitles(lib) |
| 144 | assert seen[0] == "orig", "measured the old correction, not the original" |
| 145 | rec = _records()[os.path.abspath(en)] |
| 146 | assert rec["status"] == "corrected" and rec["offset"] == 7.01 |
| 147 | assert rec["ref"] == addon.SUBSYNC_REF_VERSION |
| 148 | with open(en) as f: |
| 149 | assert f.read() == "fixed" |
| 150 | kept = [] |
| 151 | for root, _, files in os.walk(addon.SUBS_ORIGINAL_DIR): |
| 152 | for n in files: |
| 153 | with open(os.path.join(root, n)) as f: |
| 154 | kept.append(f.read()) |
| 155 | assert "old-fix" in kept, "the superseded correction was not kept" |
| 156 | assert "orig" in kept |
| 157 | |
| 158 | |
| 159 | def test_a_track_the_old_reference_withdrew_comes_back_and_is_measured( |
| 160 | lib, tmp_path, monkeypatch): |
| 161 | en = _sidecar(lib, "en") # not in the library any more |
| 162 | orig = _write(str(tmp_path / "subs-original" / "en.srt.2"), "orig") |
| 163 | _record({os.path.abspath(en): {"status": "unfixable", "offset": 9.0, |
| 164 | "scale": 1.0, "original": orig, |
| 165 | "identity": None}}) |
| 166 | measure, _ = _measure_by_body({"orig": (0.1, 1.0)}) |
| 167 | monkeypatch.setattr(addon, "_subsync_measure", measure) |
| 168 | addon._sync_subtitles(lib) |
| 169 | assert os.path.exists(en) |
| 170 | assert _records()[os.path.abspath(en)]["status"] == "in_sync" |
| 171 | |
| 172 | |
| 173 | def test_an_old_in_sync_verdict_is_measured_again(lib, monkeypatch): |
| 174 | en = _write(_sidecar(lib, "en"), "orig") |
| 175 | _record({os.path.abspath(en): {"status": "in_sync", "offset": 0.1, |
| 176 | "scale": 1.0, |
| 177 | "identity": addon._file_identity(en)}}) |
| 178 | measure, seen = _measure_by_body({"orig": (9.25, 1.0), "fixed": (0.0, 1.0)}) |
| 179 | monkeypatch.setattr(addon, "_subsync_measure", measure) |
| 180 | addon._sync_subtitles(lib) |
| 181 | assert seen and _records()[os.path.abspath(en)]["status"] == "corrected" |
| 182 | |
| 183 | |
| 184 | def test_another_language_the_old_reference_touched_is_redone_too( |
| 185 | lib, tmp_path, monkeypatch): |
| 186 | """English-only scope came AFTER 10-04's all-language pass, which moved |
| 187 | 23 good No Other Choice tracks by -8.9 s. Our own damage is repaired |
| 188 | whatever the language.""" |
| 189 | fr = _write(_sidecar(lib, "fr"), "old-fix") |
| 190 | orig = _write(str(tmp_path / "subs-original" / "fr.srt.2"), "orig") |
| 191 | _record({os.path.abspath(fr): {"status": "corrected", "offset": -8.93, |
| 192 | "scale": 1.0, "original": orig, |
| 193 | "identity": addon._file_identity(fr)}}) |
| 194 | measure, _ = _measure_by_body({"orig": (0.33, 1.0), "fixed": (0.0, 1.0)}) |
| 195 | monkeypatch.setattr(addon, "_subsync_measure", measure) |
| 196 | addon._sync_subtitles(lib) |
| 197 | rec = _records()[os.path.abspath(fr)] |
| 198 | assert rec["ref"] == addon.SUBSYNC_REF_VERSION and rec["status"] == "corrected" |
| 199 | |
| 200 | |
| 201 | def test_an_untouched_other_language_is_still_left_alone(lib, monkeypatch): |
| 202 | _write(_sidecar(lib, "fr"), "orig") |
| 203 | monkeypatch.setattr(addon, "_subsync_measure", |
| 204 | lambda *a: pytest.fail("measured a language out of scope")) |
| 205 | assert addon._sync_subtitles(lib)["tracks"] == 0 |
| 206 | |
| 207 | |
| 208 | def test_a_track_the_user_kept_is_still_theirs(lib, monkeypatch): |
| 209 | en = _write(_sidecar(lib, "en"), "restored") |
| 210 | _record({os.path.abspath(en): {"status": "kept", "offset": 22.18, "scale": 1.0, |
| 211 | "identity": addon._file_identity(en)}}) |
| 212 | monkeypatch.setattr(addon, "_subsync_measure", |
| 213 | lambda *a: pytest.fail("re-measured a kept track")) |
| 214 | assert addon._sync_subtitles(lib)["tracks"] == 0 |
| 215 | |
| 216 | |
| 217 | def test_a_current_result_is_not_measured_again(lib, monkeypatch): |
| 218 | en = _write(_sidecar(lib, "en"), "orig") |
| 219 | measure, seen = _measure_by_body({}) |
| 220 | monkeypatch.setattr(addon, "_subsync_measure", measure) |
| 221 | addon._sync_subtitles(lib) |
| 222 | addon._sync_subtitles(lib) |
| 223 | assert len(seen) == 1 |