diff --git a/app/library/Presets.py b/app/library/Presets.py index 9ddfbd74..7f68346c 100644 --- a/app/library/Presets.py +++ b/app/library/Presets.py @@ -103,6 +103,9 @@ class Preset: default: bool = False """If True, the preset is a default preset.""" + priority: int = 0 + """Priority of the preset.""" + _cookies_file: Path | None = field(init=False, default=None) """The path to the cookies file.""" @@ -219,7 +222,7 @@ class Presets(metaclass=Singleton): def get_all(self) -> list[Preset]: """Return the items.""" - return self._default + self._items + return sorted(self._default + self._items, key=lambda x: x.priority, reverse=True) def load(self) -> "Presets": """ @@ -249,6 +252,10 @@ class Presets(metaclass=Singleton): for i, preset in enumerate(presets): try: + if "priority" not in preset: + preset["priority"] = 0 + need_save = True + if "id" not in preset: preset["id"] = str(uuid.uuid4()) need_save = True @@ -272,7 +279,7 @@ class Presets(metaclass=Singleton): continue if need_save: - LOG.info(f"Saving '{self._file}'.") + LOG.warning("Saving presets due to schema changes.") self.save(self._items) return self @@ -328,6 +335,15 @@ class Presets(metaclass=Singleton): msg = f"Invalid command options for yt-dlp. '{e!s}'." raise ValueError(msg) from e + 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) + return True def save(self, items: list[Preset | dict]) -> "Presets": diff --git a/app/schema/presets.json b/app/schema/presets.json index a751d97c..13f74806 100644 --- a/app/schema/presets.json +++ b/app/schema/presets.json @@ -43,6 +43,12 @@ "type": "boolean", "description": "Whether this preset is a default preset.", "default": false + }, + "priority": { + "type": "integer", + "description": "Priority of the preset for sorting. Higher priority presets appear first.", + "default": 0, + "minimum": 0 } }, "required": [ @@ -54,12 +60,14 @@ "id": "3e163c6c-64eb-4448-924f-814b629b3810", "name": "default", "default": true, + "priority": 0, "cli": "--socket-timeout 30 --download-archive %(config_path)s/archive.log", "description": "Default preset for yt-dlp. It will download whatever yt-dlp decides is the best quality for the video and audio." }, { "id": "a6fd4b25-2b3e-458d-bb57-b75e41cc4330", "name": "Audio Only", + "priority": 5, "cli": "--extract-audio --add-chapters --embed-metadata --embed-thumbnail --format 'bestaudio/best'", "description": "This preset is designed to download only the audio of the video. It will extract the audio, add chapters, metadata, and thumbnail." } diff --git a/app/tests/test_presets.py b/app/tests/test_presets.py index 95464e8f..1da4c6dc 100644 --- a/app/tests/test_presets.py +++ b/app/tests/test_presets.py @@ -39,6 +39,7 @@ class TestPreset: cookies="test_cookies", cli="--test-option", default=True, + priority=10, ) assert preset.id == preset_id @@ -48,6 +49,7 @@ class TestPreset: assert preset.template == "test_template" assert preset.cookies == "test_cookies" assert preset.cli == "--test-option" + assert preset.priority == 10 assert preset.default is True def test_preset_serialize(self): @@ -597,3 +599,100 @@ class TestPresets: # Should have logged errors for invalid presets mock_log.error.assert_called() + + @patch("app.library.Presets.Config") + @patch("app.library.Presets.EventBus") + def test_preset_priority_validation_integer(self, mock_eventbus, mock_config): + """Test that priority must be an integer.""" + mock_config_instance = Mock(config_path="/tmp", default_preset="default") + mock_config.get_instance.return_value = mock_config_instance + mock_eventbus.get_instance.return_value = Mock() + + with tempfile.TemporaryDirectory() as temp_dir: + presets_file = Path(temp_dir) / "presets.json" + presets = Presets(file=presets_file, config=mock_config_instance) + + # Test with non-integer priority + invalid_preset = {"id": str(uuid.uuid4()), "name": "test", "priority": "not_an_int"} + + with pytest.raises(ValueError, match="Priority must be an integer"): + presets.validate(invalid_preset) + + @patch("app.library.Presets.Config") + @patch("app.library.Presets.EventBus") + def test_preset_priority_validation_negative(self, mock_eventbus, mock_config): + """Test that priority must be >= 0.""" + mock_config_instance = Mock(config_path="/tmp", default_preset="default") + mock_config.get_instance.return_value = mock_config_instance + mock_eventbus.get_instance.return_value = Mock() + + with tempfile.TemporaryDirectory() as temp_dir: + presets_file = Path(temp_dir) / "presets.json" + presets = Presets(file=presets_file, config=mock_config_instance) + + # Test with negative priority + invalid_preset = {"id": str(uuid.uuid4()), "name": "test", "priority": -5} + + with pytest.raises(ValueError, match="Priority must be >= 0"): + presets.validate(invalid_preset) + + @patch("app.library.Presets.Config") + @patch("app.library.Presets.EventBus") + def test_preset_priority_sorting(self, mock_eventbus, mock_config): + """Test that presets are sorted by priority in descending order.""" + mock_config_instance = Mock(config_path="/tmp", default_preset="default") + mock_config.get_instance.return_value = mock_config_instance + mock_eventbus.get_instance.return_value = Mock() + + with tempfile.TemporaryDirectory() as temp_dir: + presets_file = Path(temp_dir) / "presets.json" + presets = Presets(file=presets_file, config=mock_config_instance) + + # Create presets with different priorities + preset1 = Preset(name="low", priority=1) + preset2 = Preset(name="high", priority=10) + preset3 = Preset(name="medium", priority=5) + + presets.save([preset1, preset2, preset3]).load() + + all_presets = presets.get_all() + non_default = [p for p in all_presets if not p.default] + + # Should be sorted by priority descending + assert non_default[0].name == "high" + assert non_default[0].priority == 10 + assert non_default[1].name == "medium" + assert non_default[1].priority == 5 + assert non_default[2].name == "low" + assert non_default[2].priority == 1 + + @patch("app.library.Presets.Config") + @patch("app.library.Presets.EventBus") + def test_preset_priority_default_zero(self, mock_eventbus, mock_config): + """Test that priority defaults to 0 if not specified.""" + preset = Preset(name="test") + assert preset.priority == 0 + + @patch("app.library.Presets.Config") + @patch("app.library.Presets.EventBus") + def test_preset_priority_migration(self, mock_eventbus, mock_config): + """Test that old presets without priority get it added on load.""" + mock_config_instance = Mock(config_path="/tmp", default_preset="default") + mock_config.get_instance.return_value = mock_config_instance + mock_eventbus.get_instance.return_value = Mock() + + with tempfile.TemporaryDirectory() as temp_dir: + presets_file = Path(temp_dir) / "presets.json" + + # Create a preset file without priority field + old_preset_data = [{"id": str(uuid.uuid4()), "name": "old_preset", "cli": ""}] + presets_file.write_text(json.dumps(old_preset_data)) + + # Load presets - should migrate by adding priority + presets = Presets(file=presets_file, config=mock_config_instance) + presets.load() + + # Check that priority was added + loaded_data = json.loads(presets_file.read_text()) + assert "priority" in loaded_data[0] + assert loaded_data[0]["priority"] == 0 diff --git a/ui/app/components/PresetForm.vue b/ui/app/components/PresetForm.vue index 2f95e03e..0ab71337 100644 --- a/ui/app/components/PresetForm.vue +++ b/ui/app/components/PresetForm.vue @@ -86,8 +86,7 @@ - -