Merge pull request #594 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled

feat: add support for ignoring conditions in downloads and UI
This commit is contained in:
Abdulmohsen 2026-04-28 01:21:27 +03:00 committed by GitHub
commit cf4386e1a5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 640 additions and 117 deletions

5
API.md
View file

@ -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)

22
app/conftest.py Normal file
View file

@ -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()

View file

@ -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

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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:<symlink>
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

View file

@ -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()

View file

@ -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()

View file

@ -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):

View file

@ -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}'."

View file

@ -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 (

64
app/tests/helpers.py Normal file
View file

@ -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)

View file

@ -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()

View file

@ -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)

View file

@ -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()

View file

@ -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"

View file

@ -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())

View file

@ -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):

View file

@ -230,7 +230,13 @@
/>
</div>
<div class="md:col-span-2 xl:col-span-6">
<div
:class="
hasIgnoreConditionOptions
? 'md:col-span-2 xl:col-span-3'
: 'md:col-span-2 xl:col-span-6'
"
>
<DLInput
id="output_template"
v-model="form.template"
@ -244,18 +250,61 @@
>
<template #description>
<span>
All output template naming options can be found on
See
<NuxtLink
target="_blank"
class="font-medium text-primary hover:underline"
to="https://github.com/yt-dlp/yt-dlp#output-template"
>
the yt-dlp output template page.
the yt-dlp output template page
</NuxtLink>
for info.
</span>
</template>
</DLInput>
</div>
<UFormField
v-if="hasIgnoreConditionOptions"
class="md:col-span-2 xl:col-span-3 w-full"
:ui="advancedEditorFieldUi"
>
<template #label>
<span class="inline-flex items-center gap-2 font-semibold">
<UIcon name="i-lucide-list-minus" class="size-4 text-toned" />
<span>Ignore conditions</span>
</span>
</template>
<template #description>
<span> Skip selected condition rules. </span>
</template>
<template #hint>
<button
v-if="selectedIgnoreConditions.length > 0"
type="button"
class="font-medium text-primary hover:underline"
@click="selectedIgnoreConditions = []"
>
Clear selection
</button>
</template>
<USelectMenu
v-model="selectedIgnoreConditions"
:items="ignoreConditionOptions"
value-key="value"
label-key="label"
multiple
placeholder="Select conditions to ignore"
class="w-full"
size="lg"
:disabled="addInProgress || conditions.isLoading.value"
:search-input="{ placeholder: 'Search conditions' }"
:ui="{ base: 'w-full', content: 'min-w-[18rem]' }"
/>
</UFormField>
</div>
<div class="grid gap-4 xl:grid-cols-2">
@ -305,7 +354,7 @@
<template #label>
<span class="inline-flex items-center gap-2 font-semibold">
<UIcon name="i-lucide-cookie" class="size-4 text-toned" />
<span> Cookies </span>
<span>Cookies</span>
</span>
</template>
@ -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<boolean>('show_advanced', false);
@ -541,6 +593,101 @@ const ytDlpOpt = ref<AutoCompleteOptions>([]);
const cookiesDropzoneRef = ref<InstanceType<typeof TextDropzone> | 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<string, unknown> | undefined,
ignoreConditions: string[],
): Record<string, unknown> => {
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<void> => {
if (conditions.isLoading.value || hasAllConditionsLoaded.value) {
return;
}
await conditions.loadConditions(1, 1000);
};
const selectedIgnoreConditions = computed<string[]>({
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<item_request>('local_config_v1', {
}) as Ref<item_request>;
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();