| … | … | @@ -0,0 +1,141 @@ |
| 1 | """The ingest queue survives a restart. |
| 2 | |
| 3 | _mp4_jobs was in-memory only, so restarting the addon lost every job intent: a |
| 4 | copy that was mid-flight simply never resumed, and because a partial writes to |
| 5 | `<dest>.part` while the row keys off the finished file, nothing surfaced the |
| 6 | loss. The rolling library makes that worse -- it queues work the user never |
| 7 | explicitly asked for, so nobody is waiting to notice it vanished. |
| 8 | |
| 9 | Nothing is deleted to recover. An orphaned .part is reused: ffmpeg runs with -y |
| 10 | and writes the same path, so a resumed ingest overwrites it in place. |
| 11 | |
| 12 | Run: .venv/bin/python3 -m pytest test_ingest_queue.py -q |
| 13 | """ |
| 14 | |
| 15 | import json |
| 16 | import os |
| 17 | |
| 18 | import pytest |
| 19 | |
| 20 | import addon |
| 21 | |
| 22 | |
| 23 | @pytest.fixture(autouse=True) |
| 24 | def queue_file(tmp_path, monkeypatch): |
| 25 | monkeypatch.setattr(addon, "INGEST_QUEUE_FILE", |
| 26 | os.path.join(str(tmp_path), "ingest_queue.json")) |
| 27 | monkeypatch.setattr(addon, "_mp4_jobs", {}) |
| 28 | monkeypatch.setattr(addon, "LIBRARY_ROOT", str(tmp_path / "lib")) |
| 29 | yield |
| 30 | |
| 31 | |
| 32 | def _load(): |
| 33 | with open(addon.INGEST_QUEUE_FILE) as f: |
| 34 | return json.load(f) |
| 35 | |
| 36 | |
| 37 | # --- persistence ------------------------------------------------------------ |
| 38 | |
| 39 | def test_a_job_state_is_written_to_disk(): |
| 40 | addon._set_job_state("series:tmdb:1:1:1:720", "running") |
| 41 | assert _load()["series:tmdb:1:1:1:720"]["state"] == "running" |
| 42 | |
| 43 | |
| 44 | def test_a_state_change_is_persisted(): |
| 45 | addon._set_job_state("k", "running") |
| 46 | addon._set_job_state("k", "done") |
| 47 | assert _load()["k"]["state"] == "done" |
| 48 | |
| 49 | |
| 50 | def test_the_job_arguments_are_persisted_so_it_can_be_restarted(): |
| 51 | """A key alone cannot be re-run -- the resume path needs the arguments.""" |
| 52 | addon._set_job_state("k", "running", args=("series", "tmdb:1", 1, 1, 720)) |
| 53 | assert _load()["k"]["args"] == ["series", "tmdb:1", 1, 1, 720] |
| 54 | |
| 55 | |
| 56 | def test_a_missing_queue_file_is_not_an_error(): |
| 57 | assert addon._load_job_queue() == {} |
| 58 | |
| 59 | |
| 60 | def test_a_corrupt_queue_file_is_not_an_error(): |
| 61 | """State is a convenience, never a reason to refuse to boot.""" |
| 62 | with open(addon.INGEST_QUEUE_FILE, "w") as f: |
| 63 | f.write("{not json") |
| 64 | assert addon._load_job_queue() == {} |
| 65 | |
| 66 | |
| 67 | def test_an_unwritable_queue_file_does_not_break_the_job(monkeypatch): |
| 68 | monkeypatch.setattr(addon, "INGEST_QUEUE_FILE", "/nonexistent/dir/q.json") |
| 69 | addon._set_job_state("k", "running") # must not raise |
| 70 | assert addon._mp4_jobs["k"] == "running" |
| 71 | |
| 72 | |
| 73 | # --- resume ----------------------------------------------------------------- |
| 74 | |
| 75 | @pytest.fixture |
| 76 | def resumed(monkeypatch): |
| 77 | started = [] |
| 78 | monkeypatch.setattr(addon, "_start_mp4_job", |
| 79 | lambda *a, **k: started.append(tuple(a)) or "running") |
| 80 | return started |
| 81 | |
| 82 | |
| 83 | def test_a_job_interrupted_mid_flight_is_restarted(resumed): |
| 84 | addon._set_job_state("k", "running", args=("series", "tmdb:1", 1, 1, 720)) |
| 85 | addon._resume_ingests() |
| 86 | assert resumed == [("series", "tmdb:1", 1, 1, 720)] |
| 87 | |
| 88 | |
| 89 | def test_a_finished_job_is_not_restarted(resumed): |
| 90 | addon._set_job_state("k", "done", args=("series", "tmdb:1", 1, 1, 720)) |
| 91 | addon._resume_ingests() |
| 92 | assert resumed == [] |
| 93 | |
| 94 | |
| 95 | def test_a_failed_job_is_not_restarted(resumed): |
| 96 | """A failure that recurs every boot would launch a browser each time. The |
| 97 | user can still ask for it explicitly by playing the row.""" |
| 98 | addon._set_job_state("k", "failed: could not resolve title", |
| 99 | args=("series", "tmdb:1", 1, 1, 720)) |
| 100 | addon._resume_ingests() |
| 101 | assert resumed == [] |
| 102 | |
| 103 | |
| 104 | def test_a_job_with_no_arguments_is_skipped_rather_than_guessed(resumed): |
| 105 | addon._set_job_state("k", "running") |
| 106 | addon._resume_ingests() |
| 107 | assert resumed == [] |
| 108 | |
| 109 | |
| 110 | def test_resuming_survives_a_corrupt_file(resumed): |
| 111 | with open(addon.INGEST_QUEUE_FILE, "w") as f: |
| 112 | f.write("nonsense") |
| 113 | addon._resume_ingests() # must not raise |
| 114 | assert resumed == [] |
| 115 | |
| 116 | |
| 117 | def test_a_resume_rehydrates_finished_state_and_restarts_live_ones(resumed): |
| 118 | """A finished job must be remembered, or the next request for it starts a |
| 119 | duplicate ingest of something already on disk. |
| 120 | |
| 121 | A running one is deliberately NOT rehydrated as "running" -- _start_mp4_job |
| 122 | refuses a key already marked running, so pre-setting it would silently |
| 123 | prevent the restart. The restarted job sets its own state. |
| 124 | """ |
| 125 | addon._set_job_state("done-one", "done", args=("series", "tmdb:1", 1, 1, 720)) |
| 126 | addon._set_job_state("live-one", "running", args=("series", "tmdb:1", 1, 2, 720)) |
| 127 | addon._mp4_jobs.clear() # simulate a fresh process |
| 128 | addon._resume_ingests() |
| 129 | assert addon._mp4_jobs["done-one"] == "done" |
| 130 | assert resumed == [("series", "tmdb:1", 1, 2, 720)] |
| 131 | assert "live-one" not in addon._mp4_jobs |
| 132 | |
| 133 | |
| 134 | # --- the real job path writes state ---------------------------------------- |
| 135 | |
| 136 | def test_start_mp4_job_records_its_arguments(monkeypatch): |
| 137 | """So that a restart mid-remux can pick it up again.""" |
| 138 | monkeypatch.setattr(addon, "_run_ingest", lambda *a, **k: "done") |
| 139 | addon._start_mp4_job("series", "tmdb:1", 1, 1, 720) |
| 140 | entry = _load()[addon._mp4_key("series", "tmdb:1", 1, 1, 720)] |
| 141 | assert entry["args"] == ["series", "tmdb:1", 1, 1, 720] |