From 142d445ed02fdc8e034542cd313c79d3a5916022 Mon Sep 17 00:00:00 2001 From: Flaminel Date: Thu, 25 Dec 2025 22:05:23 +0200 Subject: [PATCH] Add Apprise CLI notification provider (#387) --- code/Dockerfile | 14 +- .../DependencyInjection/NotificationsDI.cs | 2 + .../Requests/CreateAppriseProviderRequest.cs | 12 +- .../Requests/TestAppriseProviderRequest.cs | 12 +- .../Requests/UpdateAppriseProviderRequest.cs | 12 +- .../NotificationProvidersController.cs | 37 +- .../Cleanuparr.Domain/Enums/AppriseMode.cs | 7 + .../Apprise/AppriseCliDetectorTests.cs | 34 + .../Apprise/AppriseCliProxyTests.cs | 73 ++ .../Notifications/AppriseProviderTests.cs | 56 +- .../NotificationProviderFactoryTests.cs | 3 + .../Cleanuparr.Infrastructure.csproj | 1 + .../Apprise/AppriseCliDetector.cs | 38 + .../Notifications/Apprise/AppriseCliProxy.cs | 69 ++ .../Notifications/Apprise/AppriseProvider.cs | 22 +- .../Apprise/IAppriseCliDetector.cs | 6 + .../Notifications/Apprise/IAppriseCliProxy.cs | 8 + .../NotificationProviderFactory.cs | 7 +- .../Notification/AppriseConfigTests.cs | 105 +- ...251213201344_AddAppriseCliMode.Designer.cs | 1101 +++++++++++++++++ .../Data/20251213201344_AddAppriseCliMode.cs | 40 + .../Data/DataContextModelSnapshot.cs | 10 + .../Notification/AppriseConfig.cs | 74 +- .../core/services/documentation.service.ts | 4 +- .../services/notification-provider.service.ts | 26 +- .../apprise-provider.component.html | 180 ++- .../apprise-provider.component.scss | 8 + .../apprise-provider.component.ts | 154 ++- .../notification-provider-base.component.html | 4 +- .../notification-provider-base.component.scss | 6 +- .../ntfy-provider.component.html | 30 +- .../ntfy-provider/ntfy-provider.component.ts | 4 +- .../models/provider-modal.model.ts | 6 +- .../notification-settings.component.ts | 12 +- .../app/shared/models/apprise-config.model.ts | 5 + code/frontend/src/app/shared/models/enums.ts | 5 + .../configuration/notifications/apprise.mdx | 50 +- 37 files changed, 2062 insertions(+), 175 deletions(-) create mode 100644 code/backend/Cleanuparr.Domain/Enums/AppriseMode.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/Apprise/AppriseCliDetectorTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/Apprise/AppriseCliProxyTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure/Features/Notifications/Apprise/AppriseCliDetector.cs create mode 100644 code/backend/Cleanuparr.Infrastructure/Features/Notifications/Apprise/AppriseCliProxy.cs create mode 100644 code/backend/Cleanuparr.Infrastructure/Features/Notifications/Apprise/IAppriseCliDetector.cs create mode 100644 code/backend/Cleanuparr.Infrastructure/Features/Notifications/Apprise/IAppriseCliProxy.cs create mode 100644 code/backend/Cleanuparr.Persistence/Migrations/Data/20251213201344_AddAppriseCliMode.Designer.cs create mode 100644 code/backend/Cleanuparr.Persistence/Migrations/Data/20251213201344_AddAppriseCliMode.cs diff --git a/code/Dockerfile b/code/Dockerfile index 6fd93ce8..3b8d37e7 100644 --- a/code/Dockerfile +++ b/code/Dockerfile @@ -23,10 +23,6 @@ ARG PACKAGES_PAT WORKDIR /app EXPOSE 11011 -# Copy solution and project files first for better layer caching -# COPY backend/*.sln ./backend/ -# COPY backend/*/*.csproj ./backend/*/ - # Copy source code COPY backend/ ./backend/ @@ -48,13 +44,21 @@ RUN --mount=type=cache,target=/root/.nuget/packages,sharing=locked \ # Runtime stage FROM mcr.microsoft.com/dotnet/aspnet:9.0-bookworm-slim -# Install required packages for user management and timezone support +# Install required packages for user management, timezone support, and Python for Apprise CLI RUN apt-get update && apt-get install -y \ curl \ tzdata \ gosu \ + python3 \ + python3-venv \ && rm -rf /var/lib/apt/lists/* +# Create virtual environment and install Apprise CLI +ENV VIRTUAL_ENV=/opt/apprise-venv +RUN python3 -m venv $VIRTUAL_ENV +ENV PATH="$VIRTUAL_ENV/bin:$PATH" +RUN pip install --no-cache-dir apprise==1.9.6 + ENV PUID=1000 \ PGID=1000 \ UMASK=022 \ diff --git a/code/backend/Cleanuparr.Api/DependencyInjection/NotificationsDI.cs b/code/backend/Cleanuparr.Api/DependencyInjection/NotificationsDI.cs index e0e3443c..ce60d502 100644 --- a/code/backend/Cleanuparr.Api/DependencyInjection/NotificationsDI.cs +++ b/code/backend/Cleanuparr.Api/DependencyInjection/NotificationsDI.cs @@ -12,6 +12,8 @@ public static class NotificationsDI services .AddScoped() .AddScoped() + .AddScoped() + .AddSingleton() .AddScoped() .AddScoped() .AddScoped() diff --git a/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/CreateAppriseProviderRequest.cs b/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/CreateAppriseProviderRequest.cs index eb810f9c..922e06d2 100644 --- a/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/CreateAppriseProviderRequest.cs +++ b/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/CreateAppriseProviderRequest.cs @@ -1,10 +1,18 @@ +using Cleanuparr.Domain.Enums; + namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests; public record CreateAppriseProviderRequest : CreateNotificationProviderRequestBase { + public AppriseMode Mode { get; init; } = AppriseMode.Api; + + // API mode fields public string Url { get; init; } = string.Empty; - + public string Key { get; init; } = string.Empty; - + public string Tags { get; init; } = string.Empty; + + // CLI mode fields + public string? ServiceUrls { get; init; } } diff --git a/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/TestAppriseProviderRequest.cs b/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/TestAppriseProviderRequest.cs index 340570f8..5bd7b0dc 100644 --- a/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/TestAppriseProviderRequest.cs +++ b/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/TestAppriseProviderRequest.cs @@ -1,10 +1,18 @@ +using Cleanuparr.Domain.Enums; + namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests; public record TestAppriseProviderRequest { + public AppriseMode Mode { get; init; } = AppriseMode.Api; + + // API mode fields public string Url { get; init; } = string.Empty; - + public string Key { get; init; } = string.Empty; - + public string Tags { get; init; } = string.Empty; + + // CLI mode fields + public string? ServiceUrls { get; init; } } diff --git a/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/UpdateAppriseProviderRequest.cs b/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/UpdateAppriseProviderRequest.cs index d087867a..55d35421 100644 --- a/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/UpdateAppriseProviderRequest.cs +++ b/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/UpdateAppriseProviderRequest.cs @@ -1,10 +1,18 @@ +using Cleanuparr.Domain.Enums; + namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests; public record UpdateAppriseProviderRequest : UpdateNotificationProviderRequestBase { + public AppriseMode Mode { get; init; } = AppriseMode.Api; + + // API mode fields public string Url { get; init; } = string.Empty; - + public string Key { get; init; } = string.Empty; - + public string Tags { get; init; } = string.Empty; + + // CLI mode fields + public string? ServiceUrls { get; init; } } diff --git a/code/backend/Cleanuparr.Api/Features/Notifications/Controllers/NotificationProvidersController.cs b/code/backend/Cleanuparr.Api/Features/Notifications/Controllers/NotificationProvidersController.cs index 052764ba..cb83aa8a 100644 --- a/code/backend/Cleanuparr.Api/Features/Notifications/Controllers/NotificationProvidersController.cs +++ b/code/backend/Cleanuparr.Api/Features/Notifications/Controllers/NotificationProvidersController.cs @@ -1,15 +1,15 @@ +using System.Net; using Cleanuparr.Api.Features.Notifications.Contracts.Requests; using Cleanuparr.Api.Features.Notifications.Contracts.Responses; using Cleanuparr.Domain.Enums; using Cleanuparr.Domain.Exceptions; using Cleanuparr.Infrastructure.Features.Notifications; +using Cleanuparr.Infrastructure.Features.Notifications.Apprise; using Cleanuparr.Infrastructure.Features.Notifications.Models; -using Cleanuparr.Infrastructure.Services.Interfaces; using Cleanuparr.Persistence; using Cleanuparr.Persistence.Models.Configuration.Notification; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; namespace Cleanuparr.Api.Features.Notifications.Controllers; @@ -21,17 +21,20 @@ public sealed class NotificationProvidersController : ControllerBase private readonly DataContext _dataContext; private readonly INotificationConfigurationService _notificationConfigurationService; private readonly NotificationService _notificationService; + private readonly IAppriseCliDetector _appriseCliDetector; public NotificationProvidersController( ILogger logger, DataContext dataContext, INotificationConfigurationService notificationConfigurationService, - NotificationService notificationService) + NotificationService notificationService, + IAppriseCliDetector appriseCliDetector) { _logger = logger; _dataContext = dataContext; _notificationConfigurationService = notificationConfigurationService; _notificationService = notificationService; + _appriseCliDetector = appriseCliDetector; } [HttpGet] @@ -86,6 +89,18 @@ public sealed class NotificationProvidersController : ControllerBase } } + [HttpGet("apprise/cli-status")] + public async Task GetAppriseCliStatus() + { + string? version = await _appriseCliDetector.GetAppriseVersionAsync(); + + return Ok(new + { + Available = version is not null, + Version = version + }); + } + [HttpPost("notifiarr")] public async Task CreateNotifiarrProvider([FromBody] CreateNotifiarrProviderRequest newProvider) { @@ -162,9 +177,11 @@ public sealed class NotificationProvidersController : ControllerBase var appriseConfig = new AppriseConfig { + Mode = newProvider.Mode, Url = newProvider.Url, Key = newProvider.Key, - Tags = newProvider.Tags + Tags = newProvider.Tags, + ServiceUrls = newProvider.ServiceUrls }; appriseConfig.Validate(); @@ -382,9 +399,11 @@ public sealed class NotificationProvidersController : ControllerBase var appriseConfig = new AppriseConfig { + Mode = updatedProvider.Mode, Url = updatedProvider.Url, Key = updatedProvider.Key, - Tags = updatedProvider.Tags + Tags = updatedProvider.Tags, + ServiceUrls = updatedProvider.ServiceUrls }; if (existingProvider.AppriseConfiguration != null) @@ -602,9 +621,11 @@ public sealed class NotificationProvidersController : ControllerBase { var appriseConfig = new AppriseConfig { + Mode = testRequest.Mode, Url = testRequest.Url, Key = testRequest.Key, - Tags = testRequest.Tags + Tags = testRequest.Tags, + ServiceUrls = testRequest.ServiceUrls }; appriseConfig.Validate(); @@ -629,6 +650,10 @@ public sealed class NotificationProvidersController : ControllerBase await _notificationService.SendTestNotificationAsync(providerDto); return Ok(new { Message = "Test notification sent successfully" }); } + catch (AppriseException exception) + { + return StatusCode((int)HttpStatusCode.InternalServerError, exception.Message); + } catch (Exception ex) { _logger.LogError(ex, "Failed to test Apprise provider"); diff --git a/code/backend/Cleanuparr.Domain/Enums/AppriseMode.cs b/code/backend/Cleanuparr.Domain/Enums/AppriseMode.cs new file mode 100644 index 00000000..df5e4d65 --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Enums/AppriseMode.cs @@ -0,0 +1,7 @@ +namespace Cleanuparr.Domain.Enums; + +public enum AppriseMode +{ + Api, + Cli +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/Apprise/AppriseCliDetectorTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/Apprise/AppriseCliDetectorTests.cs new file mode 100644 index 00000000..77528565 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/Apprise/AppriseCliDetectorTests.cs @@ -0,0 +1,34 @@ +using Cleanuparr.Infrastructure.Features.Notifications.Apprise; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Cleanuparr.Infrastructure.Tests.Features.Notifications.Apprise; + +public class AppriseCliDetectorTests +{ + private readonly AppriseCliDetector _detector; + + public AppriseCliDetectorTests() + { + _detector = new AppriseCliDetector(Substitute.For>()); + } + + [Fact] + public void Constructor_CreatesInstance() + { + // Act + var detector = new AppriseCliDetector(Substitute.For>()); + + // Assert + Assert.NotNull(detector); + } + + [Fact] + public async Task GetAppriseVersionAsync_DoesNotThrow() + { + // Act & Assert - should handle missing CLI gracefully without throwing + var exception = await Record.ExceptionAsync(() => _detector.GetAppriseVersionAsync()); + Assert.Null(exception); + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/Apprise/AppriseCliProxyTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/Apprise/AppriseCliProxyTests.cs new file mode 100644 index 00000000..7af4a7fa --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/Apprise/AppriseCliProxyTests.cs @@ -0,0 +1,73 @@ +using Cleanuparr.Infrastructure.Features.Notifications.Apprise; +using Cleanuparr.Persistence.Models.Configuration.Notification; +using Xunit; + +namespace Cleanuparr.Infrastructure.Tests.Features.Notifications.Apprise; + +public class AppriseCliProxyTests +{ + private readonly AppriseCliProxy _proxy; + + public AppriseCliProxyTests() + { + _proxy = new AppriseCliProxy(); + } + + private static ApprisePayload CreatePayload(string title = "Test Title", string body = "Test Body") + { + return new ApprisePayload + { + Title = title, + Body = body, + Type = "info" + }; + } + + private static AppriseConfig CreateConfig(string? serviceUrls = null) + { + return new AppriseConfig + { + ServiceUrls = serviceUrls + }; + } + + #region SendNotification Validation Tests + + [Fact] + public async Task SendNotification_WhenServiceUrlsIsNull_ThrowsAppriseException() + { + // Arrange + var config = CreateConfig(null); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => + _proxy.SendNotification(CreatePayload(), config)); + Assert.Contains("No service URLs configured", ex.Message); + } + + [Fact] + public async Task SendNotification_WhenServiceUrlsIsEmpty_ThrowsAppriseException() + { + // Arrange + var config = CreateConfig(""); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => + _proxy.SendNotification(CreatePayload(), config)); + Assert.Contains("No service URLs configured", ex.Message); + } + + [Fact] + public async Task SendNotification_WhenServiceUrlsIsWhitespace_ThrowsAppriseException() + { + // Arrange + var config = CreateConfig(" \n \n "); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => + _proxy.SendNotification(CreatePayload(), config)); + Assert.Contains("No service URLs configured", ex.Message); + } + + #endregion +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/AppriseProviderTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/AppriseProviderTests.cs index 987665d8..54269e59 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/AppriseProviderTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/AppriseProviderTests.cs @@ -9,16 +9,19 @@ namespace Cleanuparr.Infrastructure.Tests.Features.Notifications; public class AppriseProviderTests { - private readonly Mock _proxyMock; + private readonly Mock _apiProxyMock; + private readonly Mock _cliProxyMock; private readonly AppriseConfig _config; private readonly AppriseProvider _provider; public AppriseProviderTests() { - _proxyMock = new Mock(); + _apiProxyMock = new Mock(); + _cliProxyMock = new Mock(); _config = new AppriseConfig { Id = Guid.NewGuid(), + Mode = AppriseMode.Api, Url = "http://apprise.example.com", Key = "testkey", Tags = "tag1,tag2" @@ -28,7 +31,8 @@ public class AppriseProviderTests "TestApprise", NotificationProviderType.Apprise, _config, - _proxyMock.Object); + _apiProxyMock.Object, + _cliProxyMock.Object); } #region Constructor Tests @@ -58,7 +62,7 @@ public class AppriseProviderTests var context = CreateTestContext(); ApprisePayload? capturedPayload = null; - _proxyMock.Setup(p => p.SendNotification(It.IsAny(), _config)) + _apiProxyMock.Setup(p => p.SendNotification(It.IsAny(), _config)) .Callback((payload, config) => capturedPayload = payload) .Returns(Task.CompletedTask); @@ -81,7 +85,7 @@ public class AppriseProviderTests ApprisePayload? capturedPayload = null; - _proxyMock.Setup(p => p.SendNotification(It.IsAny(), _config)) + _apiProxyMock.Setup(p => p.SendNotification(It.IsAny(), _config)) .Callback((payload, config) => capturedPayload = payload) .Returns(Task.CompletedTask); @@ -112,7 +116,7 @@ public class AppriseProviderTests ApprisePayload? capturedPayload = null; - _proxyMock.Setup(p => p.SendNotification(It.IsAny(), _config)) + _apiProxyMock.Setup(p => p.SendNotification(It.IsAny(), _config)) .Callback((payload, config) => capturedPayload = payload) .Returns(Task.CompletedTask); @@ -131,7 +135,7 @@ public class AppriseProviderTests var context = CreateTestContext(); ApprisePayload? capturedPayload = null; - _proxyMock.Setup(p => p.SendNotification(It.IsAny(), _config)) + _apiProxyMock.Setup(p => p.SendNotification(It.IsAny(), _config)) .Callback((payload, config) => capturedPayload = payload) .Returns(Task.CompletedTask); @@ -149,7 +153,7 @@ public class AppriseProviderTests // Arrange var context = CreateTestContext(); - _proxyMock.Setup(p => p.SendNotification(It.IsAny(), _config)) + _apiProxyMock.Setup(p => p.SendNotification(It.IsAny(), _config)) .ThrowsAsync(new Exception("Proxy error")); // Act & Assert @@ -171,7 +175,7 @@ public class AppriseProviderTests ApprisePayload? capturedPayload = null; - _proxyMock.Setup(p => p.SendNotification(It.IsAny(), _config)) + _apiProxyMock.Setup(p => p.SendNotification(It.IsAny(), _config)) .Callback((payload, config) => capturedPayload = payload) .Returns(Task.CompletedTask); @@ -183,6 +187,40 @@ public class AppriseProviderTests Assert.Contains("Test Description", capturedPayload.Body); } + [Fact] + public async Task SendNotificationAsync_CliMode_CallsCliProxy() + { + // Arrange + var cliConfig = new AppriseConfig + { + Id = Guid.NewGuid(), + Mode = AppriseMode.Cli, + ServiceUrls = "discord://webhook_id/token" + }; + + var apiProxyMock = new Mock(); + var cliProxyMock = new Mock(); + + var provider = new AppriseProvider( + "TestAppriseCli", + NotificationProviderType.Apprise, + cliConfig, + apiProxyMock.Object, + cliProxyMock.Object); + + var context = CreateTestContext(); + + cliProxyMock.Setup(p => p.SendNotification(It.IsAny(), cliConfig)) + .Returns(Task.CompletedTask); + + // Act + await provider.SendNotificationAsync(context); + + // Assert + cliProxyMock.Verify(p => p.SendNotification(It.IsAny(), cliConfig), Times.Once); + apiProxyMock.Verify(p => p.SendNotification(It.IsAny(), It.IsAny()), Times.Never); + } + #endregion #region Helper Methods diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationProviderFactoryTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationProviderFactoryTests.cs index 95ac8155..eeb2045f 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationProviderFactoryTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationProviderFactoryTests.cs @@ -15,6 +15,7 @@ namespace Cleanuparr.Infrastructure.Tests.Features.Notifications; public class NotificationProviderFactoryTests { private readonly Mock _appriseProxyMock; + private readonly Mock _appriseCliProxyMock; private readonly Mock _ntfyProxyMock; private readonly Mock _notifiarrProxyMock; private readonly Mock _pushoverProxyMock; @@ -24,12 +25,14 @@ public class NotificationProviderFactoryTests public NotificationProviderFactoryTests() { _appriseProxyMock = new Mock(); + _appriseCliProxyMock = new Mock(); _ntfyProxyMock = new Mock(); _notifiarrProxyMock = new Mock(); _pushoverProxyMock = new Mock(); var services = new ServiceCollection(); services.AddSingleton(_appriseProxyMock.Object); + services.AddSingleton(_appriseCliProxyMock.Object); services.AddSingleton(_ntfyProxyMock.Object); services.AddSingleton(_notifiarrProxyMock.Object); services.AddSingleton(_pushoverProxyMock.Object); diff --git a/code/backend/Cleanuparr.Infrastructure/Cleanuparr.Infrastructure.csproj b/code/backend/Cleanuparr.Infrastructure/Cleanuparr.Infrastructure.csproj index c09561f5..b40f4b9a 100644 --- a/code/backend/Cleanuparr.Infrastructure/Cleanuparr.Infrastructure.csproj +++ b/code/backend/Cleanuparr.Infrastructure/Cleanuparr.Infrastructure.csproj @@ -7,6 +7,7 @@ + diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Apprise/AppriseCliDetector.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Apprise/AppriseCliDetector.cs new file mode 100644 index 00000000..424c2863 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Apprise/AppriseCliDetector.cs @@ -0,0 +1,38 @@ +using System.Text; +using CliWrap; +using Microsoft.Extensions.Logging; + +namespace Cleanuparr.Infrastructure.Features.Notifications.Apprise; + +public sealed class AppriseCliDetector : IAppriseCliDetector +{ + private readonly ILogger _logger; + + private static readonly TimeSpan DetectionTimeout = TimeSpan.FromSeconds(5); + + public AppriseCliDetector(ILogger logger) + { + _logger = logger; + } + + public async Task GetAppriseVersionAsync() + { + using var cts = new CancellationTokenSource(DetectionTimeout); + + try + { + StringBuilder version = new(); + _ = await Cli.Wrap("apprise") + .WithArguments("--version") + .WithStandardOutputPipe(PipeTarget.ToStringBuilder(version)) + .ExecuteAsync(cts.Token); + + return version.ToString().Split('\n').FirstOrDefault(); + } + catch (Exception exception) + { + _logger.LogError(exception, "Failed to get apprise version"); + return null; + } + } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Apprise/AppriseCliProxy.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Apprise/AppriseCliProxy.cs new file mode 100644 index 00000000..45560d76 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Apprise/AppriseCliProxy.cs @@ -0,0 +1,69 @@ +using System.Text; +using Cleanuparr.Persistence.Models.Configuration.Notification; +using CliWrap; + +namespace Cleanuparr.Infrastructure.Features.Notifications.Apprise; + +public sealed class AppriseCliProxy : IAppriseCliProxy +{ + private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30); + + public async Task SendNotification(ApprisePayload payload, AppriseConfig config) + { + var serviceUrls = config.ServiceUrls? + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(u => u.Trim()) + .Where(u => !string.IsNullOrEmpty(u)) + .ToArray(); + + if (serviceUrls == null || serviceUrls.Length == 0) + { + throw new AppriseException("No service URLs configured"); + } + + var args = new List { "--verbose" }; + + if (!string.IsNullOrEmpty(payload.Title)) + { + args.AddRange(["--title", payload.Title]); + } + + args.AddRange(["--body", payload.Body, "--notification-type", payload.Type]); + args.AddRange(serviceUrls); + + await ExecuteAppriseAsync(args); + } + + private static async Task ExecuteAppriseAsync(IEnumerable arguments) + { + using var cts = new CancellationTokenSource(DefaultTimeout); + StringBuilder message = new(); + + try + { + CommandResult result = await Cli.Wrap("apprise") + .WithArguments(arguments) + .WithStandardOutputPipe(PipeTarget.ToStringBuilder(message)) + .WithStandardErrorPipe(PipeTarget.ToStringBuilder(message)) + .WithValidation(CommandResultValidation.None) + .ExecuteAsync(cts.Token); + + if (!result.IsSuccess) + { + throw new AppriseException($"Apprise CLI failed with: {message}"); + } + } + catch (AppriseException) + { + throw; + } + catch (OperationCanceledException) + { + throw new AppriseException($"Apprise CLI timed out after {DefaultTimeout.TotalSeconds} seconds."); + } + catch (Exception exception) + { + throw new AppriseException("Apprise CLI failed", exception); + } + } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Apprise/AppriseProvider.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Apprise/AppriseProvider.cs index c5c9b0eb..05aed9b7 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Apprise/AppriseProvider.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Apprise/AppriseProvider.cs @@ -1,5 +1,4 @@ using Cleanuparr.Domain.Enums; -using Cleanuparr.Infrastructure.Features.Notifications.Apprise; using Cleanuparr.Infrastructure.Features.Notifications.Models; using Cleanuparr.Persistence.Models.Configuration.Notification; using System.Text; @@ -8,22 +7,33 @@ namespace Cleanuparr.Infrastructure.Features.Notifications.Apprise; public sealed class AppriseProvider : NotificationProviderBase { - private readonly IAppriseProxy _proxy; + private readonly IAppriseProxy _apiProxy; + private readonly IAppriseCliProxy _cliProxy; public AppriseProvider( string name, NotificationProviderType type, AppriseConfig config, - IAppriseProxy proxy + IAppriseProxy apiProxy, + IAppriseCliProxy cliProxy ) : base(name, type, config) { - _proxy = proxy; + _apiProxy = apiProxy; + _cliProxy = cliProxy; } public override async Task SendNotificationAsync(NotificationContext context) { ApprisePayload payload = BuildPayload(context); - await _proxy.SendNotification(payload, Config); + + if (Config.Mode is AppriseMode.Cli) + { + await _cliProxy.SendNotification(payload, Config); + } + else + { + await _apiProxy.SendNotification(payload, Config); + } } private ApprisePayload BuildPayload(NotificationContext context) @@ -51,7 +61,7 @@ public sealed class AppriseProvider : NotificationProviderBase var body = new StringBuilder(); body.AppendLine(context.Description); body.AppendLine(); - + foreach ((string key, string value) in context.Data) { body.AppendLine($"{key}: {value}"); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Apprise/IAppriseCliDetector.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Apprise/IAppriseCliDetector.cs new file mode 100644 index 00000000..0380449c --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Apprise/IAppriseCliDetector.cs @@ -0,0 +1,6 @@ +namespace Cleanuparr.Infrastructure.Features.Notifications.Apprise; + +public interface IAppriseCliDetector +{ + Task GetAppriseVersionAsync(); +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Apprise/IAppriseCliProxy.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Apprise/IAppriseCliProxy.cs new file mode 100644 index 00000000..c85261ef --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Apprise/IAppriseCliProxy.cs @@ -0,0 +1,8 @@ +using Cleanuparr.Persistence.Models.Configuration.Notification; + +namespace Cleanuparr.Infrastructure.Features.Notifications.Apprise; + +public interface IAppriseCliProxy +{ + Task SendNotification(ApprisePayload payload, AppriseConfig config); +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationProviderFactory.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationProviderFactory.cs index c4922444..aeca3126 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationProviderFactory.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationProviderFactory.cs @@ -41,9 +41,10 @@ public sealed class NotificationProviderFactory : INotificationProviderFactory private INotificationProvider CreateAppriseProvider(NotificationProviderDto config) { var appriseConfig = (AppriseConfig)config.Configuration; - var proxy = _serviceProvider.GetRequiredService(); - - return new AppriseProvider(config.Name, config.Type, appriseConfig, proxy); + var apiProxy = _serviceProvider.GetRequiredService(); + var cliProxy = _serviceProvider.GetRequiredService(); + + return new AppriseProvider(config.Name, config.Type, appriseConfig, apiProxy, cliProxy); } private INotificationProvider CreateNtfyProvider(NotificationProviderDto config) diff --git a/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/Notification/AppriseConfigTests.cs b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/Notification/AppriseConfigTests.cs index e375ae8f..8d2bbcad 100644 --- a/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/Notification/AppriseConfigTests.cs +++ b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/Notification/AppriseConfigTests.cs @@ -1,3 +1,4 @@ +using Cleanuparr.Domain.Enums; using Cleanuparr.Persistence.Models.Configuration.Notification; using Shouldly; using Xunit; @@ -142,7 +143,7 @@ public sealed class AppriseConfigTests }; var exception = Should.Throw(() => config.Validate()); - exception.Message.ShouldBe("Apprise server URL is required"); + exception.Message.ShouldBe("Apprise server URL is required for API mode"); } [Fact] @@ -171,7 +172,7 @@ public sealed class AppriseConfigTests }; var exception = Should.Throw(() => config.Validate()); - exception.Message.ShouldBe("Apprise configuration key is required"); + exception.Message.ShouldBe("Apprise configuration key is required for API mode"); } [Fact] @@ -200,4 +201,104 @@ public sealed class AppriseConfigTests } #endregion + + #region CLI Mode Tests + + [Fact] + public void IsValid_CliMode_WithValidServiceUrls_ReturnsTrue() + { + var config = new AppriseConfig + { + Mode = AppriseMode.Cli, + ServiceUrls = "discord://webhook_id/webhook_token" + }; + + config.IsValid().ShouldBeTrue(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void IsValid_CliMode_WithEmptyServiceUrls_ReturnsFalse(string? serviceUrls) + { + var config = new AppriseConfig + { + Mode = AppriseMode.Cli, + ServiceUrls = serviceUrls + }; + + config.IsValid().ShouldBeFalse(); + } + + [Fact] + public void Validate_CliMode_WithValidServiceUrls_DoesNotThrow() + { + var config = new AppriseConfig + { + Mode = AppriseMode.Cli, + ServiceUrls = "discord://webhook_id/webhook_token\nslack://token_a/token_b/token_c" + }; + + Should.NotThrow(() => config.Validate()); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Validate_CliMode_WithEmptyServiceUrls_ThrowsValidationException(string? serviceUrls) + { + var config = new AppriseConfig + { + Mode = AppriseMode.Cli, + ServiceUrls = serviceUrls + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("At least one service URL is required for CLI mode"); + } + + [Fact] + public void Validate_CliMode_WithValidUrlAndWhitespaceLines_DoesNotThrow() + { + // url1 is valid content, whitespace lines should be filtered out + var config = new AppriseConfig + { + Mode = AppriseMode.Cli, + ServiceUrls = "discord://webhook_id/token\n \n " + }; + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void IsValid_CliMode_IgnoresApiModeFields() + { + var config = new AppriseConfig + { + Mode = AppriseMode.Cli, + Url = string.Empty, // Would be invalid in API mode + Key = string.Empty, // Would be invalid in API mode + ServiceUrls = "discord://webhook_id/webhook_token" + }; + + config.IsValid().ShouldBeTrue(); + } + + [Fact] + public void IsValid_ApiMode_IgnoresCliModeFields() + { + var config = new AppriseConfig + { + Mode = AppriseMode.Api, + Url = "https://apprise.example.com", + Key = "my-key", + ServiceUrls = null // Would be invalid in CLI mode + }; + + config.IsValid().ShouldBeTrue(); + } + + #endregion } diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Data/20251213201344_AddAppriseCliMode.Designer.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/20251213201344_AddAppriseCliMode.Designer.cs new file mode 100644 index 00000000..bdcde53f --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/20251213201344_AddAppriseCliMode.Designer.cs @@ -0,0 +1,1101 @@ +๏ปฟ// +using System; +using System.Collections.Generic; +using Cleanuparr.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Cleanuparr.Persistence.Migrations.Data +{ + [DbContext(typeof(DataContext))] + [Migration("20251213201344_AddAppriseCliMode")] + partial class AddAppriseCliMode + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.6"); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("FailedImportMaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_max_strikes"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_arr_configs"); + + b.ToTable("arr_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("api_key"); + + b.Property("ArrConfigId") + .HasColumnType("TEXT") + .HasColumnName("arr_config_id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("url"); + + b.HasKey("Id") + .HasName("pk_arr_instances"); + + b.HasIndex("ArrConfigId") + .HasDatabaseName("ix_arr_instances_arr_config_id"); + + b.ToTable("arr_instances", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.BlacklistSync.BlacklistSyncConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("BlacklistPath") + .HasColumnType("TEXT") + .HasColumnName("blacklist_path"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.HasKey("Id") + .HasName("pk_blacklist_sync_configs"); + + b.ToTable("blacklist_sync_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.CleanCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DownloadCleanerConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_cleaner_config_id"); + + b.Property("MaxRatio") + .HasColumnType("REAL") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("REAL") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("REAL") + .HasColumnName("min_seed_time"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_clean_categories"); + + b.HasIndex("DownloadCleanerConfigId") + .HasDatabaseName("ix_clean_categories_download_cleaner_config_id"); + + b.ToTable("clean_categories", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("delete_private"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.PrimitiveCollection("UnlinkedCategories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("unlinked_categories"); + + b.Property("UnlinkedEnabled") + .HasColumnType("INTEGER") + .HasColumnName("unlinked_enabled"); + + b.Property("UnlinkedIgnoredRootDir") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("unlinked_ignored_root_dir"); + + b.Property("UnlinkedTargetCategory") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("unlinked_target_category"); + + b.Property("UnlinkedUseTag") + .HasColumnType("INTEGER") + .HasColumnName("unlinked_use_tag"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.HasKey("Id") + .HasName("pk_download_cleaner_configs"); + + b.ToTable("download_cleaner_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("Host") + .HasColumnType("TEXT") + .HasColumnName("host"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Password") + .HasColumnType("TEXT") + .HasColumnName("password"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.Property("TypeName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type_name"); + + b.Property("UrlBase") + .HasColumnType("TEXT") + .HasColumnName("url_base"); + + b.Property("Username") + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_download_clients"); + + b.ToTable("download_clients", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DisplaySupportBanner") + .HasColumnType("INTEGER") + .HasColumnName("display_support_banner"); + + b.Property("DryRun") + .HasColumnType("INTEGER") + .HasColumnName("dry_run"); + + b.Property("EncryptionKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("encryption_key"); + + b.Property("HttpCertificateValidation") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("http_certificate_validation"); + + b.Property("HttpMaxRetries") + .HasColumnType("INTEGER") + .HasColumnName("http_max_retries"); + + b.Property("HttpTimeout") + .HasColumnType("INTEGER") + .HasColumnName("http_timeout"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("SearchDelay") + .HasColumnType("INTEGER") + .HasColumnName("search_delay"); + + b.Property("SearchEnabled") + .HasColumnType("INTEGER") + .HasColumnName("search_enabled"); + + b.ComplexProperty>("Log", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Log#LoggingConfig", b1 => + { + b1.IsRequired(); + + b1.Property("ArchiveEnabled") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_enabled"); + + b1.Property("ArchiveRetainedCount") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_retained_count"); + + b1.Property("ArchiveTimeLimitHours") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_time_limit_hours"); + + b1.Property("Level") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("log_level"); + + b1.Property("RetainedFileCount") + .HasColumnType("INTEGER") + .HasColumnName("log_retained_file_count"); + + b1.Property("RollingSizeMB") + .HasColumnType("INTEGER") + .HasColumnName("log_rolling_size_mb"); + + b1.Property("TimeLimitHours") + .HasColumnType("INTEGER") + .HasColumnName("log_time_limit_hours"); + }); + + b.HasKey("Id") + .HasName("pk_general_configs"); + + b.ToTable("general_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("DeleteKnownMalware") + .HasColumnType("INTEGER") + .HasColumnName("delete_known_malware"); + + b.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("delete_private"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("IgnorePrivate") + .HasColumnType("INTEGER") + .HasColumnName("ignore_private"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.ComplexProperty>("Lidarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("lidarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("lidarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("lidarr_enabled"); + }); + + b.ComplexProperty>("Radarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("radarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("radarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("radarr_enabled"); + }); + + b.ComplexProperty>("Readarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("readarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("readarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("readarr_enabled"); + }); + + b.ComplexProperty>("Sonarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("sonarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("sonarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("sonarr_enabled"); + }); + + b.ComplexProperty>("Whisparr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("whisparr_blocklist_path"); + + b1.Property("BlocklistType") + .HasColumnType("INTEGER") + .HasColumnName("whisparr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("whisparr_enabled"); + }); + + b.HasKey("Id") + .HasName("pk_content_blocker_configs"); + + b.ToTable("content_blocker_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("key"); + + b.Property("Mode") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("mode"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("ServiceUrls") + .HasMaxLength(4000) + .HasColumnType("TEXT") + .HasColumnName("service_urls"); + + b.Property("Tags") + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("url"); + + b.HasKey("Id") + .HasName("pk_apprise_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_apprise_configs_notification_config_id"); + + b.ToTable("apprise_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("api_key"); + + b.Property("ChannelId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("channel_id"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.HasKey("Id") + .HasName("pk_notifiarr_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_notifiarr_configs_notification_config_id"); + + b.ToTable("notifiarr_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER") + .HasColumnName("is_enabled"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("OnCategoryChanged") + .HasColumnType("INTEGER") + .HasColumnName("on_category_changed"); + + b.Property("OnDownloadCleaned") + .HasColumnType("INTEGER") + .HasColumnName("on_download_cleaned"); + + b.Property("OnFailedImportStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_failed_import_strike"); + + b.Property("OnQueueItemDeleted") + .HasColumnType("INTEGER") + .HasColumnName("on_queue_item_deleted"); + + b.Property("OnSlowStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_slow_strike"); + + b.Property("OnStalledStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_stalled_strike"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_notification_configs"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_notification_configs_name"); + + b.ToTable("notification_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AccessToken") + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("access_token"); + + b.Property("AuthenticationType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("authentication_type"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Password") + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("password"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("priority"); + + b.Property("ServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("server_url"); + + b.PrimitiveCollection("Tags") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.PrimitiveCollection("Topics") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("topics"); + + b.Property("Username") + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_ntfy_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_ntfy_configs_notification_config_id"); + + b.ToTable("ntfy_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiToken") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("api_token"); + + b.Property("Devices") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("devices"); + + b.Property("Expire") + .HasColumnType("INTEGER") + .HasColumnName("expire"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("priority"); + + b.Property("Retry") + .HasColumnType("INTEGER") + .HasColumnName("retry"); + + b.Property("Sound") + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("sound"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("user_key"); + + b.HasKey("Id") + .HasName("pk_pushover_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_pushover_configs_notification_config_id"); + + b.ToTable("pushover_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("DownloadingMetadataMaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("downloading_metadata_max_strikes"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.ComplexProperty>("FailedImport", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.FailedImport#FailedImportConfig", b1 => + { + b1.IsRequired(); + + b1.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_delete_private"); + + b1.Property("IgnorePrivate") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_ignore_private"); + + b1.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_max_strikes"); + + b1.Property("PatternMode") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("failed_import_pattern_mode"); + + b1.PrimitiveCollection("Patterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("failed_import_patterns"); + + b1.Property("SkipIfNotFoundInClient") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_skip_if_not_found_in_client"); + }); + + b.HasKey("Id") + .HasName("pk_queue_cleaner_configs"); + + b.ToTable("queue_cleaner_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.SlowRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DeletePrivateTorrentsFromClient") + .HasColumnType("INTEGER") + .HasColumnName("delete_private_torrents_from_client"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("IgnoreAboveSize") + .HasColumnType("TEXT") + .HasColumnName("ignore_above_size"); + + b.Property("MaxCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("max_completion_percentage"); + + b.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("max_strikes"); + + b.Property("MaxTimeHours") + .HasColumnType("REAL") + .HasColumnName("max_time_hours"); + + b.Property("MinCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("min_completion_percentage"); + + b.Property("MinSpeed") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("min_speed"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("QueueCleanerConfigId") + .HasColumnType("TEXT") + .HasColumnName("queue_cleaner_config_id"); + + b.Property("ResetStrikesOnProgress") + .HasColumnType("INTEGER") + .HasColumnName("reset_strikes_on_progress"); + + b.HasKey("Id") + .HasName("pk_slow_rules"); + + b.HasIndex("QueueCleanerConfigId") + .HasDatabaseName("ix_slow_rules_queue_cleaner_config_id"); + + b.ToTable("slow_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.StallRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DeletePrivateTorrentsFromClient") + .HasColumnType("INTEGER") + .HasColumnName("delete_private_torrents_from_client"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("MaxCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("max_completion_percentage"); + + b.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("max_strikes"); + + b.Property("MinCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("min_completion_percentage"); + + b.Property("MinimumProgress") + .HasColumnType("TEXT") + .HasColumnName("minimum_progress"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("QueueCleanerConfigId") + .HasColumnType("TEXT") + .HasColumnName("queue_cleaner_config_id"); + + b.Property("ResetStrikesOnProgress") + .HasColumnType("INTEGER") + .HasColumnName("reset_strikes_on_progress"); + + b.HasKey("Id") + .HasName("pk_stall_rules"); + + b.HasIndex("QueueCleanerConfigId") + .HasDatabaseName("ix_stall_rules_queue_cleaner_config_id"); + + b.ToTable("stall_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.BlacklistSyncHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DownloadClientId") + .HasColumnType("TEXT") + .HasColumnName("download_client_id"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("hash"); + + b.HasKey("Id") + .HasName("pk_blacklist_sync_history"); + + b.HasIndex("DownloadClientId") + .HasDatabaseName("ix_blacklist_sync_history_download_client_id"); + + b.HasIndex("Hash") + .HasDatabaseName("ix_blacklist_sync_history_hash"); + + b.HasIndex("Hash", "DownloadClientId") + .IsUnique() + .HasDatabaseName("ix_blacklist_sync_history_hash_download_client_id"); + + b.ToTable("blacklist_sync_history", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", "ArrConfig") + .WithMany("Instances") + .HasForeignKey("ArrConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_arr_instances_arr_configs_arr_config_id"); + + b.Navigation("ArrConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.CleanCategory", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", "DownloadCleanerConfig") + .WithMany("Categories") + .HasForeignKey("DownloadCleanerConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_clean_categories_download_cleaner_configs_download_cleaner_config_id"); + + b.Navigation("DownloadCleanerConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("AppriseConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_apprise_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("NotifiarrConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_notifiarr_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("NtfyConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_ntfy_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("PushoverConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_pushover_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.SlowRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", "QueueCleanerConfig") + .WithMany("SlowRules") + .HasForeignKey("QueueCleanerConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_slow_rules_queue_cleaner_configs_queue_cleaner_config_id"); + + b.Navigation("QueueCleanerConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.StallRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", "QueueCleanerConfig") + .WithMany("StallRules") + .HasForeignKey("QueueCleanerConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_stall_rules_queue_cleaner_configs_queue_cleaner_config_id"); + + b.Navigation("QueueCleanerConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.BlacklistSyncHistory", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClient") + .WithMany() + .HasForeignKey("DownloadClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_blacklist_sync_history_download_clients_download_client_id"); + + b.Navigation("DownloadClient"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b => + { + b.Navigation("Instances"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b => + { + b.Navigation("Categories"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b => + { + b.Navigation("AppriseConfiguration"); + + b.Navigation("NotifiarrConfiguration"); + + b.Navigation("NtfyConfiguration"); + + b.Navigation("PushoverConfiguration"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b => + { + b.Navigation("SlowRules"); + + b.Navigation("StallRules"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Data/20251213201344_AddAppriseCliMode.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/20251213201344_AddAppriseCliMode.cs new file mode 100644 index 00000000..fa724fac --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/20251213201344_AddAppriseCliMode.cs @@ -0,0 +1,40 @@ +๏ปฟusing Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Cleanuparr.Persistence.Migrations.Data +{ + /// + public partial class AddAppriseCliMode : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "mode", + table: "apprise_configs", + type: "TEXT", + nullable: false, + defaultValue: "api"); + + migrationBuilder.AddColumn( + name: "service_urls", + table: "apprise_configs", + type: "TEXT", + maxLength: 4000, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "mode", + table: "apprise_configs"); + + migrationBuilder.DropColumn( + name: "service_urls", + table: "apprise_configs"); + } + } +} diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs index bb14a7cb..ebb36479 100644 --- a/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs +++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs @@ -483,10 +483,20 @@ namespace Cleanuparr.Persistence.Migrations.Data .HasColumnType("TEXT") .HasColumnName("key"); + b.Property("Mode") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("mode"); + b.Property("NotificationConfigId") .HasColumnType("TEXT") .HasColumnName("notification_config_id"); + b.Property("ServiceUrls") + .HasMaxLength(4000) + .HasColumnType("TEXT") + .HasColumnName("service_urls"); + b.Property("Tags") .HasMaxLength(255) .HasColumnType("TEXT") diff --git a/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/AppriseConfig.cs b/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/AppriseConfig.cs index 769dbec7..85fef08e 100644 --- a/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/AppriseConfig.cs +++ b/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/AppriseConfig.cs @@ -1,5 +1,7 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; +using System.Diagnostics.CodeAnalysis; +using Cleanuparr.Domain.Enums; using Cleanuparr.Persistence.Models.Configuration; using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException; @@ -10,23 +12,36 @@ public sealed record AppriseConfig : IConfig [Key] [DatabaseGenerated(DatabaseGeneratedOption.Identity)] public Guid Id { get; init; } = Guid.NewGuid(); - + [Required] + [ExcludeFromCodeCoverage] public Guid NotificationConfigId { get; init; } - + public NotificationConfig NotificationConfig { get; init; } = null!; - - [Required] + + /// + /// The mode of operation: Api (external apprise-api container) or Cli (bundled apprise CLI) + /// + public AppriseMode Mode { get; init; } = AppriseMode.Api; + + // API mode fields [MaxLength(500)] public string Url { get; init; } = string.Empty; - - [Required] + [MaxLength(255)] public string Key { get; init; } = string.Empty; - + [MaxLength(255)] public string? Tags { get; init; } - + + // CLI mode fields + /// + /// Apprise service URLs for CLI mode (one per line). + /// Example: discord://webhook_id/webhook_token + /// + [MaxLength(4000)] + public string? ServiceUrls { get; init; } + [NotMapped] public Uri? Uri { @@ -42,33 +57,56 @@ public sealed record AppriseConfig : IConfig } } } - + public bool IsValid() { - return Uri != null && - !string.IsNullOrWhiteSpace(Key); + return Mode switch + { + AppriseMode.Api => Uri != null && !string.IsNullOrWhiteSpace(Key), + AppriseMode.Cli => !string.IsNullOrWhiteSpace(ServiceUrls), + _ => false + }; } - + public void Validate() + { + if (Mode is AppriseMode.Api) + { + ValidateApiMode(); + return; + } + + ValidateCliMode(); + } + + private void ValidateApiMode() { if (string.IsNullOrWhiteSpace(Url)) { - throw new ValidationException("Apprise server URL is required"); + throw new ValidationException("Apprise server URL is required for API mode"); } - - if (Uri == null) + + if (Uri is null) { throw new ValidationException("Apprise server URL must be a valid HTTP or HTTPS URL"); } - + if (string.IsNullOrWhiteSpace(Key)) { - throw new ValidationException("Apprise configuration key is required"); + throw new ValidationException("Apprise configuration key is required for API mode"); } - + if (Key.Length < 2) { throw new ValidationException("Apprise configuration key must be at least 2 characters long"); } } + + private void ValidateCliMode() + { + if (string.IsNullOrWhiteSpace(ServiceUrls)) + { + throw new ValidationException("At least one service URL is required for CLI mode"); + } + } } diff --git a/code/frontend/src/app/core/services/documentation.service.ts b/code/frontend/src/app/core/services/documentation.service.ts index 85041309..2331b870 100644 --- a/code/frontend/src/app/core/services/documentation.service.ts +++ b/code/frontend/src/app/core/services/documentation.service.ts @@ -120,9 +120,11 @@ export class DocumentationService { 'notifiarr.channelId': 'channel-id' }, 'notifications/apprise': { + 'apprise.mode': 'mode', 'apprise.url': 'url', 'apprise.key': 'key', - 'apprise.tags': 'tags' + 'apprise.tags': 'tags', + 'apprise.serviceUrls': 'service-urls' }, 'notifications/ntfy': { 'ntfy.serverUrl': 'server-url', diff --git a/code/frontend/src/app/core/services/notification-provider.service.ts b/code/frontend/src/app/core/services/notification-provider.service.ts index 171eee6d..ffa346e4 100644 --- a/code/frontend/src/app/core/services/notification-provider.service.ts +++ b/code/frontend/src/app/core/services/notification-provider.service.ts @@ -7,11 +7,16 @@ import { NotificationProviderDto, TestNotificationResult } from '../../shared/models/notification-provider.model'; -import { NotificationProviderType } from '../../shared/models/enums'; +import { AppriseMode, NotificationProviderType } from '../../shared/models/enums'; import { NtfyAuthenticationType } from '../../shared/models/ntfy-authentication-type.enum'; import { NtfyPriority } from '../../shared/models/ntfy-priority.enum'; import { PushoverPriority } from '../../shared/models/pushover-priority.enum'; +export interface AppriseCliStatus { + available: boolean; + version: string | null; +} + // Provider-specific interfaces export interface CreateNotifiarrProviderRequest { name: string; @@ -53,9 +58,13 @@ export interface CreateAppriseProviderRequest { onQueueItemDeleted: boolean; onDownloadCleaned: boolean; onCategoryChanged: boolean; + mode: AppriseMode; + // API mode fields url: string; key: string; tags: string; + // CLI mode fields + serviceUrls: string; } export interface UpdateAppriseProviderRequest { @@ -67,15 +76,23 @@ export interface UpdateAppriseProviderRequest { onQueueItemDeleted: boolean; onDownloadCleaned: boolean; onCategoryChanged: boolean; + mode: AppriseMode; + // API mode fields url: string; key: string; tags: string; + // CLI mode fields + serviceUrls: string; } export interface TestAppriseProviderRequest { + mode: AppriseMode; + // API mode fields url: string; key: string; tags: string; + // CLI mode fields + serviceUrls: string; } export interface CreateNtfyProviderRequest { @@ -191,6 +208,13 @@ export class NotificationProviderService { return this.http.get(this.baseUrl); } + /** + * Get Apprise CLI availability status + */ + getAppriseCliStatus(): Observable { + return this.http.get(`${this.baseUrl}/apprise/cli-status`); + } + /** * Create a new Notifiarr provider */ diff --git a/code/frontend/src/app/settings/notification-settings/modals/apprise-provider/apprise-provider.component.html b/code/frontend/src/app/settings/notification-settings/modals/apprise-provider/apprise-provider.component.html index 31dddd86..ac061129 100644 --- a/code/frontend/src/app/settings/notification-settings/modals/apprise-provider/apprise-provider.component.html +++ b/code/frontend/src/app/settings/notification-settings/modals/apprise-provider/apprise-provider.component.html @@ -10,67 +10,145 @@ >
- +
-
- -
- - - Configuration key is required - Key must be at least 2 characters - The key that identifies your Apprise configuration on the server. + +
+ + Detecting local Apprise version...
- -
- - - Optional tags to filter notifications. Use comma (,) to OR tags and space ( ) to AND them. -
+ + + Apprise CLI not detected.  + + Installation Guide + + + + + + + + + +
+ + + URL is required + Must be a valid URL + Must use http or https protocol + The URL of your Apprise server where notifications will be sent. +
+ + +
+ + + Configuration key is required + Key must be at least 2 characters + The key that identifies your Apprise configuration on the server. +
+ + +
+ + + Optional tags to filter notifications. Use comma (,) to OR tags and space ( ) to AND them. +
+
+ + + + +
+ + + + Add Apprise service URLs. Example: discord://webhook_id/token. + + View all supported services + + +
+
diff --git a/code/frontend/src/app/settings/notification-settings/modals/apprise-provider/apprise-provider.component.scss b/code/frontend/src/app/settings/notification-settings/modals/apprise-provider/apprise-provider.component.scss index 82b17244..e4c8ff1d 100644 --- a/code/frontend/src/app/settings/notification-settings/modals/apprise-provider/apprise-provider.component.scss +++ b/code/frontend/src/app/settings/notification-settings/modals/apprise-provider/apprise-provider.component.scss @@ -1,2 +1,10 @@ /* Apprise Provider Modal Styles */ @use '../../../styles/settings-shared.scss'; + +.cli-detection-loading { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--text-color-secondary); + font-size: 0.875rem; +} diff --git a/code/frontend/src/app/settings/notification-settings/modals/apprise-provider/apprise-provider.component.ts b/code/frontend/src/app/settings/notification-settings/modals/apprise-provider/apprise-provider.component.ts index 63630862..2b70c027 100644 --- a/code/frontend/src/app/settings/notification-settings/modals/apprise-provider/apprise-provider.component.ts +++ b/code/frontend/src/app/settings/notification-settings/modals/apprise-provider/apprise-provider.component.ts @@ -1,12 +1,19 @@ -import { Component, Input, Output, EventEmitter, OnInit, OnDestroy, OnChanges, SimpleChanges, inject } from '@angular/core'; +import { Component, Input, Output, EventEmitter, OnInit, OnChanges, OnDestroy, SimpleChanges, inject } from '@angular/core'; import { FormControl, Validators, ReactiveFormsModule } from '@angular/forms'; +import { Subscription } from 'rxjs'; import { CommonModule } from '@angular/common'; import { InputTextModule } from 'primeng/inputtext'; +import { MobileAutocompleteComponent } from '../../../../shared/components/mobile-autocomplete/mobile-autocomplete.component'; +import { SelectModule } from 'primeng/select'; +import { Message } from 'primeng/message'; +import { ProgressSpinnerModule } from 'primeng/progressspinner'; import { AppriseFormData, BaseProviderFormData } from '../../models/provider-modal.model'; import { DocumentationService } from '../../../../core/services/documentation.service'; +import { NotificationProviderService } from '../../../../core/services/notification-provider.service'; import { NotificationProviderDto } from '../../../../shared/models/notification-provider.model'; import { NotificationProviderBaseComponent } from '../base/notification-provider-base.component'; import { UrlValidators } from '../../../../core/validators/url.validator'; +import { AppriseMode } from '../../../../shared/models/enums'; @Component({ selector: 'app-apprise-provider', @@ -15,12 +22,16 @@ import { UrlValidators } from '../../../../core/validators/url.validator'; CommonModule, ReactiveFormsModule, InputTextModule, + SelectModule, + Message, + ProgressSpinnerModule, + MobileAutocompleteComponent, NotificationProviderBaseComponent ], templateUrl: './apprise-provider.component.html', styleUrls: ['./apprise-provider.component.scss'] }) -export class AppriseProviderComponent implements OnInit, OnChanges { +export class AppriseProviderComponent implements OnInit, OnChanges, OnDestroy { @Input() visible = false; @Input() editingProvider: NotificationProviderDto | null = null; @Input() saving = false; @@ -30,11 +41,32 @@ export class AppriseProviderComponent implements OnInit, OnChanges { @Output() cancel = new EventEmitter(); @Output() test = new EventEmitter(); - // Provider-specific form controls + private documentationService = inject(DocumentationService); + private notificationProviderService = inject(NotificationProviderService); + + // Mode selection + modeControl = new FormControl(AppriseMode.Api, { nonNullable: true }); + modeOptions = [ + { label: 'API', value: AppriseMode.Api }, + { label: 'CLI', value: AppriseMode.Cli } + ]; + + // CLI availability status + checkingCliAvailability = false; + cliAvailable = false; + cliVersion: string | null = null; + + // API mode form controls urlControl = new FormControl('', [Validators.required, UrlValidators.httpUrl]); keyControl = new FormControl('', [Validators.required, Validators.minLength(2)]); tagsControl = new FormControl(''); // Optional field - private documentationService = inject(DocumentationService); + + // CLI mode form controls + serviceUrlsControl = new FormControl([]); + + // Subscription for mode changes + private modeSubscription?: Subscription; + private cliCheckedThisSession = false; /** * Exposed for template to open documentation for apprise fields @@ -44,7 +76,16 @@ export class AppriseProviderComponent implements OnInit, OnChanges { } ngOnInit(): void { - // Initialize component but don't populate yet - wait for ngOnChanges + // Subscribe to mode changes to check CLI availability when switching to CLI mode + this.modeSubscription = this.modeControl.valueChanges.subscribe((mode) => { + if (mode === AppriseMode.Cli && !this.cliCheckedThisSession) { + this.checkCliAvailability(); + } + }); + } + + ngOnDestroy(): void { + this.modeSubscription?.unsubscribe(); } ngOnChanges(changes: SimpleChanges): void { @@ -57,41 +98,106 @@ export class AppriseProviderComponent implements OnInit, OnChanges { this.resetProviderFields(); } } + + // When modal becomes visible, reset the CLI check flag and check if already in CLI mode + if (changes['visible'] && this.visible) { + this.cliCheckedThisSession = false; + // Only check CLI availability if mode is already CLI (editing existing CLI provider) + if (this.modeControl.value === AppriseMode.Cli) { + this.checkCliAvailability(); + } + } + } + + private checkCliAvailability(): void { + this.checkingCliAvailability = true; + this.cliCheckedThisSession = true; + this.notificationProviderService.getAppriseCliStatus().subscribe({ + next: (status) => { + this.cliAvailable = status.available; + this.cliVersion = status.version; + this.checkingCliAvailability = false; + }, + error: () => { + this.cliAvailable = false; + this.cliVersion = null; + this.checkingCliAvailability = false; + } + }); } private populateProviderFields(): void { if (this.editingProvider) { const config = this.editingProvider.configuration as any; - + + this.modeControl.setValue(config?.mode || AppriseMode.Api); + // API mode fields this.urlControl.setValue(config?.url || ''); this.keyControl.setValue(config?.key || ''); this.tagsControl.setValue(config?.tags || ''); + // CLI mode fields - convert newline-separated string to array + const serviceUrlsString = config?.serviceUrls || ''; + const serviceUrlsArray = serviceUrlsString + .split('\n') + .map((url: string) => url.trim()) + .filter((url: string) => url.length > 0); + this.serviceUrlsControl.setValue(serviceUrlsArray); } } private resetProviderFields(): void { + this.modeControl.setValue(AppriseMode.Api); this.urlControl.setValue(''); this.keyControl.setValue(''); this.tagsControl.setValue(''); + this.serviceUrlsControl.setValue([]); } protected hasFieldError(control: FormControl, errorType: string): boolean { return !!(control && control.errors?.[errorType] && (control.dirty || control.touched)); } - onSave(baseData: BaseProviderFormData): void { - if (this.urlControl.valid && this.keyControl.valid) { - const appriseData: AppriseFormData = { - ...baseData, - url: this.urlControl.value || '', - key: this.keyControl.value || '', - tags: this.tagsControl.value || '' - }; - this.save.emit(appriseData); + private isFormValid(): boolean { + const mode = this.modeControl.value; + if (mode === AppriseMode.Api) { + return this.urlControl.valid && this.keyControl.valid; } else { - // Mark provider-specific fields as touched to show validation errors + // CLI mode requires at least one service URL + const serviceUrls = this.serviceUrlsControl.value || []; + return serviceUrls.length > 0; + } + } + + private markFieldsTouched(): void { + const mode = this.modeControl.value; + if (mode === AppriseMode.Api) { this.urlControl.markAsTouched(); this.keyControl.markAsTouched(); + } else { + this.serviceUrlsControl.markAsTouched(); + } + } + + private buildFormData(baseData: BaseProviderFormData): AppriseFormData { + // Convert array to newline-separated string for backend + const serviceUrlsArray = this.serviceUrlsControl.value || []; + const serviceUrlsString = serviceUrlsArray.join('\n'); + + return { + ...baseData, + mode: this.modeControl.value, + url: this.urlControl.value || '', + key: this.keyControl.value || '', + tags: this.tagsControl.value || '', + serviceUrls: serviceUrlsString + }; + } + + onSave(baseData: BaseProviderFormData): void { + if (this.isFormValid()) { + this.save.emit(this.buildFormData(baseData)); + } else { + this.markFieldsTouched(); } } @@ -100,20 +206,10 @@ export class AppriseProviderComponent implements OnInit, OnChanges { } onTest(baseData: BaseProviderFormData): void { - if (this.urlControl.valid && this.keyControl.valid) { - const appriseData: AppriseFormData = { - ...baseData, - url: this.urlControl.value || '', - key: this.keyControl.value || '', - tags: this.tagsControl.value || '' - }; - this.test.emit(appriseData); + if (this.isFormValid()) { + this.test.emit(this.buildFormData(baseData)); } else { - // Mark provider-specific fields as touched to show validation errors - this.urlControl.markAsTouched(); - this.keyControl.markAsTouched(); + this.markFieldsTouched(); } } - - // URL validation delegated to shared UrlValidators.httpUrl } diff --git a/code/frontend/src/app/settings/notification-settings/modals/base/notification-provider-base.component.html b/code/frontend/src/app/settings/notification-settings/modals/base/notification-provider-base.component.html index 916f52e3..0e1f29a1 100644 --- a/code/frontend/src/app/settings/notification-settings/modals/base/notification-provider-base.component.html +++ b/code/frontend/src/app/settings/notification-settings/modals/base/notification-provider-base.component.html @@ -4,7 +4,7 @@ [closable]="true" [draggable]="false" [resizable]="false" - styleClass="instance-modal" + styleClass="notification-provider-modal" [header]="modalTitle" (onHide)="onCancel()" > @@ -111,7 +111,7 @@ -
+
@@ -190,24 +177,11 @@ > Tags (Optional) - - - - - - Optional tags to add to notifications (e.g., warning, alert). Press Enter or comma to add each tag. + Optional tags to add to notifications (e.g., warning, alert). Press Enter or click + to add each tag.
diff --git a/code/frontend/src/app/settings/notification-settings/modals/ntfy-provider/ntfy-provider.component.ts b/code/frontend/src/app/settings/notification-settings/modals/ntfy-provider/ntfy-provider.component.ts index 3cfd877a..203bbc20 100644 --- a/code/frontend/src/app/settings/notification-settings/modals/ntfy-provider/ntfy-provider.component.ts +++ b/code/frontend/src/app/settings/notification-settings/modals/ntfy-provider/ntfy-provider.component.ts @@ -1,8 +1,7 @@ -import { Component, Input, Output, EventEmitter, OnInit, OnDestroy, OnChanges, SimpleChanges, inject } from '@angular/core'; +import { Component, Input, Output, EventEmitter, OnInit, OnChanges, SimpleChanges, inject } from '@angular/core'; import { FormControl, Validators, ReactiveFormsModule } from '@angular/forms'; import { CommonModule } from '@angular/common'; import { InputTextModule } from 'primeng/inputtext'; -import { AutoCompleteModule } from 'primeng/autocomplete'; import { SelectModule } from 'primeng/select'; import { MobileAutocompleteComponent } from '../../../../shared/components/mobile-autocomplete/mobile-autocomplete.component'; import { NtfyFormData, BaseProviderFormData } from '../../models/provider-modal.model'; @@ -20,7 +19,6 @@ import { NtfyPriority } from '../../../../shared/models/ntfy-priority.enum'; CommonModule, ReactiveFormsModule, InputTextModule, - AutoCompleteModule, SelectModule, MobileAutocompleteComponent, NotificationProviderBaseComponent diff --git a/code/frontend/src/app/settings/notification-settings/models/provider-modal.model.ts b/code/frontend/src/app/settings/notification-settings/models/provider-modal.model.ts index 529308b3..ce96a16f 100644 --- a/code/frontend/src/app/settings/notification-settings/models/provider-modal.model.ts +++ b/code/frontend/src/app/settings/notification-settings/models/provider-modal.model.ts @@ -1,4 +1,4 @@ -import { NotificationProviderType } from '../../../shared/models/enums'; +import { AppriseMode, NotificationProviderType } from '../../../shared/models/enums'; import { NtfyAuthenticationType } from '../../../shared/models/ntfy-authentication-type.enum'; import { NtfyPriority } from '../../../shared/models/ntfy-priority.enum'; import { PushoverPriority } from '../../../shared/models/pushover-priority.enum'; @@ -34,9 +34,13 @@ export interface NotifiarrFormData extends BaseProviderFormData { } export interface AppriseFormData extends BaseProviderFormData { + mode: AppriseMode; + // API mode fields url: string; key: string; tags: string; + // CLI mode fields + serviceUrls: string; } export interface NtfyFormData extends BaseProviderFormData { diff --git a/code/frontend/src/app/settings/notification-settings/notification-settings.component.ts b/code/frontend/src/app/settings/notification-settings/notification-settings.component.ts index 185a8bb1..70162fc2 100644 --- a/code/frontend/src/app/settings/notification-settings/notification-settings.component.ts +++ b/code/frontend/src/app/settings/notification-settings/notification-settings.component.ts @@ -276,9 +276,11 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea case NotificationProviderType.Apprise: const appriseConfig = provider.configuration as any; testRequest = { - url: appriseConfig.url, - key: appriseConfig.key, + mode: appriseConfig.mode, + url: appriseConfig.url || "", + key: appriseConfig.key || "", tags: appriseConfig.tags || "", + serviceUrls: appriseConfig.serviceUrls || "", }; break; case NotificationProviderType.Ntfy: @@ -410,9 +412,11 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea */ onAppriseTest(data: AppriseFormData): void { const testRequest = { + mode: data.mode, url: data.url, key: data.key, tags: data.tags, + serviceUrls: data.serviceUrls, }; this.notificationProviderStore.testProvider({ @@ -570,9 +574,11 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea onQueueItemDeleted: data.onQueueItemDeleted, onDownloadCleaned: data.onDownloadCleaned, onCategoryChanged: data.onCategoryChanged, + mode: data.mode, url: data.url, key: data.key, tags: data.tags, + serviceUrls: data.serviceUrls, }; this.notificationProviderStore.createProvider({ @@ -597,9 +603,11 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea onQueueItemDeleted: data.onQueueItemDeleted, onDownloadCleaned: data.onDownloadCleaned, onCategoryChanged: data.onCategoryChanged, + mode: data.mode, url: data.url, key: data.key, tags: data.tags, + serviceUrls: data.serviceUrls, }; this.notificationProviderStore.updateProvider({ diff --git a/code/frontend/src/app/shared/models/apprise-config.model.ts b/code/frontend/src/app/shared/models/apprise-config.model.ts index 3964c2d2..33ad31b7 100644 --- a/code/frontend/src/app/shared/models/apprise-config.model.ts +++ b/code/frontend/src/app/shared/models/apprise-config.model.ts @@ -1,7 +1,12 @@ import { NotificationConfig } from './notification-config.model'; +import { AppriseMode } from './enums'; export interface AppriseConfig extends NotificationConfig { + mode: AppriseMode; + // API mode fields url?: string; key?: string; tags?: string; + // CLI mode fields + serviceUrls?: string; } diff --git a/code/frontend/src/app/shared/models/enums.ts b/code/frontend/src/app/shared/models/enums.ts index 00d510b5..c75b70a3 100644 --- a/code/frontend/src/app/shared/models/enums.ts +++ b/code/frontend/src/app/shared/models/enums.ts @@ -15,4 +15,9 @@ export enum NotificationProviderType { Apprise = "Apprise", Ntfy = "Ntfy", Pushover = "Pushover", +} + +export enum AppriseMode { + Api = "Api", + Cli = "Cli", } \ No newline at end of file diff --git a/docs/docs/configuration/notifications/apprise.mdx b/docs/docs/configuration/notifications/apprise.mdx index 48e793c8..7bb47897 100644 --- a/docs/docs/configuration/notifications/apprise.mdx +++ b/docs/docs/configuration/notifications/apprise.mdx @@ -25,9 +25,30 @@ Apprise is a universal notification library that supports over 80 different noti Configure Apprise to send notifications through any of its supported services.

+ + +Choose how to connect to Apprise: +- **API**: Requires an external [Apprise API](https://github.com/caronc/apprise-api) container running. Notifications are sent via HTTP requests to the Apprise server. +- **CLI**: Uses the Apprise CLI directly on the host machine. For Docker users, the CLI is pre-installed in the container. Non-Docker users must [install Apprise](https://github.com/caronc/apprise#installation) separately. + + + + + +
+ +API Mode + +

+ Configure settings for API mode. These settings are used when connecting to an external Apprise API server. +

+ The Apprise server URL where notification requests will be sent. @@ -54,4 +75,31 @@ Optionally notify only those tagged accordingly. Use a comma (,) to OR your tags
+
+ +CLI Mode + +

+ Configure settings for CLI mode. These settings are used when invoking the Apprise CLI directly. +

+ + + +Add Apprise service URLs that define where notifications will be sent. Each URL corresponds to a specific notification service. + +Examples: +- Discord: `discord://webhook_id/token` +- Slack: `slack://token_a/token_b/token_c` +- Telegram: `tgram://bot_token/chat_id` +- Email: `mailto://user:password@gmail.com` + +For a complete list of supported services and their URL formats, see the [Apprise Wiki](https://github.com/caronc/apprise/wiki#notification-services). + + + +
+ \ No newline at end of file