Compare commits

...

6 Commits

Author SHA1 Message Date
Flaminel
abf47fac6e fixed ntfy having redundant inputs 2025-12-18 18:55:33 +02:00
Flaminel
97e98aba47 improved Apprise arguments building 2025-12-16 22:21:29 +02:00
Flaminel
e1dc68eb85 fixed Dockerfile 2025-12-16 13:51:25 +02:00
Flaminel
f6230d295a removed some redundant code and added more tests 2025-12-16 13:42:33 +02:00
Flaminel
d79c88bc85 fixed notification modal styling 2025-12-16 13:00:03 +02:00
Flaminel
5aba4bb2c6 added Apprise CLI notification provider 2025-12-15 23:38:05 +02:00
37 changed files with 2062 additions and 175 deletions

View File

@@ -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 \

View File

@@ -12,6 +12,8 @@ public static class NotificationsDI
services
.AddScoped<INotifiarrProxy, NotifiarrProxy>()
.AddScoped<IAppriseProxy, AppriseProxy>()
.AddScoped<IAppriseCliProxy, AppriseCliProxy>()
.AddSingleton<IAppriseCliDetector, AppriseCliDetector>()
.AddScoped<INtfyProxy, NtfyProxy>()
.AddScoped<IPushoverProxy, PushoverProxy>()
.AddScoped<INotificationConfigurationService, NotificationConfigurationService>()

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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<NotificationProvidersController> 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<IActionResult> GetAppriseCliStatus()
{
string? version = await _appriseCliDetector.GetAppriseVersionAsync();
return Ok(new
{
Available = version is not null,
Version = version
});
}
[HttpPost("notifiarr")]
public async Task<IActionResult> 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", Success = true });
}
catch (AppriseException exception)
{
return StatusCode((int)HttpStatusCode.InternalServerError, exception.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Apprise provider");

View File

@@ -0,0 +1,7 @@
namespace Cleanuparr.Domain.Enums;
public enum AppriseMode
{
Api,
Cli
}

View File

@@ -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<ILogger<AppriseCliDetector>>());
}
[Fact]
public void Constructor_CreatesInstance()
{
// Act
var detector = new AppriseCliDetector(Substitute.For<ILogger<AppriseCliDetector>>());
// 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);
}
}

View File

@@ -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<AppriseException>(() =>
_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<AppriseException>(() =>
_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<AppriseException>(() =>
_proxy.SendNotification(CreatePayload(), config));
Assert.Contains("No service URLs configured", ex.Message);
}
#endregion
}

View File

@@ -9,16 +9,19 @@ namespace Cleanuparr.Infrastructure.Tests.Features.Notifications;
public class AppriseProviderTests
{
private readonly Mock<IAppriseProxy> _proxyMock;
private readonly Mock<IAppriseProxy> _apiProxyMock;
private readonly Mock<IAppriseCliProxy> _cliProxyMock;
private readonly AppriseConfig _config;
private readonly AppriseProvider _provider;
public AppriseProviderTests()
{
_proxyMock = new Mock<IAppriseProxy>();
_apiProxyMock = new Mock<IAppriseProxy>();
_cliProxyMock = new Mock<IAppriseCliProxy>();
_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<ApprisePayload>(), _config))
_apiProxyMock.Setup(p => p.SendNotification(It.IsAny<ApprisePayload>(), _config))
.Callback<ApprisePayload, AppriseConfig>((payload, config) => capturedPayload = payload)
.Returns(Task.CompletedTask);
@@ -81,7 +85,7 @@ public class AppriseProviderTests
ApprisePayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<ApprisePayload>(), _config))
_apiProxyMock.Setup(p => p.SendNotification(It.IsAny<ApprisePayload>(), _config))
.Callback<ApprisePayload, AppriseConfig>((payload, config) => capturedPayload = payload)
.Returns(Task.CompletedTask);
@@ -112,7 +116,7 @@ public class AppriseProviderTests
ApprisePayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<ApprisePayload>(), _config))
_apiProxyMock.Setup(p => p.SendNotification(It.IsAny<ApprisePayload>(), _config))
.Callback<ApprisePayload, AppriseConfig>((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<ApprisePayload>(), _config))
_apiProxyMock.Setup(p => p.SendNotification(It.IsAny<ApprisePayload>(), _config))
.Callback<ApprisePayload, AppriseConfig>((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<ApprisePayload>(), _config))
_apiProxyMock.Setup(p => p.SendNotification(It.IsAny<ApprisePayload>(), _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<ApprisePayload>(), _config))
_apiProxyMock.Setup(p => p.SendNotification(It.IsAny<ApprisePayload>(), _config))
.Callback<ApprisePayload, AppriseConfig>((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<IAppriseProxy>();
var cliProxyMock = new Mock<IAppriseCliProxy>();
var provider = new AppriseProvider(
"TestAppriseCli",
NotificationProviderType.Apprise,
cliConfig,
apiProxyMock.Object,
cliProxyMock.Object);
var context = CreateTestContext();
cliProxyMock.Setup(p => p.SendNotification(It.IsAny<ApprisePayload>(), cliConfig))
.Returns(Task.CompletedTask);
// Act
await provider.SendNotificationAsync(context);
// Assert
cliProxyMock.Verify(p => p.SendNotification(It.IsAny<ApprisePayload>(), cliConfig), Times.Once);
apiProxyMock.Verify(p => p.SendNotification(It.IsAny<ApprisePayload>(), It.IsAny<AppriseConfig>()), Times.Never);
}
#endregion
#region Helper Methods

View File

@@ -15,6 +15,7 @@ namespace Cleanuparr.Infrastructure.Tests.Features.Notifications;
public class NotificationProviderFactoryTests
{
private readonly Mock<IAppriseProxy> _appriseProxyMock;
private readonly Mock<IAppriseCliProxy> _appriseCliProxyMock;
private readonly Mock<INtfyProxy> _ntfyProxyMock;
private readonly Mock<INotifiarrProxy> _notifiarrProxyMock;
private readonly Mock<IPushoverProxy> _pushoverProxyMock;
@@ -24,12 +25,14 @@ public class NotificationProviderFactoryTests
public NotificationProviderFactoryTests()
{
_appriseProxyMock = new Mock<IAppriseProxy>();
_appriseCliProxyMock = new Mock<IAppriseCliProxy>();
_ntfyProxyMock = new Mock<INtfyProxy>();
_notifiarrProxyMock = new Mock<INotifiarrProxy>();
_pushoverProxyMock = new Mock<IPushoverProxy>();
var services = new ServiceCollection();
services.AddSingleton(_appriseProxyMock.Object);
services.AddSingleton(_appriseCliProxyMock.Object);
services.AddSingleton(_ntfyProxyMock.Object);
services.AddSingleton(_notifiarrProxyMock.Object);
services.AddSingleton(_pushoverProxyMock.Object);

View File

@@ -7,6 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CliWrap" Version="3.10.0" />
<PackageReference Include="FLM.QBittorrent" Version="1.0.2" />
<PackageReference Include="FLM.Transmission" Version="1.0.3" />
<PackageReference Include="Mapster" Version="7.4.0" />

View File

@@ -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<AppriseCliDetector> _logger;
private static readonly TimeSpan DetectionTimeout = TimeSpan.FromSeconds(5);
public AppriseCliDetector(ILogger<AppriseCliDetector> logger)
{
_logger = logger;
}
public async Task<string?> 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;
}
}
}

View File

@@ -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<string> { "--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<string> 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);
}
}
}

View File

@@ -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<AppriseConfig>
{
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<AppriseConfig>
var body = new StringBuilder();
body.AppendLine(context.Description);
body.AppendLine();
foreach ((string key, string value) in context.Data)
{
body.AppendLine($"{key}: {value}");

View File

@@ -0,0 +1,6 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Apprise;
public interface IAppriseCliDetector
{
Task<string?> GetAppriseVersionAsync();
}

View File

@@ -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);
}

View File

@@ -41,9 +41,10 @@ public sealed class NotificationProviderFactory : INotificationProviderFactory
private INotificationProvider CreateAppriseProvider(NotificationProviderDto config)
{
var appriseConfig = (AppriseConfig)config.Configuration;
var proxy = _serviceProvider.GetRequiredService<IAppriseProxy>();
return new AppriseProvider(config.Name, config.Type, appriseConfig, proxy);
var apiProxy = _serviceProvider.GetRequiredService<IAppriseProxy>();
var cliProxy = _serviceProvider.GetRequiredService<IAppriseCliProxy>();
return new AppriseProvider(config.Name, config.Type, appriseConfig, apiProxy, cliProxy);
}
private INotificationProvider CreateNtfyProvider(NotificationProviderDto config)

View File

@@ -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<ValidationException>(() => 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<ValidationException>(() => 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<ValidationException>(() => 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
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class AddAppriseCliMode : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "mode",
table: "apprise_configs",
type: "TEXT",
nullable: false,
defaultValue: "api");
migrationBuilder.AddColumn<string>(
name: "service_urls",
table: "apprise_configs",
type: "TEXT",
maxLength: 4000,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "mode",
table: "apprise_configs");
migrationBuilder.DropColumn(
name: "service_urls",
table: "apprise_configs");
}
}
}

View File

@@ -479,10 +479,20 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("key");
b.Property<string>("Mode")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("mode");
b.Property<Guid>("NotificationConfigId")
.HasColumnType("TEXT")
.HasColumnName("notification_config_id");
b.Property<string>("ServiceUrls")
.HasMaxLength(4000)
.HasColumnType("TEXT")
.HasColumnName("service_urls");
b.Property<string>("Tags")
.HasMaxLength(255)
.HasColumnType("TEXT")

View File

@@ -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]
/// <summary>
/// The mode of operation: Api (external apprise-api container) or Cli (bundled apprise CLI)
/// </summary>
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
/// <summary>
/// Apprise service URLs for CLI mode (one per line).
/// Example: discord://webhook_id/webhook_token
/// </summary>
[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");
}
}
}

View File

@@ -119,9 +119,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',

View File

@@ -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<NotificationProvidersConfig>(this.baseUrl);
}
/**
* Get Apprise CLI availability status
*/
getAppriseCliStatus(): Observable<AppriseCliStatus> {
return this.http.get<AppriseCliStatus>(`${this.baseUrl}/apprise/cli-status`);
}
/**
* Create a new Notifiarr provider
*/

View File

@@ -10,67 +10,145 @@
>
<!-- Provider-specific configuration goes here -->
<div slot="provider-config">
<!-- Apprise Server URL -->
<!-- Mode Selection -->
<div class="field">
<label for="full-url">
<label for="mode">
<i
class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('apprise.url')"
(click)="openFieldDocs('apprise.mode')"
></i>
Apprise Server URL *
Mode *
</label>
<input
id="full-url"
type="url"
pInputText
[formControl]="urlControl"
placeholder="http://localhost:8000"
<p-select
id="mode"
[options]="modeOptions"
[formControl]="modeControl"
optionLabel="label"
optionValue="value"
class="w-full"
/>
<small *ngIf="hasFieldError(urlControl, 'required')" class="form-error-text">URL is required</small>
<small *ngIf="hasFieldError(urlControl, 'invalidUri')" class="form-error-text">Must be a valid URL</small>
<small *ngIf="hasFieldError(urlControl, 'invalidProtocol')" class="form-error-text">Must use http or https protocol</small>
<small class="form-helper-text">The URL of your Apprise server where notifications will be sent.</small>
[showClear]="false"
></p-select>
<small class="form-helper-text">
API mode requires an external Apprise container. CLI mode uses the Apprise CLI directly, but requires installation for non-Docker users.
</small>
</div>
<!-- Configuration Key -->
<div class="field">
<label for="key">
<i
class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('apprise.key')"
></i>
Configuration Key *
</label>
<input id="key" type="text" pInputText [formControl]="keyControl" placeholder="my-config-key" class="w-full" />
<small *ngIf="hasFieldError(keyControl, 'required')" class="form-error-text">Configuration key is required</small>
<small *ngIf="hasFieldError(keyControl, 'minlength')" class="form-error-text">Key must be at least 2 characters</small>
<small class="form-helper-text">The key that identifies your Apprise configuration on the server.</small>
<!-- CLI Availability Loading -->
<div *ngIf="modeControl.value === 'Cli' && checkingCliAvailability" class="cli-detection-loading mb-3">
<p-progressSpinner styleClass="w-1rem h-1rem"></p-progressSpinner>
<span>Detecting local Apprise version...</span>
</div>
<!-- Tags -->
<div class="field">
<label for="tags">
<i
class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('apprise.tags')"
></i>
Tags (Optional)
</label>
<input
id="tags"
type="text"
pInputText
[formControl]="tagsControl"
placeholder="tag1,tag2 or tag3 tag4"
class="w-full"
/>
<small class="form-helper-text"
>Optional tags to filter notifications. Use comma (,) to OR tags and space ( ) to AND them.</small
>
</div>
<!-- CLI Availability Warning -->
<p-message
*ngIf="modeControl.value === 'Cli' && !checkingCliAvailability && !cliAvailable"
severity="warn"
styleClass="w-full mb-3"
>
Apprise CLI not detected.&nbsp;
<a href="https://github.com/caronc/apprise#installation" target="_blank" rel="noopener">
Installation Guide
</a>
</p-message>
<!-- CLI Available Info -->
<p-message
*ngIf="modeControl.value === 'Cli' && !checkingCliAvailability && cliAvailable && cliVersion"
severity="success"
styleClass="w-full mb-3"
[text]="'Apprise CLI detected: ' + cliVersion"
></p-message>
<!-- API Mode Fields -->
<ng-container *ngIf="modeControl.value === 'Api'">
<!-- Apprise Server URL -->
<div class="field">
<label for="full-url">
<i
class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('apprise.url')"
></i>
Apprise Server URL *
</label>
<input
id="full-url"
type="url"
pInputText
[formControl]="urlControl"
placeholder="http://localhost:8000"
class="w-full"
/>
<small *ngIf="hasFieldError(urlControl, 'required')" class="form-error-text">URL is required</small>
<small *ngIf="hasFieldError(urlControl, 'invalidUri')" class="form-error-text">Must be a valid URL</small>
<small *ngIf="hasFieldError(urlControl, 'invalidProtocol')" class="form-error-text">Must use http or https protocol</small>
<small class="form-helper-text">The URL of your Apprise server where notifications will be sent.</small>
</div>
<!-- Configuration Key -->
<div class="field">
<label for="key">
<i
class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('apprise.key')"
></i>
Configuration Key *
</label>
<input id="key" type="text" pInputText [formControl]="keyControl" placeholder="my-config-key" class="w-full" />
<small *ngIf="hasFieldError(keyControl, 'required')" class="form-error-text">Configuration key is required</small>
<small *ngIf="hasFieldError(keyControl, 'minlength')" class="form-error-text">Key must be at least 2 characters</small>
<small class="form-helper-text">The key that identifies your Apprise configuration on the server.</small>
</div>
<!-- Tags -->
<div class="field">
<label for="tags">
<i
class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('apprise.tags')"
></i>
Tags (Optional)
</label>
<input
id="tags"
type="text"
pInputText
[formControl]="tagsControl"
placeholder="tag1,tag2 or tag3 tag4"
class="w-full"
/>
<small class="form-helper-text"
>Optional tags to filter notifications. Use comma (,) to OR tags and space ( ) to AND them.</small
>
</div>
</ng-container>
<!-- CLI Mode Fields -->
<ng-container *ngIf="modeControl.value === 'Cli'">
<!-- Service URLs -->
<div class="field">
<label for="serviceUrls">
<i
class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('apprise.serviceUrls')"
></i>
Service URLs *
</label>
<app-mobile-autocomplete
[formControl]="serviceUrlsControl"
placeholder="Add service URL and press Enter"
></app-mobile-autocomplete>
<small class="form-helper-text">
Add Apprise service URLs. Example: discord://webhook_id/token.
<a href="https://github.com/caronc/apprise/wiki#notification-services" target="_blank" rel="noopener">
View all supported services
</a>
</small>
</div>
</ng-container>
</div>
</app-notification-provider-base>

