mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2025-12-27 07:58:24 -05:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7d2ec7311 | ||
|
|
bb9ac5b67b | ||
|
|
f93494adb2 | ||
|
|
7201520411 | ||
|
|
2a1e65e1af | ||
|
|
da318c3339 | ||
|
|
7149b6243f | ||
|
|
11f5a28c04 | ||
|
|
9cc36c7a50 | ||
|
|
861c135cc6 | ||
|
|
3b0275c411 | ||
|
|
cad1b51202 | ||
|
|
f50acd29f4 | ||
|
|
af11d595d8 | ||
|
|
44994d5b21 | ||
|
|
592fd2d846 |
@@ -15,7 +15,8 @@ Cleanuparr was created primarily to address malicious files, such as `*.lnk` or
|
||||
> - Remove and block downloads that are **failing to be imported** by the arrs.
|
||||
> - Remove and block downloads that are **stalled** or in **metadata downloading** state.
|
||||
> - Remove and block downloads that have a **low download speed** or **high estimated completion time**.
|
||||
> - Remove and block downloads blocked by qBittorrent or by Cleanuparr's **Content Blocker**.
|
||||
> - Remove and block downloads blocked by qBittorrent or by Cleanuparr's **Malware Blocker**.
|
||||
> - Remove and block known malware based on patterns found by the community.
|
||||
> - Automatically trigger a search for downloads removed from the arrs.
|
||||
> - Clean up downloads that have been **seeding** for a certain amount of time.
|
||||
> - Remove downloads that are **orphaned**/have no **hardlinks**/are not referenced by the arrs anymore (with [cross-seed](https://www.cross-seed.org/) support).
|
||||
|
||||
@@ -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;
|
||||
@@ -11,9 +14,9 @@ using Cleanuparr.Infrastructure.Utilities;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Mapster;
|
||||
@@ -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")]
|
||||
@@ -65,8 +71,8 @@ public class ConfigurationController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("content_blocker")]
|
||||
public async Task<IActionResult> GetContentBlockerConfig()
|
||||
[HttpGet("malware_blocker")]
|
||||
public async Task<IActionResult> GetMalwareBlockerConfig()
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
@@ -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,448 @@ 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
|
||||
.Where(x => x.Id != id)
|
||||
.Where(x => x.Name == updatedProvider.Name)
|
||||
.CountAsync();
|
||||
|
||||
if (duplicateConfig > 0)
|
||||
{
|
||||
return BadRequest("A provider with this name already exists");
|
||||
}
|
||||
|
||||
// Create provider-specific configuration with validation
|
||||
var 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
|
||||
.Where(x => x.Id != id)
|
||||
.Where(x => x.Name == updatedProvider.Name)
|
||||
.CountAsync();
|
||||
|
||||
if (duplicateConfig > 0)
|
||||
{
|
||||
return BadRequest("A provider with this name already exists");
|
||||
}
|
||||
|
||||
// Create provider-specific configuration with validation
|
||||
var 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)
|
||||
{
|
||||
@@ -483,8 +969,8 @@ public class ConfigurationController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("content_blocker")]
|
||||
public async Task<IActionResult> UpdateContentBlockerConfig([FromBody] ContentBlockerConfig newConfig)
|
||||
[HttpPut("malware_blocker")]
|
||||
public async Task<IActionResult> UpdateMalwareBlockerConfig([FromBody] ContentBlockerConfig newConfig)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
@@ -515,11 +1001,11 @@ public class ConfigurationController : ControllerBase
|
||||
// Update the scheduler based on configuration changes
|
||||
await UpdateJobSchedule(oldConfig, JobType.MalwareBlocker);
|
||||
|
||||
return Ok(new { Message = "ContentBlocker configuration updated successfully" });
|
||||
return Ok(new { Message = "MalwareBlocker configuration updated successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save ContentBlocker configuration");
|
||||
_logger.LogError(ex, "Failed to save MalwareBlocker configuration");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
@@ -700,8 +1186,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 +1951,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);
|
||||
}
|
||||
}
|
||||
@@ -87,10 +87,6 @@ public class EventsController : ControllerBase
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
events = events
|
||||
.OrderBy(e => e.Timestamp)
|
||||
.ToList();
|
||||
|
||||
// Return paginated result
|
||||
var result = new PaginatedResult<AppEvent>
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -17,16 +17,17 @@ 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();
|
||||
|
||||
config.AddConsumer<DownloadRemoverConsumer<SearchItem>>();
|
||||
config.AddConsumer<DownloadRemoverConsumer<SeriesSearchItem>>();
|
||||
config.AddConsumer<DownloadHunterConsumer<SearchItem>>();
|
||||
@@ -34,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>>();
|
||||
@@ -69,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);
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
using Cleanuparr.Application.Features.ContentBlocker;
|
||||
using Cleanuparr.Application.Features.DownloadCleaner;
|
||||
using Cleanuparr.Application.Features.MalwareBlocker;
|
||||
using Cleanuparr.Application.Features.QueueCleaner;
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Features.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.ContentBlocker;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter.Interfaces;
|
||||
@@ -11,6 +10,7 @@ using Cleanuparr.Infrastructure.Features.DownloadRemover;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Files;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Features.Security;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Infrastructure.Services;
|
||||
@@ -40,7 +40,7 @@ public static class ServicesDI
|
||||
.AddScoped<WhisparrClient>()
|
||||
.AddScoped<ArrClientFactory>()
|
||||
.AddScoped<QueueCleaner>()
|
||||
.AddScoped<ContentBlocker>()
|
||||
.AddScoped<MalwareBlocker>()
|
||||
.AddScoped<DownloadCleaner>()
|
||||
.AddScoped<IQueueItemRemover, QueueItemRemover>()
|
||||
.AddScoped<IDownloadHunter, DownloadHunter>()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
using Cleanuparr.Application.Features.ContentBlocker;
|
||||
using Cleanuparr.Application.Features.DownloadCleaner;
|
||||
using Cleanuparr.Application.Features.MalwareBlocker;
|
||||
using Cleanuparr.Application.Features.QueueCleaner;
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
using Cleanuparr.Infrastructure.Features.Jobs;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -94,7 +94,7 @@ public class BackgroundJobManager : IHostedService
|
||||
QueueCleanerConfig queueCleanerConfig = await dataContext.QueueCleanerConfigs
|
||||
.AsNoTracking()
|
||||
.FirstAsync(cancellationToken);
|
||||
ContentBlockerConfig contentBlockerConfig = await dataContext.ContentBlockerConfigs
|
||||
ContentBlockerConfig malwareBlockerConfig = await dataContext.ContentBlockerConfigs
|
||||
.AsNoTracking()
|
||||
.FirstAsync(cancellationToken);
|
||||
DownloadCleanerConfig downloadCleanerConfig = await dataContext.DownloadCleanerConfigs
|
||||
@@ -103,7 +103,7 @@ public class BackgroundJobManager : IHostedService
|
||||
|
||||
// Always register jobs, regardless of enabled status
|
||||
await RegisterQueueCleanerJob(queueCleanerConfig, cancellationToken);
|
||||
await RegisterContentBlockerJob(contentBlockerConfig, cancellationToken);
|
||||
await RegisterMalwareBlockerJob(malwareBlockerConfig, cancellationToken);
|
||||
await RegisterDownloadCleanerJob(downloadCleanerConfig, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -127,17 +127,17 @@ public class BackgroundJobManager : IHostedService
|
||||
/// <summary>
|
||||
/// Registers the QueueCleaner job and optionally adds triggers based on configuration.
|
||||
/// </summary>
|
||||
public async Task RegisterContentBlockerJob(
|
||||
public async Task RegisterMalwareBlockerJob(
|
||||
ContentBlockerConfig config,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Always register the job definition
|
||||
await AddJobWithoutTrigger<ContentBlocker>(cancellationToken);
|
||||
await AddJobWithoutTrigger<MalwareBlocker>(cancellationToken);
|
||||
|
||||
// Only add triggers if the job is enabled
|
||||
if (config.Enabled)
|
||||
{
|
||||
await AddTriggersForJob<ContentBlocker>(config, config.CronExpression, cancellationToken);
|
||||
await AddTriggersForJob<MalwareBlocker>(config, config.CronExpression, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ public class BackgroundJobManager : IHostedService
|
||||
throw new ValidationException($"{cronExpression} should have a fire time of maximum {Constants.TriggerMaxLimit.TotalHours} hours");
|
||||
}
|
||||
|
||||
if (typeof(T) != typeof(ContentBlocker) && triggerValue < Constants.TriggerMinLimit)
|
||||
if (typeof(T) != typeof(MalwareBlocker) && triggerValue < Constants.TriggerMinLimit)
|
||||
{
|
||||
throw new ValidationException($"{cronExpression} should have a fire time of minimum {Constants.TriggerMinLimit.TotalSeconds} seconds");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -3,29 +3,29 @@ using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Features.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.ContentBlocker;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.Jobs;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Helpers;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using MassTransit;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using LogContext = Serilog.Context.LogContext;
|
||||
|
||||
namespace Cleanuparr.Application.Features.ContentBlocker;
|
||||
namespace Cleanuparr.Application.Features.MalwareBlocker;
|
||||
|
||||
public sealed class ContentBlocker : GenericHandler
|
||||
public sealed class MalwareBlocker : GenericHandler
|
||||
{
|
||||
private readonly BlocklistProvider _blocklistProvider;
|
||||
|
||||
public ContentBlocker(
|
||||
ILogger<ContentBlocker> logger,
|
||||
public MalwareBlocker(
|
||||
ILogger<MalwareBlocker> logger,
|
||||
DataContext dataContext,
|
||||
IMemoryCache cache,
|
||||
IBus messageBus,
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
public enum NotificationEventType
|
||||
{
|
||||
Test,
|
||||
FailedImportStrike,
|
||||
StalledStrike,
|
||||
SlowSpeedStrike,
|
||||
SlowTimeStrike,
|
||||
QueueItemDeleted,
|
||||
DownloadCleaned,
|
||||
CategoryChanged
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
public enum NotificationProviderType
|
||||
{
|
||||
Notifiarr,
|
||||
Apprise
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
using Cleanuparr.Infrastructure.Features.ContentBlocker;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Verticals.ContentBlocker;
|
||||
namespace Cleanuparr.Infrastructure.Tests.Verticals.MalwareBlocker;
|
||||
|
||||
public class FilenameEvaluatorFixture
|
||||
{
|
||||
@@ -4,7 +4,7 @@ using Cleanuparr.Domain.Enums;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Verticals.ContentBlocker;
|
||||
namespace Cleanuparr.Infrastructure.Tests.Verticals.MalwareBlocker;
|
||||
|
||||
public class FilenameEvaluatorTests : IClassFixture<FilenameEvaluatorFixture>
|
||||
{
|
||||
@@ -7,7 +7,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FLM.QBittorrent" Version="1.0.1" />
|
||||
<PackageReference Include="FLM.QBittorrent" Version="1.0.2" />
|
||||
<PackageReference Include="FLM.Transmission" Version="1.0.3" />
|
||||
<PackageReference Include="Mapster" Version="7.4.0" />
|
||||
<PackageReference Include="MassTransit.Abstractions" Version="8.4.1" />
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
using Cleanuparr.Domain.Entities.Deluge.Response;
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Features.ContentBlocker;
|
||||
using Cleanuparr.Infrastructure.Features.Files;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Http;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Infrastructure.Interceptors;
|
||||
|
||||
@@ -4,7 +4,7 @@ using Cleanuparr.Domain.Entities.Deluge.Response;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Extensions;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
|
||||
@@ -35,9 +35,9 @@ public partial class DelugeService
|
||||
|
||||
result.IsPrivate = download.Private;
|
||||
|
||||
var contentBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
|
||||
var malwareBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
|
||||
|
||||
if (contentBlockerConfig.IgnorePrivate && download.Private)
|
||||
if (malwareBlockerConfig.IgnorePrivate && download.Private)
|
||||
{
|
||||
// ignore private trackers
|
||||
_logger.LogDebug("skip files check | download is private | {name}", download.Name);
|
||||
@@ -81,7 +81,7 @@ public partial class DelugeService
|
||||
return;
|
||||
}
|
||||
|
||||
if (contentBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(name, malwarePatterns))
|
||||
if (malwareBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(name, malwarePatterns))
|
||||
{
|
||||
_logger.LogInformation("malware file found | {file} | {title}", file.Path, download.Name);
|
||||
result.ShouldRemove = true;
|
||||
|
||||
@@ -2,10 +2,10 @@ using Cleanuparr.Domain.Entities;
|
||||
using Cleanuparr.Domain.Entities.Cache;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Features.ContentBlocker;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.Files;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Helpers;
|
||||
using Cleanuparr.Infrastructure.Http;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Features.ContentBlocker;
|
||||
using Cleanuparr.Infrastructure.Features.Files;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Http;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Infrastructure.Interceptors;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Features.ContentBlocker;
|
||||
using Cleanuparr.Infrastructure.Features.Files;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Http;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Infrastructure.Interceptors;
|
||||
|
||||
@@ -3,7 +3,7 @@ using System.Text.RegularExpressions;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Extensions;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using QBittorrent.Client;
|
||||
|
||||
@@ -49,9 +49,9 @@ public partial class QBitService
|
||||
|
||||
result.IsPrivate = isPrivate;
|
||||
|
||||
var contentBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
|
||||
var malwareBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
|
||||
|
||||
if (contentBlockerConfig.IgnorePrivate && isPrivate)
|
||||
if (malwareBlockerConfig.IgnorePrivate && isPrivate)
|
||||
{
|
||||
// ignore private trackers
|
||||
_logger.LogDebug("skip files check | download is private | {name}", download.Name);
|
||||
@@ -86,7 +86,7 @@ public partial class QBitService
|
||||
|
||||
totalFiles++;
|
||||
|
||||
if (contentBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(file.Name, malwarePatterns))
|
||||
if (malwareBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(file.Name, malwarePatterns))
|
||||
{
|
||||
_logger.LogInformation("malware file found | {file} | {title}", file.Name, download.Name);
|
||||
result.ShouldRemove = true;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Features.ContentBlocker;
|
||||
using Cleanuparr.Infrastructure.Features.Files;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Http;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Infrastructure.Interceptors;
|
||||
|
||||
@@ -3,7 +3,7 @@ using System.Text.RegularExpressions;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Extensions;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Transmission.API.RPC.Entity;
|
||||
|
||||
@@ -40,9 +40,9 @@ public partial class TransmissionService
|
||||
bool isPrivate = download.IsPrivate ?? false;
|
||||
result.IsPrivate = isPrivate;
|
||||
|
||||
var contentBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
|
||||
var malwareBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
|
||||
|
||||
if (contentBlockerConfig.IgnorePrivate && isPrivate)
|
||||
if (malwareBlockerConfig.IgnorePrivate && isPrivate)
|
||||
{
|
||||
// ignore private trackers
|
||||
_logger.LogDebug("skip files check | download is private | {name}", download.Name);
|
||||
@@ -69,7 +69,7 @@ public partial class TransmissionService
|
||||
|
||||
totalFiles++;
|
||||
|
||||
if (contentBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(download.Files[i].Name, malwarePatterns))
|
||||
if (malwareBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(download.Files[i].Name, malwarePatterns))
|
||||
{
|
||||
_logger.LogInformation("malware file found | {file} | {title}", download.Files[i].Name, download.Name);
|
||||
result.ShouldRemove = true;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Features.ContentBlocker;
|
||||
using Cleanuparr.Infrastructure.Features.Files;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Http;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Infrastructure.Interceptors;
|
||||
|
||||
@@ -5,7 +5,7 @@ using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Extensions;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent.Extensions;
|
||||
using Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
|
||||
@@ -38,9 +38,9 @@ public partial class UTorrentService
|
||||
return result;
|
||||
}
|
||||
|
||||
var contentBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
|
||||
var malwareBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
|
||||
|
||||
if (contentBlockerConfig.IgnorePrivate && result.IsPrivate)
|
||||
if (malwareBlockerConfig.IgnorePrivate && result.IsPrivate)
|
||||
{
|
||||
// ignore private trackers
|
||||
_logger.LogDebug("skip files check | download is private | {name}", download.Name);
|
||||
@@ -66,7 +66,7 @@ public partial class UTorrentService
|
||||
|
||||
for (int i = 0; i < files.Count; i++)
|
||||
{
|
||||
if (contentBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(files[i].Name, malwarePatterns))
|
||||
if (malwareBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(files[i].Name, malwarePatterns))
|
||||
{
|
||||
_logger.LogInformation("malware file found | {file} | {title}", files[i].Name, download.Name);
|
||||
result.ShouldRemove = true;
|
||||
|
||||
@@ -9,9 +9,9 @@ using Cleanuparr.Infrastructure.Features.DownloadRemover.Models;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Data.Models.Arr;
|
||||
using MassTransit;
|
||||
|
||||
@@ -6,15 +6,14 @@ using System.Text.RegularExpressions;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Helpers;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.ContentBlocker;
|
||||
namespace Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
|
||||
public sealed class BlocklistProvider
|
||||
{
|
||||
@@ -49,81 +48,81 @@ public sealed class BlocklistProvider
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
await using var dataContext = scope.ServiceProvider.GetRequiredService<DataContext>();
|
||||
int changedCount = 0;
|
||||
var contentBlockerConfig = await dataContext.ContentBlockerConfigs
|
||||
var malwareBlockerConfig = await dataContext.ContentBlockerConfigs
|
||||
.AsNoTracking()
|
||||
.FirstAsync();
|
||||
|
||||
if (!contentBlockerConfig.Enabled)
|
||||
if (!malwareBlockerConfig.Enabled)
|
||||
{
|
||||
_logger.LogDebug("Content blocker is disabled, skipping blocklist loading");
|
||||
_logger.LogDebug("Malware Blocker is disabled, skipping blocklist loading");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check and update Sonarr blocklist if needed
|
||||
string sonarrHash = GenerateSettingsHash(contentBlockerConfig.Sonarr);
|
||||
var sonarrInterval = GetLoadInterval(contentBlockerConfig.Sonarr.BlocklistPath);
|
||||
var sonarrIdentifier = $"Sonarr_{contentBlockerConfig.Sonarr.BlocklistPath}";
|
||||
string sonarrHash = GenerateSettingsHash(malwareBlockerConfig.Sonarr);
|
||||
var sonarrInterval = GetLoadInterval(malwareBlockerConfig.Sonarr.BlocklistPath);
|
||||
var sonarrIdentifier = $"Sonarr_{malwareBlockerConfig.Sonarr.BlocklistPath}";
|
||||
if (ShouldReloadBlocklist(sonarrIdentifier, sonarrInterval) || !_configHashes.TryGetValue(InstanceType.Sonarr, out string? oldSonarrHash) || sonarrHash != oldSonarrHash)
|
||||
{
|
||||
_logger.LogDebug("Loading Sonarr blocklist");
|
||||
|
||||
await LoadPatternsAndRegexesAsync(contentBlockerConfig.Sonarr, InstanceType.Sonarr);
|
||||
await LoadPatternsAndRegexesAsync(malwareBlockerConfig.Sonarr, InstanceType.Sonarr);
|
||||
_configHashes[InstanceType.Sonarr] = sonarrHash;
|
||||
_lastLoadTimes[sonarrIdentifier] = DateTime.UtcNow;
|
||||
changedCount++;
|
||||
}
|
||||
|
||||
// Check and update Radarr blocklist if needed
|
||||
string radarrHash = GenerateSettingsHash(contentBlockerConfig.Radarr);
|
||||
var radarrInterval = GetLoadInterval(contentBlockerConfig.Radarr.BlocklistPath);
|
||||
var radarrIdentifier = $"Radarr_{contentBlockerConfig.Radarr.BlocklistPath}";
|
||||
string radarrHash = GenerateSettingsHash(malwareBlockerConfig.Radarr);
|
||||
var radarrInterval = GetLoadInterval(malwareBlockerConfig.Radarr.BlocklistPath);
|
||||
var radarrIdentifier = $"Radarr_{malwareBlockerConfig.Radarr.BlocklistPath}";
|
||||
if (ShouldReloadBlocklist(radarrIdentifier, radarrInterval) || !_configHashes.TryGetValue(InstanceType.Radarr, out string? oldRadarrHash) || radarrHash != oldRadarrHash)
|
||||
{
|
||||
_logger.LogDebug("Loading Radarr blocklist");
|
||||
|
||||
await LoadPatternsAndRegexesAsync(contentBlockerConfig.Radarr, InstanceType.Radarr);
|
||||
await LoadPatternsAndRegexesAsync(malwareBlockerConfig.Radarr, InstanceType.Radarr);
|
||||
_configHashes[InstanceType.Radarr] = radarrHash;
|
||||
_lastLoadTimes[radarrIdentifier] = DateTime.UtcNow;
|
||||
changedCount++;
|
||||
}
|
||||
|
||||
// Check and update Lidarr blocklist if needed
|
||||
string lidarrHash = GenerateSettingsHash(contentBlockerConfig.Lidarr);
|
||||
var lidarrInterval = GetLoadInterval(contentBlockerConfig.Lidarr.BlocklistPath);
|
||||
var lidarrIdentifier = $"Lidarr_{contentBlockerConfig.Lidarr.BlocklistPath}";
|
||||
string lidarrHash = GenerateSettingsHash(malwareBlockerConfig.Lidarr);
|
||||
var lidarrInterval = GetLoadInterval(malwareBlockerConfig.Lidarr.BlocklistPath);
|
||||
var lidarrIdentifier = $"Lidarr_{malwareBlockerConfig.Lidarr.BlocklistPath}";
|
||||
if (ShouldReloadBlocklist(lidarrIdentifier, lidarrInterval) || !_configHashes.TryGetValue(InstanceType.Lidarr, out string? oldLidarrHash) || lidarrHash != oldLidarrHash)
|
||||
{
|
||||
_logger.LogDebug("Loading Lidarr blocklist");
|
||||
|
||||
await LoadPatternsAndRegexesAsync(contentBlockerConfig.Lidarr, InstanceType.Lidarr);
|
||||
await LoadPatternsAndRegexesAsync(malwareBlockerConfig.Lidarr, InstanceType.Lidarr);
|
||||
_configHashes[InstanceType.Lidarr] = lidarrHash;
|
||||
_lastLoadTimes[lidarrIdentifier] = DateTime.UtcNow;
|
||||
changedCount++;
|
||||
}
|
||||
|
||||
// Check and update Readarr blocklist if needed
|
||||
string readarrHash = GenerateSettingsHash(contentBlockerConfig.Readarr);
|
||||
var readarrInterval = GetLoadInterval(contentBlockerConfig.Readarr.BlocklistPath);
|
||||
var readarrIdentifier = $"Readarr_{contentBlockerConfig.Readarr.BlocklistPath}";
|
||||
string readarrHash = GenerateSettingsHash(malwareBlockerConfig.Readarr);
|
||||
var readarrInterval = GetLoadInterval(malwareBlockerConfig.Readarr.BlocklistPath);
|
||||
var readarrIdentifier = $"Readarr_{malwareBlockerConfig.Readarr.BlocklistPath}";
|
||||
if (ShouldReloadBlocklist(readarrIdentifier, readarrInterval) || !_configHashes.TryGetValue(InstanceType.Readarr, out string? oldReadarrHash) || readarrHash != oldReadarrHash)
|
||||
{
|
||||
_logger.LogDebug("Loading Readarr blocklist");
|
||||
|
||||
await LoadPatternsAndRegexesAsync(contentBlockerConfig.Readarr, InstanceType.Readarr);
|
||||
await LoadPatternsAndRegexesAsync(malwareBlockerConfig.Readarr, InstanceType.Readarr);
|
||||
_configHashes[InstanceType.Readarr] = readarrHash;
|
||||
_lastLoadTimes[readarrIdentifier] = DateTime.UtcNow;
|
||||
changedCount++;
|
||||
}
|
||||
|
||||
// Check and update Whisparr blocklist if needed
|
||||
string whisparrHash = GenerateSettingsHash(contentBlockerConfig.Whisparr);
|
||||
var whisparrInterval = GetLoadInterval(contentBlockerConfig.Whisparr.BlocklistPath);
|
||||
var whisparrIdentifier = $"Whisparr_{contentBlockerConfig.Whisparr.BlocklistPath}";
|
||||
string whisparrHash = GenerateSettingsHash(malwareBlockerConfig.Whisparr);
|
||||
var whisparrInterval = GetLoadInterval(malwareBlockerConfig.Whisparr.BlocklistPath);
|
||||
var whisparrIdentifier = $"Whisparr_{malwareBlockerConfig.Whisparr.BlocklistPath}";
|
||||
if (ShouldReloadBlocklist(whisparrIdentifier, whisparrInterval) || !_configHashes.TryGetValue(InstanceType.Whisparr, out string? oldWhisparrHash) || whisparrHash != oldWhisparrHash)
|
||||
{
|
||||
_logger.LogDebug("Loading Whisparr blocklist");
|
||||
|
||||
await LoadPatternsAndRegexesAsync(contentBlockerConfig.Whisparr, InstanceType.Whisparr);
|
||||
await LoadPatternsAndRegexesAsync(malwareBlockerConfig.Whisparr, InstanceType.Whisparr);
|
||||
_configHashes[InstanceType.Whisparr] = whisparrHash;
|
||||
_lastLoadTimes[whisparrIdentifier] = DateTime.UtcNow;
|
||||
changedCount++;
|
||||
@@ -3,7 +3,7 @@ using System.Text.RegularExpressions;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.ContentBlocker;
|
||||
namespace Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
|
||||
public class FilenameEvaluator : IFilenameEvaluator
|
||||
{
|
||||
@@ -2,7 +2,7 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.ContentBlocker;
|
||||
namespace Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
|
||||
public interface IFilenameEvaluator
|
||||
{
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications;
|
||||
|
||||
public interface INotificationProviderFactory
|
||||
{
|
||||
INotificationProvider CreateProvider(NotificationProviderDto config);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
|
||||
public sealed record SlowSpeedStrikeNotification : ArrNotification
|
||||
{
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
|
||||
public sealed record SlowStrikeNotification : ArrNotification
|
||||
public sealed record SlowTimeStrikeNotification : ArrNotification
|
||||
{
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
107
code/backend/Cleanuparr.Infrastructure/Logging/ArchiveHooks.cs
Normal file
107
code/backend/Cleanuparr.Infrastructure/Logging/ArchiveHooks.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
# Enhanced Logging System
|
||||
|
||||
## Overview
|
||||
|
||||
The enhanced logging system provides a structured approach to logging with the following features:
|
||||
|
||||
- **Category-based logging**: Organize logs by functional areas (SYSTEM, API, JOBS, etc.)
|
||||
- **Job name context**: Add job name to logs for background operations
|
||||
- **Instance context**: Add instance names (Sonarr, Radarr, etc.) to relevant logs
|
||||
- **Multiple output targets**: Console, files, and real-time SignalR streaming
|
||||
- **Category-specific log files**: Separate log files for different categories
|
||||
|
||||
## Using the Logging System
|
||||
|
||||
### Adding Category to Logs
|
||||
|
||||
```csharp
|
||||
// Using category constants
|
||||
logger.WithCategory(LoggingCategoryConstants.System)
|
||||
.LogInformation("This is a system log");
|
||||
|
||||
// Using direct category name
|
||||
logger.WithCategory("API")
|
||||
.LogInformation("This is an API log");
|
||||
```
|
||||
|
||||
### Adding Job Name Context
|
||||
|
||||
```csharp
|
||||
logger.WithCategory(LoggingCategoryConstants.Jobs)
|
||||
.WithJob("ContentBlocker")
|
||||
.LogInformation("Starting content blocking job");
|
||||
```
|
||||
|
||||
### Adding Instance Name Context
|
||||
|
||||
```csharp
|
||||
logger.WithCategory(LoggingCategoryConstants.Sonarr)
|
||||
.WithInstance("Sonarr")
|
||||
.LogInformation("Processing Sonarr data");
|
||||
```
|
||||
|
||||
### Combined Context Example
|
||||
|
||||
```csharp
|
||||
logger.WithCategory(LoggingCategoryConstants.Jobs)
|
||||
.WithJob("QueueCleaner")
|
||||
.WithInstance("Radarr")
|
||||
.LogInformation("Cleaning Radarr queue");
|
||||
```
|
||||
|
||||
## Log Storage
|
||||
|
||||
Logs are stored in the following locations:
|
||||
|
||||
- **Main log file**: `{config_path}/logs/Cleanuparr-.txt`
|
||||
- **Category logs**: `{config_path}/logs/{category}-.txt` (e.g., `system-.txt`, `api-.txt`)
|
||||
|
||||
The log files use rolling file behavior:
|
||||
- Daily rotation
|
||||
- 10MB size limit for main log files
|
||||
- 5MB size limit for category-specific logs
|
||||
|
||||
## SignalR Integration
|
||||
|
||||
The logging system includes real-time streaming via SignalR:
|
||||
|
||||
- **Hub URL**: `/hubs/logs`
|
||||
- **Hub class**: `LogHub`
|
||||
- **Event name**: `ReceiveLog`
|
||||
|
||||
### Requesting Recent Logs
|
||||
|
||||
When a client connects, it can request recent logs from the buffer:
|
||||
|
||||
```javascript
|
||||
await connection.invoke("RequestRecentLogs");
|
||||
```
|
||||
|
||||
### Log Message Format
|
||||
|
||||
Each log message contains:
|
||||
- `timestamp`: The time the log was created
|
||||
- `level`: Log level (Information, Warning, Error, etc.)
|
||||
- `message`: The log message text
|
||||
- `exception`: Exception details (if present)
|
||||
- `category`: The log category
|
||||
- `jobName`: The job name (if present)
|
||||
- `instanceName`: The instance name (if present)
|
||||
|
||||
## How It All Works
|
||||
|
||||
1. The logging system is initialized during application startup
|
||||
2. Logs are written to the console in real-time
|
||||
3. Logs are written to files based on their category
|
||||
4. Logs are buffered and sent to connected SignalR clients
|
||||
5. New clients can request recent logs from the buffer
|
||||
|
||||
## Configuration Options
|
||||
|
||||
The logging configuration is loaded from the `Logging` section in appsettings.json:
|
||||
|
||||
```json
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": "Information",
|
||||
"SignalR": {
|
||||
"Enabled": true,
|
||||
"BufferSize": 100
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Standard Categories
|
||||
|
||||
Use the `LoggingCategoryConstants` class to ensure consistent category naming:
|
||||
|
||||
- `LoggingCategoryConstants.System`: System-level logs
|
||||
- `LoggingCategoryConstants.Api`: API-related logs
|
||||
- `LoggingCategoryConstants.Jobs`: Job execution logs
|
||||
- `LoggingCategoryConstants.Notifications`: User notification logs
|
||||
- `LoggingCategoryConstants.Sonarr`: Sonarr-related logs
|
||||
- `LoggingCategoryConstants.Radarr`: Radarr-related logs
|
||||
- `LoggingCategoryConstants.Lidarr`: Lidarr-related logs
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,14 +2,15 @@ using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence.Converters;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
717
code/backend/Cleanuparr.Persistence/Migrations/Data/20250830230846_ReworkNotificationSystem.Designer.cs
generated
Normal file
717
code/backend/Cleanuparr.Persistence/Migrations/Data/20250830230846_ReworkNotificationSystem.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
|
||||
namespace Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
|
||||
/// <summary>
|
||||
/// Settings for a blocklist
|
||||
@@ -2,7 +2,7 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using ValidationException = System.ComponentModel.DataAnnotations.ValidationException;
|
||||
|
||||
namespace Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
|
||||
namespace Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
|
||||
public sealed record ContentBlockerConfig : IJobConfig
|
||||
{
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@ export const routes: Routes = [
|
||||
canDeactivate: [pendingChangesGuard]
|
||||
},
|
||||
{
|
||||
path: 'content-blocker',
|
||||
loadComponent: () => import('./settings/content-blocker/content-blocker-settings.component').then(m => m.ContentBlockerSettingsComponent),
|
||||
path: 'malware-blocker',
|
||||
loadComponent: () => import('./settings/malware-blocker/malware-blocker-settings.component').then(m => m.MalwareBlockerSettingsComponent),
|
||||
canDeactivate: [pendingChangesGuard]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { HttpClient } from "@angular/common/http";
|
||||
import { Injectable, inject } from "@angular/core";
|
||||
import { Observable, catchError, map, throwError } from "rxjs";
|
||||
import { JobSchedule, QueueCleanerConfig, ScheduleUnit } from "../../shared/models/queue-cleaner-config.model";
|
||||
import { ContentBlockerConfig, JobSchedule as ContentBlockerJobSchedule, ScheduleUnit as ContentBlockerScheduleUnit } from "../../shared/models/content-blocker-config.model";
|
||||
import { MalwareBlockerConfig as MalwareBlockerConfig, JobSchedule as MalwareBlockerJobSchedule, ScheduleUnit as MalwareBlockerScheduleUnit } from "../../shared/models/malware-blocker-config.model";
|
||||
import { SonarrConfig } from "../../shared/models/sonarr-config.model";
|
||||
import { RadarrConfig } from "../../shared/models/radarr-config.model";
|
||||
import { LidarrConfig } from "../../shared/models/lidarr-config.model";
|
||||
@@ -81,15 +81,15 @@ export class ConfigurationService {
|
||||
/**
|
||||
* Get content blocker configuration
|
||||
*/
|
||||
getContentBlockerConfig(): Observable<ContentBlockerConfig> {
|
||||
return this.http.get<ContentBlockerConfig>(this.ApplicationPathService.buildApiUrl('/configuration/content_blocker')).pipe(
|
||||
getMalwareBlockerConfig(): Observable<MalwareBlockerConfig> {
|
||||
return this.http.get<MalwareBlockerConfig>(this.ApplicationPathService.buildApiUrl('/configuration/malware_blocker')).pipe(
|
||||
map((response) => {
|
||||
response.jobSchedule = this.tryExtractContentBlockerJobScheduleFromCron(response.cronExpression);
|
||||
response.jobSchedule = this.tryExtractMalwareBlockerJobScheduleFromCron(response.cronExpression);
|
||||
return response;
|
||||
}),
|
||||
catchError((error) => {
|
||||
console.error("Error fetching content blocker config:", error);
|
||||
return throwError(() => new Error("Failed to load content blocker configuration"));
|
||||
console.error("Error fetching Malware Blocker config:", error);
|
||||
return throwError(() => new Error("Failed to load Malware Blocker configuration"));
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -97,14 +97,14 @@ export class ConfigurationService {
|
||||
/**
|
||||
* Update content blocker configuration
|
||||
*/
|
||||
updateContentBlockerConfig(config: ContentBlockerConfig): Observable<void> {
|
||||
updateMalwareBlockerConfig(config: MalwareBlockerConfig): Observable<void> {
|
||||
// Generate cron expression if using basic scheduling
|
||||
if (!config.useAdvancedScheduling && config.jobSchedule) {
|
||||
config.cronExpression = this.convertContentBlockerJobScheduleToCron(config.jobSchedule);
|
||||
config.cronExpression = this.convertMalwareBlockerJobScheduleToCron(config.jobSchedule);
|
||||
}
|
||||
return this.http.put<void>(this.ApplicationPathService.buildApiUrl('/configuration/content_blocker'), config).pipe(
|
||||
return this.http.put<void>(this.ApplicationPathService.buildApiUrl('/configuration/malware_blocker'), config).pipe(
|
||||
catchError((error) => {
|
||||
console.error("Error updating content blocker config:", error);
|
||||
console.error("Error updating Malware Blocker config:", error);
|
||||
const errorMessage = ErrorHandlerUtil.extractErrorMessage(error);
|
||||
return throwError(() => new Error(errorMessage));
|
||||
})
|
||||
@@ -188,10 +188,10 @@ export class ConfigurationService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to extract a ContentBlockerJobSchedule from a cron expression
|
||||
* Try to extract a MalwareBlockerJobSchedule from a cron expression
|
||||
* Only handles the simple cases we're generating
|
||||
*/
|
||||
private tryExtractContentBlockerJobScheduleFromCron(cronExpression: string): ContentBlockerJobSchedule | undefined {
|
||||
private tryExtractMalwareBlockerJobScheduleFromCron(cronExpression: string): MalwareBlockerJobSchedule | undefined {
|
||||
// Patterns we support:
|
||||
// Seconds: */n * * ? * * * or 0/n * * ? * * * (Quartz.NET format)
|
||||
// Minutes: 0 */n * ? * * * or 0 0/n * ? * * * (Quartz.NET format)
|
||||
@@ -205,7 +205,7 @@ export class ConfigurationService {
|
||||
if ((parts[0].startsWith("*/") || parts[0].startsWith("0/")) && parts[1] === "*") {
|
||||
const seconds = parseInt(parts[0].substring(2));
|
||||
if (!isNaN(seconds) && seconds > 0 && seconds < 60) {
|
||||
return { every: seconds, type: ContentBlockerScheduleUnit.Seconds };
|
||||
return { every: seconds, type: MalwareBlockerScheduleUnit.Seconds };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,7 +213,7 @@ export class ConfigurationService {
|
||||
if (parts[0] === "0" && (parts[1].startsWith("*/") || parts[1].startsWith("0/"))) {
|
||||
const minutes = parseInt(parts[1].substring(2));
|
||||
if (!isNaN(minutes) && minutes > 0 && minutes < 60) {
|
||||
return { every: minutes, type: ContentBlockerScheduleUnit.Minutes };
|
||||
return { every: minutes, type: MalwareBlockerScheduleUnit.Minutes };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,7 +221,7 @@ export class ConfigurationService {
|
||||
if (parts[0] === "0" && parts[1] === "0" && (parts[2].startsWith("*/") || parts[2].startsWith("0/"))) {
|
||||
const hours = parseInt(parts[2].substring(2));
|
||||
if (!isNaN(hours) && hours > 0 && hours < 24) {
|
||||
return { every: hours, type: ContentBlockerScheduleUnit.Hours };
|
||||
return { every: hours, type: MalwareBlockerScheduleUnit.Hours };
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -232,27 +232,27 @@ export class ConfigurationService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a ContentBlockerJobSchedule to a cron expression
|
||||
* Convert a MalwareBlockerJobSchedule to a cron expression
|
||||
*/
|
||||
private convertContentBlockerJobScheduleToCron(schedule: ContentBlockerJobSchedule): string {
|
||||
private convertMalwareBlockerJobScheduleToCron(schedule: MalwareBlockerJobSchedule): string {
|
||||
if (!schedule || schedule.every <= 0) {
|
||||
return "0/5 * * * * ?"; // Default: every 5 seconds (Quartz.NET format)
|
||||
}
|
||||
|
||||
switch (schedule.type) {
|
||||
case ContentBlockerScheduleUnit.Seconds:
|
||||
case MalwareBlockerScheduleUnit.Seconds:
|
||||
if (schedule.every < 60) {
|
||||
return `0/${schedule.every} * * ? * * *`; // Quartz.NET format
|
||||
}
|
||||
break;
|
||||
|
||||
case ContentBlockerScheduleUnit.Minutes:
|
||||
case MalwareBlockerScheduleUnit.Minutes:
|
||||
if (schedule.every < 60) {
|
||||
return `0 0/${schedule.every} * ? * * *`; // Quartz.NET format
|
||||
}
|
||||
break;
|
||||
|
||||
case ContentBlockerScheduleUnit.Hours:
|
||||
case MalwareBlockerScheduleUnit.Hours:
|
||||
if (schedule.every < 24) {
|
||||
return `0 0 0/${schedule.every} ? * * *`; // Quartz.NET format
|
||||
}
|
||||
|
||||
@@ -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': {
|
||||
@@ -63,8 +69,8 @@ export class DocumentationService {
|
||||
'unlinkedIgnoredRootDir': 'ignored-root-directory',
|
||||
'unlinkedCategories': 'unlinked-categories'
|
||||
},
|
||||
'content-blocker': {
|
||||
'enabled': 'enable-content-blocker',
|
||||
'malware-blocker': {
|
||||
'enabled': 'enable-malware-blocker',
|
||||
'useAdvancedScheduling': 'scheduling-mode',
|
||||
'cronExpression': 'cron-expression',
|
||||
'jobSchedule.every': 'run-schedule',
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
25
code/frontend/src/app/core/validators/url.validator.ts
Normal file
25
code/frontend/src/app/core/validators/url.validator.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -108,7 +108,7 @@ export class SidebarContentComponent implements OnInit, OnChanges, OnDestroy {
|
||||
// Settings routes
|
||||
{ route: '/general-settings', navigationPath: ['settings', 'general'] },
|
||||
{ route: '/queue-cleaner', navigationPath: ['settings', 'queue-cleaner'] },
|
||||
{ route: '/content-blocker', navigationPath: ['settings', 'content-blocker'] },
|
||||
{ route: '/malware-blocker', navigationPath: ['settings', 'malware-blocker'] },
|
||||
{ route: '/download-cleaner', navigationPath: ['settings', 'download-cleaner'] },
|
||||
{ route: '/notifications', navigationPath: ['settings', 'notifications'] },
|
||||
|
||||
@@ -224,7 +224,7 @@ export class SidebarContentComponent implements OnInit, OnChanges, OnDestroy {
|
||||
children: [
|
||||
{ id: 'general', label: 'General', icon: 'pi pi-cog', route: '/general-settings' },
|
||||
{ id: 'queue-cleaner', label: 'Queue Cleaner', icon: 'pi pi-list', route: '/queue-cleaner' },
|
||||
{ id: 'content-blocker', label: 'Malware Blocker', icon: 'pi pi-shield', route: '/content-blocker' },
|
||||
{ id: 'malware-blocker', label: 'Malware Blocker', icon: 'pi pi-shield', route: '/malware-blocker' },
|
||||
{ id: 'download-cleaner', label: 'Download Cleaner', icon: 'pi pi-trash', route: '/download-cleaner' },
|
||||
{ id: 'notifications', label: 'Notifications', icon: 'pi pi-bell', route: '/notifications' }
|
||||
]
|
||||
|
||||
@@ -11,14 +11,6 @@
|
||||
></p-tag>
|
||||
</div>
|
||||
<div class="log-controls flex align-items-center gap-2">
|
||||
<div class="auto-scroll-toggle">
|
||||
<p-inputSwitch
|
||||
[ngModel]="autoScroll()"
|
||||
(ngModelChange)="setAutoScroll($event)"
|
||||
id="autoScrollToggle"
|
||||
></p-inputSwitch>
|
||||
<label for="autoScrollToggle" class="ml-2 text-sm">Auto-scroll</label>
|
||||
</div>
|
||||
<button
|
||||
pButton
|
||||
icon="pi pi-download"
|
||||
@@ -112,7 +104,7 @@
|
||||
|
||||
<div class="card-content">
|
||||
<!-- Console-style Logs View -->
|
||||
<div class="viewer-console" #logsConsole>
|
||||
<div class="viewer-console">
|
||||
<!-- Logs List -->
|
||||
<div class="items-list" *ngIf="filteredLogs().length > 0; else emptyLogs">
|
||||
<div *ngFor="let log of filteredLogs(); let i = index" class="item-entry" [id]="'log-' + i">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, OnInit, OnDestroy, signal, computed, inject, ViewChild, ElementRef } from '@angular/core';
|
||||
import { Component, OnInit, OnDestroy, signal, computed, inject, ViewChild } from '@angular/core';
|
||||
import { DatePipe, NgFor, NgIf } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Subject, takeUntil, debounceTime, distinctUntilChanged } from 'rxjs';
|
||||
@@ -14,7 +14,6 @@ import { CardModule } from 'primeng/card';
|
||||
import { ToolbarModule } from 'primeng/toolbar';
|
||||
import { TooltipModule } from 'primeng/tooltip';
|
||||
import { ProgressSpinnerModule } from 'primeng/progressspinner';
|
||||
import { InputSwitchModule } from 'primeng/inputswitch';
|
||||
|
||||
// Services & Models
|
||||
import { AppHubService } from '../../core/services/app-hub.service';
|
||||
@@ -39,8 +38,7 @@ import { MenuItem } from 'primeng/api';
|
||||
ToolbarModule,
|
||||
TooltipModule,
|
||||
ProgressSpinnerModule,
|
||||
MenuModule,
|
||||
InputSwitchModule
|
||||
MenuModule
|
||||
],
|
||||
providers: [AppHubService],
|
||||
templateUrl: './logs-viewer.component.html',
|
||||
@@ -52,13 +50,11 @@ export class LogsViewerComponent implements OnInit, OnDestroy {
|
||||
private clipboard = inject(Clipboard);
|
||||
private search$ = new Subject<string>();
|
||||
|
||||
@ViewChild('logsConsole') logsConsole!: ElementRef;
|
||||
@ViewChild('exportMenu') exportMenu: any;
|
||||
|
||||
// Signals for reactive state
|
||||
logs = signal<LogEntry[]>([]);
|
||||
isConnected = signal<boolean>(false);
|
||||
autoScroll = signal<boolean>(true);
|
||||
expandedLogs: { [key: number]: boolean } = {};
|
||||
|
||||
// Filter state
|
||||
@@ -92,7 +88,8 @@ export class LogsViewerComponent implements OnInit, OnDestroy {
|
||||
(log.exception && log.exception.toLowerCase().includes(search)));
|
||||
}
|
||||
|
||||
return filtered;
|
||||
// Sort by timestamp descending (newest first)
|
||||
return filtered.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
||||
});
|
||||
|
||||
levels = computed(() => {
|
||||
@@ -117,9 +114,6 @@ export class LogsViewerComponent implements OnInit, OnDestroy {
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((logs: LogEntry[]) => {
|
||||
this.logs.set(logs);
|
||||
if (this.autoScroll()) {
|
||||
this.scrollToBottom();
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe to connection status
|
||||
@@ -141,12 +135,6 @@ export class LogsViewerComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
ngAfterViewChecked(): void {
|
||||
if (this.autoScroll() && this.logsConsole) {
|
||||
this.scrollToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
@@ -345,24 +333,4 @@ export class LogsViewerComponent implements OnInit, OnDestroy {
|
||||
URL.revokeObjectURL(url);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to the bottom of the logs container
|
||||
*/
|
||||
private scrollToBottom(): void {
|
||||
if (this.logsConsole && this.logsConsole.nativeElement) {
|
||||
const element = this.logsConsole.nativeElement;
|
||||
element.scrollTop = element.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the auto-scroll state
|
||||
*/
|
||||
setAutoScroll(value: boolean): void {
|
||||
this.autoScroll.set(value);
|
||||
if (value) {
|
||||
this.scrollToBottom();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { patchState, signalStore, withHooks, withMethods, withState } from '@ngrx/signals';
|
||||
import { rxMethod } from '@ngrx/signals/rxjs-interop';
|
||||
import { ContentBlockerConfig, JobSchedule, ScheduleUnit } from '../../shared/models/content-blocker-config.model';
|
||||
import { MalwareBlockerConfig as MalwareBlockerConfig, JobSchedule, ScheduleUnit } from '../../shared/models/malware-blocker-config.model';
|
||||
import { ConfigurationService } from '../../core/services/configuration.service';
|
||||
import { EMPTY, Observable, catchError, switchMap, tap, throwError } from 'rxjs';
|
||||
import { ErrorHandlerUtil } from '../../core/utils/error-handler.util';
|
||||
|
||||
export interface ContentBlockerConfigState {
|
||||
config: ContentBlockerConfig | null;
|
||||
export interface MalwareBlockerConfigState {
|
||||
config: MalwareBlockerConfig | null;
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
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: ContentBlockerConfigState = {
|
||||
const initialState: MalwareBlockerConfigState = {
|
||||
config: null,
|
||||
loading: false,
|
||||
saving: false,
|
||||
@@ -23,17 +23,17 @@ const initialState: ContentBlockerConfigState = {
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ContentBlockerConfigStore extends signalStore(
|
||||
export class MalwareBlockerConfigStore extends signalStore(
|
||||
withState(initialState),
|
||||
withMethods((store, configService = inject(ConfigurationService)) => ({
|
||||
|
||||
/**
|
||||
* Load the content blocker configuration
|
||||
* Load the malware blocker configuration
|
||||
*/
|
||||
loadConfig: rxMethod<void>(
|
||||
pipe => pipe.pipe(
|
||||
tap(() => patchState(store, { loading: true, loadError: null, saveError: null })),
|
||||
switchMap(() => configService.getContentBlockerConfig().pipe(
|
||||
switchMap(() => configService.getMalwareBlockerConfig().pipe(
|
||||
tap({
|
||||
next: (config) => patchState(store, { config, loading: false, loadError: null }),
|
||||
error: (error) => {
|
||||
@@ -59,10 +59,10 @@ export class ContentBlockerConfigStore extends signalStore(
|
||||
/**
|
||||
* Save the content blocker configuration
|
||||
*/
|
||||
saveConfig: rxMethod<ContentBlockerConfig>(
|
||||
(config$: Observable<ContentBlockerConfig>) => config$.pipe(
|
||||
saveConfig: rxMethod<MalwareBlockerConfig>(
|
||||
(config$: Observable<MalwareBlockerConfig>) => config$.pipe(
|
||||
tap(() => patchState(store, { saving: true, saveError: null })),
|
||||
switchMap(config => configService.updateContentBlockerConfig(config).pipe(
|
||||
switchMap(config => configService.updateMalwareBlockerConfig(config).pipe(
|
||||
tap({
|
||||
next: () => {
|
||||
// Don't set config - let the form stay as-is with string enum values
|
||||
@@ -94,7 +94,7 @@ export class ContentBlockerConfigStore extends signalStore(
|
||||
/**
|
||||
* Update config in the store without saving to the backend
|
||||
*/
|
||||
updateConfigLocally(config: Partial<ContentBlockerConfig>) {
|
||||
updateConfigLocally(config: Partial<MalwareBlockerConfig>) {
|
||||
const currentConfig = store.config();
|
||||
if (currentConfig) {
|
||||
patchState(store, {
|
||||
@@ -16,15 +16,15 @@
|
||||
<div class="card-content">
|
||||
<!-- Loading/Error State Component -->
|
||||
<app-loading-error-state
|
||||
*ngIf="contentBlockerLoading() || contentBlockerLoadError()"
|
||||
[loading]="contentBlockerLoading()"
|
||||
[error]="contentBlockerLoadError()"
|
||||
*ngIf="malwareBlockerLoading() || malwareBlockerLoadError()"
|
||||
[loading]="malwareBlockerLoading()"
|
||||
[error]="malwareBlockerLoadError()"
|
||||
loadingMessage="Loading settings..."
|
||||
errorMessage="Could not connect to server"
|
||||
></app-loading-error-state>
|
||||
|
||||
<!-- Form Content - only shown when not loading and no error -->
|
||||
<form *ngIf="!contentBlockerLoading() && !contentBlockerLoadError()" [formGroup]="contentBlockerForm" class="p-fluid">
|
||||
<form *ngIf="!malwareBlockerLoading() && !malwareBlockerLoadError()" [formGroup]="malwareBlockerForm" class="p-fluid">
|
||||
<!-- Main Settings -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
@@ -62,7 +62,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Basic Schedule Controls - shown when useAdvancedScheduling is false -->
|
||||
<div class="field-row" formGroupName="jobSchedule" *ngIf="!contentBlockerForm.get('useAdvancedScheduling')?.value">
|
||||
<div class="field-row" formGroupName="jobSchedule" *ngIf="!malwareBlockerForm.get('useAdvancedScheduling')?.value">
|
||||
<label class="field-label">
|
||||
Run Schedule
|
||||
</label>
|
||||
@@ -88,12 +88,12 @@
|
||||
</p-selectButton>
|
||||
</div>
|
||||
<small *ngIf="hasNestedError('jobSchedule', 'every', 'required')" class="p-error">This field is required</small>
|
||||
<small class="form-helper-text">How often the content blocker should run</small>
|
||||
<small class="form-helper-text">How often the Malware Blocker should run</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Schedule Controls - shown when useAdvancedScheduling is true -->
|
||||
<div class="field-row" *ngIf="contentBlockerForm.get('useAdvancedScheduling')?.value">
|
||||
<div class="field-row" *ngIf="malwareBlockerForm.get('useAdvancedScheduling')?.value">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('cronExpression')"
|
||||
@@ -152,7 +152,7 @@
|
||||
<!-- Arr Service Settings in Accordion -->
|
||||
<p-accordion [multiple]="false" [value]="activeAccordionIndices" styleClass="mt-3">
|
||||
<!-- Sonarr Settings -->
|
||||
<p-accordion-panel [disabled]="!contentBlockerForm.get('enabled')?.value" [value]="0">
|
||||
<p-accordion-panel [disabled]="!malwareBlockerForm.get('enabled')?.value" [value]="0">
|
||||
<p-accordion-header>
|
||||
<ng-template #toggleicon let-active="active">
|
||||
@if (active) {
|
||||
@@ -222,7 +222,7 @@
|
||||
</p-accordion-panel>
|
||||
|
||||
<!-- Radarr Settings -->
|
||||
<p-accordion-panel [disabled]="!contentBlockerForm.get('enabled')?.value" [value]="1">
|
||||
<p-accordion-panel [disabled]="!malwareBlockerForm.get('enabled')?.value" [value]="1">
|
||||
<p-accordion-header>
|
||||
<ng-template #toggleicon let-active="active">
|
||||
@if (active) {
|
||||
@@ -292,7 +292,7 @@
|
||||
</p-accordion-panel>
|
||||
|
||||
<!-- Lidarr Settings -->
|
||||
<p-accordion-panel [disabled]="!contentBlockerForm.get('enabled')?.value" [value]="2">
|
||||
<p-accordion-panel [disabled]="!malwareBlockerForm.get('enabled')?.value" [value]="2">
|
||||
<p-accordion-header>
|
||||
<ng-template #toggleicon let-active="active">
|
||||
@if (active) {
|
||||
@@ -362,7 +362,7 @@
|
||||
</p-accordion-panel>
|
||||
|
||||
<!-- Readarr Settings -->
|
||||
<p-accordion-panel [disabled]="!contentBlockerForm.get('enabled')?.value" [value]="3">
|
||||
<p-accordion-panel [disabled]="!malwareBlockerForm.get('enabled')?.value" [value]="3">
|
||||
<p-accordion-header>
|
||||
<ng-template #toggleicon let-active="active">
|
||||
@if (active) {
|
||||
@@ -432,7 +432,7 @@
|
||||
</p-accordion-panel>
|
||||
|
||||
<!-- Whisparr Settings -->
|
||||
<p-accordion-panel [disabled]="!contentBlockerForm.get('enabled')?.value" [value]="4">
|
||||
<p-accordion-panel [disabled]="!malwareBlockerForm.get('enabled')?.value" [value]="4">
|
||||
<p-accordion-header>
|
||||
<ng-template #toggleicon let-active="active">
|
||||
@if (active) {
|
||||
@@ -510,9 +510,9 @@
|
||||
label="Save"
|
||||
icon="pi pi-save"
|
||||
class="p-button-primary"
|
||||
[disabled]="(!contentBlockerForm.dirty || !hasActualChanges) || contentBlockerForm.invalid || contentBlockerSaving()"
|
||||
[loading]="contentBlockerSaving()"
|
||||
(click)="saveContentBlockerConfig()"
|
||||
[disabled]="(!malwareBlockerForm.dirty || !hasActualChanges) || malwareBlockerForm.invalid || malwareBlockerSaving()"
|
||||
[loading]="malwareBlockerSaving()"
|
||||
(click)="saveMalwareBlockerConfig()"
|
||||
></button>
|
||||
<button
|
||||
pButton
|
||||
@@ -520,7 +520,7 @@
|
||||
label="Reset"
|
||||
icon="pi pi-refresh"
|
||||
class="p-button-secondary p-button-outlined ml-2"
|
||||
(click)="resetContentBlockerConfig()"
|
||||
(click)="resetMalwareBlockerConfig()"
|
||||
></button>
|
||||
</div>
|
||||
</form>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user