238 lines
9.6 KiB
Python
238 lines
9.6 KiB
Python
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()
|