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
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:
commit
cf4386e1a5
24 changed files with 640 additions and 117 deletions
5
API.md
5
API.md
|
|
@ -348,7 +348,10 @@ or an error:
|
||||||
"cookies": "...", // -- optional. If provided, it MUST BE in Netscape HTTP Cookie format.
|
"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.
|
"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.
|
"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)
|
// Or multiple items (array of objects)
|
||||||
|
|
|
||||||
22
app/conftest.py
Normal file
22
app/conftest.py
Normal 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()
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import logging
|
import logging
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from numbers import Number
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
|
|
@ -11,6 +13,30 @@ from app.library.Singleton import Singleton
|
||||||
LOG: logging.Logger = logging.getLogger("feature.conditions")
|
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):
|
class Conditions(metaclass=Singleton):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._repo: ConditionsRepository = ConditionsRepository.get_instance()
|
self._repo: ConditionsRepository = ConditionsRepository.get_instance()
|
||||||
|
|
@ -87,12 +113,13 @@ class Conditions(metaclass=Singleton):
|
||||||
repo = self._repo
|
repo = self._repo
|
||||||
return await repo.get(identifier)
|
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.
|
Check if any condition matches the info dict.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
info (dict): The info dict to check.
|
info (dict): The info dict to check.
|
||||||
|
ignore_conditions (Iterable[str | Number] | None): Condition ids or names to skip for this match.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Condition|None: The condition if found, None otherwise.
|
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:
|
if not info or not isinstance(info, dict) or len(info) < 1:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
ignored_identifiers, ignore_all = _ignored_identifiers(ignore_conditions)
|
||||||
|
if ignore_all:
|
||||||
|
return None
|
||||||
|
|
||||||
repo = self._repo
|
repo = self._repo
|
||||||
items: list[ConditionModel] = await repo.list()
|
items: list[ConditionModel] = await repo.list()
|
||||||
if len(items) < 1:
|
if len(items) < 1:
|
||||||
|
|
@ -110,6 +141,9 @@ class Conditions(metaclass=Singleton):
|
||||||
if not item.enabled:
|
if not item.enabled:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if str(item.id) in ignored_identifiers or item.name in ignored_identifiers:
|
||||||
|
continue
|
||||||
|
|
||||||
if not item.filter:
|
if not item.filter:
|
||||||
LOG.error(f"Filter is empty for '{item.name}'.")
|
LOG.error(f"Filter is empty for '{item.name}'.")
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ from app.library.config import Config
|
||||||
from app.library.encoder import Encoder
|
from app.library.encoder import Encoder
|
||||||
from app.features.conditions.repository import ConditionsRepository
|
from app.features.conditions.repository import ConditionsRepository
|
||||||
from app.library.sqlite_store import SqliteStore
|
from app.library.sqlite_store import SqliteStore
|
||||||
|
from app.tests.helpers import make_in_memory_db_path
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
|
|
@ -21,7 +22,7 @@ async def repo():
|
||||||
ConditionsRepository._reset_singleton()
|
ConditionsRepository._reset_singleton()
|
||||||
SqliteStore._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()
|
await store.get_connection()
|
||||||
|
|
||||||
# Create repository
|
# Create repository
|
||||||
|
|
@ -30,10 +31,7 @@ async def repo():
|
||||||
yield repository
|
yield repository
|
||||||
|
|
||||||
# Cleanup - close connections properly
|
# Cleanup - close connections properly
|
||||||
if store._conn:
|
await store.close()
|
||||||
await store._conn.close()
|
|
||||||
if store._engine:
|
|
||||||
await store._engine.dispose()
|
|
||||||
|
|
||||||
# Reset singletons
|
# Reset singletons
|
||||||
ConditionsRepository._reset_singleton()
|
ConditionsRepository._reset_singleton()
|
||||||
|
|
|
||||||
|
|
@ -8,26 +8,24 @@ import pytest_asyncio
|
||||||
from app.features.dl_fields.repository import DLFieldsRepository
|
from app.features.dl_fields.repository import DLFieldsRepository
|
||||||
from app.features.dl_fields.service import DLFields
|
from app.features.dl_fields.service import DLFields
|
||||||
from app.library.sqlite_store import SqliteStore
|
from app.library.sqlite_store import SqliteStore
|
||||||
|
from app.tests.helpers import make_in_memory_db_path
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
async def repo(tmp_path):
|
async def repo():
|
||||||
"""Provide a fresh repository instance with initialized database for each test."""
|
"""Provide a fresh repository instance with initialized database for each test."""
|
||||||
DLFieldsRepository._reset_singleton()
|
DLFieldsRepository._reset_singleton()
|
||||||
DLFields._reset_singleton()
|
DLFields._reset_singleton()
|
||||||
SqliteStore._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()
|
await store.get_connection()
|
||||||
|
|
||||||
repository = DLFieldsRepository.get_instance()
|
repository = DLFieldsRepository.get_instance()
|
||||||
|
|
||||||
yield repository
|
yield repository
|
||||||
|
|
||||||
if store._conn:
|
await store.close()
|
||||||
await store._conn.close()
|
|
||||||
if store._engine:
|
|
||||||
await store._engine.dispose()
|
|
||||||
|
|
||||||
DLFieldsRepository._reset_singleton()
|
DLFieldsRepository._reset_singleton()
|
||||||
DLFields._reset_singleton()
|
DLFields._reset_singleton()
|
||||||
|
|
|
||||||
|
|
@ -7,25 +7,23 @@ import pytest_asyncio
|
||||||
|
|
||||||
from app.features.dl_fields.repository import DLFieldsRepository
|
from app.features.dl_fields.repository import DLFieldsRepository
|
||||||
from app.library.sqlite_store import SqliteStore
|
from app.library.sqlite_store import SqliteStore
|
||||||
|
from app.tests.helpers import make_in_memory_db_path
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
async def repo(tmp_path):
|
async def repo():
|
||||||
"""Provide a fresh repository instance with initialized database for each test."""
|
"""Provide a fresh repository instance with initialized database for each test."""
|
||||||
DLFieldsRepository._reset_singleton()
|
DLFieldsRepository._reset_singleton()
|
||||||
SqliteStore._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()
|
await store.get_connection()
|
||||||
|
|
||||||
repository = DLFieldsRepository.get_instance()
|
repository = DLFieldsRepository.get_instance()
|
||||||
|
|
||||||
yield repository
|
yield repository
|
||||||
|
|
||||||
if store._conn:
|
await store.close()
|
||||||
await store._conn.close()
|
|
||||||
if store._engine:
|
|
||||||
await store._engine.dispose()
|
|
||||||
|
|
||||||
DLFieldsRepository._reset_singleton()
|
DLFieldsRepository._reset_singleton()
|
||||||
SqliteStore._reset_singleton()
|
SqliteStore._reset_singleton()
|
||||||
|
|
|
||||||
|
|
@ -7,25 +7,23 @@ import pytest_asyncio
|
||||||
|
|
||||||
from app.features.notifications.repository import NotificationsRepository
|
from app.features.notifications.repository import NotificationsRepository
|
||||||
from app.library.sqlite_store import SqliteStore
|
from app.library.sqlite_store import SqliteStore
|
||||||
|
from app.tests.helpers import make_in_memory_db_path
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
async def repo(tmp_path):
|
async def repo():
|
||||||
"""Provide a fresh repository instance with initialized database for each test."""
|
"""Provide a fresh repository instance with initialized database for each test."""
|
||||||
NotificationsRepository._reset_singleton()
|
NotificationsRepository._reset_singleton()
|
||||||
SqliteStore._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()
|
await store.get_connection()
|
||||||
|
|
||||||
repository = NotificationsRepository.get_instance()
|
repository = NotificationsRepository.get_instance()
|
||||||
|
|
||||||
yield repository
|
yield repository
|
||||||
|
|
||||||
if store._conn:
|
await store.close()
|
||||||
await store._conn.close()
|
|
||||||
if store._engine:
|
|
||||||
await store._engine.dispose()
|
|
||||||
|
|
||||||
NotificationsRepository._reset_singleton()
|
NotificationsRepository._reset_singleton()
|
||||||
SqliteStore._reset_singleton()
|
SqliteStore._reset_singleton()
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ from app.features.presets.repository import PresetsRepository
|
||||||
from app.features.presets.router import presets_list
|
from app.features.presets.router import presets_list
|
||||||
from app.library.encoder import Encoder
|
from app.library.encoder import Encoder
|
||||||
from app.library.sqlite_store import SqliteStore
|
from app.library.sqlite_store import SqliteStore
|
||||||
|
from app.tests.helpers import make_in_memory_db_path
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
|
|
@ -19,16 +20,13 @@ async def repo():
|
||||||
PresetsRepository._reset_singleton()
|
PresetsRepository._reset_singleton()
|
||||||
SqliteStore._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()
|
await store.get_connection()
|
||||||
|
|
||||||
repository = PresetsRepository.get_instance()
|
repository = PresetsRepository.get_instance()
|
||||||
yield repository
|
yield repository
|
||||||
|
|
||||||
if store._conn:
|
await store.close()
|
||||||
await store._conn.close()
|
|
||||||
if store._engine:
|
|
||||||
await store._engine.dispose()
|
|
||||||
|
|
||||||
PresetsRepository._reset_singleton()
|
PresetsRepository._reset_singleton()
|
||||||
SqliteStore._reset_singleton()
|
SqliteStore._reset_singleton()
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import subprocess # type: ignore
|
import subprocess # type: ignore
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from secrets import token_hex
|
||||||
from typing import TYPE_CHECKING, ClassVar
|
from typing import TYPE_CHECKING, ClassVar
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
@ -68,21 +68,21 @@ class Segments:
|
||||||
self.attempted: set[str] = set()
|
self.attempted: set[str] = set()
|
||||||
"The set of attempted codecs."
|
"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:
|
try:
|
||||||
ff: FFProbeResult = await ffprobe(file)
|
ff: FFProbeResult = await ffprobe(file)
|
||||||
except UnicodeDecodeError:
|
except UnicodeDecodeError:
|
||||||
pass
|
pass
|
||||||
|
input_path = stream_input or file
|
||||||
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
|
|
||||||
|
|
||||||
startTime: str = f"{0:.6f}" if self.index == 0 else f"{self.duration * self.index:.6f}"
|
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
|
# hardware/global input options must come before -i
|
||||||
*input_args,
|
*input_args,
|
||||||
"-i",
|
"-i",
|
||||||
f"file:{tmpFile}",
|
f"file:{input_path}",
|
||||||
"-map_metadata",
|
"-map_metadata",
|
||||||
"-1",
|
"-1",
|
||||||
]
|
]
|
||||||
|
|
@ -240,22 +240,27 @@ class Segments:
|
||||||
else:
|
else:
|
||||||
codecs: list[str] = [codec, *list(encoder_fallback_chain(codec))]
|
codecs: list[str] = [codec, *list(encoder_fallback_chain(codec))]
|
||||||
|
|
||||||
for s_codec in codecs:
|
stream_input = self._make_stream_input(file)
|
||||||
if s_codec in self.attempted:
|
try:
|
||||||
continue
|
for s_codec in codecs:
|
||||||
|
if s_codec in self.attempted:
|
||||||
|
continue
|
||||||
|
|
||||||
ffmpeg_args: list[str] = await self.build_ffmpeg_args(file, s_codec)
|
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)
|
_, rc, client_disconnected, stderr_text = await self._run(resp, file, ffmpeg_args)
|
||||||
|
|
||||||
if 0 == rc:
|
if 0 == rc:
|
||||||
Segments._cached_vcodec = s_codec
|
Segments._cached_vcodec = s_codec
|
||||||
Segments._cache_initialized = True
|
Segments._cache_initialized = True
|
||||||
return
|
return
|
||||||
|
|
||||||
if client_disconnected:
|
if client_disconnected:
|
||||||
return
|
return
|
||||||
|
|
||||||
if 0 != rc:
|
if 0 != rc:
|
||||||
err: str = stderr_text[:500] if stderr_text else "no error output"
|
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.")
|
LOG.warning(f"transcoding has failed (cmd={ffmpeg_args}) (rc={rc}): {err}. Trying fallbacks.")
|
||||||
self.attempted.add(s_codec)
|
self.attempted.add(s_codec)
|
||||||
|
finally:
|
||||||
|
if stream_input.is_symlink():
|
||||||
|
stream_input.unlink()
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,17 @@
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from app.tests.helpers import make_test_temp_dir
|
||||||
|
|
||||||
|
|
||||||
class TestFFProbe:
|
class TestFFProbe:
|
||||||
"""Test the ffprobe module functionality."""
|
"""Test the ffprobe module functionality."""
|
||||||
|
|
||||||
def setup_method(self):
|
def setup_method(self):
|
||||||
"""Set up test files."""
|
"""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 = Path(self.temp_dir) / "test_video.mp4"
|
||||||
self.test_file.touch()
|
self.test_file.touch()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
|
||||||
import logging
|
import logging
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.features.streaming.library.segments import Segments
|
from app.features.streaming.library.segments import Segments
|
||||||
|
from app.tests.helpers import get_test_system_temp_root
|
||||||
|
|
||||||
|
|
||||||
class DummyFF:
|
class DummyFF:
|
||||||
|
|
@ -22,6 +21,10 @@ class DummyFF:
|
||||||
return self._a
|
return self._a
|
||||||
|
|
||||||
|
|
||||||
|
def _ffmpeg_input_path(args: list[str]) -> Path:
|
||||||
|
return Path(args[args.index("-i") + 1].removeprefix("file:"))
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_build_ffmpeg_args_video_and_audio(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
async def test_build_ffmpeg_args_video_and_audio(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
# Create a dummy media file
|
# 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"
|
assert captured_args, "ffmpeg was not invoked"
|
||||||
args = captured_args[0]
|
args = captured_args[0]
|
||||||
|
|
||||||
# Compute expected symlink path used by Segments
|
tmp_file = _ffmpeg_input_path(args)
|
||||||
tmpFile = Path(tempfile.gettempdir()).joinpath(f"ytptube_stream.{hashlib.sha256(str(media).encode()).hexdigest()}")
|
|
||||||
|
|
||||||
# Start time is duration * index with 6 decimals for non-zero index
|
# Start time is duration * index with 6 decimals for non-zero index
|
||||||
assert "-ss" in args
|
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}"
|
assert args[args.index("-t") + 1] == f"{5.5:.6f}"
|
||||||
# Input uses file:<symlink>
|
# Input uses file:<symlink>
|
||||||
assert "-i" in args
|
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
|
# Includes video and audio mapping and codecs
|
||||||
assert "-map" in args
|
assert "-map" in args
|
||||||
assert "0:v:0" 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
|
@pytest.mark.asyncio
|
||||||
async def test_stream_normal_flow(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
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):
|
async def fake_ffprobe(_file: Path):
|
||||||
return DummyFF(v=True, a=True)
|
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
|
# Process that yields two chunks and then EOF
|
||||||
proc = _FakeProc([b"abc", b"def", b""])
|
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
|
return proc
|
||||||
|
|
||||||
monkeypatch.setattr("asyncio.create_subprocess_exec", fake_create_subprocess_exec)
|
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)
|
seg = Segments(download_path=str(tmp_path), index=0, duration=1.0, vconvert=True, aconvert=True)
|
||||||
resp = _FakeResp()
|
resp = _FakeResp()
|
||||||
await seg.stream(tmp_path / "file.mp4", resp)
|
await seg.stream(media, resp)
|
||||||
|
|
||||||
assert bytes(resp.data) == b"abcdef"
|
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
|
# 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
|
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
|
return proc
|
||||||
|
|
||||||
monkeypatch.setattr("asyncio.create_subprocess_exec", fake_create_subprocess_exec)
|
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
|
# Should not write EOF due to client disconnect
|
||||||
assert resp.eof is False
|
assert resp.eof is False
|
||||||
|
assert not _ffmpeg_input_path(captured_args[0]).exists()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ from app.features.tasks.definitions.router import (
|
||||||
from app.library.encoder import Encoder
|
from app.library.encoder import Encoder
|
||||||
from app.library.sqlite_store import SqliteStore
|
from app.library.sqlite_store import SqliteStore
|
||||||
from app.main import EventBus
|
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:
|
def _sample_definition(name: str = "example", *, priority: int = 0) -> dict:
|
||||||
|
|
@ -49,17 +50,14 @@ async def repo() -> AsyncGenerator[TaskDefinitionsRepository, None]:
|
||||||
TaskDefinitionsRepository._reset_singleton()
|
TaskDefinitionsRepository._reset_singleton()
|
||||||
SqliteStore._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()
|
await store.get_connection()
|
||||||
|
|
||||||
repository = TaskDefinitionsRepository.get_instance()
|
repository = TaskDefinitionsRepository.get_instance()
|
||||||
|
|
||||||
yield repository
|
yield repository
|
||||||
|
|
||||||
if store._conn:
|
await store.close()
|
||||||
await store._conn.close()
|
|
||||||
if store._engine:
|
|
||||||
await store._engine.dispose()
|
|
||||||
|
|
||||||
TaskDefinitionsRepository._reset_singleton()
|
TaskDefinitionsRepository._reset_singleton()
|
||||||
SqliteStore._reset_singleton()
|
SqliteStore._reset_singleton()
|
||||||
|
|
|
||||||
|
|
@ -7,25 +7,23 @@ import pytest_asyncio
|
||||||
|
|
||||||
from app.features.tasks.repository import TasksRepository
|
from app.features.tasks.repository import TasksRepository
|
||||||
from app.library.sqlite_store import SqliteStore
|
from app.library.sqlite_store import SqliteStore
|
||||||
|
from app.tests.helpers import make_in_memory_db_path
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
async def repo(tmp_path):
|
async def repo():
|
||||||
"""Provide a fresh repository instance with initialized database for each test."""
|
"""Provide a fresh repository instance with initialized database for each test."""
|
||||||
TasksRepository._reset_singleton()
|
TasksRepository._reset_singleton()
|
||||||
SqliteStore._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()
|
await store.get_connection()
|
||||||
|
|
||||||
repository = TasksRepository.get_instance()
|
repository = TasksRepository.get_instance()
|
||||||
|
|
||||||
yield repository
|
yield repository
|
||||||
|
|
||||||
if store._conn:
|
await store.close()
|
||||||
await store._conn.close()
|
|
||||||
if store._engine:
|
|
||||||
await store._engine.dispose()
|
|
||||||
|
|
||||||
TasksRepository._reset_singleton()
|
TasksRepository._reset_singleton()
|
||||||
SqliteStore._reset_singleton()
|
SqliteStore._reset_singleton()
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
import tempfile
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
|
@ -20,6 +19,7 @@ from app.features.ytdlp.utils import (
|
||||||
archive_add,
|
archive_add,
|
||||||
archive_read,
|
archive_read,
|
||||||
)
|
)
|
||||||
|
from app.tests.helpers import make_test_temp_dir
|
||||||
|
|
||||||
|
|
||||||
class CaptureHandler(logging.Handler):
|
class CaptureHandler(logging.Handler):
|
||||||
|
|
@ -549,7 +549,7 @@ class TestArchiveFunctions:
|
||||||
|
|
||||||
def setup_method(self):
|
def setup_method(self):
|
||||||
"""Set up test archive file."""
|
"""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"
|
self.archive_file = Path(self.temp_dir) / "archive.txt"
|
||||||
|
|
||||||
def teardown_method(self):
|
def teardown_method(self):
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import asyncio
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
from numbers import Number
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
|
@ -28,6 +29,28 @@ if TYPE_CHECKING:
|
||||||
LOG: logging.Logger = logging.getLogger("downloads.add")
|
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(
|
async def add_item(
|
||||||
queue: "DownloadQueue",
|
queue: "DownloadQueue",
|
||||||
entry: dict,
|
entry: dict,
|
||||||
|
|
@ -253,7 +276,11 @@ async def add(
|
||||||
)
|
)
|
||||||
return {"status": "error", "msg": message, "hidden": True}
|
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()
|
already.pop()
|
||||||
|
|
||||||
message = f"Condition '{condition.name}' matched for '{item!r}'."
|
message = f"Condition '{condition.name}' matched for '{item!r}'."
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,10 @@ async def process_playlist(
|
||||||
if "thumbnail" not in etr and "youtube" in str(extractor_key).lower():
|
if "thumbnail" not in etr and "youtube" in str(extractor_key).lower():
|
||||||
extras["thumbnail"] = "https://img.youtube.com/vi/{id}/maxresdefault.jpg".format(**etr)
|
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)
|
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 (
|
if ("video" == etr.get("_type") and etr.get("url")) or (
|
||||||
|
|
|
||||||
64
app/tests/helpers.py
Normal file
64
app/tests/helpers.py
Normal 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)
|
||||||
167
app/tests/test_condition_ignore.py
Normal file
167
app/tests/test_condition_ignore.py
Normal 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()
|
||||||
|
|
@ -5,14 +5,13 @@ from dataclasses import asdict
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from email.utils import formatdate
|
from email.utils import formatdate
|
||||||
from unittest.mock import AsyncMock, Mock
|
from unittest.mock import AsyncMock, Mock
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.library.DataStore import DataStore, StoreType
|
from app.library.DataStore import DataStore, StoreType
|
||||||
from app.library.ItemDTO import ItemDTO
|
from app.library.ItemDTO import ItemDTO
|
||||||
from app.library.operations import Operation
|
from app.library.operations import Operation
|
||||||
from app.library.sqlite_store import SqliteStore
|
from app.library.sqlite_store import SqliteStore
|
||||||
|
from app.tests.helpers import make_in_memory_db_path
|
||||||
|
|
||||||
|
|
||||||
async def reset_sqlite_store() -> None:
|
async def reset_sqlite_store() -> None:
|
||||||
|
|
@ -32,7 +31,7 @@ async def reset_sqlite_store() -> None:
|
||||||
async def make_db(data: int = 0) -> SqliteStore:
|
async def make_db(data: int = 0) -> SqliteStore:
|
||||||
"""Create a named in-memory database with test data."""
|
"""Create a named in-memory database with test data."""
|
||||||
await reset_sqlite_store()
|
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()
|
await ins.get_connection()
|
||||||
|
|
||||||
base_time = datetime.now(UTC)
|
base_time = datetime.now(UTC)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import json
|
import json
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
|
|
@ -9,6 +8,7 @@ from app.library.DataStore import DataStore, StoreType
|
||||||
from app.library.downloads import Download
|
from app.library.downloads import Download
|
||||||
from app.library.ItemDTO import ItemDTO
|
from app.library.ItemDTO import ItemDTO
|
||||||
from app.library.sqlite_store import SqliteStore
|
from app.library.sqlite_store import SqliteStore
|
||||||
|
from app.tests.helpers import make_in_memory_db_path
|
||||||
|
|
||||||
|
|
||||||
async def reset_sqlite_store() -> None:
|
async def reset_sqlite_store() -> None:
|
||||||
|
|
@ -28,7 +28,7 @@ async def reset_sqlite_store() -> None:
|
||||||
async def make_db(data: int = 100) -> SqliteStore:
|
async def make_db(data: int = 100) -> SqliteStore:
|
||||||
"""Create a named in-memory database with test data."""
|
"""Create a named in-memory database with test data."""
|
||||||
await reset_sqlite_store()
|
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)
|
ins = SqliteStore.get_instance(db_path=db_path)
|
||||||
await ins.get_connection()
|
await ins.get_connection()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
import os
|
|
||||||
from tempfile import mkstemp
|
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -9,14 +7,13 @@ from sqlalchemy import text
|
||||||
from app.library.ItemDTO import ItemDTO
|
from app.library.ItemDTO import ItemDTO
|
||||||
from app.library.operations import Operation
|
from app.library.operations import Operation
|
||||||
from app.library.sqlite_store import SqliteStore
|
from app.library.sqlite_store import SqliteStore
|
||||||
|
from app.tests.helpers import make_in_memory_db_path
|
||||||
|
|
||||||
|
|
||||||
async def make_store() -> SqliteStore:
|
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()
|
SqliteStore._reset_singleton()
|
||||||
fd, db_path = mkstemp(prefix="ytptube-sqlite-store-", suffix=".db")
|
store = SqliteStore.get_instance(db_path=make_in_memory_db_path("sqlite-store"))
|
||||||
os.close(fd)
|
|
||||||
store = SqliteStore.get_instance(db_path=db_path)
|
|
||||||
await store.get_connection()
|
await store.get_connection()
|
||||||
assert store._engine is not None, "Engine should be initialized after _ensure_conn"
|
assert store._engine is not None, "Engine should be initialized after _ensure_conn"
|
||||||
return store
|
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:
|
async def test_sessionmaker_returns_valid_sessionmaker() -> None:
|
||||||
"""Test that sessionmaker() returns a working async_sessionmaker."""
|
"""Test that sessionmaker() returns a working async_sessionmaker."""
|
||||||
SqliteStore._reset_singleton()
|
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
|
# Ensure connection is initialized
|
||||||
await store.get_connection()
|
await store.get_connection()
|
||||||
|
|
@ -58,7 +55,7 @@ async def test_sessionmaker_returns_valid_sessionmaker() -> None:
|
||||||
async def test_sessionmaker_raises_before_init() -> None:
|
async def test_sessionmaker_raises_before_init() -> None:
|
||||||
"""Test that sessionmaker() raises error before connection initialization."""
|
"""Test that sessionmaker() raises error before connection initialization."""
|
||||||
SqliteStore._reset_singleton()
|
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"):
|
with pytest.raises(RuntimeError, match="Database connection not initialized"):
|
||||||
store.sessionmaker()
|
store.sessionmaker()
|
||||||
|
|
@ -70,7 +67,7 @@ async def test_sessionmaker_raises_before_init() -> None:
|
||||||
async def test_sqlalchemy_engine_disposed_on_close() -> None:
|
async def test_sqlalchemy_engine_disposed_on_close() -> None:
|
||||||
"""Test that SQLAlchemy engine is properly disposed on close."""
|
"""Test that SQLAlchemy engine is properly disposed on close."""
|
||||||
SqliteStore._reset_singleton()
|
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()
|
await store.get_connection()
|
||||||
assert store._engine is not None, "Engine should be created"
|
assert store._engine is not None, "Engine should be created"
|
||||||
|
|
|
||||||
32
app/tests/test_test_helpers.py
Normal file
32
app/tests/test_test_helpers.py
Normal 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())
|
||||||
|
|
@ -2,7 +2,6 @@ import asyncio
|
||||||
import copy
|
import copy
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
@ -43,6 +42,7 @@ from app.library.Utils import (
|
||||||
validate_uuid,
|
validate_uuid,
|
||||||
)
|
)
|
||||||
from app.routes.api.logs import _read_logfile, _tail_log
|
from app.routes.api.logs import _read_logfile, _tail_log
|
||||||
|
from app.tests.helpers import make_test_temp_dir, temporary_test_dir
|
||||||
|
|
||||||
|
|
||||||
class TestTimedLruCache:
|
class TestTimedLruCache:
|
||||||
|
|
@ -283,7 +283,7 @@ class TestCalcDownloadPath:
|
||||||
|
|
||||||
def setup_method(self):
|
def setup_method(self):
|
||||||
"""Set up test directory."""
|
"""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)
|
self.base_path = Path(self.temp_dir)
|
||||||
|
|
||||||
def teardown_method(self):
|
def teardown_method(self):
|
||||||
|
|
@ -977,7 +977,7 @@ class TestDeleteDir:
|
||||||
|
|
||||||
def setup_method(self):
|
def setup_method(self):
|
||||||
"""Set up test directory."""
|
"""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 = Path(self.temp_dir) / "test_delete"
|
||||||
self.test_dir.mkdir()
|
self.test_dir.mkdir()
|
||||||
(self.test_dir / "file.txt").write_text("test content")
|
(self.test_dir / "file.txt").write_text("test content")
|
||||||
|
|
@ -1007,7 +1007,7 @@ class TestListFolders:
|
||||||
|
|
||||||
def setup_method(self):
|
def setup_method(self):
|
||||||
"""Set up test directory structure."""
|
"""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 = Path(self.temp_dir)
|
||||||
(self.base / "folder1").mkdir()
|
(self.base / "folder1").mkdir()
|
||||||
(self.base / "folder2").mkdir()
|
(self.base / "folder2").mkdir()
|
||||||
|
|
@ -1237,8 +1237,7 @@ class TestGetFileSidecar:
|
||||||
|
|
||||||
def test_get_file_sidecar_with_files(self):
|
def test_get_file_sidecar_with_files(self):
|
||||||
"""Test getting sidecar files when they exist."""
|
"""Test getting sidecar files when they exist."""
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with temporary_test_dir("file-sidecar") as base_path:
|
||||||
base_path = Path(temp_dir)
|
|
||||||
video_file = base_path / "video.mp4"
|
video_file = base_path / "video.mp4"
|
||||||
srt_file = base_path / "video.srt"
|
srt_file = base_path / "video.srt"
|
||||||
nfo_file = base_path / "video.nfo"
|
nfo_file = base_path / "video.nfo"
|
||||||
|
|
@ -1252,8 +1251,7 @@ class TestGetFileSidecar:
|
||||||
|
|
||||||
def test_get_file_sidecar_no_files(self):
|
def test_get_file_sidecar_no_files(self):
|
||||||
"""Test getting sidecar files when none exist."""
|
"""Test getting sidecar files when none exist."""
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with temporary_test_dir("file-sidecar-empty") as base_path:
|
||||||
base_path = Path(temp_dir)
|
|
||||||
video_file = base_path / "video.mp4"
|
video_file = base_path / "video.mp4"
|
||||||
video_file.write_text("video content")
|
video_file.write_text("video content")
|
||||||
|
|
||||||
|
|
@ -1266,7 +1264,7 @@ class TestCheckId:
|
||||||
|
|
||||||
def setup_method(self):
|
def setup_method(self):
|
||||||
"""Set up test files."""
|
"""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)
|
self.test_dir = Path(self.temp_dir)
|
||||||
|
|
||||||
def teardown_method(self):
|
def teardown_method(self):
|
||||||
|
|
@ -1347,7 +1345,7 @@ class TestGetPossibleImages:
|
||||||
|
|
||||||
def setup_method(self):
|
def setup_method(self):
|
||||||
"""Set up test directory with images."""
|
"""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)
|
self.test_dir = Path(self.temp_dir)
|
||||||
|
|
||||||
# Create some test image files
|
# Create some test image files
|
||||||
|
|
@ -1408,7 +1406,7 @@ class TestGetFile:
|
||||||
|
|
||||||
def setup_method(self):
|
def setup_method(self):
|
||||||
"""Set up test files."""
|
"""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)
|
self.download_path = Path(self.temp_dir)
|
||||||
|
|
||||||
def teardown_method(self):
|
def teardown_method(self):
|
||||||
|
|
@ -1472,7 +1470,7 @@ class TestGetFiles:
|
||||||
|
|
||||||
def setup_method(self):
|
def setup_method(self):
|
||||||
"""Set up test directory structure."""
|
"""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)
|
self.base_path = Path(self.temp_dir)
|
||||||
|
|
||||||
# Create test files and directories
|
# Create test files and directories
|
||||||
|
|
@ -1504,7 +1502,7 @@ class TestReadLogfile:
|
||||||
|
|
||||||
def setup_method(self):
|
def setup_method(self):
|
||||||
"""Set up test log file."""
|
"""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"
|
self.log_file = Path(self.temp_dir) / "test.log"
|
||||||
|
|
||||||
def teardown_method(self):
|
def teardown_method(self):
|
||||||
|
|
@ -1540,7 +1538,7 @@ class TestTailLog:
|
||||||
|
|
||||||
def setup_method(self):
|
def setup_method(self):
|
||||||
"""Set up test log file."""
|
"""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"
|
self.log_file = Path(self.temp_dir) / "test.log"
|
||||||
|
|
||||||
def teardown_method(self):
|
def teardown_method(self):
|
||||||
|
|
@ -1571,7 +1569,7 @@ class TestLoadCookies:
|
||||||
|
|
||||||
def setup_method(self):
|
def setup_method(self):
|
||||||
"""Set up test cookie file."""
|
"""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"
|
self.cookie_file = Path(self.temp_dir) / "cookies.txt"
|
||||||
|
|
||||||
def teardown_method(self):
|
def teardown_method(self):
|
||||||
|
|
@ -1640,7 +1638,7 @@ class TestLoadModules:
|
||||||
|
|
||||||
def setup_method(self):
|
def setup_method(self):
|
||||||
"""Set up test module structure."""
|
"""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.root_path = Path(self.temp_dir)
|
||||||
self.module_dir = self.root_path / "test_modules"
|
self.module_dir = self.root_path / "test_modules"
|
||||||
self.module_dir.mkdir()
|
self.module_dir.mkdir()
|
||||||
|
|
@ -1885,7 +1883,7 @@ class TestCreateCookiesFile:
|
||||||
|
|
||||||
def setup_method(self):
|
def setup_method(self):
|
||||||
"""Set up test environment."""
|
"""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)
|
self.test_path = Path(self.temp_dir)
|
||||||
|
|
||||||
def teardown_method(self):
|
def teardown_method(self):
|
||||||
|
|
|
||||||
|
|
@ -230,7 +230,13 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<DLInput
|
||||||
id="output_template"
|
id="output_template"
|
||||||
v-model="form.template"
|
v-model="form.template"
|
||||||
|
|
@ -244,18 +250,61 @@
|
||||||
>
|
>
|
||||||
<template #description>
|
<template #description>
|
||||||
<span>
|
<span>
|
||||||
All output template naming options can be found on
|
See
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="font-medium text-primary hover:underline"
|
class="font-medium text-primary hover:underline"
|
||||||
to="https://github.com/yt-dlp/yt-dlp#output-template"
|
to="https://github.com/yt-dlp/yt-dlp#output-template"
|
||||||
>
|
>
|
||||||
the yt-dlp output template page.
|
the yt-dlp output template page
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
for info.
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</DLInput>
|
</DLInput>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="grid gap-4 xl:grid-cols-2">
|
<div class="grid gap-4 xl:grid-cols-2">
|
||||||
|
|
@ -305,7 +354,7 @@
|
||||||
<template #label>
|
<template #label>
|
||||||
<span class="inline-flex items-center gap-2 font-semibold">
|
<span class="inline-flex items-center gap-2 font-semibold">
|
||||||
<UIcon name="i-lucide-cookie" class="size-4 text-toned" />
|
<UIcon name="i-lucide-cookie" class="size-4 text-toned" />
|
||||||
<span> Cookies </span>
|
<span>Cookies</span>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -509,6 +558,8 @@
|
||||||
import { useStorage } from '@vueuse/core';
|
import { useStorage } from '@vueuse/core';
|
||||||
import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue';
|
import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue';
|
||||||
import TextDropzone from '~/components/TextDropzone.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 { item_request } from '~/types/item';
|
||||||
import type { AutoCompleteOptions } from '~/types/autocomplete';
|
import type { AutoCompleteOptions } from '~/types/autocomplete';
|
||||||
import { navigateTo } from '#app';
|
import { navigateTo } from '#app';
|
||||||
|
|
@ -522,6 +573,7 @@ const emitter = defineEmits<{
|
||||||
const config = useYtpConfig();
|
const config = useYtpConfig();
|
||||||
const toast = useNotification();
|
const toast = useNotification();
|
||||||
const dialog = useDialog();
|
const dialog = useDialog();
|
||||||
|
const conditions = useConditions();
|
||||||
const { findPreset, hasPreset, selectItems, getPresetDefault } = usePresetOptions();
|
const { findPreset, hasPreset, selectItems, getPresetDefault } = usePresetOptions();
|
||||||
|
|
||||||
const showAdvanced = useStorage<boolean>('show_advanced', false);
|
const showAdvanced = useStorage<boolean>('show_advanced', false);
|
||||||
|
|
@ -541,6 +593,101 @@ const ytDlpOpt = ref<AutoCompleteOptions>([]);
|
||||||
const cookiesDropzoneRef = ref<InstanceType<typeof TextDropzone> | null>(null);
|
const cookiesDropzoneRef = ref<InstanceType<typeof TextDropzone> | null>(null);
|
||||||
const urlTextarea = ref<{ textareaRef?: HTMLTextAreaElement | null } | 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(() => {
|
const testResultsText = computed(() => {
|
||||||
if (typeof testResultsData.value === 'string') {
|
if (typeof testResultsData.value === 'string') {
|
||||||
return testResultsData.value;
|
return testResultsData.value;
|
||||||
|
|
@ -595,6 +742,15 @@ const form = useStorage<item_request>('local_config_v1', {
|
||||||
}) as Ref<item_request>;
|
}) as Ref<item_request>;
|
||||||
|
|
||||||
const presetItems = computed(() => selectItems.value);
|
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 mobileActionGroups = computed(() => {
|
||||||
const groups = [
|
const groups = [
|
||||||
|
|
@ -912,6 +1068,18 @@ watch(
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
showAdvanced,
|
||||||
|
(open) => {
|
||||||
|
if (!open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ensureIgnoreConditionsLoaded();
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue