feat: add support for ignoring conditions in downloads and UI
This commit is contained in:
parent
fc6bc88e61
commit
084f5082ad
6 changed files with 410 additions and 7 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.
|
||||
"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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}'."
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
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()
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue