feat: reserve downloads and protect slot cleanup
This commit is contained in:
@@ -0,0 +1,395 @@
|
|||||||
|
"""Bounded, generation-safe media downloading and canonical slot cleanup."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import uuid
|
||||||
|
from concurrent.futures import Future, ThreadPoolExecutor
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from .config import ControllerConfig
|
||||||
|
from .contracts import SLOT_IDS
|
||||||
|
from .models import DeleteToken, DownloadReservation, SlotProtectedError, StaleOperationError
|
||||||
|
from .store import ControllerStore
|
||||||
|
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
MEDIA_EXTENSIONS = frozenset({".mp4", ".mkv", ".webm", ".mov", ".m4v"})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DownloadEvent:
|
||||||
|
slot: int
|
||||||
|
state: str
|
||||||
|
title: str | None = None
|
||||||
|
count: int | None = None
|
||||||
|
message: str | None = None
|
||||||
|
job_id: str | None = None
|
||||||
|
generation: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
EventCallback = Callable[[DownloadEvent], None]
|
||||||
|
Runner = Callable[..., subprocess.CompletedProcess[str]]
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_slot_directory(media_base: Path, slot: int) -> Path:
|
||||||
|
if slot not in SLOT_IDS:
|
||||||
|
raise ValueError(f"slot must be in 1..10, got {slot!r}")
|
||||||
|
return media_base / f"slot{slot}"
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_playlist_path(media_base: Path, slot: int) -> Path:
|
||||||
|
return canonical_slot_directory(media_base, slot) / "playlist.m3u"
|
||||||
|
|
||||||
|
|
||||||
|
def title_from_filename(path: Path) -> str:
|
||||||
|
stem = path.stem
|
||||||
|
if " - " in stem:
|
||||||
|
stem = stem.split(" - ", 1)[1]
|
||||||
|
if " [" in stem:
|
||||||
|
stem = stem.rsplit(" [", 1)[0]
|
||||||
|
return stem
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadManager:
|
||||||
|
"""Reserve synchronously, then perform work on a bounded executor."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: ControllerConfig,
|
||||||
|
store: ControllerStore,
|
||||||
|
*,
|
||||||
|
on_event: EventCallback | None = None,
|
||||||
|
runner: Runner = subprocess.run,
|
||||||
|
executor: ThreadPoolExecutor | None = None,
|
||||||
|
):
|
||||||
|
self.config = config
|
||||||
|
self.store = store
|
||||||
|
self.on_event = on_event or (lambda event: None)
|
||||||
|
self.runner = runner
|
||||||
|
self._executor = executor or ThreadPoolExecutor(
|
||||||
|
max_workers=config.downloads.max_workers,
|
||||||
|
thread_name_prefix="yt-download",
|
||||||
|
)
|
||||||
|
self._owns_executor = executor is None
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._futures: set[Future[Any]] = set()
|
||||||
|
self._stopping = False
|
||||||
|
|
||||||
|
def enqueue(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
submitter: str | None,
|
||||||
|
note: str | None,
|
||||||
|
) -> DownloadReservation:
|
||||||
|
with self._lock:
|
||||||
|
if self._stopping:
|
||||||
|
return DownloadReservation(status="shutting_down")
|
||||||
|
reservation = self.store.reserve_download(url, submitter, note)
|
||||||
|
if reservation.status != "reserved":
|
||||||
|
return reservation
|
||||||
|
assert reservation.slot is not None
|
||||||
|
assert reservation.job_id is not None
|
||||||
|
assert reservation.generation is not None
|
||||||
|
|
||||||
|
# Publish/record queued after the transaction commits and before a
|
||||||
|
# worker can publish downloading or ready.
|
||||||
|
self._emit(
|
||||||
|
DownloadEvent(
|
||||||
|
slot=reservation.slot,
|
||||||
|
state="queued",
|
||||||
|
job_id=reservation.job_id,
|
||||||
|
generation=reservation.generation,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
future = self._executor.submit(
|
||||||
|
self._download,
|
||||||
|
url,
|
||||||
|
reservation.slot,
|
||||||
|
reservation.generation,
|
||||||
|
reservation.job_id,
|
||||||
|
)
|
||||||
|
except BaseException as exc:
|
||||||
|
message = f"could not start download worker: {exc}"
|
||||||
|
self.store.fail_download(
|
||||||
|
reservation.job_id,
|
||||||
|
reservation.slot,
|
||||||
|
reservation.generation,
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
self._emit(
|
||||||
|
DownloadEvent(
|
||||||
|
slot=reservation.slot,
|
||||||
|
state="error",
|
||||||
|
message=message,
|
||||||
|
job_id=reservation.job_id,
|
||||||
|
generation=reservation.generation,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return DownloadReservation(
|
||||||
|
status="worker_error",
|
||||||
|
slot=reservation.slot,
|
||||||
|
generation=reservation.generation,
|
||||||
|
job_id=reservation.job_id,
|
||||||
|
reused_slot=reservation.reused_slot,
|
||||||
|
)
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._futures.add(future)
|
||||||
|
future.add_done_callback(self._forget_future)
|
||||||
|
return reservation
|
||||||
|
|
||||||
|
def _forget_future(self, future: Future[Any]) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._futures.discard(future)
|
||||||
|
try:
|
||||||
|
future.result()
|
||||||
|
except BaseException:
|
||||||
|
LOG.exception("download worker escaped its failure boundary")
|
||||||
|
|
||||||
|
def _emit(self, event: DownloadEvent) -> None:
|
||||||
|
try:
|
||||||
|
self.on_event(event)
|
||||||
|
except BaseException:
|
||||||
|
LOG.exception("download event callback failed")
|
||||||
|
|
||||||
|
def _cookie_arguments(self) -> list[str]:
|
||||||
|
path = self.config.downloads.cookie_path
|
||||||
|
if path is None:
|
||||||
|
return []
|
||||||
|
if path.is_file():
|
||||||
|
return ["--cookies", str(path)]
|
||||||
|
if self.config.downloads.cookie_required:
|
||||||
|
raise FileNotFoundError(f"required yt-dlp cookie file is missing: {path}")
|
||||||
|
LOG.warning("yt-dlp cookie file is unavailable; continuing without it: %s", path)
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _base_yt_dlp_arguments(self) -> list[str]:
|
||||||
|
return [self.config.downloads.yt_dlp_binary, "--ignore-config"] + self._cookie_arguments()
|
||||||
|
|
||||||
|
def _get_title(self, url: str) -> tuple[str, str | None]:
|
||||||
|
arguments = self._base_yt_dlp_arguments() + [
|
||||||
|
"--flat-playlist",
|
||||||
|
"--print",
|
||||||
|
"%(playlist_title)s|||%(title)s|||%(id)s",
|
||||||
|
"--playlist-end",
|
||||||
|
"1",
|
||||||
|
url,
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
result = self.runner(
|
||||||
|
arguments,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=min(120.0, self.config.downloads.process_timeout_seconds),
|
||||||
|
)
|
||||||
|
line = (result.stdout or "").strip().splitlines()[0]
|
||||||
|
playlist_title, _, remainder = line.partition("|||")
|
||||||
|
video_title, _, yt_id = remainder.partition("|||")
|
||||||
|
playlist_title = playlist_title.strip()
|
||||||
|
video_title = video_title.strip()
|
||||||
|
title = (
|
||||||
|
playlist_title
|
||||||
|
if playlist_title and playlist_title.upper() not in {"NA", "NONE"}
|
||||||
|
else video_title
|
||||||
|
)
|
||||||
|
return title or url, yt_id.strip() or None
|
||||||
|
except (IndexError, OSError, subprocess.SubprocessError, ValueError) as exc:
|
||||||
|
LOG.warning("could not determine media title for %s: %s", url, exc)
|
||||||
|
return url, None
|
||||||
|
|
||||||
|
def _download(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
slot: int,
|
||||||
|
generation: int,
|
||||||
|
job_id: str,
|
||||||
|
) -> None:
|
||||||
|
if not self.store.mark_download_running(job_id, slot, generation):
|
||||||
|
return
|
||||||
|
self._emit(
|
||||||
|
DownloadEvent(
|
||||||
|
slot=slot,
|
||||||
|
state="downloading",
|
||||||
|
job_id=job_id,
|
||||||
|
generation=generation,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
slot_directory = canonical_slot_directory(self.config.storage.media_base, slot)
|
||||||
|
temporary_playlist = slot_directory / f".playlist.{job_id}.tmp"
|
||||||
|
try:
|
||||||
|
slot_directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
if slot_directory.is_symlink():
|
||||||
|
raise RuntimeError(f"refusing to download through symlink: {slot_directory}")
|
||||||
|
title, yt_id = self._get_title(url)
|
||||||
|
arguments = self._base_yt_dlp_arguments() + [
|
||||||
|
"--yes-playlist",
|
||||||
|
"--no-overwrites",
|
||||||
|
"--no-progress",
|
||||||
|
"-o",
|
||||||
|
str(
|
||||||
|
slot_directory
|
||||||
|
/ "%(playlist_index)03d - %(title)s [%(id)s].%(ext)s"
|
||||||
|
),
|
||||||
|
"-f",
|
||||||
|
(
|
||||||
|
f"bv*[height<={self.config.downloads.max_height}]+ba/"
|
||||||
|
f"b[height<={self.config.downloads.max_height}]/b"
|
||||||
|
),
|
||||||
|
url,
|
||||||
|
]
|
||||||
|
self.runner(
|
||||||
|
arguments,
|
||||||
|
check=True,
|
||||||
|
text=True,
|
||||||
|
timeout=self.config.downloads.process_timeout_seconds,
|
||||||
|
)
|
||||||
|
media_files = sorted(
|
||||||
|
path
|
||||||
|
for path in slot_directory.iterdir()
|
||||||
|
if path.is_file() and path.suffix.lower() in MEDIA_EXTENSIONS
|
||||||
|
)
|
||||||
|
if not media_files:
|
||||||
|
raise RuntimeError("yt-dlp completed without producing media files")
|
||||||
|
|
||||||
|
items = [
|
||||||
|
(title_from_filename(path), os.path.relpath(path, slot_directory))
|
||||||
|
for path in media_files
|
||||||
|
]
|
||||||
|
with temporary_playlist.open("w", encoding="utf-8", newline="\n") as stream:
|
||||||
|
for _, relative_path in items:
|
||||||
|
stream.write(relative_path + "\n")
|
||||||
|
stream.flush()
|
||||||
|
os.fsync(stream.fileno())
|
||||||
|
os.replace(
|
||||||
|
temporary_playlist,
|
||||||
|
canonical_playlist_path(self.config.storage.media_base, slot),
|
||||||
|
)
|
||||||
|
self._fsync_directory(slot_directory)
|
||||||
|
|
||||||
|
if not self.store.complete_download(
|
||||||
|
job_id,
|
||||||
|
slot,
|
||||||
|
generation,
|
||||||
|
title=title,
|
||||||
|
items=items,
|
||||||
|
yt_id=yt_id,
|
||||||
|
):
|
||||||
|
LOG.warning(
|
||||||
|
"ignored stale download completion job=%s slot=%s generation=%s",
|
||||||
|
job_id,
|
||||||
|
slot,
|
||||||
|
generation,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
self._emit(
|
||||||
|
DownloadEvent(
|
||||||
|
slot=slot,
|
||||||
|
state="ready",
|
||||||
|
title=title,
|
||||||
|
count=len(items),
|
||||||
|
job_id=job_id,
|
||||||
|
generation=generation,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except BaseException as exc:
|
||||||
|
temporary_playlist.unlink(missing_ok=True)
|
||||||
|
message = self._failure_message(exc)
|
||||||
|
self.store.fail_download(job_id, slot, generation, message)
|
||||||
|
self._emit(
|
||||||
|
DownloadEvent(
|
||||||
|
slot=slot,
|
||||||
|
state="error",
|
||||||
|
message=message,
|
||||||
|
job_id=job_id,
|
||||||
|
generation=generation,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _failure_message(exc: BaseException) -> str:
|
||||||
|
if isinstance(exc, subprocess.TimeoutExpired):
|
||||||
|
return f"yt-dlp timed out after {exc.timeout} seconds"
|
||||||
|
if isinstance(exc, subprocess.CalledProcessError):
|
||||||
|
return f"yt-dlp failed with exit code {exc.returncode}"
|
||||||
|
return f"download failed: {exc}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _fsync_directory(path: Path) -> None:
|
||||||
|
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||||
|
try:
|
||||||
|
os.fsync(descriptor)
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
|
|
||||||
|
def delete_slot(
|
||||||
|
self,
|
||||||
|
slot: int,
|
||||||
|
*,
|
||||||
|
reason: str,
|
||||||
|
expected_url: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
token = self.store.begin_delete(slot, reason=reason, expected_url=expected_url)
|
||||||
|
return self.finish_deletion(token)
|
||||||
|
|
||||||
|
def finish_deletion(self, token: DeleteToken) -> bool:
|
||||||
|
slot_directory = canonical_slot_directory(self.config.storage.media_base, token.slot)
|
||||||
|
trash_root = self.config.storage.media_base / ".controller-trash"
|
||||||
|
trash_path: Path | None = None
|
||||||
|
if os.path.lexists(slot_directory):
|
||||||
|
if slot_directory.is_symlink():
|
||||||
|
slot_directory.unlink()
|
||||||
|
else:
|
||||||
|
trash_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
trash_path = trash_root / (
|
||||||
|
f"slot{token.slot}.generation{token.generation}.{uuid.uuid4().hex}"
|
||||||
|
)
|
||||||
|
os.replace(slot_directory, trash_path)
|
||||||
|
self._fsync_directory(self.config.storage.media_base)
|
||||||
|
if trash_path is not None:
|
||||||
|
shutil.rmtree(trash_path)
|
||||||
|
return self.store.finish_delete(token)
|
||||||
|
|
||||||
|
def expire_due_slots(self) -> list[int]:
|
||||||
|
deleted: list[int] = []
|
||||||
|
for token in self.store.begin_expired_deletions():
|
||||||
|
try:
|
||||||
|
if self.finish_deletion(token):
|
||||||
|
deleted.append(token.slot)
|
||||||
|
except OSError:
|
||||||
|
LOG.exception("could not finish expired deletion for slot %s", token.slot)
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
def recover_pending_deletions(self) -> list[int]:
|
||||||
|
recovered: list[int] = []
|
||||||
|
trash_root = self.config.storage.media_base / ".controller-trash"
|
||||||
|
for token in self.store.pending_deletions():
|
||||||
|
if trash_root.is_dir():
|
||||||
|
prefix = f"slot{token.slot}.generation{token.generation}."
|
||||||
|
for candidate in trash_root.iterdir():
|
||||||
|
if candidate.name.startswith(prefix):
|
||||||
|
if candidate.is_symlink() or candidate.is_file():
|
||||||
|
candidate.unlink()
|
||||||
|
elif candidate.is_dir():
|
||||||
|
shutil.rmtree(candidate)
|
||||||
|
if self.finish_deletion(token):
|
||||||
|
recovered.append(token.slot)
|
||||||
|
return recovered
|
||||||
|
|
||||||
|
def shutdown(self, *, wait: bool = True) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._stopping = True
|
||||||
|
if self._owns_executor:
|
||||||
|
self._executor.shutdown(wait=wait, cancel_futures=True)
|
||||||
|
|
||||||
|
def active_future_count(self) -> int:
|
||||||
|
with self._lock:
|
||||||
|
return len(self._futures)
|
||||||
@@ -1195,6 +1195,23 @@ class ControllerStore:
|
|||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def pending_deletions(self) -> list[DeleteToken]:
|
||||||
|
with self._lock:
|
||||||
|
rows = self.connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT slot_number, generation FROM slots
|
||||||
|
WHERE media_state = 'deleting' ORDER BY slot_number
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
DeleteToken(
|
||||||
|
slot=row["slot_number"],
|
||||||
|
generation=row["generation"],
|
||||||
|
reason="recovered_interrupted_deletion",
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
def begin_expired_deletions(self, now: float | None = None) -> list[DeleteToken]:
|
def begin_expired_deletions(self, now: float | None = None) -> list[DeleteToken]:
|
||||||
now = self._now() if now is None else now
|
now = self._now() if now is None else now
|
||||||
tokens: list[DeleteToken] = []
|
tokens: list[DeleteToken] = []
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user