Add ntfy support (#300)

This commit is contained in:
Flaminel
2025-09-15 22:08:48 +03:00
committed by GitHub
parent bcc117cd0d
commit cce3bb2c4a
52 changed files with 2802 additions and 195 deletions

View File

@@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> UpdateQueueCleanerConfig([FromBody] QueueCleanerConfig newConfig)
{

View File

@@ -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<INotifiarrProxy, NotifiarrProxy>()
.AddScoped<IAppriseProxy, AppriseProxy>()
.AddScoped<INtfyProxy, NtfyProxy>()
.AddScoped<INotificationConfigurationService, NotificationConfigurationService>()
.AddScoped<INotificationProviderFactory, NotificationProviderFactory>()
.AddScoped<NotificationProviderFactory>()

View File

@@ -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<string> 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<string> Tags { get; init; } = [];
}

View File

@@ -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<string> 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<string> Tags { get; init; } = [];
}

View File

@@ -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<string> 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<string> Tags { get; init; } = [];
}

View File

@@ -3,5 +3,6 @@ namespace Cleanuparr.Domain.Enums;
public enum NotificationProviderType
{
Notifiarr,
Apprise
Apprise,
Ntfy
}

View File

@@ -0,0 +1,8 @@
namespace Cleanuparr.Domain.Enums;
public enum NtfyAuthenticationType
{
None,
BasicAuth,
AccessToken
}

View File

@@ -0,0 +1,10 @@
namespace Cleanuparr.Domain.Enums;
public enum NtfyPriority
{
Min = 1,
Low = 2,
Default = 3,
High = 4,
Max = 5
}

View File

@@ -85,6 +85,7 @@ public sealed class NotificationConfigurationService : INotificationConfiguratio
var providers = await _dataContext.Set<NotificationConfig>()
.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()
};

View File

@@ -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<INtfyProxy>();
return new NtfyProvider(config.Name, config.Type, ntfyConfig, proxy);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -44,6 +44,8 @@ public class DataContext : DbContext
public DbSet<NotifiarrConfig> NotifiarrConfigs { get; set; }
public DbSet<AppriseConfig> AppriseConfigs { get; set; }
public DbSet<NtfyConfig> NtfyConfigs { get; set; }
public DbSet<BlacklistSyncHistory> BlacklistSyncHistory { get; set; }
@@ -129,6 +131,11 @@ public class DataContext : DbContext
.HasForeignKey<AppriseConfig>(c => c.NotificationConfigId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(p => p.NtfyConfiguration)
.WithOne(c => c.NotificationConfig)
.HasForeignKey<NtfyConfig>(c => c.NotificationConfigId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasIndex(p => p.Name).IsUnique();
});

View File

@@ -0,0 +1,822 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<short>("FailedImportMaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("failed_import_max_strikes");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ApiKey")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("api_key");
b.Property<Guid>("ArrConfigId")
.HasColumnType("TEXT")
.HasColumnName("arr_config_id");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<Guid>("DownloadCleanerConfigId")
.HasColumnType("TEXT")
.HasColumnName("download_cleaner_config_id");
b.Property<double>("MaxRatio")
.HasColumnType("REAL")
.HasColumnName("max_ratio");
b.Property<double>("MaxSeedTime")
.HasColumnType("REAL")
.HasColumnName("max_seed_time");
b.Property<double>("MinSeedTime")
.HasColumnType("REAL")
.HasColumnName("min_seed_time");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("CronExpression")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("cron_expression");
b.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("delete_private");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.PrimitiveCollection<string>("UnlinkedCategories")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("unlinked_categories");
b.Property<bool>("UnlinkedEnabled")
.HasColumnType("INTEGER")
.HasColumnName("unlinked_enabled");
b.Property<string>("UnlinkedIgnoredRootDir")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("unlinked_ignored_root_dir");
b.Property<string>("UnlinkedTargetCategory")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("unlinked_target_category");
b.Property<bool>("UnlinkedUseTag")
.HasColumnType("INTEGER")
.HasColumnName("unlinked_use_tag");
b.Property<bool>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<string>("Host")
.HasColumnType("TEXT")
.HasColumnName("host");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<string>("Password")
.HasColumnType("TEXT")
.HasColumnName("password");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("type");
b.Property<string>("TypeName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("type_name");
b.Property<string>("UrlBase")
.HasColumnType("TEXT")
.HasColumnName("url_base");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("DisplaySupportBanner")
.HasColumnType("INTEGER")
.HasColumnName("display_support_banner");
b.Property<bool>("DryRun")
.HasColumnType("INTEGER")
.HasColumnName("dry_run");
b.Property<string>("EncryptionKey")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("encryption_key");
b.Property<string>("HttpCertificateValidation")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("http_certificate_validation");
b.Property<ushort>("HttpMaxRetries")
.HasColumnType("INTEGER")
.HasColumnName("http_max_retries");
b.Property<ushort>("HttpTimeout")
.HasColumnType("INTEGER")
.HasColumnName("http_timeout");
b.PrimitiveCollection<string>("IgnoredDownloads")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("ignored_downloads");
b.Property<ushort>("SearchDelay")
.HasColumnType("INTEGER")
.HasColumnName("search_delay");
b.Property<bool>("SearchEnabled")
.HasColumnType("INTEGER")
.HasColumnName("search_enabled");
b.ComplexProperty<Dictionary<string, object>>("Log", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Log#LoggingConfig", b1 =>
{
b1.IsRequired();
b1.Property<bool>("ArchiveEnabled")
.HasColumnType("INTEGER")
.HasColumnName("log_archive_enabled");
b1.Property<ushort>("ArchiveRetainedCount")
.HasColumnType("INTEGER")
.HasColumnName("log_archive_retained_count");
b1.Property<ushort>("ArchiveTimeLimitHours")
.HasColumnType("INTEGER")
.HasColumnName("log_archive_time_limit_hours");
b1.Property<string>("Level")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("log_level");
b1.Property<ushort>("RetainedFileCount")
.HasColumnType("INTEGER")
.HasColumnName("log_retained_file_count");
b1.Property<ushort>("RollingSizeMB")
.HasColumnType("INTEGER")
.HasColumnName("log_rolling_size_mb");
b1.Property<ushort>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("CronExpression")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("cron_expression");
b.Property<bool>("DeleteKnownMalware")
.HasColumnType("INTEGER")
.HasColumnName("delete_known_malware");
b.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("delete_private");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<bool>("IgnorePrivate")
.HasColumnType("INTEGER")
.HasColumnName("ignore_private");
b.Property<bool>("UseAdvancedScheduling")
.HasColumnType("INTEGER")
.HasColumnName("use_advanced_scheduling");
b.ComplexProperty<Dictionary<string, object>>("Lidarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("lidarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("lidarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("lidarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Radarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("radarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("radarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("radarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Readarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("readarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("readarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("readarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Sonarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("sonarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("sonarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("sonarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Whisparr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("whisparr_blocklist_path");
b1.Property<int>("BlocklistType")
.HasColumnType("INTEGER")
.HasColumnName("whisparr_blocklist_type");
b1.Property<bool>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT")
.HasColumnName("key");
b.Property<Guid>("NotificationConfigId")
.HasColumnType("TEXT")
.HasColumnName("notification_config_id");
b.Property<string>("Tags")
.HasMaxLength(255)
.HasColumnType("TEXT")
.HasColumnName("tags");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ApiKey")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT")
.HasColumnName("api_key");
b.Property<string>("ChannelId")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT")
.HasColumnName("channel_id");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<bool>("IsEnabled")
.HasColumnType("INTEGER")
.HasColumnName("is_enabled");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<bool>("OnCategoryChanged")
.HasColumnType("INTEGER")
.HasColumnName("on_category_changed");
b.Property<bool>("OnDownloadCleaned")
.HasColumnType("INTEGER")
.HasColumnName("on_download_cleaned");
b.Property<bool>("OnFailedImportStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_failed_import_strike");
b.Property<bool>("OnQueueItemDeleted")
.HasColumnType("INTEGER")
.HasColumnName("on_queue_item_deleted");
b.Property<bool>("OnSlowStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_slow_strike");
b.Property<bool>("OnStalledStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_stalled_strike");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("type");
b.Property<DateTime>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AccessToken")
.HasMaxLength(500)
.HasColumnType("TEXT")
.HasColumnName("access_token");
b.Property<string>("AuthenticationType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("authentication_type");
b.Property<Guid>("NotificationConfigId")
.HasColumnType("TEXT")
.HasColumnName("notification_config_id");
b.Property<string>("Password")
.HasMaxLength(255)
.HasColumnType("TEXT")
.HasColumnName("password");
b.Property<string>("Priority")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("priority");
b.Property<string>("ServerUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT")
.HasColumnName("server_url");
b.PrimitiveCollection<string>("Tags")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("tags");
b.PrimitiveCollection<string>("Topics")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("topics");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("CronExpression")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("cron_expression");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<bool>("UseAdvancedScheduling")
.HasColumnType("INTEGER")
.HasColumnName("use_advanced_scheduling");
b.ComplexProperty<Dictionary<string, object>>("FailedImport", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.FailedImport#FailedImportConfig", b1 =>
{
b1.IsRequired();
b1.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("failed_import_delete_private");
b1.Property<bool>("IgnorePrivate")
.HasColumnType("INTEGER")
.HasColumnName("failed_import_ignore_private");
b1.PrimitiveCollection<string>("IgnoredPatterns")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("failed_import_ignored_patterns");
b1.Property<ushort>("MaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("failed_import_max_strikes");
});
b.ComplexProperty<Dictionary<string, object>>("Slow", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.Slow#SlowConfig", b1 =>
{
b1.IsRequired();
b1.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("slow_delete_private");
b1.Property<string>("IgnoreAboveSize")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("slow_ignore_above_size");
b1.Property<bool>("IgnorePrivate")
.HasColumnType("INTEGER")
.HasColumnName("slow_ignore_private");
b1.Property<ushort>("MaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("slow_max_strikes");
b1.Property<double>("MaxTime")
.HasColumnType("REAL")
.HasColumnName("slow_max_time");
b1.Property<string>("MinSpeed")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("slow_min_speed");
b1.Property<bool>("ResetStrikesOnProgress")
.HasColumnType("INTEGER")
.HasColumnName("slow_reset_strikes_on_progress");
});
b.ComplexProperty<Dictionary<string, object>>("Stalled", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.Stalled#StalledConfig", b1 =>
{
b1.IsRequired();
b1.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("stalled_delete_private");
b1.Property<ushort>("DownloadingMetadataMaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("stalled_downloading_metadata_max_strikes");
b1.Property<bool>("IgnorePrivate")
.HasColumnType("INTEGER")
.HasColumnName("stalled_ignore_private");
b1.Property<ushort>("MaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("stalled_max_strikes");
b1.Property<bool>("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
}
}
}

View File

@@ -0,0 +1,54 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class AddNtfy : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ntfy_configs",
columns: table => new
{
id = table.Column<Guid>(type: "TEXT", nullable: false),
notification_config_id = table.Column<Guid>(type: "TEXT", nullable: false),
server_url = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
topics = table.Column<string>(type: "TEXT", nullable: false),
authentication_type = table.Column<string>(type: "TEXT", nullable: false),
username = table.Column<string>(type: "TEXT", maxLength: 255, nullable: true),
password = table.Column<string>(type: "TEXT", maxLength: 255, nullable: true),
access_token = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true),
priority = table.Column<string>(type: "TEXT", nullable: false),
tags = table.Column<string>(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);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ntfy_configs");
}
}
}

View File

@@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AccessToken")
.HasMaxLength(500)
.HasColumnType("TEXT")
.HasColumnName("access_token");
b.Property<string>("AuthenticationType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("authentication_type");
b.Property<Guid>("NotificationConfigId")
.HasColumnType("TEXT")
.HasColumnName("notification_config_id");
b.Property<string>("Password")
.HasMaxLength(255)
.HasColumnType("TEXT")
.HasColumnName("password");
b.Property<string>("Priority")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("priority");
b.Property<string>("ServerUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT")
.HasColumnName("server_url");
b.PrimitiveCollection<string>("Tags")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("tags");
b.PrimitiveCollection<string>("Topics")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("topics");
b.Property<string>("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<Guid>("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
}

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="M433.8 75.1C386.3 26.7 323.2 0 256 0S125.7 26.7 78.2 75.1C30.8 123.4 4.7 187.7 4.7 256s26.1 132.6 73.5 180.9C125.7 485.3 188.8 512 256 512s130.3-26.7 177.8-75.1c47.4-48.3 73.5-112.6 73.5-180.9s-26.1-132.6-73.5-180.9m-76.9 98.1c-7.4-13.4-21.5-19.8-37.8-17-5.6.9-10.9-2.8-11.9-8.4-.9-5.6 2.8-10.9 8.4-11.9 25-4.2 47.7 6.3 59.3 27.4 11.4 20.8 8.4 45.8-7.7 63.7-2 2.3-4.8 3.4-7.7 3.4-2.5 0-4.9-.9-6.9-2.6-4.2-3.8-4.6-10.3-.8-14.5 10.3-11.3 12.2-27 5.1-40.1M113.1 365.1c-28.2 0-51.1-22.2-51.1-49.6 0-14.7 6.6-27.9 17.1-37 16.2 27.7 33.8 56.4 50.1 84.1-5.2 1.6-10.5 2.5-16.1 2.5m121.8 54-21.2 12.2c-7.7 4.5-17.6 1.8-22.1-5.9l-30.1-52.1 49.2-28.4 30.1 52.1c4.5 7.8 1.8 17.7-5.9 22.1M150 352.4c-16.8-29-35.8-59.7-52.6-88.7 28.8-15.5 85.8-44.5 118.5-108.4 7.2-14.1 10.9-28.8 15.7-40.9 1.5-3.9 4.7-6.9 8.7-8 1.4-.4 3.1-.6 4.9-.4 4.4.4 8.6 2.8 11 7l103.3 177.7c.2.3.3.6.5.9 4.8 9.8-3.9 21-14.7 19.3-15.3-2.4-31.9-6.7-51.3-6.8-69.4-.5-116.4 34.5-144 48.3m284.4-164.2c-1.9 22.8-9.9 38-22.3 55.2-2.1 2.9-5.4 4.5-8.8 4.5-2.2 0-4.4-.7-6.3-2-4.8-3.5-6-10.2-2.5-15.1 10.4-14.6 16.8-26.5 18.3-44.4 1.8-22.3-5.8-42.6-21.4-57-17-15.7-41.8-22.8-66.2-18.8-5.9 1-11.4-3.1-12.4-8.9-1-5.9 3.1-11.4 8.9-12.4 31-5 62.5 4.1 84.4 24.3 20.7 19 30.7 45.6 28.3 74.6" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><linearGradient id="apprise_svg__a" x1="88.655" x2="423.345" y1="423.345" y2="88.656" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#144042"/><stop offset="1" style="stop-color:#216666"/></linearGradient><ellipse cx="256" cy="256" rx="234.3" ry="239" style="fill:url(#apprise_svg__a)"/><path d="M433.8 75.1C386.3 26.7 323.2 0 256 0S125.7 26.7 78.2 75.1C30.8 123.4 4.7 187.7 4.7 256s26.1 132.6 73.5 180.9C125.7 485.3 188.8 512 256 512s130.3-26.7 177.8-75.1c47.4-48.3 73.5-112.6 73.5-180.9s-26.1-132.6-73.5-180.9M256 495C126.6 495 21.7 388 21.7 256S126.6 17 256 17s234.3 107 234.3 239S385.4 495 256 495"/><path d="M217.4 341.1c-2.1-3.7-6.9-5-10.5-2.8l-49.2 28.4c-2.6 1.5-4.2 4.4-3.8 7.6.1 1.1.6 2.2 1.1 3.2l29.7 51.5c3.3 5.6 8.5 9.7 14.8 11.3 2.1.6 4.2.8 6.3.8 4.2 0 8.4-1.1 12.1-3.3l19.9-11.5c5.3-3.1 9.5-7.8 11.4-13.6 2.2-6.7 1.5-13.9-2-19.9zm17.1 78.3-20.4 11.8c-8 4.6-18.1 1.9-22.7-6.1l-29.9-51.7 49.2-28.4 29.9 51.7c4.6 7.9 1.9 18.1-6.1 22.7m132.4-131.2c-.2-.5-.5-.9-.7-1.4L262.9 109.2c-3.6-6.1-9.9-10.2-17-10.8-2.6-.2-5.1 0-7.6.6-6.2 1.7-11.4 6.4-13.9 12.6-1.7 4.4-3.3 9-4.9 13.8-2.9 8.7-6 17.7-10.5 26.4-30.6 59.6-83.8 88.2-112.4 103.6l-2.9 1.5c-3.8 2.1-5.2 6.9-3 10.7 8.4 14.6 17.5 29.8 26.4 44.5 8.8 14.7 17.9 29.8 26.2 44.3 2 3.5 6.5 4.9 10.1 3 5-2.5 10.7-5.7 16.7-9.1 27.1-15.3 68-38.4 122.5-38.4h1.3c13.7.1 25.9 2.4 37.8 4.6 4.3.8 8.3 1.5 12.4 2.2 8 1.3 16-2 20.8-8.6 4.7-6.5 5.5-14.8 2-21.9 0 .1 0 0 0 0m-21.6 22.7c-15.3-2.4-31.9-6.7-51.3-6.8-69.4-.5-116.4 34.5-144 48.3-16.8-29-35.8-59.7-52.6-88.7 28.8-15.5 85.8-44.5 118.5-108.4 7.2-14.1 10.9-28.8 15.7-40.9 1.5-3.9 4.7-6.9 8.7-8 1.4-.4 3.1-.6 4.9-.4 4.4.4 8.6 2.8 11 7l103.3 177.7c.2.3.3.6.5.9 4.8 9.9-3.9 21-14.7 19.3M111 317.1c-8.4-14-17.1-28.5-25.3-42.6-1.2-2-3.1-3.3-5.4-3.7s-4.6.3-6.3 1.8c-12.6 10.9-19.8 26.5-19.8 42.9 0 31.6 26.4 57.3 58.9 57.3 6.3 0 12.4-1 18.4-2.9 2.2-.7 4-2.4 4.9-4.6.8-2.2.6-4.6-.6-6.7-8.1-13.6-16.6-27.8-24.8-41.5m2.1 48c-28.2 0-51.1-22.2-51.1-49.6 0-14.7 6.6-27.9 17.1-37 16.2 27.7 33.8 56.4 50.1 84.1-5.2 1.6-10.5 2.5-16.1 2.5m268.6-205.5c-13.2-24.1-39-36.1-67.3-31.3-4.7.8-8.9 3.4-11.7 7.3s-3.9 8.7-3.1 13.4c1.6 9.8 10.9 16.4 20.8 14.8 13.1-2.2 24 2.6 29.8 13.2 5.6 10.1 4 22.3-3.9 31.2-3.2 3.6-4.8 8.2-4.6 13 .3 4.8 2.4 9.2 5.9 12.4 3.3 3 7.6 4.6 12 4.6 5.1 0 10-2.2 13.4-6 18.3-20.4 21.7-48.9 8.7-72.6M367.3 227c-2 2.3-4.8 3.4-7.7 3.4-2.5 0-4.9-.9-6.9-2.6-4.2-3.8-4.6-10.3-.8-14.5 10.2-11.3 12.1-27 4.9-40-7.4-13.4-21.5-19.8-37.8-17-5.6.9-10.9-2.8-11.9-8.4-.9-5.6 2.8-10.9 8.4-11.9 25-4.2 47.7 6.3 59.3 27.4 11.6 20.7 8.6 45.7-7.5 63.6m44.1-119.1c-23.6-21.8-57.5-31.6-90.9-26.2-4.9.8-9.2 3.4-12.1 7.5-2.9 4-4.1 8.9-3.3 13.8 1.6 10.1 11.2 17 21.3 15.3 22.2-3.6 44.5 2.7 59.8 16.8 13.9 12.8 20.6 30.9 19 50.7-1.3 16.2-7 26.8-16.9 40.5-2.9 4-4 8.9-3.2 13.8s3.5 9.2 7.5 12c3.2 2.3 6.9 3.5 10.8 3.5 6 0 11.6-2.9 15.1-7.7 13.1-18.3 21.6-34.5 23.7-59.1 2.5-31.4-8.4-60.2-30.8-80.9m23 80.3c-1.9 22.8-9.9 38-22.3 55.2-2.1 2.9-5.4 4.5-8.8 4.5-2.2 0-4.4-.7-6.3-2-4.8-3.5-6-10.2-2.5-15.1 10.4-14.6 16.8-26.5 18.3-44.4 1.8-22.3-5.8-42.6-21.4-57-17-15.7-41.8-22.8-66.2-18.8-5.9 1-11.4-3.1-12.4-8.9-1-5.9 3.1-11.4 8.9-12.4 31-5 62.5 4.1 84.4 24.3 20.7 19 30.7 45.6 28.3 74.6" style="fill:#99b4b4"/><path d="M360 291.6c4.8 9.8-3.9 21-14.7 19.3-15.3-2.4-31.9-6.7-51.3-6.8-69.4-.5-116.4 34.5-144 48.3-16.8-29-35.8-59.7-52.6-88.7 28.8-15.5 85.8-44.5 118.5-108.4 7.2-14.1 10.9-28.8 15.7-40.9 1.5-3.9 4.7-6.9 8.7-8 1.4-.4 3.1-.6 4.9-.4 4.4.4 8.6 2.8 11 7l103.3 177.7c.2.3.4.6.5.9M79 278.4c-10.5 9.1-17.1 22.3-17.1 37 0 27.4 22.9 49.6 51.1 49.6 5.6 0 11-.9 16-2.5-16.2-27.7-33.7-56.4-50-84.1m161.8 118.7L210.7 345l-49.2 28.4 30.1 52.1c4.5 7.7 14.3 10.4 22.1 5.9l21.2-12.2c7.7-4.5 10.4-14.4 5.9-22.1m171.3-153.6c12.3-17.2 20.4-32.4 22.3-55.2 2.4-29.1-7.6-55.6-28.3-74.7-21.8-20.2-53.4-29.3-84.4-24.3-5.9 1-9.9 6.5-8.9 12.4s6.5 9.9 12.4 8.9c24.5-4 49.2 3.1 66.2 18.8 15.7 14.5 23.3 34.7 21.4 57-1.5 17.9-7.8 29.9-18.3 44.4-3.5 4.8-2.4 11.6 2.5 15.1 1.9 1.4 4.1 2 6.3 2 3.4.1 6.7-1.5 8.8-4.4M367.3 227c16.1-17.9 19.1-42.9 7.7-63.7-11.6-21.1-34.3-31.6-59.3-27.4-5.6.9-9.4 6.3-8.4 11.9.9 5.6 6.3 9.4 11.9 8.4 16.3-2.7 30.4 3.6 37.8 17 7.2 13 5.2 28.7-4.9 40-3.8 4.2-3.5 10.7.8 14.5 2 1.8 4.4 2.6 6.9 2.6 2.6.1 5.4-1 7.5-3.3"/></svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.8 KiB

View File

@@ -0,0 +1,2 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="M443.1 32.7h-365C40.9 32.7 9 62 9 99.2l.4 311.2-9.4 69 127.1-33.8H443c37.2 0 69.1-29.3 69.1-66.5V99.2c0-37.2-31.9-66.5-69-66.5m22 346.3c0 10-9 19.8-22.1 19.6H120.2l-64.6 19.5.7-3.8-.4-315.1c0-10.1 9.1-19.6 22.2-19.6H443c13.1 0 22.1 9.5 22.1 19.6zM110.5 139.7l124.6 67.9V254l-116.4 63.3-8.2 4.5v-50.1l76.6-40.6.5-.2-.5-.2-76.6-40.6zm158.2 152.4h132.4v46H268.7z" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 479 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><linearGradient id="ntfy_svg__a" x1="21.759" x2="445.247" y1="74.359" y2="393.379" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#348878"/><stop offset="1" style="stop-color:#56bda8"/></linearGradient><path d="M120.2 398.6H443c13.1.2 22.1-9.6 22.1-19.6V99.2c0-10.1-9-19.6-22.1-19.6H78.1c-13.1 0-22.2 9.5-22.2 19.6l.4 315.1-.7 3.8z" style="fill:url(#ntfy_svg__a)"/><path d="M443.1 32.7h-365C40.9 32.7 9 62 9 99.2l.4 311.2-9.4 69 127.1-33.8H443c37.2 0 69.1-29.3 69.1-66.5V99.2c0-37.2-31.9-66.5-69-66.5m22 346.3c0 10-9 19.8-22.1 19.6H120.2l-64.6 19.5.7-3.8-.4-315.1c0-10.1 9.1-19.6 22.2-19.6H443c13.1 0 22.1 9.5 22.1 19.6zM110.5 139.7l124.6 67.9V254l-116.4 63.3-8.2 4.5v-50.1l76.6-40.6.5-.2-.5-.2-76.6-40.6zm158.2 152.4h132.4v46H268.7z" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 862 B

View File

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

View File

@@ -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<NotificationProviderDto>(`${this.baseUrl}/notification_providers/apprise`, provider);
}
/**
* Create a new Ntfy provider
*/
createNtfyProvider(provider: CreateNtfyProviderDto): Observable<NotificationProviderDto> {
return this.http.post<NotificationProviderDto>(`${this.baseUrl}/notification_providers/ntfy`, provider);
}
/**
* Update an existing Notifiarr provider
*/
@@ -118,6 +176,13 @@ export class NotificationProviderService {
return this.http.put<NotificationProviderDto>(`${this.baseUrl}/notification_providers/apprise/${id}`, provider);
}
/**
* Update an existing Ntfy provider
*/
updateNtfyProvider(id: string, provider: UpdateNtfyProviderDto): Observable<NotificationProviderDto> {
return this.http.put<NotificationProviderDto>(`${this.baseUrl}/notification_providers/ntfy/${id}`, provider);
}
/**
* Delete a notification provider
*/
@@ -139,6 +204,13 @@ export class NotificationProviderService {
return this.http.post<TestNotificationResult>(`${this.baseUrl}/notification_providers/apprise/test`, testRequest);
}
/**
* Test an Ntfy provider (without ID - for testing configuration before saving)
*/
testNtfyProvider(testRequest: TestNtfyProviderDto): Observable<TestNotificationResult> {
return this.http.post<TestNotificationResult>(`${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}`);
}

View File

@@ -363,8 +363,6 @@
multiple
fluid
[typeahead]="false"
[suggestions]="unlinkedCategoriesSuggestions"
(completeMethod)="onUnlinkedCategoriesComplete($event)"
placeholder="Add category and press Enter"
class="desktop-only"
>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,213 @@
<app-notification-provider-base
[visible]="visible"
modalTitle="Configure ntfy Provider"
[saving]="saving"
[testing]="testing"
[editingProvider]="editingProvider"
(save)="onSave($event)"
(cancel)="onCancel()"
(test)="onTest($event)"
>
<!-- Provider-specific configuration goes here -->
<div slot="provider-config">
<!-- Server URL -->
<div class="field">
<label for="server-url">
<i
class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('ntfy.serverUrl')"
></i>
Server URL *
</label>
<input
id="server-url"
type="url"
pInputText
[formControl]="serverUrlControl"
placeholder="https://ntfy.sh"
class="w-full"
/>
<small *ngIf="hasFieldError(serverUrlControl, 'required')" class="p-error">Server URL is required</small>
<small *ngIf="hasFieldError(serverUrlControl, 'invalidUri')" class="p-error">Must be a valid URL</small>
<small *ngIf="hasFieldError(serverUrlControl, 'invalidProtocol')" class="p-error">Must use http or https protocol</small>
<small class="form-helper-text">The URL of your ntfy server. Use https://ntfy.sh for the public service or your self-hosted instance.</small>
</div>
<!-- Topics -->
<div class="field">
<label for="topics">
<i
class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('ntfy.topics')"
></i>
Topics *
</label>
<!-- Mobile-friendly autocomplete (chips UI) -->
<app-mobile-autocomplete
[formControl]="topicsControl"
placeholder="Enter topic names"
></app-mobile-autocomplete>
<!-- Desktop autocomplete (allows multiple entries) -->
<p-autocomplete
id="topics"
[formControl]="topicsControl"
multiple
fluid
[typeahead]="false"
placeholder="Add a topic and press Enter"
class="desktop-only w-full"
></p-autocomplete>
<small *ngIf="hasFieldError(topicsControl, 'required')" class="p-error">At least one topic is required</small>
<small *ngIf="hasFieldError(topicsControl, 'minlength')" class="p-error">At least one topic is required</small>
<small class="form-helper-text">Enter the ntfy topics you want to publish to. Press Enter or comma to add each topic.</small>
</div>
<!-- Authentication Type -->
<div class="field">
<label for="auth-type">
<i
class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('ntfy.authenticationType')"
></i>
Authentication Type *
</label>
<p-select
id="auth-type"
[formControl]="authenticationTypeControl"
[options]="authenticationOptions"
optionLabel="label"
optionValue="value"
class="w-full"
[showClear]="false"
></p-select>
<small *ngIf="hasFieldError(authenticationTypeControl, 'required')" class="p-error">Authentication type is required</small>
<small class="form-helper-text">Choose how to authenticate with the ntfy server.</small>
</div>
<!-- Username (conditional on Basic Auth) -->
<div class="field" *ngIf="currentAuthType === NtfyAuthenticationType.BasicAuth">
<label for="username">
<i
class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('ntfy.username')"
></i>
Username *
</label>
<input
id="username"
type="text"
pInputText
[formControl]="usernameControl"
placeholder="Enter username"
class="w-full"
autocomplete="username"
/>
<small *ngIf="hasFieldError(usernameControl, 'required')" class="p-error">Username is required for Basic Auth</small>
<small class="form-helper-text">Your username for basic authentication.</small>
</div>
<!-- Password (conditional on Basic Auth) -->
<div class="field" *ngIf="currentAuthType === NtfyAuthenticationType.BasicAuth">
<label for="password">
<i
class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('ntfy.password')"
></i>
Password *
</label>
<input
id="password"
type="password"
pInputText
[formControl]="passwordControl"
placeholder="Enter password"
class="w-full"
autocomplete="current-password"
/>
<small *ngIf="hasFieldError(passwordControl, 'required')" class="p-error">Password is required for Basic Auth</small>
<small class="form-helper-text">Your password for basic authentication.</small>
</div>
<!-- Access Token (conditional on Access Token) -->
<div class="field" *ngIf="currentAuthType === NtfyAuthenticationType.AccessToken">
<label for="access-token">
<i
class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('ntfy.accessToken')"
></i>
Access Token *
</label>
<input
id="access-token"
type="password"
pInputText
[formControl]="accessTokenControl"
placeholder="Enter access token"
class="w-full"
/>
<small *ngIf="hasFieldError(accessTokenControl, 'required')" class="p-error">Access token is required</small>
<small class="form-helper-text">Your access token for bearer token authentication.</small>
</div>
<!-- Priority -->
<div class="field">
<label for="priority">
<i
class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('ntfy.priority')"
></i>
Priority *
</label>
<p-select
id="priority"
[formControl]="priorityControl"
[options]="priorityOptions"
optionLabel="label"
optionValue="value"
class="w-full"
[showClear]="false"
></p-select>
<small *ngIf="hasFieldError(priorityControl, 'required')" class="p-error">Priority is required</small>
<small class="form-helper-text">The priority level for notifications (1=min, 5=max).</small>
</div>
<!-- Tags -->
<div class="field">
<label for="tags">
<i
class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('ntfy.tags')"
></i>
Tags (Optional)
</label>
<!-- Mobile-friendly autocomplete (chips UI) -->
<app-mobile-autocomplete
[formControl]="tagsControl"
placeholder="Enter tag names"
></app-mobile-autocomplete>
<!-- Desktop autocomplete (allows multiple entries) -->
<p-autocomplete
id="tags"
[formControl]="tagsControl"
multiple
fluid
[typeahead]="false"
placeholder="Add a tag and press Enter"
class="desktop-only w-full"
></p-autocomplete>
<small class="form-helper-text">Optional tags to add to notifications (e.g., warning, alert). Press Enter or comma to add each tag.</small>
</div>
</div>
</app-notification-provider-base>

View File

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

View File

@@ -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<NtfyFormData>();
@Output() cancel = new EventEmitter<void>();
@Output() test = new EventEmitter<NtfyFormData>();
// Provider-specific form controls
serverUrlControl = new FormControl('', [Validators.required, UrlValidators.httpUrl]);
topicsControl = new FormControl<string[]>([], [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<string[]>([]);
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;
}
}

View File

@@ -0,0 +1,49 @@
<p-dialog
[(visible)]="visible"
[modal]="true"
[closable]="true"
[draggable]="false"
[resizable]="false"
styleClass="instance-modal provider-selection-modal"
header="Add Notification Provider"
(onHide)="onCancel()">
<div class="provider-selection-content">
<p class="selection-description">
Choose a notification provider type to configure:
</p>
<div class="provider-selection-grid">
<div
class="provider-card"
*ngFor="let provider of availableProviders"
(click)="selectProvider(provider.type)"
(mouseenter)="onProviderEnter(provider.type)"
(mouseleave)="onProviderLeave()"
[attr.data-provider]="provider.type">
<div class="provider-icon">
<img [src]="hoveredProvider === provider.type && provider.iconUrlHover ? provider.iconUrlHover : provider.iconUrl" [alt]="provider.name + ' icon'" class="provider-icon-image">
</div>
<div class="provider-name">
{{ provider.name }}
</div>
<div class="provider-description" *ngIf="provider.description">
{{ provider.description }}
</div>
</div>
</div>
</div>
<ng-template pTemplate="footer">
<div class="modal-footer">
<button
pButton
type="button"
label="Cancel"
class="p-button-text"
(click)="onCancel()">
</button>
</div>
</ng-template>
</p-dialog>

View File

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

View File

@@ -13,83 +13,51 @@ import { ProviderTypeInfo } from '../../models/provider-modal.model';
DialogModule,
ButtonModule
],
template: `
<p-dialog
[(visible)]="visible"
[modal]="true"
[closable]="true"
[draggable]="false"
[resizable]="false"
styleClass="instance-modal provider-selection-modal"
header="Add Notification Provider"
(onHide)="onCancel()">
<div class="provider-selection-content">
<p class="selection-description">
Choose a notification provider type to configure:
</p>
<div class="provider-selection-grid">
<div
class="provider-card"
*ngFor="let provider of availableProviders"
(click)="selectProvider(provider.type)"
[attr.data-provider]="provider.type">
<div class="provider-icon">
<i [class]="provider.iconClass"></i>
</div>
<div class="provider-name">
{{ provider.name }}
</div>
<div class="provider-description" *ngIf="provider.description">
{{ provider.description }}
</div>
</div>
</div>
</div>
<ng-template pTemplate="footer">
<div class="modal-footer">
<button
pButton
type="button"
label="Cancel"
class="p-button-text"
(click)="onCancel()">
</button>
</div>
</ng-template>
</p-dialog>
`,
templateUrl: './provider-type-selection.component.html',
styleUrls: ['./provider-type-selection.component.scss']
})
export class ProviderTypeSelectionComponent {
@Input() visible = false;
@Output() providerSelected = new EventEmitter<NotificationProviderType>();
@Output() cancel = new EventEmitter<void>();
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();
}

View File

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

View File

@@ -176,6 +176,17 @@
(test)="onAppriseTest($event)"
></app-apprise-provider>
<!-- Ntfy Provider Modal -->
<app-ntfy-provider
[visible]="showNtfyModal"
[editingProvider]="editingProvider"
[saving]="saving()"
[testing]="testing()"
(save)="onNtfySave($event)"
(cancel)="onProviderCancel()"
(test)="onNtfyTest($event)"
></app-ntfy-provider>
<!-- Confirmation Dialog -->
<p-confirmDialog></p-confirmDialog>

View File

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

View File

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

View File

@@ -13,4 +13,5 @@ export enum DownloadClientTypeName {
export enum NotificationProviderType {
Notifiarr = "Notifiarr",
Apprise = "Apprise",
Ntfy = "Ntfy",
}

View File

@@ -0,0 +1,5 @@
export enum NtfyAuthenticationType {
None = 'None',
BasicAuth = 'BasicAuth',
AccessToken = 'AccessToken'
}

View File

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

View File

@@ -0,0 +1,7 @@
export enum NtfyPriority {
Min = 'Min',
Low = 'Low',
Default = 'Default',
High = 'High',
Max = 'Max'
}

View File

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

View File

@@ -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.
<div className={styles.documentationPage}>
<div className={styles.section}>
<h2 className={styles.sectionTitle}>
<span className={styles.sectionIcon}>📡</span>
Configuration
</h2>
<p className={styles.sectionDescription}>
Configure Apprise to send notifications through any of its supported services.
</p>
<ConfigSection
id="apprise-url"
title="Apprise URL"
icon="🌐"
>
The Apprise server URL where notification requests will be sent.
</ConfigSection>
<ConfigSection
id="apprise-key"
title="Apprise Key"
icon="🔐"
>
The key that identifies your Apprise configuration. This corresponds to a configuration defined in your Apprise server.
</ConfigSection>
<ConfigSection
id="apprise-tags"
title="Apprise Tags"
icon="🏷️"
>
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).
</ConfigSection>
</div>
</div>

View File

@@ -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.
<div className={styles.section}>
<h2 className={styles.sectionTitle}>
<span className={styles.sectionIcon}>⚙️</span>
General Settings
</h2>
<ConfigSection
id="enabled"
title="Enabled"
@@ -37,89 +40,13 @@ A unique name to identify this provider. This name will be displayed in the user
</ConfigSection>
<h2 className={styles.sectionTitle}>
<span className={styles.sectionIcon}>🚀</span>
Notifiarr
</h2>
<p className={styles.sectionDescription}>
Notifiarr is a notification service that can send alerts to Discord.
</p>
<ConfigSection
id="notifiarr-api-key"
title="Notifiarr API Key"
icon="🔑"
>
Your Notifiarr API key for authentication. This key is obtained from your Notifiarr dashboard.
<EnhancedImportant>
Requires Notifiarr's [Passthrough](https://notifiarr.wiki/pages/integrations/passthrough/) integration to work.
</EnhancedImportant>
</ConfigSection>
<ConfigSection
id="notifiarr-channel-id"
title="Notifiarr Channel ID"
icon="💬"
>
The Discord channel ID where notifications will be sent. This determines the destination for your alerts.
</ConfigSection>
</div>
<div className={styles.section}>
<h2 className={styles.sectionTitle}>
<span className={styles.sectionIcon}>📡</span>
Apprise Configuration
</h2>
<p className={styles.sectionDescription}>
Apprise is a universal notification library that supports over 80 different notification services.
</p>
<ConfigSection
id="apprise-url"
title="Apprise URL"
icon="🌐"
>
The Apprise server URL where notification requests will be sent.
</ConfigSection>
<ConfigSection
id="apprise-key"
title="Apprise Key"
icon="🔐"
>
The key that identifies your Apprise configuration. This corresponds to a configuration defined in your Apprise server.
</ConfigSection>
<ConfigSection
id="apprise-tags"
title="Apprise Tags"
icon="🏷️"
>
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).
</ConfigSection>
</div>
<div className={styles.section}>
<h2 id="event-triggers" className={styles.sectionTitle}>
<span className={styles.sectionIcon}>⚡</span>
Event Triggers
Event Configuration
</h2>
<ConfigSection
@@ -158,7 +85,7 @@ Optionally notify only those tagged accordingly. Use a comma (,) to OR your tags
icon="🗑️"
>
**Triggered When**: A download is removed from the queue.
**Triggered When**: A download is removed from an arr queue.
</ConfigSection>

View File

@@ -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.
<div className={styles.documentationPage}>
<div className={styles.section}>
<h2 className={styles.sectionTitle}>
<span className={styles.sectionIcon}>🚀</span>
Configuration
</h2>
<p className={styles.sectionDescription}>
Configure Notifiarr to send notifications to your Discord server.
</p>
<ConfigSection
id="notifiarr-api-key"
title="Notifiarr API Key"
icon="🔑"
>
Your Notifiarr API key for authentication. This key is obtained from your Notifiarr dashboard.
<EnhancedImportant>
Requires Notifiarr's [Passthrough](https://notifiarr.wiki/pages/integrations/passthrough/) integration to work.
</EnhancedImportant>
</ConfigSection>
<ConfigSection
id="notifiarr-channel-id"
title="Notifiarr Channel ID"
icon="💬"
>
The Discord channel ID where notifications will be sent. This determines the destination for your alerts.
</ConfigSection>
</div>
</div>

View File

@@ -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.
<div className={styles.documentationPage}>
<div className={styles.section}>
<h2 className={styles.sectionTitle}>
<span className={styles.sectionIcon}>🔔</span>
Configuration
</h2>
<p className={styles.sectionDescription}>
Configure ntfy to send push notifications to your devices.
</p>
<ConfigSection
id="ntfy-server-url"
title="Server URL"
icon="🌐"
>
The URL of your ntfy server. Use `https://ntfy.sh` for the public service or your self-hosted instance URL.
</ConfigSection>
<ConfigSection
id="ntfy-topics"
title="Topics"
icon="📢"
>
The ntfy topics you want to publish to. You can specify multiple topics to send notifications to different channels or devices.
</ConfigSection>
<ConfigSection
id="ntfy-authentication-type"
title="Authentication Type"
icon="🔐"
>
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
</ConfigSection>
<ConfigSection
id="ntfy-username"
title="Username"
icon="👤"
>
Your username for basic authentication. Required when using Basic Auth authentication type.
</ConfigSection>
<ConfigSection
id="ntfy-password"
title="Password"
icon="🔑"
>
Your password for basic authentication. Required when using Basic Auth authentication type.
</ConfigSection>
<ConfigSection
id="ntfy-access-token"
title="Access Token"
icon="🎫"
>
Your access token for bearer token authentication. Required when using Access Token authentication type.
</ConfigSection>
<ConfigSection
id="ntfy-priority"
title="Priority"
icon="🔥"
>
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
</ConfigSection>
<ConfigSection
id="ntfy-tags"
title="Tags"
icon="🏷️"
>
Tags to add to notifications.
Reference: https://docs.ntfy.sh/publish/#tags-emojis
</ConfigSection>
</div>
</div>