diff --git a/API.md b/API.md index ea1f1002..20514c2f 100644 --- a/API.md +++ b/API.md @@ -348,7 +348,10 @@ or an error: "cookies": "...", // -- optional. If provided, it MUST BE in Netscape HTTP Cookie format. "template": "%(title)s.%(ext)s", // -- optional. The filename template to use for this item. "cli": "--write-subs --embed-subs", // -- optional. Additional command options for yt-dlp to apply to this item. - "auto_start": true // -- optional. Whether to auto-start the download after adding it. Defaults to true. + "auto_start": true, // -- optional. Whether to auto-start the download after adding it. Defaults to true. + "extras": { + "ignore_conditions": ["123", "My Condition"] // -- optional. Skip matching conditions by id string or exact name for this request only. Use ["*"] to ignore all conditions. + } } // Or multiple items (array of objects) diff --git a/app/features/conditions/service.py b/app/features/conditions/service.py index d288e8bd..de8bdf7b 100644 --- a/app/features/conditions/service.py +++ b/app/features/conditions/service.py @@ -1,4 +1,6 @@ import logging +from collections.abc import Iterable +from numbers import Number from aiohttp import web @@ -11,6 +13,30 @@ from app.library.Singleton import Singleton LOG: logging.Logger = logging.getLogger("feature.conditions") +def _ignored_identifiers(ignore_conditions: Iterable[str | Number] | None) -> tuple[set[str], bool]: + ignored: set[str] = set() + ignore_all = False + + if not ignore_conditions: + return ignored, ignore_all + + for value in ignore_conditions: + if isinstance(value, bool) or not isinstance(value, (str, Number)): + continue + + identifier = str(value).strip() + if not identifier: + continue + + if "*" == identifier: + ignore_all = True + continue + + ignored.add(identifier) + + return ignored, ignore_all + + class Conditions(metaclass=Singleton): def __init__(self): self._repo: ConditionsRepository = ConditionsRepository.get_instance() @@ -87,12 +113,13 @@ class Conditions(metaclass=Singleton): repo = self._repo return await repo.get(identifier) - async def match(self, info: dict) -> ConditionModel | None: + async def match(self, info: dict, ignore_conditions: Iterable[str | Number] | None = None) -> ConditionModel | None: """ Check if any condition matches the info dict. Args: info (dict): The info dict to check. + ignore_conditions (Iterable[str | Number] | None): Condition ids or names to skip for this match. Returns: Condition|None: The condition if found, None otherwise. @@ -101,6 +128,10 @@ class Conditions(metaclass=Singleton): if not info or not isinstance(info, dict) or len(info) < 1: return None + ignored_identifiers, ignore_all = _ignored_identifiers(ignore_conditions) + if ignore_all: + return None + repo = self._repo items: list[ConditionModel] = await repo.list() if len(items) < 1: @@ -110,6 +141,9 @@ class Conditions(metaclass=Singleton): if not item.enabled: continue + if str(item.id) in ignored_identifiers or item.name in ignored_identifiers: + continue + if not item.filter: LOG.error(f"Filter is empty for '{item.name}'.") continue diff --git a/app/library/downloads/item_adder.py b/app/library/downloads/item_adder.py index 7ebdb967..103cda7d 100644 --- a/app/library/downloads/item_adder.py +++ b/app/library/downloads/item_adder.py @@ -2,6 +2,7 @@ import asyncio import logging import time import uuid +from numbers import Number from pathlib import Path from typing import TYPE_CHECKING @@ -28,6 +29,28 @@ if TYPE_CHECKING: LOG: logging.Logger = logging.getLogger("downloads.add") +def _get_ignored_conditions(extras: dict | None) -> list[str]: + if not extras or not isinstance(extras, dict): + return [] + + ignore_conditions = extras.get("ignore_conditions") + if not isinstance(ignore_conditions, list): + return [] + + ignored: list[str] = [] + for value in ignore_conditions: + if isinstance(value, bool) or not isinstance(value, (str, Number)): + continue + + identifier = str(value).strip() + if not identifier: + continue + + ignored.append(identifier) + + return ignored + + async def add_item( queue: "DownloadQueue", entry: dict, @@ -253,7 +276,11 @@ async def add( ) return {"status": "error", "msg": message, "hidden": True} - if not item.requeued and (condition := await Conditions.get_instance().match(info=entry)): + ignored_conditions = _get_ignored_conditions(item.extras) + + if not item.requeued and ( + condition := await Conditions.get_instance().match(info=entry, ignore_conditions=ignored_conditions) + ): already.pop() message = f"Condition '{condition.name}' matched for '{item!r}'." diff --git a/app/library/downloads/playlist_processor.py b/app/library/downloads/playlist_processor.py index 62166d10..cde700bf 100644 --- a/app/library/downloads/playlist_processor.py +++ b/app/library/downloads/playlist_processor.py @@ -83,6 +83,10 @@ async def process_playlist( if "thumbnail" not in etr and "youtube" in str(extractor_key).lower(): extras["thumbnail"] = "https://img.youtube.com/vi/{id}/maxresdefault.jpg".format(**etr) + ignore_conditions = item.extras.get("ignore_conditions") if isinstance(item.extras, dict) else None + if isinstance(ignore_conditions, list) and ignore_conditions: + extras["ignore_conditions"] = ignore_conditions + newItem: Item = item.new_with(url=etr.get("url") or etr.get("webpage_url"), extras=extras) if ("video" == etr.get("_type") and etr.get("url")) or ( diff --git a/app/tests/test_condition_ignore.py b/app/tests/test_condition_ignore.py new file mode 100644 index 00000000..f107b263 --- /dev/null +++ b/app/tests/test_condition_ignore.py @@ -0,0 +1,167 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from app.features.conditions.service import Conditions +from app.library.ItemDTO import Item +from app.library.downloads.item_adder import add +from app.library.downloads.playlist_processor import process_playlist + + +@pytest.fixture(autouse=True) +def reset_conditions_singleton(): + Conditions._reset_singleton() + yield + Conditions._reset_singleton() + + +class TestConditionIgnoreMatching: + @pytest.mark.asyncio + async def test_match_skips_condition_by_name_and_returns_next_match(self) -> None: + first = SimpleNamespace(id=1, name="Primary", enabled=True, filter="duration > 0", priority=20) + second = SimpleNamespace(id=2, name="Fallback", enabled=True, filter="duration > 0", priority=10) + + service = Conditions.get_instance() + service._repo = Mock(list=AsyncMock(return_value=[first, second])) + + matched = await service.match(info={"duration": 60}, ignore_conditions=["Primary"]) + + assert matched is second + + @pytest.mark.asyncio + async def test_match_skips_condition_by_stringified_id_and_returns_next_match(self) -> None: + first = SimpleNamespace(id=123, name="Primary", enabled=True, filter="duration > 0", priority=20) + second = SimpleNamespace(id=124, name="Fallback", enabled=True, filter="duration > 0", priority=10) + + service = Conditions.get_instance() + service._repo = Mock(list=AsyncMock(return_value=[first, second])) + + matched = await service.match(info={"duration": 60}, ignore_conditions=["123"]) + + assert matched is second + + @pytest.mark.asyncio + async def test_match_coerces_numeric_ignore_values_to_strings(self) -> None: + first = SimpleNamespace(id=123, name="Primary", enabled=True, filter="duration > 0", priority=20) + second = SimpleNamespace(id=124, name="Fallback", enabled=True, filter="duration > 0", priority=10) + + service = Conditions.get_instance() + service._repo = Mock(list=AsyncMock(return_value=[first, second])) + + matched = await service.match(info={"duration": 60}, ignore_conditions=[123]) + + assert matched is second + + @pytest.mark.asyncio + async def test_match_returns_none_when_ignore_all_wildcard_is_present(self) -> None: + first = SimpleNamespace(id=1, name="Primary", enabled=True, filter="duration > 0", priority=20) + + service = Conditions.get_instance() + service._repo = Mock(list=AsyncMock(return_value=[first])) + + matched = await service.match(info={"duration": 60}, ignore_conditions=["*"]) + + assert matched is None + + +class TestConditionIgnorePropagation: + @pytest.mark.asyncio + async def test_item_adder_passes_ignore_conditions_to_matcher(self) -> None: + queue = Mock() + queue.config = Mock(temp_path="/tmp", ignore_archived_items=False, ytdlp_debug=False) + queue._notify = Mock() + + item = Item( + url="https://example.com/watch?v=test", + preset="default", + extras={"ignore_conditions": [" 123 ", "Primary", "", " * "]}, + ) + item.get_ytdlp_opts = Mock(return_value=Mock(get_all=Mock(return_value={}))) + item.get_archive_id = Mock(return_value=None) + item.get_archive_file = Mock(return_value=None) + item.is_archived = Mock(return_value=False) + + matcher = Mock(match=AsyncMock(return_value=None)) + entry = {"id": "video-1", "duration": 60} + + with ( + patch( + "app.library.downloads.item_adder.Presets.get_instance", return_value=Mock(get=Mock(return_value=None)) + ), + patch("app.library.downloads.item_adder.Conditions.get_instance", return_value=matcher), + patch("app.library.downloads.item_adder.ytdlp_reject", return_value=(True, "")), + patch( + "app.library.downloads.item_adder.add_item", new=AsyncMock(return_value={"status": "ok"}) + ) as add_item_mock, + ): + result = await add(queue=queue, item=item, entry=entry) + + matcher.match.assert_awaited_once_with(info=entry, ignore_conditions=["123", "Primary", "*"]) + add_item_mock.assert_awaited_once() + assert result == {"status": "ok"} + + @pytest.mark.asyncio + async def test_item_adder_coerces_numeric_ignore_conditions_to_strings(self) -> None: + queue = Mock() + queue.config = Mock(temp_path="/tmp", ignore_archived_items=False, ytdlp_debug=False) + queue._notify = Mock() + + item = Item( + url="https://example.com/watch?v=test", + preset="default", + extras={"ignore_conditions": [123, "Primary", True, " * "]}, + ) + item.get_ytdlp_opts = Mock(return_value=Mock(get_all=Mock(return_value={}))) + item.get_archive_id = Mock(return_value=None) + item.get_archive_file = Mock(return_value=None) + item.is_archived = Mock(return_value=False) + + matcher = Mock(match=AsyncMock(return_value=None)) + entry = {"id": "video-1", "duration": 60} + + with ( + patch( + "app.library.downloads.item_adder.Presets.get_instance", return_value=Mock(get=Mock(return_value=None)) + ), + patch("app.library.downloads.item_adder.Conditions.get_instance", return_value=matcher), + patch("app.library.downloads.item_adder.ytdlp_reject", return_value=(True, "")), + patch("app.library.downloads.item_adder.add_item", new=AsyncMock(return_value={"status": "ok"})), + ): + await add(queue=queue, item=item, entry=entry) + + matcher.match.assert_awaited_once_with(info=entry, ignore_conditions=["123", "Primary", "*"]) + + @pytest.mark.asyncio + async def test_process_playlist_preserves_only_parent_ignore_conditions(self) -> None: + captured: dict[str, object] = {} + + class FakeItem: + def __init__(self) -> None: + self.extras = {"ignore_conditions": ["*", "Primary"], "source_name": "Manual"} + + def get_ytdlp_opts(self): + return Mock(get_all=Mock(return_value={})) + + def new_with(self, **kwargs): + captured.update(kwargs) + return SimpleNamespace(extras=kwargs["extras"], url=kwargs["url"]) + + queue = Mock(add=AsyncMock(return_value={"status": "ok"})) + entry = { + "_type": "playlist", + "id": "playlist-1", + "title": "Playlist", + "extractor": "youtube", + "entries": [{"_type": "video", "id": "video-1", "title": "Video 1", "url": "https://example.com/v/1"}], + } + + with patch("app.library.downloads.playlist_processor.ytdlp_reject", return_value=(True, "")): + result = await process_playlist(queue=queue, entry=entry, item=FakeItem()) + + assert result == {"status": "ok"} + assert captured["url"] == "https://example.com/v/1" + assert captured["extras"]["ignore_conditions"] == ["*", "Primary"] + assert captured["extras"]["playlist"] == "Playlist" + assert "source_name" not in captured["extras"] + queue.add.assert_awaited_once() diff --git a/ui/app/components/NewDownload.vue b/ui/app/components/NewDownload.vue index a0f28f6e..b54fd8a0 100644 --- a/ui/app/components/NewDownload.vue +++ b/ui/app/components/NewDownload.vue @@ -230,7 +230,13 @@ /> -
+
+ + + + + + + + + +
@@ -305,7 +354,7 @@ @@ -509,6 +558,8 @@ import { useStorage } from '@vueuse/core'; import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue'; import TextDropzone from '~/components/TextDropzone.vue'; +import { useConditions } from '~/composables/useConditions'; +import type { Condition } from '~/types/conditions'; import type { item_request } from '~/types/item'; import type { AutoCompleteOptions } from '~/types/autocomplete'; import { navigateTo } from '#app'; @@ -522,6 +573,7 @@ const emitter = defineEmits<{ const config = useYtpConfig(); const toast = useNotification(); const dialog = useDialog(); +const conditions = useConditions(); const { findPreset, hasPreset, selectItems, getPresetDefault } = usePresetOptions(); const showAdvanced = useStorage('show_advanced', false); @@ -541,6 +593,101 @@ const ytDlpOpt = ref([]); const cookiesDropzoneRef = ref | null>(null); const urlTextarea = ref<{ textareaRef?: HTMLTextAreaElement | null } | null>(null); +type IgnoreConditionOption = { + value: string; + label: string; +}; + +const normalizeIgnoreConditionValues = (value: unknown): string[] => { + if (!Array.isArray(value)) { + return []; + } + + return value + .map((entry) => { + if ('boolean' === typeof entry) { + return ''; + } + + if ('number' === typeof entry || 'string' === typeof entry) { + return String(entry).trim(); + } + + return ''; + }) + .filter((entry, index, items) => !!entry && items.indexOf(entry) === index); +}; + +const buildIgnoreConditionOptions = ( + items: readonly Condition[], + selectedValues: readonly string[] = [], +): IgnoreConditionOption[] => { + const options = items + .filter((condition) => condition.enabled) + .map((condition) => ({ + value: + condition.id !== undefined && condition.id !== null ? String(condition.id) : condition.name, + label: condition.name, + })); + + const missingSelectedItems = selectedValues + .filter((value) => value !== '*' && !options.some((item) => item.value === value)) + .map((value) => ({ + value, + label: value, + })); + + return [ + { + value: '*', + label: 'All conditions', + }, + ...options, + ...missingSelectedItems, + ]; +}; + +const mergeIgnoreConditionsIntoExtras = ( + extras: Record | undefined, + ignoreConditions: string[], +): Record => { + const nextExtras = { ...(extras || {}) }; + + if (ignoreConditions.length > 0) { + nextExtras.ignore_conditions = [...ignoreConditions]; + return nextExtras; + } + + delete nextExtras.ignore_conditions; + return nextExtras; +}; + +const hasAllConditionsLoaded = computed(() => { + const loaded = conditions.conditions.value.length; + const total = conditions.pagination.value.total; + + return loaded > 0 && (total <= 0 || loaded >= total); +}); + +const ensureIgnoreConditionsLoaded = async (): Promise => { + if (conditions.isLoading.value || hasAllConditionsLoaded.value) { + return; + } + + await conditions.loadConditions(1, 1000); +}; + +const selectedIgnoreConditions = computed({ + get: () => normalizeIgnoreConditionValues(form.value?.extras?.ignore_conditions), + set: (values) => { + const extras = mergeIgnoreConditionsIntoExtras( + form.value?.extras, + normalizeIgnoreConditionValues(values), + ); + form.value.extras = Object.keys(extras).length > 0 ? extras : {}; + }, +}); + const testResultsText = computed(() => { if (typeof testResultsData.value === 'string') { return testResultsData.value; @@ -595,6 +742,15 @@ const form = useStorage('local_config_v1', { }) as Ref; const presetItems = computed(() => selectItems.value); +const ignoreConditionOptions = computed(() => + buildIgnoreConditionOptions( + conditions.conditions.value, + normalizeIgnoreConditionValues(form.value?.extras?.ignore_conditions), + ), +); +const hasIgnoreConditionOptions = computed(() => + ignoreConditionOptions.value.some((option) => option.value !== '*'), +); const mobileActionGroups = computed(() => { const groups = [ @@ -912,6 +1068,18 @@ watch( { immediate: true }, ); +watch( + showAdvanced, + (open) => { + if (!open) { + return; + } + + void ensureIgnoreConditionsLoaded(); + }, + { immediate: true }, +); + onMounted(async () => { await nextTick();