"""Locking and crash recovery.""" import multiprocessing import pytest from conftest import add_video from youtube_automate import config, download, runner, videos def _hold_lock(path, started, release): from youtube_automate import runner as runner_module with runner_module.exclusive_lock(path): started.set() release.wait(timeout=30) class TestExclusiveLock: def test_acquires_when_free(self, tmp_path): with runner.exclusive_lock(tmp_path / "run.lock"): pass # no exception is the assertion def test_can_be_reacquired_after_release(self, tmp_path): path = tmp_path / "run.lock" with runner.exclusive_lock(path): pass with runner.exclusive_lock(path): pass def test_second_holder_is_refused(self, tmp_path): path = tmp_path / "run.lock" started = multiprocessing.Event() release = multiprocessing.Event() holder = multiprocessing.Process( target=_hold_lock, args=(path, started, release) ) holder.start() try: assert started.wait(timeout=15), "helper never acquired the lock" with pytest.raises(runner.AlreadyRunning): with runner.exclusive_lock(path): pass finally: release.set() holder.join(timeout=15) def test_creates_the_parent_directory(self, tmp_path): path = tmp_path / "nested" / "deeper" / "run.lock" with runner.exclusive_lock(path): assert path.exists() class TestRecover: def test_requeues_downloading_rows(self, conn, channel, media_root): add_video(conn, channel["id"], "a", state=videos.DOWNLOADING) result = runner.recover(conn) assert result["requeued"] == 1 assert videos.get(conn, "a")["state"] == videos.PENDING def test_clears_work_dir_orphans(self, conn, media_root): (config.WORK_DIR / "half.part").write_bytes(b"x") (config.WORK_DIR / "half.mp4").write_bytes(b"x") result = runner.recover(conn) assert result["orphans"] == 2 assert [p.name for p in config.WORK_DIR.iterdir()] == [".ignore"] def test_is_a_no_op_on_a_clean_state(self, conn, media_root): assert runner.recover(conn) == {"requeued": 0, "orphans": 0} def test_leaves_the_ignore_marker_in_place(self, conn, media_root): download.recover_orphans() assert (config.WORK_DIR / ".ignore").exists() class TestSummarise: def test_reports_each_stage(self): text = runner.summarise( { "poll": {"queued": 3, "repaired": 1, "failed": 0}, "download": {videos.DOWNLOADED: 2, videos.FAILED: 1}, "reap": {"deleted": 4, "evicted": 0}, } ) assert "discovered=3" in text assert "repaired=1" in text assert "downloaded=2" in text assert "failed=1" in text assert "reaped=4" in text def test_surfaces_a_download_error(self): text = runner.summarise({"download": {"error": "provider down"}}) assert "ERROR=provider down" in text def test_handles_empty_input(self): assert "downloaded=0" in runner.summarise({})