| … | … | @@ -0,0 +1,102 @@ |
| 1 | """One library file, one ingest -- whatever ident asked for it. |
| 2 | |
| 3 | The in-flight guard in _start_mp4_job keys on `kind:ident:season:episode:height`. |
| 4 | But the same episode reaches this service under two idents: Kodi's cinemeta rows |
| 5 | send `tt…`, the flixlab catalog and the web UI send `tmdb:…`. Both resolve |
| 6 | through _library_meta to the SAME _library_path, and therefore the same .part -- |
| 7 | yet they are different keys, so neither job sees the other. Two ffmpeg processes |
| 8 | then write one .part. (labs#12.) |
| 9 | |
| 10 | The web UI makes this likelier: the point of it is to start a download from |
| 11 | somewhere other than the Kodi row that may already be fetching it. |
| 12 | |
| 13 | Run: .venv/bin/python3 -m pytest test_one_file_one_ingest.py -q |
| 14 | """ |
| 15 | |
| 16 | import os |
| 17 | import threading |
| 18 | import time |
| 19 | |
| 20 | import pytest |
| 21 | |
| 22 | import addon |
| 23 | |
| 24 | |
| 25 | META = {"tmdb_id": "138502", "title": "X-Men '97", "year": "2024", |
| 26 | "episode_title": "Bright Eyes", "runtime": 30} |
| 27 | |
| 28 | |
| 29 | @pytest.fixture |
| 30 | def ingest_env(tmp_path, monkeypatch): |
| 31 | monkeypatch.setattr(addon, "INGEST_QUEUE_FILE", |
| 32 | os.path.join(str(tmp_path), "ingest_queue.json")) |
| 33 | monkeypatch.setattr(addon, "LIBRARY_ROOT", str(tmp_path / "lib")) |
| 34 | monkeypatch.setattr(addon, "LIBRARY_VERIFIED_FILE", |
| 35 | os.path.join(str(tmp_path), "library_verified.json")) |
| 36 | monkeypatch.setattr(addon, "_mp4_jobs", {}) |
| 37 | monkeypatch.setattr(addon, "_library_meta", lambda *a, **k: dict(META)) |
| 38 | monkeypatch.setattr(addon, "_enough_space", lambda: True) |
| 39 | monkeypatch.setattr(addon, "_resolve_vidcore", lambda *a, **k: "http://cdn/m.m3u8") |
| 40 | monkeypatch.setattr(addon, "_cap_variant", lambda url, h=None: url) |
| 41 | monkeypatch.setattr(addon, "_fetch_subtitles_for", lambda *a, **k: None) |
| 42 | monkeypatch.setattr(addon, "_copy_is_complete", lambda *a, **k: True) |
| 43 | |
| 44 | calls = {"n": 0, "live": 0, "peak": 0} |
| 45 | lock = threading.Lock() |
| 46 | |
| 47 | def fake_remux(variant_url, dest): |
| 48 | with lock: |
| 49 | calls["n"] += 1 |
| 50 | calls["live"] += 1 |
| 51 | calls["peak"] = max(calls["peak"], calls["live"]) |
| 52 | time.sleep(0.3) # long enough for the other to start |
| 53 | os.makedirs(os.path.dirname(dest), exist_ok=True) |
| 54 | with open(dest, "wb") as f: |
| 55 | f.write(b"x" * 1024) |
| 56 | with lock: |
| 57 | calls["live"] -= 1 |
| 58 | |
| 59 | monkeypatch.setattr(addon, "_remux_to_mp4", fake_remux) |
| 60 | yield calls |
| 61 | # Same reason as test_ingest_queue: a job thread that outlives the patches |
| 62 | # writes its final state to the REAL queue. |
| 63 | for t in threading.enumerate(): |
| 64 | if t.name.startswith("mp4-") and t is not threading.current_thread(): |
| 65 | t.join(10) |
| 66 | |
| 67 | |
| 68 | def _join_jobs(): |
| 69 | for t in threading.enumerate(): |
| 70 | if t.name.startswith("mp4-") and t is not threading.current_thread(): |
| 71 | t.join(10) |
| 72 | |
| 73 | |
| 74 | def test_two_idents_for_one_episode_fetch_it_once(ingest_env): |
| 75 | addon._start_mp4_job("series", "tt16026746", 2, 7, 1080) |
| 76 | addon._start_mp4_job("series", "tmdb:138502", 2, 7, 1080) |
| 77 | _join_jobs() |
| 78 | assert ingest_env["peak"] == 1, "two remuxes wrote the same .part at once" |
| 79 | assert ingest_env["n"] == 1, "the second ident fetched a copy already held" |
| 80 | |
| 81 | |
| 82 | def test_the_second_job_reports_done_not_failed(ingest_env): |
| 83 | """It got what it asked for. Reporting a failure here is the #11 shape -- |
| 84 | an outcome misreported -- and would put a false red row on /library.""" |
| 85 | addon._start_mp4_job("series", "tt16026746", 2, 7, 1080) |
| 86 | addon._start_mp4_job("series", "tmdb:138502", 2, 7, 1080) |
| 87 | _join_jobs() |
| 88 | assert addon._mp4_jobs["series:tt16026746:2:7:1080"] == "done" |
| 89 | assert addon._mp4_jobs["series:tmdb:138502:2:7:1080"] == "done" |
| 90 | |
| 91 | |
| 92 | def test_different_episodes_still_run_concurrently(ingest_env, monkeypatch): |
| 93 | """The guard is per FILE. It must not serialise unrelated ingests behind |
| 94 | one another -- that would turn the remux semaphore's two slots into one.""" |
| 95 | metas = {1: dict(META, episode_title="One"), 2: dict(META, episode_title="Two")} |
| 96 | monkeypatch.setattr(addon, "_library_meta", |
| 97 | lambda kind, ident, s=0, e=0: metas[e]) |
| 98 | addon._start_mp4_job("series", "tmdb:138502", 2, 1, 1080) |
| 99 | addon._start_mp4_job("series", "tmdb:138502", 2, 2, 1080) |
| 100 | _join_jobs() |
| 101 | assert ingest_env["n"] == 2 |
| 102 | assert ingest_env["peak"] == 2 |