import os import subprocess import tempfile import threading import time import unittest from concurrent.futures import Future from pathlib import Path from controller.config import ControllerConfig, DownloadConfig, StorageConfig from controller.downloads import DownloadManager, canonical_playlist_path from controller.models import SlotProtectedError from controller.store import ControllerStore class ImmediateExecutor: def submit(self, function, *args, **kwargs): future = Future() try: future.set_result(function(*args, **kwargs)) except BaseException as exc: future.set_exception(exc) return future class FakeYtDlp: def __init__(self, media_base: Path, *, fail_download=False, no_media=False): self.media_base = media_base self.fail_download = fail_download self.no_media = no_media self.calls = [] def __call__(self, arguments, **kwargs): self.calls.append((list(arguments), dict(kwargs))) if "--flat-playlist" in arguments: return subprocess.CompletedProcess( arguments, 0, stdout="Test Playlist|||First Video|||playlist-id\n", stderr="", ) if self.fail_download: raise subprocess.CalledProcessError(7, arguments) if not self.no_media: output_index = arguments.index("-o") + 1 output_template = Path(arguments[output_index]) directory = output_template.parent directory.mkdir(parents=True, exist_ok=True) (directory / "001 - First [one].mp4").write_bytes(b"video-one") (directory / "002 - Second [two].webm").write_bytes(b"video-two") return subprocess.CompletedProcess(arguments, 0, stdout="", stderr="") class DownloadManagerTests(unittest.TestCase): def setUp(self): self.tempdir = tempfile.TemporaryDirectory() base = Path(self.tempdir.name) self.media = base / "media" self.data = base / "data" self.cookie = base / "cookies.txt" self.cookie.write_text("cookie data", encoding="utf-8") self.config = ControllerConfig( storage=StorageConfig( media_base=self.media, data_base=self.data, retention_seconds=100, ), downloads=DownloadConfig( yt_dlp_binary="yt-dlp-test", cookie_path=self.cookie, cookie_required=True, max_workers=1, process_timeout_seconds=30, max_height=720, ), ) self.store = ControllerStore(self.config.storage).open() def tearDown(self): self.store.close() self.tempdir.cleanup() def manager(self, runner, events=None): event_list = events if events is not None else [] return DownloadManager( self.config, self.store, runner=runner, executor=ImmediateExecutor(), on_event=event_list.append, ) def test_success_uses_configured_cookie_and_atomically_builds_playlist(self): events = [] runner = FakeYtDlp(self.media) manager = self.manager(runner, events) result = manager.enqueue("https://example.test/playlist", "Alice", "Service") self.assertEqual(result.status, "reserved") self.assertEqual([event.state for event in events], ["queued", "downloading", "ready"]) slot = self.store.get_slot(1) self.assertEqual(slot.media_state, "ready") self.assertEqual(slot.title, "Test Playlist") self.assertEqual(slot.item_count, 2) playlist = canonical_playlist_path(self.media, 1) self.assertEqual( playlist.read_text(encoding="utf-8"), "001 - First [one].mp4\n002 - Second [two].webm\n", ) self.assertEqual(list(playlist.parent.glob(".playlist.*.tmp")), []) for arguments, _ in runner.calls: self.assertIn("--ignore-config", arguments) self.assertIn("--cookies", arguments) self.assertIn(str(self.cookie), arguments) download_arguments = runner.calls[-1][0] self.assertIn("bv*[height<=720]+ba/b[height<=720]/b", download_arguments) def test_worker_failure_is_contained_and_persisted(self): events = [] manager = self.manager(FakeYtDlp(self.media, fail_download=True), events) result = manager.enqueue("https://example.test/failure", None, None) self.assertEqual(result.status, "reserved") slot = self.store.get_slot(1) self.assertEqual(slot.media_state, "error") self.assertIn("exit code 7", slot.last_error) self.assertEqual(events[-1].state, "error") def test_missing_required_cookie_fails_job_without_escaping(self): self.cookie.unlink() events = [] runner = FakeYtDlp(self.media) manager = self.manager(runner, events) manager.enqueue("https://example.test/no-cookie", None, None) self.assertEqual(self.store.get_slot(1).media_state, "error") self.assertIn("required yt-dlp cookie", self.store.get_slot(1).last_error) self.assertEqual(runner.calls, []) def test_delete_moves_only_canonical_slot_and_keeps_other_identity(self): first = self.store.reserve_download("https://example.test/one", None, None, job_id="one") second = self.store.reserve_download("https://example.test/two", None, None, job_id="two") self.store.fail_download("one", 1, first.generation, "failed") self.store.fail_download("two", 2, second.generation, "failed") slot_one = self.media / "slot1" slot_two = self.media / "slot2" slot_one.mkdir(parents=True) slot_two.mkdir(parents=True) (slot_one / "one.mp4").write_bytes(b"one") (slot_two / "two.mp4").write_bytes(b"two") manager = self.manager(FakeYtDlp(self.media)) self.assertTrue(manager.delete_slot(1, reason="manual")) self.assertFalse(slot_one.exists()) self.assertTrue((slot_two / "two.mp4").exists()) self.assertEqual(self.store.get_slot(1).media_state, "empty") self.assertEqual(self.store.get_slot(2).url, "https://example.test/two") def test_delete_refuses_live_download_and_playback_guard(self): reservation = self.store.reserve_download( "https://example.test/live", None, None, job_id="live" ) manager = self.manager(FakeYtDlp(self.media)) with self.assertRaises(SlotProtectedError): manager.delete_slot(1, reason="manual") self.store.fail_download("live", 1, reservation.generation, "cancelled") self.store.protect_slot(1, "player_active", guard_id="active") with self.assertRaises(SlotProtectedError): manager.delete_slot(1, reason="manual") def test_symlink_slot_is_unlinked_without_touching_target(self): reservation = self.store.reserve_download( "https://example.test/link", None, None, job_id="link" ) self.store.fail_download("link", 1, reservation.generation, "failed") target = Path(self.tempdir.name) / "outside" target.mkdir() protected = target / "keep.txt" protected.write_text("keep", encoding="utf-8") self.media.mkdir(parents=True) os.symlink(target, self.media / "slot1") manager = self.manager(FakeYtDlp(self.media)) self.assertTrue(manager.delete_slot(1, reason="manual")) self.assertEqual(protected.read_text(encoding="utf-8"), "keep") def test_interrupted_deletion_is_recoverable(self): reservation = self.store.reserve_download( "https://example.test/recover", None, None, job_id="recover" ) self.store.fail_download("recover", 1, reservation.generation, "failed") token = self.store.begin_delete(1, reason="manual") slot = self.media / "slot1" slot.mkdir(parents=True) (slot / "partial.mp4").write_bytes(b"partial") self.assertEqual(self.store.pending_deletions(), [ type(token)(slot=1, generation=token.generation, reason="recovered_interrupted_deletion") ]) manager = self.manager(FakeYtDlp(self.media)) self.assertEqual(manager.recover_pending_deletions(), [1]) self.assertFalse(slot.exists()) self.assertEqual(self.store.get_slot(1).media_state, "empty") def test_shutdown_terminates_tracked_process_without_waiting_for_job_timeout(self): manager = DownloadManager(self.config, self.store) errors = [] def run_long_process(): try: manager._run_command( ["/bin/sh", "-c", "sleep 60"], check=True, text=True, timeout=60, capture_output=True, ) except BaseException as exc: errors.append(exc) worker = threading.Thread(target=run_long_process) worker.start() deadline = time.monotonic() + 2 while time.monotonic() < deadline: with manager._lock: if manager._processes: break time.sleep(0.01) else: self.fail("test process did not start") started = time.monotonic() manager.shutdown(wait=False) worker.join(timeout=3) self.assertFalse(worker.is_alive()) self.assertLess(time.monotonic() - started, 3) self.assertTrue(errors) with manager._lock: self.assertEqual(manager._processes, set()) if __name__ == "__main__": unittest.main()