Compare commits

...

7 Commits

Author SHA1 Message Date
Flaminel
3a79e7778c added db migration 2026-01-14 10:52:13 +02:00
Flaminel
27141e7b8c added docs 2026-01-14 10:52:05 +02:00
Flaminel
31d36c71bb added Gotify notification provider 2026-01-14 10:51:56 +02:00
Flaminel
8bd6b86018 Add Discord notification provider (#417) 2026-01-13 18:53:40 +02:00
Flaminel
6abb542271 Fix Servarr version dropdown (#414) 2026-01-11 02:33:16 +02:00
Flaminel
2aceae3078 Fix package lock file being out of sync (#411) 2026-01-09 00:05:34 +02:00
Flaminel
65b200a68e Fix MacOS build (#409) 2026-01-07 02:57:52 +02:00
67 changed files with 5789 additions and 73 deletions

View File

@@ -21,12 +21,12 @@ jobs:
matrix:
include:
- arch: Intel
runner: macos-13
runner: macos-15-intel
runtime: osx-x64
min_os_version: "10.15"
artifact_suffix: intel
- arch: ARM
runner: macos-14
runner: macos-15
runtime: osx-arm64
min_os_version: "11.0"
artifact_suffix: arm64

View File

@@ -1,9 +1,11 @@
using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
using Cleanuparr.Infrastructure.Features.Notifications.Discord;
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
using Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
using Cleanuparr.Infrastructure.Features.Notifications.Pushover;
using Cleanuparr.Infrastructure.Features.Notifications.Telegram;
using Cleanuparr.Infrastructure.Features.Notifications.Gotify;
namespace Cleanuparr.Api.DependencyInjection;
@@ -18,6 +20,8 @@ public static class NotificationsDI
.AddScoped<INtfyProxy, NtfyProxy>()
.AddScoped<IPushoverProxy, PushoverProxy>()
.AddScoped<ITelegramProxy, TelegramProxy>()
.AddScoped<IDiscordProxy, DiscordProxy>()
.AddScoped<IGotifyProxy, GotifyProxy>()
.AddScoped<INotificationConfigurationService, NotificationConfigurationService>()
.AddScoped<INotificationProviderFactory, NotificationProviderFactory>()
.AddScoped<NotificationProviderFactory>()

View File

@@ -0,0 +1,10 @@
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record CreateDiscordProviderRequest : CreateNotificationProviderRequestBase
{
public string WebhookUrl { get; init; } = string.Empty;
public string Username { get; init; } = string.Empty;
public string AvatarUrl { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,10 @@
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record CreateGotifyProviderRequest : CreateNotificationProviderRequestBase
{
public string ServerUrl { get; init; } = string.Empty;
public string ApplicationToken { get; init; } = string.Empty;
public int Priority { get; init; } = 5;
}

View File

@@ -0,0 +1,10 @@
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record TestDiscordProviderRequest
{
public string WebhookUrl { get; init; } = string.Empty;
public string Username { get; init; } = string.Empty;
public string AvatarUrl { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,10 @@
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record TestGotifyProviderRequest
{
public string ServerUrl { get; init; } = string.Empty;
public string ApplicationToken { get; init; } = string.Empty;
public int Priority { get; init; } = 5;
}

View File

@@ -0,0 +1,10 @@
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record UpdateDiscordProviderRequest : UpdateNotificationProviderRequestBase
{
public string WebhookUrl { get; init; } = string.Empty;
public string Username { get; init; } = string.Empty;
public string AvatarUrl { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,10 @@
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record UpdateGotifyProviderRequest : UpdateNotificationProviderRequestBase
{
public string ServerUrl { get; init; } = string.Empty;
public string ApplicationToken { get; init; } = string.Empty;
public int Priority { get; init; } = 5;
}

View File

@@ -5,8 +5,10 @@ using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
using Cleanuparr.Infrastructure.Features.Notifications.Discord;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Infrastructure.Features.Notifications.Telegram;
using Cleanuparr.Infrastructure.Features.Notifications.Gotify;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Microsoft.AspNetCore.Mvc;
@@ -50,6 +52,8 @@ public sealed class NotificationProvidersController : ControllerBase
.Include(p => p.NtfyConfiguration)
.Include(p => p.PushoverConfiguration)
.Include(p => p.TelegramConfiguration)
.Include(p => p.DiscordConfiguration)
.Include(p => p.GotifyConfiguration)
.AsNoTracking()
.ToListAsync();
@@ -76,6 +80,8 @@ public sealed class NotificationProvidersController : ControllerBase
NotificationProviderType.Ntfy => p.NtfyConfiguration ?? new object(),
NotificationProviderType.Pushover => p.PushoverConfiguration ?? new object(),
NotificationProviderType.Telegram => p.TelegramConfiguration ?? new object(),
NotificationProviderType.Discord => p.DiscordConfiguration ?? new object(),
NotificationProviderType.Gotify => p.GotifyConfiguration ?? new object(),
_ => new object()
}
})
@@ -694,6 +700,8 @@ public sealed class NotificationProvidersController : ControllerBase
.Include(p => p.NtfyConfiguration)
.Include(p => p.PushoverConfiguration)
.Include(p => p.TelegramConfiguration)
.Include(p => p.DiscordConfiguration)
.Include(p => p.GotifyConfiguration)
.FirstOrDefaultAsync(p => p.Id == id);
if (existingProvider == null)
@@ -926,11 +934,201 @@ public sealed class NotificationProvidersController : ControllerBase
NotificationProviderType.Ntfy => provider.NtfyConfiguration ?? new object(),
NotificationProviderType.Pushover => provider.PushoverConfiguration ?? new object(),
NotificationProviderType.Telegram => provider.TelegramConfiguration ?? new object(),
NotificationProviderType.Discord => provider.DiscordConfiguration ?? new object(),
NotificationProviderType.Gotify => provider.GotifyConfiguration ?? new object(),
_ => new object()
}
};
}
[HttpPost("discord")]
public async Task<IActionResult> CreateDiscordProvider([FromBody] CreateDiscordProviderRequest newProvider)
{
await DataContext.Lock.WaitAsync();
try
{
if (string.IsNullOrWhiteSpace(newProvider.Name))
{
return BadRequest("Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs.CountAsync(x => x.Name == newProvider.Name);
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
}
var discordConfig = new DiscordConfig
{
WebhookUrl = newProvider.WebhookUrl,
Username = newProvider.Username,
AvatarUrl = newProvider.AvatarUrl
};
discordConfig.Validate();
var provider = new NotificationConfig
{
Name = newProvider.Name,
Type = NotificationProviderType.Discord,
IsEnabled = newProvider.IsEnabled,
OnFailedImportStrike = newProvider.OnFailedImportStrike,
OnStalledStrike = newProvider.OnStalledStrike,
OnSlowStrike = newProvider.OnSlowStrike,
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
OnDownloadCleaned = newProvider.OnDownloadCleaned,
OnCategoryChanged = newProvider.OnCategoryChanged,
DiscordConfiguration = discordConfig
};
_dataContext.NotificationConfigs.Add(provider);
await _dataContext.SaveChangesAsync();
await _notificationConfigurationService.InvalidateCacheAsync();
var providerDto = MapProvider(provider);
return CreatedAtAction(nameof(GetNotificationProviders), new { id = provider.Id }, providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create Discord provider");
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("discord/{id:guid}")]
public async Task<IActionResult> UpdateDiscordProvider(Guid id, [FromBody] UpdateDiscordProviderRequest updatedProvider)
{
await DataContext.Lock.WaitAsync();
try
{
var existingProvider = await _dataContext.NotificationConfigs
.Include(p => p.DiscordConfiguration)
.FirstOrDefaultAsync(p => p.Id == id && p.Type == NotificationProviderType.Discord);
if (existingProvider == null)
{
return NotFound($"Discord provider with ID {id} not found");
}
if (string.IsNullOrWhiteSpace(updatedProvider.Name))
{
return BadRequest("Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs
.Where(x => x.Id != id)
.Where(x => x.Name == updatedProvider.Name)
.CountAsync();
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
}
var discordConfig = new DiscordConfig
{
WebhookUrl = updatedProvider.WebhookUrl,
Username = updatedProvider.Username,
AvatarUrl = updatedProvider.AvatarUrl
};
if (existingProvider.DiscordConfiguration != null)
{
discordConfig = discordConfig with { Id = existingProvider.DiscordConfiguration.Id };
}
discordConfig.Validate();
var newProvider = existingProvider with
{
Name = updatedProvider.Name,
IsEnabled = updatedProvider.IsEnabled,
OnFailedImportStrike = updatedProvider.OnFailedImportStrike,
OnStalledStrike = updatedProvider.OnStalledStrike,
OnSlowStrike = updatedProvider.OnSlowStrike,
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
OnCategoryChanged = updatedProvider.OnCategoryChanged,
DiscordConfiguration = discordConfig,
UpdatedAt = DateTime.UtcNow
};
_dataContext.NotificationConfigs.Remove(existingProvider);
_dataContext.NotificationConfigs.Add(newProvider);
await _dataContext.SaveChangesAsync();
await _notificationConfigurationService.InvalidateCacheAsync();
var providerDto = MapProvider(newProvider);
return Ok(providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update Discord provider with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPost("discord/test")]
public async Task<IActionResult> TestDiscordProvider([FromBody] TestDiscordProviderRequest testRequest)
{
try
{
var discordConfig = new DiscordConfig
{
WebhookUrl = testRequest.WebhookUrl,
Username = testRequest.Username,
AvatarUrl = testRequest.AvatarUrl
};
discordConfig.Validate();
var providerDto = new NotificationProviderDto
{
Id = Guid.NewGuid(),
Name = "Test Provider",
Type = NotificationProviderType.Discord,
IsEnabled = true,
Events = new NotificationEventFlags
{
OnFailedImportStrike = true,
OnStalledStrike = false,
OnSlowStrike = false,
OnQueueItemDeleted = false,
OnDownloadCleaned = false,
OnCategoryChanged = false
},
Configuration = discordConfig
};
await _notificationService.SendTestNotificationAsync(providerDto);
return Ok(new { Message = "Test notification sent successfully" });
}
catch (DiscordException ex)
{
_logger.LogWarning(ex, "Failed to test Discord provider");
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Discord provider");
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
}
}
[HttpPost("pushover")]
public async Task<IActionResult> CreatePushoverProvider([FromBody] CreatePushoverProviderRequest newProvider)
{
@@ -1128,4 +1326,192 @@ public sealed class NotificationProvidersController : ControllerBase
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
}
}
[HttpPost("gotify")]
public async Task<IActionResult> CreateGotifyProvider([FromBody] CreateGotifyProviderRequest newProvider)
{
await DataContext.Lock.WaitAsync();
try
{
if (string.IsNullOrWhiteSpace(newProvider.Name))
{
return BadRequest("Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs.CountAsync(x => x.Name == newProvider.Name);
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
}
var gotifyConfig = new GotifyConfig
{
ServerUrl = newProvider.ServerUrl,
ApplicationToken = newProvider.ApplicationToken,
Priority = newProvider.Priority
};
gotifyConfig.Validate();
var provider = new NotificationConfig
{
Name = newProvider.Name,
Type = NotificationProviderType.Gotify,
IsEnabled = newProvider.IsEnabled,
OnFailedImportStrike = newProvider.OnFailedImportStrike,
OnStalledStrike = newProvider.OnStalledStrike,
OnSlowStrike = newProvider.OnSlowStrike,
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
OnDownloadCleaned = newProvider.OnDownloadCleaned,
OnCategoryChanged = newProvider.OnCategoryChanged,
GotifyConfiguration = gotifyConfig
};
_dataContext.NotificationConfigs.Add(provider);
await _dataContext.SaveChangesAsync();
await _notificationConfigurationService.InvalidateCacheAsync();
var providerDto = MapProvider(provider);
return CreatedAtAction(nameof(GetNotificationProviders), new { id = provider.Id }, providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create Gotify provider");
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("gotify/{id:guid}")]
public async Task<IActionResult> UpdateGotifyProvider(Guid id, [FromBody] UpdateGotifyProviderRequest updatedProvider)
{
await DataContext.Lock.WaitAsync();
try
{
var existingProvider = await _dataContext.NotificationConfigs
.Include(p => p.GotifyConfiguration)
.FirstOrDefaultAsync(p => p.Id == id && p.Type == NotificationProviderType.Gotify);
if (existingProvider == null)
{
return NotFound($"Gotify provider with ID {id} not found");
}
if (string.IsNullOrWhiteSpace(updatedProvider.Name))
{
return BadRequest("Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs
.Where(x => x.Id != id)
.Where(x => x.Name == updatedProvider.Name)
.CountAsync();
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
}
var gotifyConfig = new GotifyConfig
{
ServerUrl = updatedProvider.ServerUrl,
ApplicationToken = updatedProvider.ApplicationToken,
Priority = updatedProvider.Priority
};
if (existingProvider.GotifyConfiguration != null)
{
gotifyConfig = gotifyConfig with { Id = existingProvider.GotifyConfiguration.Id };
}
gotifyConfig.Validate();
var newProvider = existingProvider with
{
Name = updatedProvider.Name,
IsEnabled = updatedProvider.IsEnabled,
OnFailedImportStrike = updatedProvider.OnFailedImportStrike,
OnStalledStrike = updatedProvider.OnStalledStrike,
OnSlowStrike = updatedProvider.OnSlowStrike,
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
OnCategoryChanged = updatedProvider.OnCategoryChanged,
GotifyConfiguration = gotifyConfig,
UpdatedAt = DateTime.UtcNow
};
_dataContext.NotificationConfigs.Remove(existingProvider);
_dataContext.NotificationConfigs.Add(newProvider);
await _dataContext.SaveChangesAsync();
await _notificationConfigurationService.InvalidateCacheAsync();
var providerDto = MapProvider(newProvider);
return Ok(providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update Gotify provider with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPost("gotify/test")]
public async Task<IActionResult> TestGotifyProvider([FromBody] TestGotifyProviderRequest testRequest)
{
try
{
var gotifyConfig = new GotifyConfig
{
ServerUrl = testRequest.ServerUrl,
ApplicationToken = testRequest.ApplicationToken,
Priority = testRequest.Priority
};
gotifyConfig.Validate();
var providerDto = new NotificationProviderDto
{
Id = Guid.NewGuid(),
Name = "Test Provider",
Type = NotificationProviderType.Gotify,
IsEnabled = true,
Events = new NotificationEventFlags
{
OnFailedImportStrike = true,
OnStalledStrike = false,
OnSlowStrike = false,
OnQueueItemDeleted = false,
OnDownloadCleaned = false,
OnCategoryChanged = false
},
Configuration = gotifyConfig
};
await _notificationService.SendTestNotificationAsync(providerDto);
return Ok(new { Message = "Test notification sent successfully" });
}
catch (GotifyException ex)
{
_logger.LogWarning(ex, "Failed to test Gotify provider");
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Gotify provider");
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
}
}
}

View File

@@ -7,4 +7,6 @@ public enum NotificationProviderType
Ntfy,
Pushover,
Telegram,
Discord,
Gotify,
}

View File

@@ -0,0 +1,315 @@
using System.Net;
using Cleanuparr.Infrastructure.Features.Notifications.Discord;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Cleanuparr.Shared.Helpers;
using Microsoft.Extensions.Logging;
using Moq;
using Moq.Protected;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications.Discord;
public class DiscordProxyTests
{
private readonly Mock<ILogger<DiscordProxy>> _loggerMock;
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock;
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
public DiscordProxyTests()
{
_loggerMock = new Mock<ILogger<DiscordProxy>>();
_httpMessageHandlerMock = new Mock<HttpMessageHandler>();
_httpClientFactoryMock = new Mock<IHttpClientFactory>();
var httpClient = new HttpClient(_httpMessageHandlerMock.Object);
_httpClientFactoryMock
.Setup(f => f.CreateClient(Constants.HttpClientWithRetryName))
.Returns(httpClient);
}
private DiscordProxy CreateProxy()
{
return new DiscordProxy(_loggerMock.Object, _httpClientFactoryMock.Object);
}
private static DiscordPayload CreatePayload()
{
return new DiscordPayload
{
Embeds = new List<DiscordEmbed>
{
new()
{
Title = "Test Title",
Description = "Test Description",
Color = 0x28a745
}
}
};
}
private static DiscordConfig CreateConfig()
{
return new DiscordConfig
{
WebhookUrl = "https://discord.com/api/webhooks/123456789/abcdefghij",
Username = "Test Bot",
AvatarUrl = "https://example.com/avatar.png"
};
}
#region Constructor Tests
[Fact]
public void Constructor_WithValidDependencies_CreatesInstance()
{
// Act
var proxy = CreateProxy();
// Assert
Assert.NotNull(proxy);
}
[Fact]
public void Constructor_CreatesHttpClientWithCorrectName()
{
// Act
_ = CreateProxy();
// Assert
_httpClientFactoryMock.Verify(f => f.CreateClient(Constants.HttpClientWithRetryName), Times.Once);
}
#endregion
#region SendNotification Success Tests
[Fact]
public async Task SendNotification_WhenSuccessful_CompletesWithoutException()
{
// Arrange
var proxy = CreateProxy();
SetupSuccessResponse();
// Act & Assert - Should not throw
await proxy.SendNotification(CreatePayload(), CreateConfig());
}
[Fact]
public async Task SendNotification_SendsPostRequest()
{
// Arrange
var proxy = CreateProxy();
HttpMethod? capturedMethod = null;
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedMethod = req.Method)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
// Act
await proxy.SendNotification(CreatePayload(), CreateConfig());
// Assert
Assert.Equal(HttpMethod.Post, capturedMethod);
}
[Fact]
public async Task SendNotification_BuildsCorrectUrl()
{
// Arrange
var proxy = CreateProxy();
Uri? capturedUri = null;
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedUri = req.RequestUri)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
var config = new DiscordConfig
{
WebhookUrl = "https://discord.com/api/webhooks/123/abc"
};
// Act
await proxy.SendNotification(CreatePayload(), config);
// Assert
Assert.NotNull(capturedUri);
Assert.Equal("https://discord.com/api/webhooks/123/abc", capturedUri.ToString());
}
[Fact]
public async Task SendNotification_SetsJsonContentType()
{
// Arrange
var proxy = CreateProxy();
string? capturedContentType = null;
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) =>
capturedContentType = req.Content?.Headers.ContentType?.MediaType)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
// Act
await proxy.SendNotification(CreatePayload(), CreateConfig());
// Assert
Assert.Equal("application/json", capturedContentType);
}
[Fact]
public async Task SendNotification_LogsTraceWithContent()
{
// Arrange
var proxy = CreateProxy();
SetupSuccessResponse();
// Act
await proxy.SendNotification(CreatePayload(), CreateConfig());
// Assert
_loggerMock.Verify(
x => x.Log(
LogLevel.Trace,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("sending notification")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
#endregion
#region SendNotification Error Tests
[Theory]
[InlineData(HttpStatusCode.Unauthorized)]
[InlineData(HttpStatusCode.Forbidden)]
public async Task SendNotification_WhenUnauthorized_ThrowsDiscordExceptionWithInvalidWebhook(HttpStatusCode statusCode)
{
// Arrange
var proxy = CreateProxy();
SetupErrorResponse(statusCode);
// Act & Assert
var ex = await Assert.ThrowsAsync<DiscordException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("invalid or unauthorized", ex.Message);
}
[Fact]
public async Task SendNotification_When404_ThrowsDiscordExceptionWithNotFound()
{
// Arrange
var proxy = CreateProxy();
SetupErrorResponse(HttpStatusCode.NotFound);
// Act & Assert
var ex = await Assert.ThrowsAsync<DiscordException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("not found", ex.Message);
}
[Fact]
public async Task SendNotification_When429_ThrowsDiscordExceptionWithRateLimited()
{
// Arrange
var proxy = CreateProxy();
SetupErrorResponse((HttpStatusCode)429);
// Act & Assert
var ex = await Assert.ThrowsAsync<DiscordException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("rate limited", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Theory]
[InlineData(HttpStatusCode.BadGateway)]
[InlineData(HttpStatusCode.ServiceUnavailable)]
[InlineData(HttpStatusCode.GatewayTimeout)]
public async Task SendNotification_WhenServiceUnavailable_ThrowsDiscordException(HttpStatusCode statusCode)
{
// Arrange
var proxy = CreateProxy();
SetupErrorResponse(statusCode);
// Act & Assert
var ex = await Assert.ThrowsAsync<DiscordException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("service unavailable", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task SendNotification_WhenOtherError_ThrowsDiscordException()
{
// Arrange
var proxy = CreateProxy();
SetupErrorResponse(HttpStatusCode.InternalServerError);
// Act & Assert
var ex = await Assert.ThrowsAsync<DiscordException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("unable to send notification", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task SendNotification_WhenNetworkError_ThrowsDiscordException()
{
// Arrange
var proxy = CreateProxy();
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new HttpRequestException("Network error"));
// Act & Assert
var ex = await Assert.ThrowsAsync<DiscordException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("unable to send notification", ex.Message, StringComparison.OrdinalIgnoreCase);
}
#endregion
#region Helper Methods
private void SetupSuccessResponse()
{
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
}
private void SetupErrorResponse(HttpStatusCode statusCode)
{
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new HttpRequestException("Error", null, statusCode));
}
#endregion
}

View File

@@ -0,0 +1,329 @@
using System.Net;
using Cleanuparr.Infrastructure.Features.Notifications.Gotify;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Cleanuparr.Shared.Helpers;
using Microsoft.Extensions.Logging;
using Moq;
using Moq.Protected;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications.Gotify;
public class GotifyProxyTests
{
private readonly Mock<ILogger<GotifyProxy>> _loggerMock;
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock;
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
public GotifyProxyTests()
{
_loggerMock = new Mock<ILogger<GotifyProxy>>();
_httpMessageHandlerMock = new Mock<HttpMessageHandler>();
_httpClientFactoryMock = new Mock<IHttpClientFactory>();
var httpClient = new HttpClient(_httpMessageHandlerMock.Object);
_httpClientFactoryMock
.Setup(f => f.CreateClient(Constants.HttpClientWithRetryName))
.Returns(httpClient);
}
private GotifyProxy CreateProxy()
{
return new GotifyProxy(_loggerMock.Object, _httpClientFactoryMock.Object);
}
private static GotifyPayload CreatePayload()
{
return new GotifyPayload
{
Title = "Test Title",
Message = "Test Message",
Priority = 5
};
}
private static GotifyConfig CreateConfig()
{
return new GotifyConfig
{
ServerUrl = "https://gotify.example.com",
ApplicationToken = "test-app-token",
Priority = 5
};
}
#region Constructor Tests
[Fact]
public void Constructor_WithValidDependencies_CreatesInstance()
{
// Act
var proxy = CreateProxy();
// Assert
Assert.NotNull(proxy);
}
[Fact]
public void Constructor_CreatesHttpClientWithCorrectName()
{
// Act
_ = CreateProxy();
// Assert
_httpClientFactoryMock.Verify(f => f.CreateClient(Constants.HttpClientWithRetryName), Times.Once);
}
#endregion
#region SendNotification Success Tests
[Fact]
public async Task SendNotification_WhenSuccessful_CompletesWithoutException()
{
// Arrange
var proxy = CreateProxy();
SetupSuccessResponse();
// Act & Assert - Should not throw
await proxy.SendNotification(CreatePayload(), CreateConfig());
}
[Fact]
public async Task SendNotification_SendsPostRequest()
{
// Arrange
var proxy = CreateProxy();
HttpMethod? capturedMethod = null;
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedMethod = req.Method)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
// Act
await proxy.SendNotification(CreatePayload(), CreateConfig());
// Assert
Assert.Equal(HttpMethod.Post, capturedMethod);
}
[Fact]
public async Task SendNotification_BuildsCorrectUrl()
{
// Arrange
var proxy = CreateProxy();
Uri? capturedUri = null;
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedUri = req.RequestUri)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
var config = new GotifyConfig
{
ServerUrl = "https://gotify.example.com",
ApplicationToken = "my-token",
Priority = 5
};
// Act
await proxy.SendNotification(CreatePayload(), config);
// Assert
Assert.NotNull(capturedUri);
Assert.Equal("https://gotify.example.com/message?token=my-token", capturedUri.ToString());
}
[Fact]
public async Task SendNotification_TrimsTrailingSlashFromServerUrl()
{
// Arrange
var proxy = CreateProxy();
Uri? capturedUri = null;
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedUri = req.RequestUri)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
var config = new GotifyConfig
{
ServerUrl = "https://gotify.example.com/",
ApplicationToken = "my-token",
Priority = 5
};
// Act
await proxy.SendNotification(CreatePayload(), config);
// Assert
Assert.NotNull(capturedUri);
Assert.Equal("https://gotify.example.com/message?token=my-token", capturedUri.ToString());
}
[Fact]
public async Task SendNotification_SetsJsonContentType()
{
// Arrange
var proxy = CreateProxy();
string? capturedContentType = null;
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) =>
capturedContentType = req.Content?.Headers.ContentType?.MediaType)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
// Act
await proxy.SendNotification(CreatePayload(), CreateConfig());
// Assert
Assert.Equal("application/json", capturedContentType);
}
[Fact]
public async Task SendNotification_LogsTraceWithContent()
{
// Arrange
var proxy = CreateProxy();
SetupSuccessResponse();
// Act
await proxy.SendNotification(CreatePayload(), CreateConfig());
// Assert
_loggerMock.Verify(
x => x.Log(
LogLevel.Trace,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("sending notification")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
#endregion
#region SendNotification Error Tests
[Theory]
[InlineData(HttpStatusCode.Unauthorized)]
[InlineData(HttpStatusCode.Forbidden)]
public async Task SendNotification_WhenUnauthorized_ThrowsGotifyExceptionWithInvalidToken(HttpStatusCode statusCode)
{
// Arrange
var proxy = CreateProxy();
SetupErrorResponse(statusCode);
// Act & Assert
var ex = await Assert.ThrowsAsync<GotifyException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("invalid or unauthorized", ex.Message);
}
[Fact]
public async Task SendNotification_When404_ThrowsGotifyExceptionWithNotFound()
{
// Arrange
var proxy = CreateProxy();
SetupErrorResponse(HttpStatusCode.NotFound);
// Act & Assert
var ex = await Assert.ThrowsAsync<GotifyException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("not found", ex.Message);
}
[Theory]
[InlineData(HttpStatusCode.BadGateway)]
[InlineData(HttpStatusCode.ServiceUnavailable)]
[InlineData(HttpStatusCode.GatewayTimeout)]
public async Task SendNotification_WhenServiceUnavailable_ThrowsGotifyException(HttpStatusCode statusCode)
{
// Arrange
var proxy = CreateProxy();
SetupErrorResponse(statusCode);
// Act & Assert
var ex = await Assert.ThrowsAsync<GotifyException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("service unavailable", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task SendNotification_WhenOtherError_ThrowsGotifyException()
{
// Arrange
var proxy = CreateProxy();
SetupErrorResponse(HttpStatusCode.InternalServerError);
// Act & Assert
var ex = await Assert.ThrowsAsync<GotifyException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("unable to send notification", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task SendNotification_WhenNetworkError_ThrowsGotifyException()
{
// Arrange
var proxy = CreateProxy();
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new HttpRequestException("Network error"));
// Act & Assert
var ex = await Assert.ThrowsAsync<GotifyException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("unable to send notification", ex.Message, StringComparison.OrdinalIgnoreCase);
}
#endregion
#region Helper Methods
private void SetupSuccessResponse()
{
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
}
private void SetupErrorResponse(HttpStatusCode statusCode)
{
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new HttpRequestException("Error", null, statusCode));
}
#endregion
}

View File

@@ -37,7 +37,7 @@ public class NotifiarrProxyTests
return new NotifiarrPayload
{
Notification = new NotifiarrNotification { Update = false },
Discord = new Discord
Discord = new NotifiarrDiscord
{
Color = "#FF0000",
Text = new Text { Title = "Test", Content = "Test content" },

View File

@@ -358,12 +358,9 @@ public class NotificationConfigurationServiceTests : IDisposable
#region Provider Type Mapping Tests
[Theory]
[InlineData(NotificationProviderType.Notifiarr)]
[InlineData(NotificationProviderType.Apprise)]
[InlineData(NotificationProviderType.Ntfy)]
[InlineData(NotificationProviderType.Pushover)]
[InlineData(NotificationProviderType.Telegram)]
[MemberData(nameof(NotificationProviderTypes))]
public async Task GetActiveProvidersAsync_MapsProviderTypeCorrectly(NotificationProviderType providerType)
{
// Arrange
@@ -383,11 +380,7 @@ public class NotificationConfigurationServiceTests : IDisposable
}
[Theory]
[InlineData(NotificationProviderType.Notifiarr)]
[InlineData(NotificationProviderType.Apprise)]
[InlineData(NotificationProviderType.Ntfy)]
[InlineData(NotificationProviderType.Pushover)]
[InlineData(NotificationProviderType.Telegram)]
[MemberData(nameof(NotificationProviderTypes))]
public async Task GetProvidersForEventAsync_ReturnsProviderForAllTypes(NotificationProviderType providerType)
{
// Arrange
@@ -420,6 +413,8 @@ public class NotificationConfigurationServiceTests : IDisposable
NotificationProviderType.Ntfy => CreateNtfyConfig(name, isEnabled),
NotificationProviderType.Pushover => CreatePushoverConfig(name, isEnabled),
NotificationProviderType.Telegram => CreateTelegramConfig(name, isEnabled),
NotificationProviderType.Discord => CreateDiscordConfig(name, isEnabled),
NotificationProviderType.Gotify => CreateGotifyConfig(name, isEnabled),
_ => throw new ArgumentOutOfRangeException(nameof(providerType))
};
}
@@ -549,5 +544,60 @@ public class NotificationConfigurationServiceTests : IDisposable
};
}
private static NotificationConfig CreateDiscordConfig(string name, bool isEnabled)
{
return new NotificationConfig
{
Id = Guid.NewGuid(),
Name = name,
Type = NotificationProviderType.Discord,
IsEnabled = isEnabled,
OnStalledStrike = true,
OnFailedImportStrike = true,
OnSlowStrike = true,
OnQueueItemDeleted = true,
OnDownloadCleaned = true,
OnCategoryChanged = true,
DiscordConfiguration = new DiscordConfig
{
Id = Guid.NewGuid(),
WebhookUrl = "http://localhost:8000",
AvatarUrl = "https://example.com/avatar.png",
Username = "test_username",
}
};
}
private static NotificationConfig CreateGotifyConfig(string name, bool isEnabled)
{
return new NotificationConfig
{
Id = Guid.NewGuid(),
Name = name,
Type = NotificationProviderType.Gotify,
IsEnabled = isEnabled,
OnStalledStrike = true,
OnFailedImportStrike = true,
OnSlowStrike = true,
OnQueueItemDeleted = true,
OnDownloadCleaned = true,
OnCategoryChanged = true,
GotifyConfiguration = new GotifyConfig
{
Id = Guid.NewGuid(),
ServerUrl = "http://localhost:8000",
ApplicationToken = "test_application_token",
}
};
}
#endregion
public static IEnumerable<object[]> NotificationProviderTypes =>
[
..Enum.GetValues<NotificationProviderType>()
.Cast<Object>()
.Select(x => new[] { x })
.ToList()
];
}

View File

@@ -1,6 +1,8 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
using Cleanuparr.Infrastructure.Features.Notifications.Discord;
using Cleanuparr.Infrastructure.Features.Notifications.Gotify;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
using Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
@@ -21,6 +23,8 @@ public class NotificationProviderFactoryTests
private readonly Mock<INotifiarrProxy> _notifiarrProxyMock;
private readonly Mock<IPushoverProxy> _pushoverProxyMock;
private readonly Mock<ITelegramProxy> _telegramProxyMock;
private readonly Mock<IDiscordProxy> _discordProxyMock;
private readonly Mock<IGotifyProxy> _gotifyProxyMock;
private readonly IServiceProvider _serviceProvider;
private readonly NotificationProviderFactory _factory;
@@ -32,6 +36,8 @@ public class NotificationProviderFactoryTests
_notifiarrProxyMock = new Mock<INotifiarrProxy>();
_pushoverProxyMock = new Mock<IPushoverProxy>();
_telegramProxyMock = new Mock<ITelegramProxy>();
_discordProxyMock = new Mock<IDiscordProxy>();
_gotifyProxyMock = new Mock<IGotifyProxy>();
var services = new ServiceCollection();
services.AddSingleton(_appriseProxyMock.Object);
@@ -40,6 +46,8 @@ public class NotificationProviderFactoryTests
services.AddSingleton(_notifiarrProxyMock.Object);
services.AddSingleton(_pushoverProxyMock.Object);
services.AddSingleton(_telegramProxyMock.Object);
services.AddSingleton(_discordProxyMock.Object);
services.AddSingleton(_gotifyProxyMock.Object);
_serviceProvider = services.BuildServiceProvider();
_factory = new NotificationProviderFactory(_serviceProvider);
@@ -194,6 +202,61 @@ public class NotificationProviderFactoryTests
Assert.Equal(NotificationProviderType.Telegram, provider.Type);
}
[Fact]
public void CreateProvider_DiscordType_CreatesDiscordProvider()
{
// Arrange
var config = new NotificationProviderDto
{
Id = Guid.NewGuid(),
Name = "TestDiscord",
Type = NotificationProviderType.Discord,
IsEnabled = true,
Configuration = new DiscordConfig
{
Id = Guid.NewGuid(),
WebhookUrl = "test-webhook-url",
AvatarUrl = "test-avatar-url",
Username = "test-username",
}
};
// Act
var provider = _factory.CreateProvider(config);
// Assert
Assert.NotNull(provider);
Assert.IsType<DiscordProvider>(provider);
Assert.Equal("TestDiscord", provider.Name);
Assert.Equal(NotificationProviderType.Discord, provider.Type);
}
[Fact]
public void CreateProvider_GotifyType_CreatesGotifyProvider()
{
// Arrange
var config = new NotificationProviderDto
{
Id = Guid.NewGuid(),
Name = "TestGotify",
Type = NotificationProviderType.Gotify,
IsEnabled = true,
Configuration = new GotifyConfig
{
Id = Guid.NewGuid(),
ServerUrl = "test-server-url",
ApplicationToken = "test-application-token",
}
};
var provider = _factory.CreateProvider(config);
Assert.NotNull(provider);
Assert.IsType<GotifyProvider>(provider);
Assert.Equal("TestGotify", provider.Name);
Assert.Equal(NotificationProviderType.Gotify, provider.Type);
}
[Fact]
public void CreateProvider_UnsupportedType_ThrowsNotSupportedException()
{

View File

@@ -0,0 +1,12 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Discord;
public class DiscordException : Exception
{
public DiscordException(string message) : base(message)
{
}
public DiscordException(string message, Exception innerException) : base(message, innerException)
{
}
}

View File

@@ -0,0 +1,59 @@
using Newtonsoft.Json;
namespace Cleanuparr.Infrastructure.Features.Notifications.Discord;
public class DiscordPayload
{
public string? Username { get; set; }
[JsonProperty("avatar_url")]
public string? AvatarUrl { get; set; }
public List<DiscordEmbed> Embeds { get; set; } = new();
}
public class DiscordEmbed
{
public string Title { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public int Color { get; set; }
public DiscordThumbnail? Thumbnail { get; set; }
public DiscordImage? Image { get; set; }
public List<DiscordField> Fields { get; set; } = new();
public DiscordFooter? Footer { get; set; }
public string? Timestamp { get; set; }
}
public class DiscordField
{
public string Name { get; set; } = string.Empty;
public string Value { get; set; } = string.Empty;
public bool Inline { get; set; }
}
public class DiscordThumbnail
{
public string Url { get; set; } = string.Empty;
}
public class DiscordImage
{
public string Url { get; set; } = string.Empty;
}
public class DiscordFooter
{
public string Text { get; set; } = string.Empty;
[JsonProperty("icon_url")]
public string? IconUrl { get; set; }
}

View File

@@ -0,0 +1,93 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Cleanuparr.Shared.Helpers;
namespace Cleanuparr.Infrastructure.Features.Notifications.Discord;
public sealed class DiscordProvider : NotificationProviderBase<DiscordConfig>
{
private readonly IDiscordProxy _proxy;
public DiscordProvider(
string name,
NotificationProviderType type,
DiscordConfig config,
IDiscordProxy proxy)
: base(name, type, config)
{
_proxy = proxy;
}
public override async Task SendNotificationAsync(NotificationContext context)
{
var payload = BuildPayload(context);
await _proxy.SendNotification(payload, Config);
}
private DiscordPayload BuildPayload(NotificationContext context)
{
var color = context.Severity switch
{
EventSeverity.Warning => 0xf0ad4e, // Orange/yellow
EventSeverity.Important => 0xbb2124, // Red
_ => 0x28a745 // Green
};
var embed = new DiscordEmbed
{
Title = context.Title,
Description = context.Description,
Color = color,
Thumbnail = new DiscordThumbnail { Url = Constants.LogoUrl },
Fields = BuildFields(context),
Footer = new DiscordFooter
{
Text = "Cleanuparr",
IconUrl = Constants.LogoUrl
},
Timestamp = DateTime.UtcNow.ToString("o")
};
if (context.Image != null)
{
embed.Image = new DiscordImage { Url = context.Image.ToString() };
}
var payload = new DiscordPayload
{
Embeds = new List<DiscordEmbed> { embed }
};
// Apply username override if configured
if (!string.IsNullOrWhiteSpace(Config.Username))
{
payload.Username = Config.Username;
}
// Apply avatar override if configured
if (!string.IsNullOrWhiteSpace(Config.AvatarUrl))
{
payload.AvatarUrl = Config.AvatarUrl;
}
return payload;
}
private List<DiscordField> BuildFields(NotificationContext context)
{
var fields = new List<DiscordField>();
foreach ((string key, string value) in context.Data)
{
fields.Add(new DiscordField
{
Name = key,
Value = value,
Inline = false
});
}
return fields;
}
}

View File

@@ -0,0 +1,64 @@
using System.Text;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Cleanuparr.Shared.Helpers;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
namespace Cleanuparr.Infrastructure.Features.Notifications.Discord;
public sealed class DiscordProxy : IDiscordProxy
{
private readonly ILogger<DiscordProxy> _logger;
private readonly HttpClient _httpClient;
public DiscordProxy(ILogger<DiscordProxy> logger, IHttpClientFactory httpClientFactory)
{
_logger = logger;
_httpClient = httpClientFactory.CreateClient(Constants.HttpClientWithRetryName);
}
public async Task SendNotification(DiscordPayload payload, DiscordConfig config)
{
try
{
string content = JsonConvert.SerializeObject(payload, new JsonSerializerSettings
{
ContractResolver = new CamelCasePropertyNamesContractResolver(),
NullValueHandling = NullValueHandling.Ignore
});
_logger.LogTrace("sending notification to Discord: {content}", content);
using HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, config.WebhookUrl);
request.Content = new StringContent(content, Encoding.UTF8, "application/json");
using HttpResponseMessage response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
}
catch (HttpRequestException exception)
{
if (exception.StatusCode is null)
{
throw new DiscordException("unable to send notification", exception);
}
switch ((int)exception.StatusCode)
{
case 401:
case 403:
throw new DiscordException("unable to send notification | webhook URL is invalid or unauthorized");
case 404:
throw new DiscordException("unable to send notification | webhook not found");
case 429:
throw new DiscordException("unable to send notification | rate limited, please try again later", exception);
case 502:
case 503:
case 504:
throw new DiscordException("unable to send notification | Discord service unavailable", exception);
default:
throw new DiscordException("unable to send notification", exception);
}
}
}
}

View File

@@ -0,0 +1,8 @@
using Cleanuparr.Persistence.Models.Configuration.Notification;
namespace Cleanuparr.Infrastructure.Features.Notifications.Discord;
public interface IDiscordProxy
{
Task SendNotification(DiscordPayload payload, DiscordConfig config);
}

View File

@@ -0,0 +1,12 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Gotify;
public class GotifyException : Exception
{
public GotifyException(string message) : base(message)
{
}
public GotifyException(string message, Exception innerException) : base(message, innerException)
{
}
}

View File

@@ -0,0 +1,25 @@
using Newtonsoft.Json;
namespace Cleanuparr.Infrastructure.Features.Notifications.Gotify;
public class GotifyPayload
{
public string Title { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
public int Priority { get; set; } = 5;
public GotifyExtras? Extras { get; set; }
}
public class GotifyExtras
{
[JsonProperty("client::display")]
public GotifyClientDisplay? ClientDisplay { get; set; }
}
public class GotifyClientDisplay
{
public string? ContentType { get; set; }
}

View File

@@ -0,0 +1,62 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Persistence.Models.Configuration.Notification;
namespace Cleanuparr.Infrastructure.Features.Notifications.Gotify;
public sealed class GotifyProvider : NotificationProviderBase<GotifyConfig>
{
private readonly IGotifyProxy _proxy;
public GotifyProvider(
string name,
NotificationProviderType type,
GotifyConfig config,
IGotifyProxy proxy)
: base(name, type, config)
{
_proxy = proxy;
}
public override async Task SendNotificationAsync(NotificationContext context)
{
var payload = BuildPayload(context);
await _proxy.SendNotification(payload, Config);
}
private GotifyPayload BuildPayload(NotificationContext context)
{
var message = BuildMessage(context);
return new GotifyPayload
{
Title = context.Title,
Message = message,
Priority = Config.Priority,
Extras = new GotifyExtras
{
ClientDisplay = new GotifyClientDisplay
{
ContentType = "text/markdown"
}
}
};
}
private string BuildMessage(NotificationContext context)
{
var lines = new List<string>();
if (!string.IsNullOrWhiteSpace(context.Description))
{
lines.Add(context.Description);
}
foreach ((string key, string value) in context.Data)
{
lines.Add($"**{key}:** {value}");
}
return string.Join("\n\n", lines);
}
}

View File

@@ -0,0 +1,65 @@
using System.Text;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Cleanuparr.Shared.Helpers;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
namespace Cleanuparr.Infrastructure.Features.Notifications.Gotify;
public sealed class GotifyProxy : IGotifyProxy
{
private readonly ILogger<GotifyProxy> _logger;
private readonly HttpClient _httpClient;
public GotifyProxy(ILogger<GotifyProxy> logger, IHttpClientFactory httpClientFactory)
{
_logger = logger;
_httpClient = httpClientFactory.CreateClient(Constants.HttpClientWithRetryName);
}
public async Task SendNotification(GotifyPayload payload, GotifyConfig config)
{
try
{
string baseUrl = config.ServerUrl.TrimEnd('/');
string url = $"{baseUrl}/message?token={config.ApplicationToken}";
string content = JsonConvert.SerializeObject(payload, new JsonSerializerSettings
{
ContractResolver = new CamelCasePropertyNamesContractResolver(),
NullValueHandling = NullValueHandling.Ignore
});
_logger.LogTrace("sending notification to Gotify: {content}", content);
using HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, url);
request.Content = new StringContent(content, Encoding.UTF8, "application/json");
using HttpResponseMessage response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
}
catch (HttpRequestException exception)
{
if (exception.StatusCode is null)
{
throw new GotifyException("unable to send notification", exception);
}
switch ((int)exception.StatusCode)
{
case 401:
case 403:
throw new GotifyException("unable to send notification | application token is invalid or unauthorized");
case 404:
throw new GotifyException("unable to send notification | Gotify server not found");
case 502:
case 503:
case 504:
throw new GotifyException("unable to send notification | Gotify service unavailable", exception);
default:
throw new GotifyException("unable to send notification", exception);
}
}
}
}

View File

@@ -0,0 +1,8 @@
using Cleanuparr.Persistence.Models.Configuration.Notification;
namespace Cleanuparr.Infrastructure.Features.Notifications.Gotify;
public interface IGotifyProxy
{
Task SendNotification(GotifyPayload payload, GotifyConfig config);
}

View File

@@ -3,7 +3,7 @@
public class NotifiarrPayload
{
public NotifiarrNotification Notification { get; set; } = new NotifiarrNotification();
public Discord Discord { get; set; }
public NotifiarrDiscord Discord { get; set; }
}
public class NotifiarrNotification
@@ -13,7 +13,7 @@ public class NotifiarrNotification
public int? Event { get; set; }
}
public class Discord
public class NotifiarrDiscord
{
public string Color { get; set; } = string.Empty;
public Ping Ping { get; set; }

View File

@@ -2,6 +2,7 @@ using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Cleanuparr.Shared.Helpers;
namespace Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
@@ -34,8 +35,6 @@ public sealed class NotifiarrProvider : NotificationProviderBase<NotifiarrConfig
_ => "28a745"
};
const string logo = "https://github.com/Cleanuparr/Cleanuparr/blob/main/Logo/48.png?raw=true";
return new NotifiarrPayload
{
Discord = new()
@@ -44,7 +43,7 @@ public sealed class NotifiarrProvider : NotificationProviderBase<NotifiarrConfig
Text = new()
{
Title = context.Title,
Icon = logo,
Icon = Constants.LogoUrl,
Description = context.Description,
Fields = BuildFields(context)
},
@@ -54,7 +53,7 @@ public sealed class NotifiarrProvider : NotificationProviderBase<NotifiarrConfig
},
Images = new()
{
Thumbnail = new Uri(logo),
Thumbnail = new Uri(Constants.LogoUrl),
Image = context.Image
}
}

View File

@@ -88,6 +88,8 @@ public sealed class NotificationConfigurationService : INotificationConfiguratio
.Include(p => p.NtfyConfiguration)
.Include(p => p.PushoverConfiguration)
.Include(p => p.TelegramConfiguration)
.Include(p => p.DiscordConfiguration)
.Include(p => p.GotifyConfiguration)
.AsNoTracking()
.ToListAsync();
@@ -139,6 +141,8 @@ public sealed class NotificationConfigurationService : INotificationConfiguratio
NotificationProviderType.Ntfy => config.NtfyConfiguration,
NotificationProviderType.Pushover => config.PushoverConfiguration,
NotificationProviderType.Telegram => config.TelegramConfiguration,
NotificationProviderType.Discord => config.DiscordConfiguration,
NotificationProviderType.Gotify => config.GotifyConfiguration,
_ => throw new ArgumentOutOfRangeException(nameof(config), $"Config type for provider type {config.Type.ToString()} is not registered")
};

View File

@@ -1,11 +1,13 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
using Cleanuparr.Infrastructure.Features.Notifications.Discord;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
using Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
using Cleanuparr.Infrastructure.Features.Notifications.Pushover;
using Cleanuparr.Infrastructure.Features.Notifications.Telegram;
using Cleanuparr.Infrastructure.Features.Notifications.Gotify;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Microsoft.Extensions.DependencyInjection;
@@ -29,6 +31,8 @@ public sealed class NotificationProviderFactory : INotificationProviderFactory
NotificationProviderType.Ntfy => CreateNtfyProvider(config),
NotificationProviderType.Pushover => CreatePushoverProvider(config),
NotificationProviderType.Telegram => CreateTelegramProvider(config),
NotificationProviderType.Discord => CreateDiscordProvider(config),
NotificationProviderType.Gotify => CreateGotifyProvider(config),
_ => throw new NotSupportedException($"Provider type {config.Type} is not supported")
};
}
@@ -73,4 +77,20 @@ public sealed class NotificationProviderFactory : INotificationProviderFactory
return new TelegramProvider(config.Name, config.Type, telegramConfig, proxy);
}
private INotificationProvider CreateDiscordProvider(NotificationProviderDto config)
{
var discordConfig = (DiscordConfig)config.Configuration;
var proxy = _serviceProvider.GetRequiredService<IDiscordProxy>();
return new DiscordProvider(config.Name, config.Type, discordConfig, proxy);
}
private INotificationProvider CreateGotifyProvider(NotificationProviderDto config)
{
var gotifyConfig = (GotifyConfig)config.Configuration;
var proxy = _serviceProvider.GetRequiredService<IGotifyProxy>();
return new GotifyProvider(config.Name, config.Type, gotifyConfig, proxy);
}
}

View File

@@ -0,0 +1,149 @@
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Shouldly;
using Xunit;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Tests.Models.Configuration.Notification;
public sealed class DiscordConfigTests
{
#region IsValid Tests
[Fact]
public void IsValid_WithValidWebhookUrl_ReturnsTrue()
{
var config = new DiscordConfig
{
WebhookUrl = "https://discord.com/api/webhooks/123456789/abcdefghij"
};
config.IsValid().ShouldBeTrue();
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void IsValid_WithEmptyOrNullWebhookUrl_ReturnsFalse(string? webhookUrl)
{
var config = new DiscordConfig
{
WebhookUrl = webhookUrl ?? string.Empty
};
config.IsValid().ShouldBeFalse();
}
[Fact]
public void IsValid_WithOptionalFieldsEmpty_ReturnsTrue()
{
var config = new DiscordConfig
{
WebhookUrl = "https://discord.com/api/webhooks/123456789/abcdefghij",
Username = "",
AvatarUrl = ""
};
config.IsValid().ShouldBeTrue();
}
#endregion
#region Validate Tests
[Fact]
public void Validate_WithValidConfig_DoesNotThrow()
{
var config = new DiscordConfig
{
WebhookUrl = "https://discord.com/api/webhooks/123456789/abcdefghij",
Username = "Test Bot",
AvatarUrl = "https://example.com/avatar.png"
};
Should.NotThrow(() => config.Validate());
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Validate_WithEmptyOrNullWebhookUrl_ThrowsValidationException(string? webhookUrl)
{
var config = new DiscordConfig
{
WebhookUrl = webhookUrl ?? string.Empty
};
var ex = Should.Throw<ValidationException>(() => config.Validate());
ex.Message.ShouldContain("required");
}
[Theory]
[InlineData("https://example.com/webhook")]
[InlineData("http://discord.com/api/webhooks/123/abc")]
[InlineData("not-a-url")]
[InlineData("https://discord.com/api/something-else")]
public void Validate_WithInvalidWebhookUrl_ThrowsValidationException(string webhookUrl)
{
var config = new DiscordConfig
{
WebhookUrl = webhookUrl
};
var ex = Should.Throw<ValidationException>(() => config.Validate());
ex.Message.ShouldContain("valid Discord webhook URL");
}
[Theory]
[InlineData("https://discord.com/api/webhooks/123456789/abcdefghij")]
[InlineData("https://discordapp.com/api/webhooks/123456789/abcdefghij")]
public void Validate_WithValidWebhookUrls_DoesNotThrow(string webhookUrl)
{
var config = new DiscordConfig
{
WebhookUrl = webhookUrl
};
Should.NotThrow(() => config.Validate());
}
[Fact]
public void Validate_WithInvalidAvatarUrl_ThrowsValidationException()
{
var config = new DiscordConfig
{
WebhookUrl = "https://discord.com/api/webhooks/123456789/abcdefghij",
AvatarUrl = "not-a-valid-url"
};
var ex = Should.Throw<ValidationException>(() => config.Validate());
ex.Message.ShouldContain("valid URL");
}
[Fact]
public void Validate_WithValidAvatarUrl_DoesNotThrow()
{
var config = new DiscordConfig
{
WebhookUrl = "https://discord.com/api/webhooks/123456789/abcdefghij",
AvatarUrl = "https://example.com/avatar.png"
};
Should.NotThrow(() => config.Validate());
}
[Fact]
public void Validate_WithEmptyAvatarUrl_DoesNotThrow()
{
var config = new DiscordConfig
{
WebhookUrl = "https://discord.com/api/webhooks/123456789/abcdefghij",
AvatarUrl = ""
};
Should.NotThrow(() => config.Validate());
}
#endregion
}

View File

@@ -0,0 +1,189 @@
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Shouldly;
using Xunit;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Tests.Models.Configuration.Notification;
public sealed class GotifyConfigTests
{
#region IsValid Tests
[Fact]
public void IsValid_WithValidConfig_ReturnsTrue()
{
var config = new GotifyConfig
{
ServerUrl = "https://gotify.example.com",
ApplicationToken = "test-app-token"
};
config.IsValid().ShouldBeTrue();
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void IsValid_WithEmptyOrNullServerUrl_ReturnsFalse(string? serverUrl)
{
var config = new GotifyConfig
{
ServerUrl = serverUrl ?? string.Empty,
ApplicationToken = "test-token"
};
config.IsValid().ShouldBeFalse();
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void IsValid_WithEmptyOrNullApplicationToken_ReturnsFalse(string? token)
{
var config = new GotifyConfig
{
ServerUrl = "https://gotify.example.com",
ApplicationToken = token ?? string.Empty
};
config.IsValid().ShouldBeFalse();
}
#endregion
#region Validate Tests
[Fact]
public void Validate_WithValidConfig_DoesNotThrow()
{
var config = new GotifyConfig
{
ServerUrl = "https://gotify.example.com",
ApplicationToken = "test-app-token",
Priority = 5
};
Should.NotThrow(() => config.Validate());
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Validate_WithEmptyOrNullServerUrl_ThrowsValidationException(string? serverUrl)
{
var config = new GotifyConfig
{
ServerUrl = serverUrl ?? string.Empty,
ApplicationToken = "test-token"
};
var ex = Should.Throw<ValidationException>(() => config.Validate());
ex.Message.ShouldContain("required");
}
[Theory]
[InlineData("not-a-url")]
[InlineData("ftp://gotify.example.com")]
[InlineData("invalid://scheme")]
public void Validate_WithInvalidServerUrl_ThrowsValidationException(string serverUrl)
{
var config = new GotifyConfig
{
ServerUrl = serverUrl,
ApplicationToken = "test-token"
};
var ex = Should.Throw<ValidationException>(() => config.Validate());
ex.Message.ShouldContain("valid HTTP or HTTPS URL");
}
[Theory]
[InlineData("https://gotify.example.com")]
[InlineData("http://localhost:8080")]
[InlineData("https://gotify.local:8443/")]
public void Validate_WithValidServerUrls_DoesNotThrow(string serverUrl)
{
var config = new GotifyConfig
{
ServerUrl = serverUrl,
ApplicationToken = "test-token"
};
Should.NotThrow(() => config.Validate());
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Validate_WithEmptyOrNullApplicationToken_ThrowsValidationException(string? token)
{
var config = new GotifyConfig
{
ServerUrl = "https://gotify.example.com",
ApplicationToken = token ?? string.Empty
};
var ex = Should.Throw<ValidationException>(() => config.Validate());
ex.Message.ShouldContain("required");
}
[Theory]
[InlineData(-1)]
[InlineData(11)]
[InlineData(100)]
public void Validate_WithInvalidPriority_ThrowsValidationException(int priority)
{
var config = new GotifyConfig
{
ServerUrl = "https://gotify.example.com",
ApplicationToken = "test-token",
Priority = priority
};
var ex = Should.Throw<ValidationException>(() => config.Validate());
ex.Message.ShouldContain("Priority");
}
[Theory]
[InlineData(0)]
[InlineData(5)]
[InlineData(10)]
public void Validate_WithValidPriority_DoesNotThrow(int priority)
{
var config = new GotifyConfig
{
ServerUrl = "https://gotify.example.com",
ApplicationToken = "test-token",
Priority = priority
};
Should.NotThrow(() => config.Validate());
}
#endregion
#region Default Values Tests
[Fact]
public void NewConfig_HasDefaultPriorityOf5()
{
var config = new GotifyConfig();
config.Priority.ShouldBe(5);
}
[Fact]
public void NewConfig_HasEmptyStringsForRequiredFields()
{
var config = new GotifyConfig();
config.ServerUrl.ShouldBe(string.Empty);
config.ApplicationToken.ShouldBe(string.Empty);
}
#endregion
}

View File

@@ -56,6 +56,10 @@ public class DataContext : DbContext
public DbSet<TelegramConfig> TelegramConfigs { get; set; }
public DbSet<DiscordConfig> DiscordConfigs { get; set; }
public DbSet<GotifyConfig> GotifyConfigs { get; set; }
public DbSet<BlacklistSyncHistory> BlacklistSyncHistory { get; set; }
public DbSet<BlacklistSyncConfig> BlacklistSyncConfigs { get; set; }
@@ -156,6 +160,16 @@ public class DataContext : DbContext
.HasForeignKey<TelegramConfig>(c => c.NotificationConfigId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(p => p.DiscordConfiguration)
.WithOne(c => c.NotificationConfig)
.HasForeignKey<DiscordConfig>(c => c.NotificationConfigId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(p => p.GotifyConfiguration)
.WithOne(c => c.NotificationConfig)
.HasForeignKey<GotifyConfig>(c => c.NotificationConfigId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasIndex(p => p.Name).IsUnique();
});

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,49 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class AddDiscord : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "discord_configs",
columns: table => new
{
id = table.Column<Guid>(type: "TEXT", nullable: false),
notification_config_id = table.Column<Guid>(type: "TEXT", nullable: false),
webhook_url = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
username = table.Column<string>(type: "TEXT", maxLength: 80, nullable: false),
avatar_url = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_discord_configs", x => x.id);
table.ForeignKey(
name: "fk_discord_configs_notification_configs_notification_config_id",
column: x => x.notification_config_id,
principalTable: "notification_configs",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_discord_configs_notification_config_id",
table: "discord_configs",
column: "notification_config_id",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "discord_configs");
}
}
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,49 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class AddGotify : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "gotify_configs",
columns: table => new
{
id = table.Column<Guid>(type: "TEXT", nullable: false),
notification_config_id = table.Column<Guid>(type: "TEXT", nullable: false),
server_url = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
application_token = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
priority = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_gotify_configs", x => x.id);
table.ForeignKey(
name: "fk_gotify_configs_notification_configs_notification_config_id",
column: x => x.notification_config_id,
principalTable: "notification_configs",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_gotify_configs_notification_config_id",
table: "gotify_configs",
column: "notification_config_id",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "gotify_configs");
}
}
}

View File

@@ -16,7 +16,7 @@ namespace Cleanuparr.Persistence.Migrations.Data
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.6");
modelBuilder.HasAnnotation("ProductVersion", "10.0.1");
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b =>
{
@@ -302,7 +302,7 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("INTEGER")
.HasColumnName("search_enabled");
b.ComplexProperty<Dictionary<string, object>>("Log", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Log#LoggingConfig", b1 =>
b.ComplexProperty(typeof(Dictionary<string, object>), "Log", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Log#LoggingConfig", b1 =>
{
b1.IsRequired();
@@ -379,7 +379,7 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("INTEGER")
.HasColumnName("use_advanced_scheduling");
b.ComplexProperty<Dictionary<string, object>>("Lidarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 =>
b.ComplexProperty(typeof(Dictionary<string, object>), "Lidarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
@@ -397,7 +397,7 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnName("lidarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Radarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 =>
b.ComplexProperty(typeof(Dictionary<string, object>), "Radarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
@@ -415,7 +415,7 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnName("radarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Readarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 =>
b.ComplexProperty(typeof(Dictionary<string, object>), "Readarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
@@ -433,7 +433,7 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnName("readarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Sonarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 =>
b.ComplexProperty(typeof(Dictionary<string, object>), "Sonarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
@@ -451,7 +451,7 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnName("sonarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Whisparr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 =>
b.ComplexProperty(typeof(Dictionary<string, object>), "Whisparr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 =>
{
b1.IsRequired();
@@ -522,6 +522,82 @@ namespace Cleanuparr.Persistence.Migrations.Data
b.ToTable("apprise_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AvatarUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT")
.HasColumnName("avatar_url");
b.Property<Guid>("NotificationConfigId")
.HasColumnType("TEXT")
.HasColumnName("notification_config_id");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("TEXT")
.HasColumnName("username");
b.Property<string>("WebhookUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT")
.HasColumnName("webhook_url");
b.HasKey("Id")
.HasName("pk_discord_configs");
b.HasIndex("NotificationConfigId")
.IsUnique()
.HasDatabaseName("ix_discord_configs_notification_config_id");
b.ToTable("discord_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ApplicationToken")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT")
.HasColumnName("application_token");
b.Property<Guid>("NotificationConfigId")
.HasColumnType("TEXT")
.HasColumnName("notification_config_id");
b.Property<int>("Priority")
.HasColumnType("INTEGER")
.HasColumnName("priority");
b.Property<string>("ServerUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT")
.HasColumnName("server_url");
b.HasKey("Id")
.HasName("pk_gotify_configs");
b.HasIndex("NotificationConfigId")
.IsUnique()
.HasDatabaseName("ix_gotify_configs_notification_config_id");
b.ToTable("gotify_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b =>
{
b.Property<Guid>("Id")
@@ -813,7 +889,7 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("INTEGER")
.HasColumnName("use_advanced_scheduling");
b.ComplexProperty<Dictionary<string, object>>("FailedImport", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.FailedImport#FailedImportConfig", b1 =>
b.ComplexProperty(typeof(Dictionary<string, object>), "FailedImport", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.FailedImport#FailedImportConfig", b1 =>
{
b1.IsRequired();
@@ -1043,6 +1119,30 @@ namespace Cleanuparr.Persistence.Migrations.Data
b.Navigation("NotificationConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig")
.WithOne("DiscordConfiguration")
.HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", "NotificationConfigId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_discord_configs_notification_configs_notification_config_id");
b.Navigation("NotificationConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig")
.WithOne("GotifyConfiguration")
.HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", "NotificationConfigId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_gotify_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")
@@ -1141,6 +1241,10 @@ namespace Cleanuparr.Persistence.Migrations.Data
{
b.Navigation("AppriseConfiguration");
b.Navigation("DiscordConfiguration");
b.Navigation("GotifyConfiguration");
b.Navigation("NotifiarrConfiguration");
b.Navigation("NtfyConfiguration");

View File

@@ -0,0 +1,54 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Cleanuparr.Persistence.Models.Configuration;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Models.Configuration.Notification;
public sealed record DiscordConfig : IConfig
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid Id { get; init; } = Guid.NewGuid();
[Required]
public Guid NotificationConfigId { get; init; }
[ForeignKey(nameof(NotificationConfigId))]
public NotificationConfig NotificationConfig { get; init; } = null!;
[Required]
[MaxLength(500)]
public string WebhookUrl { get; init; } = string.Empty;
[MaxLength(80)]
public string Username { get; init; } = string.Empty;
[MaxLength(500)]
public string AvatarUrl { get; init; } = string.Empty;
public bool IsValid()
{
return !string.IsNullOrWhiteSpace(WebhookUrl);
}
public void Validate()
{
if (string.IsNullOrWhiteSpace(WebhookUrl))
{
throw new ValidationException("Discord webhook URL is required");
}
if (!WebhookUrl.StartsWith("https://discord.com/api/webhooks/", StringComparison.OrdinalIgnoreCase) &&
!WebhookUrl.StartsWith("https://discordapp.com/api/webhooks/", StringComparison.OrdinalIgnoreCase))
{
throw new ValidationException("Discord webhook URL must be a valid Discord webhook URL");
}
if (!string.IsNullOrWhiteSpace(AvatarUrl) &&
!Uri.TryCreate(AvatarUrl, UriKind.Absolute, out var uri))
{
throw new ValidationException("Avatar URL must be a valid URL");
}
}
}

View File

@@ -0,0 +1,57 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Models.Configuration.Notification;
public sealed record GotifyConfig : IConfig
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid Id { get; init; } = Guid.NewGuid();
[Required]
public Guid NotificationConfigId { get; init; }
[ForeignKey(nameof(NotificationConfigId))]
public NotificationConfig NotificationConfig { get; init; } = null!;
[Required]
[MaxLength(500)]
public string ServerUrl { get; init; } = string.Empty;
[Required]
[MaxLength(200)]
public string ApplicationToken { get; init; } = string.Empty;
public int Priority { get; init; } = 5;
public bool IsValid()
{
return !string.IsNullOrWhiteSpace(ServerUrl) && !string.IsNullOrWhiteSpace(ApplicationToken);
}
public void Validate()
{
if (string.IsNullOrWhiteSpace(ServerUrl))
{
throw new ValidationException("Gotify server URL is required");
}
if (!Uri.TryCreate(ServerUrl, UriKind.Absolute, out var uri) ||
(uri.Scheme != "http" && uri.Scheme != "https"))
{
throw new ValidationException("Gotify server URL must be a valid HTTP or HTTPS URL");
}
if (string.IsNullOrWhiteSpace(ApplicationToken))
{
throw new ValidationException("Gotify application token is required");
}
if (Priority < 0 || Priority > 10)
{
throw new ValidationException("Priority must be between 0 and 10");
}
}
}

View File

@@ -45,6 +45,10 @@ public sealed record NotificationConfig
public TelegramConfig? TelegramConfiguration { get; init; }
public DiscordConfig? DiscordConfiguration { get; init; }
public GotifyConfig? GotifyConfiguration { get; init; }
[NotMapped]
public bool IsConfigured => Type switch
{
@@ -53,6 +57,8 @@ public sealed record NotificationConfig
NotificationProviderType.Ntfy => NtfyConfiguration?.IsValid() == true,
NotificationProviderType.Pushover => PushoverConfiguration?.IsValid() == true,
NotificationProviderType.Telegram => TelegramConfiguration?.IsValid() == true,
NotificationProviderType.Discord => DiscordConfiguration?.IsValid() == true,
NotificationProviderType.Gotify => GotifyConfiguration?.IsValid() == true,
_ => throw new ArgumentOutOfRangeException(nameof(Type), $"Invalid notification provider type {Type}")
};

View File

@@ -17,4 +17,6 @@ public static class Constants
public const int DefaultSearchDelaySeconds = 120;
public const int MinSearchDelaySeconds = 60;
public const string LogoUrl = "https://github.com/Cleanuparr/Cleanuparr/blob/main/Logo/48.png?raw=true";
}

View File

@@ -439,7 +439,6 @@
"resolved": "https://registry.npmjs.org/@angular/animations/-/animations-19.2.17.tgz",
"integrity": "sha512-6VTet2fzTpSHEjxcVVzL8ZIyNGo/qsUs4XF/3wh9Iwu6qfWx711qXKlqGD/IHWzMTumzvQXbTV4hzvnO7fJvIQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@@ -542,7 +541,6 @@
"resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-19.2.19.tgz",
"integrity": "sha512-PCpJagurPBqciqcq4Z8+3OtKLb7rSl4w/qBJoIMua8CgnrjvA1i+SWawhdtfI1zlY8FSwhzLwXV0CmWWfFzQPg==",
"license": "MIT",
"peer": true,
"dependencies": {
"parse5": "^7.1.2",
"tslib": "^2.3.0"
@@ -592,7 +590,6 @@
"resolved": "https://registry.npmjs.org/@angular/common/-/common-19.2.17.tgz",
"integrity": "sha512-yFUXAdpvOFirGD/EGDwp1WHravHzI4sdyRE2iH7i8im9l8IE2VZ6D1KDJp8VVpMJt38LNlRAWYek3s+z6OcAkg==",
"license": "MIT",
"peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@@ -609,7 +606,6 @@
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-19.2.17.tgz",
"integrity": "sha512-qo8psYASAlDiQ8fAL8i/E2JfWH2nPTpZDKKZxSWvgBVA8o+zUEjYAJu6/k6btnu+4Qcb425T0rmM/zao6EU9Aw==",
"license": "MIT",
"peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@@ -623,7 +619,6 @@
"integrity": "sha512-KG82fh2A0odttc6+FxlQmFfHY/Giq8rYeV1qtdafafJ8hdWIiMr4r37xwhZOl8uk2/XSLM66bxUMFHYm+zt87Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/core": "7.26.9",
"@jridgewell/sourcemap-codec": "^1.4.14",
@@ -700,7 +695,6 @@
"resolved": "https://registry.npmjs.org/@angular/core/-/core-19.2.17.tgz",
"integrity": "sha512-nVu0ryxfiXUZ9M+NV21TY+rJZkPXTYo9U0aJb19hvByPpG+EvuujXUOgpulz6vxIzGy7pz/znRa+K9kxuuC+yQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@@ -717,7 +711,6 @@
"resolved": "https://registry.npmjs.org/@angular/forms/-/forms-19.2.17.tgz",
"integrity": "sha512-INgGGmMbwXuT+niAjMiCsJrZVEGWKZOep1vCRHoKlVnGUQSRKc3UW8ztmKDKMua/io/Opi03pRMpwbYQcTBr5A==",
"license": "MIT",
"peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@@ -736,7 +729,6 @@
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-19.2.17.tgz",
"integrity": "sha512-Rn23nIQwYMSeGXWFHI/X8bGHAkdahRxH9UIGUlJKxW61MSkK6AW4kCHG/Ev1TvDq9HjijsMjcqcsd6/Sb8aBXg==",
"license": "MIT",
"peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@@ -777,7 +769,6 @@
"resolved": "https://registry.npmjs.org/@angular/router/-/router-19.2.17.tgz",
"integrity": "sha512-B3Vk+E8UHQwg06WEjGuvYaKNiIXxjHN9pN8S+hDE8xwRgIS5ojEwS94blEvsGQ4QsIja6WjZMOfDUBUPlgUSuA==",
"license": "MIT",
"peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@@ -796,7 +787,6 @@
"resolved": "https://registry.npmjs.org/@angular/service-worker/-/service-worker-19.2.17.tgz",
"integrity": "sha512-rwki6a0PBm4biGG+BnnHiALPfAnTUqebjyA5fRCJ+cbh4nYvJZ2nU9GyHc56WwgfhwXWDo3UOxB+8K0INTv2RA==",
"license": "MIT",
"peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@@ -842,7 +832,6 @@
"integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.26.2",
@@ -3450,7 +3439,6 @@
"integrity": "sha512-G1ytyOoHh5BphmEBxSwALin3n1KGNYB6yImbICcRQdzXfOGbuJ9Jske/Of5Sebk339NSGGNfUshnzK8YWkTPsQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@inquirer/checkbox": "^4.1.2",
"@inquirer/confirm": "^5.1.6",
@@ -5706,7 +5694,6 @@
"integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -5955,7 +5942,6 @@
"integrity": "sha512-LKMrmwCPoLhM45Z00O1ulb6jwyVr2kr3XJp+G+tSEZcbauNnScewcQwtJqXDhXeYPDEjZ8C1SjXm015CirEmGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.32.1",
"@typescript-eslint/types": "8.32.1",
@@ -6266,7 +6252,6 @@
"integrity": "sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
@@ -6309,7 +6294,6 @@
"integrity": "sha512-11rZYxSe0zabiKaCP2QAwRf/dnmgFgvTmeDTtZvUvXG3UuAdg/GU02NExmmIXzz3vLGgMdtrIosI84jITQOxUA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.7.0",
"@typescript-eslint/scope-manager": "8.51.0",
@@ -6606,7 +6590,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -6669,7 +6652,6 @@
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -7198,7 +7180,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -8347,6 +8328,29 @@
"node": ">= 0.8"
}
},
"node_modules/encoding": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
"license": "MIT",
"optional": true,
"dependencies": {
"iconv-lite": "^0.6.2"
}
},
"node_modules/encoding/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"optional": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/engine.io": {
"version": "6.6.5",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.5.tgz",
@@ -8627,7 +8631,6 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -10445,8 +10448,7 @@
"resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.6.0.tgz",
"integrity": "sha512-niVlkeYVRwKFpmfWg6suo6H9CrNnydfBLEqefM5UjibYS+UoTjZdmvPJSiuyrRLGnFj1eYRhFd/ch+5hSlsFVA==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/jest-worker": {
"version": "27.5.1",
@@ -10485,7 +10487,6 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -10600,7 +10601,6 @@
"integrity": "sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@colors/colors": "1.5.0",
"body-parser": "^1.19.0",
@@ -11020,7 +11020,6 @@
"integrity": "sha512-tkuLHQlvWUTeQ3doAqnHbNn8T6WX1KA8yvbKG9x4VtKtIjHsVKQZCH11zRgAfbDAXC2UNIg/K9BYAAcEzUIrNg==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"copy-anything": "^2.0.1",
"parse-node-version": "^1.0.1",
@@ -13001,7 +13000,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.8",
"picocolors": "^1.1.1",
@@ -13727,7 +13725,6 @@
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"tslib": "^2.1.0"
}
@@ -14913,7 +14910,6 @@
"integrity": "sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.8.2",
@@ -15132,8 +15128,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"peer": true
"license": "0BSD"
},
"node_modules/tuf-js": {
"version": "3.1.0",
@@ -15190,7 +15185,6 @@
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -15205,7 +15199,6 @@
"integrity": "sha512-D7el+eaDHAmXvrZBy1zpzSNIRqnCOrkwTgZxTu3MUqRWk8k0q9m9Ho4+vPf7iHtgUfrK/o8IZaEApsxPlHTFCg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.32.1",
"@typescript-eslint/parser": "8.32.1",
@@ -16041,7 +16034,6 @@
"integrity": "sha512-UFynvx+gM44Gv9qFgj0acCQK2VE1CtdfwFdimkapco3hlPCJ/zeq73n2yVKimVbtm+TnApIugGhLJnkU6gjYXA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.6",
@@ -16119,7 +16111,6 @@
"integrity": "sha512-QcQ72gh8a+7JO63TAx/6XZf/CWhgMzu5m0QirvPfGvptOusAxG12w2+aua1Jkjr7hzaWDnJ2n6JFeexMHI+Zjg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/bonjour": "^3.5.13",
"@types/connect-history-api-fallback": "^1.5.4",
@@ -16732,8 +16723,7 @@
"version": "0.15.1",
"resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.1.tgz",
"integrity": "sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==",
"license": "MIT",
"peer": true
"license": "MIT"
}
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="M433.7 91a416.5 416.5 0 0 0-105.6-33.2c-4.6 8.2-9.9 19.3-13.5 28.1-39.4-5.9-78.4-5.9-117.1 0-3.7-8.8-9.1-19.9-13.7-28.1-37.1 6.4-72.6 17.7-105.7 33.3-66.8 101-85 199.5-75.9 296.6 44.3 33.1 87.3 53.2 129.6 66.4 10.4-14.4 19.7-29.6 27.7-45.7-15.3-5.8-29.9-13-43.7-21.3 3.7-2.7 7.2-5.6 10.7-8.5 84.2 39.4 175.8 39.4 259 0 3.5 2.9 7.1 5.8 10.7 8.5-13.9 8.3-28.5 15.5-43.8 21.3 8 16 17.3 31.3 27.7 45.7 42.3-13.2 85.3-33.3 129.6-66.4 10.8-112.5-18-210.1-76-296.7M170.9 328c-25.3 0-46-23.6-46-52.4s20.3-52.4 46-52.4 46.5 23.6 46 52.4c.1 28.8-20.2 52.4-46 52.4m170.2 0c-25.3 0-46-23.6-46-52.4s20.3-52.4 46-52.4 46.5 23.6 46 52.4c0 28.8-20.3 52.4-46 52.4" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 766 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="M433.7 91a416.5 416.5 0 0 0-105.6-33.2c-4.6 8.2-9.9 19.3-13.5 28.1-39.4-5.9-78.4-5.9-117.1 0-3.7-8.8-9.1-19.9-13.7-28.1-37.1 6.4-72.6 17.7-105.7 33.3-66.8 101-85 199.5-75.9 296.6 44.3 33.1 87.3 53.2 129.6 66.4 10.4-14.4 19.7-29.6 27.7-45.7-15.3-5.8-29.9-13-43.7-21.3 3.7-2.7 7.2-5.6 10.7-8.5 84.2 39.4 175.8 39.4 259 0 3.5 2.9 7.1 5.8 10.7 8.5-13.9 8.3-28.5 15.5-43.8 21.3 8 16 17.3 31.3 27.7 45.7 42.3-13.2 85.3-33.3 129.6-66.4 10.8-112.5-18-210.1-76-296.7M170.9 328c-25.3 0-46-23.6-46-52.4s20.3-52.4 46-52.4 46.5 23.6 46 52.4c.1 28.8-20.2 52.4-46 52.4m170.2 0c-25.3 0-46-23.6-46-52.4s20.3-52.4 46-52.4 46.5 23.6 46 52.4c0 28.8-20.3 52.4-46 52.4" style="fill:#5865f2"/></svg>

After

Width:  |  Height:  |  Size: 769 B

View File

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

View File

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -152,6 +152,16 @@ export class DocumentationService {
'telegram.topicId': 'topic-id',
'telegram.sendSilently': 'send-silently'
},
'notifications/discord': {
'discord.webhookUrl': 'webhook-url',
'discord.username': 'username',
'discord.avatarUrl': 'avatar-url'
},
'notifications/gotify': {
'gotify.serverUrl': 'server-url',
'gotify.applicationToken': 'application-token',
'gotify.priority': 'priority'
},
};
constructor(private applicationPathService: ApplicationPathService) {}

View File

@@ -230,6 +230,74 @@ export interface TestTelegramProviderRequest {
sendSilently: boolean;
}
export interface CreateDiscordProviderRequest {
name: string;
isEnabled: boolean;
onFailedImportStrike: boolean;
onStalledStrike: boolean;
onSlowStrike: boolean;
onQueueItemDeleted: boolean;
onDownloadCleaned: boolean;
onCategoryChanged: boolean;
webhookUrl: string;
username: string;
avatarUrl: string;
}
export interface UpdateDiscordProviderRequest {
name: string;
isEnabled: boolean;
onFailedImportStrike: boolean;
onStalledStrike: boolean;
onSlowStrike: boolean;
onQueueItemDeleted: boolean;
onDownloadCleaned: boolean;
onCategoryChanged: boolean;
webhookUrl: string;
username: string;
avatarUrl: string;
}
export interface TestDiscordProviderRequest {
webhookUrl: string;
username: string;
avatarUrl: string;
}
export interface CreateGotifyProviderRequest {
name: string;
isEnabled: boolean;
onFailedImportStrike: boolean;
onStalledStrike: boolean;
onSlowStrike: boolean;
onQueueItemDeleted: boolean;
onDownloadCleaned: boolean;
onCategoryChanged: boolean;
serverUrl: string;
applicationToken: string;
priority: number;
}
export interface UpdateGotifyProviderRequest {
name: string;
isEnabled: boolean;
onFailedImportStrike: boolean;
onStalledStrike: boolean;
onSlowStrike: boolean;
onQueueItemDeleted: boolean;
onDownloadCleaned: boolean;
onCategoryChanged: boolean;
serverUrl: string;
applicationToken: string;
priority: number;
}
export interface TestGotifyProviderRequest {
serverUrl: string;
applicationToken: string;
priority: number;
}
@Injectable({
providedIn: 'root'
})
@@ -287,6 +355,20 @@ export class NotificationProviderService {
return this.http.post<NotificationProviderDto>(`${this.baseUrl}/telegram`, provider);
}
/**
* Create a new Discord provider
*/
createDiscordProvider(provider: CreateDiscordProviderRequest): Observable<NotificationProviderDto> {
return this.http.post<NotificationProviderDto>(`${this.baseUrl}/discord`, provider);
}
/**
* Create a new Gotify provider
*/
createGotifyProvider(provider: CreateGotifyProviderRequest): Observable<NotificationProviderDto> {
return this.http.post<NotificationProviderDto>(`${this.baseUrl}/gotify`, provider);
}
/**
* Update an existing Notifiarr provider
*/
@@ -322,6 +404,20 @@ export class NotificationProviderService {
return this.http.put<NotificationProviderDto>(`${this.baseUrl}/telegram/${id}`, provider);
}
/**
* Update an existing Discord provider
*/
updateDiscordProvider(id: string, provider: UpdateDiscordProviderRequest): Observable<NotificationProviderDto> {
return this.http.put<NotificationProviderDto>(`${this.baseUrl}/discord/${id}`, provider);
}
/**
* Update an existing Gotify provider
*/
updateGotifyProvider(id: string, provider: UpdateGotifyProviderRequest): Observable<NotificationProviderDto> {
return this.http.put<NotificationProviderDto>(`${this.baseUrl}/gotify/${id}`, provider);
}
/**
* Delete a notification provider
*/
@@ -364,6 +460,20 @@ export class NotificationProviderService {
return this.http.post<TestNotificationResult>(`${this.baseUrl}/telegram/test`, testRequest);
}
/**
* Test a Discord provider (without ID - for testing configuration before saving)
*/
testDiscordProvider(testRequest: TestDiscordProviderRequest): Observable<TestNotificationResult> {
return this.http.post<TestNotificationResult>(`${this.baseUrl}/discord/test`, testRequest);
}
/**
* Test a Gotify provider (without ID - for testing configuration before saving)
*/
testGotifyProvider(testRequest: TestGotifyProviderRequest): Observable<TestNotificationResult> {
return this.http.post<TestNotificationResult>(`${this.baseUrl}/gotify/test`, testRequest);
}
/**
* Generic create method that delegates to provider-specific methods
*/
@@ -379,6 +489,10 @@ export class NotificationProviderService {
return this.createPushoverProvider(provider as CreatePushoverProviderRequest);
case NotificationProviderType.Telegram:
return this.createTelegramProvider(provider as CreateTelegramProviderRequest);
case NotificationProviderType.Discord:
return this.createDiscordProvider(provider as CreateDiscordProviderRequest);
case NotificationProviderType.Gotify:
return this.createGotifyProvider(provider as CreateGotifyProviderRequest);
default:
throw new Error(`Unsupported provider type: ${type}`);
}
@@ -399,6 +513,10 @@ export class NotificationProviderService {
return this.updatePushoverProvider(id, provider as UpdatePushoverProviderRequest);
case NotificationProviderType.Telegram:
return this.updateTelegramProvider(id, provider as UpdateTelegramProviderRequest);
case NotificationProviderType.Discord:
return this.updateDiscordProvider(id, provider as UpdateDiscordProviderRequest);
case NotificationProviderType.Gotify:
return this.updateGotifyProvider(id, provider as UpdateGotifyProviderRequest);
default:
throw new Error(`Unsupported provider type: ${type}`);
}
@@ -419,6 +537,10 @@ export class NotificationProviderService {
return this.testPushoverProvider(testRequest as TestPushoverProviderRequest);
case NotificationProviderType.Telegram:
return this.testTelegramProvider(testRequest as TestTelegramProviderRequest);
case NotificationProviderType.Discord:
return this.testDiscordProvider(testRequest as TestDiscordProviderRequest);
case NotificationProviderType.Gotify:
return this.testGotifyProvider(testRequest as TestGotifyProviderRequest);
default:
throw new Error(`Unsupported provider type: ${type}`);
}

View File

@@ -234,6 +234,7 @@
optionValue="value"
placeholder="Select version"
styleClass="w-full"
appendTo="body"
></p-select>
<small *ngIf="hasError(instanceForm, 'version', 'required')" class="form-error-text">Version is required</small>
</div>

View File

@@ -0,0 +1,71 @@
<app-notification-provider-base
[visible]="visible"
modalTitle="Configure Discord Provider"
[saving]="saving"
[testing]="testing"
[editingProvider]="editingProvider"
(save)="onSave($event)"
(cancel)="onCancel()"
(test)="onTest($event)">
<!-- Provider-specific configuration goes here -->
<div slot="provider-config">
<!-- Webhook URL Field -->
<div class="field">
<label for="webhook-url">
<i class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('discord.webhookUrl')"></i>
Webhook URL *
</label>
<input
id="webhook-url"
type="password"
pInputText
[formControl]="webhookUrlControl"
placeholder="https://discord.com/api/webhooks/..."
class="w-full" />
<small *ngIf="hasFieldError(webhookUrlControl, 'required')" class="form-error-text">Webhook URL is required</small>
<small *ngIf="hasFieldError(webhookUrlControl, 'pattern')" class="form-error-text">Must be a valid Discord webhook URL</small>
<small class="form-helper-text">Your Discord webhook URL. Create one in your Discord server's channel settings under Integrations.</small>
</div>
<!-- Username Field (Optional) -->
<div class="field">
<label for="username">
<i class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('discord.username')"></i>
Username (Optional)
</label>
<input
id="username"
type="text"
pInputText
[formControl]="usernameControl"
placeholder="Cleanuparr"
class="w-full" />
<small *ngIf="hasFieldError(usernameControl, 'maxlength')" class="form-error-text">Username cannot exceed 80 characters</small>
<small class="form-helper-text">Override the default webhook username. Leave empty to use the webhook's default name.</small>
</div>
<!-- Avatar URL Field (Optional) -->
<div class="field">
<label for="avatar-url">
<i class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('discord.avatarUrl')"></i>
Avatar URL (Optional)
</label>
<input
id="avatar-url"
type="text"
pInputText
[formControl]="avatarUrlControl"
placeholder="https://example.com/avatar.png"
class="w-full" />
<small *ngIf="hasFieldError(avatarUrlControl, 'pattern')" class="form-error-text">Must be a valid URL</small>
<small class="form-helper-text">Override the default webhook avatar. Leave empty to use the webhook's default avatar.</small>
</div>
</div>
</app-notification-provider-base>

View File

@@ -0,0 +1 @@
@use '../../../styles/settings-shared.scss';

View File

@@ -0,0 +1,117 @@
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 { DiscordFormData, BaseProviderFormData } from '../../models/provider-modal.model';
import { DocumentationService } from '../../../../core/services/documentation.service';
import { NotificationProviderDto } from '../../../../shared/models/notification-provider.model';
import { NotificationProviderBaseComponent } from '../base/notification-provider-base.component';
@Component({
selector: 'app-discord-provider',
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
InputTextModule,
NotificationProviderBaseComponent
],
templateUrl: './discord-provider.component.html',
styleUrls: ['./discord-provider.component.scss']
})
export class DiscordProviderComponent implements OnInit, OnChanges {
@Input() visible = false;
@Input() editingProvider: NotificationProviderDto | null = null;
@Input() saving = false;
@Input() testing = false;
@Output() save = new EventEmitter<DiscordFormData>();
@Output() cancel = new EventEmitter<void>();
@Output() test = new EventEmitter<DiscordFormData>();
// Provider-specific form controls
webhookUrlControl = new FormControl('', [Validators.required, Validators.pattern(/^https:\/\/(discord\.com|discordapp\.com)\/api\/webhooks\/.+/)]);
usernameControl = new FormControl('', [Validators.maxLength(80)]);
avatarUrlControl = new FormControl('', [Validators.pattern(/^(https?:\/\/.+)?$/)]);
private documentationService = inject(DocumentationService);
/** Exposed for template to open documentation for discord fields */
openFieldDocs(fieldName: string): void {
this.documentationService.openFieldDocumentation('notifications/discord', fieldName);
}
ngOnInit(): void {
// Initialize component but don't populate yet - wait for ngOnChanges
}
ngOnChanges(changes: SimpleChanges): void {
// Populate provider-specific fields when editingProvider input changes
if (changes['editingProvider']) {
if (this.editingProvider) {
this.populateProviderFields();
} else {
// Reset fields when editingProvider is cleared
this.resetProviderFields();
}
}
}
private populateProviderFields(): void {
if (this.editingProvider) {
const config = this.editingProvider.configuration as any;
this.webhookUrlControl.setValue(config?.webhookUrl || '');
this.usernameControl.setValue(config?.username || '');
this.avatarUrlControl.setValue(config?.avatarUrl || '');
}
}
private resetProviderFields(): void {
this.webhookUrlControl.setValue('');
this.usernameControl.setValue('');
this.avatarUrlControl.setValue('https://github.com/Cleanuparr/Cleanuparr/blob/main/Logo/48.png?raw=true');
}
protected hasFieldError(control: FormControl, errorType: string): boolean {
return !!(control && control.errors?.[errorType] && (control.dirty || control.touched));
}
onSave(baseData: BaseProviderFormData): void {
if (this.webhookUrlControl.valid && this.usernameControl.valid && this.avatarUrlControl.valid) {
const discordData: DiscordFormData = {
...baseData,
webhookUrl: this.webhookUrlControl.value || '',
username: this.usernameControl.value || '',
avatarUrl: this.avatarUrlControl.value || ''
};
this.save.emit(discordData);
} else {
// Mark provider-specific fields as touched to show validation errors
this.webhookUrlControl.markAsTouched();
this.usernameControl.markAsTouched();
this.avatarUrlControl.markAsTouched();
}
}
onCancel(): void {
this.cancel.emit();
}
onTest(baseData: BaseProviderFormData): void {
if (this.webhookUrlControl.valid && this.usernameControl.valid && this.avatarUrlControl.valid) {
const discordData: DiscordFormData = {
...baseData,
webhookUrl: this.webhookUrlControl.value || '',
username: this.usernameControl.value || '',
avatarUrl: this.avatarUrlControl.value || ''
};
this.test.emit(discordData);
} else {
// Mark provider-specific fields as touched to show validation errors
this.webhookUrlControl.markAsTouched();
this.usernameControl.markAsTouched();
this.avatarUrlControl.markAsTouched();
}
}
}

View File

@@ -0,0 +1,72 @@
<app-notification-provider-base
[visible]="visible"
modalTitle="Configure Gotify Provider"
[saving]="saving"
[testing]="testing"
[editingProvider]="editingProvider"
(save)="onSave($event)"
(cancel)="onCancel()"
(test)="onTest($event)">
<!-- Provider-specific configuration goes here -->
<div slot="provider-config">
<!-- Server URL Field -->
<div class="field">
<label for="server-url">
<i class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('gotify.serverUrl')"></i>
Server URL *
</label>
<input
id="server-url"
type="text"
pInputText
[formControl]="serverUrlControl"
placeholder="https://gotify.example.com"
class="w-full" />
<small *ngIf="hasFieldError(serverUrlControl, 'required')" class="form-error-text">Server URL is required</small>
<small *ngIf="hasFieldError(serverUrlControl, 'pattern')" class="form-error-text">Must be a valid HTTP or HTTPS URL</small>
<small class="form-helper-text">The URL of your Gotify server instance.</small>
</div>
<!-- Application Token Field -->
<div class="field">
<label for="application-token">
<i class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('gotify.applicationToken')"></i>
Application Token *
</label>
<input
id="application-token"
type="password"
pInputText
[formControl]="applicationTokenControl"
placeholder="Your Gotify application token"
class="w-full" />
<small *ngIf="hasFieldError(applicationTokenControl, 'required')" class="form-error-text">Application token is required</small>
<small class="form-helper-text">Create an application in your Gotify server and use its token here.</small>
</div>
<!-- Priority Field -->
<div class="field">
<label for="priority">
<i class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('gotify.priority')"></i>
Priority
</label>
<p-inputNumber
id="priority"
[formControl]="priorityControl"
[min]="0"
[max]="10"
[showButtons]="true"
styleClass="w-full">
</p-inputNumber>
<small *ngIf="hasFieldError(priorityControl, 'min') || hasFieldError(priorityControl, 'max')" class="form-error-text">Priority must be between 0 and 10</small>
<small class="form-helper-text">Message priority (0-10). Higher values may trigger different notification behaviors on clients.</small>
</div>
</div>
</app-notification-provider-base>

View File

@@ -0,0 +1 @@
@use '../../../styles/settings-shared.scss';

View File

@@ -0,0 +1,119 @@
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 { InputNumberModule } from 'primeng/inputnumber';
import { GotifyFormData, BaseProviderFormData } from '../../models/provider-modal.model';
import { DocumentationService } from '../../../../core/services/documentation.service';
import { NotificationProviderDto } from '../../../../shared/models/notification-provider.model';
import { NotificationProviderBaseComponent } from '../base/notification-provider-base.component';
@Component({
selector: 'app-gotify-provider',
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
InputTextModule,
InputNumberModule,
NotificationProviderBaseComponent
],
templateUrl: './gotify-provider.component.html',
styleUrls: ['./gotify-provider.component.scss']
})
export class GotifyProviderComponent implements OnInit, OnChanges {
@Input() visible = false;
@Input() editingProvider: NotificationProviderDto | null = null;
@Input() saving = false;
@Input() testing = false;
@Output() save = new EventEmitter<GotifyFormData>();
@Output() cancel = new EventEmitter<void>();
@Output() test = new EventEmitter<GotifyFormData>();
// Provider-specific form controls
serverUrlControl = new FormControl('', [Validators.required, Validators.pattern(/^https?:\/\/.+/)]);
applicationTokenControl = new FormControl('', [Validators.required]);
priorityControl = new FormControl(5, [Validators.required, Validators.min(0), Validators.max(10)]);
private documentationService = inject(DocumentationService);
/** Exposed for template to open documentation for gotify fields */
openFieldDocs(fieldName: string): void {
this.documentationService.openFieldDocumentation('notifications/gotify', fieldName);
}
ngOnInit(): void {
// Initialize component but don't populate yet - wait for ngOnChanges
}
ngOnChanges(changes: SimpleChanges): void {
// Populate provider-specific fields when editingProvider input changes
if (changes['editingProvider']) {
if (this.editingProvider) {
this.populateProviderFields();
} else {
// Reset fields when editingProvider is cleared
this.resetProviderFields();
}
}
}
private populateProviderFields(): void {
if (this.editingProvider) {
const config = this.editingProvider.configuration as any;
this.serverUrlControl.setValue(config?.serverUrl || '');
this.applicationTokenControl.setValue(config?.applicationToken || '');
this.priorityControl.setValue(config?.priority ?? 5);
}
}
private resetProviderFields(): void {
this.serverUrlControl.setValue('');
this.applicationTokenControl.setValue('');
this.priorityControl.setValue(5);
}
protected hasFieldError(control: FormControl, errorType: string): boolean {
return !!(control && control.errors?.[errorType] && (control.dirty || control.touched));
}
onSave(baseData: BaseProviderFormData): void {
if (this.serverUrlControl.valid && this.applicationTokenControl.valid && this.priorityControl.valid) {
const gotifyData: GotifyFormData = {
...baseData,
serverUrl: this.serverUrlControl.value || '',
applicationToken: this.applicationTokenControl.value || '',
priority: this.priorityControl.value ?? 5
};
this.save.emit(gotifyData);
} else {
// Mark provider-specific fields as touched to show validation errors
this.serverUrlControl.markAsTouched();
this.applicationTokenControl.markAsTouched();
this.priorityControl.markAsTouched();
}
}
onCancel(): void {
this.cancel.emit();
}
onTest(baseData: BaseProviderFormData): void {
if (this.serverUrlControl.valid && this.applicationTokenControl.valid && this.priorityControl.valid) {
const gotifyData: GotifyFormData = {
...baseData,
serverUrl: this.serverUrlControl.value || '',
applicationToken: this.applicationTokenControl.value || '',
priority: this.priorityControl.value ?? 5
};
this.test.emit(gotifyData);
} else {
// Mark provider-specific fields as touched to show validation errors
this.serverUrlControl.markAsTouched();
this.applicationTokenControl.markAsTouched();
this.priorityControl.markAsTouched();
}
}
}

View File

@@ -57,8 +57,22 @@ export class ProviderTypeSelectionComponent {
iconUrl: 'icons/ext/telegram-light.svg',
iconUrlHover: 'icons/ext/telegram.svg',
description: 'https://core.telegram.org/bots'
}
];
},
{
type: NotificationProviderType.Discord,
name: 'Discord',
iconUrl: 'icons/ext/discord-light.svg',
iconUrlHover: 'icons/ext/discord.svg',
description: 'https://discord.com'
},
{
type: NotificationProviderType.Gotify,
name: 'Gotify',
iconUrl: 'icons/ext/gotify-light.svg',
iconUrlHover: 'icons/ext/gotify.svg',
description: 'https://gotify.net'
},
].sort((a, b) => a.name.localeCompare(b.name));
selectProvider(type: NotificationProviderType) {
this.providerSelected.emit(type);

View File

@@ -72,6 +72,18 @@ export interface TelegramFormData extends BaseProviderFormData {
sendSilently: boolean;
}
export interface DiscordFormData extends BaseProviderFormData {
webhookUrl: string;
username: string;
avatarUrl: string;
}
export interface GotifyFormData extends BaseProviderFormData {
serverUrl: string;
applicationToken: string;
priority: number;
}
// Events for modal communication
export interface ProviderModalEvents {
save: (data: any) => void;

View File

@@ -135,7 +135,6 @@
<h3>Provider Type Not Yet Supported</h3>
<p class="text-color-secondary">
This provider type is not yet supported by the new modal system.
<br>Please select Notifiarr or Apprise for now.
</p>
<button
pButton
@@ -209,6 +208,28 @@
(test)="onTelegramTest($event)"
></app-telegram-provider>
<!-- Discord Provider Modal -->
<app-discord-provider
[visible]="showDiscordModal"
[editingProvider]="editingProvider"
[saving]="saving()"
[testing]="testing()"
(save)="onDiscordSave($event)"
(cancel)="onProviderCancel()"
(test)="onDiscordTest($event)"
></app-discord-provider>
<!-- Gotify Provider Modal -->
<app-gotify-provider
[visible]="showGotifyModal"
[editingProvider]="editingProvider"
[saving]="saving()"
[testing]="testing()"
(save)="onGotifySave($event)"
(cancel)="onProviderCancel()"
(test)="onGotifyTest($event)"
></app-gotify-provider>
<!-- Confirmation Dialog -->
<p-confirmDialog></p-confirmDialog>

View File

@@ -8,7 +8,7 @@ import {
} from "../../shared/models/notification-provider.model";
import { NotificationProviderType } from "../../shared/models/enums";
import { DocumentationService } from "../../core/services/documentation.service";
import { NotifiarrFormData, AppriseFormData, NtfyFormData, PushoverFormData, TelegramFormData } from "./models/provider-modal.model";
import { NotifiarrFormData, AppriseFormData, NtfyFormData, PushoverFormData, TelegramFormData, DiscordFormData, GotifyFormData } from "./models/provider-modal.model";
import { LoadingErrorStateComponent } from "../../shared/components/loading-error-state/loading-error-state.component";
// New modal components
@@ -18,6 +18,8 @@ import { AppriseProviderComponent } from "./modals/apprise-provider/apprise-prov
import { NtfyProviderComponent } from "./modals/ntfy-provider/ntfy-provider.component";
import { PushoverProviderComponent } from "./modals/pushover-provider/pushover-provider.component";
import { TelegramProviderComponent } from "./modals/telegram-provider/telegram-provider.component";
import { DiscordProviderComponent } from "./modals/discord-provider/discord-provider.component";
import { GotifyProviderComponent } from "./modals/gotify-provider/gotify-provider.component";
// PrimeNG Components
import { CardModule } from "primeng/card";
@@ -55,6 +57,8 @@ import { NotificationService } from "../../core/services/notification.service";
NtfyProviderComponent,
PushoverProviderComponent,
TelegramProviderComponent,
DiscordProviderComponent,
GotifyProviderComponent,
],
providers: [NotificationProviderConfigStore, ConfirmationService, MessageService],
templateUrl: "./notification-settings.component.html",
@@ -66,12 +70,14 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
// Modal state
showProviderModal = false; // Legacy modal for unsupported types
showTypeSelectionModal = false; // New: Provider type selection modal
showNotifiarrModal = false; // New: Notifiarr provider modal
showAppriseModal = false; // New: Apprise provider modal
showNtfyModal = false; // New: Ntfy provider modal
showPushoverModal = false; // New: Pushover provider modal
showTelegramModal = false; // New: Telegram provider modal
showTypeSelectionModal = false;
showNotifiarrModal = false;
showAppriseModal = false;
showNtfyModal = false;
showPushoverModal = false;
showTelegramModal = false;
showDiscordModal = false;
showGotifyModal = false;
modalMode: 'add' | 'edit' = 'add';
editingProvider: NotificationProviderDto | null = null;
@@ -186,6 +192,12 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
case NotificationProviderType.Telegram:
this.showTelegramModal = true;
break;
case NotificationProviderType.Discord:
this.showDiscordModal = true;
break;
case NotificationProviderType.Gotify:
this.showGotifyModal = true;
break;
default:
// For unsupported types, show the legacy modal with info message
this.showProviderModal = true;
@@ -242,6 +254,12 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
case NotificationProviderType.Telegram:
this.showTelegramModal = true;
break;
case NotificationProviderType.Discord:
this.showDiscordModal = true;
break;
case NotificationProviderType.Gotify:
this.showGotifyModal = true;
break;
default:
// For unsupported types, show the legacy modal with info message
this.showProviderModal = true;
@@ -327,6 +345,22 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
sendSilently: telegramConfig.sendSilently || false,
};
break;
case NotificationProviderType.Discord:
const discordConfig = provider.configuration as any;
testRequest = {
webhookUrl: discordConfig.webhookUrl,
username: discordConfig.username || "",
avatarUrl: discordConfig.avatarUrl || "",
};
break;
case NotificationProviderType.Gotify:
const gotifyConfig = provider.configuration as any;
testRequest = {
serverUrl: gotifyConfig.serverUrl,
applicationToken: gotifyConfig.applicationToken,
priority: gotifyConfig.priority ?? 5,
};
break;
default:
this.notificationService.showError("Testing not supported for this provider type");
return;
@@ -369,6 +403,10 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
return "Pushover";
case NotificationProviderType.Telegram:
return "Telegram";
case NotificationProviderType.Discord:
return "Discord";
case NotificationProviderType.Gotify:
return "Gotify";
default:
return "Unknown";
}
@@ -537,6 +575,60 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
});
}
/**
* Handle Discord provider save
*/
onDiscordSave(data: DiscordFormData): void {
if (this.modalMode === "edit" && this.editingProvider) {
this.updateDiscordProvider(data);
} else {
this.createDiscordProvider(data);
}
}
/**
* Handle Discord provider test
*/
onDiscordTest(data: DiscordFormData): void {
const testRequest = {
webhookUrl: data.webhookUrl,
username: data.username,
avatarUrl: data.avatarUrl,
};
this.notificationProviderStore.testProvider({
testRequest,
type: NotificationProviderType.Discord,
});
}
/**
* Handle Gotify provider save
*/
onGotifySave(data: GotifyFormData): void {
if (this.modalMode === "edit" && this.editingProvider) {
this.updateGotifyProvider(data);
} else {
this.createGotifyProvider(data);
}
}
/**
* Handle Gotify provider test
*/
onGotifyTest(data: GotifyFormData): void {
const testRequest = {
serverUrl: data.serverUrl,
applicationToken: data.applicationToken,
priority: data.priority,
};
this.notificationProviderStore.testProvider({
testRequest,
type: NotificationProviderType.Gotify,
});
}
/**
* Handle provider modal cancel
*/
@@ -554,6 +646,8 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
this.showNtfyModal = false;
this.showPushoverModal = false;
this.showTelegramModal = false;
this.showDiscordModal = false;
this.showGotifyModal = false;
this.showProviderModal = false;
this.editingProvider = null;
this.notificationProviderStore.clearTestResult();
@@ -848,6 +942,112 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
this.monitorProviderOperation("updated");
}
/**
* Create new Discord provider
*/
private createDiscordProvider(data: DiscordFormData): void {
const createDto = {
name: data.name,
isEnabled: data.enabled,
onFailedImportStrike: data.onFailedImportStrike,
onStalledStrike: data.onStalledStrike,
onSlowStrike: data.onSlowStrike,
onQueueItemDeleted: data.onQueueItemDeleted,
onDownloadCleaned: data.onDownloadCleaned,
onCategoryChanged: data.onCategoryChanged,
webhookUrl: data.webhookUrl,
username: data.username,
avatarUrl: data.avatarUrl,
};
this.notificationProviderStore.createProvider({
provider: createDto,
type: NotificationProviderType.Discord,
});
this.monitorProviderOperation("created");
}
/**
* Update existing Discord provider
*/
private updateDiscordProvider(data: DiscordFormData): void {
if (!this.editingProvider) return;
const updateDto = {
name: data.name,
isEnabled: data.enabled,
onFailedImportStrike: data.onFailedImportStrike,
onStalledStrike: data.onStalledStrike,
onSlowStrike: data.onSlowStrike,
onQueueItemDeleted: data.onQueueItemDeleted,
onDownloadCleaned: data.onDownloadCleaned,
onCategoryChanged: data.onCategoryChanged,
webhookUrl: data.webhookUrl,
username: data.username,
avatarUrl: data.avatarUrl,
};
this.notificationProviderStore.updateProvider({
id: this.editingProvider.id,
provider: updateDto,
type: NotificationProviderType.Discord,
});
this.monitorProviderOperation("updated");
}
/**
* Create new Gotify provider
*/
private createGotifyProvider(data: GotifyFormData): void {
const createDto = {
name: data.name,
isEnabled: data.enabled,
onFailedImportStrike: data.onFailedImportStrike,
onStalledStrike: data.onStalledStrike,
onSlowStrike: data.onSlowStrike,
onQueueItemDeleted: data.onQueueItemDeleted,
onDownloadCleaned: data.onDownloadCleaned,
onCategoryChanged: data.onCategoryChanged,
serverUrl: data.serverUrl,
applicationToken: data.applicationToken,
priority: data.priority,
};
this.notificationProviderStore.createProvider({
provider: createDto,
type: NotificationProviderType.Gotify,
});
this.monitorProviderOperation("created");
}
/**
* Update existing Gotify provider
*/
private updateGotifyProvider(data: GotifyFormData): void {
if (!this.editingProvider) return;
const updateDto = {
name: data.name,
isEnabled: data.enabled,
onFailedImportStrike: data.onFailedImportStrike,
onStalledStrike: data.onStalledStrike,
onSlowStrike: data.onSlowStrike,
onQueueItemDeleted: data.onQueueItemDeleted,
onDownloadCleaned: data.onDownloadCleaned,
onCategoryChanged: data.onCategoryChanged,
serverUrl: data.serverUrl,
applicationToken: data.applicationToken,
priority: data.priority,
};
this.notificationProviderStore.updateProvider({
id: this.editingProvider.id,
provider: updateDto,
type: NotificationProviderType.Gotify,
});
this.monitorProviderOperation("updated");
}
/**
* Monitor provider operation completion and close modals
*/

View File

@@ -234,6 +234,7 @@
optionValue="value"
placeholder="Select version"
styleClass="w-full"
appendTo="body"
></p-select>
<small *ngIf="hasError(instanceForm, 'version', 'required')" class="form-error-text">Version is required</small>
</div>

View File

@@ -234,6 +234,7 @@
optionValue="value"
placeholder="Select version"
styleClass="w-full"
appendTo="body"
></p-select>
<small *ngIf="hasError(instanceForm, 'version', 'required')" class="form-error-text">Version is required</small>
</div>

View File

@@ -234,6 +234,7 @@
optionValue="value"
placeholder="Select version"
styleClass="w-full"
appendTo="body"
></p-select>
<small *ngIf="hasError(instanceForm, 'version', 'required')" class="form-error-text">Version is required</small>
</div>

View File

@@ -234,6 +234,7 @@
optionValue="value"
placeholder="Select version"
styleClass="w-full"
appendTo="body"
></p-select>
<small *ngIf="hasError(instanceForm, 'version', 'required')" class="form-error-text">Version is required</small>
</div>

View File

@@ -53,6 +53,7 @@ export class WhisparrSettingsComponent implements OnDestroy, CanComponentDeactiv
globalForm: FormGroup;
instanceForm: FormGroup;
// Version options for Whisparr (v2 and v3)
versionOptions = [
{ label: 'v2', value: 2 },
{ label: 'v3', value: 3 }

View File

@@ -16,6 +16,8 @@ export enum NotificationProviderType {
Ntfy = "Ntfy",
Pushover = "Pushover",
Telegram = "Telegram",
Discord = "Discord",
Gotify = "Gotify",
}
export enum AppriseMode {

View File

@@ -0,0 +1,72 @@
---
sidebar_position: 6
---
import {
ConfigSection,
ElementNavigator,
SectionTitle,
styles
} from '@site/src/components/documentation';
# Discord
Discord is a popular communication platform that supports webhooks for automated messaging. Cleanuparr can send notifications directly to any Discord channel via webhooks.
<ElementNavigator />
<div className={styles.documentationPage}>
<div className={styles.section}>
<SectionTitle icon="💬">Configuration</SectionTitle>
<p className={styles.sectionDescription}>
Configure a Discord webhook to receive notifications in your Discord server.
</p>
<ConfigSection
title="Webhook URL"
icon="🔗"
id="webhook-url"
>
The Discord webhook URL for sending messages. To create a webhook:
1. Open your Discord server and go to **Server Settings** → **Integrations** → **Webhooks**
2. Click **New Webhook** and select the channel where notifications should appear
3. Copy the **Webhook URL** and paste it here
Webhook URLs follow the format: `https://discord.com/api/webhooks/{id}/{token}`
Reference: https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks
</ConfigSection>
<ConfigSection
title="Username"
icon="👤"
id="username"
>
Override the default webhook username displayed in Discord. If left empty, the webhook's default name (configured in Discord) will be used.
Maximum length: 80 characters.
</ConfigSection>
<ConfigSection
title="Avatar URL"
icon="🖼️"
id="avatar-url"
>
Override the default webhook avatar with a custom image URL. If left empty, the webhook's default avatar (configured in Discord) will be used.
Must be a valid URL pointing to an image file.
</ConfigSection>
</div>
</div>

View File

@@ -0,0 +1,82 @@
---
sidebar_position: 7
---
import {
ConfigSection,
ElementNavigator,
SectionTitle,
styles
} from '@site/src/components/documentation';
# Gotify
Gotify is a simple, self-hosted push notification server. It provides a REST API for sending messages and has clients available for Android, web browsers, and CLI.
<ElementNavigator />
<div className={styles.documentationPage}>
<div className={styles.section}>
<SectionTitle icon="🔔">Configuration</SectionTitle>
<p className={styles.sectionDescription}>
Configure your Gotify server connection to receive push notifications.
</p>
<ConfigSection
title="Server URL"
icon="🌐"
id="server-url"
>
The URL of your Gotify server instance. This should be the base URL where your Gotify server is accessible.
Examples:
- `https://gotify.example.com`
- `http://192.168.1.100:8080`
</ConfigSection>
<ConfigSection
title="Application Token"
icon="🔑"
id="application-token"
>
The application token used to authenticate with your Gotify server. To create an application token:
1. Log in to your Gotify web interface
2. Go to **Apps** and click **Create Application**
3. Give your application a name (e.g., "Cleanuparr")
4. Copy the generated token and paste it here
Each application in Gotify has its own token and can be configured with a custom name and icon.
Reference: https://gotify.net/docs/
</ConfigSection>
<ConfigSection
title="Priority"
icon="🔥"
id="priority"
>
The priority level for notifications (0-10). Higher priority messages may trigger different notification behaviors on Gotify clients:
- **0**: Minimum priority - may not trigger any notification
- **1-3**: Low priority - quiet notification
- **4-7**: Normal priority - standard notification behavior
- **8-10**: High priority - may trigger more intrusive notifications
The exact behavior depends on your Gotify client configuration. Default is 5.
Reference: https://gotify.net/docs/msgextras
</ConfigSection>
</div>
</div>