From 63f6ca0cf4b0d94fef39e298b36c16c74934e58c Mon Sep 17 00:00:00 2001 From: arabcoders Date: Fri, 12 Sep 2025 11:45:22 +0300 Subject: [PATCH 1/5] fixed last calls to emit that were awaiting --- app/library/DownloadQueue.py | 18 +++++++----------- app/library/Tasks.py | 29 ++++++++++++----------------- 2 files changed, 19 insertions(+), 28 deletions(-) diff --git a/app/library/DownloadQueue.py b/app/library/DownloadQueue.py index d77fd066..ee86c939 100644 --- a/app/library/DownloadQueue.py +++ b/app/library/DownloadQueue.py @@ -639,7 +639,7 @@ class DownloadQueue(metaclass=Singleton): item.template = _preset.template yt_conf = {} - cookie_file = Path(self.config.temp_path) / f"c_{uuid.uuid4().hex}.txt" + cookie_file: Path = Path(self.config.temp_path) / f"c_{uuid.uuid4().hex}.txt" LOG.info(f"Adding '{item.__repr__()}'.") @@ -780,16 +780,12 @@ class DownloadQueue(metaclass=Singleton): self.done.put(dlInfo) LOG.info(log_message) - await asyncio.gather( - *[ - self._notify.emit(Events.LOG_INFO, data={}, title="Ignored Download", message=log_message), - self._notify.emit( - Events.ITEM_MOVED, - data={"to": "history", "preset": dlInfo.info.preset, "item": dlInfo.info}, - title="Download History Update", - message=f"Download history updated with '{item.url}'.", - ), - ] + self._notify.emit(Events.LOG_INFO, data={}, title="Ignored Download", message=log_message) + self._notify.emit( + Events.ITEM_MOVED, + data={"to": "history", "preset": dlInfo.info.preset, "item": dlInfo.info}, + title="Download History Update", + message=f"Download history updated with '{item.url}'.", ) return {"status": "ok"} diff --git a/app/library/Tasks.py b/app/library/Tasks.py index 569befcf..b440be26 100644 --- a/app/library/Tasks.py +++ b/app/library/Tasks.py @@ -431,23 +431,18 @@ class Tasks(metaclass=Singleton): timeNow = datetime.now(UTC).isoformat() ended: float = time.time() LOG.info(f"Task '{task.name}' completed at '{timeNow}' took '{ended - started:.2f}' seconds.") - - _tasks = [ - self._notify.emit( - Events.TASK_DISPATCHED, - data={**status, "preset": task.preset} if status else {"preset": task.preset}, - title=f"Task '{task.name}' dispatched", - message=f"Task '{task.name}' dispatched at '{timeNow}'.", - ), - self._notify.emit( - Events.LOG_SUCCESS, - data={"preset": task.preset, "lowPriority": True}, - title="Task completed", - message=f"Task '{task.name}' completed in '{ended - started:.2f}'.", - ), - ] - - await asyncio.gather(*_tasks) + self._notify.emit( + Events.TASK_DISPATCHED, + data={**status, "preset": task.preset} if status else {"preset": task.preset}, + title=f"Task '{task.name}' dispatched", + message=f"Task '{task.name}' dispatched at '{timeNow}'.", + ) + self._notify.emit( + Events.LOG_SUCCESS, + data={"preset": task.preset, "lowPriority": True}, + title="Task completed", + message=f"Task '{task.name}' completed in '{ended - started:.2f}'.", + ) except Exception as e: LOG.error(f"Failed to execute '{task.name}' at '{timeNow}'. '{e!s}'.") self._notify.emit( From 5d1eed876bf5084ed8c85d4f8800391f1f72d1cd Mon Sep 17 00:00:00 2001 From: arabcoders Date: Fri, 12 Sep 2025 17:09:02 +0300 Subject: [PATCH 2/5] updated tests --- .vscode/settings.json | 1 + app/library/Presets.py | 14 +- app/library/Singleton.py | 17 ++ app/tests/test_presets.py | 606 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 632 insertions(+), 6 deletions(-) create mode 100644 app/tests/test_presets.py diff --git a/.vscode/settings.json b/.vscode/settings.json index e949e74e..7f04cc32 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -53,6 +53,7 @@ "Errno", "esac", "euuo", + "eventbus", "excepthook", "faststart", "Fetc", diff --git a/app/library/Presets.py b/app/library/Presets.py index b24afd3e..6aa1e99c 100644 --- a/app/library/Presets.py +++ b/app/library/Presets.py @@ -112,8 +112,10 @@ class Presets(metaclass=Singleton): """The instance of the class.""" _config: Config = None + """The config instance.""" _default: list[Preset] = [] + """The list of default presets.""" def __init__(self, file: str | Path | None = None, config: Config | None = None): Presets._instance = self @@ -136,12 +138,6 @@ class Presets(metaclass=Singleton): LOG.error(f"Failed to parse default preset ':{i}'. '{e!s}'.") continue - async def event_handler(_, __): - msg = "Not implemented" - raise Exception(msg) - - EventBus.get_instance().subscribe(Events.PRESETS_ADD, event_handler, f"{__class__.__name__}.add") - @staticmethod def get_instance() -> "Presets": """ @@ -176,6 +172,12 @@ class Presets(metaclass=Singleton): LOG.error(f"Default preset '{self._config.default_preset}' not found, using 'default' preset.") self._config.default_preset = "default" + async def event_handler(_, __): + msg = "Not implemented" + raise Exception(msg) + + EventBus.get_instance().subscribe(Events.PRESETS_ADD, event_handler, f"{__class__.__name__}.add") + def get_all(self) -> list[Preset]: """Return the items.""" return self._default + self._items diff --git a/app/library/Singleton.py b/app/library/Singleton.py index 96e983b0..4c9a9641 100644 --- a/app/library/Singleton.py +++ b/app/library/Singleton.py @@ -15,6 +15,14 @@ class Singleton(type): cls._instances[cls] = instance return cls._instances[cls] + def _reset_singleton(cls) -> None: + """ + Clear the singleton instance for the class. + Useful for testing purposes. + """ + if cls in cls._instances: + del cls._instances[cls] + class ThreadSafe(type): """ @@ -30,3 +38,12 @@ class ThreadSafe(type): instance = super().__call__(*args, **kwargs) cls._instances[cls] = instance return cls._instances[cls] + + def _reset_singleton(cls) -> None: + """ + Clear the singleton instance for the class. + Useful for testing purposes. + """ + with cls._lock: + if cls in cls._instances: + del cls._instances[cls] diff --git a/app/tests/test_presets.py b/app/tests/test_presets.py new file mode 100644 index 00000000..74f605ed --- /dev/null +++ b/app/tests/test_presets.py @@ -0,0 +1,606 @@ +import json +import tempfile +import uuid +from pathlib import Path +from unittest.mock import Mock, patch + +import pytest + +from app.library.Presets import DEFAULT_PRESETS, Preset, Presets + + +class TestPreset: + """Test the Preset dataclass.""" + + def test_preset_creation_with_defaults(self): + """Test creating a preset with default values.""" + preset = Preset(name="test_preset") + + assert preset.name == "test_preset" + assert preset.description == "" + assert preset.folder == "" + assert preset.template == "" + assert preset.cookies == "" + assert preset.cli == "" + assert preset.default is False + # ID should be auto-generated UUID + assert len(preset.id) == 36 # UUID4 string length + assert "-" in preset.id + + def test_preset_creation_with_all_fields(self): + """Test creating a preset with all fields specified.""" + preset_id = str(uuid.uuid4()) + preset = Preset( + id=preset_id, + name="test_preset", + description="Test description", + folder="test_folder", + template="test_template", + cookies="test_cookies", + cli="--test-option", + default=True, + ) + + assert preset.id == preset_id + assert preset.name == "test_preset" + assert preset.description == "Test description" + assert preset.folder == "test_folder" + assert preset.template == "test_template" + assert preset.cookies == "test_cookies" + assert preset.cli == "--test-option" + assert preset.default is True + + def test_preset_serialize(self): + """Test preset serialization to dictionary.""" + preset = Preset(name="test_preset", description="Test description", cli="--test-option") + + serialized = preset.serialize() + + assert isinstance(serialized, dict) + assert serialized["name"] == "test_preset" + assert serialized["description"] == "Test description" + assert serialized["cli"] == "--test-option" + assert "id" in serialized + + def test_preset_json(self): + """Test preset JSON serialization.""" + preset = Preset(name="test_preset", cli="--test-option") + + json_str = preset.json() + + assert isinstance(json_str, str) + # Should be valid JSON + parsed = json.loads(json_str) + assert parsed["name"] == "test_preset" + assert parsed["cli"] == "--test-option" + + def test_preset_get_method(self): + """Test preset get method for accessing fields.""" + preset = Preset(name="test_preset", description="Test description") + + assert preset.get("name") == "test_preset" + assert preset.get("description") == "Test description" + assert preset.get("nonexistent") is None + assert preset.get("nonexistent", "default_value") == "default_value" + + def test_preset_id_generation(self): + """Test that each preset gets a unique ID.""" + preset1 = Preset(name="preset1") + preset2 = Preset(name="preset2") + + assert preset1.id != preset2.id + assert len(preset1.id) == 36 + assert len(preset2.id) == 36 + + +class TestPresets: + """Test the Presets singleton manager.""" + + def setup_method(self): + """Set up test environment before each test.""" + # Reset singleton completely + Presets._instance = None + + def teardown_method(self): + """Clean up after each test.""" + # Reset singleton completely + Presets.get_instance()._items.clear() + Presets._instance = None + Presets._reset_singleton() + + @patch("app.library.Presets.Config") + @patch("app.library.Presets.EventBus") + def test_presets_singleton(self, mock_eventbus, mock_config): + """Test that Presets follows singleton pattern.""" + mock_config.get_instance.return_value = Mock(config_path="/tmp", default_preset="default") + mock_eventbus.get_instance.return_value = Mock() + + instance1 = Presets.get_instance() + instance2 = Presets.get_instance() + + assert instance1 is instance2 + assert isinstance(instance1, Presets) + + @patch("app.library.Presets.Config") + @patch("app.library.Presets.EventBus") + def test_presets_initialization(self, mock_eventbus, mock_config): + """Test Presets initialization with default presets.""" + mock_config_instance = Mock(config_path="/tmp", default_preset="default") + mock_config.get_instance.return_value = mock_config_instance + mock_eventbus_instance = Mock() + mock_eventbus.get_instance.return_value = mock_eventbus_instance + + with tempfile.TemporaryDirectory() as temp_dir: + presets_file = Path(temp_dir) / "presets.json" + presets = Presets(file=presets_file, config=mock_config_instance) + + # Should have default presets loaded + default_presets = presets._default + assert len(default_presets) > 0 + + # Check that default presets are Preset instances + for preset in default_presets: + assert isinstance(preset, Preset) + assert preset.default is True + + @patch("app.library.Presets.Config") + @patch("app.library.Presets.EventBus") + def test_presets_load_empty_file(self, mock_eventbus, mock_config): + """Test loading presets from empty or non-existent file.""" + 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) + presets.clear() # Ensure we start with empty items + + result = presets.load() + + assert result is presets + assert len(presets._items) == 0 + assert len(presets._default) > 0 # Should still have default presets + + @patch("app.library.Presets.Config") + @patch("app.library.Presets.EventBus") + @patch("app.library.Presets.arg_converter") + def test_presets_load_valid_file(self, mock_arg_converter, mock_eventbus, mock_config): + """Test loading presets from valid JSON file.""" + 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() + mock_arg_converter.return_value = [] # Mock successful CLI parsing + + test_presets = [ + {"id": str(uuid.uuid4()), "name": "test_preset_1", "description": "Test preset 1", "cli": "--format best"}, + {"id": str(uuid.uuid4()), "name": "test_preset_2", "description": "Test preset 2", "cli": "--format worst"}, + ] + + with tempfile.TemporaryDirectory() as temp_dir: + presets_file = Path(temp_dir) / "presets.json" + presets_file.write_text(json.dumps(test_presets, indent=2)) + + presets = Presets(file=presets_file, config=mock_config_instance) + presets._items.clear() # Ensure we start with empty items + result = presets.load() + + assert result is presets + assert len(presets._items) == 2 + assert presets._items[0].name == "test_preset_1" + assert presets._items[1].name == "test_preset_2" + + @patch("app.library.Presets.EventBus") + @patch("app.library.Presets.Config") + def test_presets_load_invalid_json(self, mock_eventbus, mock_config): + """Test loading presets from invalid JSON file.""" + 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_file.write_text("invalid json content") + + presets = Presets(file=presets_file, config=mock_config_instance) + + # Should handle invalid JSON gracefully + result = presets.load() + assert result is presets + assert len(presets._items) == 0 + + @patch("app.library.Presets.Config") + @patch("app.library.Presets.EventBus") + @patch("app.library.Presets.arg_converter") + def test_presets_load_with_format_migration(self, mock_arg_converter, mock_eventbus, mock_config): + """Test loading presets with old format that needs migration.""" + 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() + mock_arg_converter.return_value = [] # Mock successful CLI parsing + + # Old format preset with 'format' field instead of 'cli' + old_preset = {"name": "old_preset", "format": "best[height<=720]", "description": "Old format preset"} + + with tempfile.TemporaryDirectory() as temp_dir: + presets_file = Path(temp_dir) / "presets.json" + presets_file.write_text(json.dumps([old_preset])) + + presets = Presets(file=presets_file, config=mock_config_instance) + result = presets.load() + + assert result is presets + assert len(presets._items) == 1 + loaded_preset = presets._items[0] + assert loaded_preset.name == "old_preset" + # Should have migrated format to cli + assert "best[height<=720]" in loaded_preset.cli + assert "--format" in loaded_preset.cli + # Should have generated ID + assert loaded_preset.id is not None + + @patch("app.library.Presets.Config") + @patch("app.library.Presets.EventBus") + def test_presets_validate_valid_preset(self, mock_eventbus, mock_config): + """Test validating a valid preset.""" + 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) + + valid_preset = {"id": str(uuid.uuid4()), "name": "valid_preset", "cli": "--format best"} + + result = presets.validate(valid_preset) + assert result is True + + @patch("app.library.Presets.Config") + @patch("app.library.Presets.EventBus") + def test_presets_validate_invalid_preset(self, mock_eventbus, mock_config): + """Test validating invalid presets.""" + 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 missing ID + with pytest.raises(ValueError, match="No id found"): + presets.validate({"name": "test"}) + + # Test missing name + with pytest.raises(ValueError, match="No name found"): + presets.validate({"id": str(uuid.uuid4())}) + + # Test wrong type + with pytest.raises(ValueError, match="Unexpected"): + presets.validate("invalid_type") + + @patch("app.library.Presets.Config") + @patch("app.library.Presets.EventBus") + @patch("app.library.Presets.arg_converter") + def test_presets_save(self, mock_arg_converter, mock_eventbus, mock_config): + """Test saving presets to file.""" + 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() + mock_arg_converter.return_value = [] # Mock successful CLI parsing + + test_presets = [ + Preset(name="test1", cli="--format best"), + Preset(name="test2", cli="--format worst", default=True), # This should be excluded from save + ] + + with tempfile.TemporaryDirectory() as temp_dir: + presets_file = Path(temp_dir) / "presets.json" + presets = Presets(file=presets_file, config=mock_config_instance) + + result = presets.save(test_presets) + + assert result is presets + assert presets_file.exists() + + # Should only save non-default presets + saved_data = json.loads(presets_file.read_text()) + assert len(saved_data) == 1 + assert saved_data[0]["name"] == "test1" + + @patch("app.library.Presets.Config") + @patch("app.library.Presets.EventBus") + def test_presets_get_all(self, mock_eventbus, mock_config): + """Test getting all presets (default + custom).""" + 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) + + # Add a custom preset + custom_preset = Preset(name="custom", cli="--custom") + presets._items.append(custom_preset) + + all_presets = presets.get_all() + + # Should include both default and custom presets + assert len(all_presets) > 1 + assert any(p.name == "custom" for p in all_presets) + assert any(p.default is True for p in all_presets) + + @patch("app.library.Presets.Config") + @patch("app.library.Presets.EventBus") + def test_presets_get_by_id(self, mock_eventbus, mock_config): + """Test getting preset by ID.""" + 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_id = str(uuid.uuid4()) + test_preset = Preset(id=test_id, name="test_preset") + presets._items.append(test_preset) + + found_preset = presets.get(test_id) + + assert found_preset is not None + assert found_preset.id == test_id + assert found_preset.name == "test_preset" + + @patch("app.library.Presets.Config") + @patch("app.library.Presets.EventBus") + def test_presets_get_by_name(self, mock_eventbus, mock_config): + """Test getting preset by name.""" + 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_preset = Preset(name="test_preset", cli="--test") + presets._items.append(test_preset) + + found_preset = presets.get("test_preset") + + assert found_preset is not None + assert found_preset.name == "test_preset" + assert found_preset.cli == "--test" + + @patch("app.library.Presets.Config") + @patch("app.library.Presets.EventBus") + def test_presets_get_nonexistent(self, mock_eventbus, mock_config): + """Test getting non-existent preset returns None.""" + 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) + + found_preset = presets.get("nonexistent") + + assert found_preset is None + + @patch("app.library.Presets.Config") + @patch("app.library.Presets.EventBus") + def test_presets_get_empty_parameter(self, mock_eventbus, mock_config): + """Test getting preset with empty string returns None.""" + 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) + + assert presets.get("") is None + assert presets.get(None) is None + + @patch("app.library.Presets.Config") + @patch("app.library.Presets.EventBus") + def test_presets_has_method(self, mock_eventbus, mock_config): + """Test has method for checking preset existence.""" + 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_preset = Preset(name="existing_preset") + presets._items.append(test_preset) + + assert presets.has("existing_preset") is True + assert presets.has(test_preset.id) is True + assert presets.has("nonexistent") is False + + @patch("app.library.Presets.Config") + @patch("app.library.Presets.EventBus") + def test_presets_clear(self, mock_eventbus, mock_config): + """Test clearing all custom presets.""" + 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) + + # Add some custom presets + presets._items.append(Preset(name="custom1")) + presets._items.append(Preset(name="custom2")) + + assert len(presets._items) == 2 + + result = presets.clear() + + assert result is presets + assert len(presets._items) == 0 + + @patch("app.library.Presets.Config") + @patch("app.library.Presets.EventBus") + def test_presets_file_permissions(self, mock_eventbus, mock_config): + """Test that presets file gets correct permissions.""" + 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 file with different permissions + presets_file.write_text("{}") + presets_file.chmod(0o644) + + # Creating Presets instance should attempt to fix permissions + presets = Presets(file=presets_file, config=mock_config_instance) + + # Note: The actual chmod might fail in test environment, + # but we're testing that the code attempts it + assert presets is not None + + def test_default_presets_validation(self): + """Test that all default presets are valid.""" + # This test verifies that the DEFAULT_PRESETS constant contains valid presets + for i, preset_data in enumerate(DEFAULT_PRESETS): + assert "id" in preset_data, f"Default preset {i} missing id" + assert "name" in preset_data, f"Default preset {i} missing name" + assert "default" in preset_data, f"Default preset {i} missing default flag" + assert preset_data["default"] is True, f"Default preset {i} should have default=True" + + # Should be able to create Preset instance + preset = Preset( + id=preset_data["id"], + name=preset_data["name"], + description=preset_data.get("description", ""), + folder=preset_data.get("folder", ""), + template=preset_data.get("template", ""), + cli=preset_data.get("cli", ""), + default=preset_data["default"], + ) + assert isinstance(preset, Preset) + + @patch("app.library.Presets.Config") + @patch("app.library.Presets.EventBus") + def test_presets_attach_method(self, mock_eventbus, mock_config): + """Test the attach method for aiohttp integration.""" + 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() + mock_app = Mock() + + with tempfile.TemporaryDirectory() as temp_dir: + presets_file = Path(temp_dir) / "presets.json" + presets = Presets(file=presets_file, config=mock_config_instance) + + # Mock the get method to return a preset for default_preset check + with patch.object(presets, "get", return_value=Mock()) as mock_get: + presets.attach(mock_app) + mock_get.assert_called_once_with("default") + + @patch("app.library.Presets.Config") + @patch("app.library.Presets.EventBus") + def test_presets_attach_default_preset_not_found(self, mock_eventbus, mock_config): + """Test attach method when default preset is not found.""" + mock_config_instance = Mock(config_path="/tmp", default_preset="nonexistent") + mock_config.get_instance.return_value = mock_config_instance + mock_eventbus.get_instance.return_value = Mock() + mock_app = Mock() + + with tempfile.TemporaryDirectory() as temp_dir: + presets_file = Path(temp_dir) / "presets.json" + presets = Presets(file=presets_file, config=mock_config_instance) + + with patch.object(presets, "get", return_value=None): + # The actual config instance stored in presets should be checked + presets.attach(mock_app) + # Should have reset default_preset to "default" + assert presets._config.default_preset == "default" + + @pytest.mark.asyncio + @patch("app.library.Presets.Config") + @patch("app.library.Presets.EventBus") + async def test_presets_on_shutdown(self, mock_eventbus, mock_config): + """Test the on_shutdown method.""" + 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() + mock_app = Mock() + + with tempfile.TemporaryDirectory() as temp_dir: + presets_file = Path(temp_dir) / "presets.json" + presets = Presets(file=presets_file, config=mock_config_instance) + + # Should not raise an exception + await presets.on_shutdown(mock_app) + + @patch("app.library.Presets.Config") + @patch("app.library.Presets.EventBus") + @patch("app.library.Presets.LOG") + @patch("app.library.Presets.arg_converter") + def test_presets_load_invalid_preset_in_file(self, mock_arg_converter, mock_log, mock_eventbus, mock_config): + """Test loading file with some invalid presets.""" + 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() + mock_arg_converter.return_value = [] # Mock successful CLI parsing + + # Mix of valid and invalid presets + test_presets = [ + {"id": str(uuid.uuid4()), "name": "valid_preset", "cli": "--format best"}, + { + # Missing required fields + "description": "Invalid preset" + }, + ] + + with tempfile.TemporaryDirectory() as temp_dir: + presets_file = Path(temp_dir) / "presets.json" + presets_file.write_text(json.dumps(test_presets)) + + # Clear any existing items first + presets = Presets(file=presets_file, config=mock_config_instance) + presets.load() + + # Should only load the valid preset + assert len(presets._items) == 1 + assert presets._items[0].name == "valid_preset", f"Expected 'valid_preset', got '{presets._items}'" + + # Should have logged an error for the invalid preset + mock_log.error.assert_called() + + @patch("app.library.Presets.Config") + @patch("app.library.Presets.EventBus") + @patch("app.library.Presets.LOG") + def test_presets_save_with_invalid_preset(self, mock_log, mock_eventbus, mock_config): + """Test saving presets with some invalid ones.""" + 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() + + # Create preset with invalid CLI that will fail validation + valid_preset = Preset(name="valid", cli="--valid-option") + + # Create invalid preset data + invalid_preset_data = {"name": "invalid"} # Missing ID + + presets_to_save = [valid_preset, invalid_preset_data] + + with tempfile.TemporaryDirectory() as temp_dir: + presets_file = Path(temp_dir) / "presets.json" + presets = Presets(file=presets_file, config=mock_config_instance) + + presets.save(presets_to_save) + + # Should have logged errors for invalid presets + mock_log.error.assert_called() From 669f70d9b8f536efa0a973055b2bdd8e5e70b3c8 Mon Sep 17 00:00:00 2001 From: arabcoders Date: Fri, 12 Sep 2025 17:47:21 +0300 Subject: [PATCH 3/5] fixes --- .vscode/settings.json | 8 +- app/library/Archiver.py | 10 +- app/library/BackgroundWorker.py | 23 +- app/library/DataStore.py | 26 +- app/library/Download.py | 61 +-- app/library/DownloadQueue.py | 55 +-- app/library/Events.py | 39 +- app/library/HttpSocket.py | 2 +- app/library/LogWrapper.py | 4 +- app/library/Notifications.py | 34 +- app/library/PackageInstaller.py | 5 +- app/library/Playlist.py | 4 +- app/library/Presets.py | 33 +- app/library/Scheduler.py | 28 +- app/library/Segments.py | 20 +- app/library/Services.py | 25 +- app/library/Singleton.py | 4 + app/library/Tasks.py | 62 +-- app/library/Utils.py | 139 +++---- app/library/YTDLPOpts.py | 21 +- app/library/cache.py | 4 - app/library/conditions.py | 31 +- app/library/config.py | 17 +- app/library/dl_fields.py | 39 +- app/library/ffprobe.py | 6 +- app/library/task_handlers/youtube.py | 6 +- app/library/ytdlp.py | 1 + app/tests/test_cache.py | 4 +- app/tests/test_conditions.py | 7 +- app/tests/test_dl_fields.py | 14 +- app/tests/test_events.py | 8 +- app/tests/test_presets.py | 7 - app/tests/test_services.py | 552 +++++++++++++++++++++++++++ app/tests/test_utils.py | 25 -- 34 files changed, 878 insertions(+), 446 deletions(-) create mode 100644 app/tests/test_services.py diff --git a/.vscode/settings.json b/.vscode/settings.json index 7f04cc32..c24dd4cd 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -133,6 +133,7 @@ "SUPPRESSHELP", "tebibytes", "testpaths", + "threadsafe", "tiktok", "timespec", "tmpfilename", @@ -170,5 +171,10 @@ "**/.venv": true, }, "eslint.format.enable": true, - "eslint.ignoreUntitled": true + "eslint.ignoreUntitled": true, + "python.testing.pytestArgs": [ + "app/tests" + ], + "python.testing.unittestEnabled": true, + "python.testing.pytestEnabled": true } diff --git a/app/library/Archiver.py b/app/library/Archiver.py index 977db85f..6b17f323 100644 --- a/app/library/Archiver.py +++ b/app/library/Archiver.py @@ -40,19 +40,15 @@ class Archiver(metaclass=ThreadSafe): """ def __init__(self) -> None: - if getattr(self, "_initialized", False): - return - self._cache: dict[str, _Entry] = {} self._locks: dict[str, threading.RLock] = {} self._global_lock = threading.RLock() self._stats_check: bool = True self._stats_ttl: float = 0.2 - self._initialized = True - @classmethod - def get_instance(cls) -> "Archiver": - return cls() + @staticmethod + def get_instance() -> "Archiver": + return Archiver() def _key(self, file: str | Path) -> str: """ diff --git a/app/library/BackgroundWorker.py b/app/library/BackgroundWorker.py index d7fa7139..48add1d3 100644 --- a/app/library/BackgroundWorker.py +++ b/app/library/BackgroundWorker.py @@ -22,15 +22,17 @@ class BackgroundWorker(metaclass=Singleton): It is designed to run in a separate thread and uses asyncio to handle asynchronous tasks. """ - _instance = None - """The instance of the Notification class.""" - - thread: threading.Thread - """The thread that runs the background worker.""" - def __init__(self): - self.queue = Queue() + self.queue: Queue = Queue() + "The queue to hold the tasks." self.running = True + "Whether the background worker is running or not." + self.thread: threading.Thread = None + "The thread that runs the background worker." + + @staticmethod + def get_instance() -> "BackgroundWorker": + return BackgroundWorker() def attach(self, app: web.Application): app.on_shutdown.append(self.on_shutdown) @@ -49,13 +51,6 @@ class BackgroundWorker(metaclass=Singleton): except Exception as e: LOG.error(f"Failed to shut down background worker: {e}") - @staticmethod - def get_instance() -> "BackgroundWorker": - if BackgroundWorker._instance is None: - BackgroundWorker._instance = BackgroundWorker() - - return BackgroundWorker._instance - def _run(self): asyncio.set_event_loop(asyncio.new_event_loop()) loop = asyncio.get_event_loop() diff --git a/app/library/DataStore.py b/app/library/DataStore.py index 55ff4bfd..a306adf6 100644 --- a/app/library/DataStore.py +++ b/app/library/DataStore.py @@ -11,12 +11,12 @@ from .Download import Download from .ItemDTO import ItemDTO from .Utils import init_class -LOG = logging.getLogger("datastore") +LOG: logging.Logger = logging.getLogger("datastore") class StoreType(str, Enum): - HISTORY = "done" - QUEUE = "queue" + HISTORY: str = "done" + QUEUE: str = "queue" @classmethod def all(cls) -> list[str]: @@ -53,19 +53,13 @@ class DataStore: Persistent queue. """ - _type: StoreType = None - """Type of the store, e.g., DONE, QUEUE, PENDING.""" - - _dict: OrderedDict[str, Download] = None - """Ordered dictionary to store Download objects.""" - - _connection: Connection - """SQLite connection to the database.""" - def __init__(self, type: StoreType, connection: Connection): - self._dict = OrderedDict() - self._type = type - self._connection = connection + self._dict: OrderedDict[str, Download] = OrderedDict() + "The dictionary of items." + self._type: StoreType = type + "The type of the datastore." + self._connection: Connection = connection + "The database connection." def load(self) -> None: for id, item in self.saved_items(): @@ -136,7 +130,7 @@ class DataStore: ) for row in cursor: - rowDate = datetime.strptime(row["created_at"], "%Y-%m-%d %H:%M:%S") # noqa: DTZ007 + rowDate: datetime = datetime.strptime(row["created_at"], "%Y-%m-%d %H:%M:%S") # noqa: DTZ007 data: dict = json.loads(row["data"]) data.pop("_id", None) item: ItemDTO = init_class(ItemDTO, data) diff --git a/app/library/Download.py b/app/library/Download.py index 00acd227..b18ad0d1 100644 --- a/app/library/Download.py +++ b/app/library/Download.py @@ -47,22 +47,8 @@ class Download: Download task. """ - id: str = None - download_dir: str = None - temp_dir: str = None - temp_disabled: bool = False - "Disable the temporary files feature." - template: str = None - template_chapter: str = None - info: ItemDTO = None - debug: bool = False temp_path: str = None - cancelled: bool = False - is_live: bool = False - info_dict: dict = None - "yt-dlp metadata dict." update_task = None - cancel_in_progress: bool = False final_update = False @@ -86,12 +72,6 @@ class Download: ) "Fields to be extracted from yt-dlp progress hook." - temp_keep: bool = False - "Keep temp folder after download." - - logs: list = [] - "Logs from yt-dlp." - def __init__(self, info: ItemDTO, info_dict: dict = None, logs: list | None = None): """ Initialize download task. @@ -104,29 +84,52 @@ class Download: """ config: Config = Config.get_instance() - self.download_dir = info.download_dir - self.temp_dir = info.temp_dir - self.template = info.template - self.template_chapter = info.template_chapter + self.download_dir: str = info.download_dir + "Download directory." + self.temp_dir: str | None = info.temp_dir + "Temporary directory." + self.template: str | None = info.template + "Filename template." + self.template_chapter: str | None = info.template_chapter + "Chapter filename template." self.download_info_expires = int(config.download_info_expires) - self.info = info - self.id = info._id + "Time in seconds before the download info is considered expired." + self.info: ItemDTO = info + "ItemDTO object." + self.id: str = info._id + "Download ID." self.debug = bool(config.debug) + "Debug mode." self.debug_ytdl = bool(config.ytdlp_debug) + "Debug mode for yt-dlp." self.cancelled = False + "Download cancelled." self.tmpfilename = None + "Temporary filename." self.status_queue = None + "Status queue." self.proc = None - self._notify = EventBus.get_instance() + "yt-dlp process." + self._notify: EventBus = EventBus.get_instance() + "Event bus instance." self.max_workers = int(config.max_workers) + "Maximum number of concurrent downloads." self.temp_keep = bool(config.temp_keep) + "Keep temp folder after download." self.temp_disabled = bool(config.temp_disabled) - self.is_live = bool(info.is_live) or info.live_in is not None - self.info_dict = info_dict + "Disable the temporary files feature." + self.is_live: bool = bool(info.is_live) or info.live_in is not None + "Is the download a live stream." + self.info_dict: dict = info_dict + "yt-dlp metadata dict." self.logger: logging.Logger = logging.getLogger(f"Download.{info.id if info.id else info._id}") + "Logger for the download task." self.started_time = 0 + "Time when the download started." self.queue_time: datetime = datetime.now(tz=UTC) + "Time when the download was queued." self.logs = logs if logs else [] + "Logs from yt-dlp." def _progress_hook(self, data: dict): if self.debug: diff --git a/app/library/DownloadQueue.py b/app/library/DownloadQueue.py index ee86c939..0aac9c68 100644 --- a/app/library/DownloadQueue.py +++ b/app/library/DownloadQueue.py @@ -47,44 +47,29 @@ class DownloadQueue(metaclass=Singleton): DownloadQueue class is a singleton class that manages the download queue and the download history. """ - paused: asyncio.Event - """Event to pause the download queue.""" - - event: asyncio.Event - """Event to signal the download queue to start downloading.""" - - _active: dict[str, Download] = {} - """Dictionary of active downloads.""" - - _instance = None - """Instance of the DownloadQueue.""" - - queue: DataStore - """DataStore for the download queue.""" - - done: DataStore - """DataStore for the completed downloads.""" - - workers: asyncio.Semaphore - """Semaphore to limit the number of concurrent downloads.""" - - processors: asyncio.Semaphore - """Semaphore to limit the number of concurrent processors.""" - def __init__(self, connection: Connection, config: Config | None = None): - DownloadQueue._instance = self - - self.config = config or Config.get_instance() - self._notify = EventBus.get_instance() + self.config: Config = config or Config.get_instance() + "Configuration instance." + self._notify: EventBus = EventBus.get_instance() + "Event bus instance." self.done = DataStore(type=StoreType.HISTORY, connection=connection) + "DataStore for the completed downloads." self.queue = DataStore(type=StoreType.QUEUE, connection=connection) + "DataStore for the download queue." + self.workers = asyncio.Semaphore(self.config.max_workers) + "Semaphore to limit the number of concurrent downloads." + self.processors = asyncio.Semaphore(self.config.playlist_items_concurrency) + "Semaphore to limit the number of concurrent processors." + self.paused = asyncio.Event() + "Event to pause the download queue." + self.event = asyncio.Event() + "Event to signal the download queue to start downloading." + self._active: dict[str, Download] = {} + """Dictionary of active downloads.""" + self.done.load() self.queue.load() - self.paused = asyncio.Event() self.paused.set() - self.event = asyncio.Event() - self.workers = asyncio.Semaphore(self.config.max_workers) - self.processors = asyncio.Semaphore(self.config.playlist_items_concurrency) @staticmethod def get_instance() -> "DownloadQueue": @@ -95,10 +80,7 @@ class DownloadQueue(metaclass=Singleton): DownloadQueue: The instance of the DownloadQueue """ - if not DownloadQueue._instance: - DownloadQueue._instance = DownloadQueue() - - return DownloadQueue._instance + return DownloadQueue() def attach(self, _: web.Application) -> None: """ @@ -1205,6 +1187,5 @@ class DownloadQueue(metaclass=Singleton): return if exc := task.exception(): - task_name: str = task.get_name() if task.get_name() else "unknown_task" LOG.error(f"Unhandled exception in background task '{task_name}': {exc!s}. {traceback.format_exc()}") diff --git a/app/library/Events.py b/app/library/Events.py index 2f2f2571..e9973182 100644 --- a/app/library/Events.py +++ b/app/library/Events.py @@ -194,14 +194,13 @@ class Event: class EventListener: - name: str - is_coroutine: bool = False - call_back: callable - def __init__(self, name: str, callback: callable): - self.name = name - self.call_back = callback - self.is_coroutine = asyncio.iscoroutinefunction(callback) + self.name: str = name + "The name of the listener." + self.call_back: callable = callback + "The callback function to call when the event is emitted." + self.is_coroutine: bool = asyncio.iscoroutinefunction(callback) + "Whether the callback is a coroutine function or not." async def handle(self, event: Event, **kwargs): if self.is_coroutine: @@ -215,20 +214,15 @@ class EventBus(metaclass=Singleton): This class is used to subscribe to and emit events to the registered listeners. """ - _instance = None - """the instance of the EventsSubscriber""" - - _listeners: dict[str, list[str, EventListener]] = {} - """The listeners for the events.""" - - debug: bool = False - """Whether to log debug messages or not.""" - - _offload: BackgroundWorker = None - """The background worker to offload tasks to.""" - def __init__(self): - EventBus._instance = self + self._listeners: dict[str, list[str, EventListener]] = {} + "The listeners for the events." + + self.debug: bool = False + "Whether to log debug messages or not." + + self._offload: BackgroundWorker = None + "The background worker to offload tasks to." @staticmethod def get_instance() -> "EventBus": @@ -239,10 +233,7 @@ class EventBus(metaclass=Singleton): EventsSubscriber: The instance of the EventsSubscriber """ - if not EventBus._instance: - EventBus._instance = EventBus() - - return EventBus._instance + return EventBus() def subscribe(self, event: str | list | tuple, callback: Awaitable, name: str | None = None) -> "EventBus": """ diff --git a/app/library/HttpSocket.py b/app/library/HttpSocket.py index 5c497633..543ccf2a 100644 --- a/app/library/HttpSocket.py +++ b/app/library/HttpSocket.py @@ -57,7 +57,7 @@ class HttpSocket: async def event_handler(e: Event, _, **kwargs): await self.sio.emit(event=e.event, data=encoder.encode(e), **kwargs) - services = Services.get_instance() + services: Services = Services.get_instance() services.add_all( { k: v diff --git a/app/library/LogWrapper.py b/app/library/LogWrapper.py index 2f19a9fb..149242a9 100644 --- a/app/library/LogWrapper.py +++ b/app/library/LogWrapper.py @@ -59,11 +59,9 @@ class LogWrapper: """ - targets: list[LogTarget] = [] - """A list of dictionaries where each dictionary represents a logging target with its level and type.""" - def __init__(self): self.targets: list[LogTarget] = [] + """A list of dictionaries where each dictionary represents a logging target with its level and type.""" def add_target(self, target: logging.Logger | Callable, level: int = logging.DEBUG, name: str | None = None): """ diff --git a/app/library/Notifications.py b/app/library/Notifications.py index 5ac676a1..e4b777e4 100644 --- a/app/library/Notifications.py +++ b/app/library/Notifications.py @@ -130,12 +130,6 @@ class NotificationEvents: class Notification(metaclass=Singleton): - _targets: list[Target] = [] - """Notification targets to send events to.""" - - _instance = None - """The instance of the Notification class.""" - def __init__( self, file: str | None = None, @@ -144,15 +138,23 @@ class Notification(metaclass=Singleton): config: Config | None = None, background_worker: BackgroundWorker | None = None, ): - Notification._instance = self + self._targets: list[Target] = [] + "Notification targets to send events to." + config: Config = config or Config.get_instance() self._debug: bool = config.debug + "Debug mode." self._file: Path = Path(file) if file else Path(config.config_path).joinpath("notifications.json") + "File to store notification targets." self._client: httpx.AsyncClient = client or httpx.AsyncClient() + "HTTP client to send requests." self._encoder: Encoder = encoder or Encoder() + "Encoder to encode data." self._version: str = config.app_version + "Application version." self._offload: BackgroundWorker = background_worker or BackgroundWorker.get_instance() + "Background worker to offload tasks to." if self._file.exists() and "600" != self._file.stat().st_mode: try: @@ -161,11 +163,19 @@ class Notification(metaclass=Singleton): pass @staticmethod - def get_instance() -> "Notification": - if Notification._instance is None: - Notification._instance = Notification() - - return Notification._instance + def get_instance( + file: str | None = None, + client: httpx.AsyncClient | None = None, + encoder: Encoder | None = None, + config: Config | None = None, + background_worker: BackgroundWorker | None = None, + ) -> "Notification": + """ + Get the instance of the class. + """ + return Notification( + file=file, client=client, encoder=encoder, config=config, background_worker=background_worker + ) def attach(self, _: web.Application): """ diff --git a/app/library/PackageInstaller.py b/app/library/PackageInstaller.py index c43ef67f..18c35fe5 100644 --- a/app/library/PackageInstaller.py +++ b/app/library/PackageInstaller.py @@ -37,9 +37,10 @@ class Packages: class PackageInstaller: - user_site: Path | None = None - def __init__(self, pkg_path: Path | None = None): + self.user_site: Path | None = None + "Where to install user packages." + if pkg_path: self.user_site = pkg_path diff --git a/app/library/Playlist.py b/app/library/Playlist.py index af648ebb..9fed2312 100644 --- a/app/library/Playlist.py +++ b/app/library/Playlist.py @@ -6,11 +6,11 @@ from .Utils import StreamingError, get_file_sidecar class Playlist: - _url: str = None - def __init__(self, download_path: Path, url: str): self.url: str = url + "The base URL for the playlist." self.download_path: Path = download_path + "The path where files are downloaded." async def make(self, file: Path) -> str: ref: str = Path(str(file.relative_to(self.download_path)).strip("/")) diff --git a/app/library/Presets.py b/app/library/Presets.py index 6aa1e99c..c6a83011 100644 --- a/app/library/Presets.py +++ b/app/library/Presets.py @@ -105,24 +105,20 @@ class Presets(metaclass=Singleton): This class is used to manage the presets. """ - _items: list[Preset] = [] - """The list of presets.""" - - _instance = None - """The instance of the class.""" - - _config: Config = None - """The config instance.""" - - _default: list[Preset] = [] - """The list of default presets.""" - def __init__(self, file: str | Path | None = None, config: Config | None = None): - Presets._instance = self + self._items: list[Preset] = [] + "The list of presets." + + self._config: Config = None + "The config instance." + + self._default: list[Preset] = [] + "The list of default presets." self._config = config or Config.get_instance() self._file: Path = Path(file) if file else Path(self._config.config_path).joinpath("presets.json") + "The path to the presets file." if self._file.exists() and "600" != self._file.stat().st_mode: try: @@ -139,18 +135,19 @@ class Presets(metaclass=Singleton): continue @staticmethod - def get_instance() -> "Presets": + def get_instance(file: str | Path | None = None, config: Config | None = None) -> "Presets": """ Get the instance of the class. + Args: + file (str|Path|None): The path to the presets file. + config (Config|None): The config instance. + Returns: Presets: The instance of the class """ - if not Presets._instance: - Presets._instance = Presets() - - return Presets._instance + return Presets(file=file, config=config) async def on_shutdown(self, _: web.Application): pass diff --git a/app/library/Scheduler.py b/app/library/Scheduler.py index 969eadc9..b3230454 100644 --- a/app/library/Scheduler.py +++ b/app/library/Scheduler.py @@ -15,25 +15,15 @@ class Scheduler(metaclass=Singleton): This class is used to manage the schedule. """ - _jobs: dict[str, Cron] = {} - """The scheduled jobs.""" - - _instance = None - """The instance of the class.""" - def __init__(self, loop: asyncio.AbstractEventLoop | None = None): - Scheduler._instance = self + self._jobs: dict[str, Cron] = {} + "The scheduled jobs." self._loop = loop or asyncio.get_event_loop() - - async def event_handler(data, _): - if data and data.data: - self.add(**data.data) - - EventBus.get_instance().subscribe(Events.SCHEDULE_ADD, event_handler, f"{__class__.__name__}.add") + "The event loop to use." @staticmethod - def get_instance() -> "Scheduler": + def get_instance(loop: asyncio.AbstractEventLoop | None = None) -> "Scheduler": """ Get the instance of the class. @@ -41,9 +31,7 @@ class Scheduler(metaclass=Singleton): Scheduler: The instance of the class """ - if not Scheduler._instance: - Scheduler._instance = Scheduler() - return Scheduler._instance + return Scheduler(loop=loop) async def on_shutdown(self, _: web.Application): """ @@ -70,6 +58,12 @@ class Scheduler(metaclass=Singleton): def attach(self, app: web.Application): app.on_shutdown.append(self.on_shutdown) + async def event_handler(data, _): + if data and data.data: + self.add(**data.data) + + EventBus.get_instance().subscribe(Events.SCHEDULE_ADD, event_handler, f"{__class__.__name__}.add") + def get_all(self) -> dict[str, Cron]: """Return the jobs.""" return self._jobs diff --git a/app/library/Segments.py b/app/library/Segments.py index 963ffda8..ef1edee7 100644 --- a/app/library/Segments.py +++ b/app/library/Segments.py @@ -11,22 +11,32 @@ from aiohttp import web from .config import Config from .ffprobe import ffprobe -LOG = logging.getLogger("player.segments") +LOG: logging.Logger = logging.getLogger("player.segments") class Segments: def __init__(self, download_path: str, index: int, duration: float, vconvert: bool, aconvert: bool): - config = Config.get_instance() - self.download_path = download_path + config: Config = Config.get_instance() + self.download_path: str = download_path + "The path where files are downloaded." self.index = int(index) + "The index of the segment." self.duration = float(duration) + "The duration of the segment." self.vconvert = bool(vconvert) + "Whether to convert video." self.aconvert = bool(aconvert) - self.vcodec = config.streamer_vcodec - self.acodec = config.streamer_acodec + "Whether to convert audio." + self.vcodec: str = config.streamer_vcodec + "The video codec to use." + self.acodec: str = config.streamer_acodec + "The audio codec to use." + # sadly due to unforeseen circumstances, we have to convert the video for now. self.vconvert = True + "Whether to convert video." self.aconvert = True + "Whether to convert audio." async def build_ffmpeg_args(self, file: Path) -> list[str]: try: diff --git a/app/library/Services.py b/app/library/Services.py index bf9d93cd..894c66aa 100644 --- a/app/library/Services.py +++ b/app/library/Services.py @@ -9,42 +9,37 @@ LOG: logging.Logger = logging.getLogger(__name__) class Services(metaclass=Singleton): - _dct: dict[str, T] = {} - - _instance = None - """The instance of the class.""" + def __init__(self): + self._services: dict[str, T] = {} @staticmethod def get_instance() -> "Services": - if Services._instance is None: - Services._instance = Services() - - return Services._instance + return Services() def add(self, name: str, service: T): - self._dct[name] = service + self._services[name] = service def add_all(self, services: dict[str, T]): for name, service in services.items(): self.add(name, service) def get(self, name: str) -> T | None: - return self._dct.get(name) + return self._services.get(name) def has(self, name: str) -> bool: - return name in self._dct + return name in self._services def remove(self, name: str): - if name not in self._dct: + if name not in self._services: return - self._dct.pop(name, None) + self._services.pop(name, None) def clear(self): - self._dct.clear() + self._services.clear() def get_all(self) -> dict[str, T]: - return self._dct.copy() + return self._services.copy() async def handle_async(self, handler: callable, **kwargs) -> Any: context = {**self.get_all(), **kwargs} diff --git a/app/library/Singleton.py b/app/library/Singleton.py index 4c9a9641..70c8631a 100644 --- a/app/library/Singleton.py +++ b/app/library/Singleton.py @@ -8,6 +8,7 @@ class Singleton(type): """ _instances: dict[type, Any] = {} + "The singleton instances." def __call__(cls, *args: Any, **kwargs: Any) -> Any: if cls not in cls._instances: @@ -30,7 +31,10 @@ class ThreadSafe(type): """ _instances: dict[type, Any] = {} + "The singleton instances." + _lock = threading.Lock() + "A lock to ensure thread-safe singleton creation." def __call__(cls, *args: Any, **kwargs: Any) -> Any: with cls._lock: diff --git a/app/library/Tasks.py b/app/library/Tasks.py index b440be26..26b50847 100644 --- a/app/library/Tasks.py +++ b/app/library/Tasks.py @@ -132,12 +132,6 @@ class Tasks(metaclass=Singleton): This class is used to manage the tasks. """ - _tasks: list[Task] = [] - """The tasks.""" - - _instance = None - """The instance of the Tasks.""" - def __init__( self, file: str | None = None, @@ -146,19 +140,28 @@ class Tasks(metaclass=Singleton): encoder: Encoder | None = None, scheduler: Scheduler | None = None, ): - Tasks._instance = self - + self._tasks: list[Task] = [] + "The tasks." config = config or Config.get_instance() self._debug: bool = config.debug + "Debug mode." self._default_preset: str = config.default_preset + "The default preset." self._file: Path = Path(file) if file else Path(config.config_path).joinpath("tasks.json") + "The tasks file." self._encoder: Encoder = encoder or Encoder() + "The JSON encoder." self._loop: asyncio.AbstractEventLoop = loop or asyncio.get_event_loop() + "The event loop." self._scheduler: Scheduler = scheduler or Scheduler.get_instance() + "The scheduler." self._notify: EventBus = EventBus.get_instance() + "The event bus." self._task_handler = HandleTask(self._scheduler, self, config) + "The task handler." self._downloadQueue = DownloadQueue.get_instance() + "The download queue." if self._file.exists() and "600" != self._file.stat().st_mode: try: @@ -167,7 +170,13 @@ class Tasks(metaclass=Singleton): pass @staticmethod - def get_instance() -> "Tasks": + def get_instance( + file: str | None = None, + loop: asyncio.AbstractEventLoop | None = None, + config: Config | None = None, + encoder: Encoder | None = None, + scheduler: Scheduler | None = None, + ) -> "Tasks": """ Get the instance of the class. @@ -175,10 +184,7 @@ class Tasks(metaclass=Singleton): Tasks: The instance of the class. """ - if not Tasks._instance: - Tasks._instance = Tasks() - - return Tasks._instance + return Tasks(file=file, loop=loop, config=config, encoder=encoder, scheduler=scheduler) async def on_shutdown(self, _: web.Application): self.clear(shutdown=True) @@ -454,17 +460,17 @@ class Tasks(metaclass=Singleton): class HandleTask: - _tasks: Tasks - _handlers: list[type] - _scheduler: Scheduler - _config: Config - _task_name: str - def __init__(self, scheduler: Scheduler, tasks: Tasks, config: Config) -> None: - self._tasks = tasks - self._scheduler = scheduler - self._config = config + self._handlers: list[type] = [] + "The available handlers." + self._tasks: Tasks = tasks + "The tasks manager." + self._scheduler: Scheduler = scheduler + "The scheduler." + self._config: Config = config + "The configuration." self._task_name: str = f"{__class__.__name__}._dispatcher" + "The task name for the scheduler." def load(self) -> None: self._handlers: list[type] = self._discover() @@ -496,24 +502,36 @@ class HandleTask: self._scheduler.remove(self._task_name) def _dispatcher(self): + s: dict[list[str]] = {"h": [], "d": [], "u": [], "f": []} + for task in self._tasks.get_all(): if not task.handler_enabled: + s["d"].append(task.name) continue if not task.get_ytdlp_opts().get_all().get("download_archive"): LOG.debug(f"Task '{task.name}' does not have an archive file configured.") + s["f"].append(task.name) continue try: handler = self._find_handler(task) if handler is None: + s["u"].append(task.name) continue coro = self.dispatch(task, handler=handler) t = asyncio.create_task(coro, name=f"task-{task.id}") t.add_done_callback(lambda fut, t=task: self._handle_exception(fut, t)) + s["h"].append(task.name) except Exception as e: LOG.error(f"Failed to handle task '{task.name}'. '{e!s}'.") + s["f"].append(task.name) + + if len(self._tasks.get_all()) > 0: + LOG.info( + f"Task Handler summary: Handled: {len(s['h'])}, Unhandled: {len(s['u'])}, Disabled: {len(s['d'])}, Failed: {len(s['f'])}." + ) def _handle_exception(self, fut: asyncio.Task, task: Task) -> None: if fut.cancelled(): diff --git a/app/library/Utils.py b/app/library/Utils.py index f6bbe50f..45021a7b 100644 --- a/app/library/Utils.py +++ b/app/library/Utils.py @@ -61,10 +61,13 @@ REMOVE_KEYS: list = [ "opt_list_impersonate_targets": "--list-impersonate-targets", }, ] +"Keys to remove from yt-dlp options at various levels." YTDLP_INFO_CLS: YTDLP = None +"Cached YTDLP info class." ALLOWED_SUBS_EXTENSIONS: set[str] = {".srt", ".vtt", ".ass"} +"Allowed subtitle file extensions." FILES_TYPE: list = [ {"rx": re.compile(r"\.(avi|ts|mkv|mp4|mp3|mpv|ogm|m4v|webm|m4b)$", re.IGNORECASE), "type": "video"}, @@ -74,11 +77,15 @@ FILES_TYPE: list = [ {"rx": re.compile(r"\.(txt|nfo|md|json|yml|yaml|plexmatch)$", re.IGNORECASE), "type": "text"}, {"rx": re.compile(r"\.(nfo|json|jpg|torrent|\.info\.json)$", re.IGNORECASE), "type": "metadata"}, ] +"File type patterns for sidecar files." TAG_REGEX: re.Pattern[str] = re.compile(r"%{([^:}]+)(?::([^}]*))?}c") +"Regex to find tags in templates." DT_PATTERN: re.Pattern[str] = re.compile(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2}))\s?") +"Regex to match ISO 8601 datetime strings." T = TypeVar("T") +"Generic type variable." class StreamingError(Exception): @@ -111,11 +118,11 @@ def calc_download_path(base_path: str | Path, folder: str | None = None, create_ folder = folder.removeprefix("/") - realBasePath = base_path.resolve() - download_path = Path(realBasePath).joinpath(folder).resolve(strict=False) + realBasePath: Path = base_path.resolve() + download_path: Path = Path(realBasePath).joinpath(folder).resolve(strict=False) if not str(download_path).startswith(str(realBasePath)): - msg = f'Folder "{folder}" must resolve inside the base download folder "{realBasePath}".' + msg: str = f'Folder "{folder}" must resolve inside the base download folder "{realBasePath}".' raise Exception(msg) try: @@ -165,7 +172,7 @@ def extract_info( } # Remove keys that are not needed for info extraction. - keys_to_remove = [key for key in params if str(key).startswith("write") or key in ["postprocessors"]] + keys_to_remove: list = [key for key in params if str(key).startswith("write") or key in ["postprocessors"]] for key in keys_to_remove: params.pop(key, None) @@ -175,8 +182,8 @@ def extract_info( params["quiet"] = True log_wrapper = LogWrapper() - idDict = get_archive_id(url=url) - archive_id = f".{idDict['id']}" if idDict.get("id") else None + idDict: dict[str, str | None] = get_archive_id(url=url) + archive_id: str | None = f".{idDict['id']}" if idDict.get("id") else None log_wrapper.add_target( target=logging.getLogger(f"yt-dlp{archive_id if archive_id else '.extract_info'}"), @@ -244,7 +251,7 @@ def _is_safe_key(key: any) -> bool: return False # Block only truly dangerous dunder patterns - key_stripped = key.strip() + key_stripped: str = key.strip() # Block dunder attributes (starts AND ends with __) return not (key_stripped.startswith("__") and key_stripped.endswith("__")) @@ -279,7 +286,7 @@ def merge_dict( # Prevent deep recursion DoS if _depth > max_depth: - msg = f"Recursion depth limit exceeded ({max_depth})" + msg: str = f"Recursion depth limit exceeded ({max_depth})" raise RecursionError(msg) # Initialize circular reference tracking @@ -294,10 +301,10 @@ def merge_dict( raise ValueError(msg) # Track current objects - current_seen = _seen | {source_id, dest_id} + current_seen: set[Any | int] = _seen | {source_id, dest_id} # Create a clean copy of destination with only safe keys - destination_copy = {} + destination_copy: dict = {} for k, v in destination.items(): if _is_safe_key(k): # Prevent memory DoS from large lists @@ -311,7 +318,7 @@ def merge_dict( if not _is_safe_key(key): continue - destination_value = destination_copy.get(key) + destination_value: Any | None = destination_copy.get(key) # Recursively merge dictionaries with safety checks if isinstance(value, dict) and isinstance(destination_value, dict): @@ -325,8 +332,8 @@ def merge_dict( combined_size = len(value) + len(destination_value) if combined_size > max_list_size: # Truncate to stay within limits - available_space = max_list_size - len(destination_value) - truncated_value = value[: max(0, available_space)] + available_space: int = max_list_size - len(destination_value) + truncated_value: list = value[: max(0, available_space)] destination_copy[key] = copy.deepcopy(destination_value) + copy.deepcopy(truncated_value) else: destination_copy[key] = copy.deepcopy(destination_value) + copy.deepcopy(value) @@ -354,11 +361,13 @@ def check_id(file: Path) -> bool | str: bool|str: False if no file found, else the file path. """ - match = re.search(r"(?<=\[)(?:youtube-)?(?P[a-zA-Z0-9\-_]{11})(?=\])", file.stem, re.IGNORECASE) + match: re.Match[str] | None = re.search( + r"(?<=\[)(?:youtube-)?(?P[a-zA-Z0-9\-_]{11})(?=\])", file.stem, re.IGNORECASE + ) if not match: return False - id = match.groupdict().get("id") + id: str | None = match.groupdict().get("id") try: for f in file.parent.iterdir(): @@ -378,8 +387,8 @@ def check_id(file: Path) -> bool | str: @lru_cache(maxsize=512) def is_private_address(hostname: str) -> bool: - ip = socket.gethostbyname(hostname) - ip_obj = ipaddress.ip_address(ip) + ip: str = socket.gethostbyname(hostname) + ip_obj: ipaddress.IPv4Address | ipaddress.IPv6Address = ipaddress.ip_address(ip) return ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_reserved or ip_obj.is_link_local @@ -545,7 +554,7 @@ def get_file_sidecar(file: Path) -> list[dict]: list: List of sidecar files. """ - files = {} + files: dict = {} for i, f in enumerate(file.parent.glob(f"{glob.escape(file.stem)}.*")): if f == file or f.is_file() is False or f.stem.startswith("."): @@ -557,15 +566,15 @@ def get_file_sidecar(file: Path) -> list[dict]: content_type = "Unknown" for pattern in FILES_TYPE: if pattern["rx"].search(f.name): - content_type = pattern["type"] + content_type: str = pattern["type"] break if content_type == "subtitle": if f.suffix not in ALLOWED_SUBS_EXTENSIONS: continue - lg = re.search(r"\.(?P\w{2,3})\.\w{3}$", f.name) - lang = lg.groupdict().get("lang") if lg else "und" - content = {"file": f, "lang": lang, "name": f"{f.suffix[1:].upper()} ({i}) - {lang}"} + lg: re.Match[str] | None = re.search(r"\.(?P\w{2,3})\.\w{3}$", f.name) + lang: str | None = lg.groupdict().get("lang") if lg else "und" + content: dict[str, Any] = {"file": f, "lang": lang, "name": f"{f.suffix[1:].upper()} ({i}) - {lang}"} else: content = {"file": f} @@ -574,7 +583,7 @@ def get_file_sidecar(file: Path) -> list[dict]: files[content_type].append(content) - images = get_possible_images(str(file.parent)) + images: list[dict] = get_possible_images(str(file.parent)) if len(images) > 0: if "image" not in files: files["image"] = [] @@ -586,7 +595,7 @@ def get_file_sidecar(file: Path) -> list[dict]: @lru_cache(maxsize=512) def get_possible_images(dir: str) -> list[dict]: - images = [] + images: list = [] path_loc = Path(dir, "test.jpg") @@ -615,7 +624,7 @@ def get_mime_type(metadata: dict, file_path: Path) -> str: format_name = metadata.get("format_name", "") # Define mappings for HTML5-compatible video types - format_to_mime = { + format_to_mime: dict[str, str] = { "matroska": "video/x-matroska", # Default for MKV "webm": "video/webm", # MKV can also be WebM "mp4": "video/mp4", @@ -628,7 +637,7 @@ def get_mime_type(metadata: dict, file_path: Path) -> str: for fmt in format_name.split(","): fmt = fmt.strip().lower() if fmt in format_to_mime: - selected = format_to_mime[fmt] + selected: str = format_to_mime[fmt] if selected: return selected @@ -668,7 +677,7 @@ def get_file(download_path: str | Path, file: str | Path) -> tuple[Path, int]: LOG.error(f"Error calculating download path. {e!s}") return (Path(file), 404) - possibleFile = check_id(file=realFile) + possibleFile: bool | str = check_id(file=realFile) if not possibleFile: return (realFile, 404) @@ -775,7 +784,7 @@ def get( return default() if callable(default) else default # Split the path by the separator and traverse the data structure. - segments = path.split(separator) + segments: list[str] = path.split(separator) for segment in segments: if isinstance(data, dict): if segment in data: @@ -873,7 +882,7 @@ def get_files(base_path: Path | str, dir: str | None = None): if not content_type: content_type = "download" - stat = file.stat() + stat: os.stat_result = file.stat() contents.append( { @@ -897,9 +906,6 @@ def get_files(base_path: Path | str, dir: str | None = None): def clean_item(item: dict, keys: list | tuple) -> tuple[dict, bool]: """ Remove given keys from a dictionary. - This function modifies the dictionary in place and returns a tuple - containing the modified dictionary and a boolean indicating - whether any keys were removed. Args: item (dict): The item to clean. @@ -913,6 +919,7 @@ def clean_item(item: dict, keys: list | tuple) -> tuple[dict, bool]: """ status = False + item = copy.deepcopy(item) if not isinstance(item, dict): msg = "Item must be a dictionary." @@ -946,7 +953,7 @@ def strip_newline(string: str) -> str: if not string: return "" - res = re.sub(r"(\r\n|\r|\n)", " ", string) + res: str = re.sub(r"(\r\n|\r|\n)", " ", string) return res.strip() if res else "" @@ -978,34 +985,34 @@ async def read_logfile(file: Path, offset: int = 0, limit: int = 50) -> dict: try: async with await open_file(file, "rb") as f: await f.seek(0, os.SEEK_END) - file_size = await f.tell() + file_size: int = await f.tell() block_size = 1024 - block_end = file_size - buffer = b"" - lines = [] + block_end: int = file_size + buffer: bytes = b"" + lines: list = [] - required_count = offset + limit + 1 + required_count: int = offset + limit + 1 while len(lines) < required_count and block_end > 0: - block_start = max(0, block_end - block_size) + block_start: int = max(0, block_end - block_size) await f.seek(block_start) - chunk = await f.read(block_end - block_start) - buffer = chunk + buffer # prepend the chunk + chunk: bytes = await f.read(block_end - block_start) + buffer: bytes = chunk + buffer # prepend the chunk lines = buffer.splitlines() block_end = block_start if len(lines) > offset + limit: - next_offset = offset + limit + next_offset: int = offset + limit end_is_reached = False else: next_offset = None end_is_reached = True for line in lines[-(offset + limit) : -offset] if offset else lines[-limit:]: - line_bytes = line if isinstance(line, bytes) else line.encode() - msg = line.decode(errors="replace") - dt_match = DT_PATTERN.match(msg) + line_bytes: bytes | str = line if isinstance(line, bytes) else line.encode() + msg: str = line.decode(errors="replace") + dt_match: re.Match[str] | None = DT_PATTERN.match(msg) result.append( { "id": sha256(line_bytes).hexdigest(), @@ -1041,13 +1048,13 @@ async def tail_log(file: Path, emitter: callable, sleep_time: float = 0.5): async with await open_file(file, "rb") as f: await f.seek(0, os.SEEK_END) while True: - line = await f.readline() + line: bytes = await f.readline() if not line: await asyncio_sleep(sleep_time) continue - msg = line.decode(errors="replace") - dt_match = DT_PATTERN.match(msg) + msg: str = line.decode(errors="replace") + dt_match: re.Match[str] | None = DT_PATTERN.match(msg) await emitter( { @@ -1101,7 +1108,7 @@ def get_archive_id(url: str) -> dict[str, str | None]: """ global YTDLP_INFO_CLS # noqa: PLW0603 - idDict = { + idDict: dict[str, None] = { "id": None, "ie_key": None, "archive_id": None, @@ -1158,7 +1165,7 @@ def dt_delta(delta: timedelta) -> str: hours, rem = divmod(rem, 3600) minutes, secs = divmod(rem, 60) - parts = [] + parts: list[str] = [] if days: parts.append(f"{days}d") if hours: @@ -1269,6 +1276,7 @@ def load_modules(root_path: Path, directory: Path): full_name: str = f"{package_name}.{name}" if name.startswith("_"): continue + try: importlib.import_module(full_name) except ImportError as e: @@ -1377,37 +1385,6 @@ def ytdlp_reject(entry: dict, yt_params: dict) -> tuple[bool, str]: return (True, "") -def find_unpickleable(obj, name="root", seen=None): - import pickle - - if seen is None: - seen = set() - - if id(obj) in seen: - return - - seen.add(id(obj)) - - try: - pickle.dumps(obj) - except Exception as e: - LOG.error(f"[UNPICKLEABLE] {name}: {e}") - - if isinstance(obj, dict): - for k, v in obj.items(): - find_unpickleable(v, f"{name}[{repr(k)!s}]", seen) - elif hasattr(obj, "__dict__"): - for attr in vars(obj): - try: - value = getattr(obj, attr) - find_unpickleable(value, f"{name}.{attr}", seen) - except Exception as ie: - LOG.error(f"[ERROR] Accessing {name}.{attr}: {ie}") - elif isinstance(obj, list | tuple | set): - for idx, item in enumerate(obj): - find_unpickleable(item, f"{name}[{idx}]", seen) - - def list_folders(path: Path, base: Path, depth_limit: int) -> list[str]: """ List all folders relative to a base path, up to a specified depth limit. diff --git a/app/library/YTDLPOpts.py b/app/library/YTDLPOpts.py index ab1b6239..67526352 100644 --- a/app/library/YTDLPOpts.py +++ b/app/library/YTDLPOpts.py @@ -9,20 +9,17 @@ LOG: logging.Logger = logging.getLogger("YTDLPOpts") class YTDLPOpts: - _item_opts: dict = {} - """The item options.""" - - _preset_opts: dict = {} - """The preset options.""" - - _item_cli: list = [] - """The command options for yt-dlp from item.""" - - _preset_cli: str = "" - """The command options for yt-dlp from preset.""" - def __init__(self): self._config: Config = Config.get_instance() + "The config instance." + self._item_opts: dict = {} + "The item options." + self._preset_opts: dict = {} + "The preset options." + self._item_cli: list = [] + "The command options for yt-dlp from item." + self._preset_cli: str = "" + "The command options for yt-dlp from preset." @staticmethod def get_instance() -> "YTDLPOpts": diff --git a/app/library/cache.py b/app/library/cache.py index 4fd32509..34aa7bbe 100644 --- a/app/library/cache.py +++ b/app/library/cache.py @@ -11,12 +11,8 @@ class Cache(metaclass=ThreadSafe): """ Initialize the Cache. """ - # Prevent reinitialization in singleton context. - if hasattr(self, "_initialized") and self._initialized: - return self._cache: dict[str, tuple[Any, float | None]] = {} self._lock = threading.Lock() - self._initialized = True def set(self, key: str, value: Any, ttl: float | None = None) -> None: """ diff --git a/app/library/conditions.py b/app/library/conditions.py index 98c45de9..adb813ed 100644 --- a/app/library/conditions.py +++ b/app/library/conditions.py @@ -14,7 +14,7 @@ from .mini_filter import match_str from .Singleton import Singleton from .Utils import arg_converter, init_class -LOG = logging.getLogger("conditions") +LOG: logging.Logger = logging.getLogger("conditions") @dataclass(kw_only=True) @@ -49,18 +49,14 @@ class Conditions(metaclass=Singleton): This class is used to manage the download conditions. """ - _items: list[Condition] = [] - """The list of items.""" - - _instance = None - """The instance of the class.""" - def __init__(self, file: Path | str | None = None, config: Config | None = None): - Conditions._instance = self + self._items: list[Condition] = [] + "The list of items." config = config or Config.get_instance() self._file: Path = Path(file) if file else Path(config.config_path) / "conditions.json" + "The path to the file where the items are stored." if self._file.exists() and "600" != self._file.stat().st_mode: try: @@ -68,14 +64,8 @@ class Conditions(metaclass=Singleton): except Exception: pass - async def event_handler(_, __): - msg = "Not implemented" - raise Exception(msg) - - EventBus.get_instance().subscribe(Events.CONDITIONS_ADD, event_handler, f"{__class__.__name__}.save") - @staticmethod - def get_instance() -> "Conditions": + def get_instance(file: Path | str | None = None, config: Config | None = None) -> "Conditions": """ Get the instance of the class. @@ -83,10 +73,7 @@ class Conditions(metaclass=Singleton): Conditions: The instance of the class """ - if not Conditions._instance: - Conditions._instance = Conditions() - - return Conditions._instance + return Conditions(file=file, config=config) async def on_shutdown(self, _: web.Application): pass @@ -104,6 +91,12 @@ class Conditions(metaclass=Singleton): """ self.load() + async def event_handler(_, __): + msg = "Not implemented" + raise Exception(msg) + + EventBus.get_instance().subscribe(Events.CONDITIONS_ADD, event_handler, f"{__class__.__name__}.save") + def get_all(self) -> list[Condition]: """Return the items.""" return self._items diff --git a/app/library/config.py b/app/library/config.py index 8a83578b..071194ee 100644 --- a/app/library/config.py +++ b/app/library/config.py @@ -12,6 +12,7 @@ from typing import TYPE_CHECKING import coloredlogs from dotenv import load_dotenv +from .Singleton import Singleton from .Utils import FileLogFormatter, arg_converter from .version import APP_BRANCH, APP_BUILD_DATE, APP_COMMIT_SHA, APP_VERSION @@ -19,7 +20,7 @@ if TYPE_CHECKING: from subprocess import CompletedProcess -class Config: +class Config(metaclass=Singleton): app_env: str = "production" """The application environment, can be 'production' or 'development'.""" @@ -131,9 +132,6 @@ class Config: app_branch: str = APP_BRANCH "The branch of the application." - __instance = None - "The instance of the class." - started: int = 0 "The time the application was started." @@ -206,7 +204,6 @@ class Config: "The variables that are set manually." _immutable: tuple = ( - "__instance", "ytdl_options", "started", "ytdlp_cli", @@ -284,8 +281,7 @@ class Config: @staticmethod def get_instance(is_native: bool = False) -> "Config": - """Static access method.""" - cls: Config = Config(is_native) if not Config.__instance else Config.__instance + cls = Config(is_native) cls.is_native = is_native or cls.is_native return cls @@ -297,13 +293,6 @@ class Config: return Config._manager def __init__(self, is_native: bool = False): - """Virtually private constructor.""" - if Config.__instance is not None: - msg = "This class is a singleton. Use Config.get_instance() instead." - raise Exception(msg) - - Config.__instance = self - baseDefaultPath: str = str(Path(__file__).parent.parent.parent.absolute()) self.is_native = is_native diff --git a/app/library/dl_fields.py b/app/library/dl_fields.py index b563581c..85abee56 100644 --- a/app/library/dl_fields.py +++ b/app/library/dl_fields.py @@ -100,21 +100,13 @@ class DLFields(metaclass=Singleton): This class is used to manage the DLFields. """ - _items: list[DLField] = [] - """The list of items.""" - - _instance = None - """The instance of the class.""" - - _config: Config = None - """The config instance.""" - def __init__(self, file: str | Path | None = None, config: Config | None = None): - DLFields._instance = self - - self._config = config or Config.get_instance() - - self._file: Path = Path(file) if file else Path(self._config.config_path).joinpath("dl_fields.json") + self._items: list[DLField] = [] + "The list of items." + config: Config = config or Config.get_instance() + "The configuration instance." + self._file: Path = Path(file) if file else Path(config.config_path).joinpath("dl_fields.json") + "The path to the file where the items are stored." if self._file.exists() and "600" != self._file.stat().st_mode: try: @@ -122,14 +114,8 @@ class DLFields(metaclass=Singleton): except Exception: pass - async def event_handler(_, __): - msg = "Not implemented" - raise Exception(msg) - - EventBus.get_instance().subscribe(Events.DLFIELDS_ADD, event_handler, f"{__class__.__name__}.add") - @staticmethod - def get_instance() -> "DLFields": + def get_instance(file: str | Path | None = None, config: Config | None = None) -> "DLFields": """ Get the instance of the class. @@ -137,10 +123,7 @@ class DLFields(metaclass=Singleton): DLFields: The instance of the class """ - if not DLFields._instance: - DLFields._instance = DLFields() - - return DLFields._instance + return DLFields(file=file, config=config) async def on_shutdown(self, _: web.Application): pass @@ -158,6 +141,12 @@ class DLFields(metaclass=Singleton): """ self.load() + async def event_handler(_, __): + msg = "Not implemented" + raise Exception(msg) + + EventBus.get_instance().subscribe(Events.DLFIELDS_ADD, event_handler, f"{__class__.__name__}.add") + def get_all(self) -> list[DLField]: """Return the items.""" return self._items diff --git a/app/library/ffprobe.py b/app/library/ffprobe.py index 30ef5800..54ba5404 100644 --- a/app/library/ffprobe.py +++ b/app/library/ffprobe.py @@ -277,7 +277,7 @@ async def ffprobe(file: str) -> FFProbeResult: msg = "ffprobe not found." raise OSError(msg) from e - args = ["-v", "quiet", "-of", "json", "-show_streams", "-show_format", str(f)] + args: list[str] = ["-v", "quiet", "-of", "json", "-show_streams", "-show_format", str(f)] p = await asyncio.create_subprocess_exec( "ffprobe", @@ -287,13 +287,13 @@ async def ffprobe(file: str) -> FFProbeResult: creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, ) - exitCode = await p.wait() + exitCode: int = await p.wait() data, err = await p.communicate() if 0 == exitCode: parsed: dict = json.loads(data.decode("utf-8")) else: - msg = f"ffprobe returned with non-0 exit code. '{err.decode('utf-8')}'" + msg: str = f"ffprobe returned with non-0 exit code. '{err.decode('utf-8')}'" raise FFProbeError(msg) result = FFProbeResult() diff --git a/app/library/task_handlers/youtube.py b/app/library/task_handlers/youtube.py index 9d652793..1ac3a952 100644 --- a/app/library/task_handlers/youtube.py +++ b/app/library/task_handlers/youtube.py @@ -61,7 +61,7 @@ class YoutubeHandler(BaseHandler): params: dict = task.get_ytdlp_opts().get_all() feed_url: str = YoutubeHandler.FEED.format(type=parsed["type"], id=parsed["id"]) - LOG.info(f"'{task.name}': Fetching feed.") + LOG.debug(f"'{task.name}': Fetching feed.") items: list = [] @@ -106,7 +106,7 @@ class YoutubeHandler(BaseHandler): if real_count < 1: LOG.warning(f"'{task.name}': No entries found the RSS feed. URL: {feed_url}") else: - LOG.info(f"'{task.name}': Feed has '{real_count}' entries, all already downloaded/queued.") + LOG.debug(f"'{task.name}': Feed has '{real_count}' entries, all already downloaded/queued.") return filtered: list = [] @@ -134,7 +134,7 @@ class YoutubeHandler(BaseHandler): filtered.append(item) if len(filtered) < 1: - LOG.info(f"'{task.name}': Feed has '{real_count}' entries, all already downloaded/queued.") + LOG.debug(f"'{task.name}': Feed has '{real_count}' entries, all already downloaded/queued.") return LOG.info(f"'{task.name}': Found '{len(filtered)}/{real_count}' new items from feed.") diff --git a/app/library/ytdlp.py b/app/library/ytdlp.py index c67ce607..0e4caaf4 100644 --- a/app/library/ytdlp.py +++ b/app/library/ytdlp.py @@ -14,6 +14,7 @@ class _ArchiveProxy: def __init__(self, file: str | None): self._file: str | None = file + "The archive file path." def __contains__(self, item: str) -> bool: if not self._file or not item: diff --git a/app/tests/test_cache.py b/app/tests/test_cache.py index b8460434..20f72d68 100644 --- a/app/tests/test_cache.py +++ b/app/tests/test_cache.py @@ -29,9 +29,7 @@ class TestCache: def setup_method(self): """Set up test fixtures.""" - # Clear any existing cache instance - if hasattr(Cache, "_instances"): - Cache._instances.clear() + Cache._reset_singleton() self.cache = Cache() def test_singleton_behavior(self): diff --git a/app/tests/test_conditions.py b/app/tests/test_conditions.py index 7ea5a606..8a57e761 100644 --- a/app/tests/test_conditions.py +++ b/app/tests/test_conditions.py @@ -21,7 +21,6 @@ from unittest.mock import MagicMock, patch import pytest from app.library.conditions import Condition, Conditions -from app.library.Singleton import Singleton class TestCondition: @@ -112,11 +111,7 @@ class TestConditions: def setup_method(self): """Set up test fixtures by clearing singleton instances.""" - # Clear singleton instances before each test - Singleton._instances.clear() - # Reset class variable - Conditions._items = [] - Conditions._instance = None + Conditions._reset_singleton() def test_conditions_singleton(self): """Test that Conditions follows singleton pattern.""" diff --git a/app/tests/test_dl_fields.py b/app/tests/test_dl_fields.py index 72ff381e..d888c310 100644 --- a/app/tests/test_dl_fields.py +++ b/app/tests/test_dl_fields.py @@ -22,7 +22,6 @@ from unittest.mock import MagicMock, patch import pytest from app.library.dl_fields import DLField, DLFields, FieldType -from app.library.Singleton import Singleton class TestFieldType: @@ -156,12 +155,7 @@ class TestDLFields: def setup_method(self): """Set up test fixtures.""" - # Clear singleton instances before each test - Singleton._instances.clear() - if hasattr(DLFields, "_instances"): - DLFields._instances.clear() - # Also clear the class-level _items list - DLFields._items = [] + DLFields._reset_singleton() @pytest.fixture def temp_file(self): @@ -219,11 +213,9 @@ class TestDLFields: assert fields1 is fields2 @patch("app.library.dl_fields.Config") - @patch("app.library.dl_fields.EventBus") - def test_dl_fields_initialization(self, mock_event_bus, mock_config): + def test_dl_fields_initialization(self, mock_config): """Test DLFields initialization.""" mock_config.get_instance.return_value.config_path = "/tmp" - mock_event_bus.get_instance.return_value.subscribe = MagicMock() with tempfile.TemporaryDirectory() as temp_dir: temp_file = Path(temp_dir) / "test_fields.json" @@ -231,8 +223,6 @@ class TestDLFields: fields = DLFields(file=str(temp_file)) assert fields._file == temp_file - assert fields._config is not None - mock_event_bus.get_instance.return_value.subscribe.assert_called_once() @patch("app.library.dl_fields.Config") def test_dl_fields_load_empty_file(self, mock_config, temp_file): diff --git a/app/tests/test_events.py b/app/tests/test_events.py index 11079cef..a23ee1e1 100644 --- a/app/tests/test_events.py +++ b/app/tests/test_events.py @@ -627,10 +627,7 @@ class TestEventBus: def setup_method(self): """Clear EventBus listeners and reset singleton before each test.""" - # Reset singleton instance to allow mocking to work - EventBus._instance = None - bus = EventBus.get_instance() - bus.clear() + EventBus._reset_singleton() @patch("app.library.config.Config") @patch("app.library.BackgroundWorker.BackgroundWorker") @@ -656,9 +653,6 @@ class TestEventBus: def test_event_bus_initialization(self): """Test EventBus initialization with new clean design.""" - # Reset singleton to ensure fresh instance - EventBus._instance = None - # Create EventBus with clean initialization bus = EventBus() diff --git a/app/tests/test_presets.py b/app/tests/test_presets.py index 74f605ed..95464e8f 100644 --- a/app/tests/test_presets.py +++ b/app/tests/test_presets.py @@ -99,13 +99,6 @@ class TestPresets: def setup_method(self): """Set up test environment before each test.""" # Reset singleton completely - Presets._instance = None - - def teardown_method(self): - """Clean up after each test.""" - # Reset singleton completely - Presets.get_instance()._items.clear() - Presets._instance = None Presets._reset_singleton() @patch("app.library.Presets.Config") diff --git a/app/tests/test_services.py b/app/tests/test_services.py new file mode 100644 index 00000000..75850d3f --- /dev/null +++ b/app/tests/test_services.py @@ -0,0 +1,552 @@ +import logging +from unittest.mock import MagicMock, patch + +import pytest + +from app.library.Services import Services + + +class TestServices: + """Test the Services singleton class.""" + + def setup_method(self): + """Clear services before each test.""" + Services._reset_singleton() + + def test_singleton_behavior(self): + """Test that Services follows singleton pattern.""" + service1 = Services() + service2 = Services() + service3 = Services.get_instance() + + assert service1 is service2, "Multiple Services() calls should return same instance" + assert service1 is service3, "get_instance() should return same instance" + assert id(service1) == id(service2) == id(service3), "All references should point to same object" + + def test_add_and_get_service(self): + """Test adding and retrieving services.""" + services = Services() + test_service = "test_value" + + services.add("test_service", test_service) + retrieved = services.get("test_service") + + assert retrieved == test_service, "Should retrieve the same service that was added" + + def test_get_nonexistent_service(self): + """Test retrieving a service that doesn't exist.""" + services = Services() + result = services.get("nonexistent") + + assert result is None, "Should return None for nonexistent service" + + def test_has_service(self): + """Test checking if service exists.""" + services = Services() + services.add("existing", "value") + + assert services.has("existing") is True, "Should return True for existing service" + assert services.has("nonexistent") is False, "Should return False for nonexistent service" + + def test_remove_service(self): + """Test removing a service.""" + services = Services() + services.add("to_remove", "value") + + assert services.has("to_remove") is True, "Service should exist before removal" + services.remove("to_remove") + assert services.has("to_remove") is False, "Service should not exist after removal" + + def test_remove_nonexistent_service(self): + """Test removing a service that doesn't exist.""" + services = Services() + # Should not raise an exception + services.remove("nonexistent") + assert services.has("nonexistent") is False + + def test_clear_services(self): + """Test clearing all services.""" + services = Services() + services.add("service1", "value1") + services.add("service2", "value2") + + assert len(services.get_all()) == 2, "Should have 2 services before clear" + services.clear() + assert len(services.get_all()) == 0, "Should have 0 services after clear" + + def test_add_all_services(self): + """Test adding multiple services at once.""" + services = Services() + services_dict = { + "service1": "value1", + "service2": "value2", + "service3": "value3" + } + + services.add_all(services_dict) + + assert services.get("service1") == "value1" + assert services.get("service2") == "value2" + assert services.get("service3") == "value3" + assert len(services.get_all()) == 3 + + def test_get_all_returns_copy(self): + """Test that get_all returns a copy, not the original dict.""" + services = Services() + services.add("test", "value") + + all_services = services.get_all() + all_services["injected"] = "malicious" + + assert "injected" not in services.get_all(), "Modifying returned dict should not affect internal state" + + def test_handle_sync_with_matching_args(self): + """Test synchronous handler with matching arguments.""" + services = Services() + services.add("db", "database_connection") + services.add("logger", "logger_instance") + + def test_handler(db, logger): + return f"Handler called with {db} and {logger}" + + result = services.handle_sync(test_handler) + expected = "Handler called with database_connection and logger_instance" + assert result == expected + + def test_handle_sync_with_extra_kwargs(self): + """Test synchronous handler with additional kwargs.""" + services = Services() + services.add("db", "database_connection") + + def test_handler(db, user_id): + return f"Handler called with {db} and {user_id}" + + result = services.handle_sync(test_handler, user_id=123) + expected = "Handler called with database_connection and 123" + assert result == expected + + def test_handle_sync_with_missing_args(self): + """Test synchronous handler with missing arguments.""" + services = Services() + services.add("db", "database_connection") + + def test_handler(db_param, missing_service_param): # noqa: ARG001 + return "Should not reach here" + + with patch("app.library.Services.LOG") as mock_logger: + # The current implementation still calls the handler even with missing args + # This causes a TypeError, which is the actual current behavior + with pytest.raises(TypeError, match=r"missing .* required positional argument"): + services.handle_sync(test_handler) + + # Should still log error about missing arguments + mock_logger.error.assert_called_once() + error_call = mock_logger.error.call_args[0][0] + assert "Missing arguments" in error_call + assert "missing_service_param" in error_call + + def test_handle_sync_no_args_handler(self): + """Test synchronous handler that takes no arguments.""" + services = Services() + services.add("unused", "value") + + def test_handler(): + return "No args handler" + + result = services.handle_sync(test_handler) + assert result == "No args handler" + + @pytest.mark.asyncio + async def test_handle_async_with_matching_args(self): + """Test asynchronous handler with matching arguments.""" + services = Services() + services.add("db", "database_connection") + services.add("logger", "logger_instance") + + async def test_handler(db, logger): + return f"Async handler called with {db} and {logger}" + + result = await services.handle_async(test_handler) + expected = "Async handler called with database_connection and logger_instance" + assert result == expected + + @pytest.mark.asyncio + async def test_handle_async_with_extra_kwargs(self): + """Test asynchronous handler with additional kwargs.""" + services = Services() + services.add("db", "database_connection") + + async def test_handler(db, user_id): + return f"Async handler called with {db} and {user_id}" + + result = await services.handle_async(test_handler, user_id=456) + expected = "Async handler called with database_connection and 456" + assert result == expected + + @pytest.mark.asyncio + async def test_handle_async_with_missing_args(self): + """Test asynchronous handler with missing arguments.""" + services = Services() + services.add("db", "database_connection") + + async def test_handler(db_param, missing_service_param): # noqa: ARG001 + return "Should not reach here" + + with patch("app.library.Services.LOG") as mock_logger: + # The current implementation still calls the handler even with missing args + # This causes a TypeError, which is the actual current behavior + with pytest.raises(TypeError, match=r"missing .* required positional argument"): + await services.handle_async(test_handler) + + # Should still log error about missing arguments + mock_logger.error.assert_called_once() + error_call = mock_logger.error.call_args[0][0] + assert "Missing arguments" in error_call + assert "missing_service_param" in error_call + + @pytest.mark.asyncio + async def test_handle_async_no_args_handler(self): + """Test asynchronous handler that takes no arguments.""" + services = Services() + services.add("unused", "value") + + async def test_handler(): + return "No args async handler" + + result = await services.handle_async(test_handler) + assert result == "No args async handler" + + def test_handle_sync_kwargs_override_services(self): + """Test that kwargs override services with same name.""" + services = Services() + services.add("param", "service_value") + + def test_handler(param): + return f"Received: {param}" + + result = services.handle_sync(test_handler, param="override_value") + assert result == "Received: override_value" + + @pytest.mark.asyncio + async def test_handle_async_kwargs_override_services(self): + """Test that kwargs override services with same name in async handler.""" + services = Services() + services.add("param", "service_value") + + async def test_handler(param): + return f"Received: {param}" + + result = await services.handle_async(test_handler, param="override_value") + assert result == "Received: override_value" + + def test_handle_sync_with_complex_signature(self): + """Test synchronous handler with complex function signature.""" + services = Services() + services.add("db", "database") + services.add("cache", "redis") + + def complex_handler(db, cache, *args, **kwargs): + return f"db:{db}, cache:{cache}, args:{args}, kwargs:{kwargs}" + + result = services.handle_sync(complex_handler, extra="value") + expected = "db:database, cache:redis, args:(), kwargs:{}" + assert result == expected + + @pytest.mark.asyncio + async def test_handle_async_with_complex_signature(self): + """Test asynchronous handler with complex function signature.""" + services = Services() + services.add("db", "database") + services.add("cache", "redis") + + async def complex_handler(db, cache, *args, **kwargs): + return f"db:{db}, cache:{cache}, args:{args}, kwargs:{kwargs}" + + result = await services.handle_async(complex_handler, extra="value") + expected = "db:database, cache:redis, args:(), kwargs:{}" + assert result == expected + + def test_service_types_preserved(self): + """Test that different service types are preserved correctly.""" + services = Services() + + # Test various types + string_service = "string_value" + int_service = 42 + list_service = [1, 2, 3] + dict_service = {"key": "value"} + custom_object = MagicMock() + + services.add("string", string_service) + services.add("int", int_service) + services.add("list", list_service) + services.add("dict", dict_service) + services.add("object", custom_object) + + assert services.get("string") == string_service + assert services.get("int") == int_service + assert services.get("list") == list_service + assert services.get("dict") == dict_service + assert services.get("object") is custom_object + + def test_add_none_service(self): + """Test adding None as a service value.""" + services = Services() + services.add("none_service", None) + + assert services.has("none_service") is True + assert services.get("none_service") is None + + def test_service_name_edge_cases(self): + """Test edge cases for service names.""" + services = Services() + + # Empty string name + services.add("", "empty_name_value") + assert services.get("") == "empty_name_value" + + # Numeric string name + services.add("123", "numeric_name") + assert services.get("123") == "numeric_name" + + # Special characters in name + services.add("special-chars_123!@#", "special_value") + assert services.get("special-chars_123!@#") == "special_value" + + def test_overwrite_existing_service(self): + """Test overwriting an existing service.""" + services = Services() + services.add("service", "original_value") + services.add("service", "new_value") + + assert services.get("service") == "new_value" + + def test_singleton_persistence_across_operations(self): + """Test that singleton behavior persists across various operations.""" + # Get instance and add a service + services1 = Services() + services1.add("persistent", "value") + + # Get another instance and verify service exists + services2 = Services.get_instance() + assert services2.get("persistent") == "value" + + # Clear from one instance + services1.clear() + + # Verify cleared in other instance + assert services2.get("persistent") is None + + def test_handler_exception_propagation(self): + """Test that exceptions in handlers are properly propagated.""" + services = Services() + + def failing_handler(): + msg = "Handler failed" + raise ValueError(msg) + + with pytest.raises(ValueError, match="Handler failed"): + services.handle_sync(failing_handler) + + @pytest.mark.asyncio + async def test_async_handler_exception_propagation(self): + """Test that exceptions in async handlers are properly propagated.""" + services = Services() + + async def failing_async_handler(): + msg = "Async handler failed" + raise RuntimeError(msg) + + with pytest.raises(RuntimeError, match="Async handler failed"): + await services.handle_async(failing_async_handler) + + def test_handle_sync_with_callable_object(self): + """Test handle_sync with callable object instead of function.""" + services = Services() + services.add("data", "test_data") + + class CallableHandler: + def __call__(self, data): + return f"Callable received: {data}" + + handler = CallableHandler() + result = services.handle_sync(handler) + assert result == "Callable received: test_data" + + @pytest.mark.asyncio + async def test_handle_async_with_callable_object(self): + """Test handle_async with callable object instead of function.""" + services = Services() + services.add("data", "test_data") + + class AsyncCallableHandler: + async def __call__(self, data): + return f"Async callable received: {data}" + + handler = AsyncCallableHandler() + result = await services.handle_async(handler) + assert result == "Async callable received: test_data" + + def test_inspect_signature_edge_cases(self): + """Test that inspect.signature works correctly with edge cases.""" + services = Services() + + # Lambda function replacement + def lambda_handler(x): + return f"Lambda: {x}" + services.add("x", "lambda_value") + result = services.handle_sync(lambda_handler) + assert result == "Lambda: lambda_value" + + def test_logging_configuration(self): + """Test that logging is properly configured.""" + # This test verifies the module-level logger setup + from app.library.Services import LOG + + assert isinstance(LOG, logging.Logger) + assert LOG.name == "app.library.Services" + + def test_service_container_isolation(self): + """Test that services don't interfere with each other.""" + services = Services() + + # Add services with potentially conflicting names + services.add("data", {"type": "database"}) + services.add("data_backup", {"type": "backup"}) + + assert services.get("data")["type"] == "database" + assert services.get("data_backup")["type"] == "backup" + + # Remove one, other should remain + services.remove("data") + assert services.get("data") is None + assert services.get("data_backup")["type"] == "backup" + + def test_large_number_of_services(self): + """Test handling a large number of services.""" + services = Services() + + # Add many services + num_services = 1000 + for i in range(num_services): + services.add(f"service_{i}", f"value_{i}") + + # Verify all exist + assert len(services.get_all()) == num_services + + # Verify specific services + assert services.get("service_0") == "value_0" + assert services.get("service_500") == "value_500" + assert services.get("service_999") == "value_999" + + # Clear should work efficiently + services.clear() + assert len(services.get_all()) == 0 + + def test_add_all_overwrites_existing(self): + """Test that add_all overwrites existing services.""" + services = Services() + services.add("existing", "original") + + new_services = { + "existing": "overwritten", + "new": "value" + } + + services.add_all(new_services) + + assert services.get("existing") == "overwritten" + assert services.get("new") == "value" + + def test_add_all_empty_dict(self): + """Test add_all with empty dictionary.""" + services = Services() + services.add("existing", "value") + + services.add_all({}) + + # Should not affect existing services + assert services.get("existing") == "value" + assert len(services.get_all()) == 1 + + def test_type_var_generic_behavior(self): + """Test that TypeVar T is handled correctly.""" + services = Services() + + # Add different types and ensure they're returned correctly + services.add("string", "text") + services.add("number", 42) + services.add("boolean", True) # noqa: FBT003 + + # Type should be preserved (runtime check) + assert isinstance(services.get("string"), str) + assert isinstance(services.get("number"), int) + assert isinstance(services.get("boolean"), bool) + + def test_concurrent_access_safety(self): + """Test basic thread safety aspects of singleton.""" + import threading + import time + + results = [] + + def get_instance(): + time.sleep(0.01) # Small delay to increase chance of race condition + instance = Services() + results.append(id(instance)) + + # Create multiple threads + threads = [] + for _ in range(10): + thread = threading.Thread(target=get_instance) + threads.append(thread) + thread.start() + + # Wait for all threads + for thread in threads: + thread.join() + + # All should be the same instance + assert len(set(results)) == 1, "All threads should get the same singleton instance" + + def test_method_chaining_possibility(self): + """Test that methods can be potentially chained.""" + services = Services() + + # While current implementation doesn't return self, test the pattern works + services.add("test1", "value1") + services.add("test2", "value2") + services.remove("test1") + + assert services.get("test1") is None + assert services.get("test2") == "value2" + + def test_edge_case_empty_handler_name(self): + """Test handlers with minimal or no names.""" + services = Services() + services.add("param", "value") + + # Anonymous lambda + result = services.handle_sync(lambda param: f"anon: {param}") + assert result == "anon: value" + + # Function with minimal signature info + def minimal(param): return param + result = services.handle_sync(minimal) + assert result == "value" + + def test_services_state_isolation(self): + """Test that different Services instances share state properly.""" + # This test verifies the singleton behavior more thoroughly + s1 = Services() + s1.add("shared", "data") + + s2 = Services.get_instance() + assert s2.get("shared") == "data" + + s2.add("another", "value") + assert s1.get("another") == "value" + + # Clear from s1 affects s2 + s1.clear() + assert len(s2.get_all()) == 0 diff --git a/app/tests/test_utils.py b/app/tests/test_utils.py index 214f78f6..507a28a6 100644 --- a/app/tests/test_utils.py +++ b/app/tests/test_utils.py @@ -26,7 +26,6 @@ from app.library.Utils import ( encrypt_data, extract_info, extract_ytdlp_logs, - find_unpickleable, get, get_archive_id, get_file, @@ -1424,30 +1423,6 @@ class TestYtdlpReject: assert isinstance(message, str) -class TestFindUnpickleable: - """Test the find_unpickleable function.""" - - def test_find_unpickleable_simple(self): - """Test with simple pickleable object.""" - obj = {"key": "value", "number": 42} - - try: - find_unpickleable(obj) - # Should not find any unpickleable items - except Exception: - # Function might raise exceptions for complex objects - pass - - def test_find_unpickleable_complex(self): - """Test with complex object.""" - obj = {"func": lambda x: x} # Lambda is not pickleable - - try: - find_unpickleable(obj) - except Exception: - pass - - class TestInitClass: """Test the init_class function.""" From f490c781793f535488f1dea6f8ebcf1f87d9721d Mon Sep 17 00:00:00 2001 From: arabcoders Date: Fri, 12 Sep 2025 20:38:09 +0300 Subject: [PATCH 4/5] removed deprecated features and flags --- README.md | 2 +- app/library/Download.py | 4 +- app/library/YTDLPOpts.py | 3 - app/library/config.py | 119 ++++++---------------------- app/routes/api/browser.py | 6 -- app/routes/api/conditions.py | 11 +-- app/routes/api/images.py | 40 +++++++--- app/routes/api/yt_dlp.py | 16 ++-- pyproject.toml | 7 +- ui/app/components/ConditionForm.vue | 10 ++- ui/app/components/History.vue | 10 +-- ui/app/components/NewDownload.vue | 72 ++++++++--------- ui/app/components/Queue.vue | 28 +++---- ui/app/layouts/default.vue | 7 +- ui/app/pages/browser/[...slug].vue | 25 ------ ui/app/pages/conditions.vue | 17 ++-- ui/app/pages/console.vue | 16 +--- ui/app/pages/index.vue | 54 +------------ ui/app/pages/logs.vue | 7 -- ui/app/pages/notifications.vue | 8 -- ui/app/pages/presets.vue | 27 +------ ui/app/pages/tasks.vue | 7 -- ui/app/stores/ConfigStore.ts | 5 +- ui/app/types/config.d.ts | 6 -- uv.lock | 2 + 25 files changed, 146 insertions(+), 363 deletions(-) diff --git a/README.md b/README.md index 6a49e7fb..986571dc 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ live streams, and includes features like scheduling downloads, sending notificat * Supports `curl-cffi`. See [yt-dlp documentation](https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#impersonation) * Bundled `pot provider plugin`. See [yt-dlp documentation](https://github.com/yt-dlp/yt-dlp/wiki/PO-Token-Guide) * Automatic updates for `yt-dlp` and custom `pip` packages. -* Conditions feature. +* Conditions feature to apply custom options based on `yt-dlp` returned info. * Custom browser extensions, bookmarklets and iOS shortcuts to send links to YTPTube instance. * A bundled executable version for Windows, macOS and Linux. `For non-docker users`. diff --git a/app/library/Download.py b/app/library/Download.py index b18ad0d1..aa9557ac 100644 --- a/app/library/Download.py +++ b/app/library/Download.py @@ -489,9 +489,7 @@ class Download: and self.info.downloaded_bytes and self.info.downloaded_bytes > 0 ): - self.logger.warning( - f"Keeping temp folder '{self.temp_path}', as the reported status is not finished '{self.info.status}'." - ) + self.logger.warning(f"Keeping temp folder '{self.temp_path}'. {self.info.status=}.") return tmp_dir = Path(self.temp_path) diff --git a/app/library/YTDLPOpts.py b/app/library/YTDLPOpts.py index 67526352..4996cbe0 100644 --- a/app/library/YTDLPOpts.py +++ b/app/library/YTDLPOpts.py @@ -152,9 +152,6 @@ class YTDLPOpts: self._item_cli = [] merge: list[str] = [] - if self._config._ytdlp_cli_mutable and len(self._config._ytdlp_cli_mutable) > 1: - merge.append(self._config._ytdlp_cli_mutable) - if self._preset_cli and len(self._preset_cli) > 1: merge.append(self._preset_cli) diff --git a/app/library/config.py b/app/library/config.py index 071194ee..946df2a1 100644 --- a/app/library/config.py +++ b/app/library/config.py @@ -7,13 +7,13 @@ import time from logging.handlers import TimedRotatingFileHandler from multiprocessing.managers import SyncManager from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import coloredlogs from dotenv import load_dotenv from .Singleton import Singleton -from .Utils import FileLogFormatter, arg_converter +from .Utils import FileLogFormatter from .version import APP_BRANCH, APP_BUILD_DATE, APP_COMMIT_SHA, APP_VERSION if TYPE_CHECKING: @@ -96,9 +96,6 @@ class Config(metaclass=Singleton): debugpy_port: int = 5678 """The port to use for the debugpy server.""" - socket_timeout: int = 30 - """The socket timeout to use for yt-dlp.""" - extract_info_timeout: int = 70 """The timeout to use for extracting video information.""" @@ -138,9 +135,6 @@ class Config(metaclass=Singleton): ignore_ui: bool = False "Ignore the UI and run the application in the background." - basic_mode: bool = False - "Run the frontend in basic mode." - default_preset: str = "default" "The default preset to use when no preset is specified." @@ -159,21 +153,12 @@ class Config(metaclass=Singleton): console_enabled: bool = False "Enable direct access to yt-dlp console." - browser_enabled: bool = True - "Enable file browser access." - browser_control_enabled: bool = False "Enable file browser control access." ytdlp_auto_update: bool = True """Enable in-place auto update of yt-dlp package.""" - ytdlp_cli: str = "" - """The command line options to use for yt-dlp.""" - - _ytdlp_cli_mutable: str = "" - """The command line options to use for yt-dlp.""" - ytdlp_version: str | None = None """The version of yt-dlp to use, if not set, the latest version will be used.""" @@ -206,8 +191,6 @@ class Config(metaclass=Singleton): _immutable: tuple = ( "ytdl_options", "started", - "ytdlp_cli", - "_ytdlp_cli_mutable", "is_native", "app_version", "app_commit_sha", @@ -219,7 +202,6 @@ class Config(metaclass=Singleton): _int_vars: tuple = ( "port", "max_workers", - "socket_timeout", "extract_info_timeout", "debugpy_port", "playlist_items_concurrency", @@ -238,10 +220,8 @@ class Config(metaclass=Singleton): "ignore_ui", "ui_update_title", "pip_ignore_updates", - "basic_mode", "file_logging", "console_enabled", - "browser_enabled", "browser_control_enabled", "ytdlp_auto_update", "prevent_premiere_live", @@ -257,13 +237,10 @@ class Config(metaclass=Singleton): "remove_files", "ui_update_title", "max_workers", - "basic_mode", "default_preset", "instance_title", "console_enabled", - "browser_enabled", "browser_control_enabled", - "ytdlp_cli", "file_logging", "base_path", "is_native", @@ -351,7 +328,7 @@ class Config(metaclass=Singleton): if not self.base_path.endswith("/"): self.base_path += "/" - numeric_level = getattr(logging, self.log_level.upper(), None) + numeric_level: int | None = getattr(logging, self.log_level.upper(), None) if not isinstance(numeric_level, int): msg = f"Invalid log level '{self.log_level}' specified." raise TypeError(msg) @@ -372,55 +349,26 @@ class Config(metaclass=Singleton): debugpy.listen(("0.0.0.0", self.debugpy_port), in_process_debug_adapter=True) LOG.info(f"starting debugpy server on '0.0.0.0:{self.debugpy_port}'.") except ImportError: - LOG.error("debugpy package not found, please install it with 'pip install debugpy'.") + LOG.error("debugpy package not found, install it with 'uv sync'.") except Exception as e: LOG.error(f"Error starting debugpy server at '0.0.0.0:{self.debugpy_port}'. {e}") - opts_file: Path = Path(self.config_path) / "ytdlp.cli" - if opts_file.exists() and opts_file.stat().st_size > 2: - LOG.warning( - "Usage of 'ytdlp.cli' global config file is deprecated and will be removed in future releases. please migrate to presets." - ) - with open(opts_file) as f: - self.ytdlp_cli = f.read().strip() - if self.ytdlp_cli: - self._ytdlp_cli_mutable = self.ytdlp_cli - try: - arg_converter(args=self.ytdlp_cli, level=True) - except Exception as e: - msg = f"Failed to parse yt-dlp cli options from '{opts_file}'. '{e!s}'." - raise ValueError(msg) from e - - self._ytdlp_cli_mutable += f"\n--socket-timeout {self.socket_timeout}" - - if self.keep_archive: - LOG.warning( - "The global 'keep_archive' option is deprecated and will be removed in future releases. please use presets instead." - ) - archive_file: Path = Path(self.archive_file) - if not archive_file.exists(): - LOG.info(f"Creating archive file '{archive_file}'.") - archive_file.touch(exist_ok=True) - - LOG.info(f"keep archive option is enabled. Using archive file '{archive_file}' by default.") - self._ytdlp_cli_mutable += f"\n--download-archive {archive_file.as_posix()!s}" + if (Path(self.config_path) / "ytdlp.cli").exists(): + LOG.error("Support for ./ytdlp.cli file is removed, migrate to presets and remove the file.") if self.temp_keep: - LOG.info("Keep temp files option is enabled.") + LOG.warning("Keep temp files option is enabled.") if self.auth_password and self.auth_username: - LOG.warning(f"Basic authentication enabled with username '{self.auth_username}'.") - - if self.basic_mode: - LOG.info("The frontend is running in basic mode.") + LOG.info(f"Authentication enabled with username '{self.auth_username}'.") if self.file_logging: - log_level_file = getattr(logging, self.log_level_file.upper(), None) + log_level_file: int | None = getattr(logging, self.log_level_file.upper(), None) if not isinstance(log_level_file, int): msg = f"Invalid file log level '{self.log_level_file}' specified." raise TypeError(msg) - loggingPath = Path(self.config_path) / "logs" + loggingPath: Path = Path(self.config_path) / "logs" if not loggingPath.exists(): loggingPath.mkdir(parents=True, exist_ok=True) @@ -448,15 +396,17 @@ class Config(metaclass=Singleton): self.started = time.time() - logging.getLogger("httpcore").setLevel(logging.INFO) - for _tool in ("httpx", "urllib3.connectionpool", "apprise"): - logging.getLogger(_tool).setLevel(logging.WARNING) + _log_levels = ( + ("httpx", logging.WARNING), + ("urllib3.connectionpool", logging.WARNING), + ("apprise", logging.WARNING), + ("httpcore", logging.INFO), + ) + for _tool, _level in _log_levels: + logging.getLogger(_tool).setLevel(_level) - # check env if self.app_env not in ("production", "development"): - msg: str = ( - f"Invalid application environment '{self.app_env}' specified. Must be 'production' or 'development'." - ) + msg: str = f"Invalid app environment '{self.app_env}' specified. Must be 'production' or 'development'." raise ValueError(msg) if "dev-master" == self.app_version: @@ -470,7 +420,7 @@ class Config(metaclass=Singleton): if attribute.startswith("_"): continue - value = getattr(vClass, attribute) + value: Any = getattr(vClass, attribute) if not callable(value): attrs[attribute] = value @@ -496,23 +446,6 @@ class Config(metaclass=Singleton): """ return "production" == self.app_env - def get_ytdlp_args(self) -> dict: - """ - Get the yt-dlp command line options as a dictionary. - - Returns: - dict: The yt-dlp command line options. - - Deprecated: - Usage of global ytdlp.cli file is deprecated, please use presets instead. - - """ - try: - return arg_converter(args=self._ytdlp_cli_mutable, level=True) - except Exception as e: - msg = f"Invalid ytdlp.cli options for yt-dlp. '{e!s}'." - raise ValueError(msg) from e - def frontend(self) -> dict: """ Returns configuration variables relevant to the frontend. @@ -521,23 +454,17 @@ class Config(metaclass=Singleton): dict: A dictionary with the frontend configuration """ - data = {k: getattr(self, k) for k in self._frontend_vars} - - ytdlp_args = self.get_ytdlp_args() - - # TODO: this doesn't make sense, as each item might have it's own archive file or none at all. - if not data.get("keep_archive", False) and ytdlp_args.get("download_archive", None): - data["keep_archive"] = True + data: dict[str, Any] = {k: getattr(self, k) for k in self._frontend_vars} data["ytdlp_version"] = Config._ytdlp_version() return data - def get_replacers(self) -> dict: + def get_replacers(self) -> dict[str, str]: """ Get the variables that can be used in Command options for yt-dlp. Returns: - dict: The replacer variables. + dict[str, str]: The replacer variables. """ keys: tuple[str] = ("download_path", "temp_path", "config_path") diff --git a/app/routes/api/browser.py b/app/routes/api/browser.py index 42075f85..2c47962c 100644 --- a/app/routes/api/browser.py +++ b/app/routes/api/browser.py @@ -129,9 +129,6 @@ async def file_browser(request: Request, config: Config, encoder: Encoder) -> Re Response: The response object. """ - if not config.browser_enabled: - return web.json_response(data={"error": "File browser is disabled."}, status=web.HTTPForbidden.status_code) - req_path: str = request.match_info.get("path") req_path: str = "/" if not req_path else unquote_plus(req_path) @@ -190,9 +187,6 @@ async def path_actions(request: Request, config: Config) -> Response: Response: The response object. """ - if not config.browser_enabled: - return web.json_response(data={"error": "File browser is disabled."}, status=web.HTTPForbidden.status_code) - if not config.browser_control_enabled: return web.json_response( data={"error": "File browser actions is disabled."}, status=web.HTTPForbidden.status_code diff --git a/app/routes/api/conditions.py b/app/routes/api/conditions.py index 21796cd7..6ce20818 100644 --- a/app/routes/api/conditions.py +++ b/app/routes/api/conditions.py @@ -153,13 +153,8 @@ async def conditions_test(request: Request, encoder: Encoder, cache: Cache, conf preset: str = params.get("preset", config.default_preset) key: str = cache.hash(url + str(preset)) if not cache.has(key): - opts = {} - if ytdlp_proxy := config.get_ytdlp_args().get("proxy", None): - opts["proxy"] = ytdlp_proxy - ytdlp_opts = YTDLPOpts.get_instance().preset(name=preset).add(opts).get_all() - - data = extract_info( - config=ytdlp_opts, + data: dict = extract_info( + config=YTDLPOpts.get_instance().preset(name=preset).get_all(), url=url, debug=False, no_archive=True, @@ -182,7 +177,7 @@ async def conditions_test(request: Request, encoder: Encoder, cache: Cache, conf ) try: - status = match_str(cond, data) + status: bool = match_str(cond, data) except Exception as e: LOG.exception(e) return web.json_response( diff --git a/app/routes/api/images.py b/app/routes/api/images.py index 68cd371e..d88b99e7 100644 --- a/app/routes/api/images.py +++ b/app/routes/api/images.py @@ -2,6 +2,7 @@ import logging import random import time from datetime import UTC, datetime +from typing import Any from urllib.parse import urlparse import httpx @@ -14,6 +15,7 @@ from app.library.cache import Cache from app.library.config import Config from app.library.router import route from app.library.Utils import validate_url +from app.library.YTDLPOpts import YTDLPOpts LOG: logging.Logger = logging.getLogger(__name__) @@ -33,7 +35,7 @@ async def get_thumbnail(request: Request, config: Config) -> Response: Response: The response object. """ - url = request.query.get("url") + url: str | None = request.query.get("url") if not url: return web.json_response(data={"error": "URL is required."}, status=web.HTTPForbidden.status_code) @@ -43,15 +45,28 @@ async def get_thumbnail(request: Request, config: Config) -> Response: return web.json_response(data={"error": str(e)}, status=web.HTTPForbidden.status_code) try: - - ytdlp_args = config.get_ytdlp_args() - opts = { - "proxy": ytdlp_args.get("proxy", None), + ytdlp_args: dict = YTDLPOpts.get_instance().preset(name=config.default_preset).get_all() + opts: dict[str, Any] = { "headers": { - "User-Agent": ytdlp_args.get("user_agent", request.headers.get("User-Agent", random_user_agent())), + "User-Agent": request.headers.get("User-Agent", ytdlp_args.get("user_agent", random_user_agent())), }, } + if proxy := ytdlp_args.get("proxy"): + opts["proxy"] = proxy + + try: + from httpx_curl_cffi import AsyncCurlTransport, CurlOpt + + opts["transport"] = AsyncCurlTransport( + impersonate="chrome", + default_headers=True, + curl_options={CurlOpt.FRESH_CONNECT: True}, + ) + opts.pop("headers", None) + except Exception: + pass + async with httpx.AsyncClient(**opts) as client: LOG.debug(f"Fetching thumbnail from '{url}'.") response = await client.request(method="GET", url=url, follow_redirects=True) @@ -115,13 +130,16 @@ async def get_background(request: Request, config: Config, cache: Cache) -> Resp }, ) - ytdlp_args = config.get_ytdlp_args() - opts = { - "proxy": ytdlp_args.get("proxy", None), + ytdlp_args: dict = YTDLPOpts.get_instance().preset(name=config.default_preset).get_all() + opts: dict[str, Any] = { "headers": { - "User-Agent": ytdlp_args.get("user_agent", random_user_agent()), + "User-Agent": request.headers.get("User-Agent", ytdlp_args.get("user_agent", random_user_agent())), }, } + + if proxy := ytdlp_args.get("proxy"): + opts["proxy"] = proxy + try: from httpx_curl_cffi import AsyncCurlTransport, CurlOpt @@ -166,7 +184,7 @@ async def get_background(request: Request, config: Config, cache: Cache) -> Resp status=web.HTTPInternalServerError.status_code, ) - data = { + data: dict[str, Any] = { "content": response.content, "backend": urlparse(backend).netloc, "headers": { diff --git a/app/routes/api/yt_dlp.py b/app/routes/api/yt_dlp.py index 1b56a0db..f0b8ccdd 100644 --- a/app/routes/api/yt_dlp.py +++ b/app/routes/api/yt_dlp.py @@ -150,9 +150,6 @@ async def get_info(request: Request, cache: Cache, config: Config) -> Response: } return web.json_response(body=json.dumps(data, indent=4, default=str), status=web.HTTPOk.status_code) - if ytdlp_proxy := config.get_ytdlp_args().get("proxy", None): - opts = opts.add({"proxy": ytdlp_proxy}) - logs: list = [] ytdlp_opts: dict = { @@ -203,13 +200,14 @@ async def get_info(request: Request, cache: Cache, config: Config) -> Response: "expires": time.time() + 300, } - is_archived = False - if (archive_file := ytdlp_opts.get("download_archive")) and ( - archive_id := get_archive_id(url=url).get("archive_id") - ): - is_archived: bool = len(archive_read(archive_file, [archive_id])) > 0 + data["is_archived"] = False - data["is_archived"] = is_archived + archive_file: str | None = ytdlp_opts.get("download_archive") + data["archive_file"] = archive_file if archive_file else None + + if archive_file and (archive_id := get_archive_id(url=url).get("archive_id")): + data["archive_id"] = archive_id + data["is_archived"] = len(archive_read(archive_file, [archive_id])) > 0 data = OrderedDict(sorted(data.items(), key=lambda item: len(str(item[1])))) diff --git a/pyproject.toml b/pyproject.toml index 1fc325da..fe12e5e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -209,4 +209,9 @@ testpaths = ["app/tests"] addopts = "-v --tb=short" [dependency-groups] -dev = ["pytest>=8.4.2", "pytest-asyncio>=1.1.0", "ruff>=0.13.0"] +dev = [ + "debugpy>=1.8.16", + "pytest>=8.4.2", + "pytest-asyncio>=1.1.0", + "ruff>=0.13.0", +] diff --git a/ui/app/components/ConditionForm.vue b/ui/app/components/ConditionForm.vue index b5e1b238..db50016d 100644 --- a/ui/app/components/ConditionForm.vue +++ b/ui/app/components/ConditionForm.vue @@ -83,7 +83,8 @@ - yt-dlp [--match-filters] logic. + yt-dlp --match-filters logic with OR, || + support. @@ -211,7 +212,7 @@ - + The url to test the filter against. @@ -226,9 +227,10 @@ - + - The yt-dlp [--match-filters] filter logic.
+ yt-dlp --match-filters logic with OR, || + support.
diff --git a/ui/app/components/History.vue b/ui/app/components/History.vue index 63b17e25..238bf265 100644 --- a/ui/app/components/History.vue +++ b/ui/app/components/History.vue @@ -175,7 +175,7 @@ -
+
- + yt-dlp Information - + Local Information diff --git a/ui/app/layouts/default.vue b/ui/app/layouts/default.vue index 798591d3..7c4b79bf 100644 --- a/ui/app/layouts/default.vue +++ b/ui/app/layouts/default.vue @@ -24,9 +24,8 @@
-
-
- -

- The following environment variables and features are deprecated and will be removed in - v0.10.x -

-
    -
  • - The following ENVs YTP_KEEP_ARCHIVE and YTP_SOCKET_TIMEOUT will be - removed. - Their behavior will be part of the default presets. To keep your current behavior - and avoid re-downloading, please add the following Command options for - yt-dlp to your presets: - --socket-timeout 30 --download-archive %(config_path)s/archive.log -
  • -
  • - The global yt-dlp config file /config/ytdlp.cli will be removed. Please migrate to - presets. -
  • -
  • The archive.manual.log feature has been removed.
  • -
-

- These changes help reduce confusion from multiple sources of truth. Going forward, presets - and the Command options for yt-dlp will be the single source of truth. -

-

- Notable changes in v0.10.x: -

-
    -
  • - The file browser feature is going to be enabled by default. and the associated ENV - YTP_BROWSER_ENABLED will be removed, YTP_BROWSER_CONTROL_ENABLED will - remain and - will default to false. -
  • -
  • - The Basic mode (which limited the interface to just the new download form) along it's - associated ENV YTP_BASIC_MODE is being removed. Everything except what is available - behind configurable flag will become part of the standard interface. -
  • -
-
-
-
- - import { useStorage } from '@vueuse/core' -import DeprecatedNotice from '~/components/DeprecatedNotice.vue' import type { item_request } from '~/types/item' import type { StoreItem } from '~/types/store' diff --git a/ui/app/pages/logs.vue b/ui/app/pages/logs.vue index a61c22d1..79107b52 100644 --- a/ui/app/pages/logs.vue +++ b/ui/app/pages/logs.vue @@ -155,13 +155,6 @@ watch(toggleFilter, () => { } }); -watch(() => config.app.basic_mode, async v => { - if (!config.isLoaded() || !v) { - return - } - await navigateTo('/') -}, { immediate: true }) - watch(() => config.app.file_logging, async v => { if (v) { return diff --git a/ui/app/pages/notifications.vue b/ui/app/pages/notifications.vue index 1bac5477..474c805c 100644 --- a/ui/app/pages/notifications.vue +++ b/ui/app/pages/notifications.vue @@ -221,7 +221,6 @@ import { useStorage } from '@vueuse/core' import type { notification, notificationImport } from '~/types/notification' const toast = useNotification() -const config = useConfigStore() const socket = useSocketStore() const box = useConfirm() const display_style = useStorage("tasks_display_style", "cards") @@ -244,13 +243,6 @@ const isLoading = ref(false) const initialLoad = ref(true) const addInProgress = ref(false) -watch(() => config.app.basic_mode, async v => { - if (!config.isLoaded() || !v) { - return - } - await navigateTo('/') -}, { immediate: true }) - watch(() => socket.isConnected, async () => { if (socket.isConnected && initialLoad.value) { await reloadContent(true) diff --git a/ui/app/pages/presets.vue b/ui/app/pages/presets.vue index e98f6dfd..65e487b6 100644 --- a/ui/app/pages/presets.vue +++ b/ui/app/pages/presets.vue @@ -39,8 +39,8 @@
- Custom presets. The presets are simply pre-defined yt-dlp settings - that you want to apply to given download. + Presets are pre-defined command options for yt-dlp that you want to apply to given + download.
@@ -192,16 +192,7 @@
    -
  • - When you export preset, it doesn't include Cookies field for security reasons. -
  • -
  • - If you have created a global config/ytdlp.cli file, it will be appended to your exported - preset - Command options for yt-dlp field for better - compatibility - and completeness. -
  • +
  • When you export preset, it doesn't include Cookies field for security reasons.
@@ -233,13 +224,6 @@ const remove_keys = ['raw', 'toggle_description'] const presetsNoDefault = computed(() => presets.value.filter((t) => !t.default)) -watch(() => config.app.basic_mode, async v => { - if (!config.isLoaded() || !v) { - return - } - await navigateTo('/') -}, { immediate: true }) - watch(() => socket.isConnected, async () => { if (socket.isConnected && initialLoad.value) { await reloadContent(true) @@ -391,11 +375,6 @@ const exportItem = (item: Preset) => { } } - if (config?.app?.ytdlp_cli) { - const val = `# exported from ytdlp.cli #\n${config.app.ytdlp_cli}\n# exported from ytdlp.cli #\n` - userData.cli = userData.cli ? val + '\n' + userData.cli : val - } - userData['_type'] = 'preset' userData['_version'] = '2.5' diff --git a/ui/app/pages/tasks.vue b/ui/app/pages/tasks.vue index 4ea09969..3533f8c9 100644 --- a/ui/app/pages/tasks.vue +++ b/ui/app/pages/tasks.vue @@ -444,13 +444,6 @@ watch(masterSelectAll, value => { } }) -watch(() => config.app.basic_mode, async v => { - if (!config.isLoaded() || !v) { - return - } - await navigateTo('/') -}, { immediate: true }) - watch(() => socket.isConnected, async () => { if (socket.isConnected && initialLoad.value) { socket.on('item_status', statusHandler) diff --git a/ui/app/stores/ConfigStore.ts b/ui/app/stores/ConfigStore.ts index 8b61e0af..c21c6bae 100644 --- a/ui/app/stores/ConfigStore.ts +++ b/ui/app/stores/ConfigStore.ts @@ -11,13 +11,10 @@ export const useConfigStore = defineStore('config', () => { output_template: '', ytdlp_version: '', max_workers: 1, - basic_mode: true, default_preset: 'default', instance_title: null, console_enabled: false, - browser_enabled: false, browser_control_enabled: false, - ytdlp_cli: '', file_logging: false, is_native: false, app_version: '', @@ -30,7 +27,7 @@ export const useConfigStore = defineStore('config', () => { presets: [ { 'name': 'default', - 'description': 'Default preset for downloads', + 'description': 'Default preset', 'folder': '', 'template': '', 'cookies': '', diff --git a/ui/app/types/config.d.ts b/ui/app/types/config.d.ts index d6f22f2c..5bf7beb6 100644 --- a/ui/app/types/config.d.ts +++ b/ui/app/types/config.d.ts @@ -14,20 +14,14 @@ type AppConfig = { ytdlp_version: string /** Maximum number of concurrent download workers */ max_workers: number - /** Indicates if the app is in basic mode, which may limit some features */ - basic_mode: boolean /** Default preset name, e.g. "default" */ default_preset: string /** Instance title for the app, null if not set */ instance_title: string | null /** Indicates if the console is enabled */ console_enabled: boolean - /** Indicates if the file browser is enabled */ - browser_enabled: boolean /** Indicates if the file browser control is enabled */ browser_control_enabled: boolean - /** Command options for yt-dlp */ - ytdlp_cli: string /** Indicates if file logging is enabled */ file_logging: boolean /** Indicates if the app is running in a native environment (e.g., Electron) */ diff --git a/uv.lock b/uv.lock index d7b3c96b..8b3f6b81 100644 --- a/uv.lock +++ b/uv.lock @@ -1513,6 +1513,7 @@ installer = [ [package.dev-dependencies] dev = [ + { name = "debugpy" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "ruff" }, @@ -1556,6 +1557,7 @@ provides-extras = ["installer"] [package.metadata.requires-dev] dev = [ + { name = "debugpy", specifier = ">=1.8.16" }, { name = "pytest", specifier = ">=8.4.2" }, { name = "pytest-asyncio", specifier = ">=1.1.0" }, { name = "ruff", specifier = ">=0.13.0" }, From 393cc6729c259f2bf65e17d8e7f28f009dc726d5 Mon Sep 17 00:00:00 2001 From: arabcoders Date: Fri, 12 Sep 2025 22:18:25 +0300 Subject: [PATCH 5/5] More tests --- app/library/Download.py | 37 +- app/library/YTDLPOpts.py | 2 +- app/tests/test_notifications.py | 1101 +++++++++++++++++++++++++++++++ app/tests/test_tasks.py | 864 ++++++++++++++++++++++++ app/tests/test_ytdlpopts.py | 508 ++++++++++++++ 5 files changed, 2493 insertions(+), 19 deletions(-) create mode 100644 app/tests/test_notifications.py create mode 100644 app/tests/test_tasks.py create mode 100644 app/tests/test_ytdlpopts.py diff --git a/app/library/Download.py b/app/library/Download.py index aa9557ac..8acbdcb8 100644 --- a/app/library/Download.py +++ b/app/library/Download.py @@ -10,6 +10,7 @@ from copy import deepcopy from datetime import UTC, datetime from email.utils import formatdate from pathlib import Path +from typing import Any import yt_dlp.utils @@ -128,10 +129,10 @@ class Download: "Time when the download started." self.queue_time: datetime = datetime.now(tz=UTC) "Time when the download was queued." - self.logs = logs if logs else [] + self.logs: list[str] = logs if logs else [] "Logs from yt-dlp." - def _progress_hook(self, data: dict): + def _progress_hook(self, data: dict) -> None: if self.debug: d_copy: dict = deepcopy(data) for k in ["formats", "thumbnails", "description", "tags", "_format_sort_fields"]: @@ -147,7 +148,7 @@ class Download: } ) - def _postprocessor_hook(self, data: dict): + def _postprocessor_hook(self, data: dict) -> None: if self.debug: d_copy: dict = deepcopy(data) for k in ["formats", "thumbnails", "description", "tags", "_format_sort_fields"]: @@ -159,7 +160,7 @@ class Download: if "__finaldir" in data.get("info_dict", {}) and "filepath" in data.get("info_dict", {}): filename = str(Path(data["info_dict"]["__finaldir"]) / Path(data["info_dict"]["filepath"]).name) else: - filename = data.get("info_dict", {}).get("filepath", data.get("filename")) + filename: str | None = data.get("info_dict", {}).get("filepath", data.get("filename")) self.logger.debug(f"Final filename: '{filename}'.") self.status_queue.put({"id": self.id, "action": "moved", "status": "finished", "final_name": filename}) @@ -168,13 +169,13 @@ class Download: dataDict = {k: v for k, v in data.items() if k in self._ytdlp_fields} self.status_queue.put({"id": self.id, "action": "postprocessing", **dataDict, "status": "postprocessing"}) - def post_hooks(self, filename: str | None = None): + def post_hooks(self, filename: str | None = None) -> None: if not filename: return self.status_queue.put({"id": self.id, "filename": filename}) - def _download(self): + def _download(self) -> None: if not self._notify: self._notify = EventBus.get_instance() @@ -227,11 +228,11 @@ class Download: load_cookies(cookie_file) except Exception as e: - err_msg = f"Failed to create cookie file for '{self.info.id}: {self.info.title}'. '{e!s}'." + err_msg: str = f"Failed to create cookie file for '{self.info.id}: {self.info.title}'. '{e!s}'." self.logger.error(err_msg) raise ValueError(err_msg) from e - # Safe-guard incase downloading take too long and the info expires. + # Safe-guard in-case downloading take too long and the info expires. if self.info_dict and isinstance(self.info_dict, dict) and self.download_info_expires > 0: _ts: int | None = self.info_dict.get("epoch", self.info_dict.get("timestamp", None)) _ts = datetime.fromtimestamp(_ts, tz=UTC) if _ts else None @@ -242,14 +243,14 @@ class Download: if not self.info_dict or not isinstance(self.info_dict, dict): self.logger.info(f"Extracting info for '{self.info.url}'.") self.logs = [] - ie_params = params.copy() + ie_params: dict = params.copy() ie_params["callback"] = { "func": lambda _, msg: self.logs.append(msg), "level": logging.WARNING, "name": "callback-logger", } - info = extract_info( + info: dict = extract_info( config=params, url=self.info.url, debug=self.debug, @@ -337,8 +338,8 @@ class Download: f'Task {self.info.name()} preset="{self.info.preset}" cookies="{bool(params.get("cookiefile"))}" completed.' ) - async def start(self): - self.status_queue = Config.get_manager().Queue() + async def start(self) -> None: + self.status_queue: multiprocessing.Queue[Any] = Config.get_manager().Queue() # Create temp dir for each download. if not self.temp_disabled: @@ -479,7 +480,7 @@ class Download: return False - def delete_temp(self, by_pass: bool = False): + def delete_temp(self, by_pass: bool = False) -> None: if self.temp_disabled or self.temp_keep is True or not self.temp_path: return @@ -508,7 +509,7 @@ class Download: else: self.logger.info(f"Temp folder '{self.temp_path}' deletion is {'success' if status else 'failed'}.") - async def _process_status_update(self, status): + async def _process_status_update(self, status) -> None: if status.get("id") != self.id or len(status) < 2: self.logger.warning(f"Received invalid status update. {status}") return @@ -587,7 +588,7 @@ class Download: if not self.final_update or fl: self._notify.emit(Events.ITEM_UPDATED, data=self.info) - async def progress_update(self): + async def progress_update(self) -> None: """ Update status of download task and notify the client. """ @@ -638,11 +639,11 @@ class Download: return False - def __getstate__(self): - state = self.__dict__.copy() + def __getstate__(self) -> dict[str, Any]: + state: dict[str, Any] = self.__dict__.copy() # Exclude (unpickleable) keys during pickling, this issue arise mostly on Windows. - excluded_keys = ("_notify",) + excluded_keys: tuple[str] = ("_notify",) for key in excluded_keys: if key in state: state[key] = None diff --git a/app/library/YTDLPOpts.py b/app/library/YTDLPOpts.py index 4996cbe0..727c5c78 100644 --- a/app/library/YTDLPOpts.py +++ b/app/library/YTDLPOpts.py @@ -44,7 +44,7 @@ class YTDLPOpts: YTDLPOpts: The instance of the class """ - if not args or len(args) < 2 or not isinstance(args, str): + if not args or not isinstance(args, str) or len(args) < 2: return self try: diff --git a/app/tests/test_notifications.py b/app/tests/test_notifications.py new file mode 100644 index 00000000..b67e4103 --- /dev/null +++ b/app/tests/test_notifications.py @@ -0,0 +1,1101 @@ +import json +import tempfile +import uuid +from pathlib import Path +from unittest.mock import AsyncMock, Mock, patch + +import httpx +import pytest +from aiohttp import web + +from app.library.BackgroundWorker import BackgroundWorker +from app.library.encoder import Encoder +from app.library.Events import Event +from app.library.ItemDTO import ItemDTO +from app.library.Notifications import ( + Notification, + NotificationEvents, + Target, + TargetRequest, + TargetRequestHeader, +) + + +class TestTargetRequestHeader: + """Test the TargetRequestHeader dataclass.""" + + def test_target_request_header_creation(self): + """Test creating a TargetRequestHeader.""" + header = TargetRequestHeader(key="Authorization", value="Bearer token123") + + assert header.key == "Authorization" + assert header.value == "Bearer token123" + + def test_target_request_header_serialize(self): + """Test serializing a TargetRequestHeader.""" + header = TargetRequestHeader(key="Content-Type", value="application/json") + serialized = header.serialize() + + expected = {"key": "Content-Type", "value": "application/json"} + assert serialized == expected + + def test_target_request_header_json(self): + """Test JSON serialization of TargetRequestHeader.""" + header = TargetRequestHeader(key="X-API-Key", value="secret123") + json_str = header.json() + + # Should be valid JSON + parsed = json.loads(json_str) + assert parsed["key"] == "X-API-Key" + assert parsed["value"] == "secret123" + + def test_target_request_header_get(self): + """Test get method of TargetRequestHeader.""" + header = TargetRequestHeader(key="Authorization", value="Bearer token") + + assert header.get("key") == "Authorization" + assert header.get("value") == "Bearer token" + assert header.get("nonexistent", "default") == "default" + + +class TestTargetRequest: + """Test the TargetRequest dataclass.""" + + def test_target_request_creation_defaults(self): + """Test creating a TargetRequest with defaults.""" + request = TargetRequest( + type="json", + method="POST", + url="https://example.com/webhook" + ) + + assert request.type == "json" + assert request.method == "POST" + assert request.url == "https://example.com/webhook" + assert request.headers == [] + assert request.data_key == "data" + + def test_target_request_creation_with_headers(self): + """Test creating a TargetRequest with headers.""" + headers = [ + TargetRequestHeader(key="Authorization", value="Bearer token"), + TargetRequestHeader(key="Content-Type", value="application/json") + ] + request = TargetRequest( + type="json", + method="POST", + url="https://example.com/webhook", + headers=headers, + data_key="payload" + ) + + assert len(request.headers) == 2 + assert request.headers[0].key == "Authorization" + assert request.data_key == "payload" + + def test_target_request_serialize(self): + """Test serializing a TargetRequest.""" + headers = [TargetRequestHeader(key="X-Token", value="abc123")] + request = TargetRequest( + type="json", + method="PUT", + url="https://api.example.com/notify", + headers=headers, + data_key="content" + ) + + serialized = request.serialize() + expected = { + "type": "json", + "method": "PUT", + "url": "https://api.example.com/notify", + "data_key": "content", + "headers": [{"key": "X-Token", "value": "abc123"}] + } + assert serialized == expected + + def test_target_request_json(self): + """Test JSON serialization of TargetRequest.""" + request = TargetRequest( + type="form", + method="POST", + url="https://webhook.site/test" + ) + json_str = request.json() + + parsed = json.loads(json_str) + assert parsed["type"] == "form" + assert parsed["method"] == "POST" + assert parsed["url"] == "https://webhook.site/test" + + def test_target_request_get(self): + """Test get method of TargetRequest.""" + request = TargetRequest( + type="json", + method="POST", + url="https://example.com/webhook" + ) + + assert request.get("type") == "json" + assert request.get("method") == "POST" + assert request.get("nonexistent", "default") == "default" + + +class TestTarget: + """Test the Target dataclass.""" + + def test_target_creation_minimal(self): + """Test creating a Target with minimal required fields.""" + request = TargetRequest( + type="json", + method="POST", + url="https://example.com/webhook" + ) + target = Target( + id=str(uuid.uuid4()), + name="Test Webhook", + request=request + ) + + assert target.name == "Test Webhook" + assert target.on == [] + assert target.presets == [] + assert target.request == request + + def test_target_creation_full(self): + """Test creating a Target with all fields.""" + target_id = str(uuid.uuid4()) + request = TargetRequest( + type="json", + method="POST", + url="https://example.com/webhook" + ) + target = Target( + id=target_id, + name="Full Test Webhook", + on=["item_completed", "item_failed"], + presets=["default", "audio_only"], + request=request + ) + + assert target.id == target_id + assert target.name == "Full Test Webhook" + assert target.on == ["item_completed", "item_failed"] + assert target.presets == ["default", "audio_only"] + + def test_target_serialize(self): + """Test serializing a Target.""" + target_id = str(uuid.uuid4()) + request = TargetRequest( + type="json", + method="POST", + url="https://example.com/webhook" + ) + target = Target( + id=target_id, + name="Test Target", + on=["item_completed"], + presets=["default"], + request=request + ) + + serialized = target.serialize() + assert serialized["id"] == target_id + assert serialized["name"] == "Test Target" + assert serialized["on"] == ["item_completed"] + assert serialized["presets"] == ["default"] + assert isinstance(serialized["request"], dict) + + def test_target_json(self): + """Test JSON serialization of Target.""" + request = TargetRequest( + type="json", + method="POST", + url="https://example.com/webhook" + ) + target = Target( + id=str(uuid.uuid4()), + name="JSON Test", + request=request + ) + + json_str = target.json() + parsed = json.loads(json_str) + assert parsed["name"] == "JSON Test" + + def test_target_get(self): + """Test get method of Target.""" + request = TargetRequest( + type="json", + method="POST", + url="https://example.com/webhook" + ) + target = Target( + id=str(uuid.uuid4()), + name="Get Test", + request=request + ) + + assert target.get("name") == "Get Test" + assert target.get("nonexistent", "default") == "default" + + +class TestNotificationEvents: + """Test the NotificationEvents class.""" + + def test_notification_events_constants(self): + """Test that NotificationEvents has expected constants.""" + assert hasattr(NotificationEvents, "TEST") + assert hasattr(NotificationEvents, "ITEM_ADDED") + assert hasattr(NotificationEvents, "ITEM_COMPLETED") + assert hasattr(NotificationEvents, "ITEM_CANCELLED") + assert hasattr(NotificationEvents, "ITEM_DELETED") + assert hasattr(NotificationEvents, "LOG_INFO") + assert hasattr(NotificationEvents, "LOG_ERROR") + + def test_get_events(self): + """Test get_events static method.""" + events = NotificationEvents.get_events() + + assert isinstance(events, dict) + assert "TEST" in events + assert "ITEM_ADDED" in events + assert "ITEM_COMPLETED" in events + + def test_events_function(self): + """Test events function.""" + events_list = NotificationEvents.events() + + assert isinstance(events_list, list) + assert len(events_list) > 0 + + def test_is_valid(self): + """Test is_valid static method.""" + # Valid events + assert NotificationEvents.is_valid("test") + assert NotificationEvents.is_valid("item_added") + assert NotificationEvents.is_valid("item_completed") + + # Invalid events + assert not NotificationEvents.is_valid("invalid_event") + assert not NotificationEvents.is_valid("") + assert not NotificationEvents.is_valid(None) + + +class TestNotification: + """Test the Notification singleton class.""" + + def setup_method(self): + """Set up test environment before each test.""" + # Reset singleton instance to ensure clean state + Notification._reset_singleton() + + def teardown_method(self): + """Clean up after each test.""" + # Reset singleton instance to prevent test pollution + Notification._reset_singleton() + + @patch("app.library.Notifications.Config") + @patch("app.library.Notifications.BackgroundWorker") + def test_notification_singleton(self, mock_background_worker, mock_config): + """Test that Notification follows singleton pattern.""" + mock_config.get_instance.return_value = Mock( + debug=False, + config_path="/tmp", + app_version="1.0.0" + ) + mock_background_worker.get_instance.return_value = Mock() + + instance1 = Notification.get_instance() + instance2 = Notification.get_instance() + + assert instance1 is instance2 + assert isinstance(instance1, Notification) + + @patch("app.library.Notifications.Config") + @patch("app.library.Notifications.BackgroundWorker") + def test_notification_init_default_params(self, mock_background_worker, mock_config): + """Test Notification initialization with default parameters.""" + mock_config_instance = Mock( + debug=False, + config_path="/tmp/test", + app_version="1.0.0" + ) + mock_config.get_instance.return_value = mock_config_instance + mock_background_worker.get_instance.return_value = Mock() + + with patch("pathlib.Path.exists", return_value=False): + notification = Notification.get_instance() + + assert notification._debug is False + assert notification._version == "1.0.0" + assert isinstance(notification._targets, list) + assert len(notification._targets) == 0 + + @patch("app.library.Notifications.Config") + @patch("app.library.Notifications.BackgroundWorker") + def test_notification_init_custom_params(self, mock_background_worker, mock_config): + """Test Notification initialization with custom parameters.""" + _ = mock_background_worker, mock_config # Suppress unused variable warnings + mock_config_instance = Mock( + debug=True, + config_path="/custom/path", + app_version="2.0.0" + ) + mock_client = Mock(spec=httpx.AsyncClient) + mock_encoder = Mock(spec=Encoder) + mock_worker = Mock(spec=BackgroundWorker) + + with tempfile.NamedTemporaryFile(delete=False) as temp_file: + temp_path = temp_file.name + + notification = Notification.get_instance( + file=temp_path, + client=mock_client, + encoder=mock_encoder, + config=mock_config_instance, + background_worker=mock_worker + ) + + assert notification._debug is True + assert notification._version == "2.0.0" + assert notification._client == mock_client + assert notification._encoder == mock_encoder + assert notification._offload == mock_worker + + def test_get_targets_empty(self): + """Test get_targets when no targets are loaded.""" + with patch("app.library.Notifications.Config") as mock_config, \ + patch("app.library.Notifications.BackgroundWorker") as mock_worker: + + mock_config.get_instance.return_value = Mock( + debug=False, config_path="/tmp", app_version="1.0.0" + ) + mock_worker.get_instance.return_value = Mock() + + notification = Notification.get_instance() + targets = notification.get_targets() + + assert isinstance(targets, list) + assert len(targets) == 0 + + def test_clear_targets(self): + """Test clearing notification targets.""" + with patch("app.library.Notifications.Config") as mock_config, \ + patch("app.library.Notifications.BackgroundWorker") as mock_worker: + + mock_config.get_instance.return_value = Mock( + debug=False, config_path="/tmp", app_version="1.0.0" + ) + mock_worker.get_instance.return_value = Mock() + + notification = Notification.get_instance() + + # Add a dummy target + notification._targets = ["dummy_target"] + assert len(notification._targets) == 1 + + # Clear targets + result = notification.clear() + + assert result == notification # Should return self + assert len(notification._targets) == 0 + + @patch("app.library.Notifications.Config") + @patch("app.library.Notifications.BackgroundWorker") + def test_save_targets(self, mock_worker, mock_config): + """Test saving targets to file.""" + mock_config.get_instance.return_value = Mock( + debug=False, config_path="/tmp", app_version="1.0.0" + ) + mock_worker.get_instance.return_value = Mock() + + with tempfile.NamedTemporaryFile(mode="w", delete=False) as temp_file: + temp_path = temp_file.name + + # Create a test target + request = TargetRequest( + type="json", + method="POST", + url="https://example.com/webhook" + ) + target = Target( + id=str(uuid.uuid4()), + name="Test Target", + request=request + ) + + notification = Notification.get_instance(file=temp_path) + result = notification.save([target]) + + assert result == notification # Should return self + + # Verify file was written + with open(temp_path) as f: + saved_data = json.load(f) + + assert isinstance(saved_data, list) + assert len(saved_data) == 1 + assert saved_data[0]["name"] == "Test Target" + + # Clean up + Path(temp_path).unlink() + + @patch("app.library.Notifications.Config") + @patch("app.library.Notifications.BackgroundWorker") + def test_load_targets_empty_file(self, mock_worker, mock_config): + """Test loading targets from empty file.""" + mock_config.get_instance.return_value = Mock( + debug=False, config_path="/tmp", app_version="1.0.0" + ) + mock_worker.get_instance.return_value = Mock() + + with tempfile.NamedTemporaryFile(mode="w", delete=False) as temp_file: + temp_file.write("") # Empty file + temp_path = temp_file.name + + notification = Notification.get_instance(file=temp_path) + result = notification.load() + + assert result == notification # Should return self + assert len(notification._targets) == 0 + + # Clean up + Path(temp_path).unlink() + + @patch("app.library.Notifications.Config") + @patch("app.library.Notifications.BackgroundWorker") + @patch("app.library.Notifications.Presets") + def test_load_targets_valid_file(self, mock_presets, mock_worker, mock_config): + """Test loading targets from valid file.""" + mock_config.get_instance.return_value = Mock( + debug=False, config_path="/tmp", app_version="1.0.0" + ) + mock_worker.get_instance.return_value = Mock() + + # Mock preset with name attribute + mock_preset = Mock() + mock_preset.name = "default" + mock_presets.get_instance.return_value.get_all.return_value = [mock_preset] + + target_data = [{ + "id": str(uuid.uuid4()), + "name": "Test Webhook", + "on": ["item_completed"], + "presets": ["default"], + "request": { + "type": "json", + "method": "POST", + "url": "https://example.com/webhook", + "data_key": "data", + "headers": [] + } + }] + + with tempfile.NamedTemporaryFile(mode="w", delete=False) as temp_file: + json.dump(target_data, temp_file) + temp_path = temp_file.name + + notification = Notification.get_instance(file=temp_path) + result = notification.load() + + assert result == notification # Should return self + assert len(notification._targets) == 1 + assert notification._targets[0].name == "Test Webhook" + + # Clean up + Path(temp_path).unlink() + + def test_make_target(self): + """Test make_target method.""" + with patch("app.library.Notifications.Config") as mock_config, \ + patch("app.library.Notifications.BackgroundWorker") as mock_worker: + + mock_config.get_instance.return_value = Mock( + debug=False, config_path="/tmp", app_version="1.0.0" + ) + mock_worker.get_instance.return_value = Mock() + + notification = Notification.get_instance() + + target_dict = { + "id": str(uuid.uuid4()), + "name": "Test Target", + "on": ["item_completed"], + "presets": ["default"], + "request": { + "type": "json", + "method": "POST", + "url": "https://example.com/webhook", + "data_key": "payload", + "headers": [{"key": "Authorization", "value": "Bearer token"}] + } + } + + target = notification.make_target(target_dict) + + assert isinstance(target, Target) + assert target.name == "Test Target" + assert target.on == ["item_completed"] + assert target.presets == ["default"] + assert target.request.url == "https://example.com/webhook" + assert target.request.data_key == "payload" + assert len(target.request.headers) == 1 + assert target.request.headers[0].key == "Authorization" + + def test_validate_target_valid(self): + """Test validate method with valid target.""" + target_dict = { + "id": str(uuid.uuid4()), + "name": "Valid Target", + "request": { + "url": "https://example.com/webhook" + } + } + + with patch("app.library.Notifications.NotificationEvents.get_events") as mock_events, \ + patch("app.library.Notifications.Presets") as mock_presets: + + mock_events.return_value.values.return_value = ["item_completed"] + mock_presets.get_instance.return_value.get_all.return_value = [] + + result = Notification.validate(target_dict) + assert result is True + + def test_validate_target_missing_id(self): + """Test validate method with missing ID.""" + target_dict = { + "name": "Missing ID Target", + "request": { + "url": "https://example.com/webhook" + } + } + + with pytest.raises(ValueError, match=r"Invalid notification target\. No ID found\."): + Notification.validate(target_dict) + + def test_validate_target_invalid_id(self): + """Test validate method with invalid UUID.""" + target_dict = { + "id": "invalid-uuid", + "name": "Invalid ID Target", + "request": { + "url": "https://example.com/webhook" + } + } + + with pytest.raises(ValueError, match=r"Invalid notification target\. No ID found\."): + Notification.validate(target_dict) + + def test_validate_target_missing_name(self): + """Test validate method with missing name.""" + target_dict = { + "id": str(uuid.uuid4()), + "request": { + "url": "https://example.com/webhook" + } + } + + with pytest.raises(ValueError, match=r"Invalid notification target\. No name found\."): + Notification.validate(target_dict) + + def test_validate_target_missing_request(self): + """Test validate method with missing request.""" + target_dict = { + "id": str(uuid.uuid4()), + "name": "Missing Request Target" + } + + with pytest.raises(ValueError, match=r"Invalid notification target\. No request details found\."): + Notification.validate(target_dict) + + def test_validate_target_missing_url(self): + """Test validate method with missing URL.""" + target_dict = { + "id": str(uuid.uuid4()), + "name": "Missing URL Target", + "request": {} + } + + with pytest.raises(ValueError, match=r"Invalid notification target\. No URL found\."): + Notification.validate(target_dict) + + def test_validate_target_invalid_method(self): + """Test validate method with invalid HTTP method.""" + target_dict = { + "id": str(uuid.uuid4()), + "name": "Invalid Method Target", + "request": { + "url": "https://example.com/webhook", + "method": "GET" # Only POST and PUT are allowed + } + } + + with pytest.raises(ValueError, match=r"Invalid notification target\. Invalid method found\."): + Notification.validate(target_dict) + + def test_validate_target_invalid_type(self): + """Test validate method with invalid request type.""" + target_dict = { + "id": str(uuid.uuid4()), + "name": "Invalid Type Target", + "request": { + "url": "https://example.com/webhook", + "type": "xml" # Only json and form are allowed + } + } + + with pytest.raises(ValueError, match=r"Invalid notification target\. Invalid type found\."): + Notification.validate(target_dict) + + @patch("app.library.Notifications.Config") + @patch("app.library.Notifications.BackgroundWorker") + @patch("app.library.Notifications.EventBus") + def test_attach(self, mock_eventbus, mock_worker, mock_config): + """Test attach method.""" + mock_config.get_instance.return_value = Mock( + debug=False, config_path="/tmp", app_version="1.0.0" + ) + mock_worker.get_instance.return_value = Mock() + mock_eventbus_instance = Mock() + mock_eventbus.get_instance.return_value = mock_eventbus_instance + + notification = Notification.get_instance() + app = Mock(spec=web.Application) + + with patch.object(notification, "load") as mock_load: + notification.attach(app) + + mock_load.assert_called_once() + mock_eventbus_instance.subscribe.assert_called_once() + + @pytest.mark.asyncio + @patch("app.library.Notifications.Config") + @patch("app.library.Notifications.BackgroundWorker") + async def test_send_no_targets(self, mock_worker, mock_config): + """Test send method with no targets.""" + mock_config.get_instance.return_value = Mock( + debug=False, config_path="/tmp", app_version="1.0.0" + ) + mock_worker.get_instance.return_value = Mock() + + notification = Notification.get_instance() + event = Event( + event="test", + data={"test": "data"} + ) + + result = await notification.send(event) + assert result == [] + + @pytest.mark.asyncio + @patch("app.library.Notifications.Config") + @patch("app.library.Notifications.BackgroundWorker") + async def test_send_invalid_event_data(self, mock_worker, mock_config): + """Test send method with invalid event data.""" + mock_config.get_instance.return_value = Mock( + debug=False, config_path="/tmp", app_version="1.0.0" + ) + mock_worker.get_instance.return_value = Mock() + + notification = Notification.get_instance() + # Add a target + request = TargetRequest( + type="json", + method="POST", + url="https://example.com/webhook" + ) + target = Target( + id=str(uuid.uuid4()), + name="Test Target", + request=request + ) + notification._targets = [target] + + event = Event( + event="test", + data="invalid_string_data" # Should be ItemDTO or dict + ) + + result = await notification.send(event) + assert result == [] + + @pytest.mark.asyncio + @patch("app.library.Notifications.Config") + @patch("app.library.Notifications.BackgroundWorker") + async def test_send_with_http_target(self, mock_worker, mock_config): + """Test send method with HTTP target.""" + mock_config.get_instance.return_value = Mock( + debug=False, config_path="/tmp", app_version="1.0.0" + ) + mock_worker.get_instance.return_value = Mock() + + # Mock HTTP client + mock_response = Mock() + mock_response.status_code = 200 + mock_response.text = "OK" + mock_client = AsyncMock() + mock_client.request = AsyncMock(return_value=mock_response) + + notification = Notification.get_instance(client=mock_client) + + # Add HTTP target + request = TargetRequest( + type="json", + method="POST", + url="https://example.com/webhook" + ) + target = Target( + id=str(uuid.uuid4()), + name="Test HTTP Target", + request=request + ) + notification._targets = [target] + + # Create test event + item_dto = ItemDTO( + id="test_id", + url="https://youtube.com/watch?v=test", + title="Test Video", + folder="/downloads" + ) + event = Event( + event="item_completed", + data=item_dto + ) + + result = await notification.send(event) + + assert len(result) == 1 + assert result[0]["status"] == 200 + assert result[0]["text"] == "OK" + mock_client.request.assert_called_once() + + @pytest.mark.asyncio + @patch("app.library.Notifications.Config") + @patch("app.library.Notifications.BackgroundWorker") + async def test_send_with_apprise_target(self, mock_worker, mock_config): + """Test send method with Apprise target.""" + mock_config.get_instance.return_value = Mock( + debug=False, config_path="/tmp", app_version="1.0.0", + apprise_config="/tmp/apprise.conf" + ) + mock_worker.get_instance.return_value = Mock() + + notification = Notification.get_instance() + + # Add Apprise target (non-HTTP URL) + request = TargetRequest( + type="json", + method="POST", + url="discord://webhook_id/webhook_token" + ) + target = Target( + id=str(uuid.uuid4()), + name="Test Discord Target", + request=request + ) + notification._targets = [target] + + # Create test event + event = Event( + event="item_completed", + data={"test": "data"} + ) + + with patch("app.library.Notifications.Path") as mock_path, \ + patch("builtins.__import__") as mock_import: + + # Mock apprise config file doesn't exist + mock_path.return_value.exists.return_value = False + + # Mock apprise import + mock_apprise = Mock() + mock_notify = Mock() + mock_apprise.Apprise.return_value = mock_notify + mock_import.return_value = mock_apprise + + result = await notification.send(event) + + # Should return empty dict from _apprise method + assert len(result) == 1 + assert result[0] == {} + + def test_check_preset_no_presets(self): + """Test _check_preset method with target having no preset filters.""" + with patch("app.library.Notifications.Config") as mock_config, \ + patch("app.library.Notifications.BackgroundWorker") as mock_worker: + + mock_config.get_instance.return_value = Mock( + debug=False, config_path="/tmp", app_version="1.0.0" + ) + mock_worker.get_instance.return_value = Mock() + + notification = Notification.get_instance() + + request = TargetRequest( + type="json", + method="POST", + url="https://example.com/webhook" + ) + target = Target( + id=str(uuid.uuid4()), + name="No Preset Filter", + presets=[], # No preset filter + request=request + ) + + event = Event( + event="item_completed", + data={"preset": "default"} + ) + + result = notification._check_preset(target, event) + assert result is True # Should pass when no preset filter + + def test_check_preset_with_matching_preset(self): + """Test _check_preset method with matching preset.""" + with patch("app.library.Notifications.Config") as mock_config, \ + patch("app.library.Notifications.BackgroundWorker") as mock_worker: + + mock_config.get_instance.return_value = Mock( + debug=False, config_path="/tmp", app_version="1.0.0" + ) + mock_worker.get_instance.return_value = Mock() + + notification = Notification.get_instance() + + request = TargetRequest( + type="json", + method="POST", + url="https://example.com/webhook" + ) + target = Target( + id=str(uuid.uuid4()), + name="Preset Filter", + presets=["default", "audio_only"], + request=request + ) + + # Test with ItemDTO + item_dto = ItemDTO( + id="test_id", + url="https://youtube.com/test", + title="Test Video", + folder="/downloads", + preset="default" + ) + event = Event( + event="item_completed", + data=item_dto + ) + + result = notification._check_preset(target, event) + assert result is True + + def test_check_preset_with_non_matching_preset(self): + """Test _check_preset method with non-matching preset.""" + with patch("app.library.Notifications.Config") as mock_config, \ + patch("app.library.Notifications.BackgroundWorker") as mock_worker: + + mock_config.get_instance.return_value = Mock( + debug=False, config_path="/tmp", app_version="1.0.0" + ) + mock_worker.get_instance.return_value = Mock() + + notification = Notification.get_instance() + + request = TargetRequest( + type="json", + method="POST", + url="https://example.com/webhook" + ) + target = Target( + id=str(uuid.uuid4()), + name="Preset Filter", + presets=["audio_only"], + request=request + ) + + event = Event( + event="item_completed", + data={"preset": "video_only"} # Different preset + ) + + result = notification._check_preset(target, event) + assert result is False + + def test_emit_invalid_event(self): + """Test emit method with invalid event.""" + with patch("app.library.Notifications.Config") as mock_config, \ + patch("app.library.Notifications.BackgroundWorker") as mock_worker: + + mock_config.get_instance.return_value = Mock( + debug=False, config_path="/tmp", app_version="1.0.0" + ) + mock_worker_instance = Mock() + mock_worker.get_instance.return_value = mock_worker_instance + + notification = Notification.get_instance() + + # Add a target + request = TargetRequest( + type="json", + method="POST", + url="https://example.com/webhook" + ) + target = Target( + id=str(uuid.uuid4()), + name="Test Target", + request=request + ) + notification._targets = [target] + + # Create invalid event + event = Event( + event="invalid_event", + data={"test": "data"} + ) + + with patch("app.library.Notifications.NotificationEvents.is_valid", return_value=False): + result = notification.emit(event, None) + + # Should return None and not submit to background worker + assert result is None + mock_worker_instance.submit.assert_not_called() + + def test_emit_valid_event(self): + """Test emit method with valid event.""" + with patch("app.library.Notifications.Config") as mock_config, \ + patch("app.library.Notifications.BackgroundWorker") as mock_worker: + + mock_config.get_instance.return_value = Mock( + debug=False, config_path="/tmp", app_version="1.0.0" + ) + mock_worker_instance = Mock() + mock_worker.get_instance.return_value = mock_worker_instance + + notification = Notification.get_instance() + + # Add a target + request = TargetRequest( + type="json", + method="POST", + url="https://example.com/webhook" + ) + target = Target( + id=str(uuid.uuid4()), + name="Test Target", + request=request + ) + notification._targets = [target] + + # Create valid event + event = Event( + event="item_completed", + data={"test": "data"} + ) + + with patch("app.library.Notifications.NotificationEvents.is_valid", return_value=True): + result = notification.emit(event, None) + + # Should return None but submit to background worker + assert result is None + mock_worker_instance.submit.assert_called_once_with(notification.send, event) + + def test_deep_unpack_simple_dict(self): + """Test _deep_unpack method with simple dictionary.""" + with patch("app.library.Notifications.Config") as mock_config, \ + patch("app.library.Notifications.BackgroundWorker") as mock_worker: + + mock_config.get_instance.return_value = Mock( + debug=False, config_path="/tmp", app_version="1.0.0" + ) + mock_worker.get_instance.return_value = Mock() + + notification = Notification.get_instance() + + data = {"key1": "value1", "key2": 123} + result = notification._deep_unpack(data) + + assert result == {"key1": "value1", "key2": 123} + + def test_deep_unpack_nested_dict(self): + """Test _deep_unpack method with nested dictionary.""" + with patch("app.library.Notifications.Config") as mock_config, \ + patch("app.library.Notifications.BackgroundWorker") as mock_worker: + + mock_config.get_instance.return_value = Mock( + debug=False, config_path="/tmp", app_version="1.0.0" + ) + mock_worker.get_instance.return_value = Mock() + + notification = Notification.get_instance() + + data = { + "level1": { + "level2": { + "value": "nested" + } + } + } + result = notification._deep_unpack(data) + + assert result["level1"]["level2"]["value"] == "nested" + + def test_deep_unpack_with_datetime(self): + """Test _deep_unpack method with datetime objects.""" + from datetime import UTC, datetime + + with patch("app.library.Notifications.Config") as mock_config, \ + patch("app.library.Notifications.BackgroundWorker") as mock_worker: + + mock_config.get_instance.return_value = Mock( + debug=False, config_path="/tmp", app_version="1.0.0" + ) + mock_worker.get_instance.return_value = Mock() + + notification = Notification.get_instance() + + test_datetime = datetime.now(tz=UTC) + data = {"timestamp": test_datetime} + result = notification._deep_unpack(data) + + assert result["timestamp"] == test_datetime.isoformat() + + def test_deep_unpack_with_serializable_object(self): + """Test _deep_unpack method with object having serialize method.""" + with patch("app.library.Notifications.Config") as mock_config, \ + patch("app.library.Notifications.BackgroundWorker") as mock_worker: + + mock_config.get_instance.return_value = Mock( + debug=False, config_path="/tmp", app_version="1.0.0" + ) + mock_worker.get_instance.return_value = Mock() + + notification = Notification.get_instance() + + # Mock object with serialize method + mock_obj = Mock() + mock_obj.serialize.return_value = {"serialized": "data"} + + data = {"object": mock_obj} + result = notification._deep_unpack(data) + + assert result["object"] == {"serialized": "data"} + + @pytest.mark.asyncio + async def test_noop(self): + """Test noop method.""" + with patch("app.library.Notifications.Config") as mock_config, \ + patch("app.library.Notifications.BackgroundWorker") as mock_worker: + + mock_config.get_instance.return_value = Mock( + debug=False, config_path="/tmp", app_version="1.0.0" + ) + mock_worker.get_instance.return_value = Mock() + + notification = Notification.get_instance() + result = await notification.noop() + + assert result is None diff --git a/app/tests/test_tasks.py b/app/tests/test_tasks.py new file mode 100644 index 00000000..71ceed25 --- /dev/null +++ b/app/tests/test_tasks.py @@ -0,0 +1,864 @@ +import tempfile +from pathlib import Path +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from app.library.Tasks import Task, Tasks + + +class TestTask: + """Test the Task dataclass.""" + + def test_task_creation_with_required_fields(self): + """Test creating a task with only required fields.""" + task = Task(id="test_id", name="test_task", url="https://example.com/video") + + assert task.id == "test_id" + assert task.name == "test_task" + assert task.url == "https://example.com/video" + assert task.folder == "" + assert task.preset == "" + assert task.timer == "" + assert task.template == "" + assert task.cli == "" + assert task.auto_start is True + assert task.handler_enabled is True + + def test_task_creation_with_all_fields(self): + """Test creating a task with all fields specified.""" + task = Task( + id="test_id", + name="test_task", + url="https://example.com/video", + folder="test_folder", + preset="test_preset", + timer="0 */6 * * *", + template="%(title)s.%(ext)s", + cli="--extract-flat", + auto_start=False, + handler_enabled=False, + ) + + assert task.id == "test_id" + assert task.name == "test_task" + assert task.url == "https://example.com/video" + assert task.folder == "test_folder" + assert task.preset == "test_preset" + assert task.timer == "0 */6 * * *" + assert task.template == "%(title)s.%(ext)s" + assert task.cli == "--extract-flat" + assert task.auto_start is False + assert task.handler_enabled is False + + def test_task_serialize(self): + """Test task serialization to dictionary.""" + task = Task( + id="test_id", + name="test_task", + url="https://example.com/video", + preset="test_preset", + ) + + serialized = task.serialize() + + assert isinstance(serialized, dict) + assert serialized["id"] == "test_id" + assert serialized["name"] == "test_task" + assert serialized["url"] == "https://example.com/video" + assert serialized["preset"] == "test_preset" + assert serialized["folder"] == "" + assert serialized["auto_start"] is True + + @patch("app.library.Tasks.Encoder") + def test_task_json(self, mock_encoder): + """Test task JSON serialization.""" + mock_encoder_instance = Mock() + mock_encoder_instance.encode.return_value = '{"id": "test_id"}' + mock_encoder.return_value = mock_encoder_instance + + task = Task(id="test_id", name="test_task", url="https://example.com/video") + + json_str = task.json() + + assert json_str == '{"id": "test_id"}' + mock_encoder_instance.encode.assert_called_once() + + def test_task_get_method(self): + """Test task get method for accessing fields.""" + task = Task( + id="test_id", + name="test_task", + url="https://example.com/video", + preset="test_preset", + ) + + assert task.get("id") == "test_id" + assert task.get("name") == "test_task" + assert task.get("preset") == "test_preset" + assert task.get("nonexistent") is None + assert task.get("nonexistent", "default_value") == "default_value" + + @patch("app.library.Tasks.YTDLPOpts") + def test_task_get_ytdlp_opts_without_preset_or_cli(self, mock_ytdlp_opts): + """Test getting YTDLPOpts without preset or CLI options.""" + mock_opts_instance = Mock() + mock_ytdlp_opts.get_instance.return_value = mock_opts_instance + + task = Task(id="test_id", name="test_task", url="https://example.com/video") + + result = task.get_ytdlp_opts() + + assert result == mock_opts_instance + mock_ytdlp_opts.get_instance.assert_called_once() + + @patch("app.library.Tasks.YTDLPOpts") + def test_task_get_ytdlp_opts_with_preset_and_cli(self, mock_ytdlp_opts): + """Test getting YTDLPOpts with preset and CLI options.""" + mock_opts_instance = Mock() + mock_preset_opts = Mock() + mock_final_opts = Mock() + + mock_opts_instance.preset.return_value = mock_preset_opts + mock_preset_opts.add_cli.return_value = mock_final_opts + mock_ytdlp_opts.get_instance.return_value = mock_opts_instance + + task = Task( + id="test_id", + name="test_task", + url="https://example.com/video", + preset="test_preset", + cli="--extract-flat", + ) + + result = task.get_ytdlp_opts() + + assert result == mock_final_opts + mock_opts_instance.preset.assert_called_once_with(name="test_preset") + mock_preset_opts.add_cli.assert_called_once_with("--extract-flat", from_user=True) + + @patch("app.library.Tasks.archive_add") + @patch("app.library.Tasks.extract_info") + def test_task_mark_success(self, mock_extract_info, mock_archive_add): + """Test successful task marking.""" + # Mock extract_info to return playlist data + mock_extract_info.return_value = { + "_type": "playlist", + "entries": [ + { + "_type": "video", + "id": "video1", + "ie_key": "Youtube", + }, + { + "_type": "video", + "id": "video2", + "ie_key": "Youtube", + }, + ], + } + mock_archive_add.return_value = True + + with patch.object(Task, "get_ytdlp_opts") as mock_get_opts: + mock_get_opts.return_value.get_all.return_value = {"download_archive": "/tmp/archive.txt"} + + task = Task(id="test_id", name="test_task", url="https://example.com/playlist") + + success, message = task.mark() + + assert success is True + assert "marked as downloaded" in message + mock_archive_add.assert_called_once() + # Check that archive_add was called with the correct items + call_args = mock_archive_add.call_args[0] + archive_file = call_args[0] + items = call_args[1] + assert str(archive_file) == "/tmp/archive.txt" + assert "youtube video1" in items + assert "youtube video2" in items + + def test_task_mark_no_url(self): + """Test task marking with no URL.""" + task = Task(id="test_id", name="test_task", url="") + + success, message = task.mark() + + assert success is False + assert "No URL found" in message + + def test_task_mark_no_archive_file(self): + """Test task marking with no archive file configured.""" + with patch.object(Task, "get_ytdlp_opts") as mock_get_opts: + mock_get_opts.return_value.get_all.return_value = {} + + task = Task(id="test_id", name="test_task", url="https://example.com/video") + + success, message = task.mark() + + assert success is False + assert "No archive file found" in message + + @patch("app.library.Tasks.archive_delete") + @patch("app.library.Tasks.extract_info") + def test_task_unmark_success(self, mock_extract_info, mock_archive_delete): + """Test successful task unmarking.""" + # Mock extract_info to return playlist data + mock_extract_info.return_value = { + "_type": "playlist", + "entries": [ + { + "_type": "video", + "id": "video1", + "ie_key": "Youtube", + }, + ], + } + mock_archive_delete.return_value = True + + with patch.object(Task, "get_ytdlp_opts") as mock_get_opts: + mock_get_opts.return_value.get_all.return_value = {"download_archive": "/tmp/archive.txt"} + + task = Task(id="test_id", name="test_task", url="https://example.com/playlist") + + success, message = task.unmark() + + assert success is True + assert "Removed" in message + assert "items from archive file" in message + mock_archive_delete.assert_called_once() + + @patch("app.library.Tasks.extract_info") + def test_task_mark_logic_invalid_extract_info(self, mock_extract_info): + """Test _mark_logic with invalid extract_info response.""" + mock_extract_info.return_value = None + + with patch.object(Task, "get_ytdlp_opts") as mock_get_opts: + mock_get_opts.return_value.get_all.return_value = {"download_archive": "/tmp/archive.txt"} + + task = Task(id="test_id", name="test_task", url="https://example.com/video") + + result = task._mark_logic() + + assert isinstance(result, tuple) + success, message = result + assert success is False + assert "Failed to extract information" in message + + @patch("app.library.Tasks.extract_info") + def test_task_mark_logic_not_playlist(self, mock_extract_info): + """Test _mark_logic with non-playlist type.""" + mock_extract_info.return_value = { + "_type": "video", + "id": "video1", + } + + with patch.object(Task, "get_ytdlp_opts") as mock_get_opts: + mock_get_opts.return_value.get_all.return_value = {"download_archive": "/tmp/archive.txt"} + + task = Task(id="test_id", name="test_task", url="https://example.com/video") + + result = task._mark_logic() + + assert isinstance(result, tuple) + success, message = result + assert success is False + assert "Expected a playlist type" in message + + +class TestTasks: + """Test the Tasks singleton manager.""" + + def setup_method(self): + """Set up test environment before each test.""" + # Reset singleton instance to ensure clean state + Tasks._reset_singleton() + + def teardown_method(self): + """Clean up after each test.""" + # Reset singleton instance to prevent test pollution + Tasks._reset_singleton() + + @patch("app.library.Tasks.Config") + @patch("app.library.Tasks.EventBus") + @patch("app.library.Tasks.Scheduler") + @patch("app.library.Tasks.DownloadQueue") + def test_tasks_singleton(self, mock_download_queue, mock_scheduler, mock_eventbus, mock_config): + """Test that Tasks follows singleton pattern.""" + mock_config.get_instance.return_value = Mock( + debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *" + ) + mock_eventbus.get_instance.return_value = Mock() + mock_scheduler.get_instance.return_value = Mock() + mock_download_queue.get_instance.return_value = Mock() + + instance1 = Tasks.get_instance() + instance2 = Tasks.get_instance() + + assert instance1 is instance2 + assert isinstance(instance1, Tasks) + + @patch("app.library.Tasks.Config") + @patch("app.library.Tasks.EventBus") + @patch("app.library.Tasks.Scheduler") + @patch("app.library.Tasks.DownloadQueue") + def test_tasks_initialization(self, mock_download_queue, mock_scheduler, mock_eventbus, mock_config): + """Test Tasks initialization with proper dependencies.""" + mock_config_instance = Mock( + debug=True, default_preset="test_preset", config_path="/tmp", tasks_handler_timer="15 */1 * * *" + ) + mock_config.get_instance.return_value = mock_config_instance + mock_eventbus_instance = Mock() + mock_eventbus.get_instance.return_value = mock_eventbus_instance + mock_scheduler_instance = Mock() + mock_scheduler.get_instance.return_value = mock_scheduler_instance + mock_download_queue_instance = Mock() + mock_download_queue.get_instance.return_value = mock_download_queue_instance + + with tempfile.TemporaryDirectory() as temp_dir: + tasks_file = Path(temp_dir) / "tasks.json" + tasks = Tasks(file=tasks_file, config=mock_config_instance) + + # Check initialization + assert tasks._debug is True + assert tasks._default_preset == "test_preset" + assert tasks._file == tasks_file + assert tasks._tasks == [] + assert tasks._scheduler == mock_scheduler_instance + assert tasks._notify == mock_eventbus_instance + assert tasks._downloadQueue == mock_download_queue_instance + + @patch("app.library.Tasks.Config") + @patch("app.library.Tasks.EventBus") + @patch("app.library.Tasks.Scheduler") + @patch("app.library.Tasks.DownloadQueue") + def test_tasks_get_all_empty(self, mock_download_queue, mock_scheduler, mock_eventbus, mock_config): + """Test getting all tasks when no tasks exist.""" + mock_config.get_instance.return_value = Mock( + debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *" + ) + mock_eventbus.get_instance.return_value = Mock() + mock_scheduler.get_instance.return_value = Mock() + mock_download_queue.get_instance.return_value = Mock() + + tasks = Tasks.get_instance() + + result = tasks.get_all() + + assert result == [] + assert isinstance(result, list) + + @patch("app.library.Tasks.Config") + @patch("app.library.Tasks.EventBus") + @patch("app.library.Tasks.Scheduler") + @patch("app.library.Tasks.DownloadQueue") + def test_tasks_get_by_id_not_found(self, mock_download_queue, mock_scheduler, mock_eventbus, mock_config): + """Test getting task by ID when task doesn't exist.""" + mock_config.get_instance.return_value = Mock( + debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *" + ) + mock_eventbus.get_instance.return_value = Mock() + mock_scheduler.get_instance.return_value = Mock() + mock_download_queue.get_instance.return_value = Mock() + + tasks = Tasks.get_instance() + + result = tasks.get("nonexistent_id") + + assert result is None + + @patch("app.library.Tasks.Config") + @patch("app.library.Tasks.EventBus") + @patch("app.library.Tasks.Scheduler") + @patch("app.library.Tasks.DownloadQueue") + def test_tasks_load_empty_file(self, mock_download_queue, mock_scheduler, mock_eventbus, mock_config): + """Test loading tasks from empty or non-existent file.""" + mock_config_instance = Mock( + debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *" + ) + mock_config.get_instance.return_value = mock_config_instance + mock_eventbus.get_instance.return_value = Mock() + mock_scheduler.get_instance.return_value = Mock() + mock_download_queue.get_instance.return_value = Mock() + + with tempfile.TemporaryDirectory() as temp_dir: + tasks_file = Path(temp_dir) / "nonexistent.json" + tasks = Tasks(file=tasks_file, config=mock_config_instance) + + result = tasks.load() + + assert result == tasks # Should return self + assert tasks.get_all() == [] + + @patch("app.library.Tasks.Config") + @patch("app.library.Tasks.EventBus") + @patch("app.library.Tasks.Scheduler") + @patch("app.library.Tasks.DownloadQueue") + def test_tasks_load_valid_file(self, mock_download_queue, mock_scheduler, mock_eventbus, mock_config): + """Test loading tasks from valid JSON file.""" + mock_config_instance = Mock( + debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *" + ) + mock_config.get_instance.return_value = mock_config_instance + mock_eventbus.get_instance.return_value = Mock() + mock_scheduler_instance = Mock() + mock_scheduler.get_instance.return_value = mock_scheduler_instance + mock_download_queue.get_instance.return_value = Mock() + + with tempfile.TemporaryDirectory() as temp_dir: + tasks_file = Path(temp_dir) / "tasks.json" + + # Create valid JSON file with task data + task_data = [ + { + "id": "task1", + "name": "Test Task 1", + "url": "https://example.com/video1", + "preset": "default", + "timer": "0 6 * * *", + "folder": "", + "template": "", + "cli": "", + "auto_start": True, + "handler_enabled": True, + } + ] + tasks_file.write_text("[\n" + "\n".join(f" {line}" for line in str(task_data).split("\n")) + "\n]") + + # Mock json.loads to return our test data + with patch("json.loads", return_value=task_data): + tasks = Tasks(file=tasks_file, config=mock_config_instance) + + # Mock the scheduler.add method + mock_scheduler_instance.add = Mock() + + result = tasks.load() + + assert result == tasks + loaded_tasks = tasks.get_all() + assert len(loaded_tasks) == 1 + assert loaded_tasks[0].id == "task1" + assert loaded_tasks[0].name == "Test Task 1" + assert loaded_tasks[0].url == "https://example.com/video1" + + @patch("app.library.Tasks.Config") + @patch("app.library.Tasks.EventBus") + @patch("app.library.Tasks.Scheduler") + @patch("app.library.Tasks.DownloadQueue") + def test_tasks_load_invalid_json(self, mock_download_queue, mock_scheduler, mock_eventbus, mock_config): + """Test loading tasks from file with invalid JSON.""" + mock_config_instance = Mock( + debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *" + ) + mock_config.get_instance.return_value = mock_config_instance + mock_eventbus.get_instance.return_value = Mock() + mock_scheduler.get_instance.return_value = Mock() + mock_download_queue.get_instance.return_value = Mock() + + with tempfile.TemporaryDirectory() as temp_dir: + tasks_file = Path(temp_dir) / "tasks.json" + tasks_file.write_text("invalid json {") + + tasks = Tasks(file=tasks_file, config=mock_config_instance) + + result = tasks.load() + + assert result == tasks + assert tasks.get_all() == [] # Should be empty due to invalid JSON + + @patch("app.library.Tasks.Config") + @patch("app.library.Tasks.EventBus") + @patch("app.library.Tasks.Scheduler") + @patch("app.library.Tasks.DownloadQueue") + def test_tasks_save_valid_tasks(self, mock_download_queue, mock_scheduler, mock_eventbus, mock_config): + """Test saving valid tasks to file.""" + mock_config_instance = Mock( + debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *" + ) + mock_config.get_instance.return_value = mock_config_instance + mock_eventbus.get_instance.return_value = Mock() + mock_scheduler.get_instance.return_value = Mock() + mock_download_queue.get_instance.return_value = Mock() + + with tempfile.TemporaryDirectory() as temp_dir: + tasks_file = Path(temp_dir) / "tasks.json" + tasks = Tasks(file=tasks_file, config=mock_config_instance) + + task_data = [ + { + "id": "task1", + "name": "Test Task", + "url": "https://example.com/video", + "preset": "default", + "folder": "", + "template": "", + "cli": "", + "timer": "", + "auto_start": True, + "handler_enabled": True, + } + ] + + result = tasks.save(task_data) + + assert result == tasks + assert tasks_file.exists() + # Verify file content was written + assert tasks_file.stat().st_size > 0 + + @patch("app.library.Tasks.Config") + @patch("app.library.Tasks.EventBus") + @patch("app.library.Tasks.Scheduler") + @patch("app.library.Tasks.DownloadQueue") + def test_tasks_get_by_id_found(self, mock_download_queue, mock_scheduler, mock_eventbus, mock_config): + """Test getting task by ID when task exists.""" + mock_config_instance = Mock( + debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *" + ) + mock_config.get_instance.return_value = mock_config_instance + mock_eventbus.get_instance.return_value = Mock() + mock_scheduler.get_instance.return_value = Mock() + mock_download_queue.get_instance.return_value = Mock() + + tasks = Tasks(file=None, config=mock_config_instance) + + # Manually add a task to the internal list + test_task = Task(id="test_id", name="Test Task", url="https://example.com/video") + tasks._tasks.append(test_task) + + result = tasks.get("test_id") + + assert result is not None + assert result.id == "test_id" + assert result.name == "Test Task" + + @patch("app.library.Tasks.Config") + @patch("app.library.Tasks.EventBus") + @patch("app.library.Tasks.Scheduler") + @patch("app.library.Tasks.DownloadQueue") + def test_tasks_get_all_with_tasks(self, mock_download_queue, mock_scheduler, mock_eventbus, mock_config): + """Test getting all tasks when tasks exist.""" + mock_config_instance = Mock( + debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *" + ) + mock_config.get_instance.return_value = mock_config_instance + mock_eventbus.get_instance.return_value = Mock() + mock_scheduler.get_instance.return_value = Mock() + mock_download_queue.get_instance.return_value = Mock() + + tasks = Tasks(file=None, config=mock_config_instance) + + # Manually add tasks to the internal list + task1 = Task(id="task1", name="Task 1", url="https://example.com/video1") + task2 = Task(id="task2", name="Task 2", url="https://example.com/video2") + tasks._tasks.extend([task1, task2]) + + result = tasks.get_all() + + assert len(result) == 2 + assert result[0].id == "task1" + assert result[1].id == "task2" + + @patch("app.library.Tasks.Config") + @patch("app.library.Tasks.EventBus") + @patch("app.library.Tasks.Scheduler") + @patch("app.library.Tasks.DownloadQueue") + def test_tasks_clear_with_scheduler_cleanup(self, mock_download_queue, mock_scheduler, mock_eventbus, mock_config): + """Test clearing tasks with scheduler cleanup.""" + mock_config_instance = Mock( + debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *" + ) + mock_config.get_instance.return_value = mock_config_instance + mock_eventbus.get_instance.return_value = Mock() + mock_scheduler_instance = Mock() + mock_scheduler_instance.has.return_value = True + mock_scheduler_instance.remove = Mock() + mock_scheduler.get_instance.return_value = mock_scheduler_instance + mock_download_queue.get_instance.return_value = Mock() + + tasks = Tasks(file=None, config=mock_config_instance) + + # Add a task with timer to the internal list + task_with_timer = Task(id="task1", name="Task 1", url="https://example.com/video1", timer="0 6 * * *") + tasks._tasks.append(task_with_timer) + + result = tasks.clear() + + assert result == tasks + assert len(tasks.get_all()) == 0 + mock_scheduler_instance.has.assert_called_with("task1") + mock_scheduler_instance.remove.assert_called_with("task1") + + @patch("app.library.Tasks.Config") + @patch("app.library.Tasks.EventBus") + @patch("app.library.Tasks.Scheduler") + @patch("app.library.Tasks.DownloadQueue") + def test_tasks_clear_no_tasks(self, mock_download_queue, mock_scheduler, mock_eventbus, mock_config): + """Test clearing when no tasks exist.""" + mock_config_instance = Mock( + debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *" + ) + mock_config.get_instance.return_value = mock_config_instance + mock_eventbus.get_instance.return_value = Mock() + mock_scheduler_instance = Mock() + mock_scheduler.get_instance.return_value = mock_scheduler_instance + mock_download_queue.get_instance.return_value = Mock() + + tasks = Tasks(file=None, config=mock_config_instance) + + result = tasks.clear() + + assert result == tasks + assert len(tasks.get_all()) == 0 + # Should not call scheduler methods when no tasks exist + mock_scheduler_instance.has.assert_not_called() + mock_scheduler_instance.remove.assert_not_called() + + @patch("app.library.Tasks.Config") + @patch("app.library.Tasks.EventBus") + @patch("app.library.Tasks.Scheduler") + @patch("app.library.Tasks.DownloadQueue") + def test_tasks_validate_valid_task_dict(self, mock_download_queue, mock_scheduler, mock_eventbus, mock_config): + """Test task validation with valid task dictionary.""" + mock_config.get_instance.return_value = Mock() + mock_eventbus.get_instance.return_value = Mock() + mock_scheduler.get_instance.return_value = Mock() + mock_download_queue.get_instance.return_value = Mock() + + valid_task = { + "name": "Test Task", + "url": "https://example.com/video", + "timer": "0 6 * * *", + "cli": "--write-info-json", + } + + with patch("app.library.Tasks.validate_url") as mock_validate_url: + mock_validate_url.return_value = True + + result = Tasks.validate(valid_task) + + assert result is True + mock_validate_url.assert_called_once_with("https://example.com/video", allow_internal=True) + + def test_tasks_validate_invalid_task_type(self): + """Test task validation with invalid task type.""" + invalid_task = "not a dict or Task" + + with pytest.raises(ValueError, match="Invalid task type"): + Tasks.validate(invalid_task) + + def test_tasks_validate_missing_name(self): + """Test task validation with missing name.""" + invalid_task = { + "url": "https://example.com/video", + } + + with pytest.raises(ValueError, match="No name found"): + Tasks.validate(invalid_task) + + def test_tasks_validate_missing_url(self): + """Test task validation with missing URL.""" + invalid_task = { + "name": "Test Task", + } + + with pytest.raises(ValueError, match="No URL found"): + Tasks.validate(invalid_task) + + def test_tasks_validate_invalid_url(self): + """Test task validation with invalid URL.""" + invalid_task = { + "name": "Test Task", + "url": "invalid-url", + } + + with patch("app.library.Tasks.validate_url") as mock_validate_url: + mock_validate_url.side_effect = ValueError("Invalid URL") + + with pytest.raises(ValueError, match="Invalid URL format"): + Tasks.validate(invalid_task) + + def test_tasks_validate_invalid_timer(self): + """Test task validation with invalid timer format.""" + invalid_task = { + "name": "Test Task", + "url": "https://example.com/video", + "timer": "invalid timer", + } + + with patch("app.library.Tasks.validate_url") as mock_validate_url: + mock_validate_url.return_value = True + + # Import error will be raised by cronsim for invalid format + with patch("cronsim.CronSim") as mock_cronsim: + mock_cronsim.side_effect = Exception("Invalid cron format") + + with pytest.raises(ValueError, match="Invalid timer format"): + Tasks.validate(invalid_task) + + def test_tasks_validate_invalid_cli(self): + """Test task validation with invalid CLI options.""" + invalid_task = { + "name": "Test Task", + "url": "https://example.com/video", + "cli": "invalid --cli options", + } + + with patch("app.library.Tasks.validate_url") as mock_validate_url: + mock_validate_url.return_value = True + + with patch("app.library.Utils.arg_converter") as mock_arg_converter: + mock_arg_converter.side_effect = Exception("Invalid CLI args") + + with pytest.raises(ValueError, match="Invalid command options for yt-dlp"): + Tasks.validate(invalid_task) + + @pytest.mark.asyncio + @patch("app.library.Tasks.Config") + @patch("app.library.Tasks.EventBus") + @patch("app.library.Tasks.Scheduler") + @patch("app.library.Tasks.DownloadQueue") + @patch("app.library.Tasks.Item") + @patch("app.library.Tasks.datetime") + @patch("app.library.Tasks.time") + async def test_tasks_runner_success( + self, mock_time, mock_datetime, mock_item, mock_download_queue, mock_scheduler, mock_eventbus, mock_config + ): + """Test successful task runner execution.""" + mock_config_instance = Mock( + debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *" + ) + mock_config.get_instance.return_value = mock_config_instance + mock_eventbus_instance = Mock() + mock_eventbus.get_instance.return_value = mock_eventbus_instance + mock_scheduler.get_instance.return_value = Mock() + + # Mock download queue + mock_download_queue_instance = Mock() + mock_download_queue_instance.add = AsyncMock(return_value={"success": True, "id": "download123"}) + mock_download_queue.get_instance.return_value = mock_download_queue_instance + + # Mock time and datetime + mock_time.time.side_effect = [1000.0, 1005.5] # start and end times + mock_datetime_instance = Mock() + mock_datetime_instance.isoformat.return_value = "2024-01-01T12:00:00Z" + mock_datetime.now.return_value = mock_datetime_instance + + # Mock Item.format + mock_item.format.return_value = {"formatted": "item"} + + tasks = Tasks(file=None, config=mock_config_instance) + + test_task = Task( + id="task1", + name="Test Task", + url="https://example.com/video", + preset="test_preset", + folder="test_folder", + template="test_template", + cli="--write-info-json", + auto_start=True, + ) + + await tasks._runner(test_task) + + # Verify download queue was called + mock_download_queue_instance.add.assert_called_once() + + # Verify Item.format was called with correct parameters + mock_item.format.assert_called_once_with( + { + "url": "https://example.com/video", + "preset": "test_preset", + "folder": "test_folder", + "template": "test_template", + "cli": "--write-info-json", + "auto_start": True, + } + ) + + # Verify events were emitted + assert mock_eventbus_instance.emit.call_count == 2 + + @pytest.mark.asyncio + @patch("app.library.Tasks.Config") + @patch("app.library.Tasks.EventBus") + @patch("app.library.Tasks.Scheduler") + @patch("app.library.Tasks.DownloadQueue") + @patch("app.library.Tasks.datetime") + async def test_tasks_runner_no_url( + self, mock_datetime, mock_download_queue, mock_scheduler, mock_eventbus, mock_config + ): + """Test task runner with no URL.""" + mock_config_instance = Mock( + debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *" + ) + mock_config.get_instance.return_value = mock_config_instance + mock_eventbus_instance = Mock() + mock_eventbus.get_instance.return_value = mock_eventbus_instance + mock_scheduler.get_instance.return_value = Mock() + mock_download_queue_instance = Mock() + mock_download_queue.get_instance.return_value = mock_download_queue_instance + + mock_datetime_instance = Mock() + mock_datetime_instance.isoformat.return_value = "2024-01-01T12:00:00Z" + mock_datetime.now.return_value = mock_datetime_instance + + tasks = Tasks(file=None, config=mock_config_instance) + + test_task = Task( + id="task1", + name="Test Task", + url="", # Empty URL + ) + + await tasks._runner(test_task) + + # Verify download queue was NOT called + mock_download_queue_instance.add.assert_not_called() + + @pytest.mark.asyncio + @patch("app.library.Tasks.Config") + @patch("app.library.Tasks.EventBus") + @patch("app.library.Tasks.Scheduler") + @patch("app.library.Tasks.DownloadQueue") + @patch("app.library.Tasks.Item") + @patch("app.library.Tasks.datetime") + async def test_tasks_runner_download_queue_failure( + self, mock_datetime, mock_item, mock_download_queue, mock_scheduler, mock_eventbus, mock_config + ): + """Test task runner when download queue add fails.""" + mock_config_instance = Mock( + debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *" + ) + mock_config.get_instance.return_value = mock_config_instance + mock_eventbus_instance = Mock() + mock_eventbus.get_instance.return_value = mock_eventbus_instance + mock_scheduler.get_instance.return_value = Mock() + + # Mock download queue to raise exception + mock_download_queue_instance = Mock() + mock_download_queue_instance.add = AsyncMock(side_effect=Exception("Queue error")) + mock_download_queue.get_instance.return_value = mock_download_queue_instance + + mock_datetime_instance = Mock() + mock_datetime_instance.isoformat.return_value = "2024-01-01T12:00:00Z" + mock_datetime.now.return_value = mock_datetime_instance + + mock_item.format.return_value = {"formatted": "item"} + + tasks = Tasks(file=None, config=mock_config_instance) + + test_task = Task( + id="task1", + name="Test Task", + url="https://example.com/video", + ) + + await tasks._runner(test_task) + + # Verify error event was emitted (check last call) + calls = mock_eventbus_instance.emit.call_args_list + assert len(calls) > 0 + # Verify the last call was an error event + last_call = calls[-1] + assert "Task failed" in str(last_call) diff --git a/app/tests/test_ytdlpopts.py b/app/tests/test_ytdlpopts.py new file mode 100644 index 00000000..736f1a04 --- /dev/null +++ b/app/tests/test_ytdlpopts.py @@ -0,0 +1,508 @@ +"""Tests for YTDLPOpts class.""" + +from unittest.mock import Mock, patch + +import pytest + +from app.library.Presets import Preset +from app.library.YTDLPOpts import YTDLPOpts + + +class TestYTDLPOpts: + """Test the YTDLPOpts class.""" + + def test_constructor_initializes_correctly(self): + """Test that YTDLPOpts constructor initializes all attributes.""" + with patch("app.library.YTDLPOpts.Config") as mock_config: + mock_config_instance = Mock() + mock_config.get_instance.return_value = mock_config_instance + + opts = YTDLPOpts() + + assert opts._config is mock_config_instance + assert opts._item_opts == {} + assert opts._preset_opts == {} + assert opts._item_cli == [] + assert opts._preset_cli == "" + + def test_get_instance_returns_reset_instance(self): + """Test that get_instance returns a reset YTDLPOpts instance.""" + with patch("app.library.YTDLPOpts.Config"): + opts = YTDLPOpts.get_instance() + + assert isinstance(opts, YTDLPOpts) + assert opts._item_opts == {} + assert opts._preset_opts == {} + assert opts._item_cli == [] + assert opts._preset_cli == "" + + def test_add_cli_with_valid_args(self): + """Test adding valid CLI arguments.""" + with patch("app.library.YTDLPOpts.Config"), patch("app.library.YTDLPOpts.arg_converter") as mock_converter: + mock_converter.return_value = {"format": "best"} + opts = YTDLPOpts() + + result = opts.add_cli("--format best", from_user=False) + + assert result is opts # Returns self for chaining + assert "--format best" in opts._item_cli + mock_converter.assert_called_once_with(args="--format best", level=False) + + def test_add_cli_with_invalid_args_raises_error(self): + """Test that invalid CLI arguments raise ValueError.""" + with patch("app.library.YTDLPOpts.Config"), patch("app.library.YTDLPOpts.arg_converter") as mock_converter: + mock_converter.side_effect = Exception("Invalid argument") + opts = YTDLPOpts() + + with pytest.raises(ValueError, match="Invalid command options for yt-dlp were given"): + opts.add_cli("--invalid-arg", from_user=True) + + def test_add_cli_with_empty_args_returns_self(self): + """Test that empty or invalid args return self without processing.""" + with patch("app.library.YTDLPOpts.Config"): + opts = YTDLPOpts() + + # Test empty string + result1 = opts.add_cli("", from_user=False) + assert result1 is opts + assert len(opts._item_cli) == 0 + + # Test short string + result2 = opts.add_cli("a", from_user=False) + assert result2 is opts + assert len(opts._item_cli) == 0 + + # Test non-string (this should be handled gracefully) + result3 = opts.add_cli(123, from_user=False) # type: ignore + assert result3 is opts + assert len(opts._item_cli) == 0 + + def test_add_with_valid_config(self): + """Test adding configuration options.""" + with patch("app.library.YTDLPOpts.Config"): + opts = YTDLPOpts() + + config = {"format": "best", "quality": "720p"} + result = opts.add(config, from_user=False) + + assert result is opts + assert opts._item_opts["format"] == "best" + assert opts._item_opts["quality"] == "720p" + + def test_add_with_user_config_filters_bad_options(self): + """Test that user config filters out dangerous options.""" + with ( + patch("app.library.YTDLPOpts.Config"), + patch("app.library.YTDLPOpts.REMOVE_KEYS", [{"paths": "-P, --paths", "outtmpl": "-o, --output"}]), + ): + opts = YTDLPOpts() + + config = { + "format": "best", + "paths": "/dangerous/path", # Should be filtered + "outtmpl": "dangerous_template", # Should be filtered + "quality": "720p", + } + + result = opts.add(config, from_user=True) + + assert result is opts + assert opts._item_opts["format"] == "best" + assert opts._item_opts["quality"] == "720p" + assert "paths" not in opts._item_opts + assert "outtmpl" not in opts._item_opts + + def test_preset_with_valid_preset(self): + """Test applying a valid preset.""" + with ( + patch("app.library.YTDLPOpts.Config") as mock_config, + patch("app.library.YTDLPOpts.Presets") as mock_presets, + patch("app.library.YTDLPOpts.arg_converter") as mock_converter, + ): + # Mock config + mock_config_instance = Mock() + mock_config_instance.config_path = "/test/config" + mock_config_instance.download_path = "/test/downloads" + mock_config_instance.temp_path = "/test/temp" + mock_config_instance.output_template_chapter = "chapter_template" + mock_config.get_instance.return_value = mock_config_instance + + # Mock preset + mock_preset = Mock(spec=Preset) + mock_preset.id = "test_preset" + mock_preset.name = "Test Preset" + mock_preset.cli = "--format best" + mock_preset.cookies = None + mock_preset.template = "custom_template" + mock_preset.folder = "custom_folder" + + mock_presets_instance = Mock() + mock_presets_instance.get.return_value = mock_preset + mock_presets.get_instance.return_value = mock_presets_instance + + mock_converter.return_value = {"format": "best"} + + with patch("app.library.YTDLPOpts.calc_download_path") as mock_calc_path: + mock_calc_path.return_value = "/test/downloads/custom_folder" + + opts = YTDLPOpts() + result = opts.preset("test_preset") + + assert result is opts + assert opts._preset_cli == "--format best" + assert opts._preset_opts["outtmpl"]["default"] == "custom_template" + assert opts._preset_opts["paths"]["home"] == "/test/downloads/custom_folder" + + def test_preset_with_nonexistent_preset(self): + """Test applying a nonexistent preset returns self.""" + with patch("app.library.YTDLPOpts.Config"), patch("app.library.YTDLPOpts.Presets") as mock_presets: + mock_presets_instance = Mock() + mock_presets_instance.get.return_value = None + mock_presets.get_instance.return_value = mock_presets_instance + + opts = YTDLPOpts() + result = opts.preset("nonexistent") + + assert result is opts + assert opts._preset_cli == "" + assert opts._preset_opts == {} + + def test_preset_with_cookies_creates_file(self): + """Test that preset with cookies creates cookie file.""" + with ( + patch("app.library.YTDLPOpts.Config") as mock_config, + patch("app.library.YTDLPOpts.Presets") as mock_presets, + patch("app.library.YTDLPOpts.load_cookies") as mock_load_cookies, + ): + # Mock config + mock_config_instance = Mock() + mock_config_instance.config_path = "/test/config" + mock_config.get_instance.return_value = mock_config_instance + + # Mock preset with cookies + mock_preset = Mock(spec=Preset) + mock_preset.id = "cookie_preset" + mock_preset.name = "Cookie Preset" + mock_preset.cli = None + mock_preset.cookies = "cookie_data" + mock_preset.template = None + mock_preset.folder = None + + mock_presets_instance = Mock() + mock_presets_instance.get.return_value = mock_preset + mock_presets.get_instance.return_value = mock_presets_instance + + # Use real Path but mock file operations + with ( + patch("pathlib.Path.exists", return_value=False), + patch("pathlib.Path.mkdir"), + patch("pathlib.Path.write_text"), + ): + opts = YTDLPOpts() + opts.preset("cookie_preset") + + # Check that the cookie file would be created at the right path + expected_path = "/test/config/cookies/cookie_preset.txt" + assert opts._preset_opts["cookiefile"] == expected_path + mock_load_cookies.assert_called_once() + + def test_preset_with_invalid_cli_raises_error(self): + """Test that preset with invalid CLI raises ValueError.""" + with ( + patch("app.library.YTDLPOpts.Config"), + patch("app.library.YTDLPOpts.Presets") as mock_presets, + patch("app.library.YTDLPOpts.arg_converter") as mock_converter, + ): + mock_preset = Mock(spec=Preset) + mock_preset.id = "bad_preset" + mock_preset.name = "Bad Preset" + mock_preset.cli = "--invalid-option" + mock_preset.cookies = None + mock_preset.template = None + mock_preset.folder = None + + mock_presets_instance = Mock() + mock_presets_instance.get.return_value = mock_preset + mock_presets.get_instance.return_value = mock_presets_instance + + mock_converter.side_effect = Exception("Invalid CLI") + + opts = YTDLPOpts() + + with pytest.raises(ValueError, match="Invalid preset 'Bad Preset' command options for yt-dlp"): + opts.preset("bad_preset") + + def test_get_all_with_default_options(self): + """Test get_all returns correct default options.""" + with patch("app.library.YTDLPOpts.Config") as mock_config: + mock_config_instance = Mock() + mock_config_instance.download_path = "/downloads" + mock_config_instance.temp_path = "/temp" + mock_config_instance.output_template = "default_template" + mock_config_instance.output_template_chapter = "chapter_template" + mock_config_instance.debug = False + mock_config.get_instance.return_value = mock_config_instance + + with patch("app.library.YTDLPOpts.merge_dict") as mock_merge: + mock_merge.return_value = { + "paths": {"home": "/downloads", "temp": "/temp"}, + "outtmpl": {"default": "default_template", "chapter": "chapter_template"}, + } + + opts = YTDLPOpts() + result = opts.get_all(keep=True) + + assert result["paths"]["home"] == "/downloads" + assert result["outtmpl"]["default"] == "default_template" + + def test_get_all_processes_cli_arguments(self): + """Test get_all processes CLI arguments correctly.""" + with ( + patch("app.library.YTDLPOpts.Config") as mock_config, + patch("app.library.YTDLPOpts.arg_converter") as mock_converter, + patch("app.library.YTDLPOpts.merge_dict") as mock_merge, + ): + mock_config_instance = Mock() + mock_config_instance.download_path = "/downloads" + mock_config_instance.temp_path = "/temp" + mock_config_instance.output_template = "default_template" + mock_config_instance.output_template_chapter = "chapter_template" + mock_config_instance.debug = False + mock_config_instance.get_replacers.return_value = {"home": "/test"} + mock_config.get_instance.return_value = mock_config_instance + + mock_converter.return_value = {"format": "best"} + mock_merge.return_value = {"format": "best"} + + opts = YTDLPOpts() + opts._item_cli = ["--format best"] + opts._preset_cli = "--quality 720p" + + opts.get_all(keep=True) + + # Should join CLI args and process replacers + expected_cli = "--quality 720p\n--format best" + mock_converter.assert_called_once_with(args=expected_cli, level=True) + + def test_get_all_handles_format_special_cases(self): + """Test get_all handles special format values correctly.""" + with ( + patch("app.library.YTDLPOpts.Config") as mock_config, + patch("app.library.YTDLPOpts.merge_dict") as mock_merge, + ): + mock_config_instance = Mock() + mock_config_instance.download_path = "/downloads" + mock_config_instance.temp_path = "/temp" + mock_config_instance.output_template = "default_template" + mock_config_instance.output_template_chapter = "chapter_template" + mock_config_instance.debug = False + mock_config.get_instance.return_value = mock_config_instance + + opts = YTDLPOpts() + + # Test "not_set" format + mock_merge.return_value = {"format": "not_set"} + result = opts.get_all(keep=True) + assert result["format"] is None + + # Test "default" format + mock_merge.return_value = {"format": "default"} + result = opts.get_all(keep=True) + assert result["format"] is None + + # Test "best" format + mock_merge.return_value = {"format": "best"} + result = opts.get_all(keep=True) + assert result["format"] is None + + # Test "-best" format (should remove leading dash) + mock_merge.return_value = {"format": "-best"} + result = opts.get_all(keep=True) + assert result["format"] == "best" + + def test_get_all_with_invalid_cli_raises_error(self): + """Test get_all raises error for invalid CLI arguments.""" + with ( + patch("app.library.YTDLPOpts.Config") as mock_config, + patch("app.library.YTDLPOpts.arg_converter") as mock_converter, + ): + mock_config_instance = Mock() + mock_config_instance.download_path = "/downloads" + mock_config_instance.temp_path = "/temp" + mock_config_instance.output_template = "default_template" + mock_config_instance.output_template_chapter = "chapter_template" + mock_config_instance.debug = False + mock_config_instance.get_replacers.return_value = {} + mock_config.get_instance.return_value = mock_config_instance + + mock_converter.side_effect = Exception("Invalid CLI") + + opts = YTDLPOpts() + opts._item_cli = ["--invalid-arg"] + + with pytest.raises(ValueError, match="Invalid command options for yt-dlp were given"): + opts.get_all() + + def test_get_all_resets_unless_keep_true(self): + """Test get_all resets instance unless keep=True.""" + with ( + patch("app.library.YTDLPOpts.Config") as mock_config, + patch("app.library.YTDLPOpts.merge_dict") as mock_merge, + ): + mock_config_instance = Mock() + mock_config_instance.download_path = "/downloads" + mock_config_instance.temp_path = "/temp" + mock_config_instance.output_template = "default_template" + mock_config_instance.output_template_chapter = "chapter_template" + mock_config_instance.debug = False + mock_config_instance.get_replacers.return_value = {} # Return empty dict + mock_config.get_instance.return_value = mock_config_instance + + mock_merge.return_value = {} + + opts = YTDLPOpts() + opts._item_opts = {"test": "value"} + opts._item_cli = ["--test"] + + # Test with keep=False (default) + opts.get_all(keep=False) + assert opts._item_opts == {} + assert opts._item_cli == [] + + # Reset test data + opts._item_opts = {"test": "value"} + opts._item_cli = ["--test"] + + # Test with keep=True + opts.get_all(keep=True) + assert opts._item_opts == {"test": "value"} + assert opts._item_cli == ["--test"] + + def test_reset_clears_all_options(self): + """Test reset clears all internal state.""" + with patch("app.library.YTDLPOpts.Config"): + opts = YTDLPOpts() + + # Set some state + opts._item_opts = {"format": "best"} + opts._preset_opts = {"quality": "720p"} + opts._item_cli = ["--format best"] + opts._preset_cli = "--quality 720p" + + result = opts.reset() + + assert result is opts + assert opts._item_opts == {} + assert opts._preset_opts == {} + assert opts._item_cli == [] + assert opts._preset_cli == "" + + def test_method_chaining(self): + """Test that methods support chaining.""" + with ( + patch("app.library.YTDLPOpts.Config"), + patch("app.library.YTDLPOpts.arg_converter"), + patch("app.library.YTDLPOpts.Presets") as mock_presets, + ): + mock_presets_instance = Mock() + mock_presets_instance.get.return_value = None + mock_presets.get_instance.return_value = mock_presets_instance + + opts = YTDLPOpts() + + # Test chaining + result = opts.add_cli("--format best").add({"quality": "720p"}).preset("nonexistent").reset() + + assert result is opts + + def test_debug_logging(self): + """Test debug logging when enabled.""" + with ( + patch("app.library.YTDLPOpts.Config") as mock_config, + patch("app.library.YTDLPOpts.merge_dict") as mock_merge, + patch("app.library.YTDLPOpts.LOG") as mock_log, + ): + mock_config_instance = Mock() + mock_config_instance.download_path = "/downloads" + mock_config_instance.temp_path = "/temp" + mock_config_instance.output_template = "default_template" + mock_config_instance.output_template_chapter = "chapter_template" + mock_config_instance.debug = True # Enable debug + mock_config.get_instance.return_value = mock_config_instance + + test_data = {"format": "best"} + mock_merge.return_value = test_data + + opts = YTDLPOpts() + opts.get_all(keep=True) + + mock_log.debug.assert_called_once_with(f"Final yt-dlp options: '{test_data!s}'.") + + def test_cookie_loading_error_handling(self): + """Test error handling when cookie loading fails.""" + with ( + patch("app.library.YTDLPOpts.Config") as mock_config, + patch("app.library.YTDLPOpts.Presets") as mock_presets, + patch("app.library.YTDLPOpts.load_cookies") as mock_load_cookies, + patch("app.library.YTDLPOpts.LOG") as mock_log, + ): + # Mock config + mock_config_instance = Mock() + mock_config_instance.config_path = "/test/config" + mock_config.get_instance.return_value = mock_config_instance + + # Mock preset with cookies + mock_preset = Mock(spec=Preset) + mock_preset.id = "cookie_preset" + mock_preset.name = "Cookie Preset" + mock_preset.cli = None + mock_preset.cookies = "invalid_cookie_data" + mock_preset.template = None + mock_preset.folder = None + + mock_presets_instance = Mock() + mock_presets_instance.get.return_value = mock_preset + mock_presets.get_instance.return_value = mock_presets_instance + + # Mock cookie loading failure + mock_load_cookies.side_effect = ValueError("Invalid cookies") + + with patch("pathlib.Path.exists", return_value=True), patch("pathlib.Path.write_text"): + opts = YTDLPOpts() + opts.preset("cookie_preset") + + # Should log error but not raise + mock_log.error.assert_called_once() + error_args = mock_log.error.call_args[0][0] + assert "Failed to load 'Cookie Preset' cookies" in error_args + + # cookiefile should not be set + assert "cookiefile" not in opts._preset_opts + + def test_replacer_substitution_in_cli(self): + """Test that CLI arguments get replacer substitution.""" + with ( + patch("app.library.YTDLPOpts.Config") as mock_config, + patch("app.library.YTDLPOpts.arg_converter") as mock_converter, + patch("app.library.YTDLPOpts.merge_dict") as mock_merge, + ): + mock_config_instance = Mock() + mock_config_instance.download_path = "/downloads" + mock_config_instance.temp_path = "/temp" + mock_config_instance.output_template = "default_template" + mock_config_instance.output_template_chapter = "chapter_template" + mock_config_instance.debug = False + mock_config_instance.get_replacers.return_value = {"home": "/actual/home", "user": "testuser"} + mock_config.get_instance.return_value = mock_config_instance + + mock_converter.return_value = {"output": "/actual/home/testuser"} + mock_merge.return_value = {} + + opts = YTDLPOpts() + opts._item_cli = ["--output %(home)s/%(user)s"] + + opts.get_all(keep=True) + + # Should replace %(home)s and %(user)s + expected_cli = "--output /actual/home/testuser" + mock_converter.assert_called_once_with(args=expected_cli, level=True)