diff --git a/API.md b/API.md index 510674c6..9c6486df 100644 --- a/API.md +++ b/API.md @@ -1511,12 +1511,18 @@ or an error: "filter": "...", "cli": "...", "extras": {}, - "enabled": true + "enabled": true, + "priority": 0, + "description": "What this condition does" }, ... ] ``` +**Notes**: +- Conditions are evaluated in priority order (higher priority first). +- Priority defaults to 0 when not specified. + --- ### PUT /api/conditions @@ -1531,7 +1537,9 @@ or an error: "filter": "availability = 'needs_auth' & channel_id = 'channel_id'", "cli": "--proxy http://myproxy.com:8080", "extras": {}, - "enabled": true + "enabled": true, + "priority": 10, + "description": "Apply proxy for region-locked videos" }, ... ] @@ -1546,7 +1554,9 @@ or an error: "filter": "availability = 'needs_auth' & channel_id = 'channel_id'", "cli": "--proxy http://myproxy.com:8080", "extras": {}, - "enabled": true + "enabled": true, + "priority": 10, + "description": "Apply proxy for region-locked videos" }, ... ] @@ -1555,6 +1565,7 @@ or an error: **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. +- Priority determines check order. Higher priority conditions are checked first. --- diff --git a/app/library/conditions.py b/app/library/conditions.py index 118b90e1..281e2657 100644 --- a/app/library/conditions.py +++ b/app/library/conditions.py @@ -37,6 +37,12 @@ class Condition: enabled: bool = True """Whether the condition is enabled.""" + priority: int = 0 + """Priority of the condition.""" + + description: str = "" + """A description of what the condition does.""" + def serialize(self) -> dict: return self.__dict__ @@ -145,6 +151,14 @@ class Conditions(metaclass=Singleton): item["enabled"] = True need_save = True + if "priority" not in item: + item["priority"] = 0 + need_save = True + + if "description" not in item: + item["description"] = "" + need_save = True + item: Condition = init_class(Condition, item) self._items.append(item) @@ -220,6 +234,23 @@ class Conditions(metaclass=Singleton): msg = "Extras must be a dictionary." raise ValueError(msg) + if item.get("enabled") is not None and not isinstance(item.get("enabled"), bool): + msg = "Enabled must be a boolean." + raise ValueError(msg) + + if item.get("priority") is not None: + priority = item.get("priority") + if not isinstance(priority, int): + msg = "Priority must be an integer." + raise ValueError(msg) + if priority < 0: + msg = "Priority must be >= 0." + raise ValueError(msg) + + if item.get("description") and not isinstance(item.get("description"), str): + msg = "Description must be a string." + raise ValueError(msg) + return True def save(self, items: list[Condition | dict]) -> "Conditions": @@ -305,7 +336,7 @@ class Conditions(metaclass=Singleton): if len(self._items) < 1 or not info or not isinstance(info, dict) or len(info) < 1: return None - for item in self.get_all(): + for item in sorted(self.get_all(), key=lambda x: x.priority, reverse=True): if not item.enabled: continue diff --git a/app/routes/api/conditions.py b/app/routes/api/conditions.py index 3aef2412..eecc001b 100644 --- a/app/routes/api/conditions.py +++ b/app/routes/api/conditions.py @@ -97,6 +97,12 @@ async def conditions_add(request: Request, encoder: Encoder, notify: EventBus) - if "enabled" not in item: item["enabled"] = True + if "priority" not in item: + item["priority"] = 0 + + if "description" not in item: + item["description"] = "" + if not item.get("id", None) or not validate_uuid(item.get("id"), version=4): item["id"] = str(uuid.uuid4()) diff --git a/app/schema/conditions.json b/app/schema/conditions.json index 010d8444..d4159267 100644 --- a/app/schema/conditions.json +++ b/app/schema/conditions.json @@ -39,6 +39,17 @@ "type": "boolean", "description": "Whether the condition is enabled.", "default": true + }, + "priority": { + "type": "integer", + "description": "Priority of the condition.", + "default": 0, + "minimum": 0 + }, + "description": { + "type": "string", + "description": "A description of what the condition does.", + "default": "" } }, "examples": [ @@ -49,7 +60,9 @@ "extras": { "ignore_download": true }, - "enabled": true + "enabled": true, + "priority": 10, + "description": "Ignore downloads for videos longer than 1 hour." }, { "id": "b3c4d5e6-7890-3456-1cde-f23456789012", @@ -58,7 +71,9 @@ "extras": { "set_preset": "audio-only" }, - "enabled": false + "enabled": false, + "priority": 0, + "description": "Apply audio-only preset for videos in the Music category." } ] } diff --git a/app/tests/test_conditions.py b/app/tests/test_conditions.py index ca0ce03c..94fab479 100644 --- a/app/tests/test_conditions.py +++ b/app/tests/test_conditions.py @@ -212,6 +212,8 @@ class TestConditions: "cli": "--format worst", "extras": {"category": "short"}, "enabled": True, + "priority": 0, + "description": "Download short videos", }, { "id": str(uuid.uuid4()), @@ -220,6 +222,8 @@ class TestConditions: "cli": "--audio-quality 0", "extras": {"type": "audio"}, "enabled": False, + "priority": 5, + "description": "High priority music videos", }, ] @@ -237,11 +241,15 @@ class TestConditions: assert conditions._items[0].cli == "--format worst" assert conditions._items[0].extras == {"category": "short"} assert conditions._items[0].enabled is True + assert conditions._items[0].priority == 0 + assert conditions._items[0].description == "Download short videos" # Check second condition assert conditions._items[1].name == "music_videos" assert conditions._items[1].filter == "title ~= 'music'" assert conditions._items[1].enabled is False + assert conditions._items[1].priority == 5 + assert conditions._items[1].description == "High priority music videos" def test_load_conditions_without_id(self): """Test loading conditions that don't have ID (should generate ID).""" @@ -305,6 +313,48 @@ class TestConditions: # Should call save due to changes mock_save.assert_called_once() + def test_load_conditions_without_priority(self): + """Test loading conditions that don't have priority field (should default to 0).""" + with tempfile.TemporaryDirectory() as temp_dir: + file_path = Path(temp_dir) / "no_priority_conditions.json" + + test_data = [{"id": str(uuid.uuid4()), "name": "no_priority_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 priority=0 + assert len(conditions._items) == 1 + assert conditions._items[0].priority == 0 + + # Should call save due to changes + mock_save.assert_called_once() + + def test_load_conditions_without_description(self): + """Test loading conditions that don't have description field (should default to empty string).""" + with tempfile.TemporaryDirectory() as temp_dir: + file_path = Path(temp_dir) / "no_description_conditions.json" + + test_data = [ + {"id": str(uuid.uuid4()), "name": "no_description_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 description='' + assert len(conditions._items) == 1 + assert conditions._items[0].description == "" + + # 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: @@ -449,6 +499,74 @@ class TestConditions: with pytest.raises(ValueError, match="Extras must be a dictionary"): conditions.validate(invalid_condition) + def test_validate_invalid_enabled_type(self): + """Test validating condition with non-boolean enabled field.""" + with tempfile.TemporaryDirectory() as temp_dir: + file_path = Path(temp_dir) / "invalid_enabled.json" + conditions = Conditions(file=file_path) + + invalid_condition = { + "id": str(uuid.uuid4()), + "name": "invalid_enabled_test", + "filter": "duration > 60", + "extras": {}, + "enabled": "yes", # Should be boolean, not string + } + + with pytest.raises(ValueError, match="Enabled must be a boolean"): + conditions.validate(invalid_condition) + + def test_validate_invalid_priority_type(self): + """Test validating condition with non-integer priority field.""" + with tempfile.TemporaryDirectory() as temp_dir: + file_path = Path(temp_dir) / "invalid_priority_type.json" + conditions = Conditions(file=file_path) + + invalid_condition = { + "id": str(uuid.uuid4()), + "name": "invalid_priority_type_test", + "filter": "duration > 60", + "extras": {}, + "priority": "10", # Should be integer, not string + } + + with pytest.raises(ValueError, match="Priority must be an integer"): + conditions.validate(invalid_condition) + + def test_validate_invalid_priority_negative(self): + """Test validating condition with negative priority.""" + with tempfile.TemporaryDirectory() as temp_dir: + file_path = Path(temp_dir) / "invalid_priority_negative.json" + conditions = Conditions(file=file_path) + + invalid_condition = { + "id": str(uuid.uuid4()), + "name": "invalid_priority_negative_test", + "filter": "duration > 60", + "extras": {}, + "priority": -5, # Should be >= 0 + } + + with pytest.raises(ValueError, match="Priority must be >= 0"): + conditions.validate(invalid_condition) + + def test_validate_invalid_description_type(self): + """Test validating condition with non-string description field.""" + with tempfile.TemporaryDirectory() as temp_dir: + file_path = Path(temp_dir) / "invalid_description.json" + conditions = Conditions(file=file_path) + + invalid_condition = { + "id": str(uuid.uuid4()), + "name": "invalid_description_test", + "filter": "duration > 60", + "extras": {}, + "description": 123, # Should be string, not integer + } + + with pytest.raises(ValueError, match="Description must be a string"): + conditions.validate(invalid_condition) + def test_validate_invalid_item_type(self): """Test validating invalid item type.""" with tempfile.TemporaryDirectory() as temp_dir: @@ -661,6 +779,32 @@ class TestConditions: # Should only call match_str once for enabled condition mock_match_str.assert_called_once_with("duration > 120", info_dict) + @patch("app.library.conditions.match_str") + def test_match_priority_sorting(self, mock_match_str): + """Test that conditions are evaluated by priority (highest first).""" + # All filters will match, so we test that highest priority wins + mock_match_str.return_value = True + + with tempfile.TemporaryDirectory() as temp_dir: + file_path = Path(temp_dir) / "priority_test.json" + conditions = Conditions(file=file_path) + + # Add conditions with different priorities (not in priority order) + low_priority = Condition(name="low", filter="duration > 60", priority=1) + high_priority = Condition(name="high", filter="duration > 60", priority=10) + default_priority = Condition(name="default", filter="duration > 60", priority=0) + + # Add in wrong order to verify sorting + conditions._items = [low_priority, default_priority, high_priority] + + info_dict = {"duration": 120} + result = conditions.match(info_dict) + + # Should match high_priority first (priority=10) + assert result is high_priority + # Should only call match_str once for highest priority condition + mock_match_str.assert_called_once_with("duration > 60", info_dict) + def test_match_empty_filter(self): """Test matching with condition that has empty filter.""" with tempfile.TemporaryDirectory() as temp_dir: diff --git a/ui/app/components/ConditionForm.vue b/ui/app/components/ConditionForm.vue index dc926963..dfe0bd2c 100644 --- a/ui/app/components/ConditionForm.vue +++ b/ui/app/components/ConditionForm.vue @@ -49,7 +49,7 @@ -