View File

@@ -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;
}

View File

@@ -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<void>();
@Output() test = new EventEmitter<AppriseFormData>();
// Provider-specific form controls
private documentationService = inject(DocumentationService);
private notificationProviderService = inject(NotificationProviderService);
// Mode selection
modeControl = new FormControl<AppriseMode>(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<string[]>([]);
// 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
}

View File

@@ -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 @@
<!-- Modal Footer -->
<ng-template pTemplate="footer">
<div>
<div class="pt-3">
<button pButton type="button" label="Cancel" class="p-button-text" (click)="onCancel()"></button>
<button
pButton

View File

@@ -1,2 +1,6 @@
/* Base Notification Provider Modal Styles */
@use '../../../styles/settings-shared.scss';
::ng-deep .notification-provider-modal.p-dialog {
max-width: 600px !important;
min-width: 320px !important;
}

View File

@@ -44,26 +44,13 @@
></i>
Topics *
</label>
<!-- Mobile-friendly autocomplete (chips UI) -->
<app-mobile-autocomplete
[formControl]="topicsControl"
placeholder="Enter topic names"
></app-mobile-autocomplete>
<!-- Desktop autocomplete (allows multiple entries) -->
<p-autocomplete
id="topics"
[formControl]="topicsControl"
multiple
fluid
[typeahead]="false"
placeholder="Add a topic and press Enter"
class="desktop-only w-full"
></p-autocomplete>
<small *ngIf="hasFieldError(topicsControl, 'required')" class="form-error-text">At least one topic is required</small>
<small *ngIf="hasFieldError(topicsControl, 'minlength')" class="form-error-text">At least one topic is required</small>
<small class="form-helper-text">Enter the ntfy topics you want to publish to. Press Enter or comma to add each topic.</small>
<small class="form-helper-text">Enter the ntfy topics you want to publish to. Press Enter or click + to add each topic.</small>
</div>
<!-- Authentication Type -->
@@ -190,24 +177,11 @@
></i>
Tags (Optional)
</label>
<!-- Mobile-friendly autocomplete (chips UI) -->
<app-mobile-autocomplete
[formControl]="tagsControl"
placeholder="Enter tag names"
></app-mobile-autocomplete>
<!-- Desktop autocomplete (allows multiple entries) -->
<p-autocomplete
id="tags"
[formControl]="tagsControl"
multiple
fluid
[typeahead]="false"
placeholder="Add a tag and press Enter"
class="desktop-only w-full"
></p-autocomplete>
<small class="form-helper-text">Optional tags to add to notifications (e.g., warning, alert). Press Enter or comma to add each tag.</small>
<small class="form-helper-text">Optional tags to add to notifications (e.g., warning, alert). Press Enter or click + to add each tag.</small>
</div>
</div>
</app-notification-provider-base>

View File

@@ -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

View File

@@ -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 {

View File

@@ -281,9 +281,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:
@@ -415,9 +417,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({
@@ -575,9 +579,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({
@@ -602,9 +608,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({

View File

@@ -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;
}

View File

@@ -15,4 +15,9 @@ export enum NotificationProviderType {
Apprise = "Apprise",
Ntfy = "Ntfy",
Pushover = "Pushover",
}
export enum AppriseMode {
Api = "Api",
Cli = "Cli",
}

View File

@@ -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.
</p>
<ConfigSection
title="Mode"
icon="⚙️"
>
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.
</ConfigSection>
</div>
<div className={styles.section}>
<SectionTitle icon="🌐">API Mode</SectionTitle>
<p className={styles.sectionDescription}>
Configure settings for API mode. These settings are used when connecting to an external Apprise API server.
</p>
<ConfigSection
title="URL"
icon="🌐"
icon="🔗"
>
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
</div>
<div className={styles.section}>
<SectionTitle icon="💻">CLI Mode</SectionTitle>
<p className={styles.sectionDescription}>
Configure settings for CLI mode. These settings are used when invoking the Apprise CLI directly.
</p>
<ConfigSection
title="Service URLs"
icon="📋"
>
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).
</ConfigSection>
</div>
</div>