diff --git a/code/backend/Cleanuparr.Api/DependencyInjection/NotificationsDI.cs b/code/backend/Cleanuparr.Api/DependencyInjection/NotificationsDI.cs index 99803634..a33b3d3a 100644 --- a/code/backend/Cleanuparr.Api/DependencyInjection/NotificationsDI.cs +++ b/code/backend/Cleanuparr.Api/DependencyInjection/NotificationsDI.cs @@ -1,5 +1,6 @@ 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; @@ -18,6 +19,7 @@ public static class NotificationsDI .AddScoped() .AddScoped() .AddScoped() + .AddScoped() .AddScoped() .AddScoped() .AddScoped() diff --git a/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/CreateDiscordProviderRequest.cs b/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/CreateDiscordProviderRequest.cs new file mode 100644 index 00000000..ae54e154 --- /dev/null +++ b/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/CreateDiscordProviderRequest.cs @@ -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; +} diff --git a/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/TestDiscordProviderRequest.cs b/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/TestDiscordProviderRequest.cs new file mode 100644 index 00000000..061ab31d --- /dev/null +++ b/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/TestDiscordProviderRequest.cs @@ -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; +} diff --git a/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/UpdateDiscordProviderRequest.cs b/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/UpdateDiscordProviderRequest.cs new file mode 100644 index 00000000..a2af876d --- /dev/null +++ b/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/UpdateDiscordProviderRequest.cs @@ -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; +} diff --git a/code/backend/Cleanuparr.Api/Features/Notifications/Controllers/NotificationProvidersController.cs b/code/backend/Cleanuparr.Api/Features/Notifications/Controllers/NotificationProvidersController.cs index 41b1fe93..1d92e346 100644 --- a/code/backend/Cleanuparr.Api/Features/Notifications/Controllers/NotificationProvidersController.cs +++ b/code/backend/Cleanuparr.Api/Features/Notifications/Controllers/NotificationProvidersController.cs @@ -5,6 +5,7 @@ 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.Persistence; @@ -50,6 +51,7 @@ public sealed class NotificationProvidersController : ControllerBase .Include(p => p.NtfyConfiguration) .Include(p => p.PushoverConfiguration) .Include(p => p.TelegramConfiguration) + .Include(p => p.DiscordConfiguration) .AsNoTracking() .ToListAsync(); @@ -76,6 +78,7 @@ 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(), _ => new object() } }) @@ -694,6 +697,7 @@ public sealed class NotificationProvidersController : ControllerBase .Include(p => p.NtfyConfiguration) .Include(p => p.PushoverConfiguration) .Include(p => p.TelegramConfiguration) + .Include(p => p.DiscordConfiguration) .FirstOrDefaultAsync(p => p.Id == id); if (existingProvider == null) @@ -926,11 +930,200 @@ 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(), _ => new object() } }; } + [HttpPost("discord")] + public async Task 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 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 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 CreatePushoverProvider([FromBody] CreatePushoverProviderRequest newProvider) { diff --git a/code/backend/Cleanuparr.Domain/Enums/NotificationProviderType.cs b/code/backend/Cleanuparr.Domain/Enums/NotificationProviderType.cs index 17d6005e..5a87945a 100644 --- a/code/backend/Cleanuparr.Domain/Enums/NotificationProviderType.cs +++ b/code/backend/Cleanuparr.Domain/Enums/NotificationProviderType.cs @@ -7,4 +7,5 @@ public enum NotificationProviderType Ntfy, Pushover, Telegram, + Discord, } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/Discord/DiscordProxyTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/Discord/DiscordProxyTests.cs new file mode 100644 index 00000000..14780170 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/Discord/DiscordProxyTests.cs @@ -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> _loggerMock; + private readonly Mock _httpClientFactoryMock; + private readonly Mock _httpMessageHandlerMock; + + public DiscordProxyTests() + { + _loggerMock = new Mock>(); + _httpMessageHandlerMock = new Mock(); + _httpClientFactoryMock = new Mock(); + + 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 + { + 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>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((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>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((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>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((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(), + It.Is((v, t) => v.ToString()!.Contains("sending notification")), + It.IsAny(), + It.IsAny>()), + 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(() => + 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(() => + 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(() => + 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(() => + 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(() => + 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>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ThrowsAsync(new HttpRequestException("Network error")); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => + proxy.SendNotification(CreatePayload(), CreateConfig())); + Assert.Contains("unable to send notification", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + #endregion + + #region Helper Methods + + private void SetupSuccessResponse() + { + _httpMessageHandlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); + } + + private void SetupErrorResponse(HttpStatusCode statusCode) + { + _httpMessageHandlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ThrowsAsync(new HttpRequestException("Error", null, statusCode)); + } + + #endregion +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/Notifiarr/NotifiarrProxyTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/Notifiarr/NotifiarrProxyTests.cs index 09018d17..1e22e983 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/Notifiarr/NotifiarrProxyTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/Notifiarr/NotifiarrProxyTests.cs @@ -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" }, diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationConfigurationServiceTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationConfigurationServiceTests.cs index 07bf9a1f..2d51b077 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationConfigurationServiceTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationConfigurationServiceTests.cs @@ -364,6 +364,7 @@ public class NotificationConfigurationServiceTests : IDisposable [InlineData(NotificationProviderType.Ntfy)] [InlineData(NotificationProviderType.Pushover)] [InlineData(NotificationProviderType.Telegram)] + [InlineData(NotificationProviderType.Discord)] public async Task GetActiveProvidersAsync_MapsProviderTypeCorrectly(NotificationProviderType providerType) { // Arrange @@ -388,6 +389,7 @@ public class NotificationConfigurationServiceTests : IDisposable [InlineData(NotificationProviderType.Ntfy)] [InlineData(NotificationProviderType.Pushover)] [InlineData(NotificationProviderType.Telegram)] + [InlineData(NotificationProviderType.Discord)] public async Task GetProvidersForEventAsync_ReturnsProviderForAllTypes(NotificationProviderType providerType) { // Arrange @@ -420,6 +422,7 @@ public class NotificationConfigurationServiceTests : IDisposable NotificationProviderType.Ntfy => CreateNtfyConfig(name, isEnabled), NotificationProviderType.Pushover => CreatePushoverConfig(name, isEnabled), NotificationProviderType.Telegram => CreateTelegramConfig(name, isEnabled), + NotificationProviderType.Discord => CreateDiscordConfig(name, isEnabled), _ => throw new ArgumentOutOfRangeException(nameof(providerType)) }; } @@ -549,5 +552,29 @@ 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", + } + }; + } + #endregion } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationProviderFactoryTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationProviderFactoryTests.cs index 8ddae97f..6e31ce42 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationProviderFactoryTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationProviderFactoryTests.cs @@ -1,6 +1,7 @@ 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.Models; using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr; using Cleanuparr.Infrastructure.Features.Notifications.Ntfy; @@ -21,6 +22,7 @@ public class NotificationProviderFactoryTests private readonly Mock _notifiarrProxyMock; private readonly Mock _pushoverProxyMock; private readonly Mock _telegramProxyMock; + private readonly Mock _discordProxyMock; private readonly IServiceProvider _serviceProvider; private readonly NotificationProviderFactory _factory; @@ -32,6 +34,7 @@ public class NotificationProviderFactoryTests _notifiarrProxyMock = new Mock(); _pushoverProxyMock = new Mock(); _telegramProxyMock = new Mock(); + _discordProxyMock = new Mock(); var services = new ServiceCollection(); services.AddSingleton(_appriseProxyMock.Object); @@ -40,6 +43,7 @@ public class NotificationProviderFactoryTests services.AddSingleton(_notifiarrProxyMock.Object); services.AddSingleton(_pushoverProxyMock.Object); services.AddSingleton(_telegramProxyMock.Object); + services.AddSingleton(_discordProxyMock.Object); _serviceProvider = services.BuildServiceProvider(); _factory = new NotificationProviderFactory(_serviceProvider); @@ -194,6 +198,35 @@ 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(provider); + Assert.Equal("TestDiscord", provider.Name); + Assert.Equal(NotificationProviderType.Discord, provider.Type); + } + [Fact] public void CreateProvider_UnsupportedType_ThrowsNotSupportedException() { diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Discord/DiscordException.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Discord/DiscordException.cs new file mode 100644 index 00000000..b1c638cc --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Discord/DiscordException.cs @@ -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) + { + } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Discord/DiscordPayload.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Discord/DiscordPayload.cs new file mode 100644 index 00000000..4c1023b7 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Discord/DiscordPayload.cs @@ -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 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 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; } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Discord/DiscordProvider.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Discord/DiscordProvider.cs new file mode 100644 index 00000000..8cb05a61 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Discord/DiscordProvider.cs @@ -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 +{ + 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 { 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 BuildFields(NotificationContext context) + { + var fields = new List(); + + foreach ((string key, string value) in context.Data) + { + fields.Add(new DiscordField + { + Name = key, + Value = value, + Inline = false + }); + } + + return fields; + } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Discord/DiscordProxy.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Discord/DiscordProxy.cs new file mode 100644 index 00000000..fea16163 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Discord/DiscordProxy.cs @@ -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 _logger; + private readonly HttpClient _httpClient; + + public DiscordProxy(ILogger 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); + } + } + } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Discord/IDiscordProxy.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Discord/IDiscordProxy.cs new file mode 100644 index 00000000..5cda7cd5 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Discord/IDiscordProxy.cs @@ -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); +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Notifiarr/NotifiarrPayload.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Notifiarr/NotifiarrPayload.cs index 7da2a3f7..39af0dfc 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Notifiarr/NotifiarrPayload.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Notifiarr/NotifiarrPayload.cs @@ -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; } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Notifiarr/NotifiarrProvider.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Notifiarr/NotifiarrProvider.cs index 7ff4137a..2014b649 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Notifiarr/NotifiarrProvider.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Notifiarr/NotifiarrProvider.cs @@ -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 "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 p.NtfyConfiguration) .Include(p => p.PushoverConfiguration) .Include(p => p.TelegramConfiguration) + .Include(p => p.DiscordConfiguration) .AsNoTracking() .ToListAsync(); @@ -139,6 +140,7 @@ public sealed class NotificationConfigurationService : INotificationConfiguratio NotificationProviderType.Ntfy => config.NtfyConfiguration, NotificationProviderType.Pushover => config.PushoverConfiguration, NotificationProviderType.Telegram => config.TelegramConfiguration, + NotificationProviderType.Discord => config.DiscordConfiguration, _ => throw new ArgumentOutOfRangeException(nameof(config), $"Config type for provider type {config.Type.ToString()} is not registered") }; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationProviderFactory.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationProviderFactory.cs index 630b6268..c82f67de 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationProviderFactory.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationProviderFactory.cs @@ -1,6 +1,7 @@ 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; @@ -29,6 +30,7 @@ public sealed class NotificationProviderFactory : INotificationProviderFactory NotificationProviderType.Ntfy => CreateNtfyProvider(config), NotificationProviderType.Pushover => CreatePushoverProvider(config), NotificationProviderType.Telegram => CreateTelegramProvider(config), + NotificationProviderType.Discord => CreateDiscordProvider(config), _ => throw new NotSupportedException($"Provider type {config.Type} is not supported") }; } @@ -73,4 +75,12 @@ 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(); + + return new DiscordProvider(config.Name, config.Type, discordConfig, proxy); + } } diff --git a/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/Notification/DiscordConfigTests.cs b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/Notification/DiscordConfigTests.cs new file mode 100644 index 00000000..a0c31ac5 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/Notification/DiscordConfigTests.cs @@ -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(() => 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(() => 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(() => 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 +} diff --git a/code/backend/Cleanuparr.Persistence/DataContext.cs b/code/backend/Cleanuparr.Persistence/DataContext.cs index 3103ecc8..ecb6a304 100644 --- a/code/backend/Cleanuparr.Persistence/DataContext.cs +++ b/code/backend/Cleanuparr.Persistence/DataContext.cs @@ -56,6 +56,8 @@ public class DataContext : DbContext public DbSet TelegramConfigs { get; set; } + public DbSet DiscordConfigs { get; set; } + public DbSet BlacklistSyncHistory { get; set; } public DbSet BlacklistSyncConfigs { get; set; } @@ -156,6 +158,11 @@ public class DataContext : DbContext .HasForeignKey(c => c.NotificationConfigId) .OnDelete(DeleteBehavior.Cascade); + entity.HasOne(p => p.DiscordConfiguration) + .WithOne(c => c.NotificationConfig) + .HasForeignKey(c => c.NotificationConfigId) + .OnDelete(DeleteBehavior.Cascade); + entity.HasIndex(p => p.Name).IsUnique(); }); diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Data/20260112102214_AddDiscord.Designer.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/20260112102214_AddDiscord.Designer.cs new file mode 100644 index 00000000..238cc2e0 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/20260112102214_AddDiscord.Designer.cs @@ -0,0 +1,1218 @@ +// +using System; +using System.Collections.Generic; +using Cleanuparr.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Cleanuparr.Persistence.Migrations.Data +{ + [DbContext(typeof(DataContext))] + [Migration("20260112102214_AddDiscord")] + partial class AddDiscord + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.1"); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("FailedImportMaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_max_strikes"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_arr_configs"); + + b.ToTable("arr_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("api_key"); + + b.Property("ArrConfigId") + .HasColumnType("TEXT") + .HasColumnName("arr_config_id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("url"); + + b.Property("Version") + .HasColumnType("REAL") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_arr_instances"); + + b.HasIndex("ArrConfigId") + .HasDatabaseName("ix_arr_instances_arr_config_id"); + + b.ToTable("arr_instances", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.BlacklistSync.BlacklistSyncConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("BlacklistPath") + .HasColumnType("TEXT") + .HasColumnName("blacklist_path"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.HasKey("Id") + .HasName("pk_blacklist_sync_configs"); + + b.ToTable("blacklist_sync_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("delete_private"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.PrimitiveCollection("UnlinkedCategories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("unlinked_categories"); + + b.Property("UnlinkedEnabled") + .HasColumnType("INTEGER") + .HasColumnName("unlinked_enabled"); + + b.PrimitiveCollection("UnlinkedIgnoredRootDirs") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("unlinked_ignored_root_dirs"); + + b.Property("UnlinkedTargetCategory") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("unlinked_target_category"); + + b.Property("UnlinkedUseTag") + .HasColumnType("INTEGER") + .HasColumnName("unlinked_use_tag"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.HasKey("Id") + .HasName("pk_download_cleaner_configs"); + + b.ToTable("download_cleaner_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.SeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DeleteSourceFiles") + .HasColumnType("INTEGER") + .HasColumnName("delete_source_files"); + + b.Property("DownloadCleanerConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_cleaner_config_id"); + + b.Property("MaxRatio") + .HasColumnType("REAL") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("REAL") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("REAL") + .HasColumnName("min_seed_time"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_seeding_rules"); + + b.HasIndex("DownloadCleanerConfigId") + .HasDatabaseName("ix_seeding_rules_download_cleaner_config_id"); + + b.ToTable("seeding_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("Host") + .HasColumnType("TEXT") + .HasColumnName("host"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Password") + .HasColumnType("TEXT") + .HasColumnName("password"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.Property("TypeName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type_name"); + + b.Property("UrlBase") + .HasColumnType("TEXT") + .HasColumnName("url_base"); + + b.Property("Username") + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_download_clients"); + + b.ToTable("download_clients", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DisplaySupportBanner") + .HasColumnType("INTEGER") + .HasColumnName("display_support_banner"); + + b.Property("DryRun") + .HasColumnType("INTEGER") + .HasColumnName("dry_run"); + + b.Property("EncryptionKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("encryption_key"); + + b.Property("HttpCertificateValidation") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("http_certificate_validation"); + + b.Property("HttpMaxRetries") + .HasColumnType("INTEGER") + .HasColumnName("http_max_retries"); + + b.Property("HttpTimeout") + .HasColumnType("INTEGER") + .HasColumnName("http_timeout"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("SearchDelay") + .HasColumnType("INTEGER") + .HasColumnName("search_delay"); + + b.Property("SearchEnabled") + .HasColumnType("INTEGER") + .HasColumnName("search_enabled"); + + b.ComplexProperty(typeof(Dictionary), "Log", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Log#LoggingConfig", b1 => + { + b1.IsRequired(); + + b1.Property("ArchiveEnabled") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_enabled"); + + b1.Property("ArchiveRetainedCount") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_retained_count"); + + b1.Property("ArchiveTimeLimitHours") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_time_limit_hours"); + + b1.Property("Level") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("log_level"); + + b1.Property("RetainedFileCount") + .HasColumnType("INTEGER") + .HasColumnName("log_retained_file_count"); + + b1.Property("RollingSizeMB") + .HasColumnType("INTEGER") + .HasColumnName("log_rolling_size_mb"); + + b1.Property("TimeLimitHours") + .HasColumnType("INTEGER") + .HasColumnName("log_time_limit_hours"); + }); + + b.HasKey("Id") + .HasName("pk_general_configs"); + + b.ToTable("general_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("DeleteKnownMalware") + .HasColumnType("INTEGER") + .HasColumnName("delete_known_malware"); + + b.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("delete_private"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("IgnorePrivate") + .HasColumnType("INTEGER") + .HasColumnName("ignore_private"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.ComplexProperty(typeof(Dictionary), "Lidarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("lidarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("lidarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("lidarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Radarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("radarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("radarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("radarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Readarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("readarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("readarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("readarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Sonarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("sonarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("sonarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("sonarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Whisparr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("whisparr_blocklist_path"); + + b1.Property("BlocklistType") + .HasColumnType("INTEGER") + .HasColumnName("whisparr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("whisparr_enabled"); + }); + + b.HasKey("Id") + .HasName("pk_content_blocker_configs"); + + b.ToTable("content_blocker_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("key"); + + b.Property("Mode") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("mode"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("ServiceUrls") + .HasMaxLength(4000) + .HasColumnType("TEXT") + .HasColumnName("service_urls"); + + b.Property("Tags") + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("url"); + + b.HasKey("Id") + .HasName("pk_apprise_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_apprise_configs_notification_config_id"); + + b.ToTable("apprise_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AvatarUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("avatar_url"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.Property("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.NotifiarrConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("api_key"); + + b.Property("ChannelId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("channel_id"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.HasKey("Id") + .HasName("pk_notifiarr_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_notifiarr_configs_notification_config_id"); + + b.ToTable("notifiarr_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER") + .HasColumnName("is_enabled"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("OnCategoryChanged") + .HasColumnType("INTEGER") + .HasColumnName("on_category_changed"); + + b.Property("OnDownloadCleaned") + .HasColumnType("INTEGER") + .HasColumnName("on_download_cleaned"); + + b.Property("OnFailedImportStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_failed_import_strike"); + + b.Property("OnQueueItemDeleted") + .HasColumnType("INTEGER") + .HasColumnName("on_queue_item_deleted"); + + b.Property("OnSlowStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_slow_strike"); + + b.Property("OnStalledStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_stalled_strike"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_notification_configs"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_notification_configs_name"); + + b.ToTable("notification_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AccessToken") + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("access_token"); + + b.Property("AuthenticationType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("authentication_type"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Password") + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("password"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("priority"); + + b.Property("ServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("server_url"); + + b.PrimitiveCollection("Tags") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.PrimitiveCollection("Topics") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("topics"); + + b.Property("Username") + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_ntfy_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_ntfy_configs_notification_config_id"); + + b.ToTable("ntfy_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiToken") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("api_token"); + + b.Property("Devices") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("devices"); + + b.Property("Expire") + .HasColumnType("INTEGER") + .HasColumnName("expire"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("priority"); + + b.Property("Retry") + .HasColumnType("INTEGER") + .HasColumnName("retry"); + + b.Property("Sound") + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("sound"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("user_key"); + + b.HasKey("Id") + .HasName("pk_pushover_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_pushover_configs_notification_config_id"); + + b.ToTable("pushover_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.TelegramConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("BotToken") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("bot_token"); + + b.Property("ChatId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("chat_id"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("SendSilently") + .HasColumnType("INTEGER") + .HasColumnName("send_silently"); + + b.Property("TopicId") + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("topic_id"); + + b.HasKey("Id") + .HasName("pk_telegram_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_telegram_configs_notification_config_id"); + + b.ToTable("telegram_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("DownloadingMetadataMaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("downloading_metadata_max_strikes"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.ComplexProperty(typeof(Dictionary), "FailedImport", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.FailedImport#FailedImportConfig", b1 => + { + b1.IsRequired(); + + b1.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_delete_private"); + + b1.Property("IgnorePrivate") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_ignore_private"); + + b1.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_max_strikes"); + + b1.Property("PatternMode") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("failed_import_pattern_mode"); + + b1.PrimitiveCollection("Patterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("failed_import_patterns"); + + b1.Property("SkipIfNotFoundInClient") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_skip_if_not_found_in_client"); + }); + + b.HasKey("Id") + .HasName("pk_queue_cleaner_configs"); + + b.ToTable("queue_cleaner_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.SlowRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DeletePrivateTorrentsFromClient") + .HasColumnType("INTEGER") + .HasColumnName("delete_private_torrents_from_client"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("IgnoreAboveSize") + .HasColumnType("TEXT") + .HasColumnName("ignore_above_size"); + + b.Property("MaxCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("max_completion_percentage"); + + b.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("max_strikes"); + + b.Property("MaxTimeHours") + .HasColumnType("REAL") + .HasColumnName("max_time_hours"); + + b.Property("MinCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("min_completion_percentage"); + + b.Property("MinSpeed") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("min_speed"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("QueueCleanerConfigId") + .HasColumnType("TEXT") + .HasColumnName("queue_cleaner_config_id"); + + b.Property("ResetStrikesOnProgress") + .HasColumnType("INTEGER") + .HasColumnName("reset_strikes_on_progress"); + + b.HasKey("Id") + .HasName("pk_slow_rules"); + + b.HasIndex("QueueCleanerConfigId") + .HasDatabaseName("ix_slow_rules_queue_cleaner_config_id"); + + b.ToTable("slow_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.StallRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DeletePrivateTorrentsFromClient") + .HasColumnType("INTEGER") + .HasColumnName("delete_private_torrents_from_client"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("MaxCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("max_completion_percentage"); + + b.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("max_strikes"); + + b.Property("MinCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("min_completion_percentage"); + + b.Property("MinimumProgress") + .HasColumnType("TEXT") + .HasColumnName("minimum_progress"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("QueueCleanerConfigId") + .HasColumnType("TEXT") + .HasColumnName("queue_cleaner_config_id"); + + b.Property("ResetStrikesOnProgress") + .HasColumnType("INTEGER") + .HasColumnName("reset_strikes_on_progress"); + + b.HasKey("Id") + .HasName("pk_stall_rules"); + + b.HasIndex("QueueCleanerConfigId") + .HasDatabaseName("ix_stall_rules_queue_cleaner_config_id"); + + b.ToTable("stall_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.BlacklistSyncHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DownloadClientId") + .HasColumnType("TEXT") + .HasColumnName("download_client_id"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("hash"); + + b.HasKey("Id") + .HasName("pk_blacklist_sync_history"); + + b.HasIndex("DownloadClientId") + .HasDatabaseName("ix_blacklist_sync_history_download_client_id"); + + b.HasIndex("Hash") + .HasDatabaseName("ix_blacklist_sync_history_hash"); + + b.HasIndex("Hash", "DownloadClientId") + .IsUnique() + .HasDatabaseName("ix_blacklist_sync_history_hash_download_client_id"); + + b.ToTable("blacklist_sync_history", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", "ArrConfig") + .WithMany("Instances") + .HasForeignKey("ArrConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_arr_instances_arr_configs_arr_config_id"); + + b.Navigation("ArrConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.SeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", "DownloadCleanerConfig") + .WithMany("Categories") + .HasForeignKey("DownloadCleanerConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seeding_rules_download_cleaner_configs_download_cleaner_config_id"); + + b.Navigation("DownloadCleanerConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("AppriseConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_apprise_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.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.NotifiarrConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("NotifiarrConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_notifiarr_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("NtfyConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_ntfy_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("PushoverConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_pushover_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.TelegramConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("TelegramConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.TelegramConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_telegram_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.SlowRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", "QueueCleanerConfig") + .WithMany("SlowRules") + .HasForeignKey("QueueCleanerConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_slow_rules_queue_cleaner_configs_queue_cleaner_config_id"); + + b.Navigation("QueueCleanerConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.StallRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", "QueueCleanerConfig") + .WithMany("StallRules") + .HasForeignKey("QueueCleanerConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_stall_rules_queue_cleaner_configs_queue_cleaner_config_id"); + + b.Navigation("QueueCleanerConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.BlacklistSyncHistory", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClient") + .WithMany() + .HasForeignKey("DownloadClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_blacklist_sync_history_download_clients_download_client_id"); + + b.Navigation("DownloadClient"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b => + { + b.Navigation("Instances"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b => + { + b.Navigation("Categories"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b => + { + b.Navigation("AppriseConfiguration"); + + b.Navigation("DiscordConfiguration"); + + b.Navigation("NotifiarrConfiguration"); + + b.Navigation("NtfyConfiguration"); + + b.Navigation("PushoverConfiguration"); + + b.Navigation("TelegramConfiguration"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b => + { + b.Navigation("SlowRules"); + + b.Navigation("StallRules"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Data/20260112102214_AddDiscord.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/20260112102214_AddDiscord.cs new file mode 100644 index 00000000..8083b8cf --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/20260112102214_AddDiscord.cs @@ -0,0 +1,49 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Cleanuparr.Persistence.Migrations.Data +{ + /// + public partial class AddDiscord : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "discord_configs", + columns: table => new + { + id = table.Column(type: "TEXT", nullable: false), + notification_config_id = table.Column(type: "TEXT", nullable: false), + webhook_url = table.Column(type: "TEXT", maxLength: 500, nullable: false), + username = table.Column(type: "TEXT", maxLength: 80, nullable: false), + avatar_url = table.Column(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); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "discord_configs"); + } + } +} diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs index faef5f2f..f228d245 100644 --- a/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs +++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs @@ -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>("Log", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Log#LoggingConfig", b1 => + b.ComplexProperty(typeof(Dictionary), "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>("Lidarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 => + b.ComplexProperty(typeof(Dictionary), "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>("Radarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 => + b.ComplexProperty(typeof(Dictionary), "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>("Readarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 => + b.ComplexProperty(typeof(Dictionary), "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>("Sonarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 => + b.ComplexProperty(typeof(Dictionary), "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>("Whisparr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 => + b.ComplexProperty(typeof(Dictionary), "Whisparr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 => { b1.IsRequired(); @@ -522,6 +522,45 @@ namespace Cleanuparr.Persistence.Migrations.Data b.ToTable("apprise_configs", (string)null); }); + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AvatarUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("avatar_url"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.Property("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.NotifiarrConfig", b => { b.Property("Id") @@ -813,7 +852,7 @@ namespace Cleanuparr.Persistence.Migrations.Data .HasColumnType("INTEGER") .HasColumnName("use_advanced_scheduling"); - b.ComplexProperty>("FailedImport", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.FailedImport#FailedImportConfig", b1 => + b.ComplexProperty(typeof(Dictionary), "FailedImport", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.FailedImport#FailedImportConfig", b1 => { b1.IsRequired(); @@ -1043,6 +1082,18 @@ 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.NotifiarrConfig", b => { b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") @@ -1141,6 +1192,8 @@ namespace Cleanuparr.Persistence.Migrations.Data { b.Navigation("AppriseConfiguration"); + b.Navigation("DiscordConfiguration"); + b.Navigation("NotifiarrConfiguration"); b.Navigation("NtfyConfiguration"); diff --git a/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/DiscordConfig.cs b/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/DiscordConfig.cs new file mode 100644 index 00000000..db2fbc4d --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/DiscordConfig.cs @@ -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"); + } + } +} diff --git a/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/NotificationConfig.cs b/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/NotificationConfig.cs index 69313ee0..018178bb 100644 --- a/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/NotificationConfig.cs +++ b/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/NotificationConfig.cs @@ -45,6 +45,8 @@ public sealed record NotificationConfig public TelegramConfig? TelegramConfiguration { get; init; } + public DiscordConfig? DiscordConfiguration { get; init; } + [NotMapped] public bool IsConfigured => Type switch { @@ -53,6 +55,7 @@ public sealed record NotificationConfig NotificationProviderType.Ntfy => NtfyConfiguration?.IsValid() == true, NotificationProviderType.Pushover => PushoverConfiguration?.IsValid() == true, NotificationProviderType.Telegram => TelegramConfiguration?.IsValid() == true, + NotificationProviderType.Discord => DiscordConfiguration?.IsValid() == true, _ => throw new ArgumentOutOfRangeException(nameof(Type), $"Invalid notification provider type {Type}") }; diff --git a/code/backend/Cleanuparr.Shared/Helpers/Constants.cs b/code/backend/Cleanuparr.Shared/Helpers/Constants.cs index fdd57344..f9caac64 100644 --- a/code/backend/Cleanuparr.Shared/Helpers/Constants.cs +++ b/code/backend/Cleanuparr.Shared/Helpers/Constants.cs @@ -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"; } \ No newline at end of file diff --git a/code/frontend/public/icons/ext/discord-light.svg b/code/frontend/public/icons/ext/discord-light.svg new file mode 100644 index 00000000..e814e170 --- /dev/null +++ b/code/frontend/public/icons/ext/discord-light.svg @@ -0,0 +1 @@ + diff --git a/code/frontend/public/icons/ext/discord.svg b/code/frontend/public/icons/ext/discord.svg new file mode 100644 index 00000000..7820733a --- /dev/null +++ b/code/frontend/public/icons/ext/discord.svg @@ -0,0 +1 @@ + diff --git a/code/frontend/src/app/core/services/documentation.service.ts b/code/frontend/src/app/core/services/documentation.service.ts index cfcd4509..4992a666 100644 --- a/code/frontend/src/app/core/services/documentation.service.ts +++ b/code/frontend/src/app/core/services/documentation.service.ts @@ -152,6 +152,11 @@ export class DocumentationService { 'telegram.topicId': 'topic-id', 'telegram.sendSilently': 'send-silently' }, + 'notifications/discord': { + 'discord.webhookUrl': 'webhook-url', + 'discord.username': 'username', + 'discord.avatarUrl': 'avatar-url' + }, }; constructor(private applicationPathService: ApplicationPathService) {} diff --git a/code/frontend/src/app/core/services/notification-provider.service.ts b/code/frontend/src/app/core/services/notification-provider.service.ts index 83778433..35ecd32e 100644 --- a/code/frontend/src/app/core/services/notification-provider.service.ts +++ b/code/frontend/src/app/core/services/notification-provider.service.ts @@ -230,6 +230,40 @@ 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; +} + @Injectable({ providedIn: 'root' }) @@ -287,6 +321,13 @@ export class NotificationProviderService { return this.http.post(`${this.baseUrl}/telegram`, provider); } + /** + * Create a new Discord provider + */ + createDiscordProvider(provider: CreateDiscordProviderRequest): Observable { + return this.http.post(`${this.baseUrl}/discord`, provider); + } + /** * Update an existing Notifiarr provider */ @@ -322,6 +363,13 @@ export class NotificationProviderService { return this.http.put(`${this.baseUrl}/telegram/${id}`, provider); } + /** + * Update an existing Discord provider + */ + updateDiscordProvider(id: string, provider: UpdateDiscordProviderRequest): Observable { + return this.http.put(`${this.baseUrl}/discord/${id}`, provider); + } + /** * Delete a notification provider */ @@ -364,6 +412,13 @@ export class NotificationProviderService { return this.http.post(`${this.baseUrl}/telegram/test`, testRequest); } + /** + * Test a Discord provider (without ID - for testing configuration before saving) + */ + testDiscordProvider(testRequest: TestDiscordProviderRequest): Observable { + return this.http.post(`${this.baseUrl}/discord/test`, testRequest); + } + /** * Generic create method that delegates to provider-specific methods */ @@ -379,6 +434,8 @@ 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); default: throw new Error(`Unsupported provider type: ${type}`); } @@ -399,6 +456,8 @@ 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); default: throw new Error(`Unsupported provider type: ${type}`); } @@ -419,6 +478,8 @@ 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); default: throw new Error(`Unsupported provider type: ${type}`); } diff --git a/code/frontend/src/app/settings/notification-settings/modals/discord-provider/discord-provider.component.html b/code/frontend/src/app/settings/notification-settings/modals/discord-provider/discord-provider.component.html new file mode 100644 index 00000000..b5fa279a --- /dev/null +++ b/code/frontend/src/app/settings/notification-settings/modals/discord-provider/discord-provider.component.html @@ -0,0 +1,71 @@ + + + +
+ +
+ + + Webhook URL is required + Must be a valid Discord webhook URL + Your Discord webhook URL. Create one in your Discord server's channel settings under Integrations. +
+ + +
+ + + Username cannot exceed 80 characters + Override the default webhook username. Leave empty to use the webhook's default name. +
+ + +
+ + + Must be a valid URL + Override the default webhook avatar. Leave empty to use the webhook's default avatar. +
+
+
diff --git a/code/frontend/src/app/settings/notification-settings/modals/discord-provider/discord-provider.component.scss b/code/frontend/src/app/settings/notification-settings/modals/discord-provider/discord-provider.component.scss new file mode 100644 index 00000000..de662032 --- /dev/null +++ b/code/frontend/src/app/settings/notification-settings/modals/discord-provider/discord-provider.component.scss @@ -0,0 +1 @@ +@use '../../../styles/settings-shared.scss'; \ No newline at end of file diff --git a/code/frontend/src/app/settings/notification-settings/modals/discord-provider/discord-provider.component.ts b/code/frontend/src/app/settings/notification-settings/modals/discord-provider/discord-provider.component.ts new file mode 100644 index 00000000..f5d93d0a --- /dev/null +++ b/code/frontend/src/app/settings/notification-settings/modals/discord-provider/discord-provider.component.ts @@ -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(); + @Output() cancel = new EventEmitter(); + @Output() test = new EventEmitter(); + + // 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(); + } + } +} diff --git a/code/frontend/src/app/settings/notification-settings/modals/provider-type-selection/provider-type-selection.component.ts b/code/frontend/src/app/settings/notification-settings/modals/provider-type-selection/provider-type-selection.component.ts index 6676419a..d71e9a22 100644 --- a/code/frontend/src/app/settings/notification-settings/modals/provider-type-selection/provider-type-selection.component.ts +++ b/code/frontend/src/app/settings/notification-settings/modals/provider-type-selection/provider-type-selection.component.ts @@ -57,8 +57,15 @@ 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' + }, + ].sort((a, b) => a.name.localeCompare(b.name)); selectProvider(type: NotificationProviderType) { this.providerSelected.emit(type); diff --git a/code/frontend/src/app/settings/notification-settings/models/provider-modal.model.ts b/code/frontend/src/app/settings/notification-settings/models/provider-modal.model.ts index ae217963..6c265ff2 100644 --- a/code/frontend/src/app/settings/notification-settings/models/provider-modal.model.ts +++ b/code/frontend/src/app/settings/notification-settings/models/provider-modal.model.ts @@ -72,6 +72,12 @@ export interface TelegramFormData extends BaseProviderFormData { sendSilently: boolean; } +export interface DiscordFormData extends BaseProviderFormData { + webhookUrl: string; + username: string; + avatarUrl: string; +} + // Events for modal communication export interface ProviderModalEvents { save: (data: any) => void; diff --git a/code/frontend/src/app/settings/notification-settings/notification-settings.component.html b/code/frontend/src/app/settings/notification-settings/notification-settings.component.html index 4420a58a..ea971bb5 100644 --- a/code/frontend/src/app/settings/notification-settings/notification-settings.component.html +++ b/code/frontend/src/app/settings/notification-settings/notification-settings.component.html @@ -135,7 +135,6 @@

Provider Type Not Yet Supported

This provider type is not yet supported by the new modal system. -
Please select Notifiarr or Apprise for now.