Refactor: add enabled field to conditions.

This commit is contained in:
arabcoders 2026-01-04 16:47:32 +03:00
parent 1c5b4f344d
commit 0f7e4effd8
8 changed files with 160 additions and 12 deletions

11
API.md
View file

@ -1510,7 +1510,8 @@ or an error:
"name": "condition_name",
"filter": "...",
"cli": "...",
...
"extras": {},
"enabled": true
},
...
]
@ -1529,6 +1530,8 @@ or an error:
"name": "Use proxy for region locked content",
"filter": "availability = 'needs_auth' & channel_id = 'channel_id'",
"cli": "--proxy http://myproxy.com:8080",
"extras": {},
"enabled": true
},
...
]
@ -1542,11 +1545,17 @@ or an error:
"name": "Use proxy for region locked content",
"filter": "availability = 'needs_auth' & channel_id = 'channel_id'",
"cli": "--proxy http://myproxy.com:8080",
"extras": {},
"enabled": true
},
...
]
```
**Notes**:
- Disabled conditions (`enabled: false`) will be stored but ignored during matching.
- All conditions are enabled by default when the `enabled` field is not provided.
---
### POST /api/conditions/test

View file

@ -34,6 +34,9 @@ class Condition:
extras: dict[str, Any] = field(default_factory=dict)
"""Any extra data to store with the condition."""
enabled: bool = True
"""Whether the condition is enabled."""
def serialize(self) -> dict:
return self.__dict__
@ -138,6 +141,10 @@ class Conditions(metaclass=Singleton):
item["extras"] = {}
need_save = True
if "enabled" not in item:
item["enabled"] = True
need_save = True
item: Condition = init_class(Condition, item)
self._items.append(item)
@ -146,7 +153,7 @@ class Conditions(metaclass=Singleton):
continue
if need_save:
LOG.info("Saving conditions due changes.")
LOG.warning("Saving conditions due to schema changes.")
self.save(self._items)
return self
@ -299,6 +306,9 @@ class Conditions(metaclass=Singleton):
return None
for item in self.get_all():
if not item.enabled:
continue
if not item.filter:
LOG.error(f"Filter is empty for '{item.name}'.")
continue
@ -330,7 +340,7 @@ class Conditions(metaclass=Singleton):
return None
item = self.get(name)
if not item or not item.filter:
if not item or not item.enabled or not item.filter:
return None
return item if match_str(item.filter, info) else None

View file

@ -94,6 +94,9 @@ async def conditions_add(request: Request, encoder: Encoder, notify: EventBus) -
if not item.get("extras"):
item["extras"] = {}
if "enabled" not in item:
item["enabled"] = True
if not item.get("id", None) or not validate_uuid(item.get("id"), version=4):
item["id"] = str(uuid.uuid4())

View file

@ -34,6 +34,11 @@
"type": "object",
"description": "Any extra data to store with the condition.",
"default": {}
},
"enabled": {
"type": "boolean",
"description": "Whether the condition is enabled.",
"default": true
}
},
"examples": [
@ -43,7 +48,8 @@
"filter": "duration>3600",
"extras": {
"ignore_download": true
}
},
"enabled": true
},
{
"id": "b3c4d5e6-7890-3456-1cde-f23456789012",
@ -51,7 +57,8 @@
"filter": "categories~='Music'",
"extras": {
"set_preset": "audio-only"
}
},
"enabled": false
}
]
}

View file

@ -41,6 +41,7 @@ class TestCondition:
# Check defaults
assert condition.cli == ""
assert condition.extras == {}
assert condition.enabled is True
def test_condition_creation_with_all_fields(self):
"""Test creating a condition with all fields specified."""
@ -48,7 +49,7 @@ class TestCondition:
extras = {"key": "value", "number": 42}
condition = Condition(
id=test_id, name="full_test", filter="uploader = 'test'", cli="--format best", extras=extras
id=test_id, name="full_test", filter="uploader = 'test'", cli="--format best", extras=extras, enabled=False
)
assert condition.id == test_id
@ -56,6 +57,7 @@ class TestCondition:
assert condition.filter == "uploader = 'test'"
assert condition.cli == "--format best"
assert condition.extras == extras
assert condition.enabled is False
def test_condition_serialize(self):
"""Test condition serialization to dict."""
@ -209,6 +211,7 @@ class TestConditions:
"filter": "duration < 300",
"cli": "--format worst",
"extras": {"category": "short"},
"enabled": True,
},
{
"id": str(uuid.uuid4()),
@ -216,6 +219,7 @@ class TestConditions:
"filter": "title ~= 'music'",
"cli": "--audio-quality 0",
"extras": {"type": "audio"},
"enabled": False,
},
]
@ -232,10 +236,12 @@ class TestConditions:
assert conditions._items[0].filter == "duration < 300"
assert conditions._items[0].cli == "--format worst"
assert conditions._items[0].extras == {"category": "short"}
assert conditions._items[0].enabled is True
# Check second condition
assert conditions._items[1].name == "music_videos"
assert conditions._items[1].filter == "title ~= 'music'"
assert conditions._items[1].enabled is False
def test_load_conditions_without_id(self):
"""Test loading conditions that don't have ID (should generate ID)."""
@ -279,6 +285,26 @@ class TestConditions:
# Should call save due to changes
mock_save.assert_called_once()
def test_load_conditions_without_enabled(self):
"""Test loading conditions that don't have enabled field (should default to True)."""
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "no_enabled_conditions.json"
test_data = [{"id": str(uuid.uuid4()), "name": "no_enabled_test", "filter": "duration > 60", "extras": {}}]
file_path.write_text(json.dumps(test_data))
with patch.object(Conditions, "save") as mock_save:
conditions = Conditions(file=file_path)
conditions.load()
# Should have generated enabled=True
assert len(conditions._items) == 1
assert conditions._items[0].enabled is True
# Should call save due to changes
mock_save.assert_called_once()
def test_load_invalid_json(self):
"""Test loading file with invalid JSON."""
with tempfile.TemporaryDirectory() as temp_dir:
@ -613,6 +639,28 @@ class TestConditions:
assert result is None
@patch("app.library.conditions.match_str")
def test_match_disabled_condition(self, mock_match_str):
"""Test that disabled conditions are skipped during matching."""
mock_match_str.return_value = True
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "disabled_match_test.json"
conditions = Conditions(file=file_path)
# Add disabled condition
disabled_condition = Condition(name="disabled_test", filter="duration > 60", enabled=False)
enabled_condition = Condition(name="enabled_test", filter="duration > 120", enabled=True)
conditions._items = [disabled_condition, enabled_condition]
info_dict = {"duration": 150}
result = conditions.match(info_dict)
# Should skip disabled condition and match enabled one
assert result is enabled_condition
# Should only call match_str once for enabled condition
mock_match_str.assert_called_once_with("duration > 120", info_dict)
def test_match_empty_filter(self):
"""Test matching with condition that has empty filter."""
with tempfile.TemporaryDirectory() as temp_dir:
@ -687,6 +735,21 @@ class TestConditions:
assert result is None
def test_single_match_disabled_condition(self):
"""Test single matching with disabled condition."""
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "disabled_single_test.json"
conditions = Conditions(file=file_path)
test_condition = Condition(name="disabled_single", filter="duration > 60", enabled=False)
conditions._items = [test_condition]
info_dict = {"duration": 120}
result = conditions.single_match("disabled_single", info_dict)
# Should return None because condition is disabled
assert result is None
def test_single_match_invalid_inputs(self):
"""Test single matching with invalid inputs."""
with tempfile.TemporaryDirectory() as temp_dir:

View file

@ -49,7 +49,7 @@
</span>
</div>
<div class="column is-12">
<div class="column is-9-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="name">
<span class="icon"><i class="fa-solid fa-tag" /></span>
@ -66,6 +66,26 @@
</div>
</div>
<div class="column is-3-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="enabled">
<span class="icon"><i class="fa-solid fa-power-off" /></span>
Enabled
</label>
<div class="control is-unselectable">
<input id="enabled" type="checkbox" v-model="form.enabled" :disabled="addInProgress"
class="switch is-success" />
<label for="enabled" class="is-unselectable">
{{ form.enabled ? 'Yes' : 'No' }}
</label>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span class="is-bold">Whether the condition is enabled.</span>
</span>
</div>
</div>
<div class="column is-12">
<div class="field">
<label class="label is-inline" for="filter">
@ -276,6 +296,7 @@
</template>
<script setup lang="ts">
import 'assets/css/bulma-switch.css'
import { useStorage } from '@vueuse/core'
import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue'
import type { AutoCompleteOptions } from '~/types/autocomplete';
@ -443,6 +464,10 @@ const importItem = async (): Promise<void> => {
form.extras = { ...item.extras }
}
if (item.enabled !== undefined) {
form.enabled = item.enabled
}
import_string.value = ''
showImport.value = false
} catch (e: any) {

View file

@ -50,9 +50,19 @@
<header class="card-header">
<div class="card-header-title is-text-overflow is-block" v-text="cond.name" />
<div class="card-header-icon">
<a class="has-text-info" v-tooltip="'Export item.'" @click.prevent="exportItem(cond)">
<span class="icon"><i class="fa-solid fa-file-export" /></span>
</a>
<div class="field is-grouped">
<div class="control" @click="toggleEnabled(cond)">
<span class="icon" :class="cond.enabled ? 'has-text-success' : 'has-text-danger'"
v-tooltip="`Condition is ${cond.enabled !== false ? 'enabled' : 'disabled'}. Click to toggle.`">
<i class="fa-solid" :class="cond.enabled ? 'fa-check-circle' : 'fa-times-circle'" />
</span>
</div>
<div class="control">
<a class="has-text-info" v-tooltip="'Export item'" @click.prevent="exportItem(cond)">
<span class="icon"><i class="fa-solid fa-file-export" /></span>
</a>
</div>
</div>
</div>
</header>
<div class="card-content is-flex-grow-1">
@ -193,11 +203,11 @@ const updateItems = async (newItems: ConditionItem[]): Promise<boolean> => {
try {
addInProgress.value = true
const validItems = newItems.map(({ id, name, filter, cli, extras }) => {
const validItems = newItems.map(({ id, name, filter, cli, extras, enabled }) => {
if (!name || !filter) {
throw new Error('Name and filter are required.')
}
return { id, name, filter, cli, extras }
return { id, name, filter, cli, extras, enabled }
})
const response = await request('/api/conditions', {
@ -273,6 +283,26 @@ const editItem = (_item: ConditionItem): void => {
toggleForm.value = true
}
const toggleEnabled = async (cond: ConditionItem): Promise<void> => {
const index = items.value.findIndex(t => t?.id === cond.id)
if (-1 === index) {
toast.error('Item not found.')
return
}
const item = items.value[index]
if (!item) {
toast.error('Item not found.')
return
}
item.enabled = !item.enabled
const status = await updateItems(items.value)
if (status) {
toast[item.enabled ? 'success' : 'warning'](`Condition is ${item.enabled ? 'enabled' : 'disabled'}.`)
}
}
const exportItem = (cond: ConditionItem): void => {
const clone: Partial<ImportedConditionItem> = JSON.parse(JSON.stringify(cond))
delete clone.id

View file

@ -4,6 +4,7 @@ export type ConditionItem = {
filter: string
cli: string
extras: Record<string, any>
enabled: boolean
}
export type ImportedConditionItem = ConditionItem & {