Compare commits

...

7 Commits

Author SHA1 Message Date
Flaminel
bb9ac5b67b Fix notifications migration when no event type is enabled (#290) 2025-09-03 21:12:55 +03:00
Flaminel
f93494adb2 Rework notifications system (#284) 2025-09-02 23:18:22 +03:00
Flaminel
7201520411 Add configurable log retention (#279) 2025-09-02 00:17:16 +03:00
Flaminel
2a1e65e1af Make sidebar scrollable (#285) 2025-09-02 00:16:38 +03:00
Flaminel
da318c3339 Fix HTTPS schema for Cloudflare pages links (#286) 2025-09-02 00:16:27 +03:00
Flaminel
7149b6243f Add .sql to the blacklist (#287) 2025-09-02 00:16:12 +03:00
Flaminel
11f5a28c04 Improve download client health checks (#288) 2025-09-02 00:15:09 +03:00
99 changed files with 7230 additions and 1923 deletions

View File

@@ -640,6 +640,7 @@
*.spm
*.spr
*.spt
*.sql
*.sqf
*.sqx
*.sqz

View File

@@ -1,8 +1,11 @@
using Cleanuparr.Api.Models;
using Cleanuparr.Api.Models.NotificationProviders;
using Cleanuparr.Application.Features.Arr.Dtos;
using Cleanuparr.Application.Features.DownloadClient.Dtos;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
using Cleanuparr.Infrastructure.Logging;
using Cleanuparr.Infrastructure.Models;
@@ -29,23 +32,26 @@ public class ConfigurationController : ControllerBase
{
private readonly ILogger<ConfigurationController> _logger;
private readonly DataContext _dataContext;
private readonly LoggingConfigManager _loggingConfigManager;
private readonly IJobManagementService _jobManagementService;
private readonly MemoryCache _cache;
private readonly INotificationConfigurationService _notificationConfigurationService;
private readonly NotificationService _notificationService;
public ConfigurationController(
ILogger<ConfigurationController> logger,
DataContext dataContext,
LoggingConfigManager loggingConfigManager,
IJobManagementService jobManagementService,
MemoryCache cache
MemoryCache cache,
INotificationConfigurationService notificationConfigurationService,
NotificationService notificationService
)
{
_logger = logger;
_dataContext = dataContext;
_loggingConfigManager = loggingConfigManager;
_jobManagementService = jobManagementService;
_cache = cache;
_notificationConfigurationService = notificationConfigurationService;
_notificationService = notificationService;
}
[HttpGet("queue_cleaner")]
@@ -344,26 +350,47 @@ public class ConfigurationController : ControllerBase
}
}
[HttpGet("notifications")]
public async Task<IActionResult> GetNotificationsConfig()
[HttpGet("notification_providers")]
public async Task<IActionResult> GetNotificationProviders()
{
await DataContext.Lock.WaitAsync();
try
{
var notifiarrConfig = await _dataContext.NotifiarrConfigs
var providers = await _dataContext.NotificationConfigs
.Include(p => p.NotifiarrConfiguration)
.Include(p => p.AppriseConfiguration)
.AsNoTracking()
.FirstOrDefaultAsync();
.ToListAsync();
var appriseConfig = await _dataContext.AppriseConfigs
.AsNoTracking()
.FirstOrDefaultAsync();
var providerDtos = providers
.Select(p => new NotificationProviderDto
{
Id = p.Id,
Name = p.Name,
Type = p.Type,
IsEnabled = p.IsEnabled,
Events = new NotificationEventFlags
{
OnFailedImportStrike = p.OnFailedImportStrike,
OnStalledStrike = p.OnStalledStrike,
OnSlowStrike = p.OnSlowStrike,
OnQueueItemDeleted = p.OnQueueItemDeleted,
OnDownloadCleaned = p.OnDownloadCleaned,
OnCategoryChanged = p.OnCategoryChanged
},
Configuration = p.Type switch
{
NotificationProviderType.Notifiarr => p.NotifiarrConfiguration ?? new object(),
NotificationProviderType.Apprise => p.AppriseConfiguration ?? new object(),
_ => new object()
}
})
.OrderBy(x => x.Type.ToString())
.ThenBy(x => x.Name)
.ToList();
// Return in the expected format with wrapper object
var config = new
{
notifiarr = notifiarrConfig,
apprise = appriseConfig
};
// Return in the expected format with providers wrapper
var config = new { providers = providerDtos };
return Ok(config);
}
finally
@@ -371,65 +398,82 @@ public class ConfigurationController : ControllerBase
DataContext.Lock.Release();
}
}
public class UpdateNotificationConfigDto
{
public NotifiarrConfig? Notifiarr { get; set; }
public AppriseConfig? Apprise { get; set; }
}
[HttpPut("notifications")]
public async Task<IActionResult> UpdateNotificationsConfig([FromBody] UpdateNotificationConfigDto newConfig)
[HttpPost("notification_providers/notifiarr")]
public async Task<IActionResult> CreateNotifiarrProvider([FromBody] CreateNotifiarrProviderDto newProvider)
{
await DataContext.Lock.WaitAsync();
try
{
// Update Notifiarr config if provided
if (newConfig.Notifiarr != null)
// Validate required fields
if (string.IsNullOrWhiteSpace(newProvider.Name))
{
var existingNotifiarr = await _dataContext.NotifiarrConfigs.FirstOrDefaultAsync();
if (existingNotifiarr != null)
{
// Apply updates from DTO, excluding the ID property to avoid EF key modification error
var config = new TypeAdapterConfig();
config.NewConfig<NotifiarrConfig, NotifiarrConfig>()
.Ignore(dest => dest.Id);
newConfig.Notifiarr.Adapt(existingNotifiarr, config);
}
else
{
_dataContext.NotifiarrConfigs.Add(newConfig.Notifiarr);
}
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");
}
// Update Apprise config if provided
if (newConfig.Apprise != null)
// Create provider-specific configuration with validation
var notifiarrConfig = new NotifiarrConfig
{
var existingApprise = await _dataContext.AppriseConfigs.FirstOrDefaultAsync();
if (existingApprise != null)
{
// Apply updates from DTO, excluding the ID property to avoid EF key modification error
var config = new TypeAdapterConfig();
config.NewConfig<AppriseConfig, AppriseConfig>()
.Ignore(dest => dest.Id);
newConfig.Apprise.Adapt(existingApprise, config);
}
else
{
_dataContext.AppriseConfigs.Add(newConfig.Apprise);
}
}
ApiKey = newProvider.ApiKey,
ChannelId = newProvider.ChannelId
};
// Validate the configuration
notifiarrConfig.Validate();
// Persist the configuration
// Create the provider entity
var provider = new NotificationConfig
{
Name = newProvider.Name,
Type = NotificationProviderType.Notifiarr,
IsEnabled = newProvider.IsEnabled,
OnFailedImportStrike = newProvider.OnFailedImportStrike,
OnStalledStrike = newProvider.OnStalledStrike,
OnSlowStrike = newProvider.OnSlowStrike,
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
OnDownloadCleaned = newProvider.OnDownloadCleaned,
OnCategoryChanged = newProvider.OnCategoryChanged,
NotifiarrConfiguration = notifiarrConfig
};
// Add the new provider to the database
_dataContext.NotificationConfigs.Add(provider);
await _dataContext.SaveChangesAsync();
return Ok(new { Message = "Notifications configuration updated successfully" });
// 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.NotifiarrConfiguration ?? new object()
};
return CreatedAtAction(nameof(GetNotificationProviders), new { id = provider.Id }, providerDto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save Notifications configuration");
_logger.LogError(ex, "Failed to create Notifiarr provider");
throw;
}
finally
@@ -438,6 +482,442 @@ public class ConfigurationController : ControllerBase
}
}
[HttpPost("notification_providers/apprise")]
public async Task<IActionResult> CreateAppriseProvider([FromBody] CreateAppriseProviderDto 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 appriseConfig = new AppriseConfig
{
Url = newProvider.Url,
Key = newProvider.Key,
Tags = newProvider.Tags
};
// Validate the configuration
appriseConfig.Validate();
// Create the provider entity
var provider = new NotificationConfig
{
Name = newProvider.Name,
Type = NotificationProviderType.Apprise,
IsEnabled = newProvider.IsEnabled,
OnFailedImportStrike = newProvider.OnFailedImportStrike,
OnStalledStrike = newProvider.OnStalledStrike,
OnSlowStrike = newProvider.OnSlowStrike,
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
OnDownloadCleaned = newProvider.OnDownloadCleaned,
OnCategoryChanged = newProvider.OnCategoryChanged,
AppriseConfiguration = appriseConfig
};
// 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.AppriseConfiguration ?? 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 Apprise provider");
throw;
}
finally
{
DataContext.Lock.Release();
}
}
// Provider-specific UPDATE endpoints
[HttpPut("notification_providers/notifiarr/{id}")]
public async Task<IActionResult> UpdateNotifiarrProvider(Guid id, [FromBody] UpdateNotifiarrProviderDto updatedProvider)
{
await DataContext.Lock.WaitAsync();
try
{
// Find the existing notification provider
var existingProvider = await _dataContext.NotificationConfigs
.Include(p => p.NotifiarrConfiguration)
.FirstOrDefaultAsync(p => p.Id == id && p.Type == NotificationProviderType.Notifiarr);
if (existingProvider == null)
{
return NotFound($"Notifiarr 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.CountAsync(x => x.Name == updatedProvider.Name);
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
}
// Create provider-specific configuration with validation
var notifiarrConfig = new NotifiarrConfig
{
ApiKey = updatedProvider.ApiKey,
ChannelId = updatedProvider.ChannelId
};
// Preserve the existing ID if updating
if (existingProvider.NotifiarrConfiguration != null)
{
notifiarrConfig = notifiarrConfig with { Id = existingProvider.NotifiarrConfiguration.Id };
}
// Validate the configuration
notifiarrConfig.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,
NotifiarrConfiguration = notifiarrConfig,
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.NotifiarrConfiguration ?? new object()
};
return Ok(providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update Notifiarr provider with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("notification_providers/apprise/{id}")]
public async Task<IActionResult> UpdateAppriseProvider(Guid id, [FromBody] UpdateAppriseProviderDto updatedProvider)
{
await DataContext.Lock.WaitAsync();
try
{
// Find the existing notification provider
var existingProvider = await _dataContext.NotificationConfigs
.Include(p => p.AppriseConfiguration)
.FirstOrDefaultAsync(p => p.Id == id && p.Type == NotificationProviderType.Apprise);
if (existingProvider == null)
{
return NotFound($"Apprise 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.CountAsync(x => x.Name == updatedProvider.Name);
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
}
// Create provider-specific configuration with validation
var appriseConfig = new AppriseConfig
{
Url = updatedProvider.Url,
Key = updatedProvider.Key,
Tags = updatedProvider.Tags
};
// Preserve the existing ID if updating
if (existingProvider.AppriseConfiguration != null)
{
appriseConfig = appriseConfig with { Id = existingProvider.AppriseConfiguration.Id };
}
// Validate the configuration
appriseConfig.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,
AppriseConfiguration = appriseConfig,
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.AppriseConfiguration ?? new object()
};
return Ok(providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update Apprise provider with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpDelete("notification_providers/{id}")]
public async Task<IActionResult> DeleteNotificationProvider(Guid id)
{
await DataContext.Lock.WaitAsync();
try
{
// Find the existing notification provider
var existingProvider = await _dataContext.NotificationConfigs
.Include(p => p.NotifiarrConfiguration)
.Include(p => p.AppriseConfiguration)
.FirstOrDefaultAsync(p => p.Id == id);
if (existingProvider == null)
{
return NotFound($"Notification provider with ID {id} not found");
}
// Remove the provider from the database
_dataContext.NotificationConfigs.Remove(existingProvider);
await _dataContext.SaveChangesAsync();
// Clear cache to ensure fresh data on next request
await _notificationConfigurationService.InvalidateCacheAsync();
_logger.LogInformation("Removed notification provider {ProviderName} with ID {ProviderId}",
existingProvider.Name, existingProvider.Id);
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete notification provider with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
// Provider-specific TEST endpoints (no ID required)
[HttpPost("notification_providers/notifiarr/test")]
public async Task<IActionResult> TestNotifiarrProvider([FromBody] TestNotifiarrProviderDto testRequest)
{
try
{
// Create configuration for testing with validation
var notifiarrConfig = new NotifiarrConfig
{
ApiKey = testRequest.ApiKey,
ChannelId = testRequest.ChannelId
};
// Validate the configuration
notifiarrConfig.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.Notifiarr,
IsEnabled = true,
Events = new NotificationEventFlags
{
OnFailedImportStrike = true, // Enable for test
OnStalledStrike = false,
OnSlowStrike = false,
OnQueueItemDeleted = false,
OnDownloadCleaned = false,
OnCategoryChanged = false
},
Configuration = notifiarrConfig
};
// 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 Notifiarr provider");
throw;
}
}
[HttpPost("notification_providers/apprise/test")]
public async Task<IActionResult> TestAppriseProvider([FromBody] TestAppriseProviderDto testRequest)
{
try
{
// Create configuration for testing with validation
var appriseConfig = new AppriseConfig
{
Url = testRequest.Url,
Key = testRequest.Key,
Tags = testRequest.Tags
};
// Validate the configuration
appriseConfig.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.Apprise,
IsEnabled = true,
Events = new NotificationEventFlags
{
OnFailedImportStrike = true, // Enable for test
OnStalledStrike = false,
OnSlowStrike = false,
OnQueueItemDeleted = false,
OnDownloadCleaned = false,
OnCategoryChanged = false
},
Configuration = appriseConfig
};
// 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 Apprise provider");
throw;
}
}
[HttpPut("queue_cleaner")]
public async Task<IActionResult> UpdateQueueCleanerConfig([FromBody] QueueCleanerConfig newConfig)
{
@@ -700,8 +1180,19 @@ public class ConfigurationController : ControllerBase
_logger.LogInformation("Updated all HTTP client configurations with new general settings");
// Set the logging level based on the new configuration
_loggingConfigManager.SetLogLevel(newConfig.LogLevel);
// Handle logging configuration changes
var loggingChanged = HasLoggingConfigurationChanged(oldConfig.Log, newConfig.Log);
if (loggingChanged.LevelOnly)
{
_logger.LogCritical("Setting global log level to {level}", newConfig.Log.Level);
LoggingConfigManager.SetLogLevel(newConfig.Log.Level);
}
else if (loggingChanged.FullReconfiguration)
{
_logger.LogCritical("Reconfiguring logger due to configuration changes");
LoggingConfigManager.ReconfigureLogging(newConfig);
}
return Ok(new { Message = "General configuration updated successfully" });
}
@@ -1454,4 +1945,40 @@ public class ConfigurationController : ControllerBase
DataContext.Lock.Release();
}
}
/// <summary>
/// Determines what type of logging reconfiguration is needed based on configuration changes
/// </summary>
/// <param name="oldConfig">The previous logging configuration</param>
/// <param name="newConfig">The new logging configuration</param>
/// <returns>A tuple indicating the type of reconfiguration needed</returns>
private static (bool LevelOnly, bool FullReconfiguration) HasLoggingConfigurationChanged(LoggingConfig oldConfig, LoggingConfig newConfig)
{
// Check if only the log level changed
bool levelChanged = oldConfig.Level != newConfig.Level;
// Check if other logging properties changed that require full reconfiguration
bool otherPropertiesChanged =
oldConfig.RollingSizeMB != newConfig.RollingSizeMB ||
oldConfig.RetainedFileCount != newConfig.RetainedFileCount ||
oldConfig.TimeLimitHours != newConfig.TimeLimitHours ||
oldConfig.ArchiveEnabled != newConfig.ArchiveEnabled ||
oldConfig.ArchiveRetainedCount != newConfig.ArchiveRetainedCount ||
oldConfig.ArchiveTimeLimitHours != newConfig.ArchiveTimeLimitHours;
if (otherPropertiesChanged)
{
// Full reconfiguration needed (includes level change if any)
return (false, true);
}
if (levelChanged)
{
// Only level changed, simple level update is sufficient
return (true, false);
}
// No logging configuration changes
return (false, false);
}
}

View File

@@ -1,10 +1,5 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Shared.Helpers;
using Cleanuparr.Infrastructure.Logging;
using Serilog;
using Serilog.Events;
using Serilog.Templates;
using Serilog.Templates.Themes;
namespace Cleanuparr.Api.DependencyInjection;
@@ -12,82 +7,10 @@ public static class LoggingDI
{
public static ILoggingBuilder AddLogging(this ILoggingBuilder builder)
{
Log.Logger = GetDefaultLoggerConfiguration().CreateLogger();
Log.Logger = LoggingConfigManager
.CreateLoggerConfiguration()
.CreateLogger();
return builder.ClearProviders().AddSerilog();
}
public static LoggerConfiguration GetDefaultLoggerConfiguration()
{
LoggerConfiguration logConfig = new();
const string categoryTemplate = "{#if Category is not null} {Concat('[',Category,']'),CAT_PAD}{#end}";
const string jobNameTemplate = "{#if JobName is not null} {Concat('[',JobName,']'),JOB_PAD}{#end}";
const string consoleOutputTemplate = $"[{{@t:yyyy-MM-dd HH:mm:ss.fff}} {{@l:u3}}]{jobNameTemplate}{categoryTemplate} {{@m}}\n{{@x}}";
const string fileOutputTemplate = $"{{@t:yyyy-MM-dd HH:mm:ss.fff zzz}} [{{@l:u3}}]{jobNameTemplate}{categoryTemplate} {{@m:lj}}\n{{@x}}";
// Determine job name padding
List<string> jobNames = [nameof(JobType.QueueCleaner), nameof(JobType.MalwareBlocker), nameof(JobType.DownloadCleaner)];
int jobPadding = jobNames.Max(x => x.Length) + 2;
// Determine instance name padding
List<string> categoryNames = [
InstanceType.Sonarr.ToString(),
InstanceType.Radarr.ToString(),
InstanceType.Lidarr.ToString(),
InstanceType.Readarr.ToString(),
InstanceType.Whisparr.ToString(),
"SYSTEM"
];
int catPadding = categoryNames.Max(x => x.Length) + 2;
// Apply padding values to templates
string consoleTemplate = consoleOutputTemplate
.Replace("JOB_PAD", jobPadding.ToString())
.Replace("CAT_PAD", catPadding.ToString());
string fileTemplate = fileOutputTemplate
.Replace("JOB_PAD", jobPadding.ToString())
.Replace("CAT_PAD", catPadding.ToString());
// Configure base logger with dynamic level control
logConfig
.MinimumLevel.Is(LogEventLevel.Information)
.Enrich.FromLogContext()
.WriteTo.Console(new ExpressionTemplate(consoleTemplate, theme: TemplateTheme.Literate));
// Create the logs directory
string logsPath = Path.Combine(ConfigurationPathProvider.GetConfigPath(), "logs");
if (!Directory.Exists(logsPath))
{
try
{
Directory.CreateDirectory(logsPath);
}
catch (Exception exception)
{
throw new Exception($"Failed to create log directory | {logsPath}", exception);
}
}
// Add main log file
logConfig.WriteTo.File(
path: Path.Combine(logsPath, "cleanuparr-.txt"),
formatter: new ExpressionTemplate(fileTemplate),
fileSizeLimitBytes: 10L * 1024 * 1024,
rollingInterval: RollingInterval.Day,
rollOnFileSizeLimit: true,
shared: true
);
logConfig
.MinimumLevel.Override("MassTransit", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information)
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.MinimumLevel.Override("Quartz", LogEventLevel.Warning)
.MinimumLevel.Override("System.Net.Http.HttpClient", LogEventLevel.Error)
.Enrich.WithProperty("ApplicationName", "Cleanuparr");
return logConfig;
}
}

View File

@@ -17,14 +17,13 @@ public static class MainDI
{
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) =>
services
.AddLogging(builder => builder.ClearProviders().AddConsole())
.AddHttpClients(configuration)
.AddSingleton<MemoryCache>()
.AddSingleton<IMemoryCache>(serviceProvider => serviceProvider.GetRequiredService<MemoryCache>())
.AddServices()
.AddHealthServices()
.AddQuartzServices(configuration)
.AddNotifications(configuration)
.AddNotifications()
.AddMassTransit(config =>
{
config.DisableUsageTelemetry();
@@ -36,7 +35,8 @@ public static class MainDI
config.AddConsumer<NotificationConsumer<FailedImportStrikeNotification>>();
config.AddConsumer<NotificationConsumer<StalledStrikeNotification>>();
config.AddConsumer<NotificationConsumer<SlowStrikeNotification>>();
config.AddConsumer<NotificationConsumer<SlowSpeedStrikeNotification>>();
config.AddConsumer<NotificationConsumer<SlowTimeStrikeNotification>>();
config.AddConsumer<NotificationConsumer<QueueItemDeletedNotification>>();
config.AddConsumer<NotificationConsumer<DownloadCleanedNotification>>();
config.AddConsumer<NotificationConsumer<CategoryChangedNotification>>();
@@ -71,7 +71,8 @@ public static class MainDI
{
e.ConfigureConsumer<NotificationConsumer<FailedImportStrikeNotification>>(context);
e.ConfigureConsumer<NotificationConsumer<StalledStrikeNotification>>(context);
e.ConfigureConsumer<NotificationConsumer<SlowStrikeNotification>>(context);
e.ConfigureConsumer<NotificationConsumer<SlowSpeedStrikeNotification>>(context);
e.ConfigureConsumer<NotificationConsumer<SlowTimeStrikeNotification>>(context);
e.ConfigureConsumer<NotificationConsumer<QueueItemDeletedNotification>>(context);
e.ConfigureConsumer<NotificationConsumer<DownloadCleanedNotification>>(context);
e.ConfigureConsumer<NotificationConsumer<CategoryChangedNotification>>(context);

View File

@@ -1,20 +1,18 @@
using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
using Infrastructure.Verticals.Notifications;
namespace Cleanuparr.Api.DependencyInjection;
public static class NotificationsDI
{
public static IServiceCollection AddNotifications(this IServiceCollection services, IConfiguration configuration) =>
public static IServiceCollection AddNotifications(this IServiceCollection services) =>
services
// Notification configs are now managed through ConfigManager
.AddTransient<INotifiarrProxy, NotifiarrProxy>()
.AddTransient<INotificationProvider, NotifiarrProvider>()
.AddTransient<IAppriseProxy, AppriseProxy>()
.AddTransient<INotificationProvider, AppriseProvider>()
.AddTransient<INotificationPublisher, NotificationPublisher>()
.AddTransient<INotificationFactory, NotificationFactory>()
.AddTransient<NotificationService>();
.AddScoped<INotifiarrProxy, NotifiarrProxy>()
.AddScoped<IAppriseProxy, AppriseProxy>()
.AddScoped<INotificationConfigurationService, NotificationConfigurationService>()
.AddScoped<INotificationProviderFactory, NotificationProviderFactory>()
.AddScoped<NotificationProviderFactory>()
.AddScoped<INotificationPublisher, NotificationPublisher>()
.AddScoped<NotificationService>();
}

View File

@@ -6,7 +6,7 @@ namespace Cleanuparr.Api;
public static class HostExtensions
{
public static async Task<IHost> Init(this WebApplication app)
public static async Task<IHost> InitAsync(this WebApplication app)
{
ILogger<Program> logger = app.Services.GetRequiredService<ILogger<Program>>();
@@ -20,22 +20,25 @@ public static class HostExtensions
logger.LogInformation("timezone: {tz}", TimeZoneInfo.Local.DisplayName);
// Apply db migrations
var scopeFactory = app.Services.GetRequiredService<IServiceScopeFactory>();
await using var scope = scopeFactory.CreateAsyncScope();
await using var eventsContext = scope.ServiceProvider.GetRequiredService<EventsContext>();
return app;
}
public static async Task<WebApplicationBuilder> InitAsync(this WebApplicationBuilder builder)
{
// Apply events db migrations
await using var eventsContext = EventsContext.CreateStaticInstance();
if ((await eventsContext.Database.GetPendingMigrationsAsync()).Any())
{
await eventsContext.Database.MigrateAsync();
}
await using var configContext = scope.ServiceProvider.GetRequiredService<DataContext>();
// Apply data db migrations
await using var configContext = DataContext.CreateStaticInstance();
if ((await configContext.Database.GetPendingMigrationsAsync()).Any())
{
await configContext.Database.MigrateAsync();
}
return app;
return builder;
}
}

View File

@@ -0,0 +1,10 @@
namespace Cleanuparr.Api.Models.NotificationProviders;
public sealed record CreateAppriseProviderDto : CreateNotificationProviderBaseDto
{
public string Url { get; init; } = string.Empty;
public string Key { get; init; } = string.Empty;
public string Tags { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,8 @@
namespace Cleanuparr.Api.Models.NotificationProviders;
public sealed record CreateNotifiarrProviderDto : CreateNotificationProviderBaseDto
{
public string ApiKey { get; init; } = string.Empty;
public string ChannelId { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,20 @@
namespace Cleanuparr.Api.Models.NotificationProviders;
public abstract record CreateNotificationProviderBaseDto
{
public string Name { get; init; } = string.Empty;
public bool IsEnabled { get; init; } = true;
public bool OnFailedImportStrike { get; init; }
public bool OnStalledStrike { get; init; }
public bool OnSlowStrike { get; init; }
public bool OnQueueItemDeleted { get; init; }
public bool OnDownloadCleaned { get; init; }
public bool OnCategoryChanged { get; init; }
}

View File

@@ -0,0 +1,10 @@
namespace Cleanuparr.Api.Models.NotificationProviders;
public sealed record TestAppriseProviderDto
{
public string Url { get; init; } = string.Empty;
public string Key { get; init; } = string.Empty;
public string Tags { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,8 @@
namespace Cleanuparr.Api.Models.NotificationProviders;
public sealed record TestNotifiarrProviderDto
{
public string ApiKey { get; init; } = string.Empty;
public string ChannelId { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,10 @@
namespace Cleanuparr.Api.Models.NotificationProviders;
public sealed record UpdateAppriseProviderDto : CreateNotificationProviderBaseDto
{
public string Url { get; init; } = string.Empty;
public string Key { get; init; } = string.Empty;
public string Tags { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,8 @@
namespace Cleanuparr.Api.Models.NotificationProviders;
public sealed record UpdateNotifiarrProviderDto : CreateNotificationProviderBaseDto
{
public string ApiKey { get; init; } = string.Empty;
public string ChannelId { get; init; } = string.Empty;
}

View File

@@ -2,14 +2,18 @@ using System.Runtime.InteropServices;
using System.Text.Json.Serialization;
using Cleanuparr.Api;
using Cleanuparr.Api.DependencyInjection;
using Cleanuparr.Infrastructure.Hubs;
using Cleanuparr.Infrastructure.Logging;
using Cleanuparr.Shared.Helpers;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.AspNetCore.SignalR;
using Serilog;
var builder = WebApplication.CreateBuilder(args);
await builder.InitAsync();
builder.Logging.AddLogging();
// Fix paths for single-file deployment on macOS
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
@@ -68,14 +72,6 @@ builder.Services.AddCors(options =>
});
});
// Register services needed for logging first
builder.Services
.AddScoped<LoggingConfigManager>()
.AddSingleton<SignalRLogSink>();
// Add logging with proper service provider
builder.Logging.AddLogging();
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
builder.Host.UseWindowsService(options =>
@@ -130,28 +126,11 @@ if (basePath is not null)
logger.LogInformation("Server configuration: PORT={port}, BASE_PATH={basePath}", port, basePath ?? "/");
// Initialize the host
await app.Init();
await app.InitAsync();
// Get LoggingConfigManager (will be created if not already registered)
var scopeFactory = app.Services.GetRequiredService<IServiceScopeFactory>();
using (var scope = scopeFactory.CreateScope())
{
var configManager = scope.ServiceProvider.GetRequiredService<LoggingConfigManager>();
// Get the dynamic level switch for controlling log levels
var levelSwitch = configManager.GetLevelSwitch();
// Get the SignalRLogSink instance
var signalRSink = app.Services.GetRequiredService<SignalRLogSink>();
var logConfig = LoggingDI.GetDefaultLoggerConfiguration();
logConfig.MinimumLevel.ControlledBy(levelSwitch);
// Add to Serilog pipeline
logConfig.WriteTo.Sink(signalRSink);
Log.Logger = logConfig.CreateLogger();
}
// Configure the app hub for SignalR
var appHub = app.Services.GetRequiredService<IHubContext<AppHub>>();
SignalRLogSink.Instance.SetAppHubContext(appHub);
// Configure health check endpoints before the API configuration
app.MapHealthChecks("/health", new HealthCheckOptions
@@ -168,4 +147,6 @@ app.MapHealthChecks("/health/ready", new HealthCheckOptions
app.ConfigureApi();
await app.RunAsync();
await app.RunAsync();
await Log.CloseAndFlushAsync();

View File

@@ -0,0 +1,13 @@
namespace Cleanuparr.Domain.Enums;
public enum NotificationEventType
{
Test,
FailedImportStrike,
StalledStrike,
SlowSpeedStrike,
SlowTimeStrike,
QueueItemDeleted,
DownloadCleaned,
CategoryChanged
}

View File

@@ -0,0 +1,7 @@
namespace Cleanuparr.Domain.Enums;
public enum NotificationProviderType
{
Notifiarr,
Apprise
}

View File

@@ -18,6 +18,7 @@
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.6" />
<PackageReference Include="Mono.Unix" Version="7.1.0-final.1.21458.1" />
<PackageReference Include="Quartz" Version="3.14.0" />
<PackageReference Include="Serilog.Expressions" Version="5.0.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -9,7 +9,6 @@ using Cleanuparr.Infrastructure.Hubs;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Events;
using Infrastructure.Interceptors;
using Infrastructure.Verticals.Notifications;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
@@ -79,6 +78,7 @@ public class EventPublisher
StrikeType.FailedImport => EventType.FailedImportStrike,
StrikeType.SlowSpeed => EventType.SlowSpeedStrike,
StrikeType.SlowTime => EventType.SlowTimeStrike,
_ => throw new ArgumentOutOfRangeException(nameof(strikeType), strikeType, null)
};
dynamic data;

View File

@@ -1,97 +1,62 @@
using System.Text;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Infrastructure.Verticals.Notifications;
using System.Text;
namespace Cleanuparr.Infrastructure.Features.Notifications.Apprise;
public sealed class AppriseProvider : NotificationProvider<AppriseConfig>
public sealed class AppriseProvider : NotificationProviderBase<AppriseConfig>
{
private readonly IAppriseProxy _proxy;
public override string Name => "Apprise";
public AppriseProvider(DataContext dataContext, IAppriseProxy proxy)
: base(dataContext.AppriseConfigs)
public AppriseProvider(
string name,
NotificationProviderType type,
AppriseConfig config,
IAppriseProxy proxy
) : base(name, type, config)
{
_proxy = proxy;
}
public override async Task OnFailedImportStrike(FailedImportStrikeNotification notification)
public override async Task SendNotificationAsync(NotificationContext context)
{
await _proxy.SendNotification(BuildPayload(notification, NotificationType.Warning), Config);
}
public override async Task OnStalledStrike(StalledStrikeNotification notification)
{
await _proxy.SendNotification(BuildPayload(notification, NotificationType.Warning), Config);
}
public override async Task OnSlowStrike(SlowStrikeNotification notification)
{
await _proxy.SendNotification(BuildPayload(notification, NotificationType.Warning), Config);
}
public override async Task OnQueueItemDeleted(QueueItemDeletedNotification notification)
{
await _proxy.SendNotification(BuildPayload(notification, NotificationType.Warning), Config);
ApprisePayload payload = BuildPayload(context);
await _proxy.SendNotification(payload, Config);
}
public override async Task OnDownloadCleaned(DownloadCleanedNotification notification)
private ApprisePayload BuildPayload(NotificationContext context)
{
await _proxy.SendNotification(BuildPayload(notification, NotificationType.Warning), Config);
}
public override async Task OnCategoryChanged(CategoryChangedNotification notification)
{
await _proxy.SendNotification(BuildPayload(notification, NotificationType.Warning), Config);
}
private ApprisePayload BuildPayload(ArrNotification notification, NotificationType notificationType)
{
StringBuilder body = new();
body.AppendLine(notification.Description);
body.AppendLine();
body.AppendLine($"Instance type: {notification.InstanceType.ToString()}");
body.AppendLine($"Url: {notification.InstanceUrl}");
body.AppendLine($"Download hash: {notification.Hash}");
NotificationType notificationType = context.Severity switch
{
EventSeverity.Warning => NotificationType.Warning,
EventSeverity.Important => NotificationType.Failure,
_ => NotificationType.Info
};
foreach (NotificationField field in notification.Fields ?? [])
string body = BuildBody(context);
return new ApprisePayload
{
body.AppendLine($"{field.Title}: {field.Text}");
}
ApprisePayload payload = new()
{
Title = notification.Title,
Body = body.ToString(),
Title = context.Title,
Body = body,
Type = notificationType.ToString().ToLowerInvariant(),
Tags = Config.Tags,
};
return payload;
}
private ApprisePayload BuildPayload(Notification notification, NotificationType notificationType)
{
StringBuilder body = new();
body.AppendLine(notification.Description);
body.AppendLine();
foreach (NotificationField field in notification.Fields ?? [])
private static string BuildBody(NotificationContext context)
{
var body = new StringBuilder();
body.AppendLine(context.Description);
body.AppendLine();
foreach ((string key, string value) in context.Data)
{
body.AppendLine($"{field.Title}: {field.Text}");
body.AppendLine($"{key}: {value}");
}
ApprisePayload payload = new()
{
Title = notification.Title,
Body = body.ToString(),
Type = notificationType.ToString().ToLowerInvariant(),
Tags = Config.Tags,
};
return payload;
return body.ToString();
}
}
}

View File

@@ -26,16 +26,17 @@ public sealed class AppriseProxy : IAppriseProxy
NullValueHandling = NullValueHandling.Ignore
});
UriBuilder uriBuilder = new(config.Url.ToString());
var parsedUrl = config.Uri!;
UriBuilder uriBuilder = new(parsedUrl);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/notify/{config.Key}";
using HttpRequestMessage request = new(HttpMethod.Post, uriBuilder.Uri);
request.Method = HttpMethod.Post;
request.Content = new StringContent(content, Encoding.UTF8, "application/json");
if (!string.IsNullOrEmpty(config.Url.UserInfo))
if (!string.IsNullOrEmpty(parsedUrl.UserInfo))
{
var byteArray = Encoding.ASCII.GetBytes(config.Url.UserInfo);
var byteArray = Encoding.ASCII.GetBytes(parsedUrl.UserInfo);
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(byteArray));
}

View File

@@ -1,7 +1,7 @@
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Infrastructure.Verticals.Notifications;
using MassTransit;
using Microsoft.Extensions.Logging;
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Infrastructure.Features.Notifications.Consumers;
@@ -23,22 +23,39 @@ public sealed class NotificationConsumer<T> : IConsumer<T> where T : Notificatio
switch (context.Message)
{
case FailedImportStrikeNotification failedMessage:
await _notificationService.Notify(failedMessage);
await _notificationService.SendNotificationAsync(
NotificationEventType.FailedImportStrike,
ConvertToNotificationContext(failedMessage, NotificationEventType.FailedImportStrike));
break;
case StalledStrikeNotification stalledMessage:
await _notificationService.Notify(stalledMessage);
await _notificationService.SendNotificationAsync(
NotificationEventType.StalledStrike,
ConvertToNotificationContext(stalledMessage, NotificationEventType.StalledStrike));
break;
case SlowStrikeNotification slowMessage:
await _notificationService.Notify(slowMessage);
case SlowSpeedStrikeNotification slowMessage:
await _notificationService.SendNotificationAsync(
NotificationEventType.SlowSpeedStrike,
ConvertToNotificationContext(slowMessage, NotificationEventType.SlowSpeedStrike));
break;
case SlowTimeStrikeNotification slowTimeMessage:
await _notificationService.SendNotificationAsync(
NotificationEventType.SlowTimeStrike,
ConvertToNotificationContext(slowTimeMessage, NotificationEventType.SlowTimeStrike));
break;
case QueueItemDeletedNotification queueItemDeleteMessage:
await _notificationService.Notify(queueItemDeleteMessage);
await _notificationService.SendNotificationAsync(
NotificationEventType.QueueItemDeleted,
ConvertToNotificationContext(queueItemDeleteMessage, NotificationEventType.QueueItemDeleted));
break;
case DownloadCleanedNotification downloadCleanedNotification:
await _notificationService.Notify(downloadCleanedNotification);
await _notificationService.SendNotificationAsync(
NotificationEventType.DownloadCleaned,
ConvertToNotificationContext(downloadCleanedNotification, NotificationEventType.DownloadCleaned));
break;
case CategoryChangedNotification categoryChangedNotification:
await _notificationService.Notify(categoryChangedNotification);
await _notificationService.SendNotificationAsync(
NotificationEventType.CategoryChanged,
ConvertToNotificationContext(categoryChangedNotification, NotificationEventType.CategoryChanged));
break;
default:
throw new NotImplementedException();
@@ -49,7 +66,44 @@ public sealed class NotificationConsumer<T> : IConsumer<T> where T : Notificatio
}
catch (Exception exception)
{
_logger.LogError(exception, "error while processing notifications");
_logger.LogError(exception, "Error while processing notifications");
}
}
private static NotificationContext ConvertToNotificationContext(Notification notification, NotificationEventType eventType)
{
var severity = notification.Level switch
{
NotificationLevel.Important => EventSeverity.Important,
NotificationLevel.Warning => EventSeverity.Warning,
_ => EventSeverity.Information
};
var data = new Dictionary<string, string>();
Uri? image = null;
if (notification is ArrNotification arrNotification)
{
data.Add("Instance type", arrNotification.InstanceType.ToString());
data.Add("Url", arrNotification.InstanceUrl.ToString());
data.Add("Hash", arrNotification.Hash);
image = arrNotification.Image;
}
foreach (var field in notification.Fields ?? [])
{
data[field.Key] = field.Value;
}
return new NotificationContext
{
EventType = eventType,
Title = notification.Title,
Description = notification.Description,
Severity = severity,
Data = data,
Image = image
};
}
}

View File

@@ -0,0 +1,15 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
namespace Cleanuparr.Infrastructure.Features.Notifications;
public interface INotificationConfigurationService
{
Task<List<NotificationProviderDto>> GetActiveProvidersAsync();
Task<List<NotificationProviderDto>> GetProvidersForEventAsync(NotificationEventType eventType);
Task<NotificationProviderDto?> GetProviderByIdAsync(Guid id);
Task InvalidateCacheAsync();
}

View File

@@ -1,18 +0,0 @@
using Infrastructure.Verticals.Notifications;
namespace Cleanuparr.Infrastructure.Features.Notifications;
public interface INotificationFactory
{
List<INotificationProvider> OnFailedImportStrikeEnabled();
List<INotificationProvider> OnStalledStrikeEnabled();
List<INotificationProvider> OnSlowStrikeEnabled();
List<INotificationProvider> OnQueueItemDeletedEnabled();
List<INotificationProvider> OnDownloadCleanedEnabled();
List<INotificationProvider> OnCategoryChangedEnabled();
}

View File

@@ -1,29 +1,13 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Persistence.Models.Configuration.Notification;
namespace Cleanuparr.Infrastructure.Features.Notifications;
public interface INotificationProvider<T> : INotificationProvider
where T : NotificationConfig
{
new T Config { get; }
}
public interface INotificationProvider
{
NotificationConfig Config { get; }
string Name { get; }
Task OnFailedImportStrike(FailedImportStrikeNotification notification);
Task OnStalledStrike(StalledStrikeNotification notification);
NotificationProviderType Type { get; }
Task OnSlowStrike(SlowStrikeNotification notification);
Task OnQueueItemDeleted(QueueItemDeletedNotification notification);
Task OnDownloadCleaned(DownloadCleanedNotification notification);
Task OnCategoryChanged(CategoryChangedNotification notification);
}
Task SendNotificationAsync(NotificationContext context);
}

View File

@@ -0,0 +1,8 @@
using Cleanuparr.Infrastructure.Features.Notifications.Models;
namespace Cleanuparr.Infrastructure.Features.Notifications;
public interface INotificationProviderFactory
{
INotificationProvider CreateProvider(NotificationProviderDto config);
}

View File

@@ -0,0 +1,18 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
public sealed record NotificationContext
{
public NotificationEventType EventType { get; init; }
public required string Title { get; init; }
public required string Description { get; init; }
public Dictionary<string, string> Data { get; init; } = new();
public EventSeverity Severity { get; init; } = EventSeverity.Information;
public Uri? Image { get; set; }
}

View File

@@ -0,0 +1,16 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
public sealed record NotificationEventFlags
{
public bool OnFailedImportStrike { get; init; }
public bool OnStalledStrike { get; init; }
public bool OnSlowStrike { get; init; }
public bool OnQueueItemDeleted { get; init; }
public bool OnDownloadCleaned { get; init; }
public bool OnCategoryChanged { get; init; }
}

View File

@@ -2,7 +2,7 @@
public sealed record NotificationField
{
public required string Title { get; init; }
public required string Key { get; init; }
public required string Text { get; init; }
public required string Value { get; init; }
}

View File

@@ -0,0 +1,18 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
public sealed record NotificationProviderDto
{
public Guid Id { get; init; }
public string Name { get; init; } = string.Empty;
public NotificationProviderType Type { get; init; }
public bool IsEnabled { get; init; }
public NotificationEventFlags Events { get; init; } = new();
public object Configuration { get; init; } = new();
}

View File

@@ -0,0 +1,5 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
public sealed record SlowSpeedStrikeNotification : ArrNotification
{
}

View File

@@ -1,5 +1,5 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
public sealed record SlowStrikeNotification : ArrNotification
public sealed record SlowTimeStrikeNotification : ArrNotification
{
}

View File

@@ -1,77 +1,52 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Persistence;
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Infrastructure.Verticals.Notifications;
using Mapster;
namespace Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
public class NotifiarrProvider : NotificationProvider<NotifiarrConfig>
public sealed class NotifiarrProvider : NotificationProviderBase<NotifiarrConfig>
{
private readonly DataContext _dataContext;
private readonly INotifiarrProxy _proxy;
private const string WarningColor = "f0ad4e";
private const string ImportantColor = "bb2124";
private const string Logo = "https://github.com/Cleanuparr/Cleanuparr/blob/main/Logo/48.png?raw=true";
public override string Name => "Notifiarr";
public NotifiarrProvider(DataContext dataContext, INotifiarrProxy proxy)
: base(dataContext.NotifiarrConfigs)
public NotifiarrProvider(
string name,
NotificationProviderType type,
NotifiarrConfig config,
INotifiarrProxy proxy)
: base(name, type, config)
{
_dataContext = dataContext;
_proxy = proxy;
}
public override async Task OnFailedImportStrike(FailedImportStrikeNotification notification)
public override async Task SendNotificationAsync(NotificationContext context)
{
await _proxy.SendNotification(BuildPayload(notification, WarningColor), Config);
}
public override async Task OnStalledStrike(StalledStrikeNotification notification)
{
await _proxy.SendNotification(BuildPayload(notification, WarningColor), Config);
}
public override async Task OnSlowStrike(SlowStrikeNotification notification)
{
await _proxy.SendNotification(BuildPayload(notification, WarningColor), Config);
}
public override async Task OnQueueItemDeleted(QueueItemDeletedNotification notification)
{
await _proxy.SendNotification(BuildPayload(notification, ImportantColor), Config);
var payload = BuildPayload(context);
await _proxy.SendNotification(payload, Config);
}
public override async Task OnDownloadCleaned(DownloadCleanedNotification notification)
private NotifiarrPayload BuildPayload(NotificationContext context)
{
await _proxy.SendNotification(BuildPayload(notification), Config);
}
public override async Task OnCategoryChanged(CategoryChangedNotification notification)
{
await _proxy.SendNotification(BuildPayload(notification), Config);
}
var color = context.Severity switch
{
EventSeverity.Warning => "f0ad4e",
EventSeverity.Important => "bb2124",
_ => "28a745"
};
private NotifiarrPayload BuildPayload(ArrNotification notification, string color)
{
NotifiarrPayload payload = new()
const string logo = "https://github.com/Cleanuparr/Cleanuparr/blob/main/Logo/48.png?raw=true";
return new NotifiarrPayload
{
Discord = new()
{
Color = color,
Text = new()
{
Title = notification.Title,
Icon = Logo,
Description = notification.Description,
Fields = new()
{
new() { Title = "Instance type", Text = notification.InstanceType.ToString() },
new() { Title = "Url", Text = notification.InstanceUrl.ToString() },
new() { Title = "Download hash", Text = notification.Hash }
}
Title = context.Title,
Icon = logo,
Description = context.Description,
Fields = BuildFields(context)
},
Ids = new Ids
{
@@ -79,70 +54,22 @@ public class NotifiarrProvider : NotificationProvider<NotifiarrConfig>
},
Images = new()
{
Thumbnail = new Uri(Logo),
Image = notification.Image
Thumbnail = new Uri(logo),
Image = context.Image
}
}
};
payload.Discord.Text.Fields.AddRange(notification.Fields?.Adapt<List<Field>>() ?? []);
return payload;
}
private NotifiarrPayload BuildPayload(DownloadCleanedNotification notification)
private List<Field> BuildFields(NotificationContext context)
{
NotifiarrPayload payload = new()
{
Discord = new()
{
Color = ImportantColor,
Text = new()
{
Title = notification.Title,
Icon = Logo,
Description = notification.Description,
Fields = notification.Fields?.Adapt<List<Field>>() ?? []
},
Ids = new Ids
{
Channel = Config.ChannelId
},
Images = new()
{
Thumbnail = new Uri(Logo)
}
}
};
return payload;
}
var fields = new List<Field>();
private NotifiarrPayload BuildPayload(CategoryChangedNotification notification)
{
NotifiarrPayload payload = new()
foreach ((string key, string value) in context.Data)
{
Discord = new()
{
Color = WarningColor,
Text = new()
{
Title = notification.Title,
Icon = Logo,
Description = notification.Description,
Fields = notification.Fields?.Adapt<List<Field>>() ?? []
},
Ids = new Ids
{
Channel = Config.ChannelId
},
Images = new()
{
Thumbnail = new Uri(Logo)
}
}
};
return payload;
fields.Add(new() { Title = key, Text = value });
}
return fields;
}
}
}

View File

@@ -0,0 +1,164 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.Notifications;
public sealed class NotificationConfigurationService : INotificationConfigurationService
{
private readonly DataContext _dataContext;
private readonly ILogger<NotificationConfigurationService> _logger;
private List<NotificationProviderDto>? _cachedProviders;
private readonly SemaphoreSlim _cacheSemaphore = new(1, 1);
public NotificationConfigurationService(
DataContext dataContext,
ILogger<NotificationConfigurationService> logger)
{
_dataContext = dataContext;
_logger = logger;
}
public async Task<List<NotificationProviderDto>> GetActiveProvidersAsync()
{
await _cacheSemaphore.WaitAsync();
try
{
if (_cachedProviders != null)
{
return _cachedProviders.Where(p => p.IsEnabled).ToList();
}
}
finally
{
_cacheSemaphore.Release();
}
await LoadProvidersAsync();
await _cacheSemaphore.WaitAsync();
try
{
return _cachedProviders?.Where(p => p.IsEnabled).ToList() ?? new List<NotificationProviderDto>();
}
finally
{
_cacheSemaphore.Release();
}
}
public async Task<List<NotificationProviderDto>> GetProvidersForEventAsync(NotificationEventType eventType)
{
var activeProviders = await GetActiveProvidersAsync();
return activeProviders.Where(provider => IsEventEnabled(provider.Events, eventType)).ToList();
}
public async Task<NotificationProviderDto?> GetProviderByIdAsync(Guid id)
{
var allProviders = await GetActiveProvidersAsync();
return allProviders.FirstOrDefault(p => p.Id == id);
}
public async Task InvalidateCacheAsync()
{
await _cacheSemaphore.WaitAsync();
try
{
_cachedProviders = null;
}
finally
{
_cacheSemaphore.Release();
}
_logger.LogDebug("Notification provider cache invalidated");
}
private async Task LoadProvidersAsync()
{
try
{
var providers = await _dataContext.Set<NotificationConfig>()
.Include(p => p.NotifiarrConfiguration)
.Include(p => p.AppriseConfiguration)
.AsNoTracking()
.ToListAsync();
var dtos = providers.Select(MapToDto).ToList();
await _cacheSemaphore.WaitAsync();
try
{
_cachedProviders = dtos;
}
finally
{
_cacheSemaphore.Release();
}
_logger.LogDebug("Loaded {count} notification providers", dtos.Count);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load notification providers");
await _cacheSemaphore.WaitAsync();
try
{
_cachedProviders = new List<NotificationProviderDto>();
}
finally
{
_cacheSemaphore.Release();
}
}
}
private static NotificationProviderDto MapToDto(NotificationConfig config)
{
var events = new NotificationEventFlags
{
OnFailedImportStrike = config.OnFailedImportStrike,
OnStalledStrike = config.OnStalledStrike,
OnSlowStrike = config.OnSlowStrike,
OnQueueItemDeleted = config.OnQueueItemDeleted,
OnDownloadCleaned = config.OnDownloadCleaned,
OnCategoryChanged = config.OnCategoryChanged
};
var configuration = config.Type switch
{
NotificationProviderType.Notifiarr => config.NotifiarrConfiguration,
NotificationProviderType.Apprise => config.AppriseConfiguration,
_ => new object()
};
return new NotificationProviderDto
{
Id = config.Id,
Name = config.Name,
Type = config.Type,
IsEnabled = config.IsEnabled && config.IsConfigured && config.HasAnyEventEnabled,
Events = events,
Configuration = configuration ?? new object()
};
}
private static bool IsEventEnabled(NotificationEventFlags events, NotificationEventType eventType)
{
return eventType switch
{
NotificationEventType.FailedImportStrike => events.OnFailedImportStrike,
NotificationEventType.StalledStrike => events.OnStalledStrike,
NotificationEventType.SlowSpeedStrike or NotificationEventType.SlowTimeStrike => events.OnSlowStrike,
NotificationEventType.QueueItemDeleted => events.OnQueueItemDeleted,
NotificationEventType.DownloadCleaned => events.OnDownloadCleaned,
NotificationEventType.CategoryChanged => events.OnCategoryChanged,
NotificationEventType.Test => true,
_ => false
};
}
}

View File

@@ -1,47 +0,0 @@
namespace Cleanuparr.Infrastructure.Features.Notifications;
public class NotificationFactory : INotificationFactory
{
private readonly IEnumerable<INotificationProvider> _providers;
public NotificationFactory(IEnumerable<INotificationProvider> providers)
{
_providers = providers;
}
protected List<INotificationProvider> ActiveProviders() =>
_providers
.Where(x => x.Config.IsValid())
.Where(provider => provider.Config.IsEnabled)
.ToList();
public List<INotificationProvider> OnFailedImportStrikeEnabled() =>
ActiveProviders()
.Where(n => n.Config.OnFailedImportStrike)
.ToList();
public List<INotificationProvider> OnStalledStrikeEnabled() =>
ActiveProviders()
.Where(n => n.Config.OnStalledStrike)
.ToList();
public List<INotificationProvider> OnSlowStrikeEnabled() =>
ActiveProviders()
.Where(n => n.Config.OnSlowStrike)
.ToList();
public List<INotificationProvider> OnQueueItemDeletedEnabled() =>
ActiveProviders()
.Where(n => n.Config.OnQueueItemDeleted)
.ToList();
public List<INotificationProvider> OnDownloadCleanedEnabled() =>
ActiveProviders()
.Where(n => n.Config.OnDownloadCleaned)
.ToList();
public List<INotificationProvider> OnCategoryChangedEnabled() =>
ActiveProviders()
.Where(n => n.Config.OnCategoryChanged)
.ToList();
}

View File

@@ -1,35 +0,0 @@
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Microsoft.EntityFrameworkCore;
namespace Cleanuparr.Infrastructure.Features.Notifications;
public abstract class NotificationProvider<T> : INotificationProvider<T>
where T : NotificationConfig
{
protected readonly DbSet<T> _notificationConfig;
protected T? _config;
public T Config => _config ??= _notificationConfig.AsNoTracking().First();
NotificationConfig INotificationProvider.Config => Config;
protected NotificationProvider(DbSet<T> notificationConfig)
{
_notificationConfig = notificationConfig;
}
public abstract string Name { get; }
public abstract Task OnFailedImportStrike(FailedImportStrikeNotification notification);
public abstract Task OnStalledStrike(StalledStrikeNotification notification);
public abstract Task OnSlowStrike(SlowStrikeNotification notification);
public abstract Task OnQueueItemDeleted(QueueItemDeletedNotification notification);
public abstract Task OnDownloadCleaned(DownloadCleanedNotification notification);
public abstract Task OnCategoryChanged(CategoryChangedNotification notification);
}

View File

@@ -0,0 +1,23 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
namespace Cleanuparr.Infrastructure.Features.Notifications;
public abstract class NotificationProviderBase<TConfig> : INotificationProvider
where TConfig : class
{
protected TConfig Config { get; }
public string Name { get; }
public NotificationProviderType Type { get; }
protected NotificationProviderBase(string name, NotificationProviderType type, TConfig config)
{
Name = name;
Type = type;
Config = config;
}
public abstract Task SendNotificationAsync(NotificationContext context);
}

View File

@@ -0,0 +1,44 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Microsoft.Extensions.DependencyInjection;
namespace Cleanuparr.Infrastructure.Features.Notifications;
public sealed class NotificationProviderFactory : INotificationProviderFactory
{
private readonly IServiceProvider _serviceProvider;
public NotificationProviderFactory(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public INotificationProvider CreateProvider(NotificationProviderDto config)
{
return config.Type switch
{
NotificationProviderType.Notifiarr => CreateNotifiarrProvider(config),
NotificationProviderType.Apprise => CreateAppriseProvider(config),
_ => throw new NotSupportedException($"Provider type {config.Type} is not supported")
};
}
private INotificationProvider CreateNotifiarrProvider(NotificationProviderDto config)
{
var notifiarrConfig = (NotifiarrConfig)config.Configuration;
var proxy = _serviceProvider.GetRequiredService<INotifiarrProxy>();
return new NotifiarrProvider(config.Name, config.Type, notifiarrConfig, proxy);
}
private INotificationProvider CreateAppriseProvider(NotificationProviderDto config)
{
var appriseConfig = (AppriseConfig)config.Configuration;
var proxy = _serviceProvider.GetRequiredService<IAppriseProxy>();
return new AppriseProvider(config.Name, config.Type, appriseConfig, proxy);
}
}

View File

@@ -1,63 +1,41 @@
using System.Globalization;
using System.Globalization;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Infrastructure.Interceptors;
using Mapster;
using MassTransit;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.Notifications;
public class NotificationPublisher : INotificationPublisher
{
private readonly ILogger<INotificationPublisher> _logger;
private readonly IBus _messageBus;
private readonly ILogger<NotificationPublisher> _logger;
private readonly IDryRunInterceptor _dryRunInterceptor;
private readonly INotificationConfigurationService _configurationService;
private readonly INotificationProviderFactory _providerFactory;
public NotificationPublisher(ILogger<INotificationPublisher> logger, IBus messageBus, IDryRunInterceptor dryRunInterceptor)
public NotificationPublisher(
ILogger<NotificationPublisher> logger,
IDryRunInterceptor dryRunInterceptor,
INotificationConfigurationService configurationService,
INotificationProviderFactory providerFactory)
{
_logger = logger;
_messageBus = messageBus;
_dryRunInterceptor = dryRunInterceptor;
_configurationService = configurationService;
_providerFactory = providerFactory;
}
public virtual async Task NotifyStrike(StrikeType strikeType, int strikeCount)
{
try
{
QueueRecord record = ContextProvider.Get<QueueRecord>(nameof(QueueRecord));
InstanceType instanceType = (InstanceType)ContextProvider.Get<object>(nameof(InstanceType));
Uri instanceUrl = ContextProvider.Get<Uri>(nameof(ArrInstance) + nameof(ArrInstance.Url));
Uri? imageUrl = GetImageFromContext(record, instanceType);
ArrNotification notification = new()
{
InstanceType = instanceType,
InstanceUrl = instanceUrl,
Hash = record.DownloadId.ToLowerInvariant(),
Title = $"Strike received with reason: {strikeType}",
Description = record.Title,
Image = imageUrl,
Fields = [new() { Title = "Strike count", Text = strikeCount.ToString() }]
};
var eventType = MapStrikeTypeToEventType(strikeType);
var context = BuildStrikeNotificationContext(strikeType, strikeCount, eventType);
switch (strikeType)
{
case StrikeType.Stalled:
case StrikeType.DownloadingMetadata:
await NotifyInternal(notification.Adapt<StalledStrikeNotification>());
break;
case StrikeType.FailedImport:
await NotifyInternal(notification.Adapt<FailedImportStrikeNotification>());
break;
case StrikeType.SlowSpeed:
case StrikeType.SlowTime:
await NotifyInternal(notification.Adapt<SlowStrikeNotification>());
break;
}
await SendNotificationAsync(eventType, context);
}
catch (Exception ex)
{
@@ -69,27 +47,12 @@ public class NotificationPublisher : INotificationPublisher
{
try
{
QueueRecord record = ContextProvider.Get<QueueRecord>(nameof(QueueRecord));
InstanceType instanceType = (InstanceType)ContextProvider.Get<object>(nameof(InstanceType));
Uri instanceUrl = ContextProvider.Get<Uri>(nameof(ArrInstance) + nameof(ArrInstance.Url));
Uri? imageUrl = GetImageFromContext(record, instanceType);
QueueItemDeletedNotification notification = new()
{
InstanceType = instanceType,
InstanceUrl = instanceUrl,
Hash = record.DownloadId.ToLowerInvariant(),
Title = $"Deleting item from queue with reason: {reason}",
Description = record.Title,
Image = imageUrl,
Fields = [new() { Title = "Removed from download client?", Text = removeFromClient ? "Yes" : "No" }]
};
await NotifyInternal(notification);
var context = BuildQueueItemDeletedContext(removeFromClient, reason);
await SendNotificationAsync(NotificationEventType.QueueItemDeleted, context);
}
catch (Exception ex)
{
_logger.LogError(ex, "failed to notify queue item deleted");
_logger.LogError(ex, "Failed to notify queue item deleted");
}
}
@@ -97,67 +60,174 @@ public class NotificationPublisher : INotificationPublisher
{
try
{
DownloadCleanedNotification notification = new()
{
Title = $"Cleaned item from download client with reason: {reason}",
Description = ContextProvider.Get<string>("downloadName"),
Fields =
[
new() { Title = "Hash", Text = ContextProvider.Get<string>("hash").ToLowerInvariant() },
new() { Title = "Category", Text = categoryName.ToLowerInvariant() },
new() { Title = "Ratio", Text = $"{ratio.ToString(CultureInfo.InvariantCulture)}%" },
new()
{
Title = "Seeding hours", Text = $"{Math.Round(seedingTime.TotalHours, 0).ToString(CultureInfo.InvariantCulture)}h"
}
],
Level = NotificationLevel.Important
};
await NotifyInternal(notification);
var context = BuildDownloadCleanedContext(ratio, seedingTime, categoryName, reason);
await SendNotificationAsync(NotificationEventType.DownloadCleaned, context);
}
catch (Exception ex)
{
_logger.LogError(ex, "failed to notify download cleaned");
_logger.LogError(ex, "Failed to notify download cleaned");
}
}
public virtual async Task NotifyCategoryChanged(string oldCategory, string newCategory, bool isTag = false)
{
CategoryChangedNotification notification = new()
try
{
Title = isTag? "Tag added" : "Category changed",
Description = ContextProvider.Get<string>("downloadName"),
Fields =
[
new() { Title = "Hash", Text = ContextProvider.Get<string>("hash").ToLowerInvariant() }
],
Level = NotificationLevel.Important
};
var context = BuildCategoryChangedContext(oldCategory, newCategory, isTag);
await SendNotificationAsync(NotificationEventType.CategoryChanged, context);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to notify category changed");
}
}
private async Task SendNotificationAsync(NotificationEventType eventType, NotificationContext context)
{
await _dryRunInterceptor.InterceptAsync(SendNotificationInternalAsync, (eventType, context));
}
private async Task SendNotificationInternalAsync((NotificationEventType eventType, NotificationContext context) parameters)
{
var (eventType, context) = parameters;
var providers = await _configurationService.GetProvidersForEventAsync(eventType);
if (!providers.Any())
{
_logger.LogDebug("No providers configured for event type {eventType}", eventType);
return;
}
var tasks = providers.Select(async providerConfig =>
{
try
{
var provider = _providerFactory.CreateProvider(providerConfig);
await provider.SendNotificationAsync(context);
_logger.LogDebug("Notification sent successfully via {providerName}", provider.Name);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to send notification via provider {providerName}", providerConfig.Name);
}
});
await Task.WhenAll(tasks);
}
private NotificationContext BuildStrikeNotificationContext(StrikeType strikeType, int strikeCount, NotificationEventType eventType)
{
var record = ContextProvider.Get<QueueRecord>(nameof(QueueRecord));
var instanceType = (InstanceType)ContextProvider.Get<object>(nameof(InstanceType));
var instanceUrl = ContextProvider.Get<Uri>(nameof(ArrInstance) + nameof(ArrInstance.Url));
var imageUrl = GetImageFromContext(record, instanceType);
return new NotificationContext
{
EventType = eventType,
Title = $"Strike received with reason: {strikeType}",
Description = record.Title,
Severity = EventSeverity.Warning,
Image = imageUrl,
Data = new Dictionary<string, string>
{
["Strike type"] = strikeType.ToString(),
["Strike count"] = strikeCount.ToString(),
["Hash"] = record.DownloadId.ToLowerInvariant(),
["Instance type"] = instanceType.ToString(),
["Url"] = instanceUrl.ToString(),
}
};
}
private NotificationContext BuildQueueItemDeletedContext(bool removeFromClient, DeleteReason reason)
{
var record = ContextProvider.Get<QueueRecord>(nameof(QueueRecord));
var instanceType = (InstanceType)ContextProvider.Get<object>(nameof(InstanceType));
var instanceUrl = ContextProvider.Get<Uri>(nameof(ArrInstance) + nameof(ArrInstance.Url));
var imageUrl = GetImageFromContext(record, instanceType);
return new NotificationContext
{
EventType = NotificationEventType.QueueItemDeleted,
Title = $"Deleting item from queue with reason: {reason}",
Description = record.Title,
Severity = EventSeverity.Important,
Image = imageUrl,
Data = new Dictionary<string, string>
{
["Reason"] = reason.ToString(),
["Removed from client?"] = removeFromClient.ToString(),
["Hash"] = record.DownloadId.ToLowerInvariant(),
["Instance type"] = instanceType.ToString(),
["Url"] = instanceUrl.ToString(),
}
};
}
private static NotificationContext BuildDownloadCleanedContext(double ratio, TimeSpan seedingTime, string categoryName, CleanReason reason)
{
var downloadName = ContextProvider.Get<string>("downloadName");
var hash = ContextProvider.Get<string>("hash");
return new NotificationContext
{
EventType = NotificationEventType.DownloadCleaned,
Title = $"Cleaned item from download client with reason: {reason}",
Description = downloadName,
Severity = EventSeverity.Important,
Data = new Dictionary<string, string>
{
["Hash"] = hash.ToLowerInvariant(),
["Category"] = categoryName.ToLowerInvariant(),
["Ratio"] = ratio.ToString(CultureInfo.InvariantCulture),
["Seeding hours"] = Math.Round(seedingTime.TotalHours, 0).ToString(CultureInfo.InvariantCulture)
}
};
}
private NotificationContext BuildCategoryChangedContext(string oldCategory, string newCategory, bool isTag)
{
string downloadName = ContextProvider.Get<string>("downloadName");
NotificationContext context = new()
{
EventType = NotificationEventType.CategoryChanged,
Title = isTag ? "Tag added" : "Category changed",
Description = downloadName,
Severity = EventSeverity.Information,
Data = new Dictionary<string, string>
{
["hash"] = ContextProvider.Get<string>("hash").ToLowerInvariant()
}
};
if (isTag)
{
notification.Fields.Add(new() { Title = "Tag", Text = newCategory });
context.Data.Add("Tag", newCategory);
}
else
{
notification.Fields.Add(new() { Title = "Old category", Text = oldCategory });
notification.Fields.Add(new() { Title = "New category", Text = newCategory });
context.Data.Add("Old category", oldCategory);
context.Data.Add("New category", newCategory);
}
await NotifyInternal(notification);
}
private Task NotifyInternal<T>(T message) where T: notnull
{
return _dryRunInterceptor.InterceptAsync(Notify<T>, message);
return context;
}
private Task Notify<T>(T message) where T: notnull
private static NotificationEventType MapStrikeTypeToEventType(StrikeType strikeType)
{
return _messageBus.Publish(message);
return strikeType switch
{
StrikeType.Stalled => NotificationEventType.StalledStrike,
StrikeType.DownloadingMetadata => NotificationEventType.StalledStrike,
StrikeType.FailedImport => NotificationEventType.FailedImportStrike,
StrikeType.SlowSpeed => NotificationEventType.SlowSpeedStrike,
StrikeType.SlowTime => NotificationEventType.SlowTimeStrike,
_ => throw new ArgumentOutOfRangeException(nameof(strikeType), strikeType, null)
};
}
private Uri? GetImageFromContext(QueueRecord record, InstanceType instanceType)
{
Uri? image = instanceType switch
@@ -172,9 +242,9 @@ public class NotificationPublisher : INotificationPublisher
if (image is null)
{
_logger.LogWarning("no poster found for {title}", record.Title);
_logger.LogWarning("No poster found for {title}", record.Title);
}
return image;
}
}
}

View File

@@ -1,107 +1,85 @@
using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Microsoft.Extensions.Logging;
namespace Infrastructure.Verticals.Notifications;
namespace Cleanuparr.Infrastructure.Features.Notifications;
public class NotificationService
public sealed class NotificationService
{
private readonly ILogger<NotificationService> _logger;
private readonly INotificationFactory _notificationFactory;
private readonly INotificationConfigurationService _configurationService;
private readonly INotificationProviderFactory _providerFactory;
public NotificationService(ILogger<NotificationService> logger, INotificationFactory notificationFactory)
public NotificationService(
ILogger<NotificationService> logger,
INotificationConfigurationService configurationService,
INotificationProviderFactory providerFactory)
{
_logger = logger;
_notificationFactory = notificationFactory;
_configurationService = configurationService;
_providerFactory = providerFactory;
}
public async Task Notify(FailedImportStrikeNotification notification)
public async Task SendNotificationAsync(NotificationEventType eventType, NotificationContext context)
{
foreach (INotificationProvider provider in _notificationFactory.OnFailedImportStrikeEnabled())
try
{
try
var providers = await _configurationService.GetProvidersForEventAsync(eventType);
if (!providers.Any())
{
await provider.OnFailedImportStrike(notification);
_logger.LogDebug("No providers configured for event type {eventType}", eventType);
return;
}
catch (Exception exception)
var tasks = providers.Select(async providerConfig =>
{
_logger.LogWarning(exception, "failed to send notification | provider {provider}", provider.Name);
}
try
{
var provider = _providerFactory.CreateProvider(providerConfig);
await provider.SendNotificationAsync(context);
_logger.LogDebug("Notification sent successfully via {providerName}", provider.Name);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to send notification via provider {providerName}", providerConfig.Name);
}
});
await Task.WhenAll(tasks);
_logger.LogTrace("Notification sent to {count} providers for event {eventType}", providers.Count, eventType);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send notifications for event type {eventType}", eventType);
}
}
public async Task Notify(StalledStrikeNotification notification)
public async Task SendTestNotificationAsync(NotificationProviderDto providerConfig)
{
foreach (INotificationProvider provider in _notificationFactory.OnStalledStrikeEnabled())
NotificationContext testContext = new()
{
try
EventType = NotificationEventType.Test,
Title = "Test Notification from Cleanuparr",
Description = "This is a test notification to verify your configuration is working correctly.",
Severity = EventSeverity.Information,
Data = new Dictionary<string, string>
{
await provider.OnStalledStrike(notification);
}
catch (Exception exception)
{
_logger.LogWarning(exception, "failed to send notification | provider {provider}", provider.Name);
["Test time"] = DateTime.UtcNow.ToString("o"),
["Provider type"] = providerConfig.Type.ToString(),
}
};
try
{
var provider = _providerFactory.CreateProvider(providerConfig);
await provider.SendNotificationAsync(testContext);
_logger.LogInformation("Test notification sent successfully via {providerName}", providerConfig.Name);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send test notification via {providerName}", providerConfig.Name);
throw;
}
}
public async Task Notify(SlowStrikeNotification notification)
{
foreach (INotificationProvider provider in _notificationFactory.OnSlowStrikeEnabled())
{
try
{
await provider.OnSlowStrike(notification);
}
catch (Exception exception)
{
_logger.LogWarning(exception, "failed to send notification | provider {provider}", provider.Name);
}
}
}
public async Task Notify(QueueItemDeletedNotification notification)
{
foreach (INotificationProvider provider in _notificationFactory.OnQueueItemDeletedEnabled())
{
try
{
await provider.OnQueueItemDeleted(notification);
}
catch (Exception exception)
{
_logger.LogWarning(exception, "failed to send notification | provider {provider}", provider.Name);
}
}
}
public async Task Notify(DownloadCleanedNotification notification)
{
foreach (INotificationProvider provider in _notificationFactory.OnDownloadCleanedEnabled())
{
try
{
await provider.OnDownloadCleaned(notification);
}
catch (Exception exception)
{
_logger.LogWarning(exception, "failed to send notification | provider {provider}", provider.Name);
}
}
}
public async Task Notify(CategoryChangedNotification notification)
{
foreach (INotificationProvider provider in _notificationFactory.OnCategoryChangedEnabled())
{
try
{
await provider.OnCategoryChanged(notification);
}
catch (Exception exception)
{
_logger.LogWarning(exception, "failed to send notification | provider {provider}", provider.Name);
}
}
}
}
}

View File

@@ -24,20 +24,17 @@ public class HealthCheckBackgroundService : BackgroundService
_logger = logger;
_healthCheckService = healthCheckService;
// Check health every 1 minute by default
_checkInterval = TimeSpan.FromMinutes(1);
_checkInterval = TimeSpan.FromMinutes(5);
}
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Health check background service started");
try
{
while (!stoppingToken.IsCancellationRequested)
{
_logger.LogDebug("Performing periodic health check for all clients");
_logger.LogDebug("Performing periodic health check for all download clients");
try
{
@@ -47,17 +44,27 @@ public class HealthCheckBackgroundService : BackgroundService
// Log summary
var healthyCount = results.Count(r => r.Value.IsHealthy);
var unhealthyCount = results.Count - healthyCount;
_logger.LogInformation(
"Health check completed. {healthyCount} healthy, {unhealthyCount} unhealthy clients",
healthyCount,
unhealthyCount);
if (unhealthyCount is 0)
{
_logger.LogDebug(
"Health check completed. {healthyCount} healthy, {unhealthyCount} unhealthy download clients",
healthyCount,
unhealthyCount);
}
else
{
_logger.LogWarning(
"Health check completed. {healthyCount} healthy, {unhealthyCount} unhealthy download clients",
healthyCount,
unhealthyCount);
}
// Log detailed information for unhealthy clients
foreach (var result in results.Where(r => !r.Value.IsHealthy))
{
_logger.LogWarning(
"Client {clientId} ({clientName}) is unhealthy: {errorMessage}",
"Download client {clientId} ({clientName}) is unhealthy: {errorMessage}",
result.Key,
result.Value.ClientName,
result.Value.ErrorMessage);

View File

@@ -15,11 +15,11 @@ public class AppHub : Hub
private readonly ILogger<AppHub> _logger;
private readonly SignalRLogSink _logSink;
public AppHub(EventsContext context, ILogger<AppHub> logger, SignalRLogSink logSink)
public AppHub(EventsContext context, ILogger<AppHub> logger)
{
_context = context;
_logger = logger;
_logSink = logSink;
_logSink = SignalRLogSink.Instance;
}
/// <summary>

View File

@@ -0,0 +1,107 @@
using System.IO.Compression;
using Serilog.Sinks.File;
namespace Cleanuparr.Infrastructure.Logging;
// Enhanced from Serilog.Sinks.File.Archive https://github.com/cocowalla/serilog-sinks-file-archive/blob/master/src/Serilog.Sinks.File.Archive/ArchiveHooks.cs
public class ArchiveHooks : FileLifecycleHooks
{
private readonly CompressionLevel _compressionLevel;
private readonly ushort _retainedFileCountLimit;
private readonly TimeSpan? _retainedFileTimeLimit;
public ArchiveHooks(
ushort retainedFileCountLimit,
TimeSpan? retainedFileTimeLimit,
CompressionLevel compressionLevel = CompressionLevel.Fastest
)
{
if (compressionLevel is CompressionLevel.NoCompression)
{
throw new ArgumentException($"{nameof(compressionLevel)} cannot be {CompressionLevel.NoCompression}");
}
if (retainedFileCountLimit is 0 && retainedFileTimeLimit is null)
{
throw new ArgumentException($"At least one of {nameof(retainedFileCountLimit)} or {nameof(retainedFileTimeLimit)} must be set");
}
_retainedFileCountLimit = retainedFileCountLimit;
_retainedFileTimeLimit = retainedFileTimeLimit;
_compressionLevel = compressionLevel;
}
public override void OnFileDeleting(string path)
{
FileInfo originalFileInfo = new FileInfo(path);
string newFilePath = $"{path}.gz";
using (FileStream originalFileStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read))
{
using (FileStream newFileStream = new FileStream(newFilePath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.None))
{
using (GZipStream archiveStream = new GZipStream(newFileStream, _compressionLevel))
{
originalFileStream.CopyTo(archiveStream);
}
}
}
File.SetLastWriteTime(newFilePath, originalFileInfo.LastWriteTime);
File.SetLastWriteTimeUtc(newFilePath, originalFileInfo.LastWriteTimeUtc);
RemoveExcessFiles(Path.GetDirectoryName(path)!);
}
private void RemoveExcessFiles(string folder)
{
string searchPattern = _compressionLevel != CompressionLevel.NoCompression ? "*.gz" : "*.*";
IEnumerable<FileInfo> filesToDeleteQuery = Directory.GetFiles(folder, searchPattern)
.Select((Func<string, FileInfo>)(f => new FileInfo(f)))
.OrderByDescending((Func<FileInfo, FileInfo>)(f => f), LogFileComparer.Default);
if (_retainedFileCountLimit > 0)
{
filesToDeleteQuery = filesToDeleteQuery
.Skip(_retainedFileCountLimit);
}
if (_retainedFileTimeLimit is not null)
{
filesToDeleteQuery = filesToDeleteQuery
.Where(file => file.LastWriteTimeUtc < DateTime.UtcNow - _retainedFileTimeLimit);
}
List<FileInfo> filesToDelete = filesToDeleteQuery.ToList();
foreach (FileInfo fileInfo in filesToDelete)
{
fileInfo.Delete();
}
}
private class LogFileComparer : IComparer<FileInfo>
{
public static readonly IComparer<FileInfo> Default = new LogFileComparer();
public int Compare(FileInfo? x, FileInfo? y)
{
if (x == null && y == null)
{
return 0;
}
if (x == null)
{
return -1;
}
if (y == null || x.LastWriteTimeUtc > y.LastWriteTimeUtc)
{
return 1;
}
return x.LastWriteTimeUtc < y.LastWriteTimeUtc ? -1 : 0;
}
}
}

View File

@@ -1,63 +1,153 @@
using System.IO.Compression;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.General;
using Cleanuparr.Shared.Helpers;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Serilog;
using Serilog.Core;
using Serilog.Events;
using Serilog.Templates;
using Serilog.Templates.Themes;
namespace Cleanuparr.Infrastructure.Logging;
/// <summary>
/// Manages logging configuration and provides dynamic log level control
/// </summary>
public class LoggingConfigManager
public static class LoggingConfigManager
{
private readonly DataContext _dataContext;
private readonly ILogger<LoggingConfigManager> _logger;
private static LoggingLevelSwitch LevelSwitch = new();
public LoggingConfigManager(DataContext dataContext, ILogger<LoggingConfigManager> logger)
{
_dataContext = dataContext;
_logger = logger;
// Load settings from configuration
LoadConfiguration();
}
/// <summary>
/// The level switch used to dynamically control log levels
/// </summary>
public static LoggingLevelSwitch LevelSwitch { get; } = new();
/// <summary>
/// Gets the level switch used to dynamically control log levels
/// Creates a logger configuration for startup before DI is available
/// </summary>
public LoggingLevelSwitch GetLevelSwitch() => LevelSwitch;
/// <returns>Configured LoggerConfiguration</returns>
public static LoggerConfiguration CreateLoggerConfiguration()
{
using var context = DataContext.CreateStaticInstance();
var config = context.GeneralConfigs.AsNoTracking().First();
const string categoryTemplate = "{#if Category is not null} {Concat('[',Category,']'),CAT_PAD}{#end}";
const string jobNameTemplate = "{#if JobName is not null} {Concat('[',JobName,']'),JOB_PAD}{#end}";
const string consoleOutputTemplate = $"[{{@t:yyyy-MM-dd HH:mm:ss.fff}} {{@l:u3}}]{jobNameTemplate}{categoryTemplate} {{@m}}\n{{@x}}";
const string fileOutputTemplate = $"{{@t:yyyy-MM-dd HH:mm:ss.fff zzz}} [{{@l:u3}}]{jobNameTemplate}{categoryTemplate} {{@m:lj}}\n{{@x}}";
// Determine job name padding
List<string> jobNames = [nameof(JobType.QueueCleaner), nameof(JobType.MalwareBlocker), nameof(JobType.DownloadCleaner)];
int jobPadding = jobNames.Max(x => x.Length) + 2;
// Determine instance name padding
List<string> categoryNames = [
InstanceType.Sonarr.ToString(),
InstanceType.Radarr.ToString(),
InstanceType.Lidarr.ToString(),
InstanceType.Readarr.ToString(),
InstanceType.Whisparr.ToString(),
"SYSTEM"
];
int catPadding = categoryNames.Max(x => x.Length) + 2;
// Apply padding values to templates
string consoleTemplate = consoleOutputTemplate
.Replace("JOB_PAD", jobPadding.ToString())
.Replace("CAT_PAD", catPadding.ToString());
string fileTemplate = fileOutputTemplate
.Replace("JOB_PAD", jobPadding.ToString())
.Replace("CAT_PAD", catPadding.ToString());
LoggerConfiguration logConfig = new LoggerConfiguration()
.MinimumLevel.ControlledBy(LevelSwitch)
.Enrich.FromLogContext()
.WriteTo.Console(new ExpressionTemplate(consoleTemplate, theme: TemplateTheme.Literate));
// Create the logs directory
string logsPath = Path.Combine(ConfigurationPathProvider.GetConfigPath(), "logs");
if (!Directory.Exists(logsPath))
{
try
{
Directory.CreateDirectory(logsPath);
}
catch (Exception exception)
{
throw new Exception($"Failed to create logs directory | {logsPath}", exception);
}
}
ArchiveHooks? archiveHooks = config.Log.ArchiveEnabled
? new ArchiveHooks(
retainedFileCountLimit: config.Log.ArchiveRetainedCount,
retainedFileTimeLimit: config.Log.ArchiveTimeLimitHours > 0 ? TimeSpan.FromHours(config.Log.ArchiveTimeLimitHours) : null,
compressionLevel: CompressionLevel.SmallestSize
)
: null;
// Add file sink with archive hooks
logConfig.WriteTo.File(
path: Path.Combine(logsPath, "cleanuparr-.txt"),
formatter: new ExpressionTemplate(fileTemplate),
fileSizeLimitBytes: config.Log.RollingSizeMB is 0 ? null : config.Log.RollingSizeMB * 1024L * 1024L,
rollingInterval: RollingInterval.Day,
rollOnFileSizeLimit: config.Log.RollingSizeMB > 0,
retainedFileCountLimit: config.Log.RetainedFileCount is 0 ? null : config.Log.RetainedFileCount,
retainedFileTimeLimit: config.Log.TimeLimitHours is 0 ? null : TimeSpan.FromHours(config.Log.TimeLimitHours),
hooks: archiveHooks
);
// Add SignalR sink for real-time log updates
logConfig.WriteTo.Sink(SignalRLogSink.Instance);
// Apply standard overrides
logConfig
.MinimumLevel.Override("MassTransit", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information)
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.MinimumLevel.Override("Quartz", LogEventLevel.Warning)
.MinimumLevel.Override("System.Net.Http.HttpClient", LogEventLevel.Error)
.Enrich.WithProperty("ApplicationName", "Cleanuparr");
return logConfig;
}
/// <summary>
/// Updates the global log level and persists the change to configuration
/// </summary>
/// <param name="level">The new log level</param>
public void SetLogLevel(LogEventLevel level)
public static void SetLogLevel(LogEventLevel level)
{
_logger.LogCritical("Setting global log level to {level}", level);
// Change the level in the switch
LevelSwitch.MinimumLevel = level;
}
/// <summary>
/// Loads logging settings from configuration
/// Reconfigures the entire logging system with new settings
/// </summary>
private void LoadConfiguration()
/// <param name="config">The new general configuration</param>
public static void ReconfigureLogging(GeneralConfig config)
{
try
{
var config = _dataContext.GeneralConfigs
.AsNoTracking()
.First();
LevelSwitch.MinimumLevel = config.LogLevel;
// Create new logger configuration
var newLoggerConfig = CreateLoggerConfiguration();
// Apply the new configuration to the global logger
Log.Logger = newLoggerConfig.CreateLogger();
// Update the level switch with the new level
LevelSwitch.MinimumLevel = config.Log.Level;
}
catch (Exception ex)
{
// Just log and continue with defaults
_logger.LogError(ex, "Failed to load logging configuration, using defaults");
// Log the error but don't throw to avoid breaking the application
Log.Error(ex, "Failed to reconfigure logger");
}
}
}

View File

@@ -2,7 +2,7 @@ using System.Collections.Concurrent;
using System.Globalization;
using Cleanuparr.Infrastructure.Hubs;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
using Serilog;
using Serilog.Core;
using Serilog.Events;
using Serilog.Formatting.Display;
@@ -14,20 +14,24 @@ namespace Cleanuparr.Infrastructure.Logging;
/// </summary>
public class SignalRLogSink : ILogEventSink
{
private readonly ILogger<SignalRLogSink> _logger;
private readonly ConcurrentQueue<object> _logBuffer;
private readonly int _bufferSize;
private readonly IHubContext<AppHub> _appHubContext;
private readonly MessageTemplateTextFormatter _formatter = new("{Message:l}", CultureInfo.InvariantCulture);
private IHubContext<AppHub>? _appHubContext;
public SignalRLogSink(ILogger<SignalRLogSink> logger, IHubContext<AppHub> appHubContext)
public static SignalRLogSink Instance { get; } = new();
private SignalRLogSink()
{
_appHubContext = appHubContext;
_logger = logger;
_bufferSize = 100;
_logBuffer = new ConcurrentQueue<object>();
}
public void SetAppHubContext(IHubContext<AppHub> appHubContext)
{
_appHubContext = appHubContext ?? throw new ArgumentNullException(nameof(appHubContext), "AppHub context cannot be null");
}
/// <summary>
/// Processes and emits a log event to SignalR clients
/// </summary>
@@ -52,11 +56,14 @@ public class SignalRLogSink : ILogEventSink
AddToBuffer(logData);
// Send to connected clients via the unified hub
_ = _appHubContext.Clients.All.SendAsync("LogReceived", logData);
if (_appHubContext is not null)
{
_ = _appHubContext.Clients.All.SendAsync("LogReceived", logData);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send log event via SignalR");
Log.Logger.Error(ex, "Failed to send log event via SignalR");
}
}

View File

@@ -10,6 +10,7 @@ using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Cleanuparr.Shared.Helpers;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Serilog.Events;
namespace Cleanuparr.Persistence;
@@ -36,26 +37,41 @@ public class DataContext : DbContext
public DbSet<ArrInstance> ArrInstances { get; set; }
public DbSet<AppriseConfig> AppriseConfigs { get; set; }
public DbSet<NotificationConfig> NotificationConfigs { get; set; }
public DbSet<NotifiarrConfig> NotifiarrConfigs { get; set; }
public DbSet<AppriseConfig> AppriseConfigs { get; set; }
public DataContext()
{
}
public DataContext(DbContextOptions<DataContext> options) : base(options)
{
}
public static DataContext CreateStaticInstance()
{
var optionsBuilder = new DbContextOptionsBuilder<DataContext>();
SetDbContextOptions(optionsBuilder);
return new DataContext(optionsBuilder.Options);
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
if (optionsBuilder.IsConfigured)
{
return;
}
var dbPath = Path.Combine(ConfigurationPathProvider.GetConfigPath(), "cleanuparr.db");
optionsBuilder
.UseSqlite($"Data Source={dbPath}")
.UseLowerCaseNamingConvention()
.UseSnakeCaseNamingConvention();
SetDbContextOptions(optionsBuilder);
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<GeneralConfig>(entity =>
entity.ComplexProperty(e => e.Log, cp =>
{
cp.Property(l => l.Level).HasConversion<LowercaseEnumConverter<LogEventLevel>>();
})
);
modelBuilder.Entity<QueueCleanerConfig>(entity =>
{
entity.ComplexProperty(e => e.FailedImport);
@@ -92,6 +108,24 @@ public class DataContext : DbContext
.OnDelete(DeleteBehavior.Cascade);
});
// Configure new notification system relationships
modelBuilder.Entity<NotificationConfig>(entity =>
{
entity.Property(e => e.Type).HasConversion(new LowercaseEnumConverter<NotificationProviderType>());
entity.HasOne(p => p.NotifiarrConfiguration)
.WithOne(c => c.NotificationConfig)
.HasForeignKey<NotifiarrConfig>(c => c.NotificationConfigId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(p => p.AppriseConfiguration)
.WithOne(c => c.NotificationConfig)
.HasForeignKey<AppriseConfig>(c => c.NotificationConfigId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasIndex(p => p.Name).IsUnique();
});
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
var enumProperties = entityType.ClrType.GetProperties()
@@ -115,4 +149,18 @@ public class DataContext : DbContext
}
}
}
private static void SetDbContextOptions(DbContextOptionsBuilder optionsBuilder)
{
if (optionsBuilder.IsConfigured)
{
return;
}
var dbPath = Path.Combine(ConfigurationPathProvider.GetConfigPath(), "cleanuparr.db");
optionsBuilder
.UseSqlite($"Data Source={dbPath}")
.UseLowerCaseNamingConvention()
.UseSnakeCaseNamingConvention();
}
}

View File

@@ -12,19 +12,34 @@ namespace Cleanuparr.Persistence;
public class EventsContext : DbContext
{
public DbSet<AppEvent> Events { get; set; }
public EventsContext()
{
}
public EventsContext(DbContextOptions<EventsContext> options) : base(options)
{
}
public static EventsContext CreateStaticInstance()
{
var optionsBuilder = new DbContextOptionsBuilder<EventsContext>();
SetDbContextOptions(optionsBuilder);
return new EventsContext(optionsBuilder.Options);
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
if (optionsBuilder.IsConfigured)
{
return;
}
SetDbContextOptions(optionsBuilder);
}
public static string GetLikePattern(string input)
{
input = input.Replace("[", "[[]")
.Replace("%", "[%]")
.Replace("_", "[_]");
var dbPath = Path.Combine(ConfigurationPathProvider.GetConfigPath(), "events.db");
optionsBuilder
.UseSqlite($"Data Source={dbPath}")
.UseLowerCaseNamingConvention()
.UseSnakeCaseNamingConvention();
return $"%{input}%";
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
@@ -59,12 +74,17 @@ public class EventsContext : DbContext
}
}
public static string GetLikePattern(string input)
private static void SetDbContextOptions(DbContextOptionsBuilder optionsBuilder)
{
input = input.Replace("[", "[[]")
.Replace("%", "[%]")
.Replace("_", "[_]");
if (optionsBuilder.IsConfigured)
{
return;
}
return $"%{input}%";
var dbPath = Path.Combine(ConfigurationPathProvider.GetConfigPath(), "events.db");
optionsBuilder
.UseSqlite($"Data Source={dbPath}")
.UseLowerCaseNamingConvention()
.UseSnakeCaseNamingConvention();
}
}

View File

@@ -0,0 +1,674 @@
// <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("20250816183837_AddAdvancedLoggingSettings")]
partial class AddAdvancedLoggingSettings
{
/// <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>("FullUrl")
.HasColumnType("TEXT")
.HasColumnName("full_url");
b.Property<string>("Key")
.HasColumnType("TEXT")
.HasColumnName("key");
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>("Tags")
.HasColumnType("TEXT")
.HasColumnName("tags");
b.HasKey("Id")
.HasName("pk_apprise_configs");
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")
.HasColumnType("TEXT")
.HasColumnName("api_key");
b.Property<string>("ChannelId")
.HasColumnType("TEXT")
.HasColumnName("channel_id");
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.HasKey("Id")
.HasName("pk_notifiarr_configs");
b.ToTable("notifiarr_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.Arr.ArrConfig", b =>
{
b.Navigation("Instances");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b =>
{
b.Navigation("Categories");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,88 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class AddAdvancedLoggingSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "log_archive_enabled",
table: "general_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<ushort>(
name: "log_archive_retained_count",
table: "general_configs",
type: "INTEGER",
nullable: false,
defaultValue: (ushort)0);
migrationBuilder.AddColumn<ushort>(
name: "log_archive_time_limit_hours",
table: "general_configs",
type: "INTEGER",
nullable: false,
defaultValue: (ushort)0);
migrationBuilder.AddColumn<ushort>(
name: "log_retained_file_count",
table: "general_configs",
type: "INTEGER",
nullable: false,
defaultValue: (ushort)0);
migrationBuilder.AddColumn<ushort>(
name: "log_rolling_size_mb",
table: "general_configs",
type: "INTEGER",
nullable: false,
defaultValue: (ushort)0);
migrationBuilder.AddColumn<ushort>(
name: "log_time_limit_hours",
table: "general_configs",
type: "INTEGER",
nullable: false,
defaultValue: (ushort)0);
migrationBuilder.Sql(
"UPDATE general_configs SET log_archive_enabled = 1, log_archive_retained_count = 60, log_archive_time_limit_hours = 720, log_retained_file_count = 5, log_rolling_size_mb = 10, log_time_limit_hours = 24"
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "log_archive_enabled",
table: "general_configs");
migrationBuilder.DropColumn(
name: "log_archive_retained_count",
table: "general_configs");
migrationBuilder.DropColumn(
name: "log_archive_time_limit_hours",
table: "general_configs");
migrationBuilder.DropColumn(
name: "log_retained_file_count",
table: "general_configs");
migrationBuilder.DropColumn(
name: "log_rolling_size_mb",
table: "general_configs");
migrationBuilder.DropColumn(
name: "log_time_limit_hours",
table: "general_configs");
}
}
}

View File

@@ -0,0 +1,717 @@
// <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("20250830230846_ReworkNotificationSystem")]
partial class ReworkNotificationSystem
{
/// <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<string>("LogLevel")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("log_level");
b.Property<ushort>("SearchDelay")
.HasColumnType("INTEGER")
.HasColumnName("search_delay");
b.Property<bool>("SearchEnabled")
.HasColumnType("INTEGER")
.HasColumnName("search_enabled");
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.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.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");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,415 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class ReworkNotificationSystem : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "notification_configs",
columns: table => new
{
id = table.Column<Guid>(type: "TEXT", nullable: false),
name = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
type = table.Column<string>(type: "TEXT", nullable: false),
is_enabled = table.Column<bool>(type: "INTEGER", nullable: false),
on_failed_import_strike = table.Column<bool>(type: "INTEGER", nullable: false),
on_stalled_strike = table.Column<bool>(type: "INTEGER", nullable: false),
on_slow_strike = table.Column<bool>(type: "INTEGER", nullable: false),
on_queue_item_deleted = table.Column<bool>(type: "INTEGER", nullable: false),
on_download_cleaned = table.Column<bool>(type: "INTEGER", nullable: false),
on_category_changed = table.Column<bool>(type: "INTEGER", nullable: false),
created_at = table.Column<DateTime>(type: "TEXT", nullable: false),
updated_at = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_notification_configs", x => x.id);
});
string newGuid = Guid.NewGuid().ToString().ToUpperInvariant();
migrationBuilder.Sql(
$"""
INSERT INTO notification_configs (id, name, type, is_enabled, on_failed_import_strike, on_stalled_strike, on_slow_strike, on_queue_item_deleted, on_download_cleaned, on_category_changed, created_at, updated_at)
SELECT
'{newGuid}' AS id,
'Notifiarr' AS name,
'notifiarr' AS type,
CASE WHEN (on_failed_import_strike = 1 OR on_stalled_strike = 1 OR on_slow_strike = 1 OR on_queue_item_deleted = 1 OR on_download_cleaned = 1 OR on_category_changed = 1) THEN 1 ELSE 0 END AS is_enabled,
on_failed_import_strike,
on_stalled_strike,
on_slow_strike,
on_queue_item_deleted,
on_download_cleaned,
on_category_changed,
datetime('now') AS created_at,
datetime('now') AS updated_at
FROM notifiarr_configs
WHERE
channel_id IS NOT NULL AND channel_id != '' AND api_key IS NOT NULL AND api_key != ''
""");
newGuid = Guid.NewGuid().ToString().ToUpperInvariant();
migrationBuilder.Sql(
$"""
INSERT INTO notification_configs (id, name, type, is_enabled, on_failed_import_strike, on_stalled_strike, on_slow_strike, on_queue_item_deleted, on_download_cleaned, on_category_changed, created_at, updated_at)
SELECT
'{newGuid}' AS id,
'Apprise' AS name,
'apprise' AS type,
CASE WHEN (on_failed_import_strike = 1 OR on_stalled_strike = 1 OR on_slow_strike = 1 OR on_queue_item_deleted = 1 OR on_download_cleaned = 1 OR on_category_changed = 1) THEN 1 ELSE 0 END AS is_enabled,
on_failed_import_strike,
on_stalled_strike,
on_slow_strike,
on_queue_item_deleted,
on_download_cleaned,
on_category_changed,
datetime('now') AS created_at,
datetime('now') AS updated_at
FROM apprise_configs
WHERE
key IS NOT NULL AND key != '' AND full_url IS NOT NULL AND full_url != ''
""");
migrationBuilder.DropColumn(
name: "on_category_changed",
table: "notifiarr_configs");
migrationBuilder.DropColumn(
name: "on_download_cleaned",
table: "notifiarr_configs");
migrationBuilder.DropColumn(
name: "on_failed_import_strike",
table: "notifiarr_configs");
migrationBuilder.DropColumn(
name: "on_queue_item_deleted",
table: "notifiarr_configs");
migrationBuilder.DropColumn(
name: "on_slow_strike",
table: "notifiarr_configs");
migrationBuilder.DropColumn(
name: "on_stalled_strike",
table: "notifiarr_configs");
migrationBuilder.DropColumn(
name: "on_category_changed",
table: "apprise_configs");
migrationBuilder.DropColumn(
name: "on_download_cleaned",
table: "apprise_configs");
migrationBuilder.DropColumn(
name: "on_failed_import_strike",
table: "apprise_configs");
migrationBuilder.DropColumn(
name: "on_queue_item_deleted",
table: "apprise_configs");
migrationBuilder.DropColumn(
name: "on_slow_strike",
table: "apprise_configs");
migrationBuilder.DropColumn(
name: "on_stalled_strike",
table: "apprise_configs");
migrationBuilder.AlterColumn<string>(
name: "channel_id",
table: "notifiarr_configs",
type: "TEXT",
maxLength: 50,
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "TEXT",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "api_key",
table: "notifiarr_configs",
type: "TEXT",
maxLength: 255,
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "TEXT",
oldNullable: true);
migrationBuilder.AddColumn<Guid>(
name: "notification_config_id",
table: "notifiarr_configs",
type: "TEXT",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
migrationBuilder.Sql(
"""
UPDATE notifiarr_configs
SET notification_config_id = (
SELECT id FROM notification_configs
WHERE type = 'notifiarr'
LIMIT 1
)
WHERE channel_id IS NOT NULL AND channel_id != '' AND api_key IS NOT NULL AND api_key != ''
""");
migrationBuilder.Sql(
"""
DELETE FROM notifiarr_configs
WHERE NOT EXISTS (
SELECT 1 FROM notification_configs
WHERE type = 'notifiarr'
)
""");
migrationBuilder.AlterColumn<string>(
name: "key",
table: "apprise_configs",
type: "TEXT",
maxLength: 255,
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "TEXT",
oldNullable: true);
migrationBuilder.AddColumn<string>(
name: "url",
table: "apprise_configs",
type: "TEXT",
maxLength: 500,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<Guid>(
name: "notification_config_id",
table: "apprise_configs",
type: "TEXT",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
migrationBuilder.Sql(
"""
UPDATE apprise_configs
SET notification_config_id = (
SELECT id FROM notification_configs
WHERE type = 'apprise'
LIMIT 1
),
url = full_url
WHERE key IS NOT NULL AND key != '' AND full_url IS NOT NULL AND full_url != ''
""");
migrationBuilder.Sql(
"""
DELETE FROM apprise_configs
WHERE NOT EXISTS (
SELECT 1 FROM notification_configs
WHERE type = 'apprise'
)
""");
migrationBuilder.DropColumn(
name: "full_url",
table: "apprise_configs");
migrationBuilder.CreateIndex(
name: "ix_notifiarr_configs_notification_config_id",
table: "notifiarr_configs",
column: "notification_config_id",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_apprise_configs_notification_config_id",
table: "apprise_configs",
column: "notification_config_id",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_notification_configs_name",
table: "notification_configs",
column: "name",
unique: true);
migrationBuilder.AddForeignKey(
name: "fk_apprise_configs_notification_configs_notification_config_id",
table: "apprise_configs",
column: "notification_config_id",
principalTable: "notification_configs",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "fk_notifiarr_configs_notification_configs_notification_config_id",
table: "notifiarr_configs",
column: "notification_config_id",
principalTable: "notification_configs",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_apprise_configs_notification_configs_notification_config_id",
table: "apprise_configs");
migrationBuilder.DropForeignKey(
name: "fk_notifiarr_configs_notification_configs_notification_config_id",
table: "notifiarr_configs");
migrationBuilder.DropTable(
name: "notification_configs");
migrationBuilder.DropIndex(
name: "ix_notifiarr_configs_notification_config_id",
table: "notifiarr_configs");
migrationBuilder.DropIndex(
name: "ix_apprise_configs_notification_config_id",
table: "apprise_configs");
migrationBuilder.DropColumn(
name: "notification_config_id",
table: "notifiarr_configs");
migrationBuilder.DropColumn(
name: "notification_config_id",
table: "apprise_configs");
migrationBuilder.DropColumn(
name: "url",
table: "apprise_configs");
migrationBuilder.AlterColumn<string>(
name: "channel_id",
table: "notifiarr_configs",
type: "TEXT",
nullable: true,
oldClrType: typeof(string),
oldType: "TEXT",
oldMaxLength: 50);
migrationBuilder.AlterColumn<string>(
name: "api_key",
table: "notifiarr_configs",
type: "TEXT",
nullable: true,
oldClrType: typeof(string),
oldType: "TEXT",
oldMaxLength: 255);
migrationBuilder.AddColumn<bool>(
name: "on_category_changed",
table: "notifiarr_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "on_download_cleaned",
table: "notifiarr_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "on_failed_import_strike",
table: "notifiarr_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "on_queue_item_deleted",
table: "notifiarr_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "on_slow_strike",
table: "notifiarr_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "on_stalled_strike",
table: "notifiarr_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AlterColumn<string>(
name: "key",
table: "apprise_configs",
type: "TEXT",
nullable: true,
oldClrType: typeof(string),
oldType: "TEXT",
oldMaxLength: 255);
migrationBuilder.AddColumn<string>(
name: "full_url",
table: "apprise_configs",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "on_category_changed",
table: "apprise_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "on_download_cleaned",
table: "apprise_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "on_failed_import_strike",
table: "apprise_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "on_queue_item_deleted",
table: "apprise_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "on_slow_strike",
table: "apprise_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "on_stalled_strike",
table: "apprise_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
}
}

View File

@@ -79,133 +79,6 @@ namespace Cleanuparr.Persistence.Migrations.Data
b.ToTable("arr_instances", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.ContentBlocker.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.ContentBlocker.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.ContentBlocker.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.ContentBlocker.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.ContentBlocker.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.ContentBlocker.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.DownloadCleaner.CleanCategory", b =>
{
b.Property<Guid>("Id")
@@ -382,11 +255,6 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("ignored_downloads");
b.Property<string>("LogLevel")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("log_level");
b.Property<ushort>("SearchDelay")
.HasColumnType("INTEGER")
.HasColumnName("search_delay");
@@ -395,12 +263,173 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("INTEGER")
.HasColumnName("search_enabled");
b.ComplexProperty<Dictionary<string, object>>("Log", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Log#LoggingConfig", b1 =>
{
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")
@@ -408,45 +437,34 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("FullUrl")
.HasColumnType("TEXT")
.HasColumnName("full_url");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT")
.HasColumnName("key");
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<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);
});
@@ -458,13 +476,52 @@ namespace Cleanuparr.Persistence.Migrations.Data
.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");
@@ -489,10 +546,23 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("INTEGER")
.HasColumnName("on_stalled_strike");
b.HasKey("Id")
.HasName("pk_notifiarr_configs");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("type");
b.ToTable("notifiarr_configs", (string)null);
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.QueueCleaner.QueueCleanerConfig", b =>
@@ -627,6 +697,30 @@ namespace Cleanuparr.Persistence.Migrations.Data
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.Arr.ArrConfig", b =>
{
b.Navigation("Instances");
@@ -636,6 +730,13 @@ namespace Cleanuparr.Persistence.Migrations.Data
{
b.Navigation("Categories");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b =>
{
b.Navigation("AppriseConfiguration");
b.Navigation("NotifiarrConfiguration");
});
#pragma warning restore 612, 618
}
}

View File

@@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Cleanuparr.Domain.Enums;
using Serilog;
using Serilog.Events;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
@@ -25,18 +26,20 @@ public sealed record GeneralConfig : IConfig
public bool SearchEnabled { get; set; } = true;
public ushort SearchDelay { get; set; } = 30;
public LogEventLevel LogLevel { get; set; } = LogEventLevel.Information;
public string EncryptionKey { get; set; } = Guid.NewGuid().ToString();
public List<string> IgnoredDownloads { get; set; } = [];
public LoggingConfig Log { get; set; } = new();
public void Validate()
{
if (HttpTimeout is 0)
{
throw new ValidationException("HTTP_TIMEOUT must be greater than 0");
}
Log.Validate();
}
}

View File

@@ -0,0 +1,58 @@
using System.ComponentModel.DataAnnotations.Schema;
using Cleanuparr.Domain.Exceptions;
using Serilog;
using Serilog.Events;
namespace Cleanuparr.Persistence.Models.Configuration.General;
[ComplexType]
public sealed record LoggingConfig : IConfig
{
public LogEventLevel Level { get; set; } = LogEventLevel.Information;
public ushort RollingSizeMB { get; set; } = 10; // 0 = disabled
public ushort RetainedFileCount { get; set; } = 5; // 0 = unlimited
public ushort TimeLimitHours { get; set; } = 24; // 0 = unlimited
// Archive Configuration
public bool ArchiveEnabled { get; set; } = true;
public ushort ArchiveRetainedCount { get; set; } = 60; // 0 = unlimited
public ushort ArchiveTimeLimitHours { get; set; } = 24 * 30; // 0 = unlimited
public void Validate()
{
if (RollingSizeMB > 100)
{
throw new ValidationException("Log rolling size cannot exceed 100 MB");
}
if (RetainedFileCount > 50)
{
throw new ValidationException("Log retained file count cannot exceed 50");
}
if (TimeLimitHours > 1440) // 24 * 60
{
throw new ValidationException("Log time limit cannot exceed 60 days");
}
if (ArchiveRetainedCount > 100)
{
throw new ValidationException("Log archive retained count cannot exceed 100");
}
if (ArchiveTimeLimitHours > 1440) // 24 * 60
{
throw new ValidationException("Log archive time limit cannot exceed 60 days");
}
if (ArchiveRetainedCount is 0 && ArchiveTimeLimitHours is 0 && ArchiveEnabled)
{
throw new ValidationException("Archiving is enabled, but no retention policy is set. Please set either a retained file count or time limit");
}
}
}

View File

@@ -1,30 +1,74 @@
using System.ComponentModel.DataAnnotations.Schema;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Cleanuparr.Persistence.Models.Configuration;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Models.Configuration.Notification;
public sealed record AppriseConfig : NotificationConfig
public sealed record AppriseConfig : IConfig
{
public string? FullUrl { get; set; }
[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 Url { get; init; } = string.Empty;
[Required]
[MaxLength(255)]
public string Key { get; init; } = string.Empty;
[MaxLength(255)]
public string? Tags { get; init; }
[NotMapped]
public Uri? Url => string.IsNullOrEmpty(FullUrl) ? null : new Uri(FullUrl, UriKind.Absolute);
public string? Key { get; set; }
public string? Tags { get; set; }
public override bool IsValid()
public Uri? Uri
{
if (Url is null)
get
{
return false;
try
{
return string.IsNullOrWhiteSpace(Url) ? null : new Uri(Url, UriKind.Absolute);
}
catch
{
return null;
}
}
}
public bool IsValid()
{
return Uri != null &&
!string.IsNullOrWhiteSpace(Key);
}
public void Validate()
{
if (string.IsNullOrWhiteSpace(Url))
{
throw new ValidationException("Apprise server URL is required");
}
if (string.IsNullOrEmpty(Key?.Trim()))
if (Uri == null)
{
return false;
throw new ValidationException("Apprise server URL must be a valid HTTP or HTTPS URL");
}
if (string.IsNullOrWhiteSpace(Key))
{
throw new ValidationException("Apprise configuration key is required");
}
if (Key.Length < 2)
{
throw new ValidationException("Apprise configuration key must be at least 2 characters long");
}
return true;
}
}
}

View File

@@ -1,23 +1,56 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Cleanuparr.Persistence.Models.Configuration;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Models.Configuration.Notification;
public sealed record NotifiarrConfig : NotificationConfig
public sealed record NotifiarrConfig : IConfig
{
public string? ApiKey { get; init; }
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid Id { get; init; } = Guid.NewGuid();
public string? ChannelId { get; init; }
public override bool IsValid()
[Required]
public Guid NotificationConfigId { get; init; }
[ForeignKey(nameof(NotificationConfigId))]
public NotificationConfig NotificationConfig { get; init; } = null!;
[Required]
[MaxLength(255)]
public string ApiKey { get; init; } = string.Empty;
[Required]
[MaxLength(50)]
public string ChannelId { get; init; } = string.Empty;
public bool IsValid()
{
if (string.IsNullOrEmpty(ApiKey?.Trim()))
{
return false;
}
if (string.IsNullOrEmpty(ChannelId?.Trim()))
{
return false;
}
return true;
return !string.IsNullOrWhiteSpace(ApiKey) &&
!string.IsNullOrWhiteSpace(ChannelId);
}
}
public void Validate()
{
if (string.IsNullOrWhiteSpace(ApiKey))
{
throw new ValidationException("Notifiarr API key is required");
}
if (ApiKey.Length < 10)
{
throw new ValidationException("Notifiarr API key must be at least 10 characters long");
}
if (string.IsNullOrWhiteSpace(ChannelId))
{
throw new ValidationException("Discord channel ID is required");
}
if (!ulong.TryParse(ChannelId, out _))
{
throw new ValidationException("Discord channel ID must be a valid numeric ID");
}
}
}

View File

@@ -1,14 +1,24 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Persistence.Models.Configuration.Notification;
public abstract record NotificationConfig
public sealed record NotificationConfig
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid Id { get; init; } = Guid.NewGuid();
[Required]
[MaxLength(100)]
public string Name { get; init; } = string.Empty;
[Required]
public NotificationProviderType Type { get; init; }
public bool IsEnabled { get; init; } = true;
public bool OnFailedImportStrike { get; init; }
public bool OnStalledStrike { get; init; }
@@ -20,14 +30,29 @@ public abstract record NotificationConfig
public bool OnDownloadCleaned { get; init; }
public bool OnCategoryChanged { get; init; }
public bool IsEnabled =>
public DateTime CreatedAt { get; init; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; init; } = DateTime.UtcNow;
public NotifiarrConfig? NotifiarrConfiguration { get; init; }
public AppriseConfig? AppriseConfiguration { get; init; }
[NotMapped]
public bool IsConfigured => Type switch
{
NotificationProviderType.Notifiarr => NotifiarrConfiguration?.IsValid() == true,
NotificationProviderType.Apprise => AppriseConfiguration?.IsValid() == true,
_ => false
};
[NotMapped]
public bool HasAnyEventEnabled =>
OnFailedImportStrike ||
OnStalledStrike ||
OnSlowStrike ||
OnQueueItemDeleted ||
OnDownloadCleaned ||
OnCategoryChanged;
public abstract bool IsValid();
}
}

View File

@@ -48,12 +48,10 @@ export class AppHubService {
return this.hubConnection.start()
.then(() => {
console.log('AppHub connection started');
this.connectionStatusSubject.next(true);
this.requestInitialData();
})
.catch(err => {
console.error('Error connecting to AppHub:', err);
this.connectionStatusSubject.next(false);
throw err;
});
@@ -65,18 +63,15 @@ export class AppHubService {
private registerSignalREvents(): void {
// Handle connection events
this.hubConnection.onreconnected(() => {
console.log('AppHub reconnected');
this.connectionStatusSubject.next(true);
this.requestInitialData();
});
this.hubConnection.onreconnecting(() => {
console.log('AppHub reconnecting...');
this.connectionStatusSubject.next(false);
});
this.hubConnection.onclose(() => {
console.log('AppHub connection closed');
this.connectionStatusSubject.next(false);
});
@@ -163,7 +158,6 @@ export class AppHubService {
return this.hubConnection.stop()
.then(() => {
console.log('AppHub connection stopped');
this.connectionStatusSubject.next(false);
})
.catch(err => {

View File

@@ -68,7 +68,6 @@ export abstract class BaseSignalRService<T> implements OnDestroy {
return this.hubConnection.start()
.then(() => {
console.log(`SignalR connection started to ${this.config.hubUrl}`);
this.connectionStatusSubject.next(true);
this.reconnectAttempts = 0;
this.onConnectionEstablished();
@@ -86,7 +85,6 @@ export abstract class BaseSignalRService<T> implements OnDestroy {
30000
);
console.log(`Attempting to reconnect (${this.reconnectAttempts}) in ${delay}ms...`);
setTimeout(() => this.startConnection(), delay);
}
@@ -107,11 +105,9 @@ export abstract class BaseSignalRService<T> implements OnDestroy {
return this.hubConnection.stop()
.then(() => {
console.log(`SignalR connection to ${this.config.hubUrl} stopped`);
this.connectionStatusSubject.next(false);
})
.catch(err => {
console.error(`Error stopping connection to ${this.config.hubUrl}:`, err);
throw err;
});
}
@@ -127,19 +123,16 @@ export abstract class BaseSignalRService<T> implements OnDestroy {
// Handle reconnection events
this.hubConnection.onreconnected(() => {
console.log(`SignalR connection reconnected to ${this.config.hubUrl}`);
this.connectionStatusSubject.next(true);
this.reconnectAttempts = 0;
this.onConnectionEstablished();
});
this.hubConnection.onreconnecting(() => {
console.log(`SignalR connection reconnecting to ${this.config.hubUrl}...`);
this.connectionStatusSubject.next(false);
});
this.hubConnection.onclose(() => {
console.log(`SignalR connection to ${this.config.hubUrl} closed`);
this.connectionStatusSubject.next(false);
// Try to reconnect if the connection was closed unexpectedly
@@ -178,7 +171,6 @@ export abstract class BaseSignalRService<T> implements OnDestroy {
protected checkConnectionHealth(): void {
if (!this.hubConnection ||
this.hubConnection.state === signalR.HubConnectionState.Disconnected) {
console.log('Health check detected disconnected state, attempting to reconnect...');
this.startConnection();
}
}

View File

@@ -43,7 +43,13 @@ export class DocumentationService {
'httpCertificateValidation': 'http-certificate-validation',
'searchEnabled': 'search-enabled',
'searchDelay': 'search-delay',
'logLevel': 'log-level',
'log.level': 'log-level',
'log.rollingSizeMB': 'log-rolling-size-mb',
'log.retainedFileCount': 'log-retained-file-count',
'log.timeLimitHours': 'log-time-limit-hours',
'log.archiveEnabled': 'log-archive-enabled',
'log.archiveRetainedCount': 'log-archive-retained-count',
'log.archiveTimeLimitHours': 'log-archive-time-limit-hours',
'ignoredDownloads': 'ignored-downloads'
},
'download-cleaner': {
@@ -92,13 +98,15 @@ export class DocumentationService {
'password': 'password'
},
'notifications': {
'enabled': 'enabled',
'name': 'provider-name',
'notifiarr.apiKey': 'notifiarr-api-key',
'notifiarr.channelId': 'notifiarr-channel-id',
'apprise.fullUrl': 'apprise-url',
'apprise.url': 'apprise-url',
'apprise.key': 'apprise-key',
'apprise.tags': 'apprise-tags',
'eventTriggers': 'event-triggers'
}
// Additional sections will be added here as we implement them
};
constructor(private applicationPathService: ApplicationPathService) {}

View File

@@ -0,0 +1,183 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { ApplicationPathService } from './base-path.service';
import {
NotificationProvidersConfig,
NotificationProviderDto,
TestNotificationResult
} from '../../shared/models/notification-provider.model';
import { NotificationProviderType } from '../../shared/models/enums';
// Provider-specific interfaces
export interface CreateNotifiarrProviderDto {
name: string;
isEnabled: boolean;
onFailedImportStrike: boolean;
onStalledStrike: boolean;
onSlowStrike: boolean;
onQueueItemDeleted: boolean;
onDownloadCleaned: boolean;
onCategoryChanged: boolean;
apiKey: string;
channelId: string;
}
export interface UpdateNotifiarrProviderDto {
name: string;
isEnabled: boolean;
onFailedImportStrike: boolean;
onStalledStrike: boolean;
onSlowStrike: boolean;
onQueueItemDeleted: boolean;
onDownloadCleaned: boolean;
onCategoryChanged: boolean;
apiKey: string;
channelId: string;
}
export interface TestNotifiarrProviderDto {
apiKey: string;
channelId: string;
}
export interface CreateAppriseProviderDto {
name: string;
isEnabled: boolean;
onFailedImportStrike: boolean;
onStalledStrike: boolean;
onSlowStrike: boolean;
onQueueItemDeleted: boolean;
onDownloadCleaned: boolean;
onCategoryChanged: boolean;
url: string;
key: string;
tags: string;
}
export interface UpdateAppriseProviderDto {
name: string;
isEnabled: boolean;
onFailedImportStrike: boolean;
onStalledStrike: boolean;
onSlowStrike: boolean;
onQueueItemDeleted: boolean;
onDownloadCleaned: boolean;
onCategoryChanged: boolean;
url: string;
key: string;
tags: string;
}
export interface TestAppriseProviderDto {
url: string;
key: string;
tags: string;
}
@Injectable({
providedIn: 'root'
})
export class NotificationProviderService {
private readonly http = inject(HttpClient);
private readonly pathService = inject(ApplicationPathService);
private readonly baseUrl = this.pathService.buildApiUrl('/configuration');
/**
* Get all notification providers
*/
getProviders(): Observable<NotificationProvidersConfig> {
return this.http.get<NotificationProvidersConfig>(`${this.baseUrl}/notification_providers`);
}
/**
* Create a new Notifiarr provider
*/
createNotifiarrProvider(provider: CreateNotifiarrProviderDto): Observable<NotificationProviderDto> {
return this.http.post<NotificationProviderDto>(`${this.baseUrl}/notification_providers/notifiarr`, provider);
}
/**
* Create a new Apprise provider
*/
createAppriseProvider(provider: CreateAppriseProviderDto): Observable<NotificationProviderDto> {
return this.http.post<NotificationProviderDto>(`${this.baseUrl}/notification_providers/apprise`, provider);
}
/**
* Update an existing Notifiarr provider
*/
updateNotifiarrProvider(id: string, provider: UpdateNotifiarrProviderDto): Observable<NotificationProviderDto> {
return this.http.put<NotificationProviderDto>(`${this.baseUrl}/notification_providers/notifiarr/${id}`, provider);
}
/**
* Update an existing Apprise provider
*/
updateAppriseProvider(id: string, provider: UpdateAppriseProviderDto): Observable<NotificationProviderDto> {
return this.http.put<NotificationProviderDto>(`${this.baseUrl}/notification_providers/apprise/${id}`, provider);
}
/**
* Delete a notification provider
*/
deleteProvider(id: string): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/notification_providers/${id}`);
}
/**
* Test a Notifiarr provider (without ID - for testing configuration before saving)
*/
testNotifiarrProvider(testRequest: TestNotifiarrProviderDto): Observable<TestNotificationResult> {
return this.http.post<TestNotificationResult>(`${this.baseUrl}/notification_providers/notifiarr/test`, testRequest);
}
/**
* Test an Apprise provider (without ID - for testing configuration before saving)
*/
testAppriseProvider(testRequest: TestAppriseProviderDto): Observable<TestNotificationResult> {
return this.http.post<TestNotificationResult>(`${this.baseUrl}/notification_providers/apprise/test`, testRequest);
}
/**
* Generic create method that delegates to provider-specific methods
*/
createProvider(provider: any, type: NotificationProviderType): Observable<NotificationProviderDto> {
switch (type) {
case NotificationProviderType.Notifiarr:
return this.createNotifiarrProvider(provider as CreateNotifiarrProviderDto);
case NotificationProviderType.Apprise:
return this.createAppriseProvider(provider as CreateAppriseProviderDto);
default:
throw new Error(`Unsupported provider type: ${type}`);
}
}
/**
* Generic update method that delegates to provider-specific methods
*/
updateProvider(id: string, provider: any, type: NotificationProviderType): Observable<NotificationProviderDto> {
switch (type) {
case NotificationProviderType.Notifiarr:
return this.updateNotifiarrProvider(id, provider as UpdateNotifiarrProviderDto);
case NotificationProviderType.Apprise:
return this.updateAppriseProvider(id, provider as UpdateAppriseProviderDto);
default:
throw new Error(`Unsupported provider type: ${type}`);
}
}
/**
* Generic test method that delegates to provider-specific methods
*/
testProvider(testRequest: any, type: NotificationProviderType): Observable<TestNotificationResult> {
switch (type) {
case NotificationProviderType.Notifiarr:
return this.testNotifiarrProvider(testRequest as TestNotifiarrProviderDto);
case NotificationProviderType.Apprise:
return this.testAppriseProvider(testRequest as TestAppriseProviderDto);
default:
throw new Error(`Unsupported provider type: ${type}`);
}
}
}

View File

@@ -75,40 +75,4 @@ export class ErrorHandlerUtil {
return null;
}
/**
* Determine if an error message represents a user-fixable validation error
* These should be shown as toast notifications so the user can correct them
*/
static isUserFixableError(errorMessage: string): boolean {
// Common validation error patterns that users can fix
const validationPatterns = [
/does not exist/i,
/cannot be empty/i,
/invalid/i,
/required/i,
/must be/i,
/should not/i,
/duplicate/i,
/already exists/i,
/format/i,
/expression/i,
];
// Network errors should not be shown as toast (shown in LoadingErrorStateComponent instead)
const networkErrorPatterns = [
/unable to connect/i,
/network/i,
/connection/i,
/timeout/i,
/server error/i,
];
// Check if it's a network error first
if (networkErrorPatterns.some(pattern => pattern.test(errorMessage))) {
return false;
}
// Check if it matches validation patterns
return validationPatterns.some(pattern => pattern.test(errorMessage));
}
}

View File

@@ -0,0 +1,25 @@
import { AbstractControl, ValidationErrors } from '@angular/forms';
export class UrlValidators {
/**
* Generic http/https URL validator used by the various settings components.
* Returns { invalidUri: true } when URL parsing fails or { invalidProtocol: true }
* when protocol is not http/https. Returns null for valid values or empty.
*/
public static httpUrl(control: AbstractControl): ValidationErrors | null {
const value = control.value;
if (!value) {
return null;
}
try {
const url = new URL(value);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return { invalidProtocol: true };
}
return null;
} catch {
return { invalidUri: true };
}
}
}

View File

@@ -1,8 +1,9 @@
// Main container stability
:host {
display: block;
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden; // Prevent scrolling
overflow: hidden; // Prevent scrolling on the host
position: relative;
}
@@ -12,6 +13,7 @@
align-items: center;
padding: 20px;
margin-top: 20px;
flex: 0 0 auto; // Prevent logo container from growing/shrinking
.logo {
display: flex;
@@ -55,18 +57,21 @@
// Navigation menu
.nav-menu {
display: flex;
flex-direction: column;
flex: 1;
display: block;
gap: 0; // Remove gap to prevent layout shifts
transition: opacity 0.2s ease;
// Prevent horizontal scrolling
// Keep horizontal overflow hidden to avoid horizontal scrollbar
overflow-x: hidden;
// The nav area becomes the scrollable region. Use a calc so the
// header/logo area remains visible and the nav does not shrink
// other elements when it scrolls. Default header height can be
// overridden via the --sidebar-header-height CSS variable.
height: calc(100% - var(--sidebar-header-height, 120px));
box-sizing: border-box;
overflow-y: auto;
// Fixed minimum height to prevent jumping
min-height: 400px;
-webkit-overflow-scrolling: touch;
// Navigation items container for smooth animations
.navigation-items-container {
@@ -75,6 +80,14 @@
gap: 8px; // Consistent spacing between navigation items
position: relative; // Ensure proper stacking context for animations
width: 100%; // Take full width of parent
// Prevent horizontal scrollbar when items translate on hover
overflow-x: hidden;
padding-right: 8px; // leave space for the scrollbar
// Keep layout stable: do not force this element to flex-grow
// so icons and badges won't shrink when the nav scrolls.
min-height: 0;
}
// Loading skeleton

View File

@@ -179,15 +179,8 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
if (saveErrorMessage) {
// Check if this looks like a validation error from the backend
// These are typically user-fixable errors that should be shown as toasts
const isUserFixableError = ErrorHandlerUtil.isUserFixableError(saveErrorMessage);
if (isUserFixableError) {
// Show validation errors as toast notifications so user can fix them
this.notificationService.showError(saveErrorMessage);
} else {
// For non-user-fixable save errors, also emit to parent
this.error.emit(saveErrorMessage);
}
// Always show save errors as a toast so the user sees the backend message.
this.notificationService.showError(saveErrorMessage);
}
});
@@ -753,31 +746,6 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
}
}
/**
* Simple test method to check unlinkedCategories functionality
* Call from browser console: ng.getComponent(document.querySelector('app-download-cleaner-settings')).testUnlinkedCategories()
*/
testUnlinkedCategories(): void {
console.log('=== TESTING UNLINKED CATEGORIES ===');
const control = this.downloadCleanerForm.get('unlinkedCategories');
console.log('Current value:', control?.value);
console.log('Control disabled:', control?.disabled);
console.log('Control status:', control?.status);
// Test setting values
console.log('Setting test values: ["movies", "tv-shows"]');
control?.setValue(['movies', 'tv-shows']);
console.log('Value after setting:', control?.value);
// Test what getRawValue returns
const rawValues = this.downloadCleanerForm.getRawValue();
console.log('getRawValue().unlinkedCategories:', rawValues.unlinkedCategories);
console.log('=== END TEST ===');
}
/**
* Minimal complete method for autocomplete - just returns empty array to allow manual input
*/

View File

@@ -21,6 +21,7 @@ import { ConfirmDialogModule } from "primeng/confirmdialog";
import { ConfirmationService } from "primeng/api";
import { NotificationService } from "../../core/services/notification.service";
import { LoadingErrorStateComponent } from "../../shared/components/loading-error-state/loading-error-state.component";
import { UrlValidators } from "../../core/validators/url.validator";
@Component({
selector: "app-download-client-settings",
@@ -86,7 +87,7 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
this.clientForm = this.formBuilder.group({
name: ['', Validators.required],
typeName: [null, Validators.required],
host: ['', [Validators.required, this.uriValidator.bind(this)]],
host: ['', [Validators.required, UrlValidators.httpUrl]],
username: [''],
password: [''],
urlBase: [''],
@@ -120,28 +121,6 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
this.destroy$.complete();
}
/**
* Custom validator to check if the input is a valid URI
*/
private uriValidator(control: AbstractControl): ValidationErrors | null {
if (!control.value) {
return null; // Let required validator handle empty values
}
try {
const url = new URL(control.value);
// Check that we have a valid protocol (http or https)
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return { invalidProtocol: true };
}
return null; // Valid URI
} catch (e) {
return { invalidUri: true }; // Invalid URI
}
}
/**
* Mark all controls in a form group as touched
*/
@@ -349,8 +328,8 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
if (!hostControl || !usernameControl || !urlBaseControl) return;
hostControl.setValidators([
Validators.required,
this.uriValidator.bind(this)
Validators.required,
UrlValidators.httpUrl
]);
// Clear username value and remove validation for Deluge

View File

@@ -2,6 +2,7 @@ import { Injectable, inject } from '@angular/core';
import { patchState, signalStore, withHooks, withMethods, withState } from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { GeneralConfig } from '../../shared/models/general-config.model';
import { LoggingConfig } from '../../shared/models/logging-config.model';
import { ConfigurationService } from '../../core/services/configuration.service';
import { EMPTY, Observable, catchError, switchMap, tap } from 'rxjs';
@@ -9,14 +10,16 @@ export interface GeneralConfigState {
config: GeneralConfig | null;
loading: boolean;
saving: boolean;
error: string | null;
loadError: string | null; // Only for load failures that should show "Not connected"
saveError: string | null; // Only for save failures that should show toast
}
const initialState: GeneralConfigState = {
config: null,
loading: false,
saving: false,
error: null
loadError: null,
saveError: null
};
@Injectable()
@@ -29,18 +32,26 @@ export class GeneralConfigStore extends signalStore(
*/
loadConfig: rxMethod<void>(
pipe => pipe.pipe(
tap(() => patchState(store, { loading: true, error: null })),
tap(() => patchState(store, { loading: true, loadError: null, saveError: null })),
switchMap(() => configService.getGeneralConfig().pipe(
tap({
next: (config) => patchState(store, { config, loading: false }),
next: (config) => patchState(store, { config, loading: false, loadError: null }),
error: (error) => {
const errorMessage = error.message || 'Failed to load configuration';
patchState(store, {
loading: false,
error: error.message || 'Failed to load configuration'
loadError: errorMessage // Only load errors should trigger "Not connected" state
});
}
}),
catchError(() => EMPTY)
catchError((error) => {
const errorMessage = error.message || 'Failed to load configuration';
patchState(store, {
loading: false,
loadError: errorMessage // Only load errors should trigger "Not connected" state
});
return EMPTY;
})
))
)
),
@@ -50,24 +61,31 @@ export class GeneralConfigStore extends signalStore(
*/
saveConfig: rxMethod<GeneralConfig>(
(config$: Observable<GeneralConfig>) => config$.pipe(
tap(() => patchState(store, { saving: true, error: null })),
tap(() => patchState(store, { saving: true, saveError: null })),
switchMap(config => configService.updateGeneralConfig(config).pipe(
tap({
next: () => {
// Successfully saved - just update saving state
// Don't update config to avoid triggering form effects
patchState(store, {
saving: false
saving: false,
saveError: null // Clear any previous save errors
});
},
error: (error) => {
patchState(store, {
saving: false,
error: error.message || 'Failed to save configuration'
const errorMessage = error.message || 'Failed to save configuration';
patchState(store, {
saving: false,
saveError: errorMessage // Save errors should NOT trigger "Not connected" state
});
}
}),
catchError(() => EMPTY)
catchError((error) => {
const errorMessage = error.message || 'Failed to save configuration';
patchState(store, {
saving: false,
saveError: errorMessage // Save errors should NOT trigger "Not connected" state
});
return EMPTY;
})
))
)
),
@@ -88,7 +106,14 @@ export class GeneralConfigStore extends signalStore(
* Reset any errors
*/
resetError() {
patchState(store, { error: null });
patchState(store, { loadError: null, saveError: null });
},
/**
* Reset only save errors
*/
resetSaveError() {
patchState(store, { saveError: null });
}
})),
withHooks({

View File

@@ -3,28 +3,30 @@
<h1>General Settings</h1>
</div>
<p-card styleClass="settings-card h-full">
<ng-template pTemplate="header">
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
<div class="header-title-container">
<h2 class="card-title m-0">General Configuration</h2>
<span class="card-subtitle">Configure general application settings</span>
<!-- Loading/Error Component -->
<app-loading-error-state
*ngIf="generalLoading() || generalLoadError()"
[loading]="generalLoading()"
[error]="generalLoadError()"
loadingMessage="Loading settings..."
errorMessage="Could not connect to server"
></app-loading-error-state>
<!-- Settings Form -->
<form *ngIf="!generalLoading() && !generalLoadError()" [formGroup]="generalForm" class="p-fluid">
<!-- General Configuration Card -->
<p-card styleClass="settings-card mb-4">
<ng-template pTemplate="header">
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
<div class="header-title-container">
<h2 class="card-title m-0">General Configuration</h2>
<span class="card-subtitle">Configure general application settings</span>
</div>
</div>
</div>
</ng-template>
</ng-template>
<div class="card-content">
<!-- Loading/Error Component -->
<app-loading-error-state
*ngIf="generalLoading() || generalError()"
[loading]="generalLoading()"
[error]="generalError()"
loadingMessage="Loading settings..."
errorMessage="Could not connect to server"
></app-loading-error-state>
<!-- Settings Form -->
<form *ngIf="!generalLoading() && !generalError()" [formGroup]="generalForm" class="p-fluid">
<div class="card-content">
<!-- Display Support Banner -->
<div class="field-row">
<label class="field-label">
@@ -41,207 +43,362 @@
</div>
<!-- Dry Run -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('dryRun')"
title="Click for documentation">
</i>
Dry Run
</label>
<div class="field-input">
<p-checkbox formControlName="dryRun" [binary]="true" inputId="dryRun"></p-checkbox>
<small class="form-helper-text">When enabled, no changes will be made to the system</small>
</div>
</div>
<!-- HTTP Settings -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('httpMaxRetries')"
title="Click for documentation">
</i>
Maximum HTTP Retries
</label>
<div>
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('dryRun')"
title="Click for documentation">
</i>
Dry Run
</label>
<div class="field-input">
<p-inputNumber
formControlName="httpMaxRetries"
inputId="httpMaxRetries"
[showButtons]="true"
[min]="0"
buttonLayout="horizontal"
></p-inputNumber>
<p-checkbox formControlName="dryRun" [binary]="true" inputId="dryRun"></p-checkbox>
<small class="form-helper-text">When enabled, no changes will be made to the system</small>
</div>
<small *ngIf="hasError('httpMaxRetries', 'required')" class="p-error">This field is required</small>
<small *ngIf="hasError('httpMaxRetries', 'max')" class="p-error">Maximum value is 5</small>
<small class="form-helper-text">Number of retry attempts for failed HTTPS requests</small>
</div>
</div>
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('httpTimeout')"
title="Click for documentation">
</i>
HTTP Timeout (seconds)
</label>
<div>
<!-- HTTP Settings -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('httpMaxRetries')"
title="Click for documentation">
</i>
Maximum HTTP Retries
</label>
<div>
<div class="field-input">
<p-inputNumber
formControlName="httpMaxRetries"
inputId="httpMaxRetries"
[showButtons]="true"
[min]="0"
buttonLayout="horizontal"
></p-inputNumber>
</div>
<small *ngIf="hasError('httpMaxRetries', 'required')" class="p-error">This field is required</small>
<small *ngIf="hasError('httpMaxRetries', 'max')" class="p-error">Maximum value is 5</small>
<small class="form-helper-text">Number of retry attempts for failed HTTPS requests</small>
</div>
</div>
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('httpTimeout')"
title="Click for documentation">
</i>
HTTP Timeout (seconds)
</label>
<div>
<div class="field-input">
<p-inputNumber
formControlName="httpTimeout"
inputId="httpTimeout"
[showButtons]="true"
[min]="1"
buttonLayout="horizontal"
></p-inputNumber>
</div>
<small *ngIf="hasError('httpTimeout', 'required')" class="p-error">This field is required</small>
<small *ngIf="hasError('httpTimeout', 'max')" class="p-error">Maximum value is 100</small>
<small class="form-helper-text">Timeout duration for HTTP requests in seconds</small>
</div>
</div>
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('httpCertificateValidation')"
title="Click for documentation">
</i>
Certificate Validation
</label>
<div class="field-input">
<p-inputNumber
formControlName="httpTimeout"
inputId="httpTimeout"
[showButtons]="true"
[min]="1"
buttonLayout="horizontal"
></p-inputNumber>
<p-select
formControlName="httpCertificateValidation"
inputId="httpCertificateValidation"
[options]="certificateValidationOptions"
optionLabel="label"
optionValue="value"
></p-select>
<small class="form-helper-text">Enable or disable certificate validation for HTTPS requests</small>
</div>
<small *ngIf="hasError('httpTimeout', 'required')" class="p-error">This field is required</small>
<small *ngIf="hasError('httpTimeout', 'max')" class="p-error">Maximum value is 100</small>
<small class="form-helper-text">Timeout duration for HTTP requests in seconds</small>
</div>
</div>
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('httpCertificateValidation')"
title="Click for documentation">
</i>
Certificate Validation
</label>
<div class="field-input">
<p-select
formControlName="httpCertificateValidation"
inputId="httpCertificateValidation"
[options]="certificateValidationOptions"
optionLabel="label"
optionValue="value"
></p-select>
<small class="form-helper-text">Enable or disable certificate validation for HTTPS requests</small>
</div>
</div>
<!-- Search Settings -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('searchEnabled')"
title="Click for documentation">
</i>
Enable Search
</label>
<div class="field-input">
<p-checkbox formControlName="searchEnabled" [binary]="true" inputId="searchEnabled"></p-checkbox>
<small class="form-helper-text">When enabled, the application will trigger a search after removing a download</small>
</div>
</div>
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('searchDelay')"
title="Click for documentation">
</i>
Search Delay (seconds)
</label>
<div>
<!-- Search Settings -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('searchEnabled')"
title="Click for documentation">
</i>
Enable Search
</label>
<div class="field-input">
<p-inputNumber
formControlName="searchDelay"
inputId="searchDelay"
[showButtons]="true"
[min]="1"
buttonLayout="horizontal"
></p-inputNumber>
<p-checkbox formControlName="searchEnabled" [binary]="true" inputId="searchEnabled"></p-checkbox>
<small class="form-helper-text">When enabled, the application will trigger a search after removing a download</small>
</div>
</div>
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('searchDelay')"
title="Click for documentation">
</i>
Search Delay (seconds)
</label>
<div>
<div class="field-input">
<p-inputNumber
formControlName="searchDelay"
inputId="searchDelay"
[showButtons]="true"
[min]="1"
buttonLayout="horizontal"
></p-inputNumber>
</div>
<small *ngIf="hasError('searchDelay', 'required')" class="p-error">This field is required</small>
<small *ngIf="hasError('searchDelay', 'max')" class="p-error">Maximum value is 300</small>
<small class="form-helper-text">Delay between search operations in seconds</small>
</div>
</div>
<!-- Ignored Downloads -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('ignoredDownloads')"
title="Click for documentation">
</i>
Ignored Downloads
</label>
<div class="field-input">
<!-- Mobile-friendly autocomplete -->
<app-mobile-autocomplete
formControlName="ignoredDownloads"
placeholder="Add download pattern"
></app-mobile-autocomplete>
<!-- Desktop autocomplete -->
<p-autocomplete
formControlName="ignoredDownloads"
inputId="ignoredDownloads"
multiple
fluid
[typeahead]="false"
placeholder="Add download pattern and press enter"
class="desktop-only"
></p-autocomplete>
<small class="form-helper-text">Downloads matching these patterns will be ignored (e.g. hash, tag, category, label, tracker)</small>
</div>
<small *ngIf="hasError('searchDelay', 'required')" class="p-error">This field is required</small>
<small *ngIf="hasError('searchDelay', 'max')" class="p-error">Maximum value is 300</small>
<small class="form-helper-text">Delay between search operations in seconds</small>
</div>
</div>
</p-card>
<!-- Log Level -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('logLevel')"
title="Click for documentation">
</i>
Log Level
</label>
<div class="field-input">
<p-select
formControlName="logLevel"
inputId="logLevel"
[options]="logLevelOptions"
optionLabel="label"
optionValue="value"
></p-select>
<small class="form-helper-text">Select the minimum log level to display</small>
<!-- Logging Configuration Card -->
<p-card styleClass="settings-card mb-4">
<ng-template pTemplate="header">
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
<div class="header-title-container">
<h2 class="card-title m-0">Logging Configuration</h2>
<span class="card-subtitle">Configure application logging and retention settings</span>
</div>
</div>
</ng-template>
<div class="card-content" formGroupName="log">
<!-- Log Level -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('log.level')"
title="Click for documentation">
</i>
Log Level
</label>
<div class="field-input">
<p-select
formControlName="level"
inputId="logLevel"
[options]="logLevelOptions"
optionLabel="label"
optionValue="value"
></p-select>
<small class="form-helper-text">Select the minimum log level to display</small>
</div>
</div>
<!-- Rolling Size -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('log.rollingSizeMB')"
title="Click for documentation">
</i>
Rolling Size (MB)
</label>
<div>
<div class="field-input">
<p-inputNumber
formControlName="rollingSizeMB"
inputId="rollingSizeMB"
[showButtons]="true"
[min]="0"
buttonLayout="horizontal"
></p-inputNumber>
</div>
<small *ngIf="hasNestedError('log', 'rollingSizeMB', 'required')" class="p-error">This field is required</small>
<small *ngIf="hasNestedError('log', 'rollingSizeMB', 'max')" class="p-error">Maximum value is 100 MB</small>
<small class="form-helper-text">Maximum size of each log file in megabytes (0 = disabled)</small>
</div>
</div>
<!-- Retained File Count -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('log.retainedFileCount')"
title="Click for documentation">
</i>
Retained File Count
</label>
<div>
<div class="field-input">
<p-inputNumber
formControlName="retainedFileCount"
inputId="retainedFileCount"
[showButtons]="true"
[min]="0"
buttonLayout="horizontal"
></p-inputNumber>
</div>
<small *ngIf="hasNestedError('log', 'retainedFileCount', 'required')" class="p-error">This field is required</small>
<small *ngIf="hasNestedError('log', 'retainedFileCount', 'max')" class="p-error">Maximum value is 50</small>
<small class="form-helper-text">Number of old log files to retain (0 = unlimited)</small>
<small class="form-helper-text">Files exceeding this limit will be deleted or archived</small>
</div>
</div>
<!-- Time Limit Hours -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('log.timeLimitHours')"
title="Click for documentation">
</i>
Time Limit (hours)
</label>
<div>
<div class="field-input">
<p-inputNumber
formControlName="timeLimitHours"
inputId="timeLimitHours"
[showButtons]="true"
[min]="0"
buttonLayout="horizontal"
></p-inputNumber>
</div>
<small *ngIf="hasNestedError('log', 'timeLimitHours', 'required')" class="p-error">This field is required</small>
<small *ngIf="hasNestedError('log', 'timeLimitHours', 'max')" class="p-error">Maximum value is 1440 hours (60 days)</small>
<small class="form-helper-text">Maximum age of old log files in hours (0 = unlimited)</small>
<small class="form-helper-text">Files exceeding this limit will be deleted or archived</small>
</div>
</div>
<!-- Archive Enabled -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('log.archiveEnabled')"
title="Click for documentation">
</i>
Enable Archive
</label>
<div class="field-input">
<p-checkbox formControlName="archiveEnabled" [binary]="true" inputId="archiveEnabled"></p-checkbox>
<small class="form-helper-text">Enable archiving of old log files</small>
</div>
</div>
<!-- Archive Retained Count (disabled when archiving disabled) -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('log.archiveRetainedCount')"
title="Click for documentation">
</i>
Archive Retained Count
</label>
<div>
<div class="field-input">
<p-inputNumber
formControlName="archiveRetainedCount"
inputId="archiveRetainedCount"
[showButtons]="true"
[min]="0"
buttonLayout="horizontal"
></p-inputNumber>
</div>
<small *ngIf="hasNestedError('log', 'archiveRetainedCount', 'required')" class="p-error">This field is required</small>
<small *ngIf="hasNestedError('log', 'archiveRetainedCount', 'max')" class="p-error">Maximum value is 100</small>
<small class="form-helper-text">Number of archive files to retain (0 = unlimited)</small>
</div>
</div>
<!-- Archive Time Limit Hours (disabled when archiving disabled) -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('log.archiveTimeLimitHours')"
title="Click for documentation">
</i>
Archive Time Limit (hours)
</label>
<div>
<div class="field-input">
<p-inputNumber
formControlName="archiveTimeLimitHours"
inputId="archiveTimeLimitHours"
[showButtons]="true"
[min]="0"
buttonLayout="horizontal"
></p-inputNumber>
</div>
<small *ngIf="hasNestedError('log', 'archiveTimeLimitHours', 'required')" class="p-error">This field is required</small>
<small *ngIf="hasNestedError('log', 'archiveTimeLimitHours', 'max')" class="p-error">Maximum value is 1440 hours (60 days)</small>
<small class="form-helper-text">Maximum age of archive files in hours (0 = unlimited)</small>
</div>
</div>
</div>
</p-card>
<!-- Ignored Downloads -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('ignoredDownloads')"
title="Click for documentation">
</i>
Ignored Downloads
</label>
<div class="field-input">
<!-- Mobile-friendly autocomplete -->
<app-mobile-autocomplete
formControlName="ignoredDownloads"
placeholder="Add download pattern"
></app-mobile-autocomplete>
<!-- Desktop autocomplete -->
<p-autocomplete
formControlName="ignoredDownloads"
inputId="ignoredDownloads"
multiple
fluid
[typeahead]="false"
placeholder="Add download pattern and press enter"
class="desktop-only"
></p-autocomplete>
<small class="form-helper-text">Downloads matching these patterns will be ignored (e.g. hash, tag, category, label, tracker)</small>
</div>
</div>
<!-- Action buttons -->
<div class="card-footer mt-3">
<button
pButton
type="button"
label="Save"
icon="pi pi-save"
class="p-button-primary"
[disabled]="(!generalForm.dirty || !hasActualChanges) || generalForm.invalid || generalSaving()"
[loading]="generalSaving()"
(click)="saveGeneralConfig()"
></button>
<button
pButton
type="button"
label="Reset"
icon="pi pi-refresh"
class="p-button-secondary p-button-outlined ml-2"
(click)="resetGeneralConfig()"
></button>
</div>
</form>
</div>
</p-card>
<!-- Confirmation Dialog -->
<p-confirmDialog
[style]="{ width: '500px', maxWidth: '90vw' }"
[baseZIndex]="10000">
</p-confirmDialog>
<!-- Action buttons moved outside cards -->
<div class="card-footer mt-3">
<button
pButton
type="button"
label="Save"
icon="pi pi-save"
class="p-button-primary"
[disabled]="(!generalForm.dirty || !hasActualChanges) || generalForm.invalid || generalSaving()"
[loading]="generalSaving()"
(click)="saveGeneralConfig()"
></button>
<button
pButton
type="button"
label="Reset"
icon="pi pi-refresh"
class="p-button-secondary p-button-outlined ml-2"
(click)="resetGeneralConfig()"
></button>
</div>
</form>
<!-- Confirmation Dialog -->
<p-confirmDialog
[style]="{ width: '500px', maxWidth: '90vw' }"
[baseZIndex]="10000">
</p-confirmDialog>
</div>

View File

@@ -5,6 +5,7 @@ import { Subject, takeUntil } from "rxjs";
import { GeneralConfigStore } from "./general-config.store";
import { CanComponentDeactivate } from "../../core/guards";
import { GeneralConfig } from "../../shared/models/general-config.model";
import { LoggingConfig } from "../../shared/models/logging-config.model";
import { LogEventLevel } from "../../shared/models/log-event-level.enum";
import { CertificateValidationType } from "../../shared/models/certificate-validation-type.enum";
@@ -25,6 +26,7 @@ import { LoadingErrorStateComponent } from "../../shared/components/loading-erro
import { ConfirmDialogModule } from "primeng/confirmdialog";
import { ConfirmationService } from "primeng/api";
import { MobileAutocompleteComponent } from "../../shared/components/mobile-autocomplete/mobile-autocomplete.component";
import { ErrorHandlerUtil } from "../../core/utils/error-handler.util";
@Component({
selector: "app-general-settings",
@@ -57,6 +59,11 @@ export class GeneralSettingsComponent implements OnDestroy, CanComponentDeactiva
// General Configuration Form
generalForm: FormGroup;
// Getter for easy access to the log form group
get logForm(): FormGroup {
return this.generalForm.get('log') as FormGroup;
}
// Original form values for tracking changes
private originalFormValues: any;
@@ -91,7 +98,8 @@ export class GeneralSettingsComponent implements OnDestroy, CanComponentDeactiva
readonly generalConfig = this.generalConfigStore.config;
readonly generalLoading = this.generalConfigStore.loading;
readonly generalSaving = this.generalConfigStore.saving;
readonly generalError = this.generalConfigStore.error;
readonly generalLoadError = this.generalConfigStore.loadError; // Only for "Not connected" state
readonly generalSaveError = this.generalConfigStore.saveError; // Only for toast notifications
// Subject for unsubscribing from observables when component is destroyed
private destroy$ = new Subject<void>();
@@ -127,8 +135,19 @@ export class GeneralSettingsComponent implements OnDestroy, CanComponentDeactiva
httpCertificateValidation: [CertificateValidationType.Enabled],
searchEnabled: [true],
searchDelay: [30, [Validators.required, Validators.min(1), Validators.max(300)]],
logLevel: [LogEventLevel.Information],
ignoredDownloads: [[]],
// Nested logging configuration form group
log: this.formBuilder.group({
level: [LogEventLevel.Information],
rollingSizeMB: [10, [Validators.required, Validators.min(0), Validators.max(100)]],
retainedFileCount: [5, [Validators.required, Validators.min(0), Validators.max(50)]],
timeLimitHours: [24, [Validators.required, Validators.min(0), Validators.max(1440)]], // max 60 days
archiveEnabled: [true],
archiveRetainedCount: [{ value: 60, disabled: false }, [Validators.required, Validators.min(0), Validators.max(100)]],
archiveTimeLimitHours: [{ value: 720, disabled: false }, [Validators.required, Validators.min(0), Validators.max(1440)]], // max 60 days
}),
// Temporary backward compatibility - will be removed
logLevel: [LogEventLevel.Information],
});
// Effect to handle configuration changes
@@ -144,13 +163,28 @@ export class GeneralSettingsComponent implements OnDestroy, CanComponentDeactiva
httpCertificateValidation: config.httpCertificateValidation,
searchEnabled: config.searchEnabled,
searchDelay: config.searchDelay,
logLevel: config.logLevel,
ignoredDownloads: config.ignoredDownloads || [],
// New nested logging configuration
log: config.log || {
level: config.logLevel || LogEventLevel.Information, // Fall back to old property
rollingSizeMB: 10,
retainedFileCount: 5,
timeLimitHours: 24,
archiveEnabled: true,
archiveRetainedCount: 60,
archiveTimeLimitHours: 720,
},
// Temporary backward compatibility
logLevel: config.logLevel || config.log?.level || LogEventLevel.Information,
});
// Store original values for dirty checking
this.storeOriginalValues();
// Update archive controls state based on loaded configuration
const archiveEnabled = config.log?.archiveEnabled ?? true;
this.updateArchiveControlsState(archiveEnabled);
// Track the support banner state for confirmation dialog logic
this.previousSupportBannerState = config.displaySupportBanner;
@@ -162,12 +196,21 @@ export class GeneralSettingsComponent implements OnDestroy, CanComponentDeactiva
}
});
// Effect to handle errors
// Effect to handle load errors - emit to LoadingErrorStateComponent for "Not connected" display
effect(() => {
const errorMessage = this.generalError();
if (errorMessage) {
// Only emit the error for parent components
this.error.emit(errorMessage);
const loadErrorMessage = this.generalLoadError();
if (loadErrorMessage) {
// Load errors should be shown as "Not connected to server" in LoadingErrorStateComponent
this.error.emit(loadErrorMessage);
}
});
// Effect to handle save errors - show as toast notifications for user to fix
effect(() => {
const saveErrorMessage = this.generalSaveError();
if (saveErrorMessage) {
// Always show save errors as a toast so the user sees the backend message.
this.notificationService.showError(saveErrorMessage);
}
});
@@ -200,6 +243,16 @@ export class GeneralSettingsComponent implements OnDestroy, CanComponentDeactiva
.subscribe(() => {
this.hasActualChanges = this.formValuesChanged();
});
// Listen for changes to the 'archiveEnabled' control
const archiveEnabledControl = this.generalForm.get('log.archiveEnabled');
if (archiveEnabledControl) {
archiveEnabledControl.valueChanges
.pipe(takeUntil(this.destroy$))
.subscribe((enabled: boolean) => {
this.updateArchiveControlsState(enabled);
});
}
}
/**
@@ -220,6 +273,101 @@ export class GeneralSettingsComponent implements OnDestroy, CanComponentDeactiva
return !this.isEqual(currentValues, this.originalFormValues);
}
/**
* Update the state of archive-related controls based on the 'archiveEnabled' control value
*/
private updateArchiveControlsState(archiveEnabled: boolean): void {
const archiveRetainedCountControl = this.generalForm.get('log.archiveRetainedCount');
const archiveTimeLimitHoursControl = this.generalForm.get('log.archiveTimeLimitHours');
if (archiveEnabled) {
archiveRetainedCountControl?.enable({ emitEvent: false });
archiveTimeLimitHoursControl?.enable({ emitEvent: false });
} else {
// Disable controls but ensure they can still show validation errors
archiveRetainedCountControl?.disable({ emitEvent: false });
archiveTimeLimitHoursControl?.disable({ emitEvent: false });
}
}
/**
* Validate all form controls, including disabled ones
*/
private validateAllFormControls(formGroup: FormGroup): void {
Object.keys(formGroup.controls).forEach(key => {
const control = formGroup.get(key);
if (control instanceof FormGroup) {
this.validateAllFormControls(control);
} else {
// Force validation even on disabled controls
control?.updateValueAndValidity({ onlySelf: true });
control?.markAsTouched();
}
});
}
/**
* Validate archive controls specifically, even when disabled
* Returns true if archive controls have validation errors
*/
private validateArchiveControls(): boolean {
const archiveEnabledControl = this.generalForm.get('log.archiveEnabled');
const archiveRetainedCountControl = this.generalForm.get('log.archiveRetainedCount');
const archiveTimeLimitHoursControl = this.generalForm.get('log.archiveTimeLimitHours');
if (!archiveEnabledControl || !archiveRetainedCountControl || !archiveTimeLimitHoursControl) {
return false;
}
const isArchiveEnabled = archiveEnabledControl.value;
// If archive is disabled, we need to manually validate the disabled controls
if (!isArchiveEnabled) {
const retainedCountValue = archiveRetainedCountControl.value;
const timeLimitValue = archiveTimeLimitHoursControl.value;
// Check archive retained count validation (required, min: 0, max: 100)
const retainedCountErrors: any = {};
if (retainedCountValue === null || retainedCountValue === undefined || retainedCountValue === '') {
retainedCountErrors.required = true;
} else if (retainedCountValue < 0) {
retainedCountErrors.min = { min: 0, actual: retainedCountValue };
} else if (retainedCountValue > 100) {
retainedCountErrors.max = { max: 100, actual: retainedCountValue };
}
// Check archive time limit validation (required, min: 0, max: 1440)
const timeLimitErrors: any = {};
if (timeLimitValue === null || timeLimitValue === undefined || timeLimitValue === '') {
timeLimitErrors.required = true;
} else if (timeLimitValue < 0) {
timeLimitErrors.min = { min: 0, actual: timeLimitValue };
} else if (timeLimitValue > 1440) {
timeLimitErrors.max = { max: 1440, actual: timeLimitValue };
}
// Manually set errors and mark as touched to show validation messages
if (Object.keys(retainedCountErrors).length > 0) {
archiveRetainedCountControl.setErrors(retainedCountErrors);
archiveRetainedCountControl.markAsTouched();
} else {
archiveRetainedCountControl.setErrors(null);
}
if (Object.keys(timeLimitErrors).length > 0) {
archiveTimeLimitHoursControl.setErrors(timeLimitErrors);
archiveTimeLimitHoursControl.markAsTouched();
} else {
archiveTimeLimitHoursControl.setErrors(null);
}
// Return true if there are validation errors
return Object.keys(retainedCountErrors).length > 0 || Object.keys(timeLimitErrors).length > 0;
}
return false;
}
/**
* Deep compare two objects for equality
*/
@@ -266,23 +414,36 @@ export class GeneralSettingsComponent implements OnDestroy, CanComponentDeactiva
* Save the general configuration
*/
saveGeneralConfig(): void {
// Mark all form controls as touched to trigger validation
// Force validation on all controls, including disabled ones
this.validateAllFormControls(this.generalForm);
// Specifically validate archive controls even when disabled
const archiveValidationErrors = this.validateArchiveControls();
// Mark all form controls as touched to trigger validation messages
this.markFormGroupTouched(this.generalForm);
if (this.generalForm.valid) {
const formValues = this.generalForm.getRawValue();
if (this.generalForm.invalid || archiveValidationErrors) {
this.notificationService.showValidationError();
return;
}
const config: GeneralConfig = {
displaySupportBanner: formValues.displaySupportBanner,
dryRun: formValues.dryRun,
httpMaxRetries: formValues.httpMaxRetries,
httpTimeout: formValues.httpTimeout,
httpCertificateValidation: formValues.httpCertificateValidation,
searchEnabled: formValues.searchEnabled,
searchDelay: formValues.searchDelay,
logLevel: formValues.logLevel,
ignoredDownloads: formValues.ignoredDownloads || [],
};
const formValues = this.generalForm.getRawValue();
const config: GeneralConfig = {
displaySupportBanner: formValues.displaySupportBanner,
dryRun: formValues.dryRun,
httpMaxRetries: formValues.httpMaxRetries,
httpTimeout: formValues.httpTimeout,
httpCertificateValidation: formValues.httpCertificateValidation,
searchEnabled: formValues.searchEnabled,
searchDelay: formValues.searchDelay,
ignoredDownloads: formValues.ignoredDownloads || [],
// New nested logging configuration
log: formValues.log as LoggingConfig,
// Temporary backward compatibility - keep logLevel for now
logLevel: formValues.log?.level || formValues.logLevel,
};
// Save the configuration
this.generalConfigStore.saveConfig(config);
@@ -290,9 +451,9 @@ export class GeneralSettingsComponent implements OnDestroy, CanComponentDeactiva
// Setup a one-time check to mark form as pristine after successful save
const checkSaveCompletion = () => {
const saving = this.generalSaving();
const error = this.generalError();
const saveError = this.generalSaveError();
if (!saving && !error) {
if (!saving && !saveError) {
// Mark form as pristine after successful save
this.generalForm.markAsPristine();
// Update original values reference
@@ -301,9 +462,9 @@ export class GeneralSettingsComponent implements OnDestroy, CanComponentDeactiva
this.saved.emit();
// Display success message
this.notificationService.showSuccess('General configuration saved successfully.');
} else if (!saving && error) {
// If there's an error, we can stop checking
// No need to show error toast here, it's handled by the LoadingErrorStateComponent
} else if (!saving && saveError) {
// If there's a save error, we can stop checking
// Toast notification is already handled by the effect above
} else {
// If still saving, check again in a moment
setTimeout(checkSaveCompletion, 100);
@@ -312,9 +473,6 @@ export class GeneralSettingsComponent implements OnDestroy, CanComponentDeactiva
// Start checking for save completion
checkSaveCompletion();
} else {
this.notificationService.showValidationError();
}
}
/**
@@ -329,10 +487,24 @@ export class GeneralSettingsComponent implements OnDestroy, CanComponentDeactiva
httpCertificateValidation: CertificateValidationType.Enabled,
searchEnabled: true,
searchDelay: 30,
logLevel: LogEventLevel.Information,
ignoredDownloads: [],
// Reset nested logging configuration to defaults
log: {
level: LogEventLevel.Information,
rollingSizeMB: 10,
retainedFileCount: 5,
timeLimitHours: 24,
archiveEnabled: true,
archiveRetainedCount: 60,
archiveTimeLimitHours: 720,
},
// Temporary backward compatibility
logLevel: LogEventLevel.Information,
});
// Update archive controls state after reset
this.updateArchiveControlsState(true); // archiveEnabled defaults to true
// Mark form as dirty so the save button is enabled after reset
this.generalForm.markAsDirty();
}
@@ -355,7 +527,22 @@ export class GeneralSettingsComponent implements OnDestroy, CanComponentDeactiva
*/
hasError(controlName: string, errorName: string): boolean {
const control = this.generalForm.get(controlName);
return control ? control.dirty && control.hasError(errorName) : false;
// Check for errors on both enabled and disabled controls that have been touched
return control ? (control.dirty || control.touched) && control.hasError(errorName) : false;
}
/**
* Get nested form control errors
*/
hasNestedError(parentName: string, controlName: string, errorName: string): boolean {
const parentControl = this.generalForm.get(parentName);
if (!parentControl || !(parentControl instanceof FormGroup)) {
return false;
}
const control = parentControl.get(controlName);
// Check for errors on both enabled and disabled controls that have been touched
return control ? (control.dirty || control.touched) && control.hasError(errorName) : false;
}
/**

View File

@@ -1,6 +1,6 @@
import { Component, EventEmitter, OnDestroy, Output, effect, inject } from "@angular/core";
import { CommonModule } from "@angular/common";
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators, AbstractControl, ValidationErrors } from "@angular/forms";
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
import { Subject, takeUntil } from "rxjs";
import { LidarrConfigStore } from "./lidarr-config.store";
import { CanComponentDeactivate } from "../../core/guards";
@@ -19,6 +19,7 @@ import { ConfirmDialogModule } from "primeng/confirmdialog";
import { ConfirmationService } from "primeng/api";
import { NotificationService } from "../../core/services/notification.service";
import { LoadingErrorStateComponent } from "../../shared/components/loading-error-state/loading-error-state.component";
import { UrlValidators } from "../../core/validators/url.validator";
@Component({
selector: "app-lidarr-settings",
@@ -88,7 +89,7 @@ export class LidarrSettingsComponent implements OnDestroy, CanComponentDeactivat
this.instanceForm = this.formBuilder.group({
enabled: [true],
name: ['', Validators.required],
url: ['', [Validators.required, this.uriValidator.bind(this)]],
url: ['', [Validators.required, UrlValidators.httpUrl]],
apiKey: ['', Validators.required],
});
@@ -178,24 +179,7 @@ export class LidarrSettingsComponent implements OnDestroy, CanComponentDeactivat
/**
* Custom validator to check if the input is a valid URI
*/
private uriValidator(control: AbstractControl): ValidationErrors | null {
if (!control.value) {
return null; // Let required validator handle empty values
}
try {
const url = new URL(control.value);
// Check that we have a valid protocol (http or https)
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return { invalidProtocol: true };
}
return null; // Valid URI
} catch (e) {
return { invalidUri: true }; // Invalid URI
}
}
// URL validation handled by shared UrlValidators.httpUrl
/**
* Mark all controls in a form group as touched

View File

@@ -220,15 +220,8 @@ export class MalwareBlockerSettingsComponent implements OnDestroy, CanComponentD
if (saveErrorMessage) {
// Check if this looks like a validation error from the backend
// These are typically user-fixable errors that should be shown as toasts
const isUserFixableError = ErrorHandlerUtil.isUserFixableError(saveErrorMessage);
if (isUserFixableError) {
// Show validation errors as toast notifications so user can fix them
this.notificationService.showError(saveErrorMessage);
} else {
// For non-user-fixable save errors, also emit to parent
this.error.emit(saveErrorMessage);
}
// Always show save errors as a toast so the user sees the backend message.
this.notificationService.showError(saveErrorMessage);
}
});

View File

@@ -0,0 +1,411 @@
import { Injectable, computed, inject } from '@angular/core';
import { patchState, signalStore, withComputed, withHooks, withMethods, withState } from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import {
NotificationProvidersConfig,
NotificationProviderDto,
TestNotificationResult
} from '../../shared/models/notification-provider.model';
import { NotificationProviderService } from '../../core/services/notification-provider.service';
import { NotificationProviderType } from '../../shared/models/enums';
import { EMPTY, Observable, catchError, switchMap, tap, forkJoin, of } from 'rxjs';
import { ErrorHandlerUtil } from '../../core/utils/error-handler.util';
export interface NotificationProviderConfigState {
config: NotificationProvidersConfig | null;
loading: boolean;
saving: boolean;
testing: boolean;
loadError: string | null; // Only for load failures that should show "Not connected"
saveError: string | null; // Only for save failures that should show toast
testError: string | null; // Only for test failures that should show toast
testResult: TestNotificationResult | null;
pendingOperations: number;
}
const initialState: NotificationProviderConfigState = {
config: null,
loading: false,
saving: false,
testing: false,
loadError: null,
saveError: null,
testError: null,
testResult: null,
pendingOperations: 0
};
@Injectable()
export class NotificationProviderConfigStore extends signalStore(
withState(initialState),
withMethods((store, notificationService = inject(NotificationProviderService)) => ({
/**
* Load the Notification Provider configuration
*/
loadConfig: rxMethod<void>(
pipe => pipe.pipe(
tap(() => patchState(store, { loading: true, loadError: null, saveError: null, testError: null })),
switchMap(() => notificationService.getProviders().pipe(
tap({
next: (config) => patchState(store, { config, loading: false, loadError: null }),
error: (error) => {
const errorMessage = ErrorHandlerUtil.extractErrorMessage(error);
patchState(store, {
loading: false,
loadError: errorMessage // Only load errors should trigger "Not connected" state
});
}
}),
catchError((error) => {
const errorMessage = ErrorHandlerUtil.extractErrorMessage(error);
patchState(store, {
loading: false,
loadError: errorMessage // Only load errors should trigger "Not connected" state
});
return EMPTY;
})
))
)
),
/**
* Create a new notification provider (provider-specific)
*/
createProvider: rxMethod<{ provider: any, type: NotificationProviderType }>(
(params$: Observable<{ provider: any, type: NotificationProviderType }>) => params$.pipe(
tap(() => patchState(store, { saving: true, saveError: null })),
switchMap(({ provider, type }) => notificationService.createProvider(provider, type).pipe(
tap({
next: (newProvider) => {
const currentConfig = store.config();
if (currentConfig) {
// Add the new provider to the providers array
const updatedProviders = [...currentConfig.providers, newProvider];
patchState(store, {
config: { providers: updatedProviders },
saving: false,
saveError: null
});
}
},
error: (error) => {
const errorMessage = ErrorHandlerUtil.extractErrorMessage(error);
patchState(store, {
saving: false,
saveError: errorMessage // Save errors should NOT trigger "Not connected" state
});
}
}),
catchError((error) => {
const errorMessage = ErrorHandlerUtil.extractErrorMessage(error);
patchState(store, {
saving: false,
saveError: errorMessage // Save errors should NOT trigger "Not connected" state
});
return EMPTY;
})
))
)
),
/**
* Update a specific notification provider by ID (provider-specific)
*/
updateProvider: rxMethod<{ id: string, provider: any, type: NotificationProviderType }>(
(params$: Observable<{ id: string, provider: any, type: NotificationProviderType }>) => params$.pipe(
tap(() => patchState(store, { saving: true, saveError: null })),
switchMap(({ id, provider, type }) => notificationService.updateProvider(id, provider, type).pipe(
tap({
next: (updatedProvider) => {
const currentConfig = store.config();
if (currentConfig) {
// Find and replace the updated provider in the providers array
const updatedProviders = currentConfig.providers.map((p: NotificationProviderDto) =>
p.id === id ? updatedProvider : p
);
patchState(store, {
config: { providers: updatedProviders },
saving: false,
saveError: null
});
}
},
error: (error) => {
const errorMessage = ErrorHandlerUtil.extractErrorMessage(error);
patchState(store, {
saving: false,
saveError: errorMessage // Save errors should NOT trigger "Not connected" state
});
}
}),
catchError((error) => {
const errorMessage = ErrorHandlerUtil.extractErrorMessage(error);
patchState(store, {
saving: false,
saveError: errorMessage // Save errors should NOT trigger "Not connected" state
});
return EMPTY;
})
))
)
),
/**
* Delete a notification provider by ID
*/
deleteProvider: rxMethod<string>(
(id$: Observable<string>) => id$.pipe(
tap(() => patchState(store, { saving: true, saveError: null })),
switchMap(id => notificationService.deleteProvider(id).pipe(
tap({
next: () => {
const currentConfig = store.config();
if (currentConfig) {
// Remove the provider from the providers array
const updatedProviders = currentConfig.providers.filter((p: NotificationProviderDto) => p.id !== id);
patchState(store, {
config: { providers: updatedProviders },
saving: false,
saveError: null
});
}
},
error: (error) => {
const errorMessage = ErrorHandlerUtil.extractErrorMessage(error);
patchState(store, {
saving: false,
saveError: errorMessage // Save errors should NOT trigger "Not connected" state
});
}
}),
catchError((error) => {
const errorMessage = ErrorHandlerUtil.extractErrorMessage(error);
patchState(store, {
saving: false,
saveError: errorMessage // Save errors should NOT trigger "Not connected" state
});
return EMPTY;
})
))
)
),
/**
* Test a notification provider (provider-specific - without ID)
*/
testProvider: rxMethod<{ testRequest: any, type: NotificationProviderType }>(
(params$: Observable<{ testRequest: any, type: NotificationProviderType }>) => params$.pipe(
tap(() => patchState(store, { testing: true, testError: null, testResult: null })),
switchMap(({ testRequest, type }) => notificationService.testProvider(testRequest, type).pipe(
tap({
next: (result) => {
patchState(store, {
testing: false,
testResult: result,
testError: null
});
},
error: (error) => {
const errorMessage = ErrorHandlerUtil.extractErrorMessage(error);
patchState(store, {
testing: false,
testError: errorMessage, // Test errors should NOT trigger "Not connected" state
testResult: {
success: false,
message: errorMessage
}
});
}
}),
catchError((error) => {
const errorMessage = ErrorHandlerUtil.extractErrorMessage(error);
patchState(store, {
testing: false,
testError: errorMessage, // Test errors should NOT trigger "Not connected" state
testResult: {
success: false,
message: errorMessage
}
});
return EMPTY;
})
))
)
),
/**
* Batch create multiple providers (kept for compatibility)
*/
createProviders: rxMethod<Array<{ provider: any, type: NotificationProviderType }>>(
(providers$: Observable<Array<{ provider: any, type: NotificationProviderType }>>) => providers$.pipe(
tap(() => patchState(store, { saving: true, saveError: null, pendingOperations: 0 })),
switchMap(providers => {
if (providers.length === 0) {
patchState(store, { saving: false });
return EMPTY;
}
patchState(store, { pendingOperations: providers.length });
// Create all providers in parallel
const createOperations = providers.map(({ provider, type }) =>
notificationService.createProvider(provider, type).pipe(
catchError(error => {
console.error('Failed to create provider:', error);
return of(null); // Return null for failed operations
})
)
);
return forkJoin(createOperations).pipe(
tap({
next: (results) => {
const currentConfig = store.config();
if (currentConfig) {
// Filter out failed operations (null results)
const successfulProviders = results.filter(provider => provider !== null) as NotificationProviderDto[];
const updatedProviders = [...currentConfig.providers, ...successfulProviders];
const failedCount = results.filter(provider => provider === null).length;
patchState(store, {
config: { providers: updatedProviders },
saving: false,
pendingOperations: 0,
saveError: failedCount > 0 ? `${failedCount} provider(s) failed to create` : null
});
}
},
error: (error) => {
const errorMessage = ErrorHandlerUtil.extractErrorMessage(error);
patchState(store, {
saving: false,
pendingOperations: 0,
saveError: errorMessage
});
}
})
);
})
)
),
/**
* Batch update multiple providers (kept for compatibility)
*/
updateProviders: rxMethod<Array<{ id: string, provider: any, type: NotificationProviderType }>>(
(updates$: Observable<Array<{ id: string, provider: any, type: NotificationProviderType }>>) => updates$.pipe(
tap(() => patchState(store, { saving: true, saveError: null, pendingOperations: 0 })),
switchMap(updates => {
if (updates.length === 0) {
patchState(store, { saving: false });
return EMPTY;
}
patchState(store, { pendingOperations: updates.length });
// Update all providers in parallel
const updateOperations = updates.map(({ id, provider, type }) =>
notificationService.updateProvider(id, provider, type).pipe(
catchError(error => {
console.error('Failed to update provider:', error);
return of(null); // Return null for failed operations
})
)
);
return forkJoin(updateOperations).pipe(
tap({
next: (results) => {
const currentConfig = store.config();
if (currentConfig) {
let updatedProviders = [...currentConfig.providers];
let failedCount = 0;
// Update successful results
results.forEach((result, index) => {
if (result !== null) {
const providerIndex = updatedProviders.findIndex(p => p.id === updates[index].id);
if (providerIndex !== -1) {
updatedProviders[providerIndex] = result;
}
} else {
failedCount++;
}
});
patchState(store, {
config: { providers: updatedProviders },
saving: false,
pendingOperations: 0,
saveError: failedCount > 0 ? `${failedCount} provider(s) failed to update` : null
});
}
},
error: (error) => {
const errorMessage = ErrorHandlerUtil.extractErrorMessage(error);
patchState(store, {
saving: false,
pendingOperations: 0,
saveError: errorMessage
});
}
})
);
})
)
),
/**
* Update config in the store without saving to the backend
*/
updateConfigLocally(config: Partial<NotificationProvidersConfig>) {
const currentConfig = store.config();
if (currentConfig) {
patchState(store, {
config: { ...currentConfig, ...config }
});
}
},
/**
* Reset any errors and test results
*/
resetError() {
patchState(store, { loadError: null, saveError: null, testError: null, testResult: null });
},
/**
* Reset only save errors (for when user fixes validation issues)
*/
resetSaveError() {
patchState(store, { saveError: null });
},
/**
* Reset only test errors
*/
resetTestError() {
patchState(store, { testError: null });
},
/**
* Clear test result
*/
clearTestResult() {
patchState(store, { testResult: null });
}
})),
withComputed((store) => ({
notificationProviders: computed(() => store.config()?.providers || [])
})),
withHooks({
onInit({ loadConfig }) {
loadConfig();
}
})
) {}

View File

@@ -0,0 +1,76 @@
<app-notification-provider-base
[visible]="visible"
modalTitle="Configure Apprise Provider"
[saving]="saving"
[testing]="testing"
[editingProvider]="editingProvider"
(save)="onSave($event)"
(cancel)="onCancel()"
(test)="onTest($event)"
>
<!-- Provider-specific configuration goes here -->
<div slot="provider-config">
<!-- Apprise Server URL -->
<div class="field">
<label for="full-url">
<i
class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('apprise.url')"
></i>
Apprise Server URL *
</label>
<input
id="full-url"
type="url"
pInputText
[formControl]="urlControl"
placeholder="http://localhost:8000"
class="w-full"
/>
<small *ngIf="hasFieldError(urlControl, 'required')" class="p-error">URL is required</small>
<small *ngIf="hasFieldError(urlControl, 'invalidUri')" class="p-error">Must be a valid URL</small>
<small *ngIf="hasFieldError(urlControl, 'invalidProtocol')" class="p-error">Must use http or https protocol</small>
<small class="form-helper-text">The URL of your Apprise server where notifications will be sent.</small>
</div>
<!-- Configuration Key -->
<div class="field">
<label for="key">
<i
class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('apprise.key')"
></i>
Configuration Key *
</label>
<input id="key" type="text" pInputText [formControl]="keyControl" placeholder="my-config-key" class="w-full" />
<small *ngIf="hasFieldError(keyControl, 'required')" class="p-error">Configuration key is required</small>
<small *ngIf="hasFieldError(keyControl, 'minlength')" class="p-error">Key must be at least 2 characters</small>
<small class="form-helper-text">The key that identifies your Apprise configuration on the server.</small>
</div>
<!-- Tags -->
<div class="field">
<label for="tags">
<i
class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('apprise.tags')"
></i>
Tags (Optional)
</label>
<input
id="tags"
type="text"
pInputText
[formControl]="tagsControl"
placeholder="tag1,tag2 or tag3 tag4"
class="w-full"
/>
<small class="form-helper-text"
>Optional tags to filter notifications. Use comma (,) to OR tags and space ( ) to AND them.</small
>
</div>
</div>
</app-notification-provider-base>

View File

@@ -0,0 +1,2 @@
/* Apprise Provider Modal Styles */
@use '../../../styles/settings-shared.scss';

View File

@@ -0,0 +1,119 @@
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 { AppriseFormData, 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';
@Component({
selector: 'app-apprise-provider',
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
InputTextModule,
NotificationProviderBaseComponent
],
templateUrl: './apprise-provider.component.html',
styleUrls: ['./apprise-provider.component.scss']
})
export class AppriseProviderComponent implements OnInit, OnChanges {
@Input() visible = false;
@Input() editingProvider: NotificationProviderDto | null = null;
@Input() saving = false;
@Input() testing = false;
@Output() save = new EventEmitter<AppriseFormData>();
@Output() cancel = new EventEmitter<void>();
@Output() test = new EventEmitter<AppriseFormData>();
// Provider-specific form controls
urlControl = new FormControl('', [Validators.required, UrlValidators.httpUrl]);
keyControl = new FormControl('', [Validators.required, Validators.minLength(2)]);
tagsControl = new FormControl(''); // Optional field
private documentationService = inject(DocumentationService);
/**
* Exposed for template to open documentation for apprise fields
*/
openFieldDocs(fieldName: string): void {
this.documentationService.openFieldDocumentation('notifications', fieldName);
}
ngOnInit(): void {
// Initialize component but don't populate yet - wait for ngOnChanges
}
ngOnChanges(changes: SimpleChanges): void {
// Populate provider-specific fields when editingProvider input changes
if (changes['editingProvider']) {
if (this.editingProvider) {
this.populateProviderFields();
} else {
// Reset fields when editingProvider is cleared
this.resetProviderFields();
}
}
}
private populateProviderFields(): void {
if (this.editingProvider) {
const config = this.editingProvider.configuration as any;
this.urlControl.setValue(config?.url || '');
this.keyControl.setValue(config?.key || '');
this.tagsControl.setValue(config?.tags || '');
}
}
private resetProviderFields(): void {
this.urlControl.setValue('');
this.keyControl.setValue('');
this.tagsControl.setValue('');
}
protected hasFieldError(control: FormControl, errorType: string): boolean {
return !!(control && control.errors?.[errorType] && (control.dirty || control.touched));
}
onSave(baseData: BaseProviderFormData): void {
if (this.urlControl.valid && this.keyControl.valid) {
const appriseData: AppriseFormData = {
...baseData,
url: this.urlControl.value || '',
key: this.keyControl.value || '',
tags: this.tagsControl.value || ''
};
this.save.emit(appriseData);
} else {
// Mark provider-specific fields as touched to show validation errors
this.urlControl.markAsTouched();
this.keyControl.markAsTouched();
}
}
onCancel(): void {
this.cancel.emit();
}
onTest(baseData: BaseProviderFormData): void {
if (this.urlControl.valid && this.keyControl.valid) {
const appriseData: AppriseFormData = {
...baseData,
url: this.urlControl.value || '',
key: this.keyControl.value || '',
tags: this.tagsControl.value || ''
};
this.test.emit(appriseData);
} else {
// Mark provider-specific fields as touched to show validation errors
this.urlControl.markAsTouched();
this.keyControl.markAsTouched();
}
}
// URL validation delegated to shared UrlValidators.httpUrl
}

View File

@@ -0,0 +1,138 @@
<p-dialog
[(visible)]="visible"
[modal]="true"
[closable]="true"
[draggable]="false"
[resizable]="false"
styleClass="instance-modal"
[header]="modalTitle"
(onHide)="onCancel()"
>
<form [formGroup]="baseForm" class="p-fluid instance-form">
<!-- Enabled Toggle -->
<div class="field flex flex-row">
<label class="field-label">
<i
class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('enabled')"
></i>
Enabled
</label>
<div class="field-input">
<p-checkbox formControlName="enabled" [binary]="true"></p-checkbox>
<small class="form-helper-text">Enable this notification provider</small>
</div>
</div>
<!-- Provider Name -->
<div class="field">
<label for="provider-name">
<i
class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('name')"
></i>
Provider Name *
</label>
<input
id="provider-name"
type="text"
pInputText
formControlName="name"
placeholder="My Notification Provider"
class="w-full"
/>
<small class="form-helper-text">A unique name to identify this provider</small>
<small *ngIf="hasError('name', 'required')" class="p-error"> Provider name is required </small>
</div>
<!-- Provider-Specific Configuration (Content Projection) -->
<ng-content select="[slot=provider-config]"></ng-content>
<!-- Event Triggers Section -->
<div class="field">
<label>
<i
class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('eventTriggers')"
></i>
Notification Events
</label>
<div class="grid">
<div class="col-6">
<div class="field-checkbox">
<p-checkbox
formControlName="onFailedImportStrike"
[binary]="true"
inputId="on-failed-import-strike"
></p-checkbox>
<label for="on-failed-import-strike" class="ml-2">On Failed Import Strike</label>
</div>
</div>
<div class="col-6">
<div class="field-checkbox">
<p-checkbox formControlName="onStalledStrike" [binary]="true" inputId="on-stalled-strike"></p-checkbox>
<label for="on-stalled-strike" class="ml-2">On Stalled Strike</label>
</div>
</div>
<div class="col-6">
<div class="field-checkbox">
<p-checkbox formControlName="onSlowStrike" [binary]="true" inputId="on-slow-strike"></p-checkbox>
<label for="on-slow-strike" class="ml-2">On Slow Strike</label>
</div>
</div>
<div class="col-6">
<div class="field-checkbox">
<p-checkbox
formControlName="onQueueItemDeleted"
[binary]="true"
inputId="on-queue-item-deleted"
></p-checkbox>
<label for="on-queue-item-deleted" class="ml-2">On Queue Item Deleted</label>
</div>
</div>
<div class="col-6">
<div class="field-checkbox">
<p-checkbox formControlName="onDownloadCleaned" [binary]="true" inputId="on-download-cleaned"></p-checkbox>
<label for="on-download-cleaned" class="ml-2">On Download Cleaned</label>
</div>
</div>
<div class="col-6">
<div class="field-checkbox">
<p-checkbox formControlName="onCategoryChanged" [binary]="true" inputId="on-category-changed"></p-checkbox>
<label for="on-category-changed" class="ml-2">On Category Changed</label>
</div>
</div>
</div>
</div>
</form>
<!-- Modal Footer -->
<ng-template pTemplate="footer">
<div>
<button pButton type="button" label="Cancel" class="p-button-text" (click)="onCancel()"></button>
<button
pButton
type="button"
label="Test"
icon="pi pi-play"
class="p-button-outlined ml-2"
[disabled]="baseForm.invalid || testing"
[loading]="testing"
(click)="onTest()"
></button>
<button
pButton
type="button"
label="Save"
icon="pi pi-save"
class="p-button-primary ml-2"
[disabled]="baseForm.invalid || saving"
[loading]="saving"
(click)="onSave()"
></button>
</div>
</ng-template>
</p-dialog>

View File

@@ -0,0 +1,2 @@
/* Base Notification Provider Modal Styles */
@use '../../../styles/settings-shared.scss';

View File

@@ -0,0 +1,123 @@
import { Component, Input, Output, EventEmitter, OnInit, OnChanges, SimpleChanges, inject } from '@angular/core';
import { DocumentationService } from '../../../../core/services/documentation.service';
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
import { CommonModule } from '@angular/common';
import { DialogModule } from 'primeng/dialog';
import { InputTextModule } from 'primeng/inputtext';
import { CheckboxModule } from 'primeng/checkbox';
import { ButtonModule } from 'primeng/button';
import { BaseProviderFormData } from '../../models/provider-modal.model';
import { NotificationProviderDto } from '../../../../shared/models/notification-provider.model';
@Component({
selector: 'app-notification-provider-base',
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
DialogModule,
InputTextModule,
CheckboxModule,
ButtonModule,
],
templateUrl: './notification-provider-base.component.html',
styleUrls: ['./notification-provider-base.component.scss']
})
export class NotificationProviderBaseComponent implements OnInit, OnChanges {
@Input() visible = false;
@Input() modalTitle = 'Configure Notification Provider';
@Input() saving = false;
@Input() testing = false;
@Input() editingProvider: NotificationProviderDto | null = null;
@Output() save = new EventEmitter<BaseProviderFormData>();
@Output() cancel = new EventEmitter<void>();
@Output() test = new EventEmitter<BaseProviderFormData>();
protected readonly formBuilder = inject(FormBuilder);
private readonly documentationService = inject(DocumentationService);
baseForm: FormGroup = this.formBuilder.group({
name: ['', Validators.required],
enabled: [true],
onFailedImportStrike: [false],
onStalledStrike: [false],
onSlowStrike: [false],
onQueueItemDeleted: [false],
onDownloadCleaned: [false],
onCategoryChanged: [false]
});
ngOnInit() {
// Initialize form but don't populate yet - wait for ngOnChanges
}
ngOnChanges(changes: SimpleChanges) {
// Populate form when editingProvider input changes
if (changes['editingProvider']) {
if (this.editingProvider) {
this.populateForm();
} else {
// Reset form when editingProvider is cleared
this.resetForm();
}
}
}
protected populateForm() {
if (this.editingProvider) {
this.baseForm.patchValue({
name: this.editingProvider.name,
enabled: this.editingProvider.isEnabled,
onFailedImportStrike: this.editingProvider.events?.onFailedImportStrike || false,
onStalledStrike: this.editingProvider.events?.onStalledStrike || false,
onSlowStrike: this.editingProvider.events?.onSlowStrike || false,
onQueueItemDeleted: this.editingProvider.events?.onQueueItemDeleted || false,
onDownloadCleaned: this.editingProvider.events?.onDownloadCleaned || false,
onCategoryChanged: this.editingProvider.events?.onCategoryChanged || false
});
}
}
protected resetForm() {
this.baseForm.reset({
name: '',
enabled: true,
onFailedImportStrike: false,
onStalledStrike: false,
onSlowStrike: false,
onQueueItemDeleted: false,
onDownloadCleaned: false,
onCategoryChanged: false
});
}
protected hasError(fieldName: string, errorType: string): boolean {
const field = this.baseForm.get(fieldName);
return !!(field && field.errors?.[errorType] && (field.dirty || field.touched));
}
onSave() {
if (this.baseForm.valid) {
this.save.emit(this.baseForm.value as BaseProviderFormData);
}
}
onCancel() {
this.cancel.emit();
}
onTest() {
if (this.baseForm.valid) {
this.test.emit(this.baseForm.value as BaseProviderFormData);
}
}
/**
* Open notifications documentation for a specific field (or the section when no field provided)
*/
openFieldDocs(fieldName?: string): void {
// pass empty string when undefined so the service falls back to section doc
this.documentationService.openFieldDocumentation('notifications', fieldName ?? '');
}
}

View File

@@ -0,0 +1,53 @@
<app-notification-provider-base
[visible]="visible"
modalTitle="Configure Notifiarr Provider"
[saving]="saving"
[testing]="testing"
[editingProvider]="editingProvider"
(save)="onSave($event)"
(cancel)="onCancel()"
(test)="onTest($event)">
<!-- Provider-specific configuration goes here -->
<div slot="provider-config">
<!-- API Key Field -->
<div class="field">
<label for="api-key">
<i class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('notifiarr.apiKey')"></i>
API Key *
</label>
<input
id="api-key"
type="password"
pInputText
[formControl]="apiKeyControl"
placeholder="Enter your Notifiarr API key"
class="w-full" />
<small *ngIf="hasFieldError(apiKeyControl, 'required')" class="p-error">API Key is required</small>
<small *ngIf="hasFieldError(apiKeyControl, 'minlength')" class="p-error">API Key must be at least 10 characters</small>
<small class="form-helper-text">Your Notifiarr API key from your dashboard. Requires Passthrough integration.</small>
</div>
<!-- Channel ID Field -->
<div class="field">
<label for="channel-id">
<i class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('notifiarr.channelId')"></i>
Discord Channel ID *
</label>
<input
id="channel-id"
type="text"
pInputText
numericInput
[formControl]="channelIdControl"
placeholder="Enter Discord channel ID"
class="w-full" />
<small *ngIf="hasFieldError(channelIdControl, 'required')" class="p-error">Channel ID is required</small>
<small class="form-helper-text">The Discord channel ID where notifications will be sent.</small>
</div>
</div>
</app-notification-provider-base>

View File

@@ -0,0 +1,2 @@
/* Notifiarr Provider Modal Styles */
@use '../../../styles/settings-shared.scss';

View File

@@ -0,0 +1,111 @@
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 { NotifiarrFormData, 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 { NumericInputDirective } from '../../../../shared/directives';
@Component({
selector: 'app-notifiarr-provider',
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
InputTextModule,
NotificationProviderBaseComponent,
NumericInputDirective
],
templateUrl: './notifiarr-provider.component.html',
styleUrls: ['./notifiarr-provider.component.scss']
})
export class NotifiarrProviderComponent implements OnInit, OnChanges {
@Input() visible = false;
@Input() editingProvider: NotificationProviderDto | null = null;
@Input() saving = false;
@Input() testing = false;
@Output() save = new EventEmitter<NotifiarrFormData>();
@Output() cancel = new EventEmitter<void>();
@Output() test = new EventEmitter<NotifiarrFormData>();
// Provider-specific form controls
apiKeyControl = new FormControl('', [Validators.required, Validators.minLength(10)]);
channelIdControl = new FormControl('', [Validators.required]);
private documentationService = inject(DocumentationService);
/** Exposed for template to open documentation for notifiarr fields */
openFieldDocs(fieldName: string): void {
this.documentationService.openFieldDocumentation('notifications', fieldName);
}
ngOnInit(): void {
// Initialize component but don't populate yet - wait for ngOnChanges
}
ngOnChanges(changes: SimpleChanges): void {
// Populate provider-specific fields when editingProvider input changes
if (changes['editingProvider']) {
if (this.editingProvider) {
this.populateProviderFields();
} else {
// Reset fields when editingProvider is cleared
this.resetProviderFields();
}
}
}
private populateProviderFields(): void {
if (this.editingProvider) {
const config = this.editingProvider.configuration as any;
this.apiKeyControl.setValue(config?.apiKey || '');
this.channelIdControl.setValue(config?.channelId || '');
}
}
private resetProviderFields(): void {
this.apiKeyControl.setValue('');
this.channelIdControl.setValue('');
}
protected hasFieldError(control: FormControl, errorType: string): boolean {
return !!(control && control.errors?.[errorType] && (control.dirty || control.touched));
}
onSave(baseData: BaseProviderFormData): void {
if (this.apiKeyControl.valid && this.channelIdControl.valid) {
const notifiarrData: NotifiarrFormData = {
...baseData,
apiKey: this.apiKeyControl.value || '',
channelId: this.channelIdControl.value || ''
};
this.save.emit(notifiarrData);
} else {
// Mark provider-specific fields as touched to show validation errors
this.apiKeyControl.markAsTouched();
this.channelIdControl.markAsTouched();
}
}
onCancel(): void {
this.cancel.emit();
}
onTest(baseData: BaseProviderFormData): void {
if (this.apiKeyControl.valid && this.channelIdControl.valid) {
const notifiarrData: NotifiarrFormData = {
...baseData,
apiKey: this.apiKeyControl.value || '',
channelId: this.channelIdControl.value || ''
};
this.test.emit(notifiarrData);
} else {
// Mark provider-specific fields as touched to show validation errors
this.apiKeyControl.markAsTouched();
this.channelIdControl.markAsTouched();
}
}
}

View File

@@ -0,0 +1,153 @@
.provider-selection-modal {
.p-dialog {
width: 90vw;
max-width: 600px;
}
}
.provider-selection-content {
padding: 1rem;
}
.selection-description {
text-align: center;
color: var(--text-color-secondary);
margin-bottom: 2rem;
font-size: 1rem;
}
.provider-selection-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
padding: 1rem 0;
}
.provider-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem 1.5rem;
border: 2px solid var(--surface-border);
border-radius: 12px;
cursor: pointer;
transition: all 0.3s ease;
background: var(--surface-card);
text-align: center;
position: relative;
overflow: hidden;
&:hover {
border-color: var(--primary-color);
background: var(--surface-hover);
transform: translateY(-4px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
.provider-icon {
transform: scale(1.1);
color: var(--primary-color);
}
}
&:active {
transform: translateY(-2px);
}
// Add subtle background pattern
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(45deg, transparent 30%, rgba(var(--primary-color-rgb), 0.02) 50%, transparent 70%);
pointer-events: none;
}
}
.provider-icon {
font-size: 3rem;
color: var(--text-color-secondary);
margin-bottom: 1rem;
transition: all 0.3s ease;
i {
display: block;
}
}
.provider-name {
font-weight: 700;
font-size: 1.25rem;
color: var(--text-color);
margin-bottom: 0.5rem;
letter-spacing: 0.025em;
}
.provider-description {
font-size: 0.875rem;
color: var(--text-color-secondary);
line-height: 1.4;
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;
}
.provider-card {
padding: 1.5rem 1rem;
}
.provider-icon {
font-size: 2.5rem;
}
}
// Accessibility improvements
.provider-card {
&:focus {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
&:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
}
// Animation for card entrance
.provider-card {
animation: fadeInUp 0.4s ease-out;
animation-fill-mode: both;
&: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; }
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -0,0 +1,96 @@
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DialogModule } from 'primeng/dialog';
import { ButtonModule } from 'primeng/button';
import { NotificationProviderType } from '../../../../shared/models/enums';
import { ProviderTypeInfo } from '../../models/provider-modal.model';
@Component({
selector: 'app-provider-type-selection',
standalone: true,
imports: [
CommonModule,
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>
`,
styleUrls: ['./provider-type-selection.component.scss']
})
export class ProviderTypeSelectionComponent {
@Input() visible = false;
@Output() providerSelected = new EventEmitter<NotificationProviderType>();
@Output() cancel = new EventEmitter<void>();
// 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'
}
// Add more providers as they are implemented
];
selectProvider(type: NotificationProviderType) {
this.providerSelected.emit(type);
}
onCancel() {
this.cancel.emit();
}
}

View File

@@ -0,0 +1,43 @@
import { NotificationProviderType } from '../../../shared/models/enums';
export interface ProviderTypeInfo {
type: NotificationProviderType;
name: string;
iconClass: string;
description?: string;
}
export interface ProviderModalConfig {
visible: boolean;
mode: 'add' | 'edit';
providerId?: string;
}
export interface BaseProviderFormData {
name: string;
enabled: boolean;
onFailedImportStrike: boolean;
onStalledStrike: boolean;
onSlowStrike: boolean;
onQueueItemDeleted: boolean;
onDownloadCleaned: boolean;
onCategoryChanged: boolean;
}
export interface NotifiarrFormData extends BaseProviderFormData {
apiKey: string;
channelId: string;
}
export interface AppriseFormData extends BaseProviderFormData {
url: string;
key: string;
tags: string;
}
// Events for modal communication
export interface ProviderModalEvents {
save: (data: any) => void;
cancel: () => void;
test: (data: any) => void;
}

View File

@@ -1,226 +1,180 @@
<div class="settings-container">
<div class="flex align-items-center justify-content-between mb-4">
<h1>Notifications</h1>
<h1>Notifications Configuration</h1>
</div>
<p-card styleClass="settings-card h-full">
<ng-template pTemplate="header">
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
<div class="header-title-container">
<h2 class="card-title m-0">Notification Configuration</h2>
<span class="card-subtitle">Configure notification settings for Notifiarr and Apprise</span>
<h2 class="card-title m-0">Notification Providers</h2>
<span class="card-subtitle">Configure notification providers to receive alerts</span>
</div>
</div>
</ng-template>
<div class="card-content">
<!-- Loading/Error Component -->
<app-loading-error-state
*ngIf="notificationLoading() || notificationError()"
[loading]="notificationLoading()"
[error]="notificationError()"
loadingMessage="Loading notification settings..."
*ngIf="notificationProviderLoading() || notificationProviderLoadError()"
[loading]="notificationProviderLoading()"
[error]="notificationProviderLoadError()"
loadingMessage="Loading notification providers..."
errorMessage="Could not connect to server"
></app-loading-error-state>
<!-- Settings Form -->
<form *ngIf="!notificationLoading() && !notificationError()" [formGroup]="notificationForm" class="p-fluid">
<!-- Notifiarr Configuration Section -->
<div class="mb-4">
<h3 class="section-title">Notifiarr Configuration</h3>
<div formGroupName="notifiarr">
<!-- API Key -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('notifiarr.apiKey')"
title="Click for documentation">
</i>
API Key
</label>
<div class="field-input">
<input type="text" pInputText formControlName="apiKey" inputId="notifiarrApiKey" placeholder="Enter Notifiarr API key" />
<small class="form-helper-text">Passthrough integration must be enabled and a Passthrough key needs to be created in your profile</small>
</div>
</div>
<!-- Channel ID -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('notifiarr.channelId')"
title="Click for documentation">
</i>
Channel ID
</label>
<div class="field-input">
<input
type="text"
pInputText
formControlName="channelId"
placeholder="Enter Discord channel ID"
numericInput
/>
<small class="form-helper-text">The Discord channel ID where notifications will be sent</small>
</div>
</div>
<!-- Event Triggers -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('eventTriggers')"
title="Click for documentation">
</i>
Event Triggers
</label>
<div class="field-input">
<div class="flex flex-column gap-2">
<div class="flex align-items-center">
<p-checkbox formControlName="onFailedImportStrike" [binary]="true" inputId="notifiarrFailedImport"></p-checkbox>
<label for="notifiarrFailedImport" class="ml-2">Failed Import Strike</label>
<!-- Providers List -->
<div *ngIf="!notificationProviderLoading() && !notificationProviderLoadError()" class="providers-list">
<div
*ngFor="let provider of notificationProviderStore.notificationProviders()"
class="provider-item p-3 border-round surface-border border-1 mb-3"
[ngClass]="{ 'enabled': provider.isEnabled }"
>
<div class="flex justify-content-between align-items-center">
<div class="provider-info flex-1">
<div>
<div class="flex align-items-center mb-2">
<h4 class="m-0 mr-2">{{ provider.name }}</h4>
<p-tag
[value]="provider.type | titlecase"
[severity]="'info'"
class="mr-2"
></p-tag>
<p-tag
[value]="provider.isEnabled ? 'Enabled' : 'Disabled'"
[severity]="provider.isEnabled ? 'success' : 'secondary'"
></p-tag>
</div>
<div class="flex align-items-center">
<p-checkbox formControlName="onStalledStrike" [binary]="true" inputId="notifiarrStalled"></p-checkbox>
<label for="notifiarrStalled" class="ml-2">Stalled Strike</label>
</div>
<div class="flex align-items-center">
<p-checkbox formControlName="onSlowStrike" [binary]="true" inputId="notifiarrSlow"></p-checkbox>
<label for="notifiarrSlow" class="ml-2">Slow Strike</label>
</div>
<div class="flex align-items-center">
<p-checkbox formControlName="onQueueItemDeleted" [binary]="true" inputId="notifiarrDeleted"></p-checkbox>
<label for="notifiarrDeleted" class="ml-2">Queue Item Deleted</label>
</div>
<div class="flex align-items-center">
<p-checkbox formControlName="onDownloadCleaned" [binary]="true" inputId="notifiarrCleaned"></p-checkbox>
<label for="notifiarrCleaned" class="ml-2">Download Cleaned</label>
</div>
<div class="flex align-items-center">
<p-checkbox formControlName="onCategoryChanged" [binary]="true" inputId="notifiarrCategory"></p-checkbox>
<label for="notifiarrCategory" class="ml-2">Category Changed</label>
<div class="provider-events text-sm text-color-secondary">
<span *ngIf="provider.events.onFailedImportStrike" class="mr-2">❌ Failed Import</span>
<span *ngIf="provider.events.onStalledStrike" class="mr-2">⏸️ Stalled</span>
<span *ngIf="provider.events.onSlowStrike" class="mr-2">🐌 Slow</span>
<span *ngIf="provider.events.onQueueItemDeleted" class="mr-2">🗑️ Queue Deleted</span>
<span *ngIf="provider.events.onDownloadCleaned" class="mr-2">🧹 Download Cleaned</span>
<span *ngIf="provider.events.onCategoryChanged" class="mr-2">🏷️ Category Changed</span>
</div>
</div>
<small class="form-helper-text">Select which events should trigger Notifiarr notifications</small>
</div>
</div>
<div class="provider-actions">
<button
pButton
type="button"
icon="pi pi-play"
class="p-button-rounded p-button-text p-button-sm mr-1"
pTooltip="Test notification"
tooltipPosition="left"
[loading]="testing()"
(click)="testProvider(provider)"
></button>
<button
pButton
type="button"
icon="pi pi-pencil"
class="p-button-rounded p-button-text p-button-sm mr-1"
pTooltip="Edit provider"
tooltipPosition="left"
[disabled]="saving()"
(click)="openEditProviderModal(provider)"
></button>
<button
pButton
type="button"
icon="pi pi-trash"
class="p-button-rounded p-button-text p-button-danger p-button-sm"
pTooltip="Delete provider"
tooltipPosition="left"
[disabled]="saving()"
(click)="deleteProvider(provider)"
></button>
</div>
</div>
</div>
<!-- Apprise Configuration Section -->
<div class="mb-4">
<h3 class="section-title">Apprise Configuration</h3>
<div formGroupName="apprise">
<!-- URL -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('apprise.fullUrl')"
title="Click for documentation">
</i>
URL
</label>
<div class="field-input">
<input type="text" pInputText formControlName="fullUrl" inputId="appriseUrl" placeholder="Enter Apprise URL" />
<small class="form-helper-text">The Apprise server URL</small>
</div>
</div>
<!-- Key -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('apprise.key')"
title="Click for documentation">
</i>
Key
</label>
<div class="field-input">
<input type="text" pInputText formControlName="key" inputId="appriseKey" placeholder="Enter key" />
<small class="form-helper-text">The key of your Apprise config</small>
</div>
</div>
<div
*ngIf="notificationProviderStore.notificationProviders().length === 0"
class="no-providers text-center py-5 text-color-secondary"
>
<i class="pi pi-bell text-4xl mb-3"></i>
<p class="m-0">No notification providers configured</p>
<small>Add a provider to start receiving notifications</small>
</div>
</div>
<!-- Tags -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('apprise.tags')"
title="Click for documentation">
</i>
Tags
</label>
<div class="field-input">
<input type="text" pInputText formControlName="tags" inputId="appriseTags" placeholder="Enter tags (comma or space separated)" />
<small class="form-helper-text">Optionally notify only those tagged accordingly. Use a comma (,) to OR your tags and a space ( ) to AND them.</small>
</div>
</div>
<!-- Event Triggers -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('eventTriggers')"
title="Click for documentation">
</i>
Event Triggers
</label>
<div class="field-input">
<div class="flex flex-column gap-2">
<div class="flex align-items-center">
<p-checkbox formControlName="onFailedImportStrike" [binary]="true" inputId="appriseFailedImport"></p-checkbox>
<label for="appriseFailedImport" class="ml-2">Failed Import Strike</label>
</div>
<div class="flex align-items-center">
<p-checkbox formControlName="onStalledStrike" [binary]="true" inputId="appriseStalled"></p-checkbox>
<label for="appriseStalled" class="ml-2">Stalled Strike</label>
</div>
<div class="flex align-items-center">
<p-checkbox formControlName="onSlowStrike" [binary]="true" inputId="appriseSlow"></p-checkbox>
<label for="appriseSlow" class="ml-2">Slow Strike</label>
</div>
<div class="flex align-items-center">
<p-checkbox formControlName="onQueueItemDeleted" [binary]="true" inputId="appriseDeleted"></p-checkbox>
<label for="appriseDeleted" class="ml-2">Queue Item Deleted</label>
</div>
<div class="flex align-items-center">
<p-checkbox formControlName="onDownloadCleaned" [binary]="true" inputId="appriseCleaned"></p-checkbox>
<label for="appriseCleaned" class="ml-2">Download Cleaned</label>
</div>
<div class="flex align-items-center">
<p-checkbox formControlName="onCategoryChanged" [binary]="true" inputId="appriseCategory"></p-checkbox>
<label for="appriseCategory" class="ml-2">Category Changed</label>
</div>
</div>
<small class="form-helper-text">Select which events should trigger Apprise notifications</small>
</div>
</div>
</div>
</div>
<!-- Action buttons -->
<div class="card-footer mt-3">
<button
pButton
type="button"
label="Save"
icon="pi pi-save"
class="p-button-primary"
[disabled]="(!notificationForm.dirty || !hasActualChanges) || notificationForm.invalid || notificationSaving()"
[loading]="notificationSaving()"
(click)="saveNotificationConfig()"
></button>
<button
pButton
type="button"
label="Reset"
icon="pi pi-refresh"
class="p-button-secondary p-button-outlined ml-2"
(click)="resetNotificationConfig()"
></button>
</div>
</form>
<!-- Action buttons -->
<div class="card-footer mt-3">
<button
pButton
type="button"
icon="pi pi-plus"
label="Add Provider"
class="p-button-primary"
[disabled]="saving()"
(click)="openAddProviderModal()"
></button>
</div>
</div>
</p-card>
</div>
</p-card>
</div>
<!-- Legacy Provider Modal (for unsupported provider types) -->
<p-dialog
[(visible)]="showProviderModal"
[modal]="true"
[closable]="true"
[draggable]="false"
[resizable]="false"
styleClass="instance-modal"
[header]="modalTitle"
(onHide)="closeProviderModal()"
>
<div class="text-center py-4">
<i class="pi pi-info-circle text-4xl mb-3 text-color-secondary"></i>
<h3>Provider Type Not Yet Supported</h3>
<p class="text-color-secondary">
This provider type is not yet supported by the new modal system.
<br>Please select Notifiarr or Apprise for now.
</p>
<button
pButton
type="button"
label="Close"
class="p-button-text mt-3"
(click)="closeProviderModal()"
></button>
</div>
</p-dialog>
<!-- Provider Type Selection Modal -->
<app-provider-type-selection
[visible]="showTypeSelectionModal"
(providerSelected)="onProviderTypeSelected($event)"
(cancel)="onTypeSelectionCancel()"
></app-provider-type-selection>
<!-- Notifiarr Provider Modal -->
<app-notifiarr-provider
[visible]="showNotifiarrModal"
[editingProvider]="editingProvider"
[saving]="saving()"
[testing]="testing()"
(save)="onNotifiarrSave($event)"
(cancel)="onProviderCancel()"
(test)="onNotifiarrTest($event)"
></app-notifiarr-provider>
<!-- Apprise Provider Modal -->
<app-apprise-provider
[visible]="showAppriseModal"
[editingProvider]="editingProvider"
[saving]="saving()"
[testing]="testing()"
(save)="onAppriseSave($event)"
(cancel)="onProviderCancel()"
(test)="onAppriseTest($event)"
></app-apprise-provider>
<!-- Confirmation Dialog -->
<p-confirmDialog></p-confirmDialog>
<!-- Toast for notifications -->
<p-toast></p-toast>

View File

@@ -9,4 +9,115 @@
color: var(--primary-color);
border-bottom: 1px solid var(--surface-border);
padding-bottom: 0.5rem;
}
}
/* Providers list modern styling */
.providers-list {
display: block; /* one provider per line */
gap: 1rem;
}
.provider-item {
background: linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0.01));
border: 1px solid rgba(255,255,255,0.04);
border-radius: 12px;
padding: 1rem;
transition: transform 180ms ease, box-shadow 180ms ease, border-color 180ms ease;
position: relative;
overflow: hidden;
/* Hint to the browser that we'll be animating transform - improves compositing performance */
will-change: transform;
/* subtle soft elevation */
box-shadow: 0 2px 8px rgba(16,24,40,0.04);
&:hover {
transform: translateY(-6px);
/* Reduce shadow complexity to lower paint cost while keeping visual depth */
box-shadow: 0 10px 28px rgba(16,24,40,0.10), 0 0 18px rgba(0,172,255,0.14);
border-color: rgba(0,172,255,0.16);
}
/* enabled accent stripe */
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background: linear-gradient(180deg, var(--primary-color), #4ab0ff);
opacity: 0; /* shown only when enabled */
transition: opacity 180ms ease;
}
&.enabled::before {
opacity: 1;
}
/* neon glow when enabled */
&.enabled {
/* Slightly lighter neon glow to reduce continuous paint cost when multiple items are enabled */
box-shadow: 0 10px 30px rgba(6,20,46,0.12), 0 0 24px rgba(0,172,255,0.16);
transform: translateY(-2px);
}
.provider-info {
padding-right: 0.75rem;
display: flex;
align-items: center;
gap: 0.75rem;
}
.provider-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.provider-events {
margin-top: 0.35rem;
color: var(--text-color-secondary);
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
span {
background: rgba(0,0,0,0.03);
padding: 0.15rem 0.5rem;
border-radius: 6px;
font-size: 0.85rem;
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
}
/* small tag style override to match new look */
p-tag {
border-radius: 999px;
padding: 0.25rem 0.6rem;
font-weight: 600;
font-size: 0.8rem;
text-transform: none;
}
}
.no-providers {
background: transparent;
border: 1px dashed rgba(255,255,255,0.03);
border-radius: 8px;
padding: 2rem;
}
/* make testing spinner visible on the action button without shifting layout */
.p-button .pi {
vertical-align: middle;
}
/* Small responsive tweaks */
@media (max-width: 479px) {
.provider-actions {
gap: 0.25rem;
}
}

View File

@@ -1,39 +1,56 @@
import { Component, EventEmitter, OnDestroy, Output, effect, inject } from "@angular/core";
import { Component, EventEmitter, OnDestroy, Output, effect, inject, computed } from "@angular/core";
import { CommonModule } from "@angular/common";
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
import { Subject, takeUntil } from "rxjs";
import { NotificationConfigStore } from "./notification-config.store";
import { NotificationProviderConfigStore } from "../notification-provider/notification-provider-config.store";
import { CanComponentDeactivate } from "../../core/guards";
import { NotificationsConfig } from "../../shared/models/notifications-config.model";
import { NumericInputDirective } from "../../shared/directives";
import {
NotificationProviderDto
} 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 { 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";
// PrimeNG Components
import { CardModule } from "primeng/card";
import { InputTextModule } from "primeng/inputtext";
import { InputNumberModule } from "primeng/inputnumber";
import { CheckboxModule } from "primeng/checkbox";
import { ButtonModule } from "primeng/button";
import { SelectModule } from 'primeng/select';
import { ToastModule } from "primeng/toast";
import { NotificationService } from '../../core/services/notification.service';
import { DocumentationService } from '../../core/services/documentation.service';
import { LoadingErrorStateComponent } from "../../shared/components/loading-error-state/loading-error-state.component";
import { DialogModule } from "primeng/dialog";
import { ConfirmDialogModule } from "primeng/confirmdialog";
import { TagModule } from "primeng/tag";
import { TooltipModule } from "primeng/tooltip";
import { ConfirmationService, MessageService } from "primeng/api";
import { NotificationService } from "../../core/services/notification.service";
@Component({
selector: "app-notification-settings",
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
CardModule,
InputTextModule,
InputNumberModule,
CheckboxModule,
ButtonModule,
SelectModule,
ToastModule,
DialogModule,
ConfirmDialogModule,
TagModule,
TooltipModule,
LoadingErrorStateComponent,
NumericInputDirective,
ProviderTypeSelectionComponent,
NotifiarrProviderComponent,
AppriseProviderComponent,
],
providers: [NotificationConfigStore],
providers: [NotificationProviderConfigStore, ConfirmationService, MessageService],
templateUrl: "./notification-settings.component.html",
styleUrls: ["./notification-settings.component.scss"],
})
@@ -41,114 +58,82 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
@Output() saved = new EventEmitter<void>();
@Output() error = new EventEmitter<string>();
// Notification Configuration Form
notificationForm: FormGroup;
// Original form values for tracking changes
private originalFormValues: any;
// Track whether the form has actual changes compared to original values
hasActualChanges = false;
// Modal state
showProviderModal = false; // Legacy modal for unsupported types
showTypeSelectionModal = false; // New: Provider type selection modal
showNotifiarrModal = false; // New: Notifiarr provider modal
showAppriseModal = false; // New: Apprise provider modal
modalMode: 'add' | 'edit' = 'add';
editingProvider: NotificationProviderDto | null = null;
// Inject the necessary services
private formBuilder = inject(FormBuilder);
private notificationService = inject(NotificationService);
private documentationService = inject(DocumentationService);
private notificationConfigStore = inject(NotificationConfigStore);
get isEditing(): boolean {
return this.modalMode === 'edit';
}
// Signals from the store
readonly notificationConfig = this.notificationConfigStore.config;
readonly notificationLoading = this.notificationConfigStore.loading;
readonly notificationSaving = this.notificationConfigStore.saving;
readonly notificationError = this.notificationConfigStore.error;
// Subject for unsubscribing from observables when component is destroyed
// Clean up subscriptions
private destroy$ = new Subject<void>();
// Services
private notificationService = inject(NotificationService);
private confirmationService = inject(ConfirmationService);
private messageService = inject(MessageService);
public readonly notificationProviderStore = inject(NotificationProviderConfigStore);
private documentationService = inject(DocumentationService);
// Signals from store
notificationProviderConfig = this.notificationProviderStore.config();
notificationProviderLoading = this.notificationProviderStore.loading;
notificationProviderLoadError = this.notificationProviderStore.loadError; // Only for "Not connected" state
notificationProviderSaveError = this.notificationProviderStore.saveError; // Only for toast notifications
notificationProviderTestError = this.notificationProviderStore.testError; // Only for toast notifications
notificationProviderSaving = this.notificationProviderStore.saving;
notificationProviderTesting = this.notificationProviderStore.testing;
testResult = this.notificationProviderStore.testResult;
saving = computed(() => this.notificationProviderSaving());
testing = computed(() => this.notificationProviderTesting());
/**
* Check if component can be deactivated (navigation guard)
*/
canDeactivate(): boolean {
return !this.notificationForm.dirty;
return true; // No unsaved changes in modal-based approach
}
constructor() {
// Initialize the notification settings form
this.notificationForm = this.formBuilder.group({
// Notifiarr configuration
notifiarr: this.formBuilder.group({
apiKey: [''],
channelId: [''],
onFailedImportStrike: [false],
onStalledStrike: [false],
onSlowStrike: [false],
onQueueItemDeleted: [false],
onDownloadCleaned: [false],
onCategoryChanged: [false],
}),
// Apprise configuration
apprise: this.formBuilder.group({
fullUrl: [''],
key: [''],
tags: [''],
onFailedImportStrike: [false],
onStalledStrike: [false],
onSlowStrike: [false],
onQueueItemDeleted: [false],
onDownloadCleaned: [false],
onCategoryChanged: [false],
}),
});
// Store will auto-load data via onInit hook
// Setup effect to react to config changes
// Effect to handle load errors - emit to LoadingErrorStateComponent for "Not connected" display
effect(() => {
const config = this.notificationConfig();
if (config) {
// Map the server response to form values
const formValue = {
notifiarr: config.notifiarr || {
apiKey: '',
channelId: '',
onFailedImportStrike: false,
onStalledStrike: false,
onSlowStrike: false,
onQueueItemDeleted: false,
onDownloadCleaned: false,
onCategoryChanged: false,
},
apprise: config.apprise || {
fullUrl: '',
key: '',
tags: '',
onFailedImportStrike: false,
onStalledStrike: false,
onSlowStrike: false,
onQueueItemDeleted: false,
onDownloadCleaned: false,
onCategoryChanged: false,
},
};
const loadErrorMessage = this.notificationProviderLoadError();
if (loadErrorMessage) {
// Emit to parent component which will show LoadingErrorStateComponent
this.error.emit(loadErrorMessage);
}
});
// Effect: show test errors as toast
effect(() => {
const testErrorMessage = this.notificationProviderTestError();
if (testErrorMessage) {
// Test errors should always be shown as toast notifications
this.notificationService.showError(testErrorMessage);
this.notificationForm.patchValue(formValue);
this.storeOriginalValues();
this.notificationForm.markAsPristine();
this.hasActualChanges = false;
// Clear the error after handling
this.notificationProviderStore.resetTestError();
}
});
// Track form changes for dirty state
this.notificationForm.valueChanges
.pipe(takeUntil(this.destroy$))
.subscribe(() => {
this.hasActualChanges = this.formValuesChanged();
});
// Setup effect to react to error changes
// Setup effect to react to test results
effect(() => {
const errorMessage = this.notificationError();
if (errorMessage) {
// Only emit the error for parent components
this.error.emit(errorMessage);
const result = this.testResult();
if (result) {
if (result.success) {
this.notificationService.showSuccess(result.message || "Test notification sent successfully");
} else {
// Error handling is already done in the test error effect above
// This just handles the success case
}
}
});
}
@@ -162,180 +147,384 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
}
/**
* Check if the current form values are different from the original values
* Open modal to add new provider - starts with type selection
*/
private formValuesChanged(): boolean {
return !this.isEqual(this.notificationForm.value, this.originalFormValues);
openAddProviderModal(): void {
this.modalMode = "add";
this.editingProvider = null;
this.showTypeSelectionModal = true; // New: Show type selection first
}
/**
* Deep compare two objects for equality
* Open modal to edit existing provider
*/
private isEqual(obj1: any, obj2: any): boolean {
if (obj1 === obj2) return true;
if (obj1 === null || obj2 === null) return false;
if (obj1 === undefined || obj2 === undefined) return false;
if (typeof obj1 !== 'object' && typeof obj2 !== 'object') {
return obj1 === obj2;
openEditProviderModal(provider: NotificationProviderDto): void {
// Close all modals first to ensure clean state
this.closeAllModals();
this.modalMode = "edit";
this.editingProvider = provider;
// Open the appropriate provider-specific modal based on type
switch (provider.type) {
case NotificationProviderType.Notifiarr:
this.showNotifiarrModal = true;
break;
case NotificationProviderType.Apprise:
this.showAppriseModal = true;
break;
default:
// For unsupported types, show the legacy modal with info message
this.showProviderModal = true;
break;
}
if (Array.isArray(obj1) && Array.isArray(obj2)) {
if (obj1.length !== obj2.length) return false;
for (let i = 0; i < obj1.length; i++) {
if (!this.isEqual(obj1[i], obj2[i])) return false;
}
return true;
}
const keys1 = Object.keys(obj1);
const keys2 = Object.keys(obj2);
if (keys1.length !== keys2.length) return false;
for (const key of keys1) {
if (!this.isEqual(obj1[key], obj2[key])) return false;
}
return true;
}
/**
* Store original form values for dirty checking
* Close provider modal
*/
private storeOriginalValues(): void {
this.originalFormValues = JSON.parse(JSON.stringify(this.notificationForm.value));
closeProviderModal(): void {
this.showProviderModal = false;
this.editingProvider = null;
this.notificationProviderStore.clearTestResult();
}
/**
* Save the notification configuration
* Handle provider type selection from type selection modal
*/
saveNotificationConfig(): void {
if (this.notificationForm.invalid) {
this.markFormGroupTouched(this.notificationForm);
this.notificationService.showValidationError();
return;
}
if (!this.hasActualChanges) {
this.notificationService.showSuccess('No changes detected');
return;
}
const formValues = this.notificationForm.value;
const config: NotificationsConfig = {
notifiarr: {
...formValues.notifiarr,
channelId: formValues.notifiarr.channelId ? formValues.notifiarr.channelId.toString() : null,
},
apprise: formValues.apprise,
};
// Save the configuration
this.notificationConfigStore.saveConfig(config);
// Setup a one-time check to mark form as pristine after successful save
const checkSaveCompletion = () => {
const loading = this.notificationSaving();
const error = this.notificationError();
if (!loading && !error) {
// Mark form as pristine after successful save
this.notificationForm.markAsPristine();
this.hasActualChanges = false;
// Emit saved event
this.saved.emit();
// Show success message
this.notificationService.showSuccess('Notification configuration saved successfully!');
} else if (!loading && error) {
// If there's an error, we can stop checking
} else {
// If still loading, check again in a moment
setTimeout(checkSaveCompletion, 100);
}
};
// Start checking for save completion
checkSaveCompletion();
onProviderTypeSelected(type: NotificationProviderType): void {
this.showTypeSelectionModal = false;
this.openProviderSpecificModal(type);
}
/**
* Reset the notification configuration form to default values
* Handle type selection modal cancel
*/
resetNotificationConfig(): void {
this.notificationForm.reset({
notifiarr: {
apiKey: '',
channelId: '',
onFailedImportStrike: false,
onStalledStrike: false,
onSlowStrike: false,
onQueueItemDeleted: false,
onDownloadCleaned: false,
onCategoryChanged: false,
},
apprise: {
fullUrl: '',
key: '',
tags: '',
onFailedImportStrike: false,
onStalledStrike: false,
onSlowStrike: false,
onQueueItemDeleted: false,
onDownloadCleaned: false,
onCategoryChanged: false,
onTypeSelectionCancel(): void {
this.showTypeSelectionModal = false;
}
/**
* Open provider-specific modal based on type
*/
private openProviderSpecificModal(type: NotificationProviderType): void {
// Reset editing state for new provider
this.editingProvider = null;
this.modalMode = "add";
// Open the appropriate provider-specific modal
switch (type) {
case NotificationProviderType.Notifiarr:
this.showNotifiarrModal = true;
break;
case NotificationProviderType.Apprise:
this.showAppriseModal = true;
break;
default:
// For unsupported types, show the legacy modal with info message
this.showProviderModal = true;
break;
}
}
/**
* Delete provider with confirmation
*/
deleteProvider(provider: NotificationProviderDto): void {
this.confirmationService.confirm({
message: `Are you sure you want to delete the provider "${provider.name}"?`,
header: "Confirm Deletion",
icon: "pi pi-exclamation-triangle",
acceptButtonStyleClass: "p-button-danger",
accept: () => {
this.notificationProviderStore.deleteProvider(provider.id);
// Reuse monitor for success/error handling
this.monitorProviderOperation('deleted');
},
});
// Check if this reset actually changes anything compared to the original state
const hasChangesAfterReset = this.formValuesChanged();
if (hasChangesAfterReset) {
// Only mark as dirty if the reset actually changes something
this.notificationForm.markAsDirty();
this.hasActualChanges = true;
} else {
// If reset brings us back to original state, mark as pristine
this.notificationForm.markAsPristine();
this.hasActualChanges = false;
}
}
/**
* Mark all controls in a form group as touched
* Test notification provider
*/
private markFormGroupTouched(formGroup: FormGroup): void {
Object.values(formGroup.controls).forEach((control) => {
control.markAsTouched();
testProvider(provider: NotificationProviderDto): void {
// Build test request based on provider type
let testRequest: any;
if ((control as any).controls) {
this.markFormGroupTouched(control as FormGroup);
}
switch (provider.type) {
case NotificationProviderType.Notifiarr:
const notifiarrConfig = provider.configuration as any;
testRequest = {
apiKey: notifiarrConfig.apiKey,
channelId: notifiarrConfig.channelId,
};
break;
case NotificationProviderType.Apprise:
const appriseConfig = provider.configuration as any;
testRequest = {
url: appriseConfig.url,
key: appriseConfig.key,
tags: appriseConfig.tags || "",
};
break;
default:
this.notificationService.showError("Testing not supported for this provider type");
return;
}
this.notificationProviderStore.testProvider({
testRequest,
type: provider.type,
});
}
/**
* Check if a form control has an error after it's been touched
* Test notification provider from modal
*/
hasError(controlName: string, errorName: string): boolean {
const control = this.notificationForm.get(controlName);
return control ? control.dirty && control.hasError(errorName) : false;
testProviderFromModal(): void {
if (this.editingProvider) {
this.testProvider(this.editingProvider);
}
}
/**
* Check if a nested form control has an error after it's been touched
* Get modal title based on mode
*/
hasNestedError(groupName: string, controlName: string, errorName: string): boolean {
const control = this.notificationForm.get(`${groupName}.${controlName}`);
return control ? control.dirty && control.hasError(errorName) : false;
get modalTitle(): string {
return this.modalMode === "add" ? "Add Notification Provider" : "Edit Notification Provider";
}
/**
* Opens documentation for a specific field
* Get provider type label for display
*/
getProviderTypeLabel(type: NotificationProviderType): string {
switch (type) {
case NotificationProviderType.Notifiarr:
return "Notifiarr";
case NotificationProviderType.Apprise:
return "Apprise";
default:
return "Unknown";
}
}
/**
* Get provider type label for an existing provider
*/
getProviderTypeLabelForProvider(provider: NotificationProviderDto): string {
return this.getProviderTypeLabel(provider.type);
}
/**
* Open field-specific documentation
*/
openFieldDocs(fieldName: string): void {
this.documentationService.openFieldDocumentation('notifications', fieldName);
this.documentationService.openFieldDocumentation("notifications", fieldName);
}
// Provider modal handlers
/**
* Handle Notifiarr provider save
*/
onNotifiarrSave(data: NotifiarrFormData): void {
if (this.modalMode === "edit" && this.editingProvider) {
this.updateNotifiarrProvider(data);
} else {
this.createNotifiarrProvider(data);
}
}
/**
* Handle Notifiarr provider test
*/
onNotifiarrTest(data: NotifiarrFormData): void {
const testRequest = {
apiKey: data.apiKey,
channelId: data.channelId,
};
this.notificationProviderStore.testProvider({
testRequest,
type: NotificationProviderType.Notifiarr,
});
}
/**
* Handle Apprise provider save
*/
onAppriseSave(data: AppriseFormData): void {
if (this.modalMode === "edit" && this.editingProvider) {
this.updateAppriseProvider(data);
} else {
this.createAppriseProvider(data);
}
}
/**
* Handle Apprise provider test
*/
onAppriseTest(data: AppriseFormData): void {
const testRequest = {
url: data.url,
key: data.key,
tags: data.tags,
};
this.notificationProviderStore.testProvider({
testRequest,
type: NotificationProviderType.Apprise,
});
}
/**
* Handle provider modal cancel
*/
onProviderCancel(): void {
this.closeAllModals();
}
/**
* Close all provider modals
*/
private closeAllModals(): void {
this.showTypeSelectionModal = false;
this.showNotifiarrModal = false;
this.showAppriseModal = false;
this.showProviderModal = false;
this.editingProvider = null;
this.notificationProviderStore.clearTestResult();
}
/**
* Create new Notifiarr provider
*/
private createNotifiarrProvider(data: NotifiarrFormData): 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,
apiKey: data.apiKey,
channelId: data.channelId,
};
this.notificationProviderStore.createProvider({
provider: createDto,
type: NotificationProviderType.Notifiarr,
});
this.monitorProviderOperation("created");
}
/**
* Update existing Notifiarr provider
*/
private updateNotifiarrProvider(data: NotifiarrFormData): 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,
apiKey: data.apiKey,
channelId: data.channelId,
};
this.notificationProviderStore.updateProvider({
id: this.editingProvider.id,
provider: updateDto,
type: NotificationProviderType.Notifiarr,
});
this.monitorProviderOperation("updated");
}
/**
* Create new Apprise provider
*/
private createAppriseProvider(data: AppriseFormData): 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,
url: data.url,
key: data.key,
tags: data.tags,
};
this.notificationProviderStore.createProvider({
provider: createDto,
type: NotificationProviderType.Apprise,
});
this.monitorProviderOperation("created");
}
/**
* Update existing Apprise provider
*/
private updateAppriseProvider(data: AppriseFormData): 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,
url: data.url,
key: data.key,
tags: data.tags,
};
this.notificationProviderStore.updateProvider({
id: this.editingProvider.id,
provider: updateDto,
type: NotificationProviderType.Apprise,
});
this.monitorProviderOperation("updated");
}
/**
* Monitor provider operation completion and close modals
*/
private monitorProviderOperation(operation: string): void {
const checkStatus = () => {
const saving = this.notificationProviderSaving();
const saveError = this.notificationProviderSaveError();
if (!saving) {
if (saveError) {
// Show error once and clear it
this.notificationService.showError(saveError);
this.notificationProviderStore.resetSaveError();
} else {
// Operation completed successfully
this.notificationService.showSuccess(`Provider ${operation} successfully`);
this.closeAllModals();
}
} else {
// Still saving, check again
setTimeout(checkStatus, 100);
}
};
setTimeout(checkStatus, 100);
}
}

View File

@@ -232,17 +232,8 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
effect(() => {
const saveErrorMessage = this.queueCleanerSaveError();
if (saveErrorMessage) {
// Check if this looks like a validation error from the backend
// These are typically user-fixable errors that should be shown as toasts
const isUserFixableError = ErrorHandlerUtil.isUserFixableError(saveErrorMessage);
if (isUserFixableError) {
// Show validation errors as toast notifications so user can fix them
this.notificationService.showError(saveErrorMessage);
} else {
// For non-user-fixable save errors, also emit to parent
this.error.emit(saveErrorMessage);
}
// Always show save errors as a toast so the user sees the backend message.
this.notificationService.showError(saveErrorMessage);
}
});

View File

@@ -1,6 +1,6 @@
import { Component, EventEmitter, OnDestroy, Output, effect, inject } from "@angular/core";
import { CommonModule } from "@angular/common";
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators, AbstractControl, ValidationErrors } from "@angular/forms";
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
import { Subject, takeUntil } from "rxjs";
import { RadarrConfigStore } from "./radarr-config.store";
import { CanComponentDeactivate } from "../../core/guards";
@@ -19,6 +19,7 @@ import { ConfirmDialogModule } from "primeng/confirmdialog";
import { ConfirmationService } from "primeng/api";
import { NotificationService } from "../../core/services/notification.service";
import { LoadingErrorStateComponent } from "../../shared/components/loading-error-state/loading-error-state.component";
import { UrlValidators } from "../../core/validators/url.validator";
@Component({
selector: "app-radarr-settings",
@@ -88,7 +89,7 @@ export class RadarrSettingsComponent implements OnDestroy, CanComponentDeactivat
this.instanceForm = this.formBuilder.group({
enabled: [true],
name: ['', Validators.required],
url: ['', [Validators.required, this.uriValidator.bind(this)]],
url: ['', [Validators.required, UrlValidators.httpUrl]],
apiKey: ['', Validators.required],
});
@@ -178,24 +179,7 @@ export class RadarrSettingsComponent implements OnDestroy, CanComponentDeactivat
/**
* Custom validator to check if the input is a valid URI
*/
private uriValidator(control: AbstractControl): ValidationErrors | null {
if (!control.value) {
return null; // Let required validator handle empty values
}
try {
const url = new URL(control.value);
// Check that we have a valid protocol (http or https)
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return { invalidProtocol: true };
}
return null; // Valid URI
} catch (e) {
return { invalidUri: true }; // Invalid URI
}
}
// URL validation handled by shared UrlValidators.httpUrl
/**
* Mark all controls in a form group as touched

View File

@@ -1,6 +1,6 @@
import { Component, EventEmitter, OnDestroy, Output, effect, inject } from "@angular/core";
import { CommonModule } from "@angular/common";
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators, AbstractControl, ValidationErrors } from "@angular/forms";
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
import { Subject, takeUntil } from "rxjs";
import { ReadarrConfigStore } from "./readarr-config.store";
import { CanComponentDeactivate } from "../../core/guards";
@@ -19,6 +19,7 @@ import { ConfirmDialogModule } from "primeng/confirmdialog";
import { ConfirmationService } from "primeng/api";
import { NotificationService } from "../../core/services/notification.service";
import { LoadingErrorStateComponent } from "../../shared/components/loading-error-state/loading-error-state.component";
import { UrlValidators } from "../../core/validators/url.validator";
@Component({
selector: "app-readarr-settings",
@@ -88,7 +89,7 @@ export class ReadarrSettingsComponent implements OnDestroy, CanComponentDeactiva
this.instanceForm = this.formBuilder.group({
enabled: [true],
name: ['', Validators.required],
url: ['', [Validators.required, this.uriValidator.bind(this)]],
url: ['', [Validators.required, UrlValidators.httpUrl]],
apiKey: ['', Validators.required],
});
@@ -178,24 +179,7 @@ export class ReadarrSettingsComponent implements OnDestroy, CanComponentDeactiva
/**
* Custom validator to check if the input is a valid URI
*/
private uriValidator(control: AbstractControl): ValidationErrors | null {
if (!control.value) {
return null; // Let required validator handle empty values
}
try {
const url = new URL(control.value);
// Check that we have a valid protocol (http or https)
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return { invalidProtocol: true };
}
return null; // Valid URI
} catch (e) {
return { invalidUri: true }; // Invalid URI
}
}
// URL validation handled by shared UrlValidators.httpUrl
/**
* Mark all controls in a form group as touched

View File

@@ -1,6 +1,6 @@
import { Component, EventEmitter, OnDestroy, Output, effect, inject } from "@angular/core";
import { CommonModule } from "@angular/common";
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators, AbstractControl, ValidationErrors } from "@angular/forms";
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
import { Subject, takeUntil } from "rxjs";
import { SonarrConfigStore } from "./sonarr-config.store";
import { CanComponentDeactivate } from "../../core/guards";
@@ -19,6 +19,7 @@ import { ConfirmDialogModule } from "primeng/confirmdialog";
import { ConfirmationService } from "primeng/api";
import { NotificationService } from "../../core/services/notification.service";
import { LoadingErrorStateComponent } from "../../shared/components/loading-error-state/loading-error-state.component";
import { UrlValidators } from "../../core/validators/url.validator";
@Component({
selector: "app-sonarr-settings",
@@ -88,7 +89,7 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat
this.instanceForm = this.formBuilder.group({
enabled: [true],
name: ['', Validators.required],
url: ['', [Validators.required, this.uriValidator.bind(this)]],
url: ['', [Validators.required, UrlValidators.httpUrl]],
apiKey: ['', Validators.required],
});
@@ -178,24 +179,7 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat
/**
* Custom validator to check if the input is a valid URI
*/
private uriValidator(control: AbstractControl): ValidationErrors | null {
if (!control.value) {
return null; // Let required validator handle empty values
}
try {
const url = new URL(control.value);
// Check that we have a valid protocol (http or https)
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return { invalidProtocol: true };
}
return null; // Valid URI
} catch (e) {
return { invalidUri: true }; // Invalid URI
}
}
// URL validation handled by shared UrlValidators.httpUrl
/**
* Mark all controls in a form group as touched

View File

@@ -19,6 +19,7 @@ import { ConfirmDialogModule } from "primeng/confirmdialog";
import { ConfirmationService } from "primeng/api";
import { NotificationService } from "../../core/services/notification.service";
import { LoadingErrorStateComponent } from "../../shared/components/loading-error-state/loading-error-state.component";
import { UrlValidators } from "../../core/validators/url.validator";
@Component({
selector: "app-whisparr-settings",
@@ -88,7 +89,7 @@ export class WhisparrSettingsComponent implements OnDestroy, CanComponentDeactiv
this.instanceForm = this.formBuilder.group({
enabled: [true],
name: ['', Validators.required],
url: ['', [Validators.required, this.uriValidator.bind(this)]],
url: ['', [Validators.required, UrlValidators.httpUrl]],
apiKey: ['', Validators.required],
});
@@ -175,28 +176,6 @@ export class WhisparrSettingsComponent implements OnDestroy, CanComponentDeactiv
return true;
}
/**
* Custom validator to check if the input is a valid URI
*/
private uriValidator(control: AbstractControl): ValidationErrors | null {
if (!control.value) {
return null; // Let required validator handle empty values
}
try {
const url = new URL(control.value);
// Check that we have a valid protocol (http or https)
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return { invalidProtocol: true };
}
return null; // Valid URI
} catch (e) {
return { invalidUri: true }; // Invalid URI
}
}
/**
* Mark all controls in a form group as touched
*/

View File

@@ -1,7 +1,7 @@
import { NotificationConfig } from './notification-config.model';
export interface AppriseConfig extends NotificationConfig {
fullUrl?: string;
url?: string;
key?: string;
tags?: string;
}

View File

@@ -8,4 +8,9 @@ export enum DownloadClientTypeName {
Deluge = "Deluge",
Transmission = "Transmission",
uTorrent = "uTorrent",
}
export enum NotificationProviderType {
Notifiarr = "Notifiarr",
Apprise = "Apprise",
}

View File

@@ -1,5 +1,6 @@
import { LogEventLevel } from './log-event-level.enum';
import { CertificateValidationType } from './certificate-validation-type.enum';
import { LoggingConfig } from './logging-config.model';
import { LogEventLevel } from './log-event-level.enum';
export interface GeneralConfig {
displaySupportBanner: boolean;
@@ -9,6 +10,9 @@ export interface GeneralConfig {
httpCertificateValidation: CertificateValidationType;
searchEnabled: boolean;
searchDelay: number;
logLevel: LogEventLevel;
// New logging configuration structure
log?: LoggingConfig;
// Temporary backward compatibility - will be removed in task 7
logLevel?: LogEventLevel;
ignoredDownloads: string[];
}

View File

@@ -0,0 +1,14 @@
import { LogEventLevel } from './log-event-level.enum';
/**
* Interface representing logging configuration options
*/
export interface LoggingConfig {
level: LogEventLevel;
rollingSizeMB: number; // 0 = disabled
retainedFileCount: number; // 0 = unlimited
timeLimitHours: number; // 0 = unlimited
archiveEnabled: boolean;
archiveRetainedCount: number; // 0 = unlimited
archiveTimeLimitHours: number; // 0 = unlimited
}

View File

@@ -0,0 +1,67 @@
import { NotificationProviderType } from './enums';
export interface NotificationEventFlags {
onFailedImportStrike: boolean;
onStalledStrike: boolean;
onSlowStrike: boolean;
onQueueItemDeleted: boolean;
onDownloadCleaned: boolean;
onCategoryChanged: boolean;
}
export interface NotificationProviderDto {
id: string;
name: string;
type: NotificationProviderType;
isEnabled: boolean;
events: NotificationEventFlags;
configuration: any;
}
export interface CreateNotificationProviderDto {
name: string;
type: NotificationProviderType;
isEnabled: boolean;
onFailedImportStrike: boolean;
onStalledStrike: boolean;
onSlowStrike: boolean;
onQueueItemDeleted: boolean;
onDownloadCleaned: boolean;
onCategoryChanged: boolean;
configuration: any;
}
export interface UpdateNotificationProviderDto {
name: string;
type: NotificationProviderType;
isEnabled: boolean;
onFailedImportStrike: boolean;
onStalledStrike: boolean;
onSlowStrike: boolean;
onQueueItemDeleted: boolean;
onDownloadCleaned: boolean;
onCategoryChanged: boolean;
configuration: any;
}
export interface NotificationProvidersConfig {
providers: NotificationProviderDto[];
}
// Provider-specific configuration interfaces
export interface NotifiarrConfiguration {
apiKey: string;
channelId: string;
}
export interface AppriseConfiguration {
url: string;
key: string;
tags: string;
}
export interface TestNotificationResult {
success: boolean;
message: string;
error?: string;
}

View File

@@ -143,6 +143,70 @@ Controls the detail level of application logs. Lower levels include all higher l
</ConfigSection>
<ConfigSection
id="log-rolling-size-mb"
title="Log Rolling Size (MB)"
icon="📐"
>
Maximum size (in megabytes) for a single log file before the logger rolls over to a new file. Larger values reduce the number of files created but increase individual file sizes.
</ConfigSection>
<ConfigSection
id="log-retained-file-count"
title="Retained Log Files"
icon="🗂️"
>
The number of non-archived log files to keep on disk. Older files beyond this count will be removed or archived, according to the settings.
</ConfigSection>
<ConfigSection
id="log-time-limit-hours"
title="Log Time Limit (Hours)"
icon="⏳"
>
Maximum age (in hours) for non-archived log files to keep on disk. Older files beyond this age will be removed or archived, according to the settings.
</ConfigSection>
<ConfigSection
id="log-archive-enabled"
title="Archive Logs"
icon="🗄️"
>
When enabled, log files that exceed retention limits will be archived instead of being immediately deleted. Archiving helps preserve historical logs while reducing the disk size used by them.
<EnhancedNote>
Some setups will find that a lot of logs are generated, so archiving is recommended to prevent excessive disk usage.
</EnhancedNote>
</ConfigSection>
<ConfigSection
id="log-archive-retained-count"
title="Archived Log Retention (Count)"
icon="🧾"
>
The number of archived log files to keep. Older files beyond this count will be removed.
</ConfigSection>
<ConfigSection
id="log-archive-time-limit-hours"
title="Archived Log Retention (Hours)"
icon="🕒"
>
Maximum age (in hours) for archived logs before they are deleted. Older files beyond this age will be removed.
</ConfigSection>
</div>
<div className={styles.section}>

View File

@@ -153,10 +153,10 @@ regex:<ANY_REGEX> // regex that needs to be marked at the start of the line wi
:::tip
Available blocklists that can be used with Sonarr and Radarr:
- `http://cleanuparr.pages.dev/static/blacklist`
- `http://cleanuparr.pages.dev/static/blacklist_permissive`
- `http://cleanuparr.pages.dev/static/whitelist`
- `http://cleanuparr.pages.dev/static/whitelist_with_subtitles`
- `https://cleanuparr.pages.dev/static/blacklist`
- `https://cleanuparr.pages.dev/static/blacklist_permissive`
- `https://cleanuparr.pages.dev/static/whitelist`
- `https://cleanuparr.pages.dev/static/whitelist_with_subtitles`
:::
</ConfigSection>

View File

@@ -17,6 +17,26 @@ Configure notification services to receive alerts about Cleanuparr operations.
<div className={styles.section}>
<ConfigSection
id="enabled"
title="Enabled"
icon="✅"
>
Enable or disable this notification provider. If enabled, notifications will be sent according to the configured event triggers.
</ConfigSection>
<ConfigSection
id="provider-name"
title="Provider Name"
icon="🆔"
>
A unique name to identify this provider. This name will be displayed in the user interface and logs.
</ConfigSection>
<h2 className={styles.sectionTitle}>
<span className={styles.sectionIcon}>🚀</span>
Notifiarr
@@ -97,7 +117,7 @@ Optionally notify only those tagged accordingly. Use a comma (,) to OR your tags
<div className={styles.section}>
<h2 className={styles.sectionTitle}>
<h2 id="event-triggers" className={styles.sectionTitle}>
<span className={styles.sectionIcon}>⚡</span>
Event Triggers
</h2>