Refactor: add enabled field to conditions.
This commit is contained in:
parent
1c5b4f344d
commit
0f7e4effd8
8 changed files with 160 additions and 12 deletions
11
API.md
11
API.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1
ui/app/types/conditions.d.ts
vendored
1
ui/app/types/conditions.d.ts
vendored
|
|
@ -4,6 +4,7 @@ export type ConditionItem = {
|
|||
filter: string
|
||||
cli: string
|
||||
extras: Record<string, any>
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export type ImportedConditionItem = ConditionItem & {
|
||||
|
|
|
|||
Loading…
Reference in a new issue