diff --git a/app/library/Notifications.py b/app/library/Notifications.py index 81d7b17a..e10e5145 100644 --- a/app/library/Notifications.py +++ b/app/library/Notifications.py @@ -76,6 +76,7 @@ class Target: on: list[str] = field(default_factory=list) presets: list[str] = field(default_factory=list) request: TargetRequest + enabled: bool = True def serialize(self) -> dict: return { @@ -84,6 +85,7 @@ class Target: "on": self.on, "presets": self.presets, "request": self.request.serialize(), + "enabled": self.enabled, } def json(self) -> str: @@ -234,8 +236,18 @@ class Notification(metaclass=Singleton): except Exception as e: LOG.error(f"Error loading '{self._file}'. '{e!s}'") + if not targets or len(targets) < 1: + LOG.debug(f"No targets were found in '{self._file}'.") + return self + + need_save = False + for target in targets: try: + if "enabled" not in target: + target["enabled"] = True + need_save = True + try: Notification.validate(target) target: Target = self.make_target(target) @@ -252,6 +264,10 @@ class Notification(metaclass=Singleton): except Exception as e: LOG.error(f"Error loading notification target '{target}'. '{e!s}'") + if need_save: + LOG.warning("Saving notifications due to schema changes.") + self.save(self._targets) + return self def make_target(self, target: dict) -> Target: @@ -270,6 +286,7 @@ class Notification(metaclass=Singleton): name=target.get("name"), on=target.get("on", []), presets=target.get("presets", []), + enabled=target.get("enabled", True), request=TargetRequest( type=target.get("request", {}).get("type", "json"), method=target.get("request", {}).get("method", "POST"), @@ -319,6 +336,13 @@ class Notification(metaclass=Singleton): if "data_key" not in target["request"]: target["request"]["data_key"] = "data" + if "enabled" not in target: + target["enabled"] = True + + if target.get("enabled") is not None and not isinstance(target.get("enabled"), bool): + msg = "Enabled must be a boolean." + raise ValueError(msg) + if "method" in target["request"] and target["request"]["method"].upper() not in ["POST", "PUT"]: msg = "Invalid notification target. Invalid method found." raise ValueError(msg) @@ -390,6 +414,9 @@ class Notification(metaclass=Singleton): apprise_targets: list[Target] = [] for target in self._targets: + if not target.enabled: + continue + if len(target.on) > 0 and ev.event not in target.on and "test" != ev.event: continue diff --git a/app/schema/notifications.json b/app/schema/notifications.json index e2f48a57..57bd4b89 100644 --- a/app/schema/notifications.json +++ b/app/schema/notifications.json @@ -133,6 +133,11 @@ "description": "Restrict notifications to downloads created by these presets.", "default": [] }, + "enabled": { + "type": "boolean", + "description": "Whether the notification target is enabled.", + "default": true + }, "request": { "$ref": "#/definitions/request" } diff --git a/app/tests/test_notifications.py b/app/tests/test_notifications.py index 8e1fc7c0..36eb8e6b 100644 --- a/app/tests/test_notifications.py +++ b/app/tests/test_notifications.py @@ -132,6 +132,7 @@ class TestTarget: assert target.name == "Test Webhook" assert target.on == [] assert target.presets == [] + assert target.enabled is True assert target.request == request def test_target_creation_full(self): @@ -143,6 +144,7 @@ class TestTarget: name="Full Test Webhook", on=["item_completed", "item_failed"], presets=["default", "audio_only"], + enabled=False, request=request, ) @@ -150,6 +152,7 @@ class TestTarget: assert target.name == "Full Test Webhook" assert target.on == ["item_completed", "item_failed"] assert target.presets == ["default", "audio_only"] + assert target.enabled is False def test_target_serialize(self): """Test serializing a Target.""" @@ -379,6 +382,48 @@ class TestNotification: # 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_schema_update(self, mock_presets, mock_worker, mock_config): + """Test that load automatically updates file schema when enabled field is missing.""" + 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 = Mock() + mock_preset.name = "default" + mock_presets.get_instance.return_value.get_all.return_value = [mock_preset] + + # Create target data without enabled field (old schema) + target_data = [ + { + "id": str(uuid.uuid4()), + "name": "Old Schema Target", + "on": ["item_completed"], + "presets": ["default"], + "request": {"type": "json", "method": "POST", "url": "https://example.com/webhook"}, + } + ] + + 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) + + # Mock the save method to verify it's called + with patch.object(notification, "save") as mock_save: + notification.load() + + # Verify save was called due to schema update + mock_save.assert_called_once() + + # Verify the target has enabled=True by default + assert len(notification._targets) == 1 + assert notification._targets[0].enabled is True + + # Clean up + Path(temp_path).unlink() + @patch("app.library.Notifications.Config") @patch("app.library.Notifications.BackgroundWorker") @patch("app.library.Notifications.Presets") @@ -543,6 +588,38 @@ class TestNotification: with pytest.raises(ValueError, match=r"Invalid notification target\. Invalid type found\."): Notification.validate(target_dict) + def test_validate_target_invalid_enabled(self): + """Test validating a target with invalid enabled field.""" + target_dict = { + "id": str(uuid.uuid4()), + "name": "Test Target", + "enabled": "yes", # Should be boolean + "request": { + "url": "https://example.com/webhook", + "method": "POST", + "type": "json", + }, + } + + with pytest.raises(ValueError, match=r"Enabled must be a boolean\."): + Notification.validate(target_dict) + + def test_validate_target_enabled_defaults_to_true(self): + """Test that enabled defaults to True when not provided.""" + target_dict = { + "id": str(uuid.uuid4()), + "name": "Test Target", + "request": { + "url": "https://example.com/webhook", + "method": "POST", + "type": "json", + }, + } + + result = Notification.validate(target_dict) + assert result is True + assert target_dict.get("enabled") is True + @patch("app.library.Notifications.Config") @patch("app.library.Notifications.BackgroundWorker") @patch("app.library.Notifications.EventBus") @@ -633,6 +710,45 @@ class TestNotification: 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_skips_disabled_targets(self, mock_worker, mock_config): + """Test that send method skips disabled 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() + + # 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 enabled and disabled targets + enabled_request = TargetRequest(type="json", method="POST", url="https://example.com/enabled") + enabled_target = Target(id=str(uuid.uuid4()), name="Enabled Target", enabled=True, request=enabled_request) + + disabled_request = TargetRequest(type="json", method="POST", url="https://example.com/disabled") + disabled_target = Target(id=str(uuid.uuid4()), name="Disabled Target", enabled=False, request=disabled_request) + + notification._targets = [enabled_target, disabled_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) + + # Only enabled target should be called + assert len(result) == 1 + assert result[0]["status"] == 200 + mock_client.request.assert_called_once() + @pytest.mark.asyncio @patch("app.library.Notifications.Config") @patch("app.library.Notifications.BackgroundWorker") diff --git a/ui/app/assets/css/style.css b/ui/app/assets/css/style.css index fbcee187..abe48056 100644 --- a/ui/app/assets/css/style.css +++ b/ui/app/assets/css/style.css @@ -45,12 +45,6 @@ hr { white-space: nowrap; } -.user-hint { - user-select: none; - cursor: help; - border-bottom: 1px dotted; -} - .has-tooltip { cursor: help; border-bottom: 1px dotted; diff --git a/ui/app/components/FloatingImage.vue b/ui/app/components/FloatingImage.vue deleted file mode 100644 index bd311c45..00000000 --- a/ui/app/components/FloatingImage.vue +++ /dev/null @@ -1,135 +0,0 @@ - - - diff --git a/ui/app/components/History.vue b/ui/app/components/History.vue index 7e97625f..a83fa427 100644 --- a/ui/app/components/History.vue +++ b/ui/app/components/History.vue @@ -114,17 +114,32 @@ -
- +
+ {{ formatTime(item.extras.duration) }} + + +
-
- -
- {{ item.title }} -
-
+
+
+ + + + +

{{ item.description }}

+
+