From cce3bb2c4a70b91070b5d36b725b91099b22026d Mon Sep 17 00:00:00 2001 From: Flaminel Date: Mon, 15 Sep 2025 22:08:48 +0300 Subject: [PATCH] Add ntfy support (#300) --- .../Controllers/ConfigurationController.cs | 263 ++++++ .../DependencyInjection/NotificationsDI.cs | 2 + .../CreateNtfyProviderDto.cs | 22 + .../TestNtfyProviderDto.cs | 22 + .../UpdateNtfyProviderDto.cs | 22 + .../Enums/NotificationProviderType.cs | 3 +- .../Enums/NtfyAuthenticationType.cs | 8 + .../Cleanuparr.Domain/Enums/NtfyPriority.cs | 10 + .../NotificationConfigurationService.cs | 2 + .../NotificationProviderFactory.cs | 10 + .../Features/Notifications/Ntfy/INtfyProxy.cs | 8 + .../Notifications/Ntfy/NtfyException.cs | 12 + .../Notifications/Ntfy/NtfyPayload.cs | 24 + .../Notifications/Ntfy/NtfyProvider.cs | 85 ++ .../Features/Notifications/Ntfy/NtfyProxy.cs | 90 ++ .../Cleanuparr.Persistence/DataContext.cs | 7 + .../Data/20250912234118_AddNtfy.Designer.cs | 822 ++++++++++++++++++ .../Migrations/Data/20250912234118_AddNtfy.cs | 54 ++ .../Data/DataContextModelSnapshot.cs | 78 +- .../Notification/NotificationConfig.cs | 3 + .../Configuration/Notification/NtfyConfig.cs | 121 +++ .../public/icons/ext/apprise-light.svg | 2 + code/frontend/public/icons/ext/apprise.svg | 1 + .../public/icons/ext/notifiarr-light.svg | 2 + code/frontend/public/icons/ext/notifiarr.svg | 1 + code/frontend/public/icons/ext/ntfy-light.svg | 2 + code/frontend/public/icons/ext/ntfy.svg | 1 + .../core/services/documentation.service.ts | 21 +- .../services/notification-provider.service.ts | 78 ++ .../download-cleaner-settings.component.html | 2 - .../download-cleaner-settings.component.ts | 12 - .../apprise-provider.component.ts | 2 +- .../notifiarr-provider.component.ts | 2 +- .../ntfy-provider.component.html | 213 +++++ .../ntfy-provider.component.scss | 1 + .../ntfy-provider/ntfy-provider.component.ts | 216 +++++ .../provider-type-selection.component.html | 49 ++ .../provider-type-selection.component.scss | 123 ++- .../provider-type-selection.component.ts | 86 +- .../models/provider-modal.model.ts | 16 +- .../notification-settings.component.html | 11 + .../notification-settings.component.ts | 122 ++- .../settings-page.component.scss | 8 +- code/frontend/src/app/shared/models/enums.ts | 1 + .../models/ntfy-authentication-type.enum.ts | 5 + .../app/shared/models/ntfy-config.model.ts | 14 + .../app/shared/models/ntfy-priority.enum.ts | 7 + code/frontend/src/styles.scss | 10 +- .../configuration/notifications/apprise.mdx | 59 ++ .../configuration/notifications/index.mdx | 87 +- .../configuration/notifications/notifiarr.mdx | 54 ++ .../docs/configuration/notifications/ntfy.mdx | 121 +++ 52 files changed, 2802 insertions(+), 195 deletions(-) create mode 100644 code/backend/Cleanuparr.Api/Models/NotificationProviders/CreateNtfyProviderDto.cs create mode 100644 code/backend/Cleanuparr.Api/Models/NotificationProviders/TestNtfyProviderDto.cs create mode 100644 code/backend/Cleanuparr.Api/Models/NotificationProviders/UpdateNtfyProviderDto.cs create mode 100644 code/backend/Cleanuparr.Domain/Enums/NtfyAuthenticationType.cs create mode 100644 code/backend/Cleanuparr.Domain/Enums/NtfyPriority.cs create mode 100644 code/backend/Cleanuparr.Infrastructure/Features/Notifications/Ntfy/INtfyProxy.cs create mode 100644 code/backend/Cleanuparr.Infrastructure/Features/Notifications/Ntfy/NtfyException.cs create mode 100644 code/backend/Cleanuparr.Infrastructure/Features/Notifications/Ntfy/NtfyPayload.cs create mode 100644 code/backend/Cleanuparr.Infrastructure/Features/Notifications/Ntfy/NtfyProvider.cs create mode 100644 code/backend/Cleanuparr.Infrastructure/Features/Notifications/Ntfy/NtfyProxy.cs create mode 100644 code/backend/Cleanuparr.Persistence/Migrations/Data/20250912234118_AddNtfy.Designer.cs create mode 100644 code/backend/Cleanuparr.Persistence/Migrations/Data/20250912234118_AddNtfy.cs create mode 100644 code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/NtfyConfig.cs create mode 100644 code/frontend/public/icons/ext/apprise-light.svg create mode 100644 code/frontend/public/icons/ext/apprise.svg create mode 100644 code/frontend/public/icons/ext/notifiarr-light.svg create mode 100644 code/frontend/public/icons/ext/notifiarr.svg create mode 100644 code/frontend/public/icons/ext/ntfy-light.svg create mode 100644 code/frontend/public/icons/ext/ntfy.svg create mode 100644 code/frontend/src/app/settings/notification-settings/modals/ntfy-provider/ntfy-provider.component.html create mode 100644 code/frontend/src/app/settings/notification-settings/modals/ntfy-provider/ntfy-provider.component.scss create mode 100644 code/frontend/src/app/settings/notification-settings/modals/ntfy-provider/ntfy-provider.component.ts create mode 100644 code/frontend/src/app/settings/notification-settings/modals/provider-type-selection/provider-type-selection.component.html create mode 100644 code/frontend/src/app/shared/models/ntfy-authentication-type.enum.ts create mode 100644 code/frontend/src/app/shared/models/ntfy-config.model.ts create mode 100644 code/frontend/src/app/shared/models/ntfy-priority.enum.ts create mode 100644 docs/docs/configuration/notifications/apprise.mdx create mode 100644 docs/docs/configuration/notifications/notifiarr.mdx create mode 100644 docs/docs/configuration/notifications/ntfy.mdx diff --git a/code/backend/Cleanuparr.Api/Controllers/ConfigurationController.cs b/code/backend/Cleanuparr.Api/Controllers/ConfigurationController.cs index 4605fe2c..342a3b92 100644 --- a/code/backend/Cleanuparr.Api/Controllers/ConfigurationController.cs +++ b/code/backend/Cleanuparr.Api/Controllers/ConfigurationController.cs @@ -405,6 +405,7 @@ public class ConfigurationController : ControllerBase var providers = await _dataContext.NotificationConfigs .Include(p => p.NotifiarrConfiguration) .Include(p => p.AppriseConfiguration) + .Include(p => p.NtfyConfiguration) .AsNoTracking() .ToListAsync(); @@ -428,6 +429,7 @@ public class ConfigurationController : ControllerBase { NotificationProviderType.Notifiarr => p.NotifiarrConfiguration ?? new object(), NotificationProviderType.Apprise => p.AppriseConfiguration ?? new object(), + NotificationProviderType.Ntfy => p.NtfyConfiguration ?? new object(), _ => new object() } }) @@ -846,6 +848,7 @@ public class ConfigurationController : ControllerBase var existingProvider = await _dataContext.NotificationConfigs .Include(p => p.NotifiarrConfiguration) .Include(p => p.AppriseConfiguration) + .Include(p => p.NtfyConfiguration) .FirstOrDefaultAsync(p => p.Id == id); if (existingProvider == null) @@ -970,6 +973,266 @@ public class ConfigurationController : ControllerBase } } + [HttpPost("notification_providers/ntfy")] + public async Task CreateNtfyProvider([FromBody] CreateNtfyProviderDto newProvider) + { + await DataContext.Lock.WaitAsync(); + try + { + // Validate required fields + 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"); + } + + // Create provider-specific configuration with validation + var ntfyConfig = new NtfyConfig + { + ServerUrl = newProvider.ServerUrl, + Topics = newProvider.Topics, + AuthenticationType = newProvider.AuthenticationType, + Username = newProvider.Username, + Password = newProvider.Password, + AccessToken = newProvider.AccessToken, + Priority = newProvider.Priority, + Tags = newProvider.Tags + }; + + // Validate the configuration + ntfyConfig.Validate(); + + // Create the provider entity + var provider = new NotificationConfig + { + Name = newProvider.Name, + Type = NotificationProviderType.Ntfy, + IsEnabled = newProvider.IsEnabled, + OnFailedImportStrike = newProvider.OnFailedImportStrike, + OnStalledStrike = newProvider.OnStalledStrike, + OnSlowStrike = newProvider.OnSlowStrike, + OnQueueItemDeleted = newProvider.OnQueueItemDeleted, + OnDownloadCleaned = newProvider.OnDownloadCleaned, + OnCategoryChanged = newProvider.OnCategoryChanged, + NtfyConfiguration = ntfyConfig + }; + + // Add the new provider to the database + _dataContext.NotificationConfigs.Add(provider); + await _dataContext.SaveChangesAsync(); + + // Clear cache to ensure fresh data on next request + await _notificationConfigurationService.InvalidateCacheAsync(); + + // Return the provider in DTO format to match frontend expectations + var providerDto = new NotificationProviderDto + { + Id = provider.Id, + Name = provider.Name, + Type = provider.Type, + IsEnabled = provider.IsEnabled, + Events = new NotificationEventFlags + { + OnFailedImportStrike = provider.OnFailedImportStrike, + OnStalledStrike = provider.OnStalledStrike, + OnSlowStrike = provider.OnSlowStrike, + OnQueueItemDeleted = provider.OnQueueItemDeleted, + OnDownloadCleaned = provider.OnDownloadCleaned, + OnCategoryChanged = provider.OnCategoryChanged + }, + Configuration = provider.NtfyConfiguration ?? new object() + }; + + 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 Ntfy provider"); + throw; + } + finally + { + DataContext.Lock.Release(); + } + } + + [HttpPut("notification_providers/ntfy/{id}")] + public async Task UpdateNtfyProvider(Guid id, [FromBody] UpdateNtfyProviderDto updatedProvider) + { + await DataContext.Lock.WaitAsync(); + try + { + // Find the existing notification provider + var existingProvider = await _dataContext.NotificationConfigs + .Include(p => p.NtfyConfiguration) + .FirstOrDefaultAsync(p => p.Id == id && p.Type == NotificationProviderType.Ntfy); + + if (existingProvider == null) + { + return NotFound($"Ntfy provider with ID {id} not found"); + } + + // Validate required fields + 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"); + } + + // Create provider-specific configuration with validation + var ntfyConfig = new NtfyConfig + { + ServerUrl = updatedProvider.ServerUrl, + Topics = updatedProvider.Topics, + AuthenticationType = updatedProvider.AuthenticationType, + Username = updatedProvider.Username, + Password = updatedProvider.Password, + AccessToken = updatedProvider.AccessToken, + Priority = updatedProvider.Priority, + Tags = updatedProvider.Tags + }; + + // Preserve the existing ID if updating + if (existingProvider.NtfyConfiguration != null) + { + ntfyConfig = ntfyConfig with { Id = existingProvider.NtfyConfiguration.Id }; + } + + // Validate the configuration + ntfyConfig.Validate(); + + // Create a new provider entity with updated values (records are immutable) + 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, + NtfyConfiguration = ntfyConfig, + UpdatedAt = DateTime.UtcNow + }; + + // Remove old and add new (EF handles this as an update) + _dataContext.NotificationConfigs.Remove(existingProvider); + _dataContext.NotificationConfigs.Add(newProvider); + + // Persist the configuration + await _dataContext.SaveChangesAsync(); + + // Clear cache to ensure fresh data on next request + await _notificationConfigurationService.InvalidateCacheAsync(); + + // Return the provider in DTO format to match frontend expectations + var providerDto = new NotificationProviderDto + { + Id = newProvider.Id, + Name = newProvider.Name, + Type = newProvider.Type, + IsEnabled = newProvider.IsEnabled, + Events = new NotificationEventFlags + { + OnFailedImportStrike = newProvider.OnFailedImportStrike, + OnStalledStrike = newProvider.OnStalledStrike, + OnSlowStrike = newProvider.OnSlowStrike, + OnQueueItemDeleted = newProvider.OnQueueItemDeleted, + OnDownloadCleaned = newProvider.OnDownloadCleaned, + OnCategoryChanged = newProvider.OnCategoryChanged + }, + Configuration = newProvider.NtfyConfiguration ?? new object() + }; + + return Ok(providerDto); + } + catch (ValidationException ex) + { + return BadRequest(ex.Message); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update Ntfy provider with ID {Id}", id); + throw; + } + finally + { + DataContext.Lock.Release(); + } + } + + [HttpPost("notification_providers/ntfy/test")] + public async Task TestNtfyProvider([FromBody] TestNtfyProviderDto testRequest) + { + try + { + // Create configuration for testing with validation + var ntfyConfig = new NtfyConfig + { + ServerUrl = testRequest.ServerUrl, + Topics = testRequest.Topics, + AuthenticationType = testRequest.AuthenticationType, + Username = testRequest.Username, + Password = testRequest.Password, + AccessToken = testRequest.AccessToken, + Priority = testRequest.Priority, + Tags = testRequest.Tags + }; + + // Validate the configuration + ntfyConfig.Validate(); + + // Create a temporary provider DTO for the test service + var providerDto = new NotificationProviderDto + { + Id = Guid.NewGuid(), // Temporary ID for testing + Name = "Test Provider", + Type = NotificationProviderType.Ntfy, + IsEnabled = true, + Events = new NotificationEventFlags + { + OnFailedImportStrike = true, // Enable for test + OnStalledStrike = false, + OnSlowStrike = false, + OnQueueItemDeleted = false, + OnDownloadCleaned = false, + OnCategoryChanged = false + }, + Configuration = ntfyConfig + }; + + // Test the notification provider + await _notificationService.SendTestNotificationAsync(providerDto); + + return Ok(new { Message = "Test notification sent successfully", Success = true }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to test Ntfy provider"); + throw; + } + } + [HttpPut("queue_cleaner")] public async Task UpdateQueueCleanerConfig([FromBody] QueueCleanerConfig newConfig) { diff --git a/code/backend/Cleanuparr.Api/DependencyInjection/NotificationsDI.cs b/code/backend/Cleanuparr.Api/DependencyInjection/NotificationsDI.cs index d92a756f..f839db5c 100644 --- a/code/backend/Cleanuparr.Api/DependencyInjection/NotificationsDI.cs +++ b/code/backend/Cleanuparr.Api/DependencyInjection/NotificationsDI.cs @@ -1,6 +1,7 @@ using Cleanuparr.Infrastructure.Features.Notifications; using Cleanuparr.Infrastructure.Features.Notifications.Apprise; using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr; +using Cleanuparr.Infrastructure.Features.Notifications.Ntfy; namespace Cleanuparr.Api.DependencyInjection; @@ -10,6 +11,7 @@ public static class NotificationsDI services .AddScoped() .AddScoped() + .AddScoped() .AddScoped() .AddScoped() .AddScoped() diff --git a/code/backend/Cleanuparr.Api/Models/NotificationProviders/CreateNtfyProviderDto.cs b/code/backend/Cleanuparr.Api/Models/NotificationProviders/CreateNtfyProviderDto.cs new file mode 100644 index 00000000..b9dec04d --- /dev/null +++ b/code/backend/Cleanuparr.Api/Models/NotificationProviders/CreateNtfyProviderDto.cs @@ -0,0 +1,22 @@ +using Cleanuparr.Domain.Enums; + +namespace Cleanuparr.Api.Models.NotificationProviders; + +public sealed record CreateNtfyProviderDto : CreateNotificationProviderBaseDto +{ + public string ServerUrl { get; init; } = string.Empty; + + public List Topics { get; init; } = []; + + public NtfyAuthenticationType AuthenticationType { get; init; } = NtfyAuthenticationType.None; + + public string Username { get; init; } = string.Empty; + + public string Password { get; init; } = string.Empty; + + public string AccessToken { get; init; } = string.Empty; + + public NtfyPriority Priority { get; init; } = NtfyPriority.Default; + + public List Tags { get; init; } = []; +} diff --git a/code/backend/Cleanuparr.Api/Models/NotificationProviders/TestNtfyProviderDto.cs b/code/backend/Cleanuparr.Api/Models/NotificationProviders/TestNtfyProviderDto.cs new file mode 100644 index 00000000..1f751023 --- /dev/null +++ b/code/backend/Cleanuparr.Api/Models/NotificationProviders/TestNtfyProviderDto.cs @@ -0,0 +1,22 @@ +using Cleanuparr.Domain.Enums; + +namespace Cleanuparr.Api.Models.NotificationProviders; + +public sealed record TestNtfyProviderDto +{ + public string ServerUrl { get; init; } = string.Empty; + + public List Topics { get; init; } = []; + + public NtfyAuthenticationType AuthenticationType { get; init; } = NtfyAuthenticationType.None; + + public string Username { get; init; } = string.Empty; + + public string Password { get; init; } = string.Empty; + + public string AccessToken { get; init; } = string.Empty; + + public NtfyPriority Priority { get; init; } = NtfyPriority.Default; + + public List Tags { get; init; } = []; +} diff --git a/code/backend/Cleanuparr.Api/Models/NotificationProviders/UpdateNtfyProviderDto.cs b/code/backend/Cleanuparr.Api/Models/NotificationProviders/UpdateNtfyProviderDto.cs new file mode 100644 index 00000000..a42e124d --- /dev/null +++ b/code/backend/Cleanuparr.Api/Models/NotificationProviders/UpdateNtfyProviderDto.cs @@ -0,0 +1,22 @@ +using Cleanuparr.Domain.Enums; + +namespace Cleanuparr.Api.Models.NotificationProviders; + +public sealed record UpdateNtfyProviderDto : CreateNotificationProviderBaseDto +{ + public string ServerUrl { get; init; } = string.Empty; + + public List Topics { get; init; } = []; + + public NtfyAuthenticationType AuthenticationType { get; init; } = NtfyAuthenticationType.None; + + public string Username { get; init; } = string.Empty; + + public string Password { get; init; } = string.Empty; + + public string AccessToken { get; init; } = string.Empty; + + public NtfyPriority Priority { get; init; } = NtfyPriority.Default; + + public List Tags { get; init; } = []; +} diff --git a/code/backend/Cleanuparr.Domain/Enums/NotificationProviderType.cs b/code/backend/Cleanuparr.Domain/Enums/NotificationProviderType.cs index 6ecd95fe..d6df6bca 100644 --- a/code/backend/Cleanuparr.Domain/Enums/NotificationProviderType.cs +++ b/code/backend/Cleanuparr.Domain/Enums/NotificationProviderType.cs @@ -3,5 +3,6 @@ namespace Cleanuparr.Domain.Enums; public enum NotificationProviderType { Notifiarr, - Apprise + Apprise, + Ntfy } diff --git a/code/backend/Cleanuparr.Domain/Enums/NtfyAuthenticationType.cs b/code/backend/Cleanuparr.Domain/Enums/NtfyAuthenticationType.cs new file mode 100644 index 00000000..abf0766e --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Enums/NtfyAuthenticationType.cs @@ -0,0 +1,8 @@ +namespace Cleanuparr.Domain.Enums; + +public enum NtfyAuthenticationType +{ + None, + BasicAuth, + AccessToken +} diff --git a/code/backend/Cleanuparr.Domain/Enums/NtfyPriority.cs b/code/backend/Cleanuparr.Domain/Enums/NtfyPriority.cs new file mode 100644 index 00000000..23226d9f --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Enums/NtfyPriority.cs @@ -0,0 +1,10 @@ +namespace Cleanuparr.Domain.Enums; + +public enum NtfyPriority +{ + Min = 1, + Low = 2, + Default = 3, + High = 4, + Max = 5 +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationConfigurationService.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationConfigurationService.cs index 632c0a4d..d0d73159 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationConfigurationService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationConfigurationService.cs @@ -85,6 +85,7 @@ public sealed class NotificationConfigurationService : INotificationConfiguratio var providers = await _dataContext.Set() .Include(p => p.NotifiarrConfiguration) .Include(p => p.AppriseConfiguration) + .Include(p => p.NtfyConfiguration) .AsNoTracking() .ToListAsync(); @@ -133,6 +134,7 @@ public sealed class NotificationConfigurationService : INotificationConfiguratio { NotificationProviderType.Notifiarr => config.NotifiarrConfiguration, NotificationProviderType.Apprise => config.AppriseConfiguration, + NotificationProviderType.Ntfy => config.NtfyConfiguration, _ => new object() }; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationProviderFactory.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationProviderFactory.cs index 70f30b66..61122973 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationProviderFactory.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationProviderFactory.cs @@ -2,6 +2,7 @@ using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Features.Notifications.Apprise; using Cleanuparr.Infrastructure.Features.Notifications.Models; using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr; +using Cleanuparr.Infrastructure.Features.Notifications.Ntfy; using Cleanuparr.Persistence.Models.Configuration.Notification; using Microsoft.Extensions.DependencyInjection; @@ -22,6 +23,7 @@ public sealed class NotificationProviderFactory : INotificationProviderFactory { NotificationProviderType.Notifiarr => CreateNotifiarrProvider(config), NotificationProviderType.Apprise => CreateAppriseProvider(config), + NotificationProviderType.Ntfy => CreateNtfyProvider(config), _ => throw new NotSupportedException($"Provider type {config.Type} is not supported") }; } @@ -41,4 +43,12 @@ public sealed class NotificationProviderFactory : INotificationProviderFactory return new AppriseProvider(config.Name, config.Type, appriseConfig, proxy); } + + private INotificationProvider CreateNtfyProvider(NotificationProviderDto config) + { + var ntfyConfig = (NtfyConfig)config.Configuration; + var proxy = _serviceProvider.GetRequiredService(); + + return new NtfyProvider(config.Name, config.Type, ntfyConfig, proxy); + } } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Ntfy/INtfyProxy.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Ntfy/INtfyProxy.cs new file mode 100644 index 00000000..5a8d9d18 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Ntfy/INtfyProxy.cs @@ -0,0 +1,8 @@ +using Cleanuparr.Persistence.Models.Configuration.Notification; + +namespace Cleanuparr.Infrastructure.Features.Notifications.Ntfy; + +public interface INtfyProxy +{ + Task SendNotification(NtfyPayload payload, NtfyConfig config); +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Ntfy/NtfyException.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Ntfy/NtfyException.cs new file mode 100644 index 00000000..4bcc855c --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Ntfy/NtfyException.cs @@ -0,0 +1,12 @@ +namespace Cleanuparr.Infrastructure.Features.Notifications.Ntfy; + +public sealed class NtfyException : Exception +{ + public NtfyException(string message) : base(message) + { + } + + public NtfyException(string message, Exception innerException) : base(message, innerException) + { + } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Ntfy/NtfyPayload.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Ntfy/NtfyPayload.cs new file mode 100644 index 00000000..4f734286 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Ntfy/NtfyPayload.cs @@ -0,0 +1,24 @@ +using Newtonsoft.Json; + +namespace Cleanuparr.Infrastructure.Features.Notifications.Ntfy; + +public sealed class NtfyPayload +{ + [JsonProperty("topic")] + public string Topic { get; init; } = string.Empty; + + [JsonProperty("message")] + public string Message { get; init; } = string.Empty; + + [JsonProperty("title")] + public string? Title { get; init; } + + [JsonProperty("priority")] + public int? Priority { get; init; } + + [JsonProperty("tags")] + public string[]? Tags { get; init; } + + [JsonProperty("click")] + public string? Click { get; init; } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Ntfy/NtfyProvider.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Ntfy/NtfyProvider.cs new file mode 100644 index 00000000..25290928 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Ntfy/NtfyProvider.cs @@ -0,0 +1,85 @@ +using System.Text; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Features.Notifications.Models; +using Cleanuparr.Persistence.Models.Configuration.Notification; + +namespace Cleanuparr.Infrastructure.Features.Notifications.Ntfy; + +public sealed class NtfyProvider : NotificationProviderBase +{ + private readonly INtfyProxy _proxy; + + public NtfyProvider( + string name, + NotificationProviderType type, + NtfyConfig config, + INtfyProxy proxy + ) : base(name, type, config) + { + _proxy = proxy; + } + + public override async Task SendNotificationAsync(NotificationContext context) + { + var topics = GetTopics(); + var tasks = topics.Select(topic => SendToTopic(topic, context)); + await Task.WhenAll(tasks); + } + + private async Task SendToTopic(string topic, NotificationContext context) + { + NtfyPayload payload = BuildPayload(topic, context); + await _proxy.SendNotification(payload, Config); + } + + private NtfyPayload BuildPayload(string topic, NotificationContext context) + { + int priority = MapSeverityToPriority(context.Severity); + string message = BuildMessage(context); + + return new NtfyPayload + { + Topic = topic.Trim(), + Title = context.Title, + Message = message, + Priority = priority, + Tags = Config.Tags.ToArray() + }; + } + + private string BuildMessage(NotificationContext context) + { + var message = new StringBuilder(); + message.AppendLine(context.Description); + + if (context.Data.Any()) + { + message.AppendLine(); + foreach ((string key, string value) in context.Data) + { + message.AppendLine($"{key}: {value}"); + } + } + + return message.ToString().Trim(); + } + + private int MapSeverityToPriority(EventSeverity severity) + { + return severity switch + { + EventSeverity.Information => (int)Config.Priority, + EventSeverity.Warning => Math.Max((int)Config.Priority, (int)NtfyPriority.High), + EventSeverity.Important => (int)NtfyPriority.Max, + _ => (int)Config.Priority + }; + } + + private string[] GetTopics() + { + return Config.Topics + .Where(t => !string.IsNullOrWhiteSpace(t)) + .Select(t => t.Trim()) + .ToArray(); + } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Ntfy/NtfyProxy.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Ntfy/NtfyProxy.cs new file mode 100644 index 00000000..0b2e11ec --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Ntfy/NtfyProxy.cs @@ -0,0 +1,90 @@ +using System.Net.Http.Headers; +using System.Text; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Persistence.Models.Configuration.Notification; +using Cleanuparr.Shared.Helpers; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; + +namespace Cleanuparr.Infrastructure.Features.Notifications.Ntfy; + +public sealed class NtfyProxy : INtfyProxy +{ + private readonly HttpClient _httpClient; + + public NtfyProxy(IHttpClientFactory httpClientFactory) + { + _httpClient = httpClientFactory.CreateClient(Constants.HttpClientWithRetryName); + } + + public async Task SendNotification(NtfyPayload payload, NtfyConfig config) + { + try + { + string content = JsonConvert.SerializeObject(payload, new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + NullValueHandling = NullValueHandling.Ignore + }); + + var parsedUrl = config.Uri!; + using HttpRequestMessage request = new(HttpMethod.Post, parsedUrl); + request.Content = new StringContent(content, Encoding.UTF8, "application/json"); + + // Set authentication headers based on configuration + SetAuthenticationHeaders(request, config); + + using HttpResponseMessage response = await _httpClient.SendAsync(request); + response.EnsureSuccessStatusCode(); + } + catch (HttpRequestException exception) + { + if (exception.StatusCode is null) + { + throw new NtfyException("Unable to send notification", exception); + } + + switch ((int)exception.StatusCode) + { + case 400: + throw new NtfyException("Bad request - invalid topic or payload", exception); + case 401: + throw new NtfyException("Unauthorized - invalid credentials", exception); + case 413: + throw new NtfyException("Payload too large", exception); + case 429: + throw new NtfyException("Rate limited - too many requests", exception); + case 507: + throw new NtfyException("Insufficient storage on server", exception); + default: + throw new NtfyException("Unable to send notification", exception); + } + } + } + + private static void SetAuthenticationHeaders(HttpRequestMessage request, NtfyConfig config) + { + switch (config.AuthenticationType) + { + case NtfyAuthenticationType.BasicAuth: + if (!string.IsNullOrWhiteSpace(config.Username) && !string.IsNullOrWhiteSpace(config.Password)) + { + var credentials = Convert.ToBase64String(Encoding.ASCII.GetBytes($"{config.Username}:{config.Password}")); + request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials); + } + break; + + case NtfyAuthenticationType.AccessToken: + if (!string.IsNullOrWhiteSpace(config.AccessToken)) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", config.AccessToken); + } + break; + + case NtfyAuthenticationType.None: + default: + // No authentication required + break; + } + } +} diff --git a/code/backend/Cleanuparr.Persistence/DataContext.cs b/code/backend/Cleanuparr.Persistence/DataContext.cs index 44bb4b10..6e192eac 100644 --- a/code/backend/Cleanuparr.Persistence/DataContext.cs +++ b/code/backend/Cleanuparr.Persistence/DataContext.cs @@ -44,6 +44,8 @@ public class DataContext : DbContext public DbSet NotifiarrConfigs { get; set; } public DbSet AppriseConfigs { get; set; } + + public DbSet NtfyConfigs { get; set; } public DbSet BlacklistSyncHistory { get; set; } @@ -129,6 +131,11 @@ public class DataContext : DbContext .HasForeignKey(c => c.NotificationConfigId) .OnDelete(DeleteBehavior.Cascade); + entity.HasOne(p => p.NtfyConfiguration) + .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/20250912234118_AddNtfy.Designer.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/20250912234118_AddNtfy.Designer.cs new file mode 100644 index 00000000..4bc49889 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/20250912234118_AddNtfy.Designer.cs @@ -0,0 +1,822 @@ +// +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("20250912234118_AddNtfy")] + partial class AddNtfy + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.6"); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("FailedImportMaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_max_strikes"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_arr_configs"); + + b.ToTable("arr_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("api_key"); + + b.Property("ArrConfigId") + .HasColumnType("TEXT") + .HasColumnName("arr_config_id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("url"); + + b.HasKey("Id") + .HasName("pk_arr_instances"); + + b.HasIndex("ArrConfigId") + .HasDatabaseName("ix_arr_instances_arr_config_id"); + + b.ToTable("arr_instances", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.CleanCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DownloadCleanerConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_cleaner_config_id"); + + b.Property("MaxRatio") + .HasColumnType("REAL") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("REAL") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("REAL") + .HasColumnName("min_seed_time"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_clean_categories"); + + b.HasIndex("DownloadCleanerConfigId") + .HasDatabaseName("ix_clean_categories_download_cleaner_config_id"); + + b.ToTable("clean_categories", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("delete_private"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.PrimitiveCollection("UnlinkedCategories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("unlinked_categories"); + + b.Property("UnlinkedEnabled") + .HasColumnType("INTEGER") + .HasColumnName("unlinked_enabled"); + + b.Property("UnlinkedIgnoredRootDir") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("unlinked_ignored_root_dir"); + + b.Property("UnlinkedTargetCategory") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("unlinked_target_category"); + + b.Property("UnlinkedUseTag") + .HasColumnType("INTEGER") + .HasColumnName("unlinked_use_tag"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.HasKey("Id") + .HasName("pk_download_cleaner_configs"); + + b.ToTable("download_cleaner_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("Host") + .HasColumnType("TEXT") + .HasColumnName("host"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Password") + .HasColumnType("TEXT") + .HasColumnName("password"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.Property("TypeName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type_name"); + + b.Property("UrlBase") + .HasColumnType("TEXT") + .HasColumnName("url_base"); + + b.Property("Username") + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_download_clients"); + + b.ToTable("download_clients", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DisplaySupportBanner") + .HasColumnType("INTEGER") + .HasColumnName("display_support_banner"); + + b.Property("DryRun") + .HasColumnType("INTEGER") + .HasColumnName("dry_run"); + + b.Property("EncryptionKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("encryption_key"); + + b.Property("HttpCertificateValidation") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("http_certificate_validation"); + + b.Property("HttpMaxRetries") + .HasColumnType("INTEGER") + .HasColumnName("http_max_retries"); + + b.Property("HttpTimeout") + .HasColumnType("INTEGER") + .HasColumnName("http_timeout"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("SearchDelay") + .HasColumnType("INTEGER") + .HasColumnName("search_delay"); + + b.Property("SearchEnabled") + .HasColumnType("INTEGER") + .HasColumnName("search_enabled"); + + b.ComplexProperty>("Log", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Log#LoggingConfig", b1 => + { + b1.IsRequired(); + + b1.Property("ArchiveEnabled") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_enabled"); + + b1.Property("ArchiveRetainedCount") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_retained_count"); + + b1.Property("ArchiveTimeLimitHours") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_time_limit_hours"); + + b1.Property("Level") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("log_level"); + + b1.Property("RetainedFileCount") + .HasColumnType("INTEGER") + .HasColumnName("log_retained_file_count"); + + b1.Property("RollingSizeMB") + .HasColumnType("INTEGER") + .HasColumnName("log_rolling_size_mb"); + + b1.Property("TimeLimitHours") + .HasColumnType("INTEGER") + .HasColumnName("log_time_limit_hours"); + }); + + b.HasKey("Id") + .HasName("pk_general_configs"); + + b.ToTable("general_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("DeleteKnownMalware") + .HasColumnType("INTEGER") + .HasColumnName("delete_known_malware"); + + b.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("delete_private"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("IgnorePrivate") + .HasColumnType("INTEGER") + .HasColumnName("ignore_private"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.ComplexProperty>("Lidarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("lidarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("lidarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("lidarr_enabled"); + }); + + b.ComplexProperty>("Radarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("radarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("radarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("radarr_enabled"); + }); + + b.ComplexProperty>("Readarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("readarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("readarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("readarr_enabled"); + }); + + b.ComplexProperty>("Sonarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("sonarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("sonarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("sonarr_enabled"); + }); + + b.ComplexProperty>("Whisparr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("whisparr_blocklist_path"); + + b1.Property("BlocklistType") + .HasColumnType("INTEGER") + .HasColumnName("whisparr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("whisparr_enabled"); + }); + + b.HasKey("Id") + .HasName("pk_content_blocker_configs"); + + b.ToTable("content_blocker_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("key"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Tags") + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("url"); + + b.HasKey("Id") + .HasName("pk_apprise_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_apprise_configs_notification_config_id"); + + b.ToTable("apprise_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("api_key"); + + b.Property("ChannelId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("channel_id"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.HasKey("Id") + .HasName("pk_notifiarr_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_notifiarr_configs_notification_config_id"); + + b.ToTable("notifiarr_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER") + .HasColumnName("is_enabled"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("OnCategoryChanged") + .HasColumnType("INTEGER") + .HasColumnName("on_category_changed"); + + b.Property("OnDownloadCleaned") + .HasColumnType("INTEGER") + .HasColumnName("on_download_cleaned"); + + b.Property("OnFailedImportStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_failed_import_strike"); + + b.Property("OnQueueItemDeleted") + .HasColumnType("INTEGER") + .HasColumnName("on_queue_item_deleted"); + + b.Property("OnSlowStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_slow_strike"); + + b.Property("OnStalledStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_stalled_strike"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_notification_configs"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_notification_configs_name"); + + b.ToTable("notification_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AccessToken") + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("access_token"); + + b.Property("AuthenticationType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("authentication_type"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Password") + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("password"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("priority"); + + b.Property("ServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("server_url"); + + b.PrimitiveCollection("Tags") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.PrimitiveCollection("Topics") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("topics"); + + b.Property("Username") + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_ntfy_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_ntfy_configs_notification_config_id"); + + b.ToTable("ntfy_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.ComplexProperty>("FailedImport", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.FailedImport#FailedImportConfig", b1 => + { + b1.IsRequired(); + + b1.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_delete_private"); + + b1.Property("IgnorePrivate") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_ignore_private"); + + b1.PrimitiveCollection("IgnoredPatterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("failed_import_ignored_patterns"); + + b1.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_max_strikes"); + }); + + b.ComplexProperty>("Slow", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.Slow#SlowConfig", b1 => + { + b1.IsRequired(); + + b1.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("slow_delete_private"); + + b1.Property("IgnoreAboveSize") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("slow_ignore_above_size"); + + b1.Property("IgnorePrivate") + .HasColumnType("INTEGER") + .HasColumnName("slow_ignore_private"); + + b1.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("slow_max_strikes"); + + b1.Property("MaxTime") + .HasColumnType("REAL") + .HasColumnName("slow_max_time"); + + b1.Property("MinSpeed") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("slow_min_speed"); + + b1.Property("ResetStrikesOnProgress") + .HasColumnType("INTEGER") + .HasColumnName("slow_reset_strikes_on_progress"); + }); + + b.ComplexProperty>("Stalled", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.Stalled#StalledConfig", b1 => + { + b1.IsRequired(); + + b1.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("stalled_delete_private"); + + b1.Property("DownloadingMetadataMaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("stalled_downloading_metadata_max_strikes"); + + b1.Property("IgnorePrivate") + .HasColumnType("INTEGER") + .HasColumnName("stalled_ignore_private"); + + b1.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("stalled_max_strikes"); + + b1.Property("ResetStrikesOnProgress") + .HasColumnType("INTEGER") + .HasColumnName("stalled_reset_strikes_on_progress"); + }); + + b.HasKey("Id") + .HasName("pk_queue_cleaner_configs"); + + b.ToTable("queue_cleaner_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", "ArrConfig") + .WithMany("Instances") + .HasForeignKey("ArrConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_arr_instances_arr_configs_arr_config_id"); + + b.Navigation("ArrConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.CleanCategory", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", "DownloadCleanerConfig") + .WithMany("Categories") + .HasForeignKey("DownloadCleanerConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_clean_categories_download_cleaner_configs_download_cleaner_config_id"); + + b.Navigation("DownloadCleanerConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("AppriseConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_apprise_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("NotifiarrConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_notifiarr_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("NtfyConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_ntfy_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b => + { + b.Navigation("Instances"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b => + { + b.Navigation("Categories"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b => + { + b.Navigation("AppriseConfiguration"); + + b.Navigation("NotifiarrConfiguration"); + + b.Navigation("NtfyConfiguration"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Data/20250912234118_AddNtfy.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/20250912234118_AddNtfy.cs new file mode 100644 index 00000000..2008a445 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/20250912234118_AddNtfy.cs @@ -0,0 +1,54 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Cleanuparr.Persistence.Migrations.Data +{ + /// + public partial class AddNtfy : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ntfy_configs", + columns: table => new + { + id = table.Column(type: "TEXT", nullable: false), + notification_config_id = table.Column(type: "TEXT", nullable: false), + server_url = table.Column(type: "TEXT", maxLength: 500, nullable: false), + topics = table.Column(type: "TEXT", nullable: false), + authentication_type = table.Column(type: "TEXT", nullable: false), + username = table.Column(type: "TEXT", maxLength: 255, nullable: true), + password = table.Column(type: "TEXT", maxLength: 255, nullable: true), + access_token = table.Column(type: "TEXT", maxLength: 500, nullable: true), + priority = table.Column(type: "TEXT", nullable: false), + tags = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_ntfy_configs", x => x.id); + table.ForeignKey( + name: "fk_ntfy_configs_notification_configs_notification_config_id", + column: x => x.notification_config_id, + principalTable: "notification_configs", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_ntfy_configs_notification_config_id", + table: "ntfy_configs", + column: "notification_config_id", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ntfy_configs"); + } + } +} diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs index d462e5dd..8d98191b 100644 --- a/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs +++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs @@ -1,4 +1,4 @@ -// +// using System; using System.Collections.Generic; using Cleanuparr.Persistence; @@ -601,6 +601,68 @@ namespace Cleanuparr.Persistence.Migrations.Data 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.QueueCleaner.QueueCleanerConfig", b => { b.Property("Id") @@ -806,6 +868,18 @@ namespace Cleanuparr.Persistence.Migrations.Data b.Navigation("DownloadClient"); }); + 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.Arr.ArrConfig", b => { b.Navigation("Instances"); @@ -821,6 +895,8 @@ namespace Cleanuparr.Persistence.Migrations.Data b.Navigation("AppriseConfiguration"); b.Navigation("NotifiarrConfiguration"); + + b.Navigation("NtfyConfiguration"); }); #pragma warning restore 612, 618 } diff --git a/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/NotificationConfig.cs b/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/NotificationConfig.cs index 7601e46e..2c91c7dd 100644 --- a/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/NotificationConfig.cs +++ b/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/NotificationConfig.cs @@ -39,11 +39,14 @@ public sealed record NotificationConfig public AppriseConfig? AppriseConfiguration { get; init; } + public NtfyConfig? NtfyConfiguration { get; init; } + [NotMapped] public bool IsConfigured => Type switch { NotificationProviderType.Notifiarr => NotifiarrConfiguration?.IsValid() == true, NotificationProviderType.Apprise => AppriseConfiguration?.IsValid() == true, + NotificationProviderType.Ntfy => NtfyConfiguration?.IsValid() == true, _ => false }; diff --git a/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/NtfyConfig.cs b/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/NtfyConfig.cs new file mode 100644 index 00000000..62857128 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/NtfyConfig.cs @@ -0,0 +1,121 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Persistence.Models.Configuration; +using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException; + +namespace Cleanuparr.Persistence.Models.Configuration.Notification; + +public sealed record NtfyConfig : IConfig +{ + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public Guid Id { get; init; } = Guid.NewGuid(); + + [Required] + public Guid NotificationConfigId { get; init; } + + public NotificationConfig NotificationConfig { get; init; } = null!; + + [Required] + [MaxLength(500)] + public string ServerUrl { get; init; } = string.Empty; + + [Required] + public List Topics { get; init; } = new(); + + [Required] + public NtfyAuthenticationType AuthenticationType { get; init; } = NtfyAuthenticationType.None; + + [MaxLength(255)] + public string? Username { get; init; } + + [MaxLength(255)] + public string? Password { get; init; } + + [MaxLength(500)] + public string? AccessToken { get; init; } + + [Required] + public NtfyPriority Priority { get; init; } = NtfyPriority.Default; + + public List Tags { get; init; } = new(); + + [NotMapped] + public Uri? Uri + { + get + { + try + { + return string.IsNullOrWhiteSpace(ServerUrl) ? null : new Uri(ServerUrl, UriKind.Absolute); + } + catch + { + return null; + } + } + } + + public bool IsValid() + { + return Uri != null && + Topics.Any(t => !string.IsNullOrWhiteSpace(t)) && + IsAuthenticationValid(); + } + + public void Validate() + { + if (string.IsNullOrWhiteSpace(ServerUrl)) + { + throw new ValidationException("ntfy server URL is required"); + } + + if (Uri == null) + { + throw new ValidationException("ntfy server URL must be a valid HTTP or HTTPS URL"); + } + + if (!Topics.Any(t => !string.IsNullOrWhiteSpace(t))) + { + throw new ValidationException("At least one ntfy topic is required"); + } + + ValidateAuthentication(); + } + + private bool IsAuthenticationValid() + { + return AuthenticationType switch + { + NtfyAuthenticationType.None => true, + NtfyAuthenticationType.BasicAuth => !string.IsNullOrWhiteSpace(Username) && !string.IsNullOrWhiteSpace(Password), + NtfyAuthenticationType.AccessToken => !string.IsNullOrWhiteSpace(AccessToken), + _ => false + }; + } + + private void ValidateAuthentication() + { + switch (AuthenticationType) + { + case NtfyAuthenticationType.BasicAuth: + if (string.IsNullOrWhiteSpace(Username)) + { + throw new ValidationException("Username is required for Basic Auth"); + } + if (string.IsNullOrWhiteSpace(Password)) + { + throw new ValidationException("Password is required for Basic Auth"); + } + break; + + case NtfyAuthenticationType.AccessToken: + if (string.IsNullOrWhiteSpace(AccessToken)) + { + throw new ValidationException("Access token is required for Token authentication"); + } + break; + } + } +} diff --git a/code/frontend/public/icons/ext/apprise-light.svg b/code/frontend/public/icons/ext/apprise-light.svg new file mode 100644 index 00000000..4e58c826 --- /dev/null +++ b/code/frontend/public/icons/ext/apprise-light.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/code/frontend/public/icons/ext/apprise.svg b/code/frontend/public/icons/ext/apprise.svg new file mode 100644 index 00000000..1fb0dbfa --- /dev/null +++ b/code/frontend/public/icons/ext/apprise.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/code/frontend/public/icons/ext/notifiarr-light.svg b/code/frontend/public/icons/ext/notifiarr-light.svg new file mode 100644 index 00000000..c7717e5a --- /dev/null +++ b/code/frontend/public/icons/ext/notifiarr-light.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/code/frontend/public/icons/ext/notifiarr.svg b/code/frontend/public/icons/ext/notifiarr.svg new file mode 100644 index 00000000..d4c735f5 --- /dev/null +++ b/code/frontend/public/icons/ext/notifiarr.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/code/frontend/public/icons/ext/ntfy-light.svg b/code/frontend/public/icons/ext/ntfy-light.svg new file mode 100644 index 00000000..a687f31f --- /dev/null +++ b/code/frontend/public/icons/ext/ntfy-light.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/code/frontend/public/icons/ext/ntfy.svg b/code/frontend/public/icons/ext/ntfy.svg new file mode 100644 index 00000000..aa3c2e9e --- /dev/null +++ b/code/frontend/public/icons/ext/ntfy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/code/frontend/src/app/core/services/documentation.service.ts b/code/frontend/src/app/core/services/documentation.service.ts index 7434954e..f73492f8 100644 --- a/code/frontend/src/app/core/services/documentation.service.ts +++ b/code/frontend/src/app/core/services/documentation.service.ts @@ -105,11 +105,28 @@ export class DocumentationService { 'notifications': { 'enabled': 'enabled', 'name': 'provider-name', + 'eventTriggers': 'event-triggers' + }, + 'notifications/notifiarr': { 'notifiarr.apiKey': 'notifiarr-api-key', - 'notifiarr.channelId': 'notifiarr-channel-id', + 'notifiarr.channelId': 'notifiarr-channel-id' + }, + 'notifications/apprise': { 'apprise.url': 'apprise-url', 'apprise.key': 'apprise-key', - 'apprise.tags': 'apprise-tags', + 'apprise.tags': 'apprise-tags' + }, + 'notifications/ntfy': { + 'ntfy.serverUrl': 'ntfy-server-url', + 'ntfy.topics': 'ntfy-topics', + 'ntfy.authenticationType': 'ntfy-authentication-type', + 'ntfy.username': 'ntfy-username', + 'ntfy.password': 'ntfy-password', + 'ntfy.accessToken': 'ntfy-access-token', + 'ntfy.priority': 'ntfy-priority', + 'ntfy.tags': 'ntfy-tags' + }, + 'notifications/events': { 'eventTriggers': 'event-triggers' } }; 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 f20a0e72..b0f534c3 100644 --- a/code/frontend/src/app/core/services/notification-provider.service.ts +++ b/code/frontend/src/app/core/services/notification-provider.service.ts @@ -8,6 +8,8 @@ import { TestNotificationResult } from '../../shared/models/notification-provider.model'; import { NotificationProviderType } from '../../shared/models/enums'; +import { NtfyAuthenticationType } from '../../shared/models/ntfy-authentication-type.enum'; +import { NtfyPriority } from '../../shared/models/ntfy-priority.enum'; // Provider-specific interfaces export interface CreateNotifiarrProviderDto { @@ -75,6 +77,55 @@ export interface TestAppriseProviderDto { tags: string; } +export interface CreateNtfyProviderDto { + name: string; + isEnabled: boolean; + onFailedImportStrike: boolean; + onStalledStrike: boolean; + onSlowStrike: boolean; + onQueueItemDeleted: boolean; + onDownloadCleaned: boolean; + onCategoryChanged: boolean; + serverUrl: string; + topics: string[]; + authenticationType: NtfyAuthenticationType; + username: string; + password: string; + accessToken: string; + priority: NtfyPriority; + tags: string[]; +} + +export interface UpdateNtfyProviderDto { + name: string; + isEnabled: boolean; + onFailedImportStrike: boolean; + onStalledStrike: boolean; + onSlowStrike: boolean; + onQueueItemDeleted: boolean; + onDownloadCleaned: boolean; + onCategoryChanged: boolean; + serverUrl: string; + topics: string[]; + authenticationType: NtfyAuthenticationType; + username: string; + password: string; + accessToken: string; + priority: NtfyPriority; + tags: string[]; +} + +export interface TestNtfyProviderDto { + serverUrl: string; + topics: string[]; + authenticationType: NtfyAuthenticationType; + username: string; + password: string; + accessToken: string; + priority: NtfyPriority; + tags: string[]; +} + @Injectable({ providedIn: 'root' }) @@ -104,6 +155,13 @@ export class NotificationProviderService { return this.http.post(`${this.baseUrl}/notification_providers/apprise`, provider); } + /** + * Create a new Ntfy provider + */ + createNtfyProvider(provider: CreateNtfyProviderDto): Observable { + return this.http.post(`${this.baseUrl}/notification_providers/ntfy`, provider); + } + /** * Update an existing Notifiarr provider */ @@ -118,6 +176,13 @@ export class NotificationProviderService { return this.http.put(`${this.baseUrl}/notification_providers/apprise/${id}`, provider); } + /** + * Update an existing Ntfy provider + */ + updateNtfyProvider(id: string, provider: UpdateNtfyProviderDto): Observable { + return this.http.put(`${this.baseUrl}/notification_providers/ntfy/${id}`, provider); + } + /** * Delete a notification provider */ @@ -139,6 +204,13 @@ export class NotificationProviderService { return this.http.post(`${this.baseUrl}/notification_providers/apprise/test`, testRequest); } + /** + * Test an Ntfy provider (without ID - for testing configuration before saving) + */ + testNtfyProvider(testRequest: TestNtfyProviderDto): Observable { + return this.http.post(`${this.baseUrl}/notification_providers/ntfy/test`, testRequest); + } + /** * Generic create method that delegates to provider-specific methods */ @@ -148,6 +220,8 @@ export class NotificationProviderService { return this.createNotifiarrProvider(provider as CreateNotifiarrProviderDto); case NotificationProviderType.Apprise: return this.createAppriseProvider(provider as CreateAppriseProviderDto); + case NotificationProviderType.Ntfy: + return this.createNtfyProvider(provider as CreateNtfyProviderDto); default: throw new Error(`Unsupported provider type: ${type}`); } @@ -162,6 +236,8 @@ export class NotificationProviderService { return this.updateNotifiarrProvider(id, provider as UpdateNotifiarrProviderDto); case NotificationProviderType.Apprise: return this.updateAppriseProvider(id, provider as UpdateAppriseProviderDto); + case NotificationProviderType.Ntfy: + return this.updateNtfyProvider(id, provider as UpdateNtfyProviderDto); default: throw new Error(`Unsupported provider type: ${type}`); } @@ -176,6 +252,8 @@ export class NotificationProviderService { return this.testNotifiarrProvider(testRequest as TestNotifiarrProviderDto); case NotificationProviderType.Apprise: return this.testAppriseProvider(testRequest as TestAppriseProviderDto); + case NotificationProviderType.Ntfy: + return this.testNtfyProvider(testRequest as TestNtfyProviderDto); default: throw new Error(`Unsupported provider type: ${type}`); } diff --git a/code/frontend/src/app/settings/download-cleaner/download-cleaner-settings.component.html b/code/frontend/src/app/settings/download-cleaner/download-cleaner-settings.component.html index 9ba69124..daaf1b59 100644 --- a/code/frontend/src/app/settings/download-cleaner/download-cleaner-settings.component.html +++ b/code/frontend/src/app/settings/download-cleaner/download-cleaner-settings.component.html @@ -363,8 +363,6 @@ multiple fluid [typeahead]="false" - [suggestions]="unlinkedCategoriesSuggestions" - (completeMethod)="onUnlinkedCategoriesComplete($event)" placeholder="Add category and press Enter" class="desktop-only" > diff --git a/code/frontend/src/app/settings/download-cleaner/download-cleaner-settings.component.ts b/code/frontend/src/app/settings/download-cleaner/download-cleaner-settings.component.ts index 19a6fef5..90f6a93a 100644 --- a/code/frontend/src/app/settings/download-cleaner/download-cleaner-settings.component.ts +++ b/code/frontend/src/app/settings/download-cleaner/download-cleaner-settings.component.ts @@ -91,9 +91,6 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent // Flag to track if form has been initially loaded to avoid showing dialog on page load private formInitialized = false; - // Minimal autocomplete support - empty suggestions to allow manual input - unlinkedCategoriesSuggestions: string[] = []; - // Get the categories form array for easier access in the template get categoriesFormArray(): FormArray { return this.downloadCleanerForm.get('categories') as FormArray; @@ -751,15 +748,6 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent } } - /** - * Minimal complete method for autocomplete - just returns empty array to allow manual input - */ - onUnlinkedCategoriesComplete(event: any): void { - // Return empty array - this allows users to type any value manually - // PrimeNG requires this method even when we don't want suggestions - this.unlinkedCategoriesSuggestions = []; - } - /** * Show confirmation dialog when enabling the download cleaner */ diff --git a/code/frontend/src/app/settings/notification-settings/modals/apprise-provider/apprise-provider.component.ts b/code/frontend/src/app/settings/notification-settings/modals/apprise-provider/apprise-provider.component.ts index 6e091563..63630862 100644 --- a/code/frontend/src/app/settings/notification-settings/modals/apprise-provider/apprise-provider.component.ts +++ b/code/frontend/src/app/settings/notification-settings/modals/apprise-provider/apprise-provider.component.ts @@ -40,7 +40,7 @@ export class AppriseProviderComponent implements OnInit, OnChanges { * Exposed for template to open documentation for apprise fields */ openFieldDocs(fieldName: string): void { - this.documentationService.openFieldDocumentation('notifications', fieldName); + this.documentationService.openFieldDocumentation('notifications/apprise', fieldName); } ngOnInit(): void { diff --git a/code/frontend/src/app/settings/notification-settings/modals/notifiarr-provider/notifiarr-provider.component.ts b/code/frontend/src/app/settings/notification-settings/modals/notifiarr-provider/notifiarr-provider.component.ts index e91c9166..f720c1c1 100644 --- a/code/frontend/src/app/settings/notification-settings/modals/notifiarr-provider/notifiarr-provider.component.ts +++ b/code/frontend/src/app/settings/notification-settings/modals/notifiarr-provider/notifiarr-provider.component.ts @@ -38,7 +38,7 @@ export class NotifiarrProviderComponent implements OnInit, OnChanges { /** Exposed for template to open documentation for notifiarr fields */ openFieldDocs(fieldName: string): void { - this.documentationService.openFieldDocumentation('notifications', fieldName); + this.documentationService.openFieldDocumentation('notifications/notifiarr', fieldName); } ngOnInit(): void { diff --git a/code/frontend/src/app/settings/notification-settings/modals/ntfy-provider/ntfy-provider.component.html b/code/frontend/src/app/settings/notification-settings/modals/ntfy-provider/ntfy-provider.component.html new file mode 100644 index 00000000..3ed61807 --- /dev/null +++ b/code/frontend/src/app/settings/notification-settings/modals/ntfy-provider/ntfy-provider.component.html @@ -0,0 +1,213 @@ + + +
+ +
+ + + Server URL is required + Must be a valid URL + Must use http or https protocol + The URL of your ntfy server. Use https://ntfy.sh for the public service or your self-hosted instance. +
+ + +
+ + + + + + + + At least one topic is required + At least one topic is required + Enter the ntfy topics you want to publish to. Press Enter or comma to add each topic. +
+ + +
+ + + Authentication type is required + Choose how to authenticate with the ntfy server. +
+ + +
+ + + Username is required for Basic Auth + Your username for basic authentication. +
+ + +
+ + + Password is required for Basic Auth + Your password for basic authentication. +
+ + +
+ + + Access token is required + Your access token for bearer token authentication. +
+ + +
+ + + Priority is required + The priority level for notifications (1=min, 5=max). +
+ + +
+ + + + + + + + Optional tags to add to notifications (e.g., warning, alert). Press Enter or comma to add each tag. +
+
+
diff --git a/code/frontend/src/app/settings/notification-settings/modals/ntfy-provider/ntfy-provider.component.scss b/code/frontend/src/app/settings/notification-settings/modals/ntfy-provider/ntfy-provider.component.scss new file mode 100644 index 00000000..cd71ecb6 --- /dev/null +++ b/code/frontend/src/app/settings/notification-settings/modals/ntfy-provider/ntfy-provider.component.scss @@ -0,0 +1 @@ +@use '../../../styles/settings-shared.scss'; diff --git a/code/frontend/src/app/settings/notification-settings/modals/ntfy-provider/ntfy-provider.component.ts b/code/frontend/src/app/settings/notification-settings/modals/ntfy-provider/ntfy-provider.component.ts new file mode 100644 index 00000000..3cfd877a --- /dev/null +++ b/code/frontend/src/app/settings/notification-settings/modals/ntfy-provider/ntfy-provider.component.ts @@ -0,0 +1,216 @@ +import { Component, Input, Output, EventEmitter, OnInit, OnDestroy, OnChanges, SimpleChanges, inject } from '@angular/core'; +import { FormControl, Validators, ReactiveFormsModule } from '@angular/forms'; +import { CommonModule } from '@angular/common'; +import { InputTextModule } from 'primeng/inputtext'; +import { AutoCompleteModule } from 'primeng/autocomplete'; +import { SelectModule } from 'primeng/select'; +import { MobileAutocompleteComponent } from '../../../../shared/components/mobile-autocomplete/mobile-autocomplete.component'; +import { NtfyFormData, BaseProviderFormData } from '../../models/provider-modal.model'; +import { DocumentationService } from '../../../../core/services/documentation.service'; +import { NotificationProviderDto } from '../../../../shared/models/notification-provider.model'; +import { NotificationProviderBaseComponent } from '../base/notification-provider-base.component'; +import { UrlValidators } from '../../../../core/validators/url.validator'; +import { NtfyAuthenticationType } from '../../../../shared/models/ntfy-authentication-type.enum'; +import { NtfyPriority } from '../../../../shared/models/ntfy-priority.enum'; + +@Component({ + selector: 'app-ntfy-provider', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + InputTextModule, + AutoCompleteModule, + SelectModule, + MobileAutocompleteComponent, + NotificationProviderBaseComponent + ], + templateUrl: './ntfy-provider.component.html', + styleUrls: ['./ntfy-provider.component.scss'] +}) +export class NtfyProviderComponent 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 + serverUrlControl = new FormControl('', [Validators.required, UrlValidators.httpUrl]); + topicsControl = new FormControl([], [Validators.required, Validators.minLength(1)]); + authenticationTypeControl = new FormControl(NtfyAuthenticationType.None, [Validators.required]); + usernameControl = new FormControl(''); + passwordControl = new FormControl(''); + accessTokenControl = new FormControl(''); + priorityControl = new FormControl(NtfyPriority.Default, [Validators.required]); + tagsControl = new FormControl([]); + + private documentationService = inject(DocumentationService); + + // Enum references for template + readonly NtfyAuthenticationType = NtfyAuthenticationType; + readonly NtfyPriority = NtfyPriority; + + // Dropdown options + authenticationOptions = [ + { label: 'None', value: NtfyAuthenticationType.None }, + { label: 'Basic Auth', value: NtfyAuthenticationType.BasicAuth }, + { label: 'Access Token', value: NtfyAuthenticationType.AccessToken } + ]; + + priorityOptions = [ + { label: 'Min', value: NtfyPriority.Min }, + { label: 'Low', value: NtfyPriority.Low }, + { label: 'Default', value: NtfyPriority.Default }, + { label: 'High', value: NtfyPriority.High }, + { label: 'Max', value: NtfyPriority.Max } + ]; + + /** + * Exposed for template to open documentation for ntfy fields + */ + openFieldDocs(fieldName: string): void { + this.documentationService.openFieldDocumentation('notifications/ntfy', fieldName); + } + + ngOnInit(): void { + // Initialize component but don't populate yet - wait for ngOnChanges + + // Set up conditional validation for authentication fields + this.authenticationTypeControl.valueChanges.subscribe(type => { + this.updateAuthFieldValidation(type); + }); + } + + ngOnChanges(changes: SimpleChanges): void { + // Populate provider-specific fields when editingProvider input changes + if (changes['editingProvider']) { + if (this.editingProvider) { + this.populateProviderFields(); + } else { + // Reset fields when editingProvider is cleared + this.resetProviderFields(); + } + } + } + + private populateProviderFields(): void { + if (this.editingProvider) { + const config = this.editingProvider.configuration as any; + + this.serverUrlControl.setValue(config?.serverUrl || 'https://ntfy.sh'); + this.topicsControl.setValue(config?.topics || []); + this.authenticationTypeControl.setValue(config?.authenticationType || NtfyAuthenticationType.None); + this.usernameControl.setValue(config?.username || ''); + this.passwordControl.setValue(config?.password || ''); + this.accessTokenControl.setValue(config?.accessToken || ''); + this.priorityControl.setValue(config?.priority || NtfyPriority.Default); + this.tagsControl.setValue(config?.tags || []); + } + } + + private resetProviderFields(): void { + this.serverUrlControl.setValue('https://ntfy.sh'); + this.topicsControl.setValue([]); + this.authenticationTypeControl.setValue(NtfyAuthenticationType.None); + this.usernameControl.setValue(''); + this.passwordControl.setValue(''); + this.accessTokenControl.setValue(''); + this.priorityControl.setValue(NtfyPriority.Default); + this.tagsControl.setValue([]); + } + + private updateAuthFieldValidation(authType: NtfyAuthenticationType | null): void { + // Clear previous validators + this.usernameControl.clearValidators(); + this.passwordControl.clearValidators(); + this.accessTokenControl.clearValidators(); + + // Set validators based on auth type + if (authType === NtfyAuthenticationType.BasicAuth) { + this.usernameControl.setValidators([Validators.required]); + this.passwordControl.setValidators([Validators.required]); + } else if (authType === NtfyAuthenticationType.AccessToken) { + this.accessTokenControl.setValidators([Validators.required]); + } + + // Update validation status + this.usernameControl.updateValueAndValidity(); + this.passwordControl.updateValueAndValidity(); + this.accessTokenControl.updateValueAndValidity(); + } + + protected hasFieldError(control: FormControl, errorType: string): boolean { + return !!(control && control.errors?.[errorType] && (control.dirty || control.touched)); + } + + private isFormValid(): boolean { + return this.serverUrlControl.valid && + this.topicsControl.valid && + this.authenticationTypeControl.valid && + this.usernameControl.valid && + this.passwordControl.valid && + this.accessTokenControl.valid && + this.priorityControl.valid; + } + + private buildNtfyData(baseData: BaseProviderFormData): NtfyFormData { + return { + ...baseData, + serverUrl: this.serverUrlControl.value || '', + topics: this.topicsControl.value || [], + authenticationType: this.authenticationTypeControl.value || NtfyAuthenticationType.None, + username: this.usernameControl.value || '', + password: this.passwordControl.value || '', + accessToken: this.accessTokenControl.value || '', + priority: this.priorityControl.value || NtfyPriority.Default, + tags: this.tagsControl.value || [] + }; + } + + onSave(baseData: BaseProviderFormData): void { + if (this.isFormValid()) { + const ntfyData = this.buildNtfyData(baseData); + this.save.emit(ntfyData); + } else { + // Mark provider-specific fields as touched to show validation errors + this.serverUrlControl.markAsTouched(); + this.topicsControl.markAsTouched(); + this.authenticationTypeControl.markAsTouched(); + this.usernameControl.markAsTouched(); + this.passwordControl.markAsTouched(); + this.accessTokenControl.markAsTouched(); + this.priorityControl.markAsTouched(); + } + } + + onCancel(): void { + this.cancel.emit(); + } + + onTest(baseData: BaseProviderFormData): void { + if (this.isFormValid()) { + const ntfyData = this.buildNtfyData(baseData); + this.test.emit(ntfyData); + } else { + // Mark provider-specific fields as touched to show validation errors + this.serverUrlControl.markAsTouched(); + this.topicsControl.markAsTouched(); + this.authenticationTypeControl.markAsTouched(); + this.usernameControl.markAsTouched(); + this.passwordControl.markAsTouched(); + this.accessTokenControl.markAsTouched(); + this.priorityControl.markAsTouched(); + } + } + + /** + * Get current authentication type for template conditionals + */ + get currentAuthType(): NtfyAuthenticationType | null { + return this.authenticationTypeControl.value; + } +} diff --git a/code/frontend/src/app/settings/notification-settings/modals/provider-type-selection/provider-type-selection.component.html b/code/frontend/src/app/settings/notification-settings/modals/provider-type-selection/provider-type-selection.component.html new file mode 100644 index 00000000..6b11e7a4 --- /dev/null +++ b/code/frontend/src/app/settings/notification-settings/modals/provider-type-selection/provider-type-selection.component.html @@ -0,0 +1,49 @@ + + +
+

+ Choose a notification provider type to configure: +

+ +
+
+ +
+ +
+
+ {{ provider.name }} +
+
+ {{ provider.description }} +
+
+
+
+ + + + +
diff --git a/code/frontend/src/app/settings/notification-settings/modals/provider-type-selection/provider-type-selection.component.scss b/code/frontend/src/app/settings/notification-settings/modals/provider-type-selection/provider-type-selection.component.scss index 78b493d3..a6a1fe5c 100644 --- a/code/frontend/src/app/settings/notification-settings/modals/provider-type-selection/provider-type-selection.component.scss +++ b/code/frontend/src/app/settings/notification-settings/modals/provider-type-selection/provider-type-selection.component.scss @@ -1,12 +1,15 @@ .provider-selection-modal { .p-dialog { width: 90vw; - max-width: 600px; + max-width: 900px; // Increased to accommodate multiple columns + min-width: 320px; // Ensure minimum usable width } } .provider-selection-content { padding: 1rem; + max-height: 70vh; // Limit height to prevent excessive tall modals + overflow-y: auto; // Enable scrolling if needed } .selection-description { @@ -18,9 +21,22 @@ .provider-selection-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1.5rem; padding: 1rem 0; + + // Default: 3 columns for desktop (large screens) + grid-template-columns: repeat(3, 1fr); + + // Medium screens (tablets): 2 columns + @media (max-width: 992px) { + grid-template-columns: repeat(2, 1fr); + } + + // Small screens (mobile): 1 column + @media (max-width: 576px) { + grid-template-columns: 1fr; + gap: 1rem; + } } .provider-card { @@ -45,7 +61,10 @@ .provider-icon { transform: scale(1.1); - color: var(--primary-color); + + .provider-icon-image { + filter: opacity(1); + } } } @@ -67,13 +86,15 @@ } .provider-icon { - font-size: 3rem; - color: var(--text-color-secondary); margin-bottom: 1rem; transition: all 0.3s ease; - i { + .provider-icon-image { + width: 48px; + height: 48px; display: block; + filter: opacity(0.7); + transition: all 0.3s ease; } } @@ -92,28 +113,34 @@ text-align: center; } -.modal-footer { - display: flex; - justify-content: center; - align-items: center; - padding-top: 1rem; - border-top: 1px solid var(--surface-border); - margin-top: 1rem; -} - -// Responsive design -@media (max-width: 768px) { - .provider-selection-grid { - grid-template-columns: 1fr; - gap: 1rem; - } - +// Responsive design for card content +@media (max-width: 576px) { .provider-card { padding: 1.5rem 1rem; } .provider-icon { - font-size: 2.5rem; + margin-bottom: 0.75rem; + + .provider-icon-image { + width: 40px; + height: 40px; + } + } + + .provider-name { + font-size: 1.125rem; + } + + .provider-description { + font-size: 0.8rem; + } +} + +// Ensure proper spacing on medium screens +@media (max-width: 992px) and (min-width: 577px) { + .provider-card { + padding: 1.75rem 1.25rem; } } @@ -135,10 +162,17 @@ animation: fadeInUp 0.4s ease-out; animation-fill-mode: both; + // Staggered animation delays for up to 12 providers (4 rows x 3 columns) &:nth-child(1) { animation-delay: 0.1s; } - &:nth-child(2) { animation-delay: 0.2s; } - &:nth-child(3) { animation-delay: 0.3s; } - &:nth-child(4) { animation-delay: 0.4s; } + &:nth-child(2) { animation-delay: 0.15s; } + &:nth-child(3) { animation-delay: 0.2s; } + &:nth-child(4) { animation-delay: 0.25s; } + &:nth-child(5) { animation-delay: 0.3s; } + &:nth-child(6) { animation-delay: 0.35s; } + &:nth-child(7) { animation-delay: 0.4s; } + &:nth-child(8) { animation-delay: 0.45s; } + &:nth-child(9) { animation-delay: 0.5s; } + &:nth-child(n+10) { animation-delay: 0.55s; } // All remaining items } @keyframes fadeInUp { @@ -151,3 +185,40 @@ transform: translateY(0); } } + +// Add subtle scroll indicators when content overflows +.provider-selection-content { + position: relative; + + // Gradient fade at bottom when scrolling is needed + &::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 20px; + background: linear-gradient(to top, var(--surface-card), transparent); + pointer-events: none; + opacity: 0; + transition: opacity 0.3s ease; + } + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: var(--surface-border); + border-radius: 3px; + } + + &::-webkit-scrollbar-thumb { + background: var(--text-color-secondary); + border-radius: 3px; + + &:hover { + background: var(--primary-color); + } + } +} 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 72421882..c93fc73e 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 @@ -13,83 +13,51 @@ import { ProviderTypeInfo } from '../../models/provider-modal.model'; DialogModule, ButtonModule ], - template: ` - - -
-

- Choose a notification provider type to configure: -

- -
-
- -
- -
-
- {{ provider.name }} -
-
- {{ provider.description }} -
-
-
-
- - - - -
- `, + templateUrl: './provider-type-selection.component.html', styleUrls: ['./provider-type-selection.component.scss'] }) export class ProviderTypeSelectionComponent { @Input() visible = false; @Output() providerSelected = new EventEmitter(); @Output() cancel = new EventEmitter(); + hoveredProvider: NotificationProviderType | null = null; - // Available providers - only show implemented ones availableProviders: ProviderTypeInfo[] = [ - { - type: NotificationProviderType.Notifiarr, - name: 'Notifiarr', - iconClass: 'pi pi-bell', - description: 'Discord integration via Notifiarr service' - }, { type: NotificationProviderType.Apprise, name: 'Apprise', - iconClass: 'pi pi-send', - description: 'Universal notification library supporting many services' + iconUrl: '/icons/ext/apprise-light.svg', + iconUrlHover: '/icons/ext/apprise.svg', + description: 'https://github.com/caronc/apprise' + }, + { + type: NotificationProviderType.Notifiarr, + name: 'Notifiarr', + iconUrl: '/icons/ext/notifiarr-light.svg', + iconUrlHover: '/icons/ext/notifiarr.svg', + description: 'https://notifiarr.com' + }, + { + type: NotificationProviderType.Ntfy, + name: 'ntfy', + iconUrl: '/icons/ext/ntfy-light.svg', + iconUrlHover: '/icons/ext/ntfy.svg', + description: 'https://ntfy.sh/' } - // Add more providers as they are implemented ]; selectProvider(type: NotificationProviderType) { this.providerSelected.emit(type); } + onProviderEnter(type: NotificationProviderType) { + this.hoveredProvider = type; + } + + onProviderLeave() { + this.hoveredProvider = null; + } + onCancel() { this.cancel.emit(); } 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 99368df4..c0c1dfa4 100644 --- a/code/frontend/src/app/settings/notification-settings/models/provider-modal.model.ts +++ b/code/frontend/src/app/settings/notification-settings/models/provider-modal.model.ts @@ -1,9 +1,12 @@ import { NotificationProviderType } from '../../../shared/models/enums'; +import { NtfyAuthenticationType } from '../../../shared/models/ntfy-authentication-type.enum'; +import { NtfyPriority } from '../../../shared/models/ntfy-priority.enum'; export interface ProviderTypeInfo { type: NotificationProviderType; name: string; - iconClass: string; + iconUrl: string; + iconUrlHover?: string; description?: string; } @@ -35,6 +38,17 @@ export interface AppriseFormData extends BaseProviderFormData { tags: string; } +export interface NtfyFormData extends BaseProviderFormData { + serverUrl: string; + topics: string[]; + authenticationType: NtfyAuthenticationType; + username: string; + password: string; + accessToken: string; + priority: NtfyPriority; + tags: 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 9cec4c6c..bd8e7357 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 @@ -176,6 +176,17 @@ (test)="onAppriseTest($event)" > + + + diff --git a/code/frontend/src/app/settings/notification-settings/notification-settings.component.ts b/code/frontend/src/app/settings/notification-settings/notification-settings.component.ts index 044d0a92..60175d25 100644 --- a/code/frontend/src/app/settings/notification-settings/notification-settings.component.ts +++ b/code/frontend/src/app/settings/notification-settings/notification-settings.component.ts @@ -8,13 +8,14 @@ import { } from "../../shared/models/notification-provider.model"; import { NotificationProviderType } from "../../shared/models/enums"; import { DocumentationService } from "../../core/services/documentation.service"; -import { NotifiarrFormData, AppriseFormData } from "./models/provider-modal.model"; +import { NotifiarrFormData, AppriseFormData, NtfyFormData } from "./models/provider-modal.model"; import { LoadingErrorStateComponent } from "../../shared/components/loading-error-state/loading-error-state.component"; // New modal components import { ProviderTypeSelectionComponent } from "./modals/provider-type-selection/provider-type-selection.component"; import { NotifiarrProviderComponent } from "./modals/notifiarr-provider/notifiarr-provider.component"; import { AppriseProviderComponent } from "./modals/apprise-provider/apprise-provider.component"; +import { NtfyProviderComponent } from "./modals/ntfy-provider/ntfy-provider.component"; // PrimeNG Components import { CardModule } from "primeng/card"; @@ -49,6 +50,7 @@ import { NotificationService } from "../../core/services/notification.service"; ProviderTypeSelectionComponent, NotifiarrProviderComponent, AppriseProviderComponent, + NtfyProviderComponent, ], providers: [NotificationProviderConfigStore, ConfirmationService, MessageService], templateUrl: "./notification-settings.component.html", @@ -63,6 +65,7 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea showTypeSelectionModal = false; // New: Provider type selection modal showNotifiarrModal = false; // New: Notifiarr provider modal showAppriseModal = false; // New: Apprise provider modal + showNtfyModal = false; // New: Ntfy provider modal modalMode: 'add' | 'edit' = 'add'; editingProvider: NotificationProviderDto | null = null; @@ -173,6 +176,9 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea case NotificationProviderType.Apprise: this.showAppriseModal = true; break; + case NotificationProviderType.Ntfy: + this.showNtfyModal = true; + break; default: // For unsupported types, show the legacy modal with info message this.showProviderModal = true; @@ -220,6 +226,9 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea case NotificationProviderType.Apprise: this.showAppriseModal = true; break; + case NotificationProviderType.Ntfy: + this.showNtfyModal = true; + break; default: // For unsupported types, show the legacy modal with info message this.showProviderModal = true; @@ -268,6 +277,19 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea tags: appriseConfig.tags || "", }; break; + case NotificationProviderType.Ntfy: + const ntfyConfig = provider.configuration as any; + testRequest = { + serverUrl: ntfyConfig.serverUrl, + topics: ntfyConfig.topics, + authenticationType: ntfyConfig.authenticationType, + username: ntfyConfig.username || "", + password: ntfyConfig.password || "", + accessToken: ntfyConfig.accessToken || "", + priority: ntfyConfig.priority, + tags: ntfyConfig.tags || "", + }; + break; default: this.notificationService.showError("Testing not supported for this provider type"); return; @@ -304,6 +326,8 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea return "Notifiarr"; case NotificationProviderType.Apprise: return "Apprise"; + case NotificationProviderType.Ntfy: + return "ntfy"; default: return "Unknown"; } @@ -378,6 +402,38 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea }); } + /** + * Handle Ntfy provider save + */ + onNtfySave(data: NtfyFormData): void { + if (this.modalMode === "edit" && this.editingProvider) { + this.updateNtfyProvider(data); + } else { + this.createNtfyProvider(data); + } + } + + /** + * Handle Ntfy provider test + */ + onNtfyTest(data: NtfyFormData): void { + const testRequest = { + serverUrl: data.serverUrl, + topics: data.topics, + authenticationType: data.authenticationType, + username: data.username, + password: data.password, + accessToken: data.accessToken, + priority: data.priority, + tags: data.tags, + }; + + this.notificationProviderStore.testProvider({ + testRequest, + type: NotificationProviderType.Ntfy, + }); + } + /** * Handle provider modal cancel */ @@ -392,6 +448,7 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea this.showTypeSelectionModal = false; this.showNotifiarrModal = false; this.showAppriseModal = false; + this.showNtfyModal = false; this.showProviderModal = false; this.editingProvider = null; this.notificationProviderStore.clearTestResult(); @@ -501,6 +558,69 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea this.monitorProviderOperation("updated"); } + /** + * Create new Ntfy provider + */ + private createNtfyProvider(data: NtfyFormData): void { + const createDto = { + name: data.name, + isEnabled: data.enabled, + onFailedImportStrike: data.onFailedImportStrike, + onStalledStrike: data.onStalledStrike, + onSlowStrike: data.onSlowStrike, + onQueueItemDeleted: data.onQueueItemDeleted, + onDownloadCleaned: data.onDownloadCleaned, + onCategoryChanged: data.onCategoryChanged, + serverUrl: data.serverUrl, + topics: data.topics, + authenticationType: data.authenticationType, + username: data.username, + password: data.password, + accessToken: data.accessToken, + priority: data.priority, + tags: data.tags, + }; + + this.notificationProviderStore.createProvider({ + provider: createDto, + type: NotificationProviderType.Ntfy, + }); + this.monitorProviderOperation("created"); + } + + /** + * Update existing Ntfy provider + */ + private updateNtfyProvider(data: NtfyFormData): void { + if (!this.editingProvider) return; + + const updateDto = { + name: data.name, + isEnabled: data.enabled, + onFailedImportStrike: data.onFailedImportStrike, + onStalledStrike: data.onStalledStrike, + onSlowStrike: data.onSlowStrike, + onQueueItemDeleted: data.onQueueItemDeleted, + onDownloadCleaned: data.onDownloadCleaned, + onCategoryChanged: data.onCategoryChanged, + serverUrl: data.serverUrl, + topics: data.topics, + authenticationType: data.authenticationType, + username: data.username, + password: data.password, + accessToken: data.accessToken, + priority: data.priority, + tags: data.tags, + }; + + this.notificationProviderStore.updateProvider({ + id: this.editingProvider.id, + provider: updateDto, + type: NotificationProviderType.Ntfy, + }); + this.monitorProviderOperation("updated"); + } + /** * Monitor provider operation completion and close modals */ diff --git a/code/frontend/src/app/settings/settings-page/settings-page.component.scss b/code/frontend/src/app/settings/settings-page/settings-page.component.scss index 29cbed56..de22c478 100644 --- a/code/frontend/src/app/settings/settings-page/settings-page.component.scss +++ b/code/frontend/src/app/settings/settings-page/settings-page.component.scss @@ -59,7 +59,7 @@ font-size: 0.85rem; } - input, .p-dropdown, .p-inputnumber { + input, .p-select, .p-autocomplete, .p-inputnumber { width: 100%; } } @@ -81,7 +81,7 @@ } /* Input styling */ - .p-inputtext, .p-dropdown, .p-inputnumber { + .p-inputtext, .p-select, .p-autocomplete, .p-inputnumber { &:hover { border-color: var(--primary-color); } @@ -92,8 +92,8 @@ } } - .p-dropdown { - .p-dropdown-label { + .p-select, .p-autocomplete { + .p-select-label, .p-autocomplete-input { padding: 0.5rem 0.75rem; } } diff --git a/code/frontend/src/app/shared/models/enums.ts b/code/frontend/src/app/shared/models/enums.ts index 195c5362..6ee0631e 100644 --- a/code/frontend/src/app/shared/models/enums.ts +++ b/code/frontend/src/app/shared/models/enums.ts @@ -13,4 +13,5 @@ export enum DownloadClientTypeName { export enum NotificationProviderType { Notifiarr = "Notifiarr", Apprise = "Apprise", + Ntfy = "Ntfy", } \ No newline at end of file diff --git a/code/frontend/src/app/shared/models/ntfy-authentication-type.enum.ts b/code/frontend/src/app/shared/models/ntfy-authentication-type.enum.ts new file mode 100644 index 00000000..bfa26bbe --- /dev/null +++ b/code/frontend/src/app/shared/models/ntfy-authentication-type.enum.ts @@ -0,0 +1,5 @@ +export enum NtfyAuthenticationType { + None = 'None', + BasicAuth = 'BasicAuth', + AccessToken = 'AccessToken' +} diff --git a/code/frontend/src/app/shared/models/ntfy-config.model.ts b/code/frontend/src/app/shared/models/ntfy-config.model.ts new file mode 100644 index 00000000..1811a81b --- /dev/null +++ b/code/frontend/src/app/shared/models/ntfy-config.model.ts @@ -0,0 +1,14 @@ +import { NotificationConfig } from './notification-config.model'; +import { NtfyAuthenticationType } from './ntfy-authentication-type.enum'; +import { NtfyPriority } from './ntfy-priority.enum'; + +export interface NtfyConfig extends NotificationConfig { + serverUrl?: string; + topics?: string[]; + authenticationType?: NtfyAuthenticationType; + username?: string; + password?: string; + accessToken?: string; + priority?: NtfyPriority; + tags?: string[]; +} diff --git a/code/frontend/src/app/shared/models/ntfy-priority.enum.ts b/code/frontend/src/app/shared/models/ntfy-priority.enum.ts new file mode 100644 index 00000000..67316d3e --- /dev/null +++ b/code/frontend/src/app/shared/models/ntfy-priority.enum.ts @@ -0,0 +1,7 @@ +export enum NtfyPriority { + Min = 'Min', + Low = 'Low', + Default = 'Default', + High = 'High', + Max = 'Max' +} diff --git a/code/frontend/src/styles.scss b/code/frontend/src/styles.scss index 244401e7..16558d4b 100644 --- a/code/frontend/src/styles.scss +++ b/code/frontend/src/styles.scss @@ -253,7 +253,7 @@ a { } } - .p-dropdown { + .p-select, .p-autocomplete { width: 100%; } } @@ -430,10 +430,10 @@ a { } /* Accent color for various components */ - .p-dropdown-panel .p-dropdown-items .p-dropdown-item.p-highlight, + .p-select-panel .p-select-items .p-select-item.p-highlight, .p-multiselect-panel .p-multiselect-items .p-multiselect-item.p-highlight, .p-listbox .p-listbox-list .p-listbox-item.p-highlight, - .p-dropdown-panel .p-dropdown-items .p-dropdown-item:focus, + .p-select-panel .p-select-items .p-select-item:focus, .p-multiselect-panel .p-multiselect-items .p-multiselect-item:focus, .p-listbox .p-listbox-list .p-listbox-item:focus { background-color: rgba(126, 87, 194, 0.16); @@ -448,7 +448,9 @@ a { /* Focus and selection ring */ .p-component:focus, - .p-inputtext:focus { + .p-inputtext:focus, + .p-select:focus, + .p-autocomplete:focus { box-shadow: 0 0 0 1px var(--primary-light); border-color: var(--primary-color); } diff --git a/docs/docs/configuration/notifications/apprise.mdx b/docs/docs/configuration/notifications/apprise.mdx new file mode 100644 index 00000000..fe3b3856 --- /dev/null +++ b/docs/docs/configuration/notifications/apprise.mdx @@ -0,0 +1,59 @@ +--- +sidebar_position: 1 +--- + +import { + ConfigSection, + styles +} from '@site/src/components/documentation'; + +# Apprise + +Apprise is a universal notification library that supports over 80 different notification services. + +
+ +
+ +

+ 📡 + Configuration +

+ +

+ Configure Apprise to send notifications through any of its supported services. +

+ + + +The Apprise server URL where notification requests will be sent. + + + + + +The key that identifies your Apprise configuration. This corresponds to a configuration defined in your Apprise server. + + + + + +Optionally notify only those tagged accordingly. Use a comma (,) to OR your tags and a space ( ) to AND them. More details on this can be seen in the [Apprise documentation](https://github.com/caronc/apprise-api?tab=readme-ov-file#tagging). + + + +
+ +
\ No newline at end of file diff --git a/docs/docs/configuration/notifications/index.mdx b/docs/docs/configuration/notifications/index.mdx index b0a16b1c..34c6a60b 100644 --- a/docs/docs/configuration/notifications/index.mdx +++ b/docs/docs/configuration/notifications/index.mdx @@ -2,10 +2,8 @@ sidebar_position: 7 --- -import { Important } from '@site/src/components/Admonition'; import { ConfigSection, - EnhancedImportant, styles } from '@site/src/components/documentation'; @@ -17,6 +15,11 @@ Configure notification services to receive alerts about Cleanuparr operations.
+

+ ⚙️ + General Settings +

+ -

- 🚀 - Notifiarr -

- -

- Notifiarr is a notification service that can send alerts to Discord. -

- - - -Your Notifiarr API key for authentication. This key is obtained from your Notifiarr dashboard. - - -Requires Notifiarr's [Passthrough](https://notifiarr.wiki/pages/integrations/passthrough/) integration to work. - - - - - - -The Discord channel ID where notifications will be sent. This determines the destination for your alerts. - - -

- 📡 - Apprise Configuration -

- -

- Apprise is a universal notification library that supports over 80 different notification services. -

- - - -The Apprise server URL where notification requests will be sent. - - - - - -The key that identifies your Apprise configuration. This corresponds to a configuration defined in your Apprise server. - - - - - -Optionally notify only those tagged accordingly. Use a comma (,) to OR your tags and a space ( ) to AND them. More details on this can be seen in the [Apprise documentation](https://github.com/caronc/apprise-api?tab=readme-ov-file#tagging). - - - -
- -
- -

- Event Triggers + Event Configuration

-**Triggered When**: A download is removed from the queue. +**Triggered When**: A download is removed from an arr queue. diff --git a/docs/docs/configuration/notifications/notifiarr.mdx b/docs/docs/configuration/notifications/notifiarr.mdx new file mode 100644 index 00000000..40bb0317 --- /dev/null +++ b/docs/docs/configuration/notifications/notifiarr.mdx @@ -0,0 +1,54 @@ +--- +sidebar_position: 2 +--- + +import { + ConfigSection, + EnhancedImportant, + styles +} from '@site/src/components/documentation'; + +# Notifiarr + +Notifiarr is a notification service that can send alerts to Discord. + +
+ +
+ +

+ 🚀 + Configuration +

+ +

+ Configure Notifiarr to send notifications to your Discord server. +

+ + + +Your Notifiarr API key for authentication. This key is obtained from your Notifiarr dashboard. + + +Requires Notifiarr's [Passthrough](https://notifiarr.wiki/pages/integrations/passthrough/) integration to work. + + + + + + +The Discord channel ID where notifications will be sent. This determines the destination for your alerts. + + + +
+ +
\ No newline at end of file diff --git a/docs/docs/configuration/notifications/ntfy.mdx b/docs/docs/configuration/notifications/ntfy.mdx new file mode 100644 index 00000000..7de07932 --- /dev/null +++ b/docs/docs/configuration/notifications/ntfy.mdx @@ -0,0 +1,121 @@ +--- +sidebar_position: 3 +--- + +import { + ConfigSection, + styles +} from '@site/src/components/documentation'; + +# ntfy + +ntfy is a simple HTTP-based pub-sub notification service. You can use the public service at ntfy.sh or run your own server. + +
+ +
+ +

+ 🔔 + Configuration +

+ +

+ Configure ntfy to send push notifications to your devices. +

+ + + +The URL of your ntfy server. Use `https://ntfy.sh` for the public service or your self-hosted instance URL. + + + + + +The ntfy topics you want to publish to. You can specify multiple topics to send notifications to different channels or devices. + + + + + +Choose how to authenticate with the ntfy server: +- **None**: No authentication required (public topics) +- **Basic Auth**: Username and password authentication +- **Access Token**: Bearer token authentication + + + + + +Your username for basic authentication. Required when using Basic Auth authentication type. + + + + + +Your password for basic authentication. Required when using Basic Auth authentication type. + + + + + +Your access token for bearer token authentication. Required when using Access Token authentication type. + + + + + +The priority level for notifications: +- **Min**: No vibration or sound. The notification will be under the fold in "Other notifications". +- **Low**: No vibration or sound. Notification will not visibly show up until notification drawer is pulled down. +- **Default**: Short default vibration and sound. Default notification behavior. +- **High**: Long vibration burst, default notification sound with a pop-over notification. +- **Max**: Really long vibration bursts, default notification sound with a pop-over notification. + +Reference: https://docs.ntfy.sh/publish/#message-priority + + + + + +Tags to add to notifications. + +Reference: https://docs.ntfy.sh/publish/#tags-emojis + + + +
+ +
\ No newline at end of file