diff --git a/API.md b/API.md index ea1f1002..20514c2f 100644 --- a/API.md +++ b/API.md @@ -348,7 +348,10 @@ or an error: "cookies": "...", // -- optional. If provided, it MUST BE in Netscape HTTP Cookie format. "template": "%(title)s.%(ext)s", // -- optional. The filename template to use for this item. "cli": "--write-subs --embed-subs", // -- optional. Additional command options for yt-dlp to apply to this item. - "auto_start": true // -- optional. Whether to auto-start the download after adding it. Defaults to true. + "auto_start": true, // -- optional. Whether to auto-start the download after adding it. Defaults to true. + "extras": { + "ignore_conditions": ["123", "My Condition"] // -- optional. Skip matching conditions by id string or exact name for this request only. Use ["*"] to ignore all conditions. + } } // Or multiple items (array of objects) diff --git a/app/conftest.py b/app/conftest.py new file mode 100644 index 00000000..e75a390b --- /dev/null +++ b/app/conftest.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import os +import tempfile + +from app.tests.helpers import cleanup_test_run_root, get_test_run_root, get_test_system_temp_root + + +def pytest_configure(config) -> None: + temp_root = get_test_system_temp_root() + for env_name in ("TMPDIR", "TEMP", "TMP"): + os.environ[env_name] = str(temp_root) + + tempfile.tempdir = None + + if getattr(config.option, "basetemp", None) is None: + config.option.basetemp = str(get_test_run_root() / "pytest") + + +def pytest_unconfigure(config) -> None: + del config + cleanup_test_run_root() diff --git a/app/features/conditions/service.py b/app/features/conditions/service.py index d288e8bd..de8bdf7b 100644 --- a/app/features/conditions/service.py +++ b/app/features/conditions/service.py @@ -1,4 +1,6 @@ import logging +from collections.abc import Iterable +from numbers import Number from aiohttp import web @@ -11,6 +13,30 @@ from app.library.Singleton import Singleton LOG: logging.Logger = logging.getLogger("feature.conditions") +def _ignored_identifiers(ignore_conditions: Iterable[str | Number] | None) -> tuple[set[str], bool]: + ignored: set[str] = set() + ignore_all = False + + if not ignore_conditions: + return ignored, ignore_all + + for value in ignore_conditions: + if isinstance(value, bool) or not isinstance(value, (str, Number)): + continue + + identifier = str(value).strip() + if not identifier: + continue + + if "*" == identifier: + ignore_all = True + continue + + ignored.add(identifier) + + return ignored, ignore_all + + class Conditions(metaclass=Singleton): def __init__(self): self._repo: ConditionsRepository = ConditionsRepository.get_instance() @@ -87,12 +113,13 @@ class Conditions(metaclass=Singleton): repo = self._repo return await repo.get(identifier) - async def match(self, info: dict) -> ConditionModel | None: + async def match(self, info: dict, ignore_conditions: Iterable[str | Number] | None = None) -> ConditionModel | None: """ Check if any condition matches the info dict. Args: info (dict): The info dict to check. + ignore_conditions (Iterable[str | Number] | None): Condition ids or names to skip for this match. Returns: Condition|None: The condition if found, None otherwise. @@ -101,6 +128,10 @@ class Conditions(metaclass=Singleton): if not info or not isinstance(info, dict) or len(info) < 1: return None + ignored_identifiers, ignore_all = _ignored_identifiers(ignore_conditions) + if ignore_all: + return None + repo = self._repo items: list[ConditionModel] = await repo.list() if len(items) < 1: @@ -110,6 +141,9 @@ class Conditions(metaclass=Singleton): if not item.enabled: continue + if str(item.id) in ignored_identifiers or item.name in ignored_identifiers: + continue + if not item.filter: LOG.error(f"Filter is empty for '{item.name}'.") continue diff --git a/app/features/conditions/tests/test_conditions.py b/app/features/conditions/tests/test_conditions.py index 050f8aa6..f72ec622 100644 --- a/app/features/conditions/tests/test_conditions.py +++ b/app/features/conditions/tests/test_conditions.py @@ -14,6 +14,7 @@ from app.library.config import Config from app.library.encoder import Encoder from app.features.conditions.repository import ConditionsRepository from app.library.sqlite_store import SqliteStore +from app.tests.helpers import make_in_memory_db_path @pytest_asyncio.fixture @@ -21,7 +22,7 @@ async def repo(): ConditionsRepository._reset_singleton() SqliteStore._reset_singleton() - store = SqliteStore(db_path=":memory:") + store = SqliteStore(db_path=make_in_memory_db_path("conditions-repository")) await store.get_connection() # Create repository @@ -30,10 +31,7 @@ async def repo(): yield repository # Cleanup - close connections properly - if store._conn: - await store._conn.close() - if store._engine: - await store._engine.dispose() + await store.close() # Reset singletons ConditionsRepository._reset_singleton() diff --git a/app/features/dl_fields/tests/test_dl_fields.py b/app/features/dl_fields/tests/test_dl_fields.py index 9f7c408e..381bee54 100644 --- a/app/features/dl_fields/tests/test_dl_fields.py +++ b/app/features/dl_fields/tests/test_dl_fields.py @@ -8,26 +8,24 @@ import pytest_asyncio from app.features.dl_fields.repository import DLFieldsRepository from app.features.dl_fields.service import DLFields from app.library.sqlite_store import SqliteStore +from app.tests.helpers import make_in_memory_db_path @pytest_asyncio.fixture -async def repo(tmp_path): +async def repo(): """Provide a fresh repository instance with initialized database for each test.""" DLFieldsRepository._reset_singleton() DLFields._reset_singleton() SqliteStore._reset_singleton() - store = SqliteStore(db_path=":memory:") + store = SqliteStore(db_path=make_in_memory_db_path("dl-fields-service")) await store.get_connection() repository = DLFieldsRepository.get_instance() yield repository - if store._conn: - await store._conn.close() - if store._engine: - await store._engine.dispose() + await store.close() DLFieldsRepository._reset_singleton() DLFields._reset_singleton() diff --git a/app/features/dl_fields/tests/test_dl_fields_repository.py b/app/features/dl_fields/tests/test_dl_fields_repository.py index 4a08ba23..7b2de6aa 100644 --- a/app/features/dl_fields/tests/test_dl_fields_repository.py +++ b/app/features/dl_fields/tests/test_dl_fields_repository.py @@ -7,25 +7,23 @@ import pytest_asyncio from app.features.dl_fields.repository import DLFieldsRepository from app.library.sqlite_store import SqliteStore +from app.tests.helpers import make_in_memory_db_path @pytest_asyncio.fixture -async def repo(tmp_path): +async def repo(): """Provide a fresh repository instance with initialized database for each test.""" DLFieldsRepository._reset_singleton() SqliteStore._reset_singleton() - store = SqliteStore(db_path=str(":memory:")) + store = SqliteStore(db_path=make_in_memory_db_path("dl-fields-repository")) await store.get_connection() repository = DLFieldsRepository.get_instance() yield repository - if store._conn: - await store._conn.close() - if store._engine: - await store._engine.dispose() + await store.close() DLFieldsRepository._reset_singleton() SqliteStore._reset_singleton() diff --git a/app/features/notifications/tests/test_notifications_repository.py b/app/features/notifications/tests/test_notifications_repository.py index 51ef1260..0940ed4e 100644 --- a/app/features/notifications/tests/test_notifications_repository.py +++ b/app/features/notifications/tests/test_notifications_repository.py @@ -7,25 +7,23 @@ import pytest_asyncio from app.features.notifications.repository import NotificationsRepository from app.library.sqlite_store import SqliteStore +from app.tests.helpers import make_in_memory_db_path @pytest_asyncio.fixture -async def repo(tmp_path): +async def repo(): """Provide a fresh repository instance with initialized database for each test.""" NotificationsRepository._reset_singleton() SqliteStore._reset_singleton() - store = SqliteStore(db_path=":memory:") + store = SqliteStore(db_path=make_in_memory_db_path("notifications-repository")) await store.get_connection() repository = NotificationsRepository.get_instance() yield repository - if store._conn: - await store._conn.close() - if store._engine: - await store._engine.dispose() + await store.close() NotificationsRepository._reset_singleton() SqliteStore._reset_singleton() diff --git a/app/features/presets/tests/test_presets_repository.py b/app/features/presets/tests/test_presets_repository.py index 656ac446..1dfe6746 100644 --- a/app/features/presets/tests/test_presets_repository.py +++ b/app/features/presets/tests/test_presets_repository.py @@ -12,6 +12,7 @@ from app.features.presets.repository import PresetsRepository from app.features.presets.router import presets_list from app.library.encoder import Encoder from app.library.sqlite_store import SqliteStore +from app.tests.helpers import make_in_memory_db_path @pytest_asyncio.fixture @@ -19,16 +20,13 @@ async def repo(): PresetsRepository._reset_singleton() SqliteStore._reset_singleton() - store = SqliteStore(db_path=":memory:") + store = SqliteStore(db_path=make_in_memory_db_path("presets-repository")) await store.get_connection() repository = PresetsRepository.get_instance() yield repository - if store._conn: - await store._conn.close() - if store._engine: - await store._engine.dispose() + await store.close() PresetsRepository._reset_singleton() SqliteStore._reset_singleton() diff --git a/app/features/streaming/library/segments.py b/app/features/streaming/library/segments.py index 5789ed01..11d71f66 100644 --- a/app/features/streaming/library/segments.py +++ b/app/features/streaming/library/segments.py @@ -1,11 +1,11 @@ import asyncio -import hashlib import logging import os import subprocess # type: ignore import sys import tempfile from pathlib import Path +from secrets import token_hex from typing import TYPE_CHECKING, ClassVar from aiohttp import web @@ -68,21 +68,21 @@ class Segments: self.attempted: set[str] = set() "The set of attempted codecs." - async def build_ffmpeg_args(self, file: Path, s_codec: str) -> list[str]: + def _make_stream_input(self, file: Path) -> Path: + while True: + stream_input = Path(tempfile.gettempdir()).joinpath(f"ytptube_stream.{token_hex(8)}") + try: + stream_input.symlink_to(file, target_is_directory=False) + return stream_input + except FileExistsError: + continue + + async def build_ffmpeg_args(self, file: Path, s_codec: str, *, stream_input: Path | None = None) -> list[str]: try: ff: FFProbeResult = await ffprobe(file) except UnicodeDecodeError: pass - - tmpFile: Path = Path(tempfile.gettempdir()).joinpath( - f"ytptube_stream.{hashlib.sha256(str(file).encode()).hexdigest()}" - ) - - if not tmpFile.exists(): - try: - tmpFile.symlink_to(file, target_is_directory=False) - except FileExistsError: - pass + input_path = stream_input or file startTime: str = f"{0:.6f}" if self.index == 0 else f"{self.duration * self.index:.6f}" @@ -123,7 +123,7 @@ class Segments: # hardware/global input options must come before -i *input_args, "-i", - f"file:{tmpFile}", + f"file:{input_path}", "-map_metadata", "-1", ] @@ -240,22 +240,27 @@ class Segments: else: codecs: list[str] = [codec, *list(encoder_fallback_chain(codec))] - for s_codec in codecs: - if s_codec in self.attempted: - continue + stream_input = self._make_stream_input(file) + try: + for s_codec in codecs: + if s_codec in self.attempted: + continue - ffmpeg_args: list[str] = await self.build_ffmpeg_args(file, s_codec) - _, rc, client_disconnected, stderr_text = await self._run(resp, file, ffmpeg_args) + ffmpeg_args: list[str] = await self.build_ffmpeg_args(file, s_codec, stream_input=stream_input) + _, rc, client_disconnected, stderr_text = await self._run(resp, file, ffmpeg_args) - if 0 == rc: - Segments._cached_vcodec = s_codec - Segments._cache_initialized = True - return + if 0 == rc: + Segments._cached_vcodec = s_codec + Segments._cache_initialized = True + return - if client_disconnected: - return + if client_disconnected: + return - if 0 != rc: - err: str = stderr_text[:500] if stderr_text else "no error output" - LOG.warning(f"transcoding has failed (cmd={ffmpeg_args}) (rc={rc}): {err}. Trying fallbacks.") - self.attempted.add(s_codec) + if 0 != rc: + err: str = stderr_text[:500] if stderr_text else "no error output" + LOG.warning(f"transcoding has failed (cmd={ffmpeg_args}) (rc={rc}): {err}. Trying fallbacks.") + self.attempted.add(s_codec) + finally: + if stream_input.is_symlink(): + stream_input.unlink() diff --git a/app/features/streaming/tests/test_ffprobe.py b/app/features/streaming/tests/test_ffprobe.py index 1ef7c549..621ac12b 100644 --- a/app/features/streaming/tests/test_ffprobe.py +++ b/app/features/streaming/tests/test_ffprobe.py @@ -1,16 +1,17 @@ -import tempfile from pathlib import Path from unittest.mock import AsyncMock, patch import pytest +from app.tests.helpers import make_test_temp_dir + class TestFFProbe: """Test the ffprobe module functionality.""" def setup_method(self): """Set up test files.""" - self.temp_dir = tempfile.mkdtemp() + self.temp_dir = str(make_test_temp_dir("ffprobe")) self.test_file = Path(self.temp_dir) / "test_video.mp4" self.test_file.touch() diff --git a/app/features/streaming/tests/test_segments.py b/app/features/streaming/tests/test_segments.py index c2bb160e..57c30e5a 100644 --- a/app/features/streaming/tests/test_segments.py +++ b/app/features/streaming/tests/test_segments.py @@ -1,13 +1,12 @@ import asyncio -import hashlib import logging -import tempfile from pathlib import Path from typing import Any import pytest from app.features.streaming.library.segments import Segments +from app.tests.helpers import get_test_system_temp_root class DummyFF: @@ -22,6 +21,10 @@ class DummyFF: return self._a +def _ffmpeg_input_path(args: list[str]) -> Path: + return Path(args[args.index("-i") + 1].removeprefix("file:")) + + @pytest.mark.asyncio async def test_build_ffmpeg_args_video_and_audio(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: # Create a dummy media file @@ -53,8 +56,7 @@ async def test_build_ffmpeg_args_video_and_audio(tmp_path: Path, monkeypatch: py assert captured_args, "ffmpeg was not invoked" args = captured_args[0] - # Compute expected symlink path used by Segments - tmpFile = Path(tempfile.gettempdir()).joinpath(f"ytptube_stream.{hashlib.sha256(str(media).encode()).hexdigest()}") + tmp_file = _ffmpeg_input_path(args) # Start time is duration * index with 6 decimals for non-zero index assert "-ss" in args @@ -64,7 +66,9 @@ async def test_build_ffmpeg_args_video_and_audio(tmp_path: Path, monkeypatch: py assert args[args.index("-t") + 1] == f"{5.5:.6f}" # Input uses file: assert "-i" in args - assert args[args.index("-i") + 1] == f"file:{tmpFile}" + assert tmp_file.parent == get_test_system_temp_root() + assert tmp_file.name.startswith("ytptube_stream.") + assert not tmp_file.exists() # Includes video and audio mapping and codecs assert "-map" in args assert "0:v:0" in args @@ -338,6 +342,9 @@ async def test_stream_gpu_fallback_switches_codec(monkeypatch: pytest.MonkeyPatc @pytest.mark.asyncio async def test_stream_normal_flow(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + media = tmp_path / "file.mp4" + media.write_bytes(b"data") + async def fake_ffprobe(_file: Path): return DummyFF(v=True, a=True) @@ -346,16 +353,21 @@ async def test_stream_normal_flow(monkeypatch: pytest.MonkeyPatch, tmp_path: Pat # Process that yields two chunks and then EOF proc = _FakeProc([b"abc", b"def", b""]) - async def fake_create_subprocess_exec(*_args: Any, **_kwargs: Any): + captured_args: list[list[str]] = [] + + async def fake_create_subprocess_exec(*args: Any, **_kwargs: Any): + captured_args.append(list(args[1:])) return proc monkeypatch.setattr("asyncio.create_subprocess_exec", fake_create_subprocess_exec) seg = Segments(download_path=str(tmp_path), index=0, duration=1.0, vconvert=True, aconvert=True) resp = _FakeResp() - await seg.stream(tmp_path / "file.mp4", resp) + await seg.stream(media, resp) assert bytes(resp.data) == b"abcdef" + assert not _ffmpeg_input_path(captured_args[0]).exists() + assert media.exists() # EOF behavior may differ; don't require True @@ -368,7 +380,10 @@ async def test_stream_client_reset(monkeypatch: pytest.MonkeyPatch, tmp_path: Pa proc = _FakeProc([b"abc", b"def"]) # will attempt to write and fail - async def fake_create_subprocess_exec(*_args: Any, **_kwargs: Any): + captured_args: list[list[str]] = [] + + async def fake_create_subprocess_exec(*args: Any, **_kwargs: Any): + captured_args.append(list(args[1:])) return proc monkeypatch.setattr("asyncio.create_subprocess_exec", fake_create_subprocess_exec) @@ -380,6 +395,7 @@ async def test_stream_client_reset(monkeypatch: pytest.MonkeyPatch, tmp_path: Pa # Should not write EOF due to client disconnect assert resp.eof is False + assert not _ffmpeg_input_path(captured_args[0]).exists() @pytest.mark.asyncio diff --git a/app/features/tasks/definitions/tests/test_task_definitions.py b/app/features/tasks/definitions/tests/test_task_definitions.py index 388bd3b4..5a3d23ff 100644 --- a/app/features/tasks/definitions/tests/test_task_definitions.py +++ b/app/features/tasks/definitions/tests/test_task_definitions.py @@ -21,6 +21,7 @@ from app.features.tasks.definitions.router import ( from app.library.encoder import Encoder from app.library.sqlite_store import SqliteStore from app.main import EventBus +from app.tests.helpers import make_in_memory_db_path def _sample_definition(name: str = "example", *, priority: int = 0) -> dict: @@ -49,17 +50,14 @@ async def repo() -> AsyncGenerator[TaskDefinitionsRepository, None]: TaskDefinitionsRepository._reset_singleton() SqliteStore._reset_singleton() - store = SqliteStore(db_path=":memory:") + store = SqliteStore(db_path=make_in_memory_db_path("task-definitions-repository")) await store.get_connection() repository = TaskDefinitionsRepository.get_instance() yield repository - if store._conn: - await store._conn.close() - if store._engine: - await store._engine.dispose() + await store.close() TaskDefinitionsRepository._reset_singleton() SqliteStore._reset_singleton() diff --git a/app/features/tasks/tests/test_tasks_repository.py b/app/features/tasks/tests/test_tasks_repository.py index d92e078e..221cd66e 100644 --- a/app/features/tasks/tests/test_tasks_repository.py +++ b/app/features/tasks/tests/test_tasks_repository.py @@ -7,25 +7,23 @@ import pytest_asyncio from app.features.tasks.repository import TasksRepository from app.library.sqlite_store import SqliteStore +from app.tests.helpers import make_in_memory_db_path @pytest_asyncio.fixture -async def repo(tmp_path): +async def repo(): """Provide a fresh repository instance with initialized database for each test.""" TasksRepository._reset_singleton() SqliteStore._reset_singleton() - store = SqliteStore(db_path=str(":memory:")) + store = SqliteStore(db_path=make_in_memory_db_path("tasks-repository")) await store.get_connection() repository = TasksRepository.get_instance() yield repository - if store._conn: - await store._conn.close() - if store._engine: - await store._engine.dispose() + await store.close() TasksRepository._reset_singleton() SqliteStore._reset_singleton() diff --git a/app/features/ytdlp/tests/test_ytdlp_utils.py b/app/features/ytdlp/tests/test_ytdlp_utils.py index 476f1b2b..863fa7ee 100644 --- a/app/features/ytdlp/tests/test_ytdlp_utils.py +++ b/app/features/ytdlp/tests/test_ytdlp_utils.py @@ -1,7 +1,6 @@ import logging from pathlib import Path import re -import tempfile from typing import Any from unittest.mock import MagicMock, patch @@ -20,6 +19,7 @@ from app.features.ytdlp.utils import ( archive_add, archive_read, ) +from app.tests.helpers import make_test_temp_dir class CaptureHandler(logging.Handler): @@ -549,7 +549,7 @@ class TestArchiveFunctions: def setup_method(self): """Set up test archive file.""" - self.temp_dir = tempfile.mkdtemp() + self.temp_dir = str(make_test_temp_dir("ytdlp-archive")) self.archive_file = Path(self.temp_dir) / "archive.txt" def teardown_method(self): diff --git a/app/library/downloads/item_adder.py b/app/library/downloads/item_adder.py index 7ebdb967..103cda7d 100644 --- a/app/library/downloads/item_adder.py +++ b/app/library/downloads/item_adder.py @@ -2,6 +2,7 @@ import asyncio import logging import time import uuid +from numbers import Number from pathlib import Path from typing import TYPE_CHECKING @@ -28,6 +29,28 @@ if TYPE_CHECKING: LOG: logging.Logger = logging.getLogger("downloads.add") +def _get_ignored_conditions(extras: dict | None) -> list[str]: + if not extras or not isinstance(extras, dict): + return [] + + ignore_conditions = extras.get("ignore_conditions") + if not isinstance(ignore_conditions, list): + return [] + + ignored: list[str] = [] + for value in ignore_conditions: + if isinstance(value, bool) or not isinstance(value, (str, Number)): + continue + + identifier = str(value).strip() + if not identifier: + continue + + ignored.append(identifier) + + return ignored + + async def add_item( queue: "DownloadQueue", entry: dict, @@ -253,7 +276,11 @@ async def add( ) return {"status": "error", "msg": message, "hidden": True} - if not item.requeued and (condition := await Conditions.get_instance().match(info=entry)): + ignored_conditions = _get_ignored_conditions(item.extras) + + if not item.requeued and ( + condition := await Conditions.get_instance().match(info=entry, ignore_conditions=ignored_conditions) + ): already.pop() message = f"Condition '{condition.name}' matched for '{item!r}'." diff --git a/app/library/downloads/playlist_processor.py b/app/library/downloads/playlist_processor.py index 62166d10..cde700bf 100644 --- a/app/library/downloads/playlist_processor.py +++ b/app/library/downloads/playlist_processor.py @@ -83,6 +83,10 @@ async def process_playlist( if "thumbnail" not in etr and "youtube" in str(extractor_key).lower(): extras["thumbnail"] = "https://img.youtube.com/vi/{id}/maxresdefault.jpg".format(**etr) + ignore_conditions = item.extras.get("ignore_conditions") if isinstance(item.extras, dict) else None + if isinstance(ignore_conditions, list) and ignore_conditions: + extras["ignore_conditions"] = ignore_conditions + newItem: Item = item.new_with(url=etr.get("url") or etr.get("webpage_url"), extras=extras) if ("video" == etr.get("_type") and etr.get("url")) or ( diff --git a/app/tests/helpers.py b/app/tests/helpers.py new file mode 100644 index 00000000..b01f8c21 --- /dev/null +++ b/app/tests/helpers.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import atexit +import contextlib +import shutil +from pathlib import Path +from tempfile import gettempdir +from typing import TYPE_CHECKING +from uuid import uuid4 + +if TYPE_CHECKING: + from collections.abc import Iterator + +_TEST_RUN_ROOT = Path(gettempdir()) / "ytptube-tests" / uuid4().hex +_TEST_SYSTEM_TEMP_ROOT = _TEST_RUN_ROOT / "tmp" + + +def _slugify(name: str) -> str: + return "".join(char if char.isalnum() else "-" for char in name).strip("-") or "tmp" + + +def get_test_run_root() -> Path: + return _TEST_RUN_ROOT + + +def get_test_system_temp_root() -> Path: + _TEST_SYSTEM_TEMP_ROOT.mkdir(parents=True, exist_ok=True) + return _TEST_SYSTEM_TEMP_ROOT + + +def cleanup_test_run_root() -> None: + shutil.rmtree(_TEST_RUN_ROOT, ignore_errors=True) + + +def make_in_memory_db_path(name: str) -> str: + """Return a unique named in-memory SQLite path for test isolation.""" + slug = _slugify(name) + return f":memory:{slug}-{uuid4().hex}" + + +def make_test_disk_path(*parts: str) -> Path: + """Return a per-run temp path for tests that must write to disk.""" + _TEST_RUN_ROOT.mkdir(parents=True, exist_ok=True) + path = _TEST_RUN_ROOT.joinpath(*parts) + path.parent.mkdir(parents=True, exist_ok=True) + return path + + +def make_test_temp_dir(name: str) -> Path: + path = make_test_disk_path(f"{_slugify(name)}-{uuid4().hex}") + path.mkdir(parents=True, exist_ok=False) + return path + + +@contextlib.contextmanager +def temporary_test_dir(name: str) -> Iterator[Path]: + path = make_test_temp_dir(name) + try: + yield path + finally: + shutil.rmtree(path, ignore_errors=True) + + +atexit.register(cleanup_test_run_root) diff --git a/app/tests/test_condition_ignore.py b/app/tests/test_condition_ignore.py new file mode 100644 index 00000000..f107b263 --- /dev/null +++ b/app/tests/test_condition_ignore.py @@ -0,0 +1,167 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from app.features.conditions.service import Conditions +from app.library.ItemDTO import Item +from app.library.downloads.item_adder import add +from app.library.downloads.playlist_processor import process_playlist + + +@pytest.fixture(autouse=True) +def reset_conditions_singleton(): + Conditions._reset_singleton() + yield + Conditions._reset_singleton() + + +class TestConditionIgnoreMatching: + @pytest.mark.asyncio + async def test_match_skips_condition_by_name_and_returns_next_match(self) -> None: + first = SimpleNamespace(id=1, name="Primary", enabled=True, filter="duration > 0", priority=20) + second = SimpleNamespace(id=2, name="Fallback", enabled=True, filter="duration > 0", priority=10) + + service = Conditions.get_instance() + service._repo = Mock(list=AsyncMock(return_value=[first, second])) + + matched = await service.match(info={"duration": 60}, ignore_conditions=["Primary"]) + + assert matched is second + + @pytest.mark.asyncio + async def test_match_skips_condition_by_stringified_id_and_returns_next_match(self) -> None: + first = SimpleNamespace(id=123, name="Primary", enabled=True, filter="duration > 0", priority=20) + second = SimpleNamespace(id=124, name="Fallback", enabled=True, filter="duration > 0", priority=10) + + service = Conditions.get_instance() + service._repo = Mock(list=AsyncMock(return_value=[first, second])) + + matched = await service.match(info={"duration": 60}, ignore_conditions=["123"]) + + assert matched is second + + @pytest.mark.asyncio + async def test_match_coerces_numeric_ignore_values_to_strings(self) -> None: + first = SimpleNamespace(id=123, name="Primary", enabled=True, filter="duration > 0", priority=20) + second = SimpleNamespace(id=124, name="Fallback", enabled=True, filter="duration > 0", priority=10) + + service = Conditions.get_instance() + service._repo = Mock(list=AsyncMock(return_value=[first, second])) + + matched = await service.match(info={"duration": 60}, ignore_conditions=[123]) + + assert matched is second + + @pytest.mark.asyncio + async def test_match_returns_none_when_ignore_all_wildcard_is_present(self) -> None: + first = SimpleNamespace(id=1, name="Primary", enabled=True, filter="duration > 0", priority=20) + + service = Conditions.get_instance() + service._repo = Mock(list=AsyncMock(return_value=[first])) + + matched = await service.match(info={"duration": 60}, ignore_conditions=["*"]) + + assert matched is None + + +class TestConditionIgnorePropagation: + @pytest.mark.asyncio + async def test_item_adder_passes_ignore_conditions_to_matcher(self) -> None: + queue = Mock() + queue.config = Mock(temp_path="/tmp", ignore_archived_items=False, ytdlp_debug=False) + queue._notify = Mock() + + item = Item( + url="https://example.com/watch?v=test", + preset="default", + extras={"ignore_conditions": [" 123 ", "Primary", "", " * "]}, + ) + item.get_ytdlp_opts = Mock(return_value=Mock(get_all=Mock(return_value={}))) + item.get_archive_id = Mock(return_value=None) + item.get_archive_file = Mock(return_value=None) + item.is_archived = Mock(return_value=False) + + matcher = Mock(match=AsyncMock(return_value=None)) + entry = {"id": "video-1", "duration": 60} + + with ( + patch( + "app.library.downloads.item_adder.Presets.get_instance", return_value=Mock(get=Mock(return_value=None)) + ), + patch("app.library.downloads.item_adder.Conditions.get_instance", return_value=matcher), + patch("app.library.downloads.item_adder.ytdlp_reject", return_value=(True, "")), + patch( + "app.library.downloads.item_adder.add_item", new=AsyncMock(return_value={"status": "ok"}) + ) as add_item_mock, + ): + result = await add(queue=queue, item=item, entry=entry) + + matcher.match.assert_awaited_once_with(info=entry, ignore_conditions=["123", "Primary", "*"]) + add_item_mock.assert_awaited_once() + assert result == {"status": "ok"} + + @pytest.mark.asyncio + async def test_item_adder_coerces_numeric_ignore_conditions_to_strings(self) -> None: + queue = Mock() + queue.config = Mock(temp_path="/tmp", ignore_archived_items=False, ytdlp_debug=False) + queue._notify = Mock() + + item = Item( + url="https://example.com/watch?v=test", + preset="default", + extras={"ignore_conditions": [123, "Primary", True, " * "]}, + ) + item.get_ytdlp_opts = Mock(return_value=Mock(get_all=Mock(return_value={}))) + item.get_archive_id = Mock(return_value=None) + item.get_archive_file = Mock(return_value=None) + item.is_archived = Mock(return_value=False) + + matcher = Mock(match=AsyncMock(return_value=None)) + entry = {"id": "video-1", "duration": 60} + + with ( + patch( + "app.library.downloads.item_adder.Presets.get_instance", return_value=Mock(get=Mock(return_value=None)) + ), + patch("app.library.downloads.item_adder.Conditions.get_instance", return_value=matcher), + patch("app.library.downloads.item_adder.ytdlp_reject", return_value=(True, "")), + patch("app.library.downloads.item_adder.add_item", new=AsyncMock(return_value={"status": "ok"})), + ): + await add(queue=queue, item=item, entry=entry) + + matcher.match.assert_awaited_once_with(info=entry, ignore_conditions=["123", "Primary", "*"]) + + @pytest.mark.asyncio + async def test_process_playlist_preserves_only_parent_ignore_conditions(self) -> None: + captured: dict[str, object] = {} + + class FakeItem: + def __init__(self) -> None: + self.extras = {"ignore_conditions": ["*", "Primary"], "source_name": "Manual"} + + def get_ytdlp_opts(self): + return Mock(get_all=Mock(return_value={})) + + def new_with(self, **kwargs): + captured.update(kwargs) + return SimpleNamespace(extras=kwargs["extras"], url=kwargs["url"]) + + queue = Mock(add=AsyncMock(return_value={"status": "ok"})) + entry = { + "_type": "playlist", + "id": "playlist-1", + "title": "Playlist", + "extractor": "youtube", + "entries": [{"_type": "video", "id": "video-1", "title": "Video 1", "url": "https://example.com/v/1"}], + } + + with patch("app.library.downloads.playlist_processor.ytdlp_reject", return_value=(True, "")): + result = await process_playlist(queue=queue, entry=entry, item=FakeItem()) + + assert result == {"status": "ok"} + assert captured["url"] == "https://example.com/v/1" + assert captured["extras"]["ignore_conditions"] == ["*", "Primary"] + assert captured["extras"]["playlist"] == "Playlist" + assert "source_name" not in captured["extras"] + queue.add.assert_awaited_once() diff --git a/app/tests/test_datastore.py b/app/tests/test_datastore.py index f17f97c7..dfb6bed3 100644 --- a/app/tests/test_datastore.py +++ b/app/tests/test_datastore.py @@ -5,14 +5,13 @@ from dataclasses import asdict from datetime import UTC, datetime from email.utils import formatdate from unittest.mock import AsyncMock, Mock -from uuid import uuid4 - import pytest from app.library.DataStore import DataStore, StoreType from app.library.ItemDTO import ItemDTO from app.library.operations import Operation from app.library.sqlite_store import SqliteStore +from app.tests.helpers import make_in_memory_db_path async def reset_sqlite_store() -> None: @@ -32,7 +31,7 @@ async def reset_sqlite_store() -> None: async def make_db(data: int = 0) -> SqliteStore: """Create a named in-memory database with test data.""" await reset_sqlite_store() - ins = SqliteStore.get_instance(db_path=f":memory:test-datastore-{uuid4().hex}") + ins = SqliteStore.get_instance(db_path=make_in_memory_db_path("test-datastore")) await ins.get_connection() base_time = datetime.now(UTC) diff --git a/app/tests/test_datastore_pagination.py b/app/tests/test_datastore_pagination.py index 49b7dc57..23696087 100644 --- a/app/tests/test_datastore_pagination.py +++ b/app/tests/test_datastore_pagination.py @@ -1,6 +1,5 @@ import json from datetime import UTC, datetime -from uuid import uuid4 import pytest import pytest_asyncio @@ -9,6 +8,7 @@ from app.library.DataStore import DataStore, StoreType from app.library.downloads import Download from app.library.ItemDTO import ItemDTO from app.library.sqlite_store import SqliteStore +from app.tests.helpers import make_in_memory_db_path async def reset_sqlite_store() -> None: @@ -28,7 +28,7 @@ async def reset_sqlite_store() -> None: async def make_db(data: int = 100) -> SqliteStore: """Create a named in-memory database with test data.""" await reset_sqlite_store() - db_path = f":memory:test-datastore-pagination-{uuid4().hex}" + db_path = make_in_memory_db_path("test-datastore-pagination") ins = SqliteStore.get_instance(db_path=db_path) await ins.get_connection() diff --git a/app/tests/test_sqlite_store.py b/app/tests/test_sqlite_store.py index 18c5d6c7..56326fa1 100644 --- a/app/tests/test_sqlite_store.py +++ b/app/tests/test_sqlite_store.py @@ -1,6 +1,4 @@ from datetime import UTC, datetime, timedelta -import os -from tempfile import mkstemp from unittest.mock import AsyncMock, patch import pytest @@ -9,14 +7,13 @@ from sqlalchemy import text from app.library.ItemDTO import ItemDTO from app.library.operations import Operation from app.library.sqlite_store import SqliteStore +from app.tests.helpers import make_in_memory_db_path async def make_store() -> SqliteStore: - """Create an isolated temporary SqliteStore instance with schema.""" + """Create an isolated named in-memory SqliteStore instance with schema.""" SqliteStore._reset_singleton() - fd, db_path = mkstemp(prefix="ytptube-sqlite-store-", suffix=".db") - os.close(fd) - store = SqliteStore.get_instance(db_path=db_path) + store = SqliteStore.get_instance(db_path=make_in_memory_db_path("sqlite-store")) await store.get_connection() assert store._engine is not None, "Engine should be initialized after _ensure_conn" return store @@ -38,7 +35,7 @@ def make_item(idx: int, *, status: str = "finished", cli: str = "", download_ski async def test_sessionmaker_returns_valid_sessionmaker() -> None: """Test that sessionmaker() returns a working async_sessionmaker.""" SqliteStore._reset_singleton() - store = SqliteStore.get_instance(db_path=":memory:") + store = SqliteStore.get_instance(db_path=make_in_memory_db_path("sessionmaker")) # Ensure connection is initialized await store.get_connection() @@ -58,7 +55,7 @@ async def test_sessionmaker_returns_valid_sessionmaker() -> None: async def test_sessionmaker_raises_before_init() -> None: """Test that sessionmaker() raises error before connection initialization.""" SqliteStore._reset_singleton() - store = SqliteStore.get_instance(db_path=":memory:") + store = SqliteStore.get_instance(db_path=make_in_memory_db_path("sessionmaker-before-init")) with pytest.raises(RuntimeError, match="Database connection not initialized"): store.sessionmaker() @@ -70,7 +67,7 @@ async def test_sessionmaker_raises_before_init() -> None: async def test_sqlalchemy_engine_disposed_on_close() -> None: """Test that SQLAlchemy engine is properly disposed on close.""" SqliteStore._reset_singleton() - store = SqliteStore.get_instance(db_path=":memory:") + store = SqliteStore.get_instance(db_path=make_in_memory_db_path("engine-close")) await store.get_connection() assert store._engine is not None, "Engine should be created" diff --git a/app/tests/test_test_helpers.py b/app/tests/test_test_helpers.py new file mode 100644 index 00000000..4f52e55e --- /dev/null +++ b/app/tests/test_test_helpers.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from pathlib import Path + +from app.tests.helpers import ( + get_test_run_root, + get_test_system_temp_root, + make_test_disk_path, + make_test_temp_dir, +) + + +def test_make_test_disk_path_uses_test_run_root() -> None: + path = make_test_disk_path("artifacts", "example.txt") + + assert path.parent.exists() + assert path.is_relative_to(get_test_run_root()) + + +def test_make_test_temp_dir_creates_directory() -> None: + path = make_test_temp_dir("helpers") + + assert path.exists() + assert path.is_dir() + assert path.is_relative_to(get_test_run_root()) + + +def test_tmp_path_runs_under_custom_temp_root(tmp_path: Path) -> None: + expected_root = get_test_run_root() / "pytest" + + assert tmp_path.is_relative_to(expected_root) + assert get_test_system_temp_root().is_relative_to(get_test_run_root()) diff --git a/app/tests/test_utils.py b/app/tests/test_utils.py index 9f7518c0..e2375067 100644 --- a/app/tests/test_utils.py +++ b/app/tests/test_utils.py @@ -2,7 +2,6 @@ import asyncio import copy import re import shutil -import tempfile import uuid from dataclasses import dataclass from datetime import datetime, timedelta @@ -43,6 +42,7 @@ from app.library.Utils import ( validate_uuid, ) from app.routes.api.logs import _read_logfile, _tail_log +from app.tests.helpers import make_test_temp_dir, temporary_test_dir class TestTimedLruCache: @@ -283,7 +283,7 @@ class TestCalcDownloadPath: def setup_method(self): """Set up test directory.""" - self.temp_dir = tempfile.mkdtemp() + self.temp_dir = str(make_test_temp_dir("calc-download-path")) self.base_path = Path(self.temp_dir) def teardown_method(self): @@ -977,7 +977,7 @@ class TestDeleteDir: def setup_method(self): """Set up test directory.""" - self.temp_dir = tempfile.mkdtemp() + self.temp_dir = str(make_test_temp_dir("delete-dir")) self.test_dir = Path(self.temp_dir) / "test_delete" self.test_dir.mkdir() (self.test_dir / "file.txt").write_text("test content") @@ -1007,7 +1007,7 @@ class TestListFolders: def setup_method(self): """Set up test directory structure.""" - self.temp_dir = tempfile.mkdtemp() + self.temp_dir = str(make_test_temp_dir("list-folders")) self.base = Path(self.temp_dir) (self.base / "folder1").mkdir() (self.base / "folder2").mkdir() @@ -1237,8 +1237,7 @@ class TestGetFileSidecar: def test_get_file_sidecar_with_files(self): """Test getting sidecar files when they exist.""" - with tempfile.TemporaryDirectory() as temp_dir: - base_path = Path(temp_dir) + with temporary_test_dir("file-sidecar") as base_path: video_file = base_path / "video.mp4" srt_file = base_path / "video.srt" nfo_file = base_path / "video.nfo" @@ -1252,8 +1251,7 @@ class TestGetFileSidecar: def test_get_file_sidecar_no_files(self): """Test getting sidecar files when none exist.""" - with tempfile.TemporaryDirectory() as temp_dir: - base_path = Path(temp_dir) + with temporary_test_dir("file-sidecar-empty") as base_path: video_file = base_path / "video.mp4" video_file.write_text("video content") @@ -1266,7 +1264,7 @@ class TestCheckId: def setup_method(self): """Set up test files.""" - self.temp_dir = tempfile.mkdtemp() + self.temp_dir = str(make_test_temp_dir("check-id")) self.test_dir = Path(self.temp_dir) def teardown_method(self): @@ -1347,7 +1345,7 @@ class TestGetPossibleImages: def setup_method(self): """Set up test directory with images.""" - self.temp_dir = tempfile.mkdtemp() + self.temp_dir = str(make_test_temp_dir("possible-images")) self.test_dir = Path(self.temp_dir) # Create some test image files @@ -1408,7 +1406,7 @@ class TestGetFile: def setup_method(self): """Set up test files.""" - self.temp_dir = tempfile.mkdtemp() + self.temp_dir = str(make_test_temp_dir("get-file")) self.download_path = Path(self.temp_dir) def teardown_method(self): @@ -1472,7 +1470,7 @@ class TestGetFiles: def setup_method(self): """Set up test directory structure.""" - self.temp_dir = tempfile.mkdtemp() + self.temp_dir = str(make_test_temp_dir("get-files")) self.base_path = Path(self.temp_dir) # Create test files and directories @@ -1504,7 +1502,7 @@ class TestReadLogfile: def setup_method(self): """Set up test log file.""" - self.temp_dir = tempfile.mkdtemp() + self.temp_dir = str(make_test_temp_dir("read-logfile")) self.log_file = Path(self.temp_dir) / "test.log" def teardown_method(self): @@ -1540,7 +1538,7 @@ class TestTailLog: def setup_method(self): """Set up test log file.""" - self.temp_dir = tempfile.mkdtemp() + self.temp_dir = str(make_test_temp_dir("tail-log")) self.log_file = Path(self.temp_dir) / "test.log" def teardown_method(self): @@ -1571,7 +1569,7 @@ class TestLoadCookies: def setup_method(self): """Set up test cookie file.""" - self.temp_dir = tempfile.mkdtemp() + self.temp_dir = str(make_test_temp_dir("load-cookies")) self.cookie_file = Path(self.temp_dir) / "cookies.txt" def teardown_method(self): @@ -1640,7 +1638,7 @@ class TestLoadModules: def setup_method(self): """Set up test module structure.""" - self.temp_dir = tempfile.mkdtemp() + self.temp_dir = str(make_test_temp_dir("load-modules")) self.root_path = Path(self.temp_dir) self.module_dir = self.root_path / "test_modules" self.module_dir.mkdir() @@ -1885,7 +1883,7 @@ class TestCreateCookiesFile: def setup_method(self): """Set up test environment.""" - self.temp_dir = tempfile.mkdtemp() + self.temp_dir = str(make_test_temp_dir("create-cookies-file")) self.test_path = Path(self.temp_dir) def teardown_method(self): diff --git a/ui/app/components/NewDownload.vue b/ui/app/components/NewDownload.vue index a0f28f6e..b54fd8a0 100644 --- a/ui/app/components/NewDownload.vue +++ b/ui/app/components/NewDownload.vue @@ -230,7 +230,13 @@ /> -
+
+ + + + + + + + + +
@@ -305,7 +354,7 @@ @@ -509,6 +558,8 @@ import { useStorage } from '@vueuse/core'; import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue'; import TextDropzone from '~/components/TextDropzone.vue'; +import { useConditions } from '~/composables/useConditions'; +import type { Condition } from '~/types/conditions'; import type { item_request } from '~/types/item'; import type { AutoCompleteOptions } from '~/types/autocomplete'; import { navigateTo } from '#app'; @@ -522,6 +573,7 @@ const emitter = defineEmits<{ const config = useYtpConfig(); const toast = useNotification(); const dialog = useDialog(); +const conditions = useConditions(); const { findPreset, hasPreset, selectItems, getPresetDefault } = usePresetOptions(); const showAdvanced = useStorage('show_advanced', false); @@ -541,6 +593,101 @@ const ytDlpOpt = ref([]); const cookiesDropzoneRef = ref | null>(null); const urlTextarea = ref<{ textareaRef?: HTMLTextAreaElement | null } | null>(null); +type IgnoreConditionOption = { + value: string; + label: string; +}; + +const normalizeIgnoreConditionValues = (value: unknown): string[] => { + if (!Array.isArray(value)) { + return []; + } + + return value + .map((entry) => { + if ('boolean' === typeof entry) { + return ''; + } + + if ('number' === typeof entry || 'string' === typeof entry) { + return String(entry).trim(); + } + + return ''; + }) + .filter((entry, index, items) => !!entry && items.indexOf(entry) === index); +}; + +const buildIgnoreConditionOptions = ( + items: readonly Condition[], + selectedValues: readonly string[] = [], +): IgnoreConditionOption[] => { + const options = items + .filter((condition) => condition.enabled) + .map((condition) => ({ + value: + condition.id !== undefined && condition.id !== null ? String(condition.id) : condition.name, + label: condition.name, + })); + + const missingSelectedItems = selectedValues + .filter((value) => value !== '*' && !options.some((item) => item.value === value)) + .map((value) => ({ + value, + label: value, + })); + + return [ + { + value: '*', + label: 'All conditions', + }, + ...options, + ...missingSelectedItems, + ]; +}; + +const mergeIgnoreConditionsIntoExtras = ( + extras: Record | undefined, + ignoreConditions: string[], +): Record => { + const nextExtras = { ...(extras || {}) }; + + if (ignoreConditions.length > 0) { + nextExtras.ignore_conditions = [...ignoreConditions]; + return nextExtras; + } + + delete nextExtras.ignore_conditions; + return nextExtras; +}; + +const hasAllConditionsLoaded = computed(() => { + const loaded = conditions.conditions.value.length; + const total = conditions.pagination.value.total; + + return loaded > 0 && (total <= 0 || loaded >= total); +}); + +const ensureIgnoreConditionsLoaded = async (): Promise => { + if (conditions.isLoading.value || hasAllConditionsLoaded.value) { + return; + } + + await conditions.loadConditions(1, 1000); +}; + +const selectedIgnoreConditions = computed({ + get: () => normalizeIgnoreConditionValues(form.value?.extras?.ignore_conditions), + set: (values) => { + const extras = mergeIgnoreConditionsIntoExtras( + form.value?.extras, + normalizeIgnoreConditionValues(values), + ); + form.value.extras = Object.keys(extras).length > 0 ? extras : {}; + }, +}); + const testResultsText = computed(() => { if (typeof testResultsData.value === 'string') { return testResultsData.value; @@ -595,6 +742,15 @@ const form = useStorage('local_config_v1', { }) as Ref; const presetItems = computed(() => selectItems.value); +const ignoreConditionOptions = computed(() => + buildIgnoreConditionOptions( + conditions.conditions.value, + normalizeIgnoreConditionValues(form.value?.extras?.ignore_conditions), + ), +); +const hasIgnoreConditionOptions = computed(() => + ignoreConditionOptions.value.some((option) => option.value !== '*'), +); const mobileActionGroups = computed(() => { const groups = [ @@ -912,6 +1068,18 @@ watch( { immediate: true }, ); +watch( + showAdvanced, + (open) => { + if (!open) { + return; + } + + void ensureIgnoreConditionsLoaded(); + }, + { immediate: true }, +); + onMounted(async () => { await nextTick();