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 @@
-
- pImg(e)" :src="url" :alt="props.title" @error="clearCache"
- :crossorigin="props.privacy ? 'anonymous' : 'use-credentials'"
- :referrerpolicy="props.privacy ? 'no-referrer' : 'origin'" />
-
{{ item.description }}
+{{ item.description }}
+