Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b9c347ed6 | ||
|
|
98ccee866d | ||
|
|
911849c6dd | ||
|
|
cce3bb2c4a | ||
|
|
bcc117cd0d | ||
|
|
8e20a68ae2 | ||
|
|
736c146f25 | ||
|
|
6398ef1cc6 | ||
|
|
83e6a289be | ||
|
|
5662118b01 | ||
|
|
22dfc7b40d | ||
|
|
a51e387453 |
7
.github/workflows/docs.yml
vendored
@@ -2,9 +2,9 @@ name: Deploy Docusaurus to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'docs/**'
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -22,6 +22,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: main
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
@@ -19,6 +19,7 @@ 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.Persistence.Models.Configuration.BlacklistSync;
|
||||
using Mapster;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -54,6 +55,23 @@ public class ConfigurationController : ControllerBase
|
||||
_notificationService = notificationService;
|
||||
}
|
||||
|
||||
[HttpGet("blacklist_sync")]
|
||||
public async Task<IActionResult> GetBlacklistSyncConfig()
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var config = await _dataContext.BlacklistSyncConfigs
|
||||
.AsNoTracking()
|
||||
.FirstAsync();
|
||||
return Ok(config);
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("queue_cleaner")]
|
||||
public async Task<IActionResult> GetQueueCleanerConfig()
|
||||
{
|
||||
@@ -116,9 +134,12 @@ public class ConfigurationController : ControllerBase
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
// Return in the expected format with clients wrapper
|
||||
var config = new { clients = clients };
|
||||
return Ok(config);
|
||||
clients = clients
|
||||
.OrderBy(c => c.TypeName)
|
||||
.ThenBy(c => c.Name)
|
||||
.ToList();
|
||||
|
||||
return Ok(new { clients });
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -270,6 +291,11 @@ public class ConfigurationController : ControllerBase
|
||||
.Include(x => x.Instances)
|
||||
.AsNoTracking()
|
||||
.FirstAsync(x => x.Type == InstanceType.Sonarr);
|
||||
|
||||
config.Instances = config.Instances
|
||||
.OrderBy(i => i.Name)
|
||||
.ToList();
|
||||
|
||||
return Ok(config.Adapt<ArrConfigDto>());
|
||||
}
|
||||
finally
|
||||
@@ -288,6 +314,11 @@ public class ConfigurationController : ControllerBase
|
||||
.Include(x => x.Instances)
|
||||
.AsNoTracking()
|
||||
.FirstAsync(x => x.Type == InstanceType.Radarr);
|
||||
|
||||
config.Instances = config.Instances
|
||||
.OrderBy(i => i.Name)
|
||||
.ToList();
|
||||
|
||||
return Ok(config.Adapt<ArrConfigDto>());
|
||||
}
|
||||
finally
|
||||
@@ -306,6 +337,11 @@ public class ConfigurationController : ControllerBase
|
||||
.Include(x => x.Instances)
|
||||
.AsNoTracking()
|
||||
.FirstAsync(x => x.Type == InstanceType.Lidarr);
|
||||
|
||||
config.Instances = config.Instances
|
||||
.OrderBy(i => i.Name)
|
||||
.ToList();
|
||||
|
||||
return Ok(config.Adapt<ArrConfigDto>());
|
||||
}
|
||||
finally
|
||||
@@ -324,6 +360,11 @@ public class ConfigurationController : ControllerBase
|
||||
.Include(x => x.Instances)
|
||||
.AsNoTracking()
|
||||
.FirstAsync(x => x.Type == InstanceType.Readarr);
|
||||
|
||||
config.Instances = config.Instances
|
||||
.OrderBy(i => i.Name)
|
||||
.ToList();
|
||||
|
||||
return Ok(config.Adapt<ArrConfigDto>());
|
||||
}
|
||||
finally
|
||||
@@ -342,6 +383,11 @@ public class ConfigurationController : ControllerBase
|
||||
.Include(x => x.Instances)
|
||||
.AsNoTracking()
|
||||
.FirstAsync(x => x.Type == InstanceType.Whisparr);
|
||||
|
||||
config.Instances = config.Instances
|
||||
.OrderBy(i => i.Name)
|
||||
.ToList();
|
||||
|
||||
return Ok(config.Adapt<ArrConfigDto>());
|
||||
}
|
||||
finally
|
||||
@@ -359,6 +405,7 @@ public class ConfigurationController : ControllerBase
|
||||
var providers = await _dataContext.NotificationConfigs
|
||||
.Include(p => p.NotifiarrConfiguration)
|
||||
.Include(p => p.AppriseConfiguration)
|
||||
.Include(p => p.NtfyConfiguration)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
@@ -382,6 +429,7 @@ public class ConfigurationController : ControllerBase
|
||||
{
|
||||
NotificationProviderType.Notifiarr => p.NotifiarrConfiguration ?? new object(),
|
||||
NotificationProviderType.Apprise => p.AppriseConfiguration ?? new object(),
|
||||
NotificationProviderType.Ntfy => p.NtfyConfiguration ?? new object(),
|
||||
_ => new object()
|
||||
}
|
||||
})
|
||||
@@ -800,6 +848,7 @@ public class ConfigurationController : ControllerBase
|
||||
var existingProvider = await _dataContext.NotificationConfigs
|
||||
.Include(p => p.NotifiarrConfiguration)
|
||||
.Include(p => p.AppriseConfiguration)
|
||||
.Include(p => p.NtfyConfiguration)
|
||||
.FirstOrDefaultAsync(p => p.Id == id);
|
||||
|
||||
if (existingProvider == null)
|
||||
@@ -924,6 +973,266 @@ public class ConfigurationController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("notification_providers/ntfy")]
|
||||
public async Task<IActionResult> CreateNtfyProvider([FromBody] CreateNtfyProviderDto newProvider)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
// Validate required fields
|
||||
if (string.IsNullOrWhiteSpace(newProvider.Name))
|
||||
{
|
||||
return BadRequest("Provider name is required");
|
||||
}
|
||||
|
||||
var duplicateConfig = await _dataContext.NotificationConfigs.CountAsync(x => x.Name == newProvider.Name);
|
||||
|
||||
if (duplicateConfig > 0)
|
||||
{
|
||||
return BadRequest("A provider with this name already exists");
|
||||
}
|
||||
|
||||
// Create provider-specific configuration with validation
|
||||
var ntfyConfig = new NtfyConfig
|
||||
{
|
||||
ServerUrl = newProvider.ServerUrl,
|
||||
Topics = newProvider.Topics,
|
||||
AuthenticationType = newProvider.AuthenticationType,
|
||||
Username = newProvider.Username,
|
||||
Password = newProvider.Password,
|
||||
AccessToken = newProvider.AccessToken,
|
||||
Priority = newProvider.Priority,
|
||||
Tags = newProvider.Tags
|
||||
};
|
||||
|
||||
// Validate the configuration
|
||||
ntfyConfig.Validate();
|
||||
|
||||
// Create the provider entity
|
||||
var provider = new NotificationConfig
|
||||
{
|
||||
Name = newProvider.Name,
|
||||
Type = NotificationProviderType.Ntfy,
|
||||
IsEnabled = newProvider.IsEnabled,
|
||||
OnFailedImportStrike = newProvider.OnFailedImportStrike,
|
||||
OnStalledStrike = newProvider.OnStalledStrike,
|
||||
OnSlowStrike = newProvider.OnSlowStrike,
|
||||
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = newProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = newProvider.OnCategoryChanged,
|
||||
NtfyConfiguration = ntfyConfig
|
||||
};
|
||||
|
||||
// Add the new provider to the database
|
||||
_dataContext.NotificationConfigs.Add(provider);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Clear cache to ensure fresh data on next request
|
||||
await _notificationConfigurationService.InvalidateCacheAsync();
|
||||
|
||||
// Return the provider in DTO format to match frontend expectations
|
||||
var providerDto = new NotificationProviderDto
|
||||
{
|
||||
Id = provider.Id,
|
||||
Name = provider.Name,
|
||||
Type = provider.Type,
|
||||
IsEnabled = provider.IsEnabled,
|
||||
Events = new NotificationEventFlags
|
||||
{
|
||||
OnFailedImportStrike = provider.OnFailedImportStrike,
|
||||
OnStalledStrike = provider.OnStalledStrike,
|
||||
OnSlowStrike = provider.OnSlowStrike,
|
||||
OnQueueItemDeleted = provider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = provider.OnDownloadCleaned,
|
||||
OnCategoryChanged = provider.OnCategoryChanged
|
||||
},
|
||||
Configuration = provider.NtfyConfiguration ?? new object()
|
||||
};
|
||||
|
||||
return CreatedAtAction(nameof(GetNotificationProviders), new { id = provider.Id }, providerDto);
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create Ntfy provider");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("notification_providers/ntfy/{id}")]
|
||||
public async Task<IActionResult> UpdateNtfyProvider(Guid id, [FromBody] UpdateNtfyProviderDto updatedProvider)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
// Find the existing notification provider
|
||||
var existingProvider = await _dataContext.NotificationConfigs
|
||||
.Include(p => p.NtfyConfiguration)
|
||||
.FirstOrDefaultAsync(p => p.Id == id && p.Type == NotificationProviderType.Ntfy);
|
||||
|
||||
if (existingProvider == null)
|
||||
{
|
||||
return NotFound($"Ntfy provider with ID {id} not found");
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (string.IsNullOrWhiteSpace(updatedProvider.Name))
|
||||
{
|
||||
return BadRequest("Provider name is required");
|
||||
}
|
||||
|
||||
var duplicateConfig = await _dataContext.NotificationConfigs
|
||||
.Where(x => x.Id != id)
|
||||
.Where(x => x.Name == updatedProvider.Name)
|
||||
.CountAsync();
|
||||
|
||||
if (duplicateConfig > 0)
|
||||
{
|
||||
return BadRequest("A provider with this name already exists");
|
||||
}
|
||||
|
||||
// Create provider-specific configuration with validation
|
||||
var ntfyConfig = new NtfyConfig
|
||||
{
|
||||
ServerUrl = updatedProvider.ServerUrl,
|
||||
Topics = updatedProvider.Topics,
|
||||
AuthenticationType = updatedProvider.AuthenticationType,
|
||||
Username = updatedProvider.Username,
|
||||
Password = updatedProvider.Password,
|
||||
AccessToken = updatedProvider.AccessToken,
|
||||
Priority = updatedProvider.Priority,
|
||||
Tags = updatedProvider.Tags
|
||||
};
|
||||
|
||||
// Preserve the existing ID if updating
|
||||
if (existingProvider.NtfyConfiguration != null)
|
||||
{
|
||||
ntfyConfig = ntfyConfig with { Id = existingProvider.NtfyConfiguration.Id };
|
||||
}
|
||||
|
||||
// Validate the configuration
|
||||
ntfyConfig.Validate();
|
||||
|
||||
// Create a new provider entity with updated values (records are immutable)
|
||||
var newProvider = existingProvider with
|
||||
{
|
||||
Name = updatedProvider.Name,
|
||||
IsEnabled = updatedProvider.IsEnabled,
|
||||
OnFailedImportStrike = updatedProvider.OnFailedImportStrike,
|
||||
OnStalledStrike = updatedProvider.OnStalledStrike,
|
||||
OnSlowStrike = updatedProvider.OnSlowStrike,
|
||||
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = updatedProvider.OnCategoryChanged,
|
||||
NtfyConfiguration = ntfyConfig,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
// Remove old and add new (EF handles this as an update)
|
||||
_dataContext.NotificationConfigs.Remove(existingProvider);
|
||||
_dataContext.NotificationConfigs.Add(newProvider);
|
||||
|
||||
// Persist the configuration
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Clear cache to ensure fresh data on next request
|
||||
await _notificationConfigurationService.InvalidateCacheAsync();
|
||||
|
||||
// Return the provider in DTO format to match frontend expectations
|
||||
var providerDto = new NotificationProviderDto
|
||||
{
|
||||
Id = newProvider.Id,
|
||||
Name = newProvider.Name,
|
||||
Type = newProvider.Type,
|
||||
IsEnabled = newProvider.IsEnabled,
|
||||
Events = new NotificationEventFlags
|
||||
{
|
||||
OnFailedImportStrike = newProvider.OnFailedImportStrike,
|
||||
OnStalledStrike = newProvider.OnStalledStrike,
|
||||
OnSlowStrike = newProvider.OnSlowStrike,
|
||||
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = newProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = newProvider.OnCategoryChanged
|
||||
},
|
||||
Configuration = newProvider.NtfyConfiguration ?? new object()
|
||||
};
|
||||
|
||||
return Ok(providerDto);
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update Ntfy provider with ID {Id}", id);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("notification_providers/ntfy/test")]
|
||||
public async Task<IActionResult> TestNtfyProvider([FromBody] TestNtfyProviderDto testRequest)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Create configuration for testing with validation
|
||||
var ntfyConfig = new NtfyConfig
|
||||
{
|
||||
ServerUrl = testRequest.ServerUrl,
|
||||
Topics = testRequest.Topics,
|
||||
AuthenticationType = testRequest.AuthenticationType,
|
||||
Username = testRequest.Username,
|
||||
Password = testRequest.Password,
|
||||
AccessToken = testRequest.AccessToken,
|
||||
Priority = testRequest.Priority,
|
||||
Tags = testRequest.Tags
|
||||
};
|
||||
|
||||
// Validate the configuration
|
||||
ntfyConfig.Validate();
|
||||
|
||||
// Create a temporary provider DTO for the test service
|
||||
var providerDto = new NotificationProviderDto
|
||||
{
|
||||
Id = Guid.NewGuid(), // Temporary ID for testing
|
||||
Name = "Test Provider",
|
||||
Type = NotificationProviderType.Ntfy,
|
||||
IsEnabled = true,
|
||||
Events = new NotificationEventFlags
|
||||
{
|
||||
OnFailedImportStrike = true, // Enable for test
|
||||
OnStalledStrike = false,
|
||||
OnSlowStrike = false,
|
||||
OnQueueItemDeleted = false,
|
||||
OnDownloadCleaned = false,
|
||||
OnCategoryChanged = false
|
||||
},
|
||||
Configuration = ntfyConfig
|
||||
};
|
||||
|
||||
// Test the notification provider
|
||||
await _notificationService.SendTestNotificationAsync(providerDto);
|
||||
|
||||
return Ok(new { Message = "Test notification sent successfully", Success = true });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to test Ntfy provider");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("queue_cleaner")]
|
||||
public async Task<IActionResult> UpdateQueueCleanerConfig([FromBody] QueueCleanerConfig newConfig)
|
||||
{
|
||||
@@ -1100,6 +1409,7 @@ public class ConfigurationController : ControllerBase
|
||||
oldConfig.UnlinkedUseTag = newConfigDto.UnlinkedUseTag;
|
||||
oldConfig.UnlinkedIgnoredRootDir = newConfigDto.UnlinkedIgnoredRootDir;
|
||||
oldConfig.UnlinkedCategories = newConfigDto.UnlinkedCategories;
|
||||
oldConfig.IgnoredDownloads = newConfigDto.IgnoredDownloads;
|
||||
|
||||
// Handle Categories collection separately to avoid EF tracking issues
|
||||
// Clear existing categories
|
||||
@@ -1173,6 +1483,9 @@ public class ConfigurationController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
// Handle logging configuration changes
|
||||
var loggingChanged = HasLoggingConfigurationChanged(oldConfig.Log, newConfig.Log);
|
||||
|
||||
newConfig.Adapt(oldConfig, config);
|
||||
|
||||
// Persist the configuration
|
||||
@@ -1185,9 +1498,6 @@ public class ConfigurationController : ControllerBase
|
||||
dynamicHttpClientFactory.UpdateAllClientsFromGeneralConfig(oldConfig);
|
||||
|
||||
_logger.LogInformation("Updated all HTTP client configurations with new general settings");
|
||||
|
||||
// Handle logging configuration changes
|
||||
var loggingChanged = HasLoggingConfigurationChanged(oldConfig.Log, newConfig.Log);
|
||||
|
||||
if (loggingChanged.LevelOnly)
|
||||
{
|
||||
@@ -1213,6 +1523,64 @@ public class ConfigurationController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("blacklist_sync")]
|
||||
public async Task<IActionResult> UpdateBlacklistSyncConfig([FromBody] BlacklistSyncConfig newConfig)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
newConfig.Validate();
|
||||
|
||||
var oldConfig = await _dataContext.BlacklistSyncConfigs
|
||||
.FirstAsync();
|
||||
|
||||
bool enabledChanged = oldConfig.Enabled != newConfig.Enabled;
|
||||
bool becameEnabled = !oldConfig.Enabled && newConfig.Enabled;
|
||||
bool pathChanged = !(oldConfig.BlacklistPath?.Equals(newConfig.BlacklistPath, StringComparison.InvariantCultureIgnoreCase) ?? true);
|
||||
|
||||
var adapterConfig = new TypeAdapterConfig();
|
||||
adapterConfig.NewConfig<BlacklistSyncConfig, BlacklistSyncConfig>()
|
||||
.Ignore(dest => dest.Id)
|
||||
// Cron expression changes are not supported yet for this type of job
|
||||
.Ignore(dest => dest.CronExpression);
|
||||
|
||||
newConfig.Adapt(oldConfig, adapterConfig);
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
if (enabledChanged)
|
||||
{
|
||||
if (becameEnabled)
|
||||
{
|
||||
_logger.LogInformation("BlacklistSynchronizer enabled, starting job");
|
||||
await _jobManagementService.StartJob(JobType.BlacklistSynchronizer, null, newConfig.CronExpression);
|
||||
await _jobManagementService.TriggerJobOnce(JobType.BlacklistSynchronizer);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("BlacklistSynchronizer disabled, stopping the job");
|
||||
await _jobManagementService.StopJob(JobType.BlacklistSynchronizer);
|
||||
}
|
||||
}
|
||||
else if (pathChanged && oldConfig.Enabled)
|
||||
{
|
||||
_logger.LogDebug("BlacklistSynchronizer path changed");
|
||||
await _jobManagementService.TriggerJobOnce(JobType.BlacklistSynchronizer);
|
||||
}
|
||||
|
||||
return Ok(new { Message = "BlacklistSynchronizer configuration updated successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save BlacklistSync configuration");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("sonarr")]
|
||||
public async Task<IActionResult> UpdateSonarrConfig([FromBody] UpdateSonarrConfigDto newConfigDto)
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Cleanuparr.Infrastructure.Features.Notifications;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
|
||||
|
||||
namespace Cleanuparr.Api.DependencyInjection;
|
||||
|
||||
@@ -10,6 +11,7 @@ public static class NotificationsDI
|
||||
services
|
||||
.AddScoped<INotifiarrProxy, NotifiarrProxy>()
|
||||
.AddScoped<IAppriseProxy, AppriseProxy>()
|
||||
.AddScoped<INtfyProxy, NtfyProxy>()
|
||||
.AddScoped<INotificationConfigurationService, NotificationConfigurationService>()
|
||||
.AddScoped<INotificationProviderFactory, NotificationProviderFactory>()
|
||||
.AddScoped<NotificationProviderFactory>()
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Cleanuparr.Application.Features.BlacklistSync;
|
||||
using Cleanuparr.Application.Features.DownloadCleaner;
|
||||
using Cleanuparr.Application.Features.DownloadClient;
|
||||
using Cleanuparr.Application.Features.MalwareBlocker;
|
||||
using Cleanuparr.Application.Features.QueueCleaner;
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
@@ -12,11 +14,11 @@ using Cleanuparr.Infrastructure.Features.Files;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Features.Security;
|
||||
using Cleanuparr.Infrastructure.Helpers;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Infrastructure.Services;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Infrastructure.Interceptors;
|
||||
using Infrastructure.Verticals.Files;
|
||||
|
||||
namespace Cleanuparr.Api.DependencyInjection;
|
||||
@@ -40,6 +42,7 @@ public static class ServicesDI
|
||||
.AddScoped<WhisparrClient>()
|
||||
.AddScoped<ArrClientFactory>()
|
||||
.AddScoped<QueueCleaner>()
|
||||
.AddScoped<BlacklistSynchronizer>()
|
||||
.AddScoped<MalwareBlocker>()
|
||||
.AddScoped<DownloadCleaner>()
|
||||
.AddScoped<IQueueItemRemover, QueueItemRemover>()
|
||||
@@ -51,6 +54,7 @@ public static class ServicesDI
|
||||
.AddScoped<ArrQueueIterator>()
|
||||
.AddScoped<DownloadServiceFactory>()
|
||||
.AddScoped<IStriker, Striker>()
|
||||
.AddScoped<FileReader>()
|
||||
.AddSingleton<IJobManagementService, JobManagementService>()
|
||||
.AddSingleton<BlocklistProvider>();
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
using Cleanuparr.Application.Features.BlacklistSync;
|
||||
using Cleanuparr.Application.Features.DownloadCleaner;
|
||||
using Cleanuparr.Application.Features.DownloadClient;
|
||||
using Cleanuparr.Application.Features.MalwareBlocker;
|
||||
using Cleanuparr.Application.Features.QueueCleaner;
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
@@ -8,6 +10,8 @@ using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Quartz;
|
||||
@@ -45,12 +49,12 @@ public class BackgroundJobManager : IHostedService
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Starting BackgroundJobManager");
|
||||
_logger.LogDebug("Starting BackgroundJobManager");
|
||||
_scheduler = await _schedulerFactory.GetScheduler(cancellationToken);
|
||||
|
||||
await InitializeJobsFromConfiguration(cancellationToken);
|
||||
|
||||
_logger.LogInformation("BackgroundJobManager started");
|
||||
_logger.LogDebug("BackgroundJobManager started");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -64,15 +68,15 @@ public class BackgroundJobManager : IHostedService
|
||||
/// </summary>
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Stopping BackgroundJobManager");
|
||||
_logger.LogDebug("Stopping BackgroundJobManager");
|
||||
|
||||
if (_scheduler != null)
|
||||
{
|
||||
// Don't shutdown the scheduler as it's managed by QuartzHostedService
|
||||
// Don't shut down the scheduler as it's managed by QuartzHostedService
|
||||
await _scheduler.Standby(cancellationToken);
|
||||
}
|
||||
|
||||
_logger.LogInformation("BackgroundJobManager stopped");
|
||||
_logger.LogDebug("BackgroundJobManager stopped");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -86,7 +90,6 @@ public class BackgroundJobManager : IHostedService
|
||||
throw new InvalidOperationException("Scheduler not initialized");
|
||||
}
|
||||
|
||||
// Use scoped DataContext to prevent memory leaks
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
await using var dataContext = scope.ServiceProvider.GetRequiredService<DataContext>();
|
||||
|
||||
@@ -100,11 +103,15 @@ public class BackgroundJobManager : IHostedService
|
||||
DownloadCleanerConfig downloadCleanerConfig = await dataContext.DownloadCleanerConfigs
|
||||
.AsNoTracking()
|
||||
.FirstAsync(cancellationToken);
|
||||
BlacklistSyncConfig blacklistSyncConfig = await dataContext.BlacklistSyncConfigs
|
||||
.AsNoTracking()
|
||||
.FirstAsync(cancellationToken);
|
||||
|
||||
// Always register jobs, regardless of enabled status
|
||||
await RegisterQueueCleanerJob(queueCleanerConfig, cancellationToken);
|
||||
await RegisterMalwareBlockerJob(malwareBlockerConfig, cancellationToken);
|
||||
await RegisterDownloadCleanerJob(downloadCleanerConfig, cancellationToken);
|
||||
await RegisterBlacklistSyncJob(blacklistSyncConfig, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -120,7 +127,7 @@ public class BackgroundJobManager : IHostedService
|
||||
// Only add triggers if the job is enabled
|
||||
if (config.Enabled)
|
||||
{
|
||||
await AddTriggersForJob<QueueCleaner>(config, config.CronExpression, cancellationToken);
|
||||
await AddTriggersForJob<QueueCleaner>(config.CronExpression, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +144,7 @@ public class BackgroundJobManager : IHostedService
|
||||
// Only add triggers if the job is enabled
|
||||
if (config.Enabled)
|
||||
{
|
||||
await AddTriggersForJob<MalwareBlocker>(config, config.CronExpression, cancellationToken);
|
||||
await AddTriggersForJob<MalwareBlocker>(config.CronExpression, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,7 +159,21 @@ public class BackgroundJobManager : IHostedService
|
||||
// Only add triggers if the job is enabled
|
||||
if (config.Enabled)
|
||||
{
|
||||
await AddTriggersForJob<DownloadCleaner>(config, config.CronExpression, cancellationToken);
|
||||
await AddTriggersForJob<DownloadCleaner>(config.CronExpression, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the BlacklistSync job and optionally adds triggers based on general configuration.
|
||||
/// </summary>
|
||||
public async Task RegisterBlacklistSyncJob(BlacklistSyncConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Always register the job definition
|
||||
await AddJobWithoutTrigger<BlacklistSynchronizer>(cancellationToken);
|
||||
|
||||
if (config.Enabled)
|
||||
{
|
||||
await AddTriggersForJob<BlacklistSynchronizer>(config.CronExpression, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,10 +181,9 @@ public class BackgroundJobManager : IHostedService
|
||||
/// Helper method to add triggers for an existing job.
|
||||
/// </summary>
|
||||
private async Task AddTriggersForJob<T>(
|
||||
IJobConfig config,
|
||||
string cronExpression,
|
||||
CancellationToken cancellationToken = default)
|
||||
where T : GenericHandler
|
||||
where T : IHandler
|
||||
{
|
||||
if (_scheduler == null)
|
||||
{
|
||||
@@ -228,7 +248,7 @@ public class BackgroundJobManager : IHostedService
|
||||
/// Helper method to add a job without a trigger (for chained jobs).
|
||||
/// </summary>
|
||||
private async Task AddJobWithoutTrigger<T>(CancellationToken cancellationToken = default)
|
||||
where T : GenericHandler
|
||||
where T : IHandler
|
||||
{
|
||||
if (_scheduler == null)
|
||||
{
|
||||
@@ -254,6 +274,6 @@ public class BackgroundJobManager : IHostedService
|
||||
// Add job to scheduler
|
||||
await _scheduler.AddJob(jobDetail, true, cancellationToken);
|
||||
|
||||
_logger.LogInformation("Registered job {name} without trigger", typeName);
|
||||
_logger.LogDebug("Registered job {name} without trigger", typeName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Api.Models.NotificationProviders;
|
||||
|
||||
public sealed record CreateNtfyProviderDto : CreateNotificationProviderBaseDto
|
||||
{
|
||||
public string ServerUrl { get; init; } = string.Empty;
|
||||
|
||||
public List<string> Topics { get; init; } = [];
|
||||
|
||||
public NtfyAuthenticationType AuthenticationType { get; init; } = NtfyAuthenticationType.None;
|
||||
|
||||
public string Username { get; init; } = string.Empty;
|
||||
|
||||
public string Password { get; init; } = string.Empty;
|
||||
|
||||
public string AccessToken { get; init; } = string.Empty;
|
||||
|
||||
public NtfyPriority Priority { get; init; } = NtfyPriority.Default;
|
||||
|
||||
public List<string> Tags { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Api.Models.NotificationProviders;
|
||||
|
||||
public sealed record TestNtfyProviderDto
|
||||
{
|
||||
public string ServerUrl { get; init; } = string.Empty;
|
||||
|
||||
public List<string> Topics { get; init; } = [];
|
||||
|
||||
public NtfyAuthenticationType AuthenticationType { get; init; } = NtfyAuthenticationType.None;
|
||||
|
||||
public string Username { get; init; } = string.Empty;
|
||||
|
||||
public string Password { get; init; } = string.Empty;
|
||||
|
||||
public string AccessToken { get; init; } = string.Empty;
|
||||
|
||||
public NtfyPriority Priority { get; init; } = NtfyPriority.Default;
|
||||
|
||||
public List<string> Tags { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Api.Models.NotificationProviders;
|
||||
|
||||
public sealed record UpdateNtfyProviderDto : CreateNotificationProviderBaseDto
|
||||
{
|
||||
public string ServerUrl { get; init; } = string.Empty;
|
||||
|
||||
public List<string> Topics { get; init; } = [];
|
||||
|
||||
public NtfyAuthenticationType AuthenticationType { get; init; } = NtfyAuthenticationType.None;
|
||||
|
||||
public string Username { get; init; } = string.Empty;
|
||||
|
||||
public string Password { get; init; } = string.Empty;
|
||||
|
||||
public string AccessToken { get; init; } = string.Empty;
|
||||
|
||||
public NtfyPriority Priority { get; init; } = NtfyPriority.Default;
|
||||
|
||||
public List<string> Tags { get; init; } = [];
|
||||
}
|
||||
@@ -29,6 +29,8 @@ public class UpdateDownloadCleanerConfigDto
|
||||
public string UnlinkedIgnoredRootDir { get; set; } = string.Empty;
|
||||
|
||||
public List<string> UnlinkedCategories { get; set; } = [];
|
||||
|
||||
public List<string> IgnoredDownloads { get; set; } = [];
|
||||
}
|
||||
|
||||
public class CleanCategoryDto
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
|
||||
using Cleanuparr.Infrastructure.Features.Jobs;
|
||||
using Cleanuparr.Infrastructure.Helpers;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
|
||||
using Cleanuparr.Persistence.Models.State;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Application.Features.BlacklistSync;
|
||||
|
||||
public sealed class BlacklistSynchronizer : IHandler
|
||||
{
|
||||
private readonly ILogger<BlacklistSynchronizer> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly DownloadServiceFactory _downloadServiceFactory;
|
||||
private readonly FileReader _fileReader;
|
||||
private readonly IDryRunInterceptor _dryRunInterceptor;
|
||||
|
||||
public BlacklistSynchronizer(
|
||||
ILogger<BlacklistSynchronizer> logger,
|
||||
DataContext dataContext,
|
||||
DownloadServiceFactory downloadServiceFactory,
|
||||
FileReader fileReader,
|
||||
IDryRunInterceptor dryRunInterceptor
|
||||
)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
_downloadServiceFactory = downloadServiceFactory;
|
||||
_fileReader = fileReader;
|
||||
_dryRunInterceptor = dryRunInterceptor;
|
||||
}
|
||||
|
||||
public async Task ExecuteAsync()
|
||||
{
|
||||
BlacklistSyncConfig config = await _dataContext.BlacklistSyncConfigs
|
||||
.AsNoTracking()
|
||||
.FirstAsync();
|
||||
|
||||
if (!config.Enabled)
|
||||
{
|
||||
_logger.LogDebug("Blacklist sync is disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(config.BlacklistPath))
|
||||
{
|
||||
_logger.LogWarning("Blacklist sync path is not configured");
|
||||
return;
|
||||
}
|
||||
|
||||
string[] patterns = await _fileReader.ReadContentAsync(config.BlacklistPath);
|
||||
string excludedFileNames = string.Join('\n', patterns.Where(p => !string.IsNullOrWhiteSpace(p)));
|
||||
|
||||
string currentHash = ComputeHash(excludedFileNames);
|
||||
|
||||
await _dryRunInterceptor.InterceptAsync(SyncBlacklist, currentHash, excludedFileNames);
|
||||
await _dryRunInterceptor.InterceptAsync(RemoveOldSyncDataAsync, currentHash);
|
||||
|
||||
_logger.LogDebug("Blacklist synchronization completed");
|
||||
}
|
||||
|
||||
private async Task SyncBlacklist(string currentHash, string excludedFileNames)
|
||||
{
|
||||
List<DownloadClientConfig> qBittorrentClients = await _dataContext.DownloadClients
|
||||
.AsNoTracking()
|
||||
.Where(c => c.Enabled && c.TypeName == DownloadClientTypeName.qBittorrent)
|
||||
.ToListAsync();
|
||||
|
||||
if (qBittorrentClients.Count is 0)
|
||||
{
|
||||
_logger.LogDebug("No enabled qBittorrent clients found for blacklist sync");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Starting blacklist synchronization for {Count} qBittorrent clients", qBittorrentClients.Count);
|
||||
|
||||
// Pull existing sync history for this hash
|
||||
var alreadySynced = await _dataContext.BlacklistSyncHistory
|
||||
.AsNoTracking()
|
||||
.Where(s => s.Hash == currentHash)
|
||||
.Select(x => x.DownloadClientId)
|
||||
.ToListAsync();
|
||||
|
||||
// Only update clients not present in history for current hash
|
||||
foreach (var clientConfig in qBittorrentClients)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (alreadySynced.Contains(clientConfig.Id))
|
||||
{
|
||||
_logger.LogDebug("Client {ClientName} already synced for current blacklist, skipping", clientConfig.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
var downloadService = _downloadServiceFactory.GetDownloadService(clientConfig);
|
||||
if (downloadService is not QBitService qBitService)
|
||||
{
|
||||
_logger.LogError("Expected QBitService but got {ServiceType} for client {ClientName}", downloadService.GetType().Name, clientConfig.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await qBitService.LoginAsync();
|
||||
await qBitService.UpdateBlacklistAsync(excludedFileNames);
|
||||
|
||||
_logger.LogDebug("Successfully updated blacklist for qBittorrent client {ClientName}", clientConfig.Name);
|
||||
|
||||
// Insert history row marking this client as synced for current hash
|
||||
_dataContext.BlacklistSyncHistory.Add(new BlacklistSyncHistory
|
||||
{
|
||||
Hash = currentHash,
|
||||
DownloadClientId = clientConfig.Id
|
||||
});
|
||||
await _dataContext.SaveChangesAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update blacklist for qBittorrent client {ClientName}", clientConfig.Name);
|
||||
}
|
||||
finally
|
||||
{
|
||||
qBitService.Dispose();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create download service for client {ClientName}", clientConfig.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeHash(string excludedFileNames)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
byte[] bytes = Encoding.UTF8.GetBytes(excludedFileNames);
|
||||
byte[] hash = sha.ComputeHash(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private async Task RemoveOldSyncDataAsync(string currentHash)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _dataContext.BlacklistSyncHistory
|
||||
.Where(s => s.Hash != currentHash)
|
||||
.ExecuteDeleteAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to cleanup old blacklist sync history");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,8 @@ public sealed class DownloadCleaner : GenericHandler
|
||||
return;
|
||||
}
|
||||
|
||||
IReadOnlyList<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads;
|
||||
List<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads;
|
||||
ignoredDownloads.AddRange(ContextProvider.Get<DownloadCleanerConfig>().IgnoredDownloads);
|
||||
|
||||
var downloadServiceToDownloadsMap = new Dictionary<IDownloadService, List<object>>();
|
||||
|
||||
|
||||
@@ -94,7 +94,8 @@ public sealed class MalwareBlocker : GenericHandler
|
||||
|
||||
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType)
|
||||
{
|
||||
IReadOnlyList<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>().IgnoredDownloads;
|
||||
List<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads;
|
||||
ignoredDownloads.AddRange(ContextProvider.Get<ContentBlockerConfig>().IgnoredDownloads);
|
||||
|
||||
using var _ = LogContext.PushProperty(LogProperties.Category, instanceType.ToString());
|
||||
|
||||
|
||||
@@ -54,7 +54,8 @@ public sealed class QueueCleaner : GenericHandler
|
||||
|
||||
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType)
|
||||
{
|
||||
IReadOnlyList<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>().IgnoredDownloads;
|
||||
List<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads;
|
||||
ignoredDownloads.AddRange(ContextProvider.Get<QueueCleanerConfig>().IgnoredDownloads);
|
||||
|
||||
using var _ = LogContext.PushProperty(LogProperties.Category, instanceType.ToString());
|
||||
|
||||
|
||||
@@ -3,5 +3,6 @@ namespace Cleanuparr.Domain.Enums;
|
||||
public enum NotificationProviderType
|
||||
{
|
||||
Notifiarr,
|
||||
Apprise
|
||||
Apprise,
|
||||
Ntfy
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
public enum NtfyAuthenticationType
|
||||
{
|
||||
None,
|
||||
BasicAuth,
|
||||
AccessToken
|
||||
}
|
||||
10
code/backend/Cleanuparr.Domain/Enums/NtfyPriority.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
public enum NtfyPriority
|
||||
{
|
||||
Min = 1,
|
||||
Low = 2,
|
||||
Default = 3,
|
||||
High = 4,
|
||||
Max = 5
|
||||
}
|
||||
@@ -6,9 +6,9 @@ using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications;
|
||||
using Cleanuparr.Infrastructure.Hubs;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Events;
|
||||
using Infrastructure.Interceptors;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
|
||||
@@ -3,11 +3,11 @@ using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Data.Models.Arr;
|
||||
using Infrastructure.Interceptors;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Entities.Lidarr;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Data.Models.Arr;
|
||||
using Infrastructure.Interceptors;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Entities.Radarr;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Data.Models.Arr;
|
||||
using Infrastructure.Interceptors;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Entities.Readarr;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Data.Models.Arr;
|
||||
using Infrastructure.Interceptors;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ using Cleanuparr.Domain.Entities.Sonarr;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Data.Models.Arr;
|
||||
using Infrastructure.Interceptors;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
using Series = Cleanuparr.Domain.Entities.Sonarr.Series;
|
||||
|
||||
@@ -6,9 +6,9 @@ using Cleanuparr.Domain.Entities.Whisparr;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Data.Models.Arr;
|
||||
using Infrastructure.Interceptors;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ using Cleanuparr.Infrastructure.Features.Files;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Http;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Infrastructure.Interceptors;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
|
||||
@@ -8,11 +8,11 @@ using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Helpers;
|
||||
using Cleanuparr.Infrastructure.Http;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Infrastructure.Interceptors;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -125,7 +125,7 @@ public abstract class DownloadService : IDownloadService
|
||||
{
|
||||
var queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>(nameof(QueueCleanerConfig));
|
||||
|
||||
if (queueCleanerConfig.Slow.ResetStrikesOnProgress)
|
||||
if (!queueCleanerConfig.Slow.ResetStrikesOnProgress)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -145,7 +145,7 @@ public abstract class DownloadService : IDownloadService
|
||||
{
|
||||
var queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>(nameof(QueueCleanerConfig));
|
||||
|
||||
if (queueCleanerConfig.Slow.ResetStrikesOnProgress)
|
||||
if (!queueCleanerConfig.Slow.ResetStrikesOnProgress)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@ using Cleanuparr.Infrastructure.Events;
|
||||
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.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Infrastructure.Interceptors;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
|
||||
public interface IQBitService : IDownloadService, IDisposable
|
||||
{
|
||||
Task UpdateBlacklistAsync(string blacklistPath);
|
||||
}
|
||||
@@ -2,11 +2,13 @@ using Cleanuparr.Infrastructure.Events;
|
||||
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.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Infrastructure.Interceptors;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using QBittorrent.Client;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
|
||||
@@ -45,11 +47,11 @@ public partial class QBitService : DownloadService, IQBitService
|
||||
try
|
||||
{
|
||||
await _client.LoginAsync(_downloadClientConfig.Username, _downloadClientConfig.Password);
|
||||
_logger.LogDebug("Successfully logged in to QBittorrent client {clientId}", _downloadClientConfig.Id);
|
||||
_logger.LogDebug("Successfully logged in to qBittorrent client {clientId}", _downloadClientConfig.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to login to QBittorrent client {clientId}", _downloadClientConfig.Id);
|
||||
_logger.LogError(ex, "Failed to login to qBittorrent client {clientId}", _downloadClientConfig.Id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
@@ -65,15 +67,15 @@ public partial class QBitService : DownloadService, IQBitService
|
||||
|
||||
if (hasCredentials)
|
||||
{
|
||||
// If credentials are provided, we must be able to login for the service to be healthy
|
||||
// If credentials are provided, we must be able to log in for the service to be healthy
|
||||
await _client.LoginAsync(_downloadClientConfig.Username, _downloadClientConfig.Password);
|
||||
_logger.LogDebug("Health check: Successfully logged in to QBittorrent client {clientId}", _downloadClientConfig.Id);
|
||||
_logger.LogDebug("Health check: Successfully logged in to qBittorrent client {clientId}", _downloadClientConfig.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
// If no credentials, test connectivity using version endpoint
|
||||
await _client.GetApiVersionAsync();
|
||||
_logger.LogDebug("Health check: Successfully connected to QBittorrent client {clientId}", _downloadClientConfig.Id);
|
||||
_logger.LogDebug("Health check: Successfully connected to qBittorrent client {clientId}", _downloadClientConfig.Id);
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
@@ -88,7 +90,7 @@ public partial class QBitService : DownloadService, IQBitService
|
||||
{
|
||||
stopwatch.Stop();
|
||||
|
||||
_logger.LogWarning(ex, "Health check failed for QBittorrent client {clientId}", _downloadClientConfig.Id);
|
||||
_logger.LogWarning(ex, "Health check failed for qBittorrent client {clientId}", _downloadClientConfig.Id);
|
||||
|
||||
return new HealthCheckResult
|
||||
{
|
||||
@@ -98,6 +100,23 @@ public partial class QBitService : DownloadService, IQBitService
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Syncs blacklist patterns from configured file to qBittorrent excluded file names
|
||||
/// </summary>
|
||||
/// <param name="excludedFileNames">List of excluded file names for qBittorrent</param>
|
||||
public async Task UpdateBlacklistAsync(string excludedFileNames)
|
||||
{
|
||||
Preferences preferences = new()
|
||||
{
|
||||
AdditionalData = new Dictionary<string, JToken>
|
||||
{
|
||||
{ "excluded_file_names", excludedFileNames }
|
||||
}
|
||||
};
|
||||
|
||||
await _client.SetPreferencesAsync(preferences);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<TorrentTracker>> GetTrackersAsync(string hash)
|
||||
{
|
||||
|
||||
@@ -3,8 +3,8 @@ using Cleanuparr.Infrastructure.Features.Files;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Http;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Infrastructure.Interceptors;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Transmission.API.RPC;
|
||||
|
||||
@@ -3,8 +3,8 @@ using Cleanuparr.Infrastructure.Features.Files;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Http;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Infrastructure.Interceptors;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Helpers;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -19,7 +18,6 @@ public sealed class BlocklistProvider
|
||||
{
|
||||
private readonly ILogger<BlocklistProvider> _logger;
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly Dictionary<InstanceType, string> _configHashes = new();
|
||||
private readonly Dictionary<string, DateTime> _lastLoadTimes = new();
|
||||
@@ -31,14 +29,12 @@ public sealed class BlocklistProvider
|
||||
public BlocklistProvider(
|
||||
ILogger<BlocklistProvider> logger,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IMemoryCache cache,
|
||||
IHttpClientFactory httpClientFactory
|
||||
IMemoryCache cache
|
||||
)
|
||||
{
|
||||
_logger = logger;
|
||||
_scopeFactory = scopeFactory;
|
||||
_cache = cache;
|
||||
_httpClient = httpClientFactory.CreateClient(Constants.HttpClientWithRetryName);
|
||||
}
|
||||
|
||||
public async Task LoadBlocklistsAsync()
|
||||
@@ -47,6 +43,8 @@ public sealed class BlocklistProvider
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
await using var dataContext = scope.ServiceProvider.GetRequiredService<DataContext>();
|
||||
var fileReader = scope.ServiceProvider.GetRequiredService<FileReader>();
|
||||
|
||||
int changedCount = 0;
|
||||
var malwareBlockerConfig = await dataContext.ContentBlockerConfigs
|
||||
.AsNoTracking()
|
||||
@@ -58,78 +56,25 @@ public sealed class BlocklistProvider
|
||||
return;
|
||||
}
|
||||
|
||||
// Check and update Sonarr blocklist if needed
|
||||
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)
|
||||
var instances = new Dictionary<InstanceType, BlocklistSettings>
|
||||
{
|
||||
_logger.LogDebug("Loading Sonarr blocklist");
|
||||
|
||||
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(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)
|
||||
{ InstanceType.Sonarr, malwareBlockerConfig.Sonarr },
|
||||
{ InstanceType.Radarr, malwareBlockerConfig.Radarr },
|
||||
{ InstanceType.Lidarr, malwareBlockerConfig.Lidarr },
|
||||
{ InstanceType.Readarr, malwareBlockerConfig.Readarr },
|
||||
{ InstanceType.Whisparr, malwareBlockerConfig.Whisparr }
|
||||
};
|
||||
|
||||
foreach (var kv in instances)
|
||||
{
|
||||
_logger.LogDebug("Loading Radarr blocklist");
|
||||
|
||||
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(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(malwareBlockerConfig.Lidarr, InstanceType.Lidarr);
|
||||
_configHashes[InstanceType.Lidarr] = lidarrHash;
|
||||
_lastLoadTimes[lidarrIdentifier] = DateTime.UtcNow;
|
||||
changedCount++;
|
||||
}
|
||||
|
||||
// Check and update Readarr blocklist if needed
|
||||
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(malwareBlockerConfig.Readarr, InstanceType.Readarr);
|
||||
_configHashes[InstanceType.Readarr] = readarrHash;
|
||||
_lastLoadTimes[readarrIdentifier] = DateTime.UtcNow;
|
||||
changedCount++;
|
||||
}
|
||||
|
||||
// Check and update Whisparr blocklist if needed
|
||||
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(malwareBlockerConfig.Whisparr, InstanceType.Whisparr);
|
||||
_configHashes[InstanceType.Whisparr] = whisparrHash;
|
||||
_lastLoadTimes[whisparrIdentifier] = DateTime.UtcNow;
|
||||
changedCount++;
|
||||
if (await EnsureInstanceLoadedAsync(kv.Value, kv.Key, fileReader))
|
||||
{
|
||||
changedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Always check and update malware patterns
|
||||
await LoadMalwarePatternsAsync();
|
||||
await LoadMalwarePatternsAsync(fileReader);
|
||||
|
||||
if (changedCount > 0)
|
||||
{
|
||||
@@ -175,6 +120,29 @@ public sealed class BlocklistProvider
|
||||
return patterns ?? [];
|
||||
}
|
||||
|
||||
private async Task<bool> EnsureInstanceLoadedAsync(BlocklistSettings settings, InstanceType instanceType, FileReader fileReader)
|
||||
{
|
||||
if (!settings.Enabled || string.IsNullOrEmpty(settings.BlocklistPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string hash = GenerateSettingsHash(settings);
|
||||
var interval = GetLoadInterval(settings.BlocklistPath);
|
||||
var identifier = $"{instanceType}_{settings.BlocklistPath}";
|
||||
|
||||
if (ShouldReloadBlocklist(identifier, interval) || !_configHashes.TryGetValue(instanceType, out string? oldHash) || hash != oldHash)
|
||||
{
|
||||
_logger.LogDebug("Loading {instance} blocklist", instanceType);
|
||||
await LoadPatternsAndRegexesAsync(settings, instanceType, fileReader);
|
||||
_configHashes[instanceType] = hash;
|
||||
_lastLoadTimes[identifier] = DateTime.UtcNow;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private TimeSpan GetLoadInterval(string? path)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(path) && Uri.TryCreate(path, UriKind.Absolute, out var uri))
|
||||
@@ -201,7 +169,7 @@ public sealed class BlocklistProvider
|
||||
return DateTime.UtcNow - lastLoad >= interval;
|
||||
}
|
||||
|
||||
private async Task LoadMalwarePatternsAsync()
|
||||
private async Task LoadMalwarePatternsAsync(FileReader fileReader)
|
||||
{
|
||||
var malwareInterval = TimeSpan.FromMinutes(FastLoadIntervalMinutes);
|
||||
|
||||
@@ -214,7 +182,7 @@ public sealed class BlocklistProvider
|
||||
{
|
||||
_logger.LogDebug("Loading malware patterns");
|
||||
|
||||
string[] filePatterns = await ReadContentAsync(MalwareListUrl);
|
||||
string[] filePatterns = await fileReader.ReadContentAsync(MalwareListUrl);
|
||||
|
||||
long startTime = Stopwatch.GetTimestamp();
|
||||
ParallelOptions options = new() { MaxDegreeOfParallelism = 5 };
|
||||
@@ -239,14 +207,14 @@ public sealed class BlocklistProvider
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadPatternsAndRegexesAsync(BlocklistSettings blocklistSettings, InstanceType instanceType)
|
||||
private async Task LoadPatternsAndRegexesAsync(BlocklistSettings blocklistSettings, InstanceType instanceType, FileReader fileReader)
|
||||
{
|
||||
if (string.IsNullOrEmpty(blocklistSettings.BlocklistPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string[] filePatterns = await ReadContentAsync(blocklistSettings.BlocklistPath);
|
||||
string[] filePatterns = await fileReader.ReadContentAsync(blocklistSettings.BlocklistPath);
|
||||
|
||||
long startTime = Stopwatch.GetTimestamp();
|
||||
ParallelOptions options = new() { MaxDegreeOfParallelism = 5 };
|
||||
@@ -286,36 +254,10 @@ public sealed class BlocklistProvider
|
||||
_logger.LogDebug("blocklist loaded in {elapsed} ms | {path}", elapsed.TotalMilliseconds, blocklistSettings.BlocklistPath);
|
||||
}
|
||||
|
||||
private async Task<string[]> ReadContentAsync(string path)
|
||||
{
|
||||
if (Uri.TryCreate(path, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
|
||||
{
|
||||
// http(s) url
|
||||
return await ReadFromUrlAsync(path);
|
||||
}
|
||||
|
||||
if (File.Exists(path))
|
||||
{
|
||||
// local file path
|
||||
return await File.ReadAllLinesAsync(path);
|
||||
}
|
||||
|
||||
throw new ArgumentException($"blocklist not found | {path}");
|
||||
}
|
||||
|
||||
private async Task<string[]> ReadFromUrlAsync(string url)
|
||||
{
|
||||
using HttpResponseMessage response = await _httpClient.GetAsync(url);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return (await response.Content.ReadAsStringAsync())
|
||||
.Split(['\r','\n'], StringSplitOptions.RemoveEmptyEntries);
|
||||
}
|
||||
|
||||
private string GenerateSettingsHash(BlocklistSettings blocklistSettings)
|
||||
{
|
||||
// Create a string that represents the relevant blocklist configuration
|
||||
var configStr = $"{blocklistSettings.BlocklistPath ?? string.Empty}|{blocklistSettings.BlocklistType}";
|
||||
var configStr = $"{blocklistSettings.Enabled}|{blocklistSettings.BlocklistPath ?? string.Empty}|{blocklistSettings.BlocklistType}";
|
||||
|
||||
// Create SHA256 hash of the configuration string
|
||||
using var sha = SHA256.Create();
|
||||
|
||||
@@ -85,6 +85,7 @@ public sealed class NotificationConfigurationService : INotificationConfiguratio
|
||||
var providers = await _dataContext.Set<NotificationConfig>()
|
||||
.Include(p => p.NotifiarrConfiguration)
|
||||
.Include(p => p.AppriseConfiguration)
|
||||
.Include(p => p.NtfyConfiguration)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
@@ -133,6 +134,7 @@ public sealed class NotificationConfigurationService : INotificationConfiguratio
|
||||
{
|
||||
NotificationProviderType.Notifiarr => config.NotifiarrConfiguration,
|
||||
NotificationProviderType.Apprise => config.AppriseConfiguration,
|
||||
NotificationProviderType.Ntfy => config.NtfyConfiguration,
|
||||
_ => new object()
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
@@ -22,6 +23,7 @@ public sealed class NotificationProviderFactory : INotificationProviderFactory
|
||||
{
|
||||
NotificationProviderType.Notifiarr => CreateNotifiarrProvider(config),
|
||||
NotificationProviderType.Apprise => CreateAppriseProvider(config),
|
||||
NotificationProviderType.Ntfy => CreateNtfyProvider(config),
|
||||
_ => throw new NotSupportedException($"Provider type {config.Type} is not supported")
|
||||
};
|
||||
}
|
||||
@@ -41,4 +43,12 @@ public sealed class NotificationProviderFactory : INotificationProviderFactory
|
||||
|
||||
return new AppriseProvider(config.Name, config.Type, appriseConfig, proxy);
|
||||
}
|
||||
|
||||
private INotificationProvider CreateNtfyProvider(NotificationProviderDto config)
|
||||
{
|
||||
var ntfyConfig = (NtfyConfig)config.Configuration;
|
||||
var proxy = _serviceProvider.GetRequiredService<INtfyProxy>();
|
||||
|
||||
return new NtfyProvider(config.Name, config.Type, ntfyConfig, proxy);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Infrastructure.Interceptors;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
|
||||
|
||||
public interface INtfyProxy
|
||||
{
|
||||
Task SendNotification(NtfyPayload payload, NtfyConfig config);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
|
||||
|
||||
public sealed class NtfyException : Exception
|
||||
{
|
||||
public NtfyException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public NtfyException(string message, Exception innerException) : base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
|
||||
|
||||
public sealed class NtfyPayload
|
||||
{
|
||||
[JsonProperty("topic")]
|
||||
public string Topic { get; init; } = string.Empty;
|
||||
|
||||
[JsonProperty("message")]
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
[JsonProperty("title")]
|
||||
public string? Title { get; init; }
|
||||
|
||||
[JsonProperty("priority")]
|
||||
public int? Priority { get; init; }
|
||||
|
||||
[JsonProperty("tags")]
|
||||
public string[]? Tags { get; init; }
|
||||
|
||||
[JsonProperty("click")]
|
||||
public string? Click { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using System.Text;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
|
||||
|
||||
public sealed class NtfyProvider : NotificationProviderBase<NtfyConfig>
|
||||
{
|
||||
private readonly INtfyProxy _proxy;
|
||||
|
||||
public NtfyProvider(
|
||||
string name,
|
||||
NotificationProviderType type,
|
||||
NtfyConfig config,
|
||||
INtfyProxy proxy
|
||||
) : base(name, type, config)
|
||||
{
|
||||
_proxy = proxy;
|
||||
}
|
||||
|
||||
public override async Task SendNotificationAsync(NotificationContext context)
|
||||
{
|
||||
var topics = GetTopics();
|
||||
var tasks = topics.Select(topic => SendToTopic(topic, context));
|
||||
await Task.WhenAll(tasks);
|
||||
}
|
||||
|
||||
private async Task SendToTopic(string topic, NotificationContext context)
|
||||
{
|
||||
NtfyPayload payload = BuildPayload(topic, context);
|
||||
await _proxy.SendNotification(payload, Config);
|
||||
}
|
||||
|
||||
private NtfyPayload BuildPayload(string topic, NotificationContext context)
|
||||
{
|
||||
int priority = MapSeverityToPriority(context.Severity);
|
||||
string message = BuildMessage(context);
|
||||
|
||||
return new NtfyPayload
|
||||
{
|
||||
Topic = topic.Trim(),
|
||||
Title = context.Title,
|
||||
Message = message,
|
||||
Priority = priority,
|
||||
Tags = Config.Tags.ToArray()
|
||||
};
|
||||
}
|
||||
|
||||
private string BuildMessage(NotificationContext context)
|
||||
{
|
||||
var message = new StringBuilder();
|
||||
message.AppendLine(context.Description);
|
||||
|
||||
if (context.Data.Any())
|
||||
{
|
||||
message.AppendLine();
|
||||
foreach ((string key, string value) in context.Data)
|
||||
{
|
||||
message.AppendLine($"{key}: {value}");
|
||||
}
|
||||
}
|
||||
|
||||
return message.ToString().Trim();
|
||||
}
|
||||
|
||||
private int MapSeverityToPriority(EventSeverity severity)
|
||||
{
|
||||
return severity switch
|
||||
{
|
||||
EventSeverity.Information => (int)Config.Priority,
|
||||
EventSeverity.Warning => Math.Max((int)Config.Priority, (int)NtfyPriority.High),
|
||||
EventSeverity.Important => (int)NtfyPriority.Max,
|
||||
_ => (int)Config.Priority
|
||||
};
|
||||
}
|
||||
|
||||
private string[] GetTopics()
|
||||
{
|
||||
return Config.Topics
|
||||
.Where(t => !string.IsNullOrWhiteSpace(t))
|
||||
.Select(t => t.Trim())
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Serialization;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
|
||||
|
||||
public sealed class NtfyProxy : INtfyProxy
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public NtfyProxy(IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient(Constants.HttpClientWithRetryName);
|
||||
}
|
||||
|
||||
public async Task SendNotification(NtfyPayload payload, NtfyConfig config)
|
||||
{
|
||||
try
|
||||
{
|
||||
string content = JsonConvert.SerializeObject(payload, new JsonSerializerSettings
|
||||
{
|
||||
ContractResolver = new CamelCasePropertyNamesContractResolver(),
|
||||
NullValueHandling = NullValueHandling.Ignore
|
||||
});
|
||||
|
||||
var parsedUrl = config.Uri!;
|
||||
using HttpRequestMessage request = new(HttpMethod.Post, parsedUrl);
|
||||
request.Content = new StringContent(content, Encoding.UTF8, "application/json");
|
||||
|
||||
// Set authentication headers based on configuration
|
||||
SetAuthenticationHeaders(request, config);
|
||||
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
catch (HttpRequestException exception)
|
||||
{
|
||||
if (exception.StatusCode is null)
|
||||
{
|
||||
throw new NtfyException("Unable to send notification", exception);
|
||||
}
|
||||
|
||||
switch ((int)exception.StatusCode)
|
||||
{
|
||||
case 400:
|
||||
throw new NtfyException("Bad request - invalid topic or payload", exception);
|
||||
case 401:
|
||||
throw new NtfyException("Unauthorized - invalid credentials", exception);
|
||||
case 413:
|
||||
throw new NtfyException("Payload too large", exception);
|
||||
case 429:
|
||||
throw new NtfyException("Rate limited - too many requests", exception);
|
||||
case 507:
|
||||
throw new NtfyException("Insufficient storage on server", exception);
|
||||
default:
|
||||
throw new NtfyException("Unable to send notification", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void SetAuthenticationHeaders(HttpRequestMessage request, NtfyConfig config)
|
||||
{
|
||||
switch (config.AuthenticationType)
|
||||
{
|
||||
case NtfyAuthenticationType.BasicAuth:
|
||||
if (!string.IsNullOrWhiteSpace(config.Username) && !string.IsNullOrWhiteSpace(config.Password))
|
||||
{
|
||||
var credentials = Convert.ToBase64String(Encoding.ASCII.GetBytes($"{config.Username}:{config.Password}"));
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials);
|
||||
}
|
||||
break;
|
||||
|
||||
case NtfyAuthenticationType.AccessToken:
|
||||
if (!string.IsNullOrWhiteSpace(config.AccessToken))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", config.AccessToken);
|
||||
}
|
||||
break;
|
||||
|
||||
case NtfyAuthenticationType.None:
|
||||
default:
|
||||
// No authentication required
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
44
code/backend/Cleanuparr.Infrastructure/Helpers/FileReader.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Helpers;
|
||||
|
||||
public class FileReader
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public FileReader(IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient(Constants.HttpClientWithRetryName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads content from either a local file or HTTP(S) URL
|
||||
/// Extracted from BlocklistProvider.ReadContentAsync for reuse
|
||||
/// </summary>
|
||||
/// <param name="path">File path or URL</param>
|
||||
/// <returns>Array of lines from the content</returns>
|
||||
public async Task<string[]> ReadContentAsync(string path)
|
||||
{
|
||||
if (Uri.TryCreate(path, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
|
||||
{
|
||||
return await ReadFromUrlAsync(path);
|
||||
}
|
||||
|
||||
if (File.Exists(path))
|
||||
{
|
||||
// local file path
|
||||
return await File.ReadAllLinesAsync(path);
|
||||
}
|
||||
|
||||
throw new ArgumentException($"File not found: {path}");
|
||||
}
|
||||
|
||||
private async Task<string[]> ReadFromUrlAsync(string url)
|
||||
{
|
||||
using HttpResponseMessage response = await _httpClient.GetAsync(url);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return (await response.Content.ReadAsStringAsync())
|
||||
.Split(['\r','\n'], StringSplitOptions.RemoveEmptyEntries);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
using System.Reflection;
|
||||
using Cleanuparr.Persistence;
|
||||
using Infrastructure.Interceptors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Infrastructure.Interceptors;
|
||||
namespace Cleanuparr.Infrastructure.Interceptors;
|
||||
|
||||
public interface IDryRunInterceptor
|
||||
{
|
||||
|
||||
@@ -32,6 +32,7 @@ public static class LoggingConfigManager
|
||||
{
|
||||
using var context = DataContext.CreateStaticInstance();
|
||||
var config = context.GeneralConfigs.AsNoTracking().First();
|
||||
SetLogLevel(config.Log.Level);
|
||||
|
||||
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}";
|
||||
|
||||
@@ -7,5 +7,6 @@ public enum JobType
|
||||
{
|
||||
QueueCleaner,
|
||||
MalwareBlocker,
|
||||
DownloadCleaner
|
||||
DownloadCleaner,
|
||||
BlacklistSynchronizer,
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ 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.Persistence.Models.Configuration.BlacklistSync;
|
||||
using Cleanuparr.Persistence.Models.State;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
@@ -42,6 +44,12 @@ public class DataContext : DbContext
|
||||
public DbSet<NotifiarrConfig> NotifiarrConfigs { get; set; }
|
||||
|
||||
public DbSet<AppriseConfig> AppriseConfigs { get; set; }
|
||||
|
||||
public DbSet<NtfyConfig> NtfyConfigs { get; set; }
|
||||
|
||||
public DbSet<BlacklistSyncHistory> BlacklistSyncHistory { get; set; }
|
||||
|
||||
public DbSet<BlacklistSyncConfig> BlacklistSyncConfigs { get; set; }
|
||||
|
||||
public DataContext()
|
||||
{
|
||||
@@ -123,8 +131,26 @@ public class DataContext : DbContext
|
||||
.HasForeignKey<AppriseConfig>(c => c.NotificationConfigId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(p => p.NtfyConfiguration)
|
||||
.WithOne(c => c.NotificationConfig)
|
||||
.HasForeignKey<NtfyConfig>(c => c.NotificationConfigId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasIndex(p => p.Name).IsUnique();
|
||||
});
|
||||
|
||||
// Configure BlacklistSyncState relationships and indexes
|
||||
modelBuilder.Entity<BlacklistSyncHistory>(entity =>
|
||||
{
|
||||
// FK to DownloadClientConfig by DownloadClientId with cascade on delete
|
||||
entity.HasOne(s => s.DownloadClient)
|
||||
.WithMany()
|
||||
.HasForeignKey(s => s.DownloadClientId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasIndex(s => new { s.Hash, DownloadClientId = s.DownloadClientId }).IsUnique();
|
||||
entity.HasIndex(s => s.Hash);
|
||||
});
|
||||
|
||||
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
|
||||
{
|
||||
|
||||
822
code/backend/Cleanuparr.Persistence/Migrations/Data/20250912234118_AddNtfy.Designer.cs
generated
Normal file
@@ -0,0 +1,822 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Cleanuparr.Persistence.Migrations.Data
|
||||
{
|
||||
[DbContext(typeof(DataContext))]
|
||||
[Migration("20250912234118_AddNtfy")]
|
||||
partial class AddNtfy
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.6");
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<short>("FailedImportMaxStrikes")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("failed_import_max_strikes");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("type");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_arr_configs");
|
||||
|
||||
b.ToTable("arr_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("ApiKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("api_key");
|
||||
|
||||
b.Property<Guid>("ArrConfigId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("arr_config_id");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string>("Url")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("url");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_arr_instances");
|
||||
|
||||
b.HasIndex("ArrConfigId")
|
||||
.HasDatabaseName("ix_arr_instances_arr_config_id");
|
||||
|
||||
b.ToTable("arr_instances", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.CleanCategory", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("DownloadCleanerConfigId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("download_cleaner_config_id");
|
||||
|
||||
b.Property<double>("MaxRatio")
|
||||
.HasColumnType("REAL")
|
||||
.HasColumnName("max_ratio");
|
||||
|
||||
b.Property<double>("MaxSeedTime")
|
||||
.HasColumnType("REAL")
|
||||
.HasColumnName("max_seed_time");
|
||||
|
||||
b.Property<double>("MinSeedTime")
|
||||
.HasColumnType("REAL")
|
||||
.HasColumnName("min_seed_time");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_clean_categories");
|
||||
|
||||
b.HasIndex("DownloadCleanerConfigId")
|
||||
.HasDatabaseName("ix_clean_categories_download_cleaner_config_id");
|
||||
|
||||
b.ToTable("clean_categories", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("CronExpression")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("cron_expression");
|
||||
|
||||
b.Property<bool>("DeletePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("delete_private");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.PrimitiveCollection<string>("UnlinkedCategories")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("unlinked_categories");
|
||||
|
||||
b.Property<bool>("UnlinkedEnabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("unlinked_enabled");
|
||||
|
||||
b.Property<string>("UnlinkedIgnoredRootDir")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("unlinked_ignored_root_dir");
|
||||
|
||||
b.Property<string>("UnlinkedTargetCategory")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("unlinked_target_category");
|
||||
|
||||
b.Property<bool>("UnlinkedUseTag")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("unlinked_use_tag");
|
||||
|
||||
b.Property<bool>("UseAdvancedScheduling")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("use_advanced_scheduling");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_download_cleaner_configs");
|
||||
|
||||
b.ToTable("download_cleaner_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.Property<string>("Host")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("host");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string>("Password")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("password");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("type");
|
||||
|
||||
b.Property<string>("TypeName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("type_name");
|
||||
|
||||
b.Property<string>("UrlBase")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("url_base");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("username");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_download_clients");
|
||||
|
||||
b.ToTable("download_clients", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<bool>("DisplaySupportBanner")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("display_support_banner");
|
||||
|
||||
b.Property<bool>("DryRun")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("dry_run");
|
||||
|
||||
b.Property<string>("EncryptionKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("encryption_key");
|
||||
|
||||
b.Property<string>("HttpCertificateValidation")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("http_certificate_validation");
|
||||
|
||||
b.Property<ushort>("HttpMaxRetries")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("http_max_retries");
|
||||
|
||||
b.Property<ushort>("HttpTimeout")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("http_timeout");
|
||||
|
||||
b.PrimitiveCollection<string>("IgnoredDownloads")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("ignored_downloads");
|
||||
|
||||
b.Property<ushort>("SearchDelay")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("search_delay");
|
||||
|
||||
b.Property<bool>("SearchEnabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("search_enabled");
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Log", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Log#LoggingConfig", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<bool>("ArchiveEnabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("log_archive_enabled");
|
||||
|
||||
b1.Property<ushort>("ArchiveRetainedCount")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("log_archive_retained_count");
|
||||
|
||||
b1.Property<ushort>("ArchiveTimeLimitHours")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("log_archive_time_limit_hours");
|
||||
|
||||
b1.Property<string>("Level")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("log_level");
|
||||
|
||||
b1.Property<ushort>("RetainedFileCount")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("log_retained_file_count");
|
||||
|
||||
b1.Property<ushort>("RollingSizeMB")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("log_rolling_size_mb");
|
||||
|
||||
b1.Property<ushort>("TimeLimitHours")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("log_time_limit_hours");
|
||||
});
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_general_configs");
|
||||
|
||||
b.ToTable("general_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("CronExpression")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("cron_expression");
|
||||
|
||||
b.Property<bool>("DeleteKnownMalware")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("delete_known_malware");
|
||||
|
||||
b.Property<bool>("DeletePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("delete_private");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.Property<bool>("IgnorePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("ignore_private");
|
||||
|
||||
b.Property<bool>("UseAdvancedScheduling")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("use_advanced_scheduling");
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Lidarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<string>("BlocklistPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("lidarr_blocklist_path");
|
||||
|
||||
b1.Property<string>("BlocklistType")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("lidarr_blocklist_type");
|
||||
|
||||
b1.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("lidarr_enabled");
|
||||
});
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Radarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<string>("BlocklistPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("radarr_blocklist_path");
|
||||
|
||||
b1.Property<string>("BlocklistType")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("radarr_blocklist_type");
|
||||
|
||||
b1.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("radarr_enabled");
|
||||
});
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Readarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<string>("BlocklistPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("readarr_blocklist_path");
|
||||
|
||||
b1.Property<string>("BlocklistType")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("readarr_blocklist_type");
|
||||
|
||||
b1.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("readarr_enabled");
|
||||
});
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Sonarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<string>("BlocklistPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("sonarr_blocklist_path");
|
||||
|
||||
b1.Property<string>("BlocklistType")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("sonarr_blocklist_type");
|
||||
|
||||
b1.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("sonarr_enabled");
|
||||
});
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Whisparr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<string>("BlocklistPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("whisparr_blocklist_path");
|
||||
|
||||
b1.Property<int>("BlocklistType")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("whisparr_blocklist_type");
|
||||
|
||||
b1.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("whisparr_enabled");
|
||||
});
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_content_blocker_configs");
|
||||
|
||||
b.ToTable("content_blocker_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("key");
|
||||
|
||||
b.Property<Guid>("NotificationConfigId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("notification_config_id");
|
||||
|
||||
b.Property<string>("Tags")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("tags");
|
||||
|
||||
b.Property<string>("Url")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("url");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_apprise_configs");
|
||||
|
||||
b.HasIndex("NotificationConfigId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_apprise_configs_notification_config_id");
|
||||
|
||||
b.ToTable("apprise_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("ApiKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("api_key");
|
||||
|
||||
b.Property<string>("ChannelId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("channel_id");
|
||||
|
||||
b.Property<Guid>("NotificationConfigId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("notification_config_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_notifiarr_configs");
|
||||
|
||||
b.HasIndex("NotificationConfigId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_notifiarr_configs_notification_config_id");
|
||||
|
||||
b.ToTable("notifiarr_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("is_enabled");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<bool>("OnCategoryChanged")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_category_changed");
|
||||
|
||||
b.Property<bool>("OnDownloadCleaned")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_download_cleaned");
|
||||
|
||||
b.Property<bool>("OnFailedImportStrike")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_failed_import_strike");
|
||||
|
||||
b.Property<bool>("OnQueueItemDeleted")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_queue_item_deleted");
|
||||
|
||||
b.Property<bool>("OnSlowStrike")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_slow_strike");
|
||||
|
||||
b.Property<bool>("OnStalledStrike")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_stalled_strike");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("type");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_notification_configs");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_notification_configs_name");
|
||||
|
||||
b.ToTable("notification_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("AccessToken")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("access_token");
|
||||
|
||||
b.Property<string>("AuthenticationType")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("authentication_type");
|
||||
|
||||
b.Property<Guid>("NotificationConfigId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("notification_config_id");
|
||||
|
||||
b.Property<string>("Password")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("password");
|
||||
|
||||
b.Property<string>("Priority")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("priority");
|
||||
|
||||
b.Property<string>("ServerUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("server_url");
|
||||
|
||||
b.PrimitiveCollection<string>("Tags")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("tags");
|
||||
|
||||
b.PrimitiveCollection<string>("Topics")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("topics");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("username");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_ntfy_configs");
|
||||
|
||||
b.HasIndex("NotificationConfigId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_ntfy_configs_notification_config_id");
|
||||
|
||||
b.ToTable("ntfy_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("CronExpression")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("cron_expression");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.Property<bool>("UseAdvancedScheduling")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("use_advanced_scheduling");
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("FailedImport", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.FailedImport#FailedImportConfig", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<bool>("DeletePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("failed_import_delete_private");
|
||||
|
||||
b1.Property<bool>("IgnorePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("failed_import_ignore_private");
|
||||
|
||||
b1.PrimitiveCollection<string>("IgnoredPatterns")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("failed_import_ignored_patterns");
|
||||
|
||||
b1.Property<ushort>("MaxStrikes")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("failed_import_max_strikes");
|
||||
});
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Slow", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.Slow#SlowConfig", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<bool>("DeletePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("slow_delete_private");
|
||||
|
||||
b1.Property<string>("IgnoreAboveSize")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("slow_ignore_above_size");
|
||||
|
||||
b1.Property<bool>("IgnorePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("slow_ignore_private");
|
||||
|
||||
b1.Property<ushort>("MaxStrikes")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("slow_max_strikes");
|
||||
|
||||
b1.Property<double>("MaxTime")
|
||||
.HasColumnType("REAL")
|
||||
.HasColumnName("slow_max_time");
|
||||
|
||||
b1.Property<string>("MinSpeed")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("slow_min_speed");
|
||||
|
||||
b1.Property<bool>("ResetStrikesOnProgress")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("slow_reset_strikes_on_progress");
|
||||
});
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Stalled", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.Stalled#StalledConfig", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<bool>("DeletePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("stalled_delete_private");
|
||||
|
||||
b1.Property<ushort>("DownloadingMetadataMaxStrikes")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("stalled_downloading_metadata_max_strikes");
|
||||
|
||||
b1.Property<bool>("IgnorePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("stalled_ignore_private");
|
||||
|
||||
b1.Property<ushort>("MaxStrikes")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("stalled_max_strikes");
|
||||
|
||||
b1.Property<bool>("ResetStrikesOnProgress")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("stalled_reset_strikes_on_progress");
|
||||
});
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_queue_cleaner_configs");
|
||||
|
||||
b.ToTable("queue_cleaner_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b =>
|
||||
{
|
||||
b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", "ArrConfig")
|
||||
.WithMany("Instances")
|
||||
.HasForeignKey("ArrConfigId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_arr_instances_arr_configs_arr_config_id");
|
||||
|
||||
b.Navigation("ArrConfig");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.CleanCategory", b =>
|
||||
{
|
||||
b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", "DownloadCleanerConfig")
|
||||
.WithMany("Categories")
|
||||
.HasForeignKey("DownloadCleanerConfigId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_clean_categories_download_cleaner_configs_download_cleaner_config_id");
|
||||
|
||||
b.Navigation("DownloadCleanerConfig");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b =>
|
||||
{
|
||||
b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig")
|
||||
.WithOne("AppriseConfiguration")
|
||||
.HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", "NotificationConfigId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_apprise_configs_notification_configs_notification_config_id");
|
||||
|
||||
b.Navigation("NotificationConfig");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b =>
|
||||
{
|
||||
b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig")
|
||||
.WithOne("NotifiarrConfiguration")
|
||||
.HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", "NotificationConfigId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_notifiarr_configs_notification_configs_notification_config_id");
|
||||
|
||||
b.Navigation("NotificationConfig");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", b =>
|
||||
{
|
||||
b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig")
|
||||
.WithOne("NtfyConfiguration")
|
||||
.HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", "NotificationConfigId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_ntfy_configs_notification_configs_notification_config_id");
|
||||
|
||||
b.Navigation("NotificationConfig");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b =>
|
||||
{
|
||||
b.Navigation("Instances");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b =>
|
||||
{
|
||||
b.Navigation("Categories");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b =>
|
||||
{
|
||||
b.Navigation("AppriseConfiguration");
|
||||
|
||||
b.Navigation("NotifiarrConfiguration");
|
||||
|
||||
b.Navigation("NtfyConfiguration");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Cleanuparr.Persistence.Migrations.Data
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddNtfy : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ntfy_configs",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
notification_config_id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
server_url = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
|
||||
topics = table.Column<string>(type: "TEXT", nullable: false),
|
||||
authentication_type = table.Column<string>(type: "TEXT", nullable: false),
|
||||
username = table.Column<string>(type: "TEXT", maxLength: 255, nullable: true),
|
||||
password = table.Column<string>(type: "TEXT", maxLength: 255, nullable: true),
|
||||
access_token = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true),
|
||||
priority = table.Column<string>(type: "TEXT", nullable: false),
|
||||
tags = table.Column<string>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_ntfy_configs", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_ntfy_configs_notification_configs_notification_config_id",
|
||||
column: x => x.notification_config_id,
|
||||
principalTable: "notification_configs",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_ntfy_configs_notification_config_id",
|
||||
table: "ntfy_configs",
|
||||
column: "notification_config_id",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ntfy_configs");
|
||||
}
|
||||
}
|
||||
}
|
||||
816
code/backend/Cleanuparr.Persistence/Migrations/Data/20250915153159_AddBlacklistSyncSettings.Designer.cs
generated
Normal file
@@ -0,0 +1,816 @@
|
||||
// <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("20250915153159_AddBlacklistSyncSettings")]
|
||||
partial class AddBlacklistSyncSettings
|
||||
{
|
||||
/// <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.BlacklistSync.BlacklistSyncConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("BlacklistPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("blacklist_path");
|
||||
|
||||
b.Property<string>("CronExpression")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("cron_expression");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_blacklist_sync_configs");
|
||||
|
||||
b.ToTable("blacklist_sync_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.CleanCategory", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("DownloadCleanerConfigId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("download_cleaner_config_id");
|
||||
|
||||
b.Property<double>("MaxRatio")
|
||||
.HasColumnType("REAL")
|
||||
.HasColumnName("max_ratio");
|
||||
|
||||
b.Property<double>("MaxSeedTime")
|
||||
.HasColumnType("REAL")
|
||||
.HasColumnName("max_seed_time");
|
||||
|
||||
b.Property<double>("MinSeedTime")
|
||||
.HasColumnType("REAL")
|
||||
.HasColumnName("min_seed_time");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_clean_categories");
|
||||
|
||||
b.HasIndex("DownloadCleanerConfigId")
|
||||
.HasDatabaseName("ix_clean_categories_download_cleaner_config_id");
|
||||
|
||||
b.ToTable("clean_categories", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("CronExpression")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("cron_expression");
|
||||
|
||||
b.Property<bool>("DeletePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("delete_private");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.PrimitiveCollection<string>("UnlinkedCategories")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("unlinked_categories");
|
||||
|
||||
b.Property<bool>("UnlinkedEnabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("unlinked_enabled");
|
||||
|
||||
b.Property<string>("UnlinkedIgnoredRootDir")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("unlinked_ignored_root_dir");
|
||||
|
||||
b.Property<string>("UnlinkedTargetCategory")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("unlinked_target_category");
|
||||
|
||||
b.Property<bool>("UnlinkedUseTag")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("unlinked_use_tag");
|
||||
|
||||
b.Property<bool>("UseAdvancedScheduling")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("use_advanced_scheduling");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_download_cleaner_configs");
|
||||
|
||||
b.ToTable("download_cleaner_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.Property<string>("Host")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("host");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string>("Password")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("password");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("type");
|
||||
|
||||
b.Property<string>("TypeName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("type_name");
|
||||
|
||||
b.Property<string>("UrlBase")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("url_base");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("username");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_download_clients");
|
||||
|
||||
b.ToTable("download_clients", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<bool>("DisplaySupportBanner")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("display_support_banner");
|
||||
|
||||
b.Property<bool>("DryRun")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("dry_run");
|
||||
|
||||
b.Property<string>("EncryptionKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("encryption_key");
|
||||
|
||||
b.Property<string>("HttpCertificateValidation")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("http_certificate_validation");
|
||||
|
||||
b.Property<ushort>("HttpMaxRetries")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("http_max_retries");
|
||||
|
||||
b.Property<ushort>("HttpTimeout")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("http_timeout");
|
||||
|
||||
b.PrimitiveCollection<string>("IgnoredDownloads")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("ignored_downloads");
|
||||
|
||||
b.Property<ushort>("SearchDelay")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("search_delay");
|
||||
|
||||
b.Property<bool>("SearchEnabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("search_enabled");
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Log", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Log#LoggingConfig", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<bool>("ArchiveEnabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("log_archive_enabled");
|
||||
|
||||
b1.Property<ushort>("ArchiveRetainedCount")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("log_archive_retained_count");
|
||||
|
||||
b1.Property<ushort>("ArchiveTimeLimitHours")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("log_archive_time_limit_hours");
|
||||
|
||||
b1.Property<string>("Level")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("log_level");
|
||||
|
||||
b1.Property<ushort>("RetainedFileCount")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("log_retained_file_count");
|
||||
|
||||
b1.Property<ushort>("RollingSizeMB")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("log_rolling_size_mb");
|
||||
|
||||
b1.Property<ushort>("TimeLimitHours")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("log_time_limit_hours");
|
||||
});
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_general_configs");
|
||||
|
||||
b.ToTable("general_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("CronExpression")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("cron_expression");
|
||||
|
||||
b.Property<bool>("DeleteKnownMalware")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("delete_known_malware");
|
||||
|
||||
b.Property<bool>("DeletePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("delete_private");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.Property<bool>("IgnorePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("ignore_private");
|
||||
|
||||
b.Property<bool>("UseAdvancedScheduling")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("use_advanced_scheduling");
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Lidarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<string>("BlocklistPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("lidarr_blocklist_path");
|
||||
|
||||
b1.Property<string>("BlocklistType")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("lidarr_blocklist_type");
|
||||
|
||||
b1.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("lidarr_enabled");
|
||||
});
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Radarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<string>("BlocklistPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("radarr_blocklist_path");
|
||||
|
||||
b1.Property<string>("BlocklistType")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("radarr_blocklist_type");
|
||||
|
||||
b1.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("radarr_enabled");
|
||||
});
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Readarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<string>("BlocklistPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("readarr_blocklist_path");
|
||||
|
||||
b1.Property<string>("BlocklistType")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("readarr_blocklist_type");
|
||||
|
||||
b1.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("readarr_enabled");
|
||||
});
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Sonarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<string>("BlocklistPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("sonarr_blocklist_path");
|
||||
|
||||
b1.Property<string>("BlocklistType")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("sonarr_blocklist_type");
|
||||
|
||||
b1.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("sonarr_enabled");
|
||||
});
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Whisparr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<string>("BlocklistPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("whisparr_blocklist_path");
|
||||
|
||||
b1.Property<int>("BlocklistType")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("whisparr_blocklist_type");
|
||||
|
||||
b1.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("whisparr_enabled");
|
||||
});
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_content_blocker_configs");
|
||||
|
||||
b.ToTable("content_blocker_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("key");
|
||||
|
||||
b.Property<Guid>("NotificationConfigId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("notification_config_id");
|
||||
|
||||
b.Property<string>("Tags")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("tags");
|
||||
|
||||
b.Property<string>("Url")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("url");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_apprise_configs");
|
||||
|
||||
b.HasIndex("NotificationConfigId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_apprise_configs_notification_config_id");
|
||||
|
||||
b.ToTable("apprise_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("ApiKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("api_key");
|
||||
|
||||
b.Property<string>("ChannelId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("channel_id");
|
||||
|
||||
b.Property<Guid>("NotificationConfigId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("notification_config_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_notifiarr_configs");
|
||||
|
||||
b.HasIndex("NotificationConfigId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_notifiarr_configs_notification_config_id");
|
||||
|
||||
b.ToTable("notifiarr_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("is_enabled");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<bool>("OnCategoryChanged")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_category_changed");
|
||||
|
||||
b.Property<bool>("OnDownloadCleaned")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_download_cleaned");
|
||||
|
||||
b.Property<bool>("OnFailedImportStrike")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_failed_import_strike");
|
||||
|
||||
b.Property<bool>("OnQueueItemDeleted")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_queue_item_deleted");
|
||||
|
||||
b.Property<bool>("OnSlowStrike")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_slow_strike");
|
||||
|
||||
b.Property<bool>("OnStalledStrike")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_stalled_strike");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("type");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_notification_configs");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_notification_configs_name");
|
||||
|
||||
b.ToTable("notification_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.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.State.BlacklistSyncHistory", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("DownloadClientId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("download_client_id");
|
||||
|
||||
b.Property<string>("Hash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("hash");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_blacklist_sync_history");
|
||||
|
||||
b.HasIndex("DownloadClientId")
|
||||
.HasDatabaseName("ix_blacklist_sync_history_download_client_id");
|
||||
|
||||
b.HasIndex("Hash")
|
||||
.HasDatabaseName("ix_blacklist_sync_history_hash");
|
||||
|
||||
b.HasIndex("Hash", "DownloadClientId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_blacklist_sync_history_hash_download_client_id");
|
||||
|
||||
b.ToTable("blacklist_sync_history", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b =>
|
||||
{
|
||||
b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", "ArrConfig")
|
||||
.WithMany("Instances")
|
||||
.HasForeignKey("ArrConfigId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_arr_instances_arr_configs_arr_config_id");
|
||||
|
||||
b.Navigation("ArrConfig");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.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.State.BlacklistSyncHistory", b =>
|
||||
{
|
||||
b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClient")
|
||||
.WithMany()
|
||||
.HasForeignKey("DownloadClientId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_blacklist_sync_history_download_clients_download_client_id");
|
||||
|
||||
b.Navigation("DownloadClient");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b =>
|
||||
{
|
||||
b.Navigation("Instances");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b =>
|
||||
{
|
||||
b.Navigation("Categories");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b =>
|
||||
{
|
||||
b.Navigation("AppriseConfiguration");
|
||||
|
||||
b.Navigation("NotifiarrConfiguration");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Cleanuparr.Persistence.Migrations.Data
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddBlacklistSyncSettings : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "blacklist_sync_configs",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
enabled = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
cron_expression = table.Column<string>(type: "TEXT", nullable: false),
|
||||
blacklist_path = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_blacklist_sync_configs", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "blacklist_sync_history",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
hash = table.Column<string>(type: "TEXT", nullable: false),
|
||||
download_client_id = table.Column<Guid>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_blacklist_sync_history", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_blacklist_sync_history_download_clients_download_client_id",
|
||||
column: x => x.download_client_id,
|
||||
principalTable: "download_clients",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "blacklist_sync_configs",
|
||||
columns: new[] { "id", "enabled", "cron_expression", "blacklist_path" },
|
||||
values: new object[] { Guid.NewGuid(), false, "0 0 * * * ?", null });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_blacklist_sync_history_download_client_id",
|
||||
table: "blacklist_sync_history",
|
||||
column: "download_client_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_blacklist_sync_history_hash",
|
||||
table: "blacklist_sync_history",
|
||||
column: "hash");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_blacklist_sync_history_hash_download_client_id",
|
||||
table: "blacklist_sync_history",
|
||||
columns: new[] { "hash", "download_client_id" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "blacklist_sync_configs");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "blacklist_sync_history");
|
||||
}
|
||||
}
|
||||
}
|
||||
761
code/backend/Cleanuparr.Persistence/Migrations/Data/20250915181529_AddPerJobIgnoredDownloads.Designer.cs
generated
Normal file
@@ -0,0 +1,761 @@
|
||||
// <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("20250915181529_AddPerJobIgnoredDownloads")]
|
||||
partial class AddPerJobIgnoredDownloads
|
||||
{
|
||||
/// <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>("IgnoredDownloads")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("ignored_downloads");
|
||||
|
||||
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.PrimitiveCollection<string>("IgnoredDownloads")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("ignored_downloads");
|
||||
|
||||
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.PrimitiveCollection<string>("IgnoredDownloads")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("ignored_downloads");
|
||||
|
||||
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,51 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Cleanuparr.Persistence.Migrations.Data
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPerJobIgnoredDownloads : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ignored_downloads",
|
||||
table: "queue_cleaner_configs",
|
||||
type: "TEXT",
|
||||
nullable: false,
|
||||
defaultValue: "[]");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ignored_downloads",
|
||||
table: "download_cleaner_configs",
|
||||
type: "TEXT",
|
||||
nullable: false,
|
||||
defaultValue: "[]");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ignored_downloads",
|
||||
table: "content_blocker_configs",
|
||||
type: "TEXT",
|
||||
nullable: false,
|
||||
defaultValue: "[]");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ignored_downloads",
|
||||
table: "queue_cleaner_configs");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ignored_downloads",
|
||||
table: "download_cleaner_configs");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ignored_downloads",
|
||||
table: "content_blocker_configs");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// <auto-generated />
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Cleanuparr.Persistence;
|
||||
@@ -79,6 +79,32 @@ namespace Cleanuparr.Persistence.Migrations.Data
|
||||
b.ToTable("arr_instances", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.BlacklistSync.BlacklistSyncConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("BlacklistPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("blacklist_path");
|
||||
|
||||
b.Property<string>("CronExpression")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("cron_expression");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_blacklist_sync_configs");
|
||||
|
||||
b.ToTable("blacklist_sync_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.CleanCategory", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -136,6 +162,11 @@ namespace Cleanuparr.Persistence.Migrations.Data
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.PrimitiveCollection<string>("IgnoredDownloads")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("ignored_downloads");
|
||||
|
||||
b.PrimitiveCollection<string>("UnlinkedCategories")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
@@ -331,6 +362,11 @@ namespace Cleanuparr.Persistence.Migrations.Data
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("ignore_private");
|
||||
|
||||
b.PrimitiveCollection<string>("IgnoredDownloads")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("ignored_downloads");
|
||||
|
||||
b.Property<bool>("UseAdvancedScheduling")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("use_advanced_scheduling");
|
||||
@@ -565,6 +601,68 @@ namespace Cleanuparr.Persistence.Migrations.Data
|
||||
b.ToTable("notification_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("AccessToken")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("access_token");
|
||||
|
||||
b.Property<string>("AuthenticationType")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("authentication_type");
|
||||
|
||||
b.Property<Guid>("NotificationConfigId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("notification_config_id");
|
||||
|
||||
b.Property<string>("Password")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("password");
|
||||
|
||||
b.Property<string>("Priority")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("priority");
|
||||
|
||||
b.Property<string>("ServerUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("server_url");
|
||||
|
||||
b.PrimitiveCollection<string>("Tags")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("tags");
|
||||
|
||||
b.PrimitiveCollection<string>("Topics")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("topics");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("username");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_ntfy_configs");
|
||||
|
||||
b.HasIndex("NotificationConfigId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_ntfy_configs_notification_config_id");
|
||||
|
||||
b.ToTable("ntfy_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -581,6 +679,11 @@ namespace Cleanuparr.Persistence.Migrations.Data
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.PrimitiveCollection<string>("IgnoredDownloads")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("ignored_downloads");
|
||||
|
||||
b.Property<bool>("UseAdvancedScheduling")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("use_advanced_scheduling");
|
||||
@@ -673,6 +776,38 @@ namespace Cleanuparr.Persistence.Migrations.Data
|
||||
b.ToTable("queue_cleaner_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.State.BlacklistSyncHistory", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("DownloadClientId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("download_client_id");
|
||||
|
||||
b.Property<string>("Hash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("hash");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_blacklist_sync_history");
|
||||
|
||||
b.HasIndex("DownloadClientId")
|
||||
.HasDatabaseName("ix_blacklist_sync_history_download_client_id");
|
||||
|
||||
b.HasIndex("Hash")
|
||||
.HasDatabaseName("ix_blacklist_sync_history_hash");
|
||||
|
||||
b.HasIndex("Hash", "DownloadClientId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_blacklist_sync_history_hash_download_client_id");
|
||||
|
||||
b.ToTable("blacklist_sync_history", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b =>
|
||||
{
|
||||
b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", "ArrConfig")
|
||||
@@ -721,6 +856,30 @@ namespace Cleanuparr.Persistence.Migrations.Data
|
||||
b.Navigation("NotificationConfig");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.State.BlacklistSyncHistory", b =>
|
||||
{
|
||||
b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClient")
|
||||
.WithMany()
|
||||
.HasForeignKey("DownloadClientId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_blacklist_sync_history_download_clients_download_client_id");
|
||||
|
||||
b.Navigation("DownloadClient");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", b =>
|
||||
{
|
||||
b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig")
|
||||
.WithOne("NtfyConfiguration")
|
||||
.HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", "NotificationConfigId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_ntfy_configs_notification_configs_notification_config_id");
|
||||
|
||||
b.Navigation("NotificationConfig");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b =>
|
||||
{
|
||||
b.Navigation("Instances");
|
||||
@@ -736,6 +895,8 @@ namespace Cleanuparr.Persistence.Migrations.Data
|
||||
b.Navigation("AppriseConfiguration");
|
||||
|
||||
b.Navigation("NotifiarrConfiguration");
|
||||
|
||||
b.Navigation("NtfyConfiguration");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
|
||||
|
||||
namespace Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for Blacklist Synchronization to qBittorrent
|
||||
/// </summary>
|
||||
public sealed record BlacklistSyncConfig : IJobConfig
|
||||
{
|
||||
[Key]
|
||||
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
public string CronExpression { get; set; } = "0 0 * * * ?";
|
||||
|
||||
public string? BlacklistPath { get; set; }
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (!Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(BlacklistPath))
|
||||
{
|
||||
throw new ValidationException("Blacklist sync is enabled but the path is not configured");
|
||||
}
|
||||
|
||||
bool isValidPath = Uri.TryCreate(BlacklistPath, UriKind.Absolute, out var uri) &&
|
||||
(uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps) ||
|
||||
File.Exists(BlacklistPath);
|
||||
|
||||
if (!isValidPath)
|
||||
{
|
||||
throw new ValidationException("Blacklist path must be a valid URL or an existing local file path");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,8 @@ public sealed record DownloadCleanerConfig : IJobConfig
|
||||
|
||||
public List<string> UnlinkedCategories { get; set; } = [];
|
||||
|
||||
public List<string> IgnoredDownloads { get; set; } = [];
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (!Enabled)
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
|
||||
|
||||
namespace Cleanuparr.Persistence.Models.Configuration.General;
|
||||
|
||||
@@ -5,9 +5,4 @@ public interface IJobConfig : IConfig
|
||||
bool Enabled { get; set; }
|
||||
|
||||
string CronExpression { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether to use the CronExpression directly (true) or convert from JobSchedule (false)
|
||||
/// </summary>
|
||||
bool UseAdvancedScheduling { get; set; }
|
||||
}
|
||||
@@ -31,6 +31,8 @@ public sealed record ContentBlockerConfig : IJobConfig
|
||||
public BlocklistSettings Readarr { get; set; } = new();
|
||||
|
||||
public BlocklistSettings Whisparr { get; set; } = new();
|
||||
|
||||
public List<string> IgnoredDownloads { get; set; } = [];
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
|
||||
@@ -39,11 +39,14 @@ public sealed record NotificationConfig
|
||||
|
||||
public AppriseConfig? AppriseConfiguration { get; init; }
|
||||
|
||||
public NtfyConfig? NtfyConfiguration { get; init; }
|
||||
|
||||
[NotMapped]
|
||||
public bool IsConfigured => Type switch
|
||||
{
|
||||
NotificationProviderType.Notifiarr => NotifiarrConfiguration?.IsValid() == true,
|
||||
NotificationProviderType.Apprise => AppriseConfiguration?.IsValid() == true,
|
||||
NotificationProviderType.Ntfy => NtfyConfiguration?.IsValid() == true,
|
||||
_ => false
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
|
||||
|
||||
namespace Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
|
||||
public sealed record NtfyConfig : IConfig
|
||||
{
|
||||
[Key]
|
||||
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||
public Guid Id { get; init; } = Guid.NewGuid();
|
||||
|
||||
[Required]
|
||||
public Guid NotificationConfigId { get; init; }
|
||||
|
||||
public NotificationConfig NotificationConfig { get; init; } = null!;
|
||||
|
||||
[Required]
|
||||
[MaxLength(500)]
|
||||
public string ServerUrl { get; init; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public List<string> Topics { get; init; } = new();
|
||||
|
||||
[Required]
|
||||
public NtfyAuthenticationType AuthenticationType { get; init; } = NtfyAuthenticationType.None;
|
||||
|
||||
[MaxLength(255)]
|
||||
public string? Username { get; init; }
|
||||
|
||||
[MaxLength(255)]
|
||||
public string? Password { get; init; }
|
||||
|
||||
[MaxLength(500)]
|
||||
public string? AccessToken { get; init; }
|
||||
|
||||
[Required]
|
||||
public NtfyPriority Priority { get; init; } = NtfyPriority.Default;
|
||||
|
||||
public List<string> Tags { get; init; } = new();
|
||||
|
||||
[NotMapped]
|
||||
public Uri? Uri
|
||||
{
|
||||
get
|
||||
{
|
||||
try
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(ServerUrl) ? null : new Uri(ServerUrl, UriKind.Absolute);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsValid()
|
||||
{
|
||||
return Uri != null &&
|
||||
Topics.Any(t => !string.IsNullOrWhiteSpace(t)) &&
|
||||
IsAuthenticationValid();
|
||||
}
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ServerUrl))
|
||||
{
|
||||
throw new ValidationException("ntfy server URL is required");
|
||||
}
|
||||
|
||||
if (Uri == null)
|
||||
{
|
||||
throw new ValidationException("ntfy server URL must be a valid HTTP or HTTPS URL");
|
||||
}
|
||||
|
||||
if (!Topics.Any(t => !string.IsNullOrWhiteSpace(t)))
|
||||
{
|
||||
throw new ValidationException("At least one ntfy topic is required");
|
||||
}
|
||||
|
||||
ValidateAuthentication();
|
||||
}
|
||||
|
||||
private bool IsAuthenticationValid()
|
||||
{
|
||||
return AuthenticationType switch
|
||||
{
|
||||
NtfyAuthenticationType.None => true,
|
||||
NtfyAuthenticationType.BasicAuth => !string.IsNullOrWhiteSpace(Username) && !string.IsNullOrWhiteSpace(Password),
|
||||
NtfyAuthenticationType.AccessToken => !string.IsNullOrWhiteSpace(AccessToken),
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private void ValidateAuthentication()
|
||||
{
|
||||
switch (AuthenticationType)
|
||||
{
|
||||
case NtfyAuthenticationType.BasicAuth:
|
||||
if (string.IsNullOrWhiteSpace(Username))
|
||||
{
|
||||
throw new ValidationException("Username is required for Basic Auth");
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(Password))
|
||||
{
|
||||
throw new ValidationException("Password is required for Basic Auth");
|
||||
}
|
||||
break;
|
||||
|
||||
case NtfyAuthenticationType.AccessToken:
|
||||
if (string.IsNullOrWhiteSpace(AccessToken))
|
||||
{
|
||||
throw new ValidationException("Access token is required for Token authentication");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,8 @@ public sealed record QueueCleanerConfig : IJobConfig
|
||||
public StalledConfig Stalled { get; set; } = new();
|
||||
|
||||
public SlowConfig Slow { get; set; } = new();
|
||||
|
||||
public List<string> IgnoredDownloads { get; set; } = [];
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
|
||||
namespace Cleanuparr.Persistence.Models.State;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks which download clients have been synchronized for a specific blacklist content hash.
|
||||
/// </summary>
|
||||
public sealed record BlacklistSyncHistory
|
||||
{
|
||||
/// <summary>
|
||||
/// Primary key
|
||||
/// </summary>
|
||||
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||
public Guid Id { get; init; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 hash of the blacklist contents used during synchronization
|
||||
/// </summary>
|
||||
public required string Hash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Foreign key to the download client this sync entry applies to
|
||||
/// </summary>
|
||||
public required Guid DownloadClientId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Navigation property to the associated download client configuration
|
||||
/// </summary>
|
||||
public DownloadClientConfig DownloadClient { get; init; } = null!;
|
||||
}
|
||||
2
code/frontend/public/icons/ext/apprise-light.svg
Normal file
@@ -0,0 +1,2 @@
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="M433.8 75.1C386.3 26.7 323.2 0 256 0S125.7 26.7 78.2 75.1C30.8 123.4 4.7 187.7 4.7 256s26.1 132.6 73.5 180.9C125.7 485.3 188.8 512 256 512s130.3-26.7 177.8-75.1c47.4-48.3 73.5-112.6 73.5-180.9s-26.1-132.6-73.5-180.9m-76.9 98.1c-7.4-13.4-21.5-19.8-37.8-17-5.6.9-10.9-2.8-11.9-8.4-.9-5.6 2.8-10.9 8.4-11.9 25-4.2 47.7 6.3 59.3 27.4 11.4 20.8 8.4 45.8-7.7 63.7-2 2.3-4.8 3.4-7.7 3.4-2.5 0-4.9-.9-6.9-2.6-4.2-3.8-4.6-10.3-.8-14.5 10.3-11.3 12.2-27 5.1-40.1M113.1 365.1c-28.2 0-51.1-22.2-51.1-49.6 0-14.7 6.6-27.9 17.1-37 16.2 27.7 33.8 56.4 50.1 84.1-5.2 1.6-10.5 2.5-16.1 2.5m121.8 54-21.2 12.2c-7.7 4.5-17.6 1.8-22.1-5.9l-30.1-52.1 49.2-28.4 30.1 52.1c4.5 7.8 1.8 17.7-5.9 22.1M150 352.4c-16.8-29-35.8-59.7-52.6-88.7 28.8-15.5 85.8-44.5 118.5-108.4 7.2-14.1 10.9-28.8 15.7-40.9 1.5-3.9 4.7-6.9 8.7-8 1.4-.4 3.1-.6 4.9-.4 4.4.4 8.6 2.8 11 7l103.3 177.7c.2.3.3.6.5.9 4.8 9.8-3.9 21-14.7 19.3-15.3-2.4-31.9-6.7-51.3-6.8-69.4-.5-116.4 34.5-144 48.3m284.4-164.2c-1.9 22.8-9.9 38-22.3 55.2-2.1 2.9-5.4 4.5-8.8 4.5-2.2 0-4.4-.7-6.3-2-4.8-3.5-6-10.2-2.5-15.1 10.4-14.6 16.8-26.5 18.3-44.4 1.8-22.3-5.8-42.6-21.4-57-17-15.7-41.8-22.8-66.2-18.8-5.9 1-11.4-3.1-12.4-8.9-1-5.9 3.1-11.4 8.9-12.4 31-5 62.5 4.1 84.4 24.3 20.7 19 30.7 45.6 28.3 74.6" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
code/frontend/public/icons/ext/apprise.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><linearGradient id="apprise_svg__a" x1="88.655" x2="423.345" y1="423.345" y2="88.656" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#144042"/><stop offset="1" style="stop-color:#216666"/></linearGradient><ellipse cx="256" cy="256" rx="234.3" ry="239" style="fill:url(#apprise_svg__a)"/><path d="M433.8 75.1C386.3 26.7 323.2 0 256 0S125.7 26.7 78.2 75.1C30.8 123.4 4.7 187.7 4.7 256s26.1 132.6 73.5 180.9C125.7 485.3 188.8 512 256 512s130.3-26.7 177.8-75.1c47.4-48.3 73.5-112.6 73.5-180.9s-26.1-132.6-73.5-180.9M256 495C126.6 495 21.7 388 21.7 256S126.6 17 256 17s234.3 107 234.3 239S385.4 495 256 495"/><path d="M217.4 341.1c-2.1-3.7-6.9-5-10.5-2.8l-49.2 28.4c-2.6 1.5-4.2 4.4-3.8 7.6.1 1.1.6 2.2 1.1 3.2l29.7 51.5c3.3 5.6 8.5 9.7 14.8 11.3 2.1.6 4.2.8 6.3.8 4.2 0 8.4-1.1 12.1-3.3l19.9-11.5c5.3-3.1 9.5-7.8 11.4-13.6 2.2-6.7 1.5-13.9-2-19.9zm17.1 78.3-20.4 11.8c-8 4.6-18.1 1.9-22.7-6.1l-29.9-51.7 49.2-28.4 29.9 51.7c4.6 7.9 1.9 18.1-6.1 22.7m132.4-131.2c-.2-.5-.5-.9-.7-1.4L262.9 109.2c-3.6-6.1-9.9-10.2-17-10.8-2.6-.2-5.1 0-7.6.6-6.2 1.7-11.4 6.4-13.9 12.6-1.7 4.4-3.3 9-4.9 13.8-2.9 8.7-6 17.7-10.5 26.4-30.6 59.6-83.8 88.2-112.4 103.6l-2.9 1.5c-3.8 2.1-5.2 6.9-3 10.7 8.4 14.6 17.5 29.8 26.4 44.5 8.8 14.7 17.9 29.8 26.2 44.3 2 3.5 6.5 4.9 10.1 3 5-2.5 10.7-5.7 16.7-9.1 27.1-15.3 68-38.4 122.5-38.4h1.3c13.7.1 25.9 2.4 37.8 4.6 4.3.8 8.3 1.5 12.4 2.2 8 1.3 16-2 20.8-8.6 4.7-6.5 5.5-14.8 2-21.9 0 .1 0 0 0 0m-21.6 22.7c-15.3-2.4-31.9-6.7-51.3-6.8-69.4-.5-116.4 34.5-144 48.3-16.8-29-35.8-59.7-52.6-88.7 28.8-15.5 85.8-44.5 118.5-108.4 7.2-14.1 10.9-28.8 15.7-40.9 1.5-3.9 4.7-6.9 8.7-8 1.4-.4 3.1-.6 4.9-.4 4.4.4 8.6 2.8 11 7l103.3 177.7c.2.3.3.6.5.9 4.8 9.9-3.9 21-14.7 19.3M111 317.1c-8.4-14-17.1-28.5-25.3-42.6-1.2-2-3.1-3.3-5.4-3.7s-4.6.3-6.3 1.8c-12.6 10.9-19.8 26.5-19.8 42.9 0 31.6 26.4 57.3 58.9 57.3 6.3 0 12.4-1 18.4-2.9 2.2-.7 4-2.4 4.9-4.6.8-2.2.6-4.6-.6-6.7-8.1-13.6-16.6-27.8-24.8-41.5m2.1 48c-28.2 0-51.1-22.2-51.1-49.6 0-14.7 6.6-27.9 17.1-37 16.2 27.7 33.8 56.4 50.1 84.1-5.2 1.6-10.5 2.5-16.1 2.5m268.6-205.5c-13.2-24.1-39-36.1-67.3-31.3-4.7.8-8.9 3.4-11.7 7.3s-3.9 8.7-3.1 13.4c1.6 9.8 10.9 16.4 20.8 14.8 13.1-2.2 24 2.6 29.8 13.2 5.6 10.1 4 22.3-3.9 31.2-3.2 3.6-4.8 8.2-4.6 13 .3 4.8 2.4 9.2 5.9 12.4 3.3 3 7.6 4.6 12 4.6 5.1 0 10-2.2 13.4-6 18.3-20.4 21.7-48.9 8.7-72.6M367.3 227c-2 2.3-4.8 3.4-7.7 3.4-2.5 0-4.9-.9-6.9-2.6-4.2-3.8-4.6-10.3-.8-14.5 10.2-11.3 12.1-27 4.9-40-7.4-13.4-21.5-19.8-37.8-17-5.6.9-10.9-2.8-11.9-8.4-.9-5.6 2.8-10.9 8.4-11.9 25-4.2 47.7 6.3 59.3 27.4 11.6 20.7 8.6 45.7-7.5 63.6m44.1-119.1c-23.6-21.8-57.5-31.6-90.9-26.2-4.9.8-9.2 3.4-12.1 7.5-2.9 4-4.1 8.9-3.3 13.8 1.6 10.1 11.2 17 21.3 15.3 22.2-3.6 44.5 2.7 59.8 16.8 13.9 12.8 20.6 30.9 19 50.7-1.3 16.2-7 26.8-16.9 40.5-2.9 4-4 8.9-3.2 13.8s3.5 9.2 7.5 12c3.2 2.3 6.9 3.5 10.8 3.5 6 0 11.6-2.9 15.1-7.7 13.1-18.3 21.6-34.5 23.7-59.1 2.5-31.4-8.4-60.2-30.8-80.9m23 80.3c-1.9 22.8-9.9 38-22.3 55.2-2.1 2.9-5.4 4.5-8.8 4.5-2.2 0-4.4-.7-6.3-2-4.8-3.5-6-10.2-2.5-15.1 10.4-14.6 16.8-26.5 18.3-44.4 1.8-22.3-5.8-42.6-21.4-57-17-15.7-41.8-22.8-66.2-18.8-5.9 1-11.4-3.1-12.4-8.9-1-5.9 3.1-11.4 8.9-12.4 31-5 62.5 4.1 84.4 24.3 20.7 19 30.7 45.6 28.3 74.6" style="fill:#99b4b4"/><path d="M360 291.6c4.8 9.8-3.9 21-14.7 19.3-15.3-2.4-31.9-6.7-51.3-6.8-69.4-.5-116.4 34.5-144 48.3-16.8-29-35.8-59.7-52.6-88.7 28.8-15.5 85.8-44.5 118.5-108.4 7.2-14.1 10.9-28.8 15.7-40.9 1.5-3.9 4.7-6.9 8.7-8 1.4-.4 3.1-.6 4.9-.4 4.4.4 8.6 2.8 11 7l103.3 177.7c.2.3.4.6.5.9M79 278.4c-10.5 9.1-17.1 22.3-17.1 37 0 27.4 22.9 49.6 51.1 49.6 5.6 0 11-.9 16-2.5-16.2-27.7-33.7-56.4-50-84.1m161.8 118.7L210.7 345l-49.2 28.4 30.1 52.1c4.5 7.7 14.3 10.4 22.1 5.9l21.2-12.2c7.7-4.5 10.4-14.4 5.9-22.1m171.3-153.6c12.3-17.2 20.4-32.4 22.3-55.2 2.4-29.1-7.6-55.6-28.3-74.7-21.8-20.2-53.4-29.3-84.4-24.3-5.9 1-9.9 6.5-8.9 12.4s6.5 9.9 12.4 8.9c24.5-4 49.2 3.1 66.2 18.8 15.7 14.5 23.3 34.7 21.4 57-1.5 17.9-7.8 29.9-18.3 44.4-3.5 4.8-2.4 11.6 2.5 15.1 1.9 1.4 4.1 2 6.3 2 3.4.1 6.7-1.5 8.8-4.4M367.3 227c16.1-17.9 19.1-42.9 7.7-63.7-11.6-21.1-34.3-31.6-59.3-27.4-5.6.9-9.4 6.3-8.4 11.9.9 5.6 6.3 9.4 11.9 8.4 16.3-2.7 30.4 3.6 37.8 17 7.2 13 5.2 28.7-4.9 40-3.8 4.2-3.5 10.7.8 14.5 2 1.8 4.4 2.6 6.9 2.6 2.6.1 5.4-1 7.5-3.3"/></svg>
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
2
code/frontend/public/icons/ext/lidarr-light.svg
Normal file
@@ -0,0 +1,2 @@
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="M291.1 190.6c-9.5-5.2-20.3-8.3-31.7-8.9V20.9L83 322.9h136.7c9.5 5.2 20.3 8.3 31.7 8.9v160.8l16.2-27.7 160.2-274.3zm-39.7-8.9c-39.7 2.1-71.2 34.9-71.2 75.1 0 23.4 10.7 44.4 27.5 58.1H97L251.7 49.2zm7.5 283.5.4-133.4c39.7-2.1 71.2-34.9 71.2-75.1 0-23.4-10.7-44.4-27.5-58.1h110.8zm34.2-282.6c-8.1-4.1-16.7-6.9-25.7-8.2v-71.6c36.7 2.8 70.8 18.4 97.2 44.8 10.5 10.5 19.3 22.3 26.3 35zm-75.4 148.3c8.1 4.1 16.7 6.9 25.7 8.2v71.6c-36.7-2.8-70.8-18.4-97.2-44.8a156 156 0 0 1-26.3-35zm97.5 68.2L408.7 239c.7 5.8 1 11.8 1 17.7 0 41.2-16.1 80-45.2 109.2-14.3 14.4-31.1 25.6-49.3 33.2M512 256c0 141.4-114.6 256-256 256-2.2 0-4.4 0-6.6-.1l23-39.3c23.2-1.8 45.9-7.3 67.3-16.4 25.8-10.9 48.9-26.5 68.8-46.4s35.5-43 46.4-68.8c11.3-26.7 17-55.1 17-84.3s-5.7-57.6-17-84.3c-10.9-25.8-26.5-48.9-46.4-68.8s-43-35.5-68.8-46.4c-23-9.7-47.3-15.3-72.3-16.7V.3C403.5 6.2 512 118.4 512 256M171.1 456.3c23 9.7 47.3 15.3 72.3 16.7v38.7C107.8 505.1 0 393.1 0 256 0 114.6 114.6 0 256 0c2.1 0 4.2 0 6.3.1l-23.8 40.8c-23.2 1.8-45.9 7.3-67.3 16.4-25.8 10.9-48.9 26.5-68.8 46.4s-35.5 43-46.4 68.8c-11.3 26.7-17 55.1-17 84.3s5.7 57.6 17 84.3c10.9 25.8 26.5 48.9 46.4 68.8 19.8 19.8 42.9 35.4 68.7 46.4m24.4-341.9L102 274.5c-.7-5.8-1-11.8-1-17.7 0-41.2 16.1-80 45.2-109.2 14.4-14.4 31.1-25.6 49.3-33.2" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
1
code/frontend/public/icons/ext/lidarr.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><circle cx="256" cy="256" r="256" style="fill:#e5e5e5"/><path d="M460.9 256.8c0-113.5-92-205.5-205.5-205.5V35.7l-9.2 15.7C137 56.3 49.9 146.3 49.9 256.8c0 113.5 92 205.5 205.5 205.5v15.5l9.2-15.7c109.2-4.9 196.3-94.9 196.3-205.3m-96.4-109.2c13.8 13.8 24.6 29.7 32.2 47H255.4v-92.2c41.2 0 80 16 109.1 45.2m-218.3 0c18.9-18.9 41.8-32.3 66.8-39.4L105.2 292.8q-4.2-17.55-4.2-36c0-41.3 16.1-80 45.2-109.2m0 218.3c-13.8-13.8-24.6-29.7-32.2-47h141.4v92.2c-41.3 0-80-16-109.2-45.2m151.5 39.4 107.8-184.6q4.2 17.55 4.2 36c0 41.2-16.1 80-45.2 109.2-18.8 18.9-41.8 32.3-66.8 39.4"/><path d="M447.5 175.6c-10.5-24.8-25.5-47.1-44.7-66.3-19.1-19.1-41.4-34.2-66.3-44.7-24.5-10.4-50.4-15.8-77.2-16.3V20.9l-16.2 27.7c-23.9 1.4-47 6.7-69 16-24.8 10.5-47.1 25.5-66.3 44.7-19.1 19.1-34.2 41.4-44.7 66.3-10.9 25.7-16.4 53-16.4 81.2s5.5 55.5 16.4 81.2c10.5 24.8 25.5 47.1 44.7 66.3s41.4 34.2 66.3 44.7c24.5 10.4 50.4 15.8 77.2 16.3v27.4l16.2-27.7c23.9-1.4 47-6.7 69-16 24.8-10.5 47.1-25.5 66.3-44.7s34.2-41.4 44.7-66.3c10.9-25.7 16.4-53 16.4-81.2s-5.5-55.5-16.4-81.2m-144.5 23h110.8L261.6 459.2c-.7 0-1.5 0-2.2.1V331.8c39.7-2.1 71.2-34.9 71.2-75.1 0-23.4-10.8-44.3-27.6-58.1m-95.3 116.3H97L249.1 54.4c.7 0 1.5 0 2.2-.1v127.4c-39.7 2.1-71.2 34.9-71.2 75.1.1 23.4 10.8 44.3 27.6 58.1M52.9 256.8c0-106.3 82.4-193.8 186.7-201.9L83 322.9h136.7c9.5 5.2 20.3 8.3 31.7 8.9v127.4c-109.8-2.1-198.5-92.1-198.5-202.4m218.3 201.8 156.6-268H291.1c-9.5-5.2-20.3-8.3-31.7-8.9V54.3c109.8 2.1 198.5 92.1 198.5 202.5 0 106.3-82.4 193.7-186.7 201.8" style="fill:#009252"/></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
2
code/frontend/public/icons/ext/notifiarr-light.svg
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
1
code/frontend/public/icons/ext/notifiarr.svg
Normal file
|
After Width: | Height: | Size: 9.8 KiB |
2
code/frontend/public/icons/ext/ntfy-light.svg
Normal file
@@ -0,0 +1,2 @@
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="M443.1 32.7h-365C40.9 32.7 9 62 9 99.2l.4 311.2-9.4 69 127.1-33.8H443c37.2 0 69.1-29.3 69.1-66.5V99.2c0-37.2-31.9-66.5-69-66.5m22 346.3c0 10-9 19.8-22.1 19.6H120.2l-64.6 19.5.7-3.8-.4-315.1c0-10.1 9.1-19.6 22.2-19.6H443c13.1 0 22.1 9.5 22.1 19.6zM110.5 139.7l124.6 67.9V254l-116.4 63.3-8.2 4.5v-50.1l76.6-40.6.5-.2-.5-.2-76.6-40.6zm158.2 152.4h132.4v46H268.7z" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 479 B |
1
code/frontend/public/icons/ext/ntfy.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><linearGradient id="ntfy_svg__a" x1="21.759" x2="445.247" y1="74.359" y2="393.379" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#348878"/><stop offset="1" style="stop-color:#56bda8"/></linearGradient><path d="M120.2 398.6H443c13.1.2 22.1-9.6 22.1-19.6V99.2c0-10.1-9-19.6-22.1-19.6H78.1c-13.1 0-22.2 9.5-22.2 19.6l.4 315.1-.7 3.8z" style="fill:url(#ntfy_svg__a)"/><path d="M443.1 32.7h-365C40.9 32.7 9 62 9 99.2l.4 311.2-9.4 69 127.1-33.8H443c37.2 0 69.1-29.3 69.1-66.5V99.2c0-37.2-31.9-66.5-69-66.5m22 346.3c0 10-9 19.8-22.1 19.6H120.2l-64.6 19.5.7-3.8-.4-315.1c0-10.1 9.1-19.6 22.2-19.6H443c13.1 0 22.1 9.5 22.1 19.6zM110.5 139.7l124.6 67.9V254l-116.4 63.3-8.2 4.5v-50.1l76.6-40.6.5-.2-.5-.2-76.6-40.6zm158.2 152.4h132.4v46H268.7z" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 862 B |
2
code/frontend/public/icons/ext/radarr-light.svg
Normal file
@@ -0,0 +1,2 @@
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="m80.3 80.8 3.9 372.4c-31.4 3.9-54.9-11.8-54.9-43.1l-3.9-309.7c0-98 90.2-121.5 145.1-82.3l278.3 160.7c39.2 27.4 47 78.4 27.4 113.7-3.9-27.4-15.7-43.1-39.2-58.8L123.4 57.2C99.9 41.6 80.3 45.5 80.3 80.8m-23.5 392c23.5 7.8 47 3.9 66.6-7.8l321.5-188.2c19.6 27.4 15.7 54.9-7.8 70.6L166.5 504.2c-39.2 19.6-90.1 0-109.7-31.4M150.9 363 343 253.3 154.8 147.4z" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 469 B |
1
code/frontend/public/icons/ext/radarr.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="m80.3 80.8 3.9 372.4c-31.4 3.9-54.9-11.8-54.9-43.1l-3.9-309.7c0-98 90.2-121.5 145.1-82.3l278.3 160.7c39.2 27.4 47 78.4 27.4 113.7-3.9-27.4-15.7-43.1-39.2-58.8L123.4 57.2C99.9 41.6 80.3 45.5 80.3 80.8m-23.5 392c23.5 7.8 47 3.9 66.6-7.8l321.5-188.2c19.6 27.4 15.7 54.9-7.8 70.6L166.5 504.2c-39.2 19.6-90.1 0-109.7-31.4" style="fill:#24292e"/><path d="M150.9 363 343 253.3 154.8 147.4z" style="fill:#ffc230"/></svg>
|
||||
|
After Width: | Height: | Size: 504 B |
2
code/frontend/public/icons/ext/readarr-light.svg
Normal file
@@ -0,0 +1,2 @@
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="M255.2.4C114 .4-.4 114.8-.4 256S114 511.6 255.2 511.6 510.8 397.2 510.8 256 396.4.4 255.2.4m156.2 411.8c-20.3 20.3-43.9 36.2-70.2 47.3-27.2 11.5-56.2 17.4-86 17.4s-58.8-5.8-86-17.4c-26.3-11.1-49.9-27.1-70.2-47.3-20.3-20.3-36.2-43.9-47.3-70.2-11.5-27.2-17.4-56.2-17.4-86s5.8-58.8 17.4-86c11.1-26.3 27.1-49.9 47.3-70.2 20.3-20.3 43.9-36.2 70.2-47.3 27.2-11.5 56.2-17.4 86-17.4s58.8 5.8 86 17.4c26.3 11.1 49.9 27.1 70.2 47.3 20.3 20.3 36.2 43.9 47.3 70.2 11.5 27.2 17.4 56.2 17.4 86s-5.8 58.8-17.4 86c-11 26.3-27 49.9-47.3 70.2m-95.9-266.1c-15 4.8-32.2 11.8-46.3 21.6-.2.2-.4.3-.6.4-.3.2-.7.4-2 1.4l-1.2.8c-6.7 4.1-9.8 10.2-9.4 14.2v1.1c0 1.2-.1 3-.1 5.3-.1 4.5-.2 11-.3 19-.2 9.9-.4 22-.5 35.3-.1-11.3-.2-21.6-.3-30.2-.1-7.7-.2-14.1-.2-18.5 0-2.2-.1-4-.1-5.2v-1.8c0-.2 0-.5-.1-.9-.1-.5-.2-.9-.3-1.4-1.3-5-5.6-15.9-13.5-19.4-.1-.1-.2-.2-.4-.3-14.1-9.8-31.3-16.8-46.4-21.6 18.5-10.3 39.3-15.6 60.9-15.6 21.5.2 42.3 5.5 60.8 15.8M449.2 256c0-32-7.7-62.2-21.5-88.8 0-1.9.1-3.3.1-4.1l.1-.1v-1c-.3-24.2-7.2-26.6-15.5-27.1-.7 0-1.4-.1-2-.2h-.4c-1.1.1-2.2.1-3.2.2C371.2 90.5 316.5 62 255.2 62c-61.5 0-116.4 28.7-151.9 73.4-1.3-.1-2.7-.2-4-.2h-.4c-.6.1-1.3.1-2 .2-8.3.5-15.2 2.9-15.5 27.1v1l.1.1c0 1.1.1 3.1.1 6-13 26.1-20.3 55.4-20.3 86.5 0 32.8 8.2 63.7 22.5 90.8.1 8.1.2 15.8.3 22.7 0 2.9 1.5 8.5 7.2 8.9.3.1.8.2 1.6.3 4 .7 8.9 1.7 14.5 2.9C143 423.5 196 450 255.2 450s112.2-26.5 147.8-68.3c5.1-1.1 9.6-2 13.3-2.6.8-.1 1.3-.2 1.6-.3 5.7-.4 7.2-6 7.2-8.9.1-6.3.2-13.3.3-20.6 15.2-27.7 23.8-59.5 23.8-93.3m-383.1 0c0-26.8 5.6-52.2 15.7-75.3v.5q-.45 4.2-.6 9.6v.5c.3 6.4 1.5 84 2.4 144-11.2-24.1-17.5-51-17.5-79.3m185.7 172.9v-9c0-3.6 0-8.2-.1-13.7.3-1.5.4-3.4.1-5.6v-9c0-5.6-.1-13.6-.1-23.9-.1-19.1-.3-44.7-.6-72.3-.2-27.5-.5-53.2-.7-72.3-.1-10.3-.2-18.3-.3-23.9 0-2.9-.1-5.2-.1-6.7 0-.8 0-1.4-.1-1.9v-.5c0-.1 0-.2-.1-.4 0-.2-.1-.4-.1-.5-.8-3.3-4.5-14.1-11.4-16.9-.2-.1-.5-.3-.8-.5-16.2-11.3-54.1-30.4-128.2-35.9C144 93.8 196.5 67 255.2 67c58.4 0 110.7 26.6 145.4 68.4-74.6 5.4-112.6 24.7-128.9 36.1-.3.2-.6.4-.8.5-6.9 2.8-10.6 13.6-11.4 16.9-.1.1-.1.3-.1.5s-.1.3-.1.4v.4c0 .5 0 1.1-.1 2 0 1.6-.1 3.8-.1 6.7-.1 5.6-.2 13.6-.3 23.9-.2 19.1-.5 44.9-.7 72.5s-.5 53.3-.6 72.4c-.1 10.3-.1 18.3-.1 23.9v9c-.3 2-.2 3.7 0 5.2 0 5.8-.1 10.7-.1 14.4v9c-1 6.9 1.6 9.8 4 10.9 1 .5 2.3.8 3.4.8.8 0 1.4-.1 1.8-.4 42-32.5 94.6-49.1 128.2-57-34.6 37.8-84.3 61.6-139.5 61.6s-104.9-23.7-139.5-61.5c33.6 8 85.4 24.6 126.9 56.6.4.3 1 .4 1.8.4 1.1 0 2.3-.3 3.4-.8 2.3-1.2 4.9-4 4-10.9m176.1-237.7v-.6c0-3.4-.2-6.4-.5-9 0-1.2 0-2.3.1-3.4 10.8 23.8 16.8 50.1 16.8 77.9 0 29.4-6.7 57.3-18.8 82.1.9-60.6 2.2-140.6 2.4-147M410 139.7s.1 0 0 0c.7.1 1.4.1 2.1.2 5.4.3 10.5.7 10.8 22.1 0 .5-.1 1.1-.1 2 0 1-.1 2.4-.1 4.1-2.8-3-6.2-3.2-9.2-3.4-.7 0-1.4-.1-2.1-.2h-.2c-81.7 4.4-122.7 24.7-139.8 36.4-.4.3-.8.5-.9.6-2.2 1.9-4.4 4.7-6.3 7.5.1-11.3.3-18.6.3-19.5.7-2.6 4.1-11.5 8.5-13.1l.2-.1c.3-.2.7-.4 1.4-.9 16.5-11.4 56.1-31.3 135.4-35.7m-312.8.6c.7 0 1.4-.1 2.1-.2 79.4 4.4 118.9 24.2 135.4 35.7.7.5 1.1.7 1.4.9l.2.1c4.6 1.7 8.1 11.4 8.5 13.4l.1 1.3c.1 2.5.1 8.1.2 16.1-1.7-2.6-3.9-4.9-6.4-5.9-.2-.1-.5-.4-.9-.6-17.1-11.7-58.1-31.9-139.8-36.3h-.2c-.7.1-1.4.1-2.1.2-3 .2-6.4.4-9.2 3.3 0-1.6-.1-2.9-.1-3.8s0-1.6-.1-2c.4-21.5 5.5-21.8 10.9-22.2" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
1
code/frontend/public/icons/ext/readarr.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" id="readarr_svg__Layer_1" x="0" y="0" version="1.1" viewBox="0 0 512 512"><style>.readarr_svg__st0{fill:#eee}</style><circle id="readarr_svg__svg_2_00000098218664590018320060000008938703571635696276_" cx="256" cy="256" r="255.6" class="readarr_svg__st0"/><path d="M256 512c-34.6 0-68.1-6.8-99.6-20.1C125.9 479 98.5 460.5 75 437s-42-50.9-54.9-81.4C6.8 324.1 0 290.6 0 256s6.8-68.1 20.1-99.6C33 125.9 51.5 98.5 75 75s50.9-42 81.4-54.9C187.9 6.8 221.4 0 256 0s68.1 6.8 99.6 20.1C386.1 33 413.5 51.5 437 75s42 50.9 54.9 81.4C505.2 188 512 221.5 512 256s-6.8 68.1-20.1 99.6C479 386.1 460.5 413.5 437 437s-50.9 42-81.4 54.9c-31.5 13.3-65 20.1-99.6 20.1M256 .8C115.3.8.8 115.3.8 256S115.3 511.2 256 511.2 511.2 396.7 511.2 256 396.7.8 256 .8M459.6 170c-11.1-26.3-27.1-49.9-47.3-70.2s-44-36.2-70.3-47.4C314.8 40.9 285.8 35 256 35s-58.8 5.8-86 17.4c-26.3 11.1-49.9 27.1-70.2 47.3s-36.2 44-47.4 70.3C40.9 197.2 35 226.2 35 256s5.8 58.8 17.4 86c11.1 26.3 27.1 49.9 47.3 70.2s43.9 36.2 70.2 47.3c27.2 11.5 56.2 17.4 86 17.4s58.8-5.8 86-17.4c26.3-11.1 49.9-27.1 70.2-47.3s36.2-43.9 47.3-70.2c11.5-27.2 17.4-56.2 17.4-86 .1-29.8-5.7-58.8-17.2-86" style="fill:#443c3c"/><path d="M425.9 172.1c.1-6.3.2-9.9.3-10-.3-26.7-8.8-24-15.3-24.8-2.4.1-4.7.3-6.9.4-34.8-43.4-88.1-71.2-148-71.2s-113.4 27.8-148.1 71.3c-2.6-.2-5.3-.4-8.1-.5-6.5.8-14.9-1.9-15.3 24.8.1.1.2 4.7.3 12.5C73 199.3 66.4 226.9 66.4 256c0 31.5 7.7 61.2 21.3 87.3.3 1.2.9 2.6 1.9 3.6 32.2 58.7 94.6 98.6 166.3 98.6 104.6 0 189.5-84.8 189.5-189.5.1-30.1-7-58.6-19.5-83.9M255.3 389.9c-1.8 0-3.6 0-5.4-.1-.2-42.5-1.6-199.7-1.9-200 .3.3-3.5-13.1-10.2-15.6-1.5-.8-18.6-14.9-60.3-25.6 22.5-16.3 49.5-25.1 77.8-25.1s55.3 8.8 77.8 25c-41.6 10.7-58.7 24.9-60.2 25.6l-2.8 2c-5.9 3.5-6.6 8.1-6.2 7.8-.3.3-2.6 161.7-3 205.8-1.9.1-3.7.2-5.6.2" class="readarr_svg__st0"/><path d="M450 256c0-32-7.7-62.2-21.5-88.8 0-1.9.1-3.3.1-4.1l.1-.1v-1c-.3-24.2-7.2-26.6-15.5-27.1-.7 0-1.4-.1-2-.2h-.4c-1.1.1-2.2.1-3.2.2C372 90.5 317.3 62 256 62c-61.5 0-116.4 28.7-151.9 73.4-1.3-.1-2.7-.2-4-.2h-.4c-.6.1-1.3.1-2 .2-8.3.5-15.2 2.9-15.5 27.1v1l.1.1c0 1.1.1 3.1.1 6C69.4 195.5 62 224.9 62 256c0 32.8 8.2 63.7 22.5 90.8.1 8.1.2 15.8.3 22.7 0 2.9 1.5 8.5 7.2 8.9.3.1.8.2 1.6.3 4 .7 8.9 1.7 14.5 2.9C143.8 423.5 196.8 450 256 450s112.2-26.5 147.8-68.3c5.1-1.1 9.6-2 13.3-2.6.8-.1 1.3-.2 1.6-.3 5.7-.4 7.2-6 7.2-8.9.1-6.3.2-13.3.3-20.6 15.1-27.7 23.8-59.5 23.8-93.3m-383.1 0c0-26.8 5.6-52.2 15.7-75.3v.5q-.45 4.2-.6 9.6v.5c.3 6.4 1.5 84 2.4 144-11.2-24.1-17.5-51-17.5-79.3m185.6 172.9v-9c0-3.6 0-8.2-.1-13.7.3-1.5.4-3.4.1-5.6v-9c0-5.6-.1-13.6-.1-23.9-.1-19.1-.3-44.7-.6-72.3-.2-27.5-.5-53.2-.7-72.3-.1-10.3-.2-18.3-.3-23.9 0-2.9-.1-5.2-.1-6.7 0-.8 0-1.4-.1-1.9v-.5c0-.1 0-.2-.1-.4 0-.2-.1-.4-.1-.5-.8-3.3-4.5-14.1-11.4-16.9-.2-.1-.5-.3-.8-.5-16.2-11.3-54.1-30.4-128.2-35.9C144.7 93.8 197.2 67 255.9 67c58.4 0 110.7 26.6 145.4 68.4-74.6 5.4-112.6 24.7-128.9 36.1-.3.2-.6.4-.8.5-6.9 2.8-10.6 13.6-11.4 16.9-.1.1-.1.3-.1.5s-.1.3-.1.4v.4c0 .5 0 1.1-.1 2 0 1.6-.1 3.8-.1 6.7-.1 5.6-.2 13.6-.3 23.9-.2 19.1-.5 44.9-.7 72.5s-.5 53.3-.6 72.4c-.1 10.3-.1 18.3-.1 23.9v9c-.3 2-.2 3.7 0 5.2 0 5.8-.1 10.7-.1 14.4v9c-1 6.9 1.6 9.8 4 10.9 1 .5 2.3.8 3.4.8.8 0 1.4-.1 1.8-.4 42-32.5 94.6-49.1 128.2-57-34.6 37.8-84.3 61.6-139.5 61.6S151 421.4 116.4 383.6c33.6 8 85.4 24.6 126.9 56.6.4.3 1 .4 1.8.4 1.1 0 2.3-.3 3.4-.8 2.4-1.2 5-4 4-10.9m176.2-237.7v-.6c0-3.4-.2-6.4-.5-9 0-1.2 0-2.3.1-3.4 10.8 23.8 16.8 50.1 16.8 77.9 0 29.4-6.7 57.3-18.8 82.1.9-60.6 2.1-140.6 2.4-147m-17.9-51.5c.7.1 1.4.1 2.1.2 5.4.3 10.5.7 10.8 22.1 0 .5-.1 1.1-.1 2 0 1-.1 2.4-.1 4.1-2.8-3-6.2-3.2-9.2-3.4-.7 0-1.4-.1-2.1-.2h-.2c-81.7 4.4-122.7 24.7-139.8 36.4-.4.3-.8.5-.9.6-2.2 1.9-4.4 4.7-6.3 7.5.1-11.3.3-18.6.3-19.5.7-2.6 4.1-11.5 8.5-13.1l.2-.1c.3-.2.7-.4 1.4-.9 16.5-11.4 56.1-31.3 135.4-35.7m-312.8.6c.7 0 1.4-.1 2.1-.2 79.4 4.4 118.9 24.2 135.4 35.7.7.5 1.1.7 1.4.9l.2.1c4.6 1.7 8.1 11.4 8.5 13.4l.1 1.3c.1 2.5.1 8.1.2 16.1-1.7-2.6-3.9-4.9-6.4-5.9-.2-.1-.5-.4-.9-.6-17.1-11.7-58.1-31.9-139.8-36.3h-.2c-.7.1-1.4.1-2.1.2-3 .2-6.4.4-9.2 3.3 0-1.6-.1-2.9-.1-3.8s0-1.6-.1-2c.3-21.5 5.4-21.8 10.9-22.2" style="fill:#8e2222"/></svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
2
code/frontend/public/icons/ext/sonarr-light.svg
Normal file
@@ -0,0 +1,2 @@
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="M144.2 103.3c30.7 30.7 70 38.6 112.4 38.6 43.6 0 82.8-8.4 114.7-40.4 14.7-14.7 44.4-44.3 45.5-45.4C371.1 18.7 317.6 0 256.2 0c-60.8 0-114 18.5-159.8 55.5zM373 258.4c0 42.3 6.7 81.2 38.2 112.7 22.9 22.9 44.7 44.5 45 44.8 37-45.8 55.6-99.1 55.6-159.9 0-58.9-17.4-110.8-52.3-155.9L406.6 153c-30.9 31-33.6 57.9-33.6 105.4m-271.1 113c32.7-32.7 38-70.6 38-113.1 0-41.3-6.8-79.9-36.8-110-20.1-20-47.6-47.2-49.7-49.4-31.8 40.3-49.2 86.4-52.3 138.3-.3.6-.5 1.1-.5 1.7C.3 244.3.2 250 .2 256c0 5.7.2 11.3.4 17 .5 10.2 1.7 20.3 3.4 30.2 7.3 42.1 24.8 80 52.7 113.6.1-.2 23.2-23.4 45.2-45.4m269.6 46c-36.8-36.8-66.1-40.4-114.7-40.4-46.7 0-78.4 4.3-112.6 38.5-20.2 20.3-43.4 43.6-43.8 43.9 2.2 1.7 4.4 3.3 6.6 4.9 43 31.8 92.7 47.7 149.3 47.7q84.75 0 149.4-47.7c2.5-1.7 4.9-3.5 7.3-5.4zM186 269.1c-.5-2.8-.8-5.5-.9-8.4-.1-1.6-.1-3.1-.1-4.7 0-1.7 0-3.2.1-4.7 0-.2 0-.3.1-.5 1-17.4 7.9-32.4 20.5-45.1 13.9-13.8 30.6-20.7 50.2-20.7s36.3 6.9 50.2 20.7c13.8 14 20.7 30.8 20.7 50.3s-6.9 36.2-20.7 50.2c-.5.5-1 1.1-1.5 1.5q-3.45 3.3-7.2 6-18 13.2-41.4 13.2c-23.4 0-29.4-4.4-41.3-13.2-3.1-2.2-6.1-4.7-8.9-7.6-10.8-10.6-17.3-22.9-19.8-37" style="fill-rule:evenodd;clip-rule:evenodd;fill:#fff"/><path d="m375.2 143.5-1.6-1.6v-.1L440 77.2l-1.4-1.4-66.4 64.6.7.7-.7-.7h-.1l-1.9-1.9-40 40.6 5 5zm-238.3 2.1 40.6 40.5 5-5-40.6-40.5-1.7 1.7-66.4-66.1-1.4 1.4 66.4 66.1zm234.9 223.9-42.6-42.4-5 5 42.6 42.4 1.8-1.8 65.6 67.8 1.4-1.4-65.5-67.9zm-233.3 2.1 1.9 1.9-64.3 64.4 1.4 1.4 64.4-64.5 1.6 1.6 39.5-41.1-5-4.8z" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
1
code/frontend/public/icons/ext/sonarr.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="M511.8 256c0 70.4-24.9 130.8-74.6 181.1-1.7 2-3.5 3.8-5.5 5.4-8.2 8-16.8 15.3-26 21.8Q341.05 512 256.3 512c-56.6 0-106.3-15.9-149.2-47.7-11.3-8-22-17.1-31.9-27.3C36.5 398.7 12.8 354 4 303.2c-1.7-9.9-2.9-20-3.4-30.2-.2-5.7-.4-11.3-.4-17 0-6 .1-11.7.4-17.1 0-.6.2-1.1.5-1.7 3.7-62.8 28.4-117 74.1-162.8C125.5 24.8 185.8 0 256.2 0c70.7 0 131 24.8 180.9 74.5q74.7 75.9 74.7 181.5" style="fill-rule:evenodd;clip-rule:evenodd;fill:#eee"/><path d="m459.7 100.3-52.9 52.9c-30.9 30.9-33.6 57.8-33.6 105.3 0 42.3 6.7 81.1 38.2 112.6 23 23 44.9 44.7 44.9 44.7-5.9 7.2-12.3 14.3-19.1 21.2-1.7 2-3.5 3.8-5.5 5.4-6 5.9-12.2 11.4-18.6 16.4l-41.4-41.4C334.9 380.6 305.6 377 257 377c-46.7 0-78.4 4.3-112.6 38.5-20.4 20.4-43.8 43.9-43.8 43.9-8.9-6.8-17.3-14.2-25.3-22.4-6.6-6.6-12.8-13.4-18.5-20.3 0 0 23.1-23.2 45.2-45.3 32.7-32.7 38-70.6 38-113 0-41.3-6.8-79.8-36.8-109.9C82.2 127.7 53.3 99 53.3 99c6.7-8.5 14-16.7 21.8-24.5 6.9-6.8 14-13.1 21.2-19l48 48c30.7 30.7 70 38.6 112.4 38.6 43.6 0 82.8-8.4 114.7-40.4C391 82.1 417 56.3 417 56.3c6.8 5.6 13.5 11.6 20.1 18.2 8.3 8.3 15.8 16.9 22.6 25.8" style="fill-rule:evenodd;clip-rule:evenodd;fill:#3a3f51"/><path d="M186 269.1c-.5-2.8-.8-5.5-.9-8.4-.1-1.6-.1-3.1-.1-4.7 0-1.7 0-3.2.1-4.7 0-.2 0-.3.1-.5 1-17.4 7.9-32.4 20.5-45.1 13.9-13.8 30.6-20.7 50.2-20.7s36.3 6.9 50.2 20.7c13.8 14 20.7 30.8 20.7 50.3s-6.9 36.2-20.7 50.2c-.5.5-1 1.1-1.5 1.5q-3.45 3.3-7.2 6-18 13.2-41.4 13.2c-23.4 0-29.4-4.4-41.3-13.2-3.1-2.2-6.1-4.7-8.9-7.6-10.8-10.6-17.3-22.9-19.8-37m189.2-125.6-1.8-1.8 66.4-64.6-1.4-1.4-66.4 64.6.7.7-.7-.7-1.8-1.8-40 40.6 5 5zm-238.3 2.1 40.6 40.5 5-5-40.6-40.5-1.8 1.8-66.5-66.3-1.4 1.4 66.5 66.3zm234.9 223.9-42.6-42.4-5 5 42.6 42.4 1.8-1.8 65.4 67.7 1.4-1.4-65.4-67.7zm-233.3 2.1 1.8 1.7-64.4 64.5 1.4 1.4 64.4-64.5 1.8 1.7 39.5-41.1-5-4.8z" style="fill-rule:evenodd;clip-rule:evenodd;fill:#0cf"/></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
2
code/frontend/public/icons/ext/whisparr-light.svg
Normal file
@@ -0,0 +1,2 @@
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="M256 512c-34.6 0-68.1-6.8-99.7-20.1C125.9 479 98.5 460.5 75 437s-42-50.9-54.9-81.4C6.8 324.1 0 290.6 0 256s6.8-68.1 20.1-99.7C33 125.9 51.5 98.5 75 75s50.9-42 81.4-54.9C187.9 6.8 221.4 0 256 0s68.1 6.8 99.7 20.1C386.1 33 413.5 51.5 437 75s42 50.9 54.9 81.4c13.4 31.6 20.1 65.1 20.1 99.7s-6.8 68.1-20.1 99.7C479 386.1 460.5 413.5 437 437s-50.9 42-81.4 54.9c-31.5 13.3-65 20.1-99.6 20.1m0-486.2C129.1 25.8 25.8 129.1 25.8 256S129.1 486.2 256 486.2 486.2 382.9 486.2 256 382.9 25.8 256 25.8M59.4 413.3c-4.4 0-5.9-3.2-4.4-9.5 1.4-6.1 5.5-13.8 12.1-23.1l98-114.8q45.15-52.95 69-86.7c23.85-33.75 24.9-38.6 27.1-47.9 1-4.4.4-7.7-2.1-9.9-2.4-2.2-5.5-3.7-9.2-4.5s-7.3-1.2-10.7-1.2c-20.8 0-53.2 14.3-97.2 43-12.4 8-24.2 16.9-35.4 26.9q-16.8 14.85-32.1 31.8c-20 22.6-31.9 41.6-35.5 57-2.8 11.8.4 19 9.6 21.5 3.4 1.1 7.4 1.7 12.1 1.7 8.8 0 18.2-2.5 28.2-7.4 10-5 20-11.3 30.2-19 7.8-6.1 12.2-9.1 13.2-9.1s1.1 1 .4 2.9c-2.8 5.8-7.7 11.3-14.6 16.5s-14.7 10-23.6 14.3c-8.8 4.3-17.5 7.6-26 9.9s-15.8 3.5-21.9 3.5c-9.8 0-17.7-2.8-23.7-8.5-6-5.6-7.7-14-5.1-25 4-16.8 17.6-37.5 40.7-62 22.2-23.7 47.8-44.8 76.6-63.2 11.9-7.7 25.3-15.1 40.5-22.1 15.1-7 30.1-12.8 44.8-17.3 14.8-4.5 27.6-6.8 38.3-6.8 19.3 0 27.3 7.2 23.9 21.5-4.1 17.4-22.8 47.2-56.1 89.6-23.3 29.2-53.2 63.9-89.6 104.1-30.2 33.3-45.4 50-45.6 50 .7 0 15.8-14.2 45.3-42.5 18.1-17.6 35-33.4 50.7-47.3 15.6-13.9 30-26.1 43-36.6 6.4-5.2 14.2-11.8 23.4-19.6s19.9-17 32.2-27.5c10.6-8.8 20.9-16.8 30.8-24s18.3-10.7 25.1-10.7c7.1 0 11.3 1.9 12.6 5.8-29.9 31.4-60.1 69.8-90.4 115.3-21.6 32.5-34.4 57.1-38.4 73.9-2.3 9.9-1.7 17.1 1.9 21.5q5.4 6.6 16.8 6.6c8.3 0 17.7-2.6 28.1-7.8s21.1-12 32.1-20.2c11-8.3 21.5-16.9 31.5-26 35.7-31.9 66.3-66.5 91.7-103.7 19.9-29.2 31.9-52.5 36-69.8q.6-2.55.9-4.5c.2-1.4.3-2.6.3-3.7-5.1 0-9.9-3-14.4-9.1-2.9-3.3-3.9-6.7-3.1-10.3.8-3.3 2.7-6.1 5.6-8.3 3-2.2 6-3.3 9.2-3.3 4.6 0 7.5 2.3 8.6 7 .5 2.2 1.8 5 3.9 8.3 1.4 2.2 1.9 3.9 1.4 5 .9 2.5 3 3.7 6.5 3.7 3.4 0 7.1-1.7 10.9-5s7.6-5 11.3-5c2 0 2.7.8 2.3 2.5q-1.05 4.5-9 8.7c-2.9 1.9-6.1 3.2-9.7 3.7q-3 .45-6 .6c-3 .15-3.9-.1-5.7-.6.3 11-.1 19.1-1.4 24.4-5.2 22-21.3 50.5-48.1 85.5-13.5 17.6-28.1 34.3-43.8 50s-32.6 30.4-50.8 44.2c-36.3 28.1-65.9 42.1-88.9 42.1-25.2 0-34.7-13.2-28.4-39.7 2.9-12.4 8.6-26.5 17-42.3s17.6-31.6 27.7-47.3l41.8-64.9c-13 9.4-23.5 17-31.7 22.9-8.1 5.9-14.9 10.9-20.5 15.1-5.5 4.1-10.8 8.3-15.8 12.6s-11.1 9.4-18.2 15.5c-7.7 6.9-18.3 16.7-31.6 29.5s-29.1 28.7-47.3 47.7l-56.5 58.7c-3.2 3.3-6.8 6.3-10.6 9.1-3.6 2.2-7.1 3.6-10.2 3.6" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
1
code/frontend/public/icons/ext/whisparr.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><circle cx="256" cy="256" r="243.1" style="fill:#ff69b4"/><path d="M491.9 156.4c-1.4-3.3-2.8-6.5-4.4-9.7.3 0 .6-.1.9-.1 3.6-.5 6.8-1.8 9.7-3.7q7.95-4.2 9-8.7c.4-1.7-.3-2.5-2.3-2.5-3.7 0-7.5 1.7-11.3 5-2.9 2.5-5.8 4.1-8.5 4.7-12.3-24.5-28.4-46.8-48-66.4-23.5-23.5-50.9-42-81.3-54.9C324.1 6.8 290.6 0 256 0s-68.1 6.8-99.6 20.1C125.9 33 98.5 51.5 75 75s-42 50.9-54.9 81.3C6.8 187.9 0 221.4 0 256s6.8 68.1 20.1 99.6C33 386.1 51.5 413.5 75 437s50.9 42 81.3 54.9c31.6 13.3 65.1 20.1 99.7 20.1s68.1-6.8 99.6-20.1c30.5-12.9 57.9-31.4 81.4-54.9s42-50.9 54.9-81.2c13.3-31.6 20.1-65.1 20.1-99.7s-6.7-68.1-20.1-99.7M256 25.8c79.8 0 150.3 40.9 191.6 102.8-.1.2-.1.5-.2.7-.8 3.6.2 7 3.1 10.3 2.7 3.6 5.4 6.1 8.3 7.6 1.7 3.2 3.4 6.4 4.9 9.7v.1c-4.1 17.3-16.1 40.6-36 69.8-25.4 37.2-56 71.8-91.7 103.7-10 9.1-20.5 17.7-31.5 26-11 8.2-21.7 15-32.1 20.2s-19.8 7.8-28.1 7.8q-11.4 0-16.8-6.6c-3.6-4.4-4.2-11.6-1.9-21.5 4-16.8 16.8-41.4 38.4-73.9 30.3-45.5 60.5-83.9 90.4-115.3-1.3-3.9-5.5-5.8-12.6-5.8-6.8 0-15.2 3.5-25.1 10.7s-20.2 15.2-30.8 24c-12.3 10.5-23 19.7-32.2 27.5s-17 14.4-23.4 19.6c-13 10.5-27.4 22.7-43 36.6-15.7 13.9-32.6 29.7-50.7 47.3-29.5 28.3-44.6 42.5-45.3 42.5.2 0 15.4-16.7 45.6-50 36.4-40.2 66.3-74.9 89.6-104.1 33.3-42.4 52-72.2 56.1-89.6 3.4-14.3-4.6-21.5-23.9-21.5-10.7 0-23.5 2.3-38.3 6.8-14.7 4.5-29.7 10.3-44.8 17.3-15.2 7-28.6 14.4-40.5 22.1-28.8 18.4-54.4 39.5-76.6 63.2-14.8 15.7-25.7 29.9-32.7 42.5v-.2c0-127 103.3-230.3 230.2-230.3M64.8 384c-15.6-23.3-27.1-49.5-33.4-77.7 4.4 1.9 9.5 2.9 15.2 2.9 6.1 0 13.4-1.2 21.9-3.5s17.2-5.6 26-9.9c8.9-4.3 16.7-9.1 23.6-14.3s11.8-10.7 14.6-16.5c.7-1.9.6-2.9-.4-2.9s-5.4 3-13.2 9.1c-10.2 7.7-20.2 14-30.2 19-10 4.9-19.4 7.4-28.2 7.4-4.7 0-8.7-.6-12.1-1.7-9.2-2.5-12.4-9.7-9.6-21.5 3.6-15.4 15.5-34.4 35.5-57q15.3-16.95 32.1-31.8c11.2-10 23-18.9 35.4-26.9 44-28.7 76.4-43 97.2-43 3.4 0 7 .4 10.7 1.2s6.8 2.3 9.2 4.5c2.5 2.2 3.1 5.5 2.1 9.9-2.2 9.3-11.2 25.4-27.1 47.9q-23.85 33.75-69 86.7l-98 114.8c-.8 1.1-1.6 2.2-2.3 3.3M256 486.2c-71.4 0-135.3-32.7-177.6-83.9.6-.6 1.2-1.1 1.8-1.7l56.5-58.7c18.2-19 34-34.9 47.3-47.7s23.9-22.6 31.6-29.5c7.1-6.1 13.2-11.2 18.2-15.5s10.3-8.5 15.8-12.6c5.6-4.2 12.4-9.2 20.5-15.1 8.2-5.9 18.7-13.5 31.7-22.9L260 263.5c-10.1 15.7-19.3 31.5-27.7 47.3s-14.1 29.9-17 42.3c-6.3 26.5 3.2 39.7 28.4 39.7 23 0 52.6-14 88.9-42.1 18.2-13.8 35.1-28.5 50.8-44.2s30.3-32.4 43.8-50c23.6-30.8 38.9-56.6 45.8-77.4 8.6 24.1 13.2 49.9 13.2 76.9 0 126.9-103.3 230.2-230.2 230.2" style="fill:#333"/></svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -27,6 +27,11 @@ export const routes: Routes = [
|
||||
loadComponent: () => import('./settings/download-cleaner/download-cleaner-settings.component').then(m => m.DownloadCleanerSettingsComponent),
|
||||
canDeactivate: [pendingChangesGuard]
|
||||
},
|
||||
{
|
||||
path: 'blacklist-synchronizer',
|
||||
loadComponent: () => import('./settings/blacklist-sync/blacklist-sync-settings.component').then(m => m.BlacklistSyncSettingsComponent),
|
||||
canDeactivate: [pendingChangesGuard]
|
||||
},
|
||||
|
||||
{ path: 'sonarr', loadComponent: () => import('./settings/sonarr/sonarr-settings.component').then(m => m.SonarrSettingsComponent) },
|
||||
{ path: 'radarr', loadComponent: () => import('./settings/radarr/radarr-settings.component').then(m => m.RadarrSettingsComponent) },
|
||||
|
||||
@@ -13,6 +13,7 @@ import { ArrInstance, CreateArrInstanceDto } from "../../shared/models/arr-confi
|
||||
import { GeneralConfig } from "../../shared/models/general-config.model";
|
||||
import { ApplicationPathService } from "./base-path.service";
|
||||
import { ErrorHandlerUtil } from "../utils/error-handler.util";
|
||||
import { BlacklistSyncConfig } from "../../shared/models/blacklist-sync-config.model";
|
||||
|
||||
@Injectable({
|
||||
providedIn: "root",
|
||||
@@ -33,6 +34,31 @@ export class ConfigurationService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Blacklist Sync configuration
|
||||
*/
|
||||
getBlacklistSyncConfig(): Observable<BlacklistSyncConfig> {
|
||||
return this.http.get<BlacklistSyncConfig>(this.ApplicationPathService.buildApiUrl('/configuration/blacklist_sync')).pipe(
|
||||
catchError((error) => {
|
||||
console.error("Error fetching Blacklist Sync config:", error);
|
||||
return throwError(() => new Error("Failed to load Blacklist Sync configuration"));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Blacklist Sync configuration
|
||||
*/
|
||||
updateBlacklistSyncConfig(config: BlacklistSyncConfig): Observable<any> {
|
||||
return this.http.put<any>(this.ApplicationPathService.buildApiUrl('/configuration/blacklist_sync'), config).pipe(
|
||||
catchError((error) => {
|
||||
console.error("Error updating Blacklist Sync config:", error);
|
||||
const errorMessage = ErrorHandlerUtil.extractErrorMessage(error);
|
||||
return throwError(() => new Error(errorMessage));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update general configuration
|
||||
*/
|
||||
|
||||
@@ -16,6 +16,7 @@ export class DocumentationService {
|
||||
private readonly fieldMappings: FieldDocumentationMapping = {
|
||||
'queue-cleaner': {
|
||||
'enabled': 'enable-queue-cleaner',
|
||||
'ignoredDownloads': 'ignored-downloads',
|
||||
'useAdvancedScheduling': 'scheduling-mode',
|
||||
'cronExpression': 'cron-expression',
|
||||
'failedImport.maxStrikes': 'failed-import-max-strikes',
|
||||
@@ -43,6 +44,8 @@ export class DocumentationService {
|
||||
'httpCertificateValidation': 'http-certificate-validation',
|
||||
'searchEnabled': 'search-enabled',
|
||||
'searchDelay': 'search-delay',
|
||||
'enableBlacklistSync': 'enable-blacklist-sync',
|
||||
'blacklistPath': 'blacklist-path',
|
||||
'log.level': 'log-level',
|
||||
'log.rollingSizeMB': 'log-rolling-size-mb',
|
||||
'log.retainedFileCount': 'log-retained-file-count',
|
||||
@@ -54,6 +57,7 @@ export class DocumentationService {
|
||||
},
|
||||
'download-cleaner': {
|
||||
'enabled': 'enable-download-cleaner',
|
||||
'ignoredDownloads': 'ignored-downloads',
|
||||
'useAdvancedScheduling': 'scheduling-mode',
|
||||
'cronExpression': 'cron-expression',
|
||||
'jobSchedule.every': 'run-schedule',
|
||||
@@ -71,6 +75,7 @@ export class DocumentationService {
|
||||
},
|
||||
'malware-blocker': {
|
||||
'enabled': 'enable-malware-blocker',
|
||||
'ignoredDownloads': 'ignored-downloads',
|
||||
'useAdvancedScheduling': 'scheduling-mode',
|
||||
'cronExpression': 'cron-expression',
|
||||
'jobSchedule.every': 'run-schedule',
|
||||
@@ -100,11 +105,28 @@ export class DocumentationService {
|
||||
'notifications': {
|
||||
'enabled': 'enabled',
|
||||
'name': 'provider-name',
|
||||
'eventTriggers': 'event-triggers'
|
||||
},
|
||||
'notifications/notifiarr': {
|
||||
'notifiarr.apiKey': 'notifiarr-api-key',
|
||||
'notifiarr.channelId': 'notifiarr-channel-id',
|
||||
'notifiarr.channelId': 'notifiarr-channel-id'
|
||||
},
|
||||
'notifications/apprise': {
|
||||
'apprise.url': 'apprise-url',
|
||||
'apprise.key': 'apprise-key',
|
||||
'apprise.tags': 'apprise-tags',
|
||||
'apprise.tags': 'apprise-tags'
|
||||
},
|
||||
'notifications/ntfy': {
|
||||
'ntfy.serverUrl': 'ntfy-server-url',
|
||||
'ntfy.topics': 'ntfy-topics',
|
||||
'ntfy.authenticationType': 'ntfy-authentication-type',
|
||||
'ntfy.username': 'ntfy-username',
|
||||
'ntfy.password': 'ntfy-password',
|
||||
'ntfy.accessToken': 'ntfy-access-token',
|
||||
'ntfy.priority': 'ntfy-priority',
|
||||
'ntfy.tags': 'ntfy-tags'
|
||||
},
|
||||
'notifications/events': {
|
||||
'eventTriggers': 'event-triggers'
|
||||
}
|
||||
};
|
||||
@@ -159,4 +181,4 @@ export class DocumentationService {
|
||||
hasFieldDocumentation(section: string, fieldName: string): boolean {
|
||||
return !!this.getFieldAnchor(section, fieldName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
TestNotificationResult
|
||||
} from '../../shared/models/notification-provider.model';
|
||||
import { NotificationProviderType } from '../../shared/models/enums';
|
||||
import { NtfyAuthenticationType } from '../../shared/models/ntfy-authentication-type.enum';
|
||||
import { NtfyPriority } from '../../shared/models/ntfy-priority.enum';
|
||||
|
||||
// Provider-specific interfaces
|
||||
export interface CreateNotifiarrProviderDto {
|
||||
@@ -75,6 +77,55 @@ export interface TestAppriseProviderDto {
|
||||
tags: string;
|
||||
}
|
||||
|
||||
export interface CreateNtfyProviderDto {
|
||||
name: string;
|
||||
isEnabled: boolean;
|
||||
onFailedImportStrike: boolean;
|
||||
onStalledStrike: boolean;
|
||||
onSlowStrike: boolean;
|
||||
onQueueItemDeleted: boolean;
|
||||
onDownloadCleaned: boolean;
|
||||
onCategoryChanged: boolean;
|
||||
serverUrl: string;
|
||||
topics: string[];
|
||||
authenticationType: NtfyAuthenticationType;
|
||||
username: string;
|
||||
password: string;
|
||||
accessToken: string;
|
||||
priority: NtfyPriority;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface UpdateNtfyProviderDto {
|
||||
name: string;
|
||||
isEnabled: boolean;
|
||||
onFailedImportStrike: boolean;
|
||||
onStalledStrike: boolean;
|
||||
onSlowStrike: boolean;
|
||||
onQueueItemDeleted: boolean;
|
||||
onDownloadCleaned: boolean;
|
||||
onCategoryChanged: boolean;
|
||||
serverUrl: string;
|
||||
topics: string[];
|
||||
authenticationType: NtfyAuthenticationType;
|
||||
username: string;
|
||||
password: string;
|
||||
accessToken: string;
|
||||
priority: NtfyPriority;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface TestNtfyProviderDto {
|
||||
serverUrl: string;
|
||||
topics: string[];
|
||||
authenticationType: NtfyAuthenticationType;
|
||||
username: string;
|
||||
password: string;
|
||||
accessToken: string;
|
||||
priority: NtfyPriority;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
@@ -104,6 +155,13 @@ export class NotificationProviderService {
|
||||
return this.http.post<NotificationProviderDto>(`${this.baseUrl}/notification_providers/apprise`, provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Ntfy provider
|
||||
*/
|
||||
createNtfyProvider(provider: CreateNtfyProviderDto): Observable<NotificationProviderDto> {
|
||||
return this.http.post<NotificationProviderDto>(`${this.baseUrl}/notification_providers/ntfy`, provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing Notifiarr provider
|
||||
*/
|
||||
@@ -118,6 +176,13 @@ export class NotificationProviderService {
|
||||
return this.http.put<NotificationProviderDto>(`${this.baseUrl}/notification_providers/apprise/${id}`, provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing Ntfy provider
|
||||
*/
|
||||
updateNtfyProvider(id: string, provider: UpdateNtfyProviderDto): Observable<NotificationProviderDto> {
|
||||
return this.http.put<NotificationProviderDto>(`${this.baseUrl}/notification_providers/ntfy/${id}`, provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a notification provider
|
||||
*/
|
||||
@@ -139,6 +204,13 @@ export class NotificationProviderService {
|
||||
return this.http.post<TestNotificationResult>(`${this.baseUrl}/notification_providers/apprise/test`, testRequest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test an Ntfy provider (without ID - for testing configuration before saving)
|
||||
*/
|
||||
testNtfyProvider(testRequest: TestNtfyProviderDto): Observable<TestNotificationResult> {
|
||||
return this.http.post<TestNotificationResult>(`${this.baseUrl}/notification_providers/ntfy/test`, testRequest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic create method that delegates to provider-specific methods
|
||||
*/
|
||||
@@ -148,6 +220,8 @@ export class NotificationProviderService {
|
||||
return this.createNotifiarrProvider(provider as CreateNotifiarrProviderDto);
|
||||
case NotificationProviderType.Apprise:
|
||||
return this.createAppriseProvider(provider as CreateAppriseProviderDto);
|
||||
case NotificationProviderType.Ntfy:
|
||||
return this.createNtfyProvider(provider as CreateNtfyProviderDto);
|
||||
default:
|
||||
throw new Error(`Unsupported provider type: ${type}`);
|
||||
}
|
||||
@@ -162,6 +236,8 @@ export class NotificationProviderService {
|
||||
return this.updateNotifiarrProvider(id, provider as UpdateNotifiarrProviderDto);
|
||||
case NotificationProviderType.Apprise:
|
||||
return this.updateAppriseProvider(id, provider as UpdateAppriseProviderDto);
|
||||
case NotificationProviderType.Ntfy:
|
||||
return this.updateNtfyProvider(id, provider as UpdateNtfyProviderDto);
|
||||
default:
|
||||
throw new Error(`Unsupported provider type: ${type}`);
|
||||
}
|
||||
@@ -176,6 +252,8 @@ export class NotificationProviderService {
|
||||
return this.testNotifiarrProvider(testRequest as TestNotifiarrProviderDto);
|
||||
case NotificationProviderType.Apprise:
|
||||
return this.testAppriseProvider(testRequest as TestAppriseProviderDto);
|
||||
case NotificationProviderType.Ntfy:
|
||||
return this.testNtfyProvider(testRequest as TestNtfyProviderDto);
|
||||
default:
|
||||
throw new Error(`Unsupported provider type: ${type}`);
|
||||
}
|
||||
|
||||
@@ -6,14 +6,11 @@
|
||||
<p-confirmDialog></p-confirmDialog>
|
||||
<!-- Main layout with sidebar and content -->
|
||||
<div class="layout-main">
|
||||
<!-- Desktop Sidebar -->
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-top-line"></div>
|
||||
|
||||
<!-- Shared Sidebar Content -->
|
||||
<app-sidebar-content
|
||||
[menuItems]="menuItems"
|
||||
[isMobile]="false">
|
||||
#sidebarContent
|
||||
[isMobile]="false"
|
||||
[enableMobileDrawer]="true">
|
||||
</app-sidebar-content>
|
||||
</div>
|
||||
|
||||
@@ -25,7 +22,7 @@
|
||||
<div class="topbar-section left">
|
||||
<button pButton class="p-button-text sidebar-toggle-mobile"
|
||||
icon="pi pi-bars"
|
||||
(click)="mobileSidebarVisible.set(true)"></button>
|
||||
(click)="sidebarContent.toggleMobileDrawer()"></button>
|
||||
</div>
|
||||
<!-- <div class="topbar-section center">
|
||||
<div class="version-bar">
|
||||
@@ -64,26 +61,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Sidebar using PrimeNG sidebar component -->
|
||||
<p-drawer
|
||||
styleClass="mobile-sidebar"
|
||||
[visible]="mobileSidebarVisible()"
|
||||
(visibleChange)="mobileSidebarVisible.set($event)"
|
||||
[dismissible]="true"
|
||||
[showCloseIcon]="false"
|
||||
position="left">
|
||||
<ng-template #headless>
|
||||
<div class="mobile-sidebar-content">
|
||||
<div class="sidebar-top-line"></div>
|
||||
|
||||
<!-- Shared Sidebar Content for Mobile -->
|
||||
<app-sidebar-content
|
||||
[menuItems]="menuItems"
|
||||
[isMobile]="true"
|
||||
(navItemClicked)="mobileSidebarVisible.set(false)">
|
||||
</app-sidebar-content>
|
||||
</div>
|
||||
</ng-template>
|
||||
</p-drawer>
|
||||
</div>
|
||||
|
||||
@@ -15,208 +15,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Main Sidebar Styling
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 260px;
|
||||
min-width: 260px;
|
||||
height: 100vh;
|
||||
background-color: var(--surface-overlay);
|
||||
border-right: 1px solid var(--surface-border);
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||
overflow-y: auto;
|
||||
z-index: 10;
|
||||
|
||||
// Top color line
|
||||
.sidebar-top-line {
|
||||
height: 3px;
|
||||
background: var(--primary-color);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
// Hide on mobile screens
|
||||
@media (max-width: 768px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Top color line
|
||||
.sidebar-top-line {
|
||||
height: 3px;
|
||||
background: linear-gradient(to right, #673ab7, #9b59b6);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
// Logo container
|
||||
.logo-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 15px 20px;
|
||||
margin-bottom: 5px;
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
margin-right: 15px;
|
||||
background-color: rgba(142, 68, 173, 0.1);
|
||||
border: 1px solid rgba(142, 68, 173, 0.3);
|
||||
animation: logo-glow 10s infinite ease-in-out;
|
||||
|
||||
i {
|
||||
font-size: 20px;
|
||||
color: #a569bd;
|
||||
}
|
||||
}
|
||||
|
||||
.logo-small {
|
||||
display: none;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
background: linear-gradient(to right, #fff, #bbb);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation menu
|
||||
.nav-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 5px 0 20px 0;
|
||||
flex: 1;
|
||||
|
||||
// Sponsor link
|
||||
.sponsor-link {
|
||||
margin-bottom: 15px;
|
||||
border-bottom: 1px solid rgba(142, 68, 173, 0.15);
|
||||
padding-bottom: 15px !important;
|
||||
|
||||
.heart-icon i {
|
||||
color: #ff4d6d !important;
|
||||
animation: heart-pulse 7.2s infinite ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
// Nav groups
|
||||
.nav-group {
|
||||
margin-bottom: 15px;
|
||||
|
||||
.nav-group-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: rgba(142, 68, 173, 0.8);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
padding: 0 20px 8px;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 5px;
|
||||
border-bottom: 1px solid rgba(142, 68, 173, 0.2);
|
||||
text-shadow: 0 0 10px rgba(142, 68, 173, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation items
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 20px;
|
||||
color: #e0e0e0;
|
||||
text-decoration: none;
|
||||
border-radius: 0 6px 6px 0;
|
||||
margin: 2px 0 2px 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
.nav-icon-wrapper {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-right: 15px;
|
||||
border-radius: 8px;
|
||||
background: rgba(46, 39, 56, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
i {
|
||||
font-size: 14px;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 3px;
|
||||
background: transparent;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(142, 68, 173, 0.15);
|
||||
transform: translateX(2px);
|
||||
|
||||
.nav-icon-wrapper {
|
||||
background: rgba(142, 68, 173, 0.2);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
&::before {
|
||||
background: linear-gradient(to bottom, rgba(142, 68, 173, 0.4), rgba(103, 58, 183, 0.4));
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: linear-gradient(to right, rgba(142, 68, 173, 0.9), rgba(103, 58, 183, 0.7));
|
||||
box-shadow: 0 2px 10px rgba(142, 68, 173, 0.3);
|
||||
|
||||
.nav-icon-wrapper {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
&::before {
|
||||
background: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Animation keyframes
|
||||
@keyframes logo-glow {
|
||||
0% { box-shadow: 0 0 15px #8e44ad, 0 0 5px rgba(142, 68, 173, 0.4); } // Purple
|
||||
20% { box-shadow: 0 0 15px #9b59b6, 0 0 5px rgba(155, 89, 182, 0.4); } // Lighter purple
|
||||
40% { box-shadow: 0 0 15px #673ab7, 0 0 5px rgba(103, 58, 183, 0.4); } // Deep purple
|
||||
60% { box-shadow: 0 0 15px #5e35b1, 0 0 5px rgba(94, 53, 177, 0.4); } // Dark deep purple
|
||||
80% { box-shadow: 0 0 15px #7e57c2, 0 0 5px rgba(126, 87, 194, 0.4); } // Medium purple
|
||||
100% { box-shadow: 0 0 15px #8e44ad, 0 0 5px rgba(142, 68, 173, 0.4); } // Back to purple
|
||||
}
|
||||
|
||||
@keyframes heart-pulse {
|
||||
0% { color: #ff4d6d; text-shadow: 0 0 10px #ff4d6d, 0 0 20px #ff4d6d; } // Pink
|
||||
50% { color: #ff0a33; text-shadow: 0 0 15px #ff0a33, 0 0 25px #ff0a33; } // Red
|
||||
100% { color: #ff4d6d; text-shadow: 0 0 10px #ff4d6d, 0 0 20px #ff4d6d; } // Pink
|
||||
}
|
||||
// Animation keyframes (logo-glow and heart-pulse moved to sidebar-content component)
|
||||
|
||||
@keyframes pulse-heart {
|
||||
0% {
|
||||
@@ -375,22 +174,6 @@
|
||||
|
||||
// Deep styling overrides for PrimeNG components
|
||||
:host ::ng-deep {
|
||||
// Mobile sidebar styling
|
||||
.p-sidebar.p-component.mobile-sidebar {
|
||||
background-color: var(--surface-overlay) !important;
|
||||
border: none !important;
|
||||
color: var(--text-color) !important;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2) !important;
|
||||
|
||||
.p-sidebar-header {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.p-sidebar-content {
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Override Prime NG button styles
|
||||
.p-button {
|
||||
&.p-button-text {
|
||||
@@ -419,453 +202,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile sidebar content
|
||||
.mobile-sidebar-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--surface-overlay);
|
||||
color: var(--text-color);
|
||||
height: 100%;
|
||||
|
||||
.sidebar-top-line {
|
||||
height: 3px;
|
||||
background: var(--primary-color);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 5px;
|
||||
|
||||
.sidebar-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
margin-right: 15px;
|
||||
background-color: rgba(142, 68, 173, 0.1);
|
||||
border: 1px solid rgba(142, 68, 173, 0.3);
|
||||
animation: logo-glow 10s infinite ease-in-out;
|
||||
|
||||
i {
|
||||
font-size: 20px;
|
||||
color: #a569bd;
|
||||
}
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin-left: 0.5rem;
|
||||
background: linear-gradient(to right, #fff, #bbb);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
padding: 5px 0 20px 0;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
|
||||
.sidebar-section-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: rgba(142, 68, 173, 0.8);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
padding: 0 20px 8px;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 5px;
|
||||
border-bottom: 1px solid rgba(142, 68, 173, 0.2);
|
||||
text-shadow: 0 0 10px rgba(142, 68, 173, 0.3);
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.sidebar-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 20px;
|
||||
color: #e0e0e0;
|
||||
text-decoration: none;
|
||||
border-radius: 0 6px 6px 0;
|
||||
margin: 2px 0 2px 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
.item-icon-wrapper {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-right: 15px;
|
||||
border-radius: 8px;
|
||||
background: rgba(46, 39, 56, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
font-size: 14px;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.item-label {
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 3px;
|
||||
background: transparent;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(142, 68, 173, 0.15);
|
||||
transform: translateX(2px);
|
||||
|
||||
.item-icon-wrapper {
|
||||
background: rgba(142, 68, 173, 0.2);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
&::before {
|
||||
background: linear-gradient(to bottom, rgba(142, 68, 173, 0.4), rgba(103, 58, 183, 0.4));
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
border-left: 3px solid #4299e1;
|
||||
padding-left: calc(1rem - 3px);
|
||||
|
||||
i {
|
||||
color: #4299e1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sponsor-section {
|
||||
padding: 0.5rem 1rem 1rem;
|
||||
margin-top: auto;
|
||||
|
||||
.sponsor-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 4px;
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
i {
|
||||
color: #ff5a5f;
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
|
||||
span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Main layout
|
||||
.layout-main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
|
||||
// Sidebar
|
||||
.sidebar {
|
||||
width: 250px;
|
||||
height: 100vh;
|
||||
background: linear-gradient(135deg, #2c1a47 0%, #1a0e29 50%, #1e1230 75%, rgba(30, 18, 48, 0.95) 100%);
|
||||
background-size: 100% 100%;
|
||||
border-right: 1px solid rgba(142, 68, 173, 0.15);
|
||||
box-shadow: 2px 0 15px rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 1000;
|
||||
flex-shrink: 0;
|
||||
overflow-y: auto;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(5px);
|
||||
|
||||
// Colored edge accents
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: linear-gradient(to bottom, rgba(142, 68, 173, 0.6), rgba(103, 58, 183, 0.6)); // Purple gradient
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
&::after {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.sidebar-top-line {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, rgba(142, 68, 173, 0.6), rgba(103, 58, 183, 0.6)); // Purple gradient
|
||||
z-index: 2;
|
||||
box-shadow: 0 0 8px rgba(142, 68, 173, 0.4); // Add subtle glow
|
||||
}
|
||||
// Sidebar container - all styling moved to sidebar-content component
|
||||
// The sidebar-content component now handles all visual styling via :host selector
|
||||
|
||||
// Transitions for text elements in expanded state
|
||||
.app-name,
|
||||
.sidebar-section-title,
|
||||
.sidebar-nav span,
|
||||
.sponsor-link span {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
width: auto;
|
||||
max-width: 180px;
|
||||
white-space: nowrap;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
@media (max-width: 991px) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.collapsed {
|
||||
width: 70px;
|
||||
|
||||
.app-name,
|
||||
.sidebar-section-title,
|
||||
.sidebar-nav span,
|
||||
.sponsor-link span {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
width: 0;
|
||||
max-width: 0;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.sidebar-nav li a {
|
||||
padding: 0.75rem 0;
|
||||
justify-content: center;
|
||||
|
||||
i {
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.sponsor-section .sponsor-link {
|
||||
padding: 0.75rem 0;
|
||||
justify-content: center;
|
||||
|
||||
i {
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
// Logo container
|
||||
.logo-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 15px 20px 0px;
|
||||
border-bottom: none;
|
||||
margin-bottom: -15px;
|
||||
position: relative;
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
margin-right: 15px;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(142, 68, 173, 0.1);
|
||||
border: 1px solid rgba(142, 68, 173, 0.3);
|
||||
animation: logo-glow 10s infinite ease-in-out;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
i {
|
||||
font-size: 30px;
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
.logo-small {
|
||||
display: none;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: #fff;
|
||||
text-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
|
||||
letter-spacing: 0.5px;
|
||||
background: linear-gradient(to right, #fff, #bbb);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
// Sponsor section
|
||||
.sponsor-section {
|
||||
padding: 0.5rem 1rem 1rem;
|
||||
|
||||
.sponsor-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 4px;
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
i {
|
||||
color: #ff5a5f;
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sidebar footer with toggle button
|
||||
.sidebar-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 1rem;
|
||||
margin-top: auto;
|
||||
|
||||
.sidebar-toggle {
|
||||
color: #ffffff;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sidebar navigation
|
||||
.sidebar-nav {
|
||||
padding: 1rem 0;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
|
||||
.sidebar-section {
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
.sidebar-section-title {
|
||||
padding: 0 1rem;
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
li {
|
||||
a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
text-decoration: none;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 1.25rem;
|
||||
width: 1.5rem;
|
||||
margin-right: 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 0.95rem;
|
||||
transition: opacity 0.2s ease, width 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
&.active a {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
border-left: 3px solid #4299e1;
|
||||
padding-left: calc(1rem - 3px);
|
||||
|
||||
i {
|
||||
color: #4299e1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sidebar navigation styles moved to sidebar-content component
|
||||
|
||||
// Content area
|
||||
.layout-content {
|
||||
@@ -1196,39 +541,12 @@
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes logo-glow {
|
||||
0% { box-shadow: 0 0 15px #8e44ad, 0 0 5px rgba(142, 68, 173, 0.4); } // Purple
|
||||
20% { box-shadow: 0 0 15px #9b59b6, 0 0 5px rgba(155, 89, 182, 0.4); } // Lighter purple
|
||||
40% { box-shadow: 0 0 15px #673ab7, 0 0 5px rgba(103, 58, 183, 0.4); } // Deep purple
|
||||
60% { box-shadow: 0 0 15px #5e35b1, 0 0 5px rgba(94, 53, 177, 0.4); } // Dark deep purple
|
||||
80% { box-shadow: 0 0 15px #7e57c2, 0 0 5px rgba(126, 87, 194, 0.4); } // Medium purple
|
||||
100% { box-shadow: 0 0 15px #8e44ad, 0 0 5px rgba(142, 68, 173, 0.4); } // Back to purple
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile sidebar styling
|
||||
.mobile-sidebar-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #2c1a47 0%, #1a0e29 50%, #1e1230 75%, rgba(30, 18, 48, 0.95) 100%);
|
||||
overflow-y: auto;
|
||||
|
||||
.sidebar-top-line {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, rgba(142, 68, 173, 0.6), rgba(103, 58, 183, 0.6));
|
||||
z-index: 2;
|
||||
box-shadow: 0 0 8px rgba(142, 68, 173, 0.4);
|
||||
}
|
||||
}
|
||||
// Mobile sidebar styling moved to sidebar-content component
|
||||
|
||||
/* Media queries for responsiveness */
|
||||
@media screen and (max-width: 768px) {
|
||||
@@ -1252,16 +570,5 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Style PrimeNG mobile sidebar
|
||||
:host ::ng-deep {
|
||||
.p-sidebar.mobile-sidebar {
|
||||
.p-sidebar-header {
|
||||
display: none; // Hide the header since we're not using it
|
||||
}
|
||||
|
||||
.p-sidebar-content {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Drawer/Sidebar mobile styling is centralized in sidebar-content component
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { Component, HostListener, inject, signal } from '@angular/core';
|
||||
import { Router, RouterOutlet } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
@@ -9,7 +9,7 @@ import { ToolbarModule } from 'primeng/toolbar';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MenuModule } from 'primeng/menu';
|
||||
import { SidebarModule } from 'primeng/sidebar';
|
||||
import { DrawerModule } from 'primeng/drawer';
|
||||
|
||||
import { DividerModule } from 'primeng/divider';
|
||||
import { RippleModule } from 'primeng/ripple';
|
||||
import { ConfirmDialogModule } from 'primeng/confirmdialog';
|
||||
@@ -18,13 +18,6 @@ import { ConfirmDialogModule } from 'primeng/confirmdialog';
|
||||
import { SidebarContentComponent } from '../sidebar-content/sidebar-content.component';
|
||||
import { ToastContainerComponent } from '../../shared/components/toast-container/toast-container.component';
|
||||
|
||||
interface MenuItem {
|
||||
label: string;
|
||||
icon: string;
|
||||
route: string;
|
||||
badge?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-main-layout',
|
||||
standalone: true,
|
||||
@@ -36,7 +29,6 @@ interface MenuItem {
|
||||
FormsModule,
|
||||
MenuModule,
|
||||
SidebarModule,
|
||||
DrawerModule,
|
||||
DividerModule,
|
||||
RippleModule,
|
||||
ConfirmDialogModule,
|
||||
@@ -47,33 +39,15 @@ interface MenuItem {
|
||||
styleUrl: './main-layout.component.scss'
|
||||
})
|
||||
export class MainLayoutComponent {
|
||||
// Menu items
|
||||
menuItems: MenuItem[] = [
|
||||
{ label: 'Dashboard', icon: 'pi pi-home', route: '/dashboard' },
|
||||
{ label: 'Logs', icon: 'pi pi-list', route: '/logs' },
|
||||
{ label: 'Settings', icon: 'pi pi-cog', route: '/settings' },
|
||||
{ label: 'Events', icon: 'pi pi-calendar', route: '/events' },
|
||||
];
|
||||
|
||||
// Mobile menu state
|
||||
mobileSidebarVisible = signal<boolean>(false);
|
||||
|
||||
// Inject router
|
||||
public router = inject(Router);
|
||||
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Handles mobile navigation click events by closing the sidebar
|
||||
*/
|
||||
onMobileNavClick(): void {
|
||||
this.mobileSidebarVisible.set(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle mobile sidebar visibility
|
||||
* Toggle mobile sidebar visibility via sidebar component
|
||||
*/
|
||||
toggleMobileSidebar(): void {
|
||||
this.mobileSidebarVisible.update(value => !value);
|
||||
// This will be called via template reference
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
<!-- Logo Container -->
|
||||
<div class="logo-container">
|
||||
<div class="logo logo-large logo-glow">
|
||||
<img src="icons/128.png" width="40" height="40" alt="Logo">
|
||||
</div>
|
||||
<div class="logo logo-small logo-glow">
|
||||
<img src="icons/128.png" width="40" height="40" alt="Logo">
|
||||
<img src="/icons/128.png" width="40" height="40" alt="Logo">
|
||||
</div>
|
||||
<h2>Cleanuparr</h2>
|
||||
</div>
|
||||
@@ -26,7 +23,7 @@
|
||||
[@staggerItems]>
|
||||
<!-- Project Sponsors Link (always visible) -->
|
||||
<a href="https://cleanuparr.github.io/Cleanuparr/support"
|
||||
class="nav-item sponsor-link"
|
||||
class="nav-item support-link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
<div class="nav-icon-wrapper heart-icon">
|
||||
@@ -73,9 +70,19 @@
|
||||
<div
|
||||
class="nav-item nav-parent"
|
||||
*ngIf="item.children && item.children.length > 0 && !item.topLevel"
|
||||
(click)="navigateToLevel(item)">
|
||||
(click)="navigateToLevel(item)"
|
||||
(mouseenter)="hoveredNavId = item.id"
|
||||
(mouseleave)="hoveredNavId = null">
|
||||
<div class="nav-icon-wrapper">
|
||||
<i [class]="item.icon"></i>
|
||||
<ng-container *ngIf="item.iconUrl; else iconClass">
|
||||
<div class="icon-stack">
|
||||
<img class="icon-base" [src]="item.iconUrl" alt="{{item.label}} icon">
|
||||
<img class="icon-top" [src]="item.iconUrlHover || item.iconUrl" [class.visible]="hoveredNavId === item.id" alt="{{item.label}} icon (hover)">
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #iconClass>
|
||||
<i [class]="item.icon"></i>
|
||||
</ng-template>
|
||||
</div>
|
||||
<span>{{ item.label }}</span>
|
||||
<div class="nav-chevron">
|
||||
@@ -89,9 +96,19 @@
|
||||
class="nav-item"
|
||||
*ngIf="!item.children && item.route && !item.isHeader"
|
||||
[class.active]="router.url.includes(item.route)"
|
||||
(click)="onNavItemClick()">
|
||||
(click)="onNavItemClick()"
|
||||
(mouseenter)="hoveredNavId = item.id"
|
||||
(mouseleave)="hoveredNavId = null">
|
||||
<div class="nav-icon-wrapper">
|
||||
<i [class]="item.icon"></i>
|
||||
<ng-container *ngIf="item.iconUrl; else iconClassLink">
|
||||
<div class="icon-stack">
|
||||
<img class="icon-base" [src]="item.iconUrl" alt="{{item.label}} icon">
|
||||
<img class="icon-top" [src]="item.iconUrlHover || item.iconUrl" [class.visible]="hoveredNavId === item.id" alt="{{item.label}} icon (hover)">
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #iconClassLink>
|
||||
<i [class]="item.icon"></i>
|
||||
</ng-template>
|
||||
</div>
|
||||
<span>{{ item.label }}</span>
|
||||
<span class="nav-badge" *ngIf="item.badge">{{ item.badge }}</span>
|
||||
@@ -103,12 +120,40 @@
|
||||
class="nav-item"
|
||||
*ngIf="!item.children && item.isExternal && !item.isHeader"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
rel="noopener noreferrer"
|
||||
(mouseenter)="hoveredNavId = item.id"
|
||||
(mouseleave)="hoveredNavId = null">
|
||||
<div class="nav-icon-wrapper">
|
||||
<i [class]="item.icon"></i>
|
||||
<ng-container *ngIf="item.iconUrl; else iconClassExternal">
|
||||
<div class="icon-stack">
|
||||
<img class="icon-base" [src]="item.iconUrl" alt="{{item.label}} icon">
|
||||
<img class="icon-top" [src]="item.iconUrlHover || item.iconUrl" [class.visible]="hoveredNavId === item.id" alt="{{item.label}} icon (hover)">
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #iconClassExternal>
|
||||
<i [class]="item.icon"></i>
|
||||
</ng-template>
|
||||
</div>
|
||||
<span>{{ item.label }}</span>
|
||||
</a>
|
||||
</ng-container>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Mobile Drawer (only rendered when enableMobileDrawer is true) -->
|
||||
<p-drawer
|
||||
*ngIf="enableMobileDrawer"
|
||||
styleClass="mobile-sidebar"
|
||||
[visible]="mobileSidebarVisible()"
|
||||
(visibleChange)="onMobileDrawerVisibilityChange($event)"
|
||||
[dismissible]="true"
|
||||
[showCloseIcon]="false"
|
||||
position="left">
|
||||
<ng-template #headless>
|
||||
<app-sidebar-content
|
||||
[isMobile]="true"
|
||||
[enableMobileDrawer]="false"
|
||||
(navItemClicked)="onNavItemClick()">
|
||||
</app-sidebar-content>
|
||||
</ng-template>
|
||||
</p-drawer>
|
||||
|
||||
@@ -1,57 +1,158 @@
|
||||
// Main container stability
|
||||
:host {
|
||||
width: var(--sidebar-width);
|
||||
height: var(--sidebar-height);
|
||||
background: var(--sidebar-background);
|
||||
background-size: 100% 100%;
|
||||
border-right: var(--sidebar-border);
|
||||
box-shadow: var(--sidebar-shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden; // Prevent scrolling on the host
|
||||
position: relative;
|
||||
z-index: var(--sidebar-z-index);
|
||||
flex-shrink: 0;
|
||||
overflow-y: auto;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
transition: all var(--sidebar-transition-duration) ease;
|
||||
backdrop-filter: var(--sidebar-backdrop-filter);
|
||||
|
||||
// Colored edge accents
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: var(--sidebar-top-line-height);
|
||||
background: var(--sidebar-top-line-background);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
&::after {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
// Transitions for text elements in expanded state
|
||||
.app-name,
|
||||
.sidebar-section-title,
|
||||
.sidebar-nav span,
|
||||
.support-link span {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
width: auto;
|
||||
max-width: 180px;
|
||||
white-space: nowrap;
|
||||
transition: all var(--sidebar-transition-duration) ease;
|
||||
}
|
||||
|
||||
@media (max-width: 991px) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// When isMobile input is true, ensure component is always visible regardless of screen size
|
||||
&.mobile-variant {
|
||||
display: flex !important;
|
||||
position: static;
|
||||
height: 100% !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
&.collapsed {
|
||||
width: var(--sidebar-width-collapsed);
|
||||
|
||||
.app-name,
|
||||
.sidebar-section-title,
|
||||
.sidebar-nav span,
|
||||
.support-link span {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
width: 0;
|
||||
max-width: 0;
|
||||
overflow: hidden;
|
||||
transition: all var(--sidebar-transition-duration) ease;
|
||||
}
|
||||
|
||||
.sidebar-nav li a {
|
||||
padding: 0.75rem 0;
|
||||
justify-content: center;
|
||||
|
||||
i {
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.sponsor-section .support-link {
|
||||
padding: 0.75rem 0;
|
||||
justify-content: center;
|
||||
|
||||
i {
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Logo container
|
||||
/* ========================================================================
|
||||
LOGO SECTION
|
||||
======================================================================== */
|
||||
|
||||
.logo-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 20px;
|
||||
position: relative;
|
||||
flex: 0 0 auto; // Prevent logo container from growing/shrinking
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
width: var(--sidebar-logo-size);
|
||||
height: var(--sidebar-logo-size);
|
||||
margin-right: 15px;
|
||||
background-color: var(--primary-100);
|
||||
border: 1px solid var(--primary-300);
|
||||
box-shadow: 0 0 10px rgba(var(--primary-500-rgb), 0.2);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 0 15px rgba(var(--primary-500-rgb), 0.3);
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 20px;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
border-radius: 50%;
|
||||
background-color: var(--sidebar-logo-background);
|
||||
border: var(--sidebar-logo-border);
|
||||
animation: logo-glow 10s infinite ease-in-out;
|
||||
transition: all var(--sidebar-transition-duration) ease;
|
||||
|
||||
.logo-glow {
|
||||
box-shadow: 0 0 10px 6px rgba(89, 16, 185, 0.5);
|
||||
animation: logo-glow 2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.logo-small {
|
||||
display: none;
|
||||
img {
|
||||
width: var(--sidebar-logo-size);
|
||||
height: var(--sidebar-logo-size);
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--text-color);
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
color: var(--sidebar-text-white);
|
||||
text-shadow: var(--sidebar-text-shadow);
|
||||
letter-spacing: 0.5px;
|
||||
background: var(--sidebar-text-gradient);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.logo-glow {
|
||||
box-shadow: var(--sidebar-logo-glow);
|
||||
animation: logo-glow 2s infinite ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +161,7 @@
|
||||
display: block;
|
||||
gap: 0; // Remove gap to prevent layout shifts
|
||||
transition: opacity 0.2s ease;
|
||||
margin-top: 15px; // Add spacing from logo container
|
||||
|
||||
// Keep horizontal overflow hidden to avoid horizontal scrollbar
|
||||
overflow-x: hidden;
|
||||
@@ -136,14 +238,14 @@
|
||||
}
|
||||
|
||||
// Sponsor link
|
||||
.sponsor-link {
|
||||
.support-link {
|
||||
border-bottom: none;
|
||||
margin-bottom: 15px;
|
||||
|
||||
.heart-icon i {
|
||||
color: rgb(147 0 255) !important;
|
||||
color: var(--sidebar-heart-color) !important;
|
||||
transition: all 0.3s ease;
|
||||
text-shadow: 0 0 6px rgba(239, 68, 68, 0.7), 0 0 12px rgba(239, 68, 68, 0.5);
|
||||
text-shadow: var(--sidebar-heart-shadow);
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.2);
|
||||
@@ -224,9 +326,14 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation items
|
||||
.nav-item {
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
NAVIGATION SECTION
|
||||
======================================================================== */
|
||||
|
||||
// Main navigation items
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 20px;
|
||||
@@ -243,8 +350,8 @@
|
||||
}
|
||||
|
||||
.nav-icon-wrapper {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
@@ -256,10 +363,39 @@
|
||||
flex-shrink: 0; // Prevent icon from shrinking
|
||||
|
||||
i {
|
||||
font-size: 20px;
|
||||
font-size: 22px;
|
||||
color: var(--text-color-secondary);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0.0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Stacked image icon (grayscale base + colored top) */
|
||||
.icon-stack {
|
||||
position: relative;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.icon-stack img {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
transition: opacity 180ms ease-in-out, transform 180ms ease-in-out;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.icon-stack .icon-top {
|
||||
opacity: 0;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.icon-stack .icon-top.visible {
|
||||
opacity: 1;
|
||||
transform: scale(1.03);
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
@@ -321,7 +457,7 @@
|
||||
}
|
||||
|
||||
&::before {
|
||||
background-color: #ffffff;
|
||||
background-color: var(--sidebar-text-white);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -347,9 +483,65 @@
|
||||
transform: translateX(3px) scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
SPONSOR SECTION
|
||||
======================================================================== */
|
||||
|
||||
.sponsor-section {
|
||||
padding: 0.5rem 1rem 1rem;
|
||||
|
||||
.support-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 4px;
|
||||
color: var(--sidebar-text-white);
|
||||
text-decoration: none;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
i {
|
||||
color: #ff5a5f;
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sidebar footer with toggle button
|
||||
.sidebar-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 1rem;
|
||||
margin-top: auto;
|
||||
|
||||
.sidebar-toggle {
|
||||
color: var(--sidebar-text-white);
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
ANIMATIONS
|
||||
======================================================================== */
|
||||
|
||||
@keyframes logo-glow {
|
||||
0% { box-shadow: 0 0 15px #8e44ad, 0 0 5px rgba(142, 68, 173, 0.4); }
|
||||
20% { box-shadow: 0 0 15px #9b59b6, 0 0 5px rgba(155, 89, 182, 0.4); }
|
||||
40% { box-shadow: 0 0 15px #673ab7, 0 0 5px rgba(103, 58, 183, 0.4); }
|
||||
60% { box-shadow: 0 0 15px #5e35b1, 0 0 5px rgba(94, 53, 177, 0.4); }
|
||||
80% { box-shadow: 0 0 15px #7e57c2, 0 0 5px rgba(126, 87, 194, 0.4); }
|
||||
100% { box-shadow: 0 0 15px #8e44ad, 0 0 5px rgba(142, 68, 173, 0.4); }
|
||||
}
|
||||
|
||||
// Loading skeleton animation
|
||||
@keyframes skeleton-loading {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
@@ -359,7 +551,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Animation keyframes
|
||||
@keyframes heart-pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
@@ -374,3 +565,124 @@
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
::ng-deep {
|
||||
.p-drawer.mobile-sidebar {
|
||||
background: var(--sidebar-background) !important;
|
||||
border: none !important;
|
||||
|
||||
.p-drawer-content {
|
||||
padding: 0 !important;
|
||||
background: var(--sidebar-background) !important;
|
||||
color: var(--sidebar-text-white) !important;
|
||||
|
||||
.sidebar-header {
|
||||
padding: 1.5rem 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.sidebar-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.sidebar-icon {
|
||||
font-size: 1.5rem;
|
||||
color: #ffcc00;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
margin-left: 0.5rem;
|
||||
color: var(--sidebar-text-white);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
padding: var(--sidebar-nav-padding);
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
|
||||
.sidebar-section-title {
|
||||
padding: 0 1rem;
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
li {
|
||||
a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
text-decoration: none;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 1.25rem;
|
||||
width: 1.5rem;
|
||||
margin-right: 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
|
||||
&.active a {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
color: var(--sidebar-text-white);
|
||||
font-weight: 600;
|
||||
border-left: 3px solid #4299e1;
|
||||
padding-left: calc(1rem - 3px);
|
||||
|
||||
i {
|
||||
color: #4299e1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sponsor-section {
|
||||
padding: 0.5rem 1rem 1rem;
|
||||
margin-top: auto;
|
||||
|
||||
.support-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 4px;
|
||||
color: var(--sidebar-text-white);
|
||||
text-decoration: none;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
i {
|
||||
color: #ff5a5f;
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
import { Component, Input, inject, Output, EventEmitter, OnInit, OnChanges, SimpleChanges, OnDestroy } from '@angular/core';
|
||||
import { Component, Input, inject, Output, EventEmitter, OnInit, OnDestroy, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router, RouterLink, NavigationEnd } from '@angular/router';
|
||||
import { ButtonModule } from 'primeng/button';
|
||||
import { filter } from 'rxjs/operators';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { DrawerModule } from 'primeng/drawer';
|
||||
import { filter, debounceTime } from 'rxjs/operators';
|
||||
import { Subscription, fromEvent } from 'rxjs';
|
||||
import { trigger, state, style, transition, animate, query, stagger } from '@angular/animations';
|
||||
|
||||
interface MenuItem {
|
||||
label: string;
|
||||
icon: string;
|
||||
route: string;
|
||||
badge?: string;
|
||||
}
|
||||
|
||||
interface NavigationItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
iconUrl?: string;
|
||||
iconUrlHover?: string;
|
||||
route?: string; // For direct navigation items
|
||||
children?: NavigationItem[]; // For parent items with sub-menus
|
||||
isExternal?: boolean; // For external links
|
||||
@@ -37,10 +33,14 @@ interface RouteMapping {
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterLink,
|
||||
ButtonModule
|
||||
ButtonModule,
|
||||
DrawerModule
|
||||
],
|
||||
templateUrl: './sidebar-content.component.html',
|
||||
styleUrl: './sidebar-content.component.scss',
|
||||
host: {
|
||||
'[class.mobile-variant]': 'isMobile'
|
||||
},
|
||||
animations: [
|
||||
trigger('staggerItems', [
|
||||
transition(':enter', [
|
||||
@@ -70,10 +70,14 @@ interface RouteMapping {
|
||||
])
|
||||
]
|
||||
})
|
||||
export class SidebarContentComponent implements OnInit, OnChanges, OnDestroy {
|
||||
@Input() menuItems: MenuItem[] = [];
|
||||
export class SidebarContentComponent implements OnInit, OnDestroy {
|
||||
@Input() isMobile = false;
|
||||
@Input() enableMobileDrawer = false;
|
||||
@Output() navItemClicked = new EventEmitter<void>();
|
||||
@Output() mobileDrawerVisibilityChange = new EventEmitter<boolean>();
|
||||
|
||||
// Mobile drawer state
|
||||
mobileSidebarVisible = signal<boolean>(false);
|
||||
|
||||
// Inject router for active route styling
|
||||
public router = inject(Router);
|
||||
@@ -91,8 +95,12 @@ export class SidebarContentComponent implements OnInit, OnChanges, OnDestroy {
|
||||
// Animation trigger property - changes to force re-render and trigger animations
|
||||
navigationStateKey = 0;
|
||||
|
||||
// Track hovered navigation item id to swap images
|
||||
hoveredNavId: string | null = null;
|
||||
|
||||
// Route synchronization properties
|
||||
private routerSubscription?: Subscription;
|
||||
private resizeSubscription?: Subscription;
|
||||
private routeMappings: RouteMapping[] = [
|
||||
// Dashboard
|
||||
{ route: '/dashboard', navigationPath: ['dashboard'] },
|
||||
@@ -110,9 +118,12 @@ export class SidebarContentComponent implements OnInit, OnChanges, OnDestroy {
|
||||
{ route: '/queue-cleaner', navigationPath: ['settings', 'queue-cleaner'] },
|
||||
{ route: '/malware-blocker', navigationPath: ['settings', 'malware-blocker'] },
|
||||
{ route: '/download-cleaner', navigationPath: ['settings', 'download-cleaner'] },
|
||||
{ route: '/blacklist-synchronizer', navigationPath: ['settings', 'blacklist-synchronizer'] },
|
||||
{ route: '/notifications', navigationPath: ['settings', 'notifications'] },
|
||||
|
||||
// Other routes will be handled dynamically
|
||||
// Activity routes
|
||||
{ route: '/logs', navigationPath: ['activity', 'logs'] },
|
||||
{ route: '/events', navigationPath: ['activity', 'events'] }
|
||||
];
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -123,16 +134,31 @@ export class SidebarContentComponent implements OnInit, OnChanges, OnDestroy {
|
||||
setTimeout(() => {
|
||||
this.initializeNavigation();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['menuItems']) {
|
||||
this.updateActivityItems();
|
||||
}
|
||||
// Listen for window resize events to auto-hide mobile drawer
|
||||
this.setupWindowResizeListener();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.routerSubscription?.unsubscribe();
|
||||
this.resizeSubscription?.unsubscribe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup window resize listener to auto-hide mobile drawer on larger screens
|
||||
*/
|
||||
private setupWindowResizeListener(): void {
|
||||
// Define the mobile breakpoint (should match CSS media query)
|
||||
const MOBILE_BREAKPOINT = 991;
|
||||
|
||||
this.resizeSubscription = fromEvent(window, 'resize')
|
||||
.pipe(
|
||||
debounceTime(150), // Debounce resize events for better performance
|
||||
filter(() => window.innerWidth > MOBILE_BREAKPOINT && this.mobileSidebarVisible())
|
||||
)
|
||||
.subscribe(() => {
|
||||
this.mobileSidebarVisible.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -141,18 +167,10 @@ export class SidebarContentComponent implements OnInit, OnChanges, OnDestroy {
|
||||
private initializeNavigation(): void {
|
||||
if (this.hasInitialized) return;
|
||||
|
||||
// 1. Initialize navigation data
|
||||
this.setupNavigationData();
|
||||
|
||||
// 2. Update activity items if available
|
||||
if (this.menuItems && this.menuItems.length > 0) {
|
||||
this.updateActivityItems();
|
||||
}
|
||||
|
||||
// 3. Determine correct navigation level based on current route
|
||||
this.syncSidebarWithCurrentRoute();
|
||||
|
||||
// 4. Mark as ready and subscribe to route changes
|
||||
this.isNavigationReady = true;
|
||||
this.hasInitialized = true;
|
||||
this.subscribeToRouteChanges();
|
||||
@@ -164,6 +182,29 @@ export class SidebarContentComponent implements OnInit, OnChanges, OnDestroy {
|
||||
private setupNavigationData(): void {
|
||||
this.navigationData = this.getNavigationData();
|
||||
this.currentNavigation = this.buildTopLevelNavigation();
|
||||
// Preload hover icons to avoid flicker on first hover
|
||||
this.preloadIcons();
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload hover icon images to reduce flicker when user first hovers over an item
|
||||
*/
|
||||
private preloadIcons(): void {
|
||||
const urls = new Set<string>();
|
||||
const collect = (items: NavigationItem[] | undefined) => {
|
||||
if (!items) return;
|
||||
items.forEach(i => {
|
||||
if (i.iconUrlHover) urls.add(i.iconUrlHover);
|
||||
if (i.children) collect(i.children);
|
||||
});
|
||||
};
|
||||
|
||||
collect(this.navigationData);
|
||||
|
||||
urls.forEach(url => {
|
||||
const img = new Image();
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -209,11 +250,46 @@ export class SidebarContentComponent implements OnInit, OnChanges, OnDestroy {
|
||||
label: 'Media Apps',
|
||||
icon: 'pi pi-play-circle',
|
||||
children: [
|
||||
{ id: 'sonarr', label: 'Sonarr', icon: 'pi pi-play-circle', route: '/sonarr' },
|
||||
{ id: 'radarr', label: 'Radarr', icon: 'pi pi-play-circle', route: '/radarr' },
|
||||
{ id: 'lidarr', label: 'Lidarr', icon: 'pi pi-bolt', route: '/lidarr' },
|
||||
{ id: 'readarr', label: 'Readarr', icon: 'pi pi-book', route: '/readarr' },
|
||||
{ id: 'whisparr', label: 'Whisparr', icon: 'pi pi-lock', route: '/whisparr' },
|
||||
{
|
||||
id: 'sonarr',
|
||||
label: 'Sonarr',
|
||||
icon: 'pi pi-play-circle',
|
||||
route: '/sonarr',
|
||||
iconUrl: '/icons/ext/sonarr-light.svg',
|
||||
iconUrlHover: '/icons/ext/sonarr.svg'
|
||||
},
|
||||
{
|
||||
id: 'radarr',
|
||||
label: 'Radarr',
|
||||
icon: 'pi pi-play-circle',
|
||||
route: '/radarr',
|
||||
iconUrl: '/icons/ext/radarr-light.svg',
|
||||
iconUrlHover: '/icons/ext/radarr.svg'
|
||||
},
|
||||
{
|
||||
id: 'lidarr',
|
||||
label: 'Lidarr',
|
||||
icon: 'pi pi-bolt',
|
||||
route: '/lidarr',
|
||||
iconUrl: '/icons/ext/lidarr-light.svg',
|
||||
iconUrlHover: '/icons/ext/lidarr.svg'
|
||||
},
|
||||
{
|
||||
id: 'readarr',
|
||||
label: 'Readarr',
|
||||
icon: 'pi pi-book',
|
||||
route: '/readarr',
|
||||
iconUrl: '/icons/ext/readarr-light.svg',
|
||||
iconUrlHover: '/icons/ext/readarr.svg'
|
||||
},
|
||||
{
|
||||
id: 'whisparr',
|
||||
label: 'Whisparr',
|
||||
icon: 'pi pi-lock',
|
||||
route: '/whisparr',
|
||||
iconUrl: '/icons/ext/whisparr-light.svg',
|
||||
iconUrlHover: '/icons/ext/whisparr.svg'
|
||||
},
|
||||
{ id: 'download-clients', label: 'Download Clients', icon: 'pi pi-download', route: '/download-clients' }
|
||||
]
|
||||
},
|
||||
@@ -226,6 +302,7 @@ export class SidebarContentComponent implements OnInit, OnChanges, OnDestroy {
|
||||
{ id: 'queue-cleaner', label: 'Queue Cleaner', icon: 'pi pi-list', route: '/queue-cleaner' },
|
||||
{ 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: 'blacklist-synchronizer', label: 'Blacklist Synchronizer', icon: 'pi pi-sync', route: '/blacklist-synchronizer' },
|
||||
{ id: 'notifications', label: 'Notifications', icon: 'pi pi-bell', route: '/notifications' }
|
||||
]
|
||||
},
|
||||
@@ -233,7 +310,10 @@ export class SidebarContentComponent implements OnInit, OnChanges, OnDestroy {
|
||||
id: 'activity',
|
||||
label: 'Activity',
|
||||
icon: 'pi pi-chart-line',
|
||||
children: [] // Will be populated dynamically from menuItems
|
||||
children: [
|
||||
{ id: 'logs', label: 'Logs', icon: 'pi pi-list', route: '/logs' },
|
||||
{ id: 'events', label: 'Events', icon: 'pi pi-calendar', route: '/events' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'help-support',
|
||||
@@ -341,57 +421,7 @@ export class SidebarContentComponent implements OnInit, OnChanges, OnDestroy {
|
||||
return `${item.id}-${index}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update activity items from menuItems input
|
||||
*/
|
||||
private updateActivityItems(): void {
|
||||
const activityItem = this.navigationData.find(item => item.id === 'activity');
|
||||
if (activityItem && this.menuItems) {
|
||||
activityItem.children = this.menuItems
|
||||
.filter(item => !['Dashboard', 'Settings'].includes(item.label))
|
||||
.map(item => ({
|
||||
id: item.label.toLowerCase().replace(/\s+/g, '-'),
|
||||
label: item.label,
|
||||
icon: item.icon,
|
||||
route: item.route,
|
||||
badge: item.badge
|
||||
}));
|
||||
|
||||
// Update route mappings for activity items
|
||||
this.updateActivityRouteMappings();
|
||||
|
||||
// Update current navigation if we're showing the root level
|
||||
if (this.navigationBreadcrumb.length === 0) {
|
||||
this.currentNavigation = this.buildTopLevelNavigation();
|
||||
}
|
||||
|
||||
// Re-sync with current route to handle activity routes
|
||||
this.syncSidebarWithCurrentRoute();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update route mappings for activity items
|
||||
*/
|
||||
private updateActivityRouteMappings(): void {
|
||||
// Remove old activity mappings
|
||||
this.routeMappings = this.routeMappings.filter(mapping =>
|
||||
!mapping.navigationPath[0] || !mapping.navigationPath[0].startsWith('activity')
|
||||
);
|
||||
|
||||
// Add new activity mappings
|
||||
const activityItem = this.navigationData.find(item => item.id === 'activity');
|
||||
if (activityItem?.children) {
|
||||
activityItem.children.forEach(child => {
|
||||
if (child.route) {
|
||||
this.routeMappings.push({
|
||||
route: child.route,
|
||||
navigationPath: ['activity', child.id]
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync sidebar state with current route
|
||||
@@ -483,5 +513,38 @@ export class SidebarContentComponent implements OnInit, OnChanges, OnDestroy {
|
||||
if (this.isMobile) {
|
||||
this.navItemClicked.emit();
|
||||
}
|
||||
// Close mobile drawer when nav item is clicked
|
||||
if (this.mobileSidebarVisible()) {
|
||||
this.mobileSidebarVisible.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show mobile drawer
|
||||
*/
|
||||
showMobileDrawer(): void {
|
||||
this.mobileSidebarVisible.set(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide mobile drawer
|
||||
*/
|
||||
hideMobileDrawer(): void {
|
||||
this.mobileSidebarVisible.set(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle mobile drawer visibility
|
||||
*/
|
||||
toggleMobileDrawer(): void {
|
||||
this.mobileSidebarVisible.update(visible => !visible);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mobile drawer visibility change
|
||||
*/
|
||||
onMobileDrawerVisibilityChange(visible: boolean): void {
|
||||
this.mobileSidebarVisible.set(visible);
|
||||
this.mobileDrawerVisibilityChange.emit(visible);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { patchState, signalStore, withHooks, withMethods, withState } from '@ngrx/signals';
|
||||
import { rxMethod } from '@ngrx/signals/rxjs-interop';
|
||||
import { ConfigurationService } from '../../core/services/configuration.service';
|
||||
import { BlacklistSyncConfig } from '../../shared/models/blacklist-sync-config.model';
|
||||
import { EMPTY, Observable } from 'rxjs';
|
||||
import { switchMap, tap, catchError } from 'rxjs/operators';
|
||||
|
||||
export interface BlacklistSyncState {
|
||||
config: BlacklistSyncConfig | 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: BlacklistSyncState = {
|
||||
config: null,
|
||||
loading: false,
|
||||
saving: false,
|
||||
loadError: null,
|
||||
saveError: null,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class BlacklistSyncConfigStore extends signalStore(
|
||||
withState(initialState),
|
||||
withMethods((store, configService = inject(ConfigurationService)) => ({
|
||||
loadConfig: rxMethod<void>(
|
||||
pipe => pipe.pipe(
|
||||
tap(() => patchState(store, { loading: true, loadError: null, saveError: null })),
|
||||
switchMap(() => configService.getBlacklistSyncConfig().pipe(
|
||||
tap({
|
||||
next: (config) => patchState(store, { config, loading: false, loadError: null }),
|
||||
error: (error) => {
|
||||
const errorMessage = error.message || 'Failed to load Blacklist Sync configuration';
|
||||
patchState(store, {
|
||||
loading: false,
|
||||
loadError: errorMessage // Only load errors should trigger "Not connected" state
|
||||
});
|
||||
}
|
||||
}),
|
||||
catchError((error) => {
|
||||
const errorMessage = error.message || 'Failed to load Blacklist Sync configuration';
|
||||
patchState(store, {
|
||||
loading: false,
|
||||
loadError: errorMessage // Only load errors should trigger "Not connected" state
|
||||
});
|
||||
return EMPTY;
|
||||
})
|
||||
))
|
||||
)
|
||||
),
|
||||
saveConfig: rxMethod<BlacklistSyncConfig>(
|
||||
(config$: Observable<BlacklistSyncConfig>) => config$.pipe(
|
||||
tap(() => patchState(store, { saving: true, saveError: null })),
|
||||
switchMap(config => configService.updateBlacklistSyncConfig(config).pipe(
|
||||
tap({
|
||||
next: () => patchState(store, { config, saving: false, saveError: null }),
|
||||
error: (error) => {
|
||||
const errorMessage = error.message || 'Failed to update Blacklist Sync configuration';
|
||||
patchState(store, {
|
||||
saving: false,
|
||||
saveError: errorMessage // Save errors don't affect "Not connected" state
|
||||
});
|
||||
}
|
||||
}),
|
||||
catchError((error) => {
|
||||
const errorMessage = error.message || 'Failed to update Blacklist Sync configuration';
|
||||
patchState(store, {
|
||||
saving: false,
|
||||
saveError: errorMessage // Save errors don't affect "Not connected" state
|
||||
});
|
||||
return EMPTY;
|
||||
})
|
||||
))
|
||||
)
|
||||
),
|
||||
updateConfigLocally(config: Partial<BlacklistSyncConfig>) {
|
||||
const current = store.config();
|
||||
if (current) {
|
||||
patchState(store, { config: { ...current, ...config } });
|
||||
}
|
||||
}
|
||||
})),
|
||||
withHooks({
|
||||
onInit({ loadConfig }) {
|
||||
loadConfig();
|
||||
}
|
||||
})
|
||||
) {}
|
||||
@@ -0,0 +1,92 @@
|
||||
<div class="settings-container">
|
||||
<div class="flex align-items-center justify-content-between mb-4">
|
||||
<h1>Blacklist Sync</h1>
|
||||
</div>
|
||||
|
||||
<!-- Loading/Error Component -->
|
||||
<app-loading-error-state
|
||||
*ngIf="blacklistSyncLoading() || blacklistSyncLoadError()"
|
||||
[loading]="blacklistSyncLoading()"
|
||||
[error]="blacklistSyncLoadError()"
|
||||
loadingMessage="Loading settings..."
|
||||
errorMessage="Could not connect to server"
|
||||
></app-loading-error-state>
|
||||
|
||||
<!-- Settings Form -->
|
||||
<form *ngIf="!blacklistSyncLoading() && !blacklistSyncLoadError() && blacklistSyncForm" [formGroup]="blacklistSyncForm" class="p-fluid">
|
||||
|
||||
<!-- Blacklist Sync 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">Blacklist Sync Configuration</h2>
|
||||
<span class="card-subtitle">Configure automatic blacklist synchronization for qBittorrent clients</span>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<div class="card-content">
|
||||
<!-- Enable Blacklist Sync -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('enabled')"
|
||||
title="Click for documentation">
|
||||
</i>
|
||||
Enable Blacklist Sync
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<p-checkbox formControlName="enabled" [binary]="true" inputId="blacklistSyncEnabled"></p-checkbox>
|
||||
<small class="form-helper-text">When enabled, blacklist patterns will be synchronized to enabled qBittorrent clients hourly</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Blacklist Path -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('blacklistPath')"
|
||||
title="Click for documentation">
|
||||
</i>
|
||||
Blacklist Path
|
||||
</label>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<input
|
||||
type="text"
|
||||
pInputText
|
||||
formControlName="blacklistPath"
|
||||
placeholder="File path or http(s) URL"
|
||||
id="blacklistPath" />
|
||||
</div>
|
||||
<small *ngIf="hasError('blacklistPath', 'required')" class="p-error">This field is required when blacklist sync is enabled</small>
|
||||
<small class="form-helper-text">Path to blacklist file or HTTP(S) URL containing blacklist patterns</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="card-footer mt-3">
|
||||
<button
|
||||
pButton
|
||||
type="button"
|
||||
label="Save"
|
||||
icon="pi pi-save"
|
||||
class="p-button-primary"
|
||||
[disabled]="(!blacklistSyncForm.dirty || !hasActualChanges) || blacklistSyncForm.invalid || blacklistSyncSaving()"
|
||||
[loading]="blacklistSyncSaving()"
|
||||
(click)="saveBlacklistSyncConfig()"
|
||||
></button>
|
||||
<button
|
||||
pButton
|
||||
type="button"
|
||||
label="Reset"
|
||||
icon="pi pi-refresh"
|
||||
class="p-button-secondary p-button-outlined ml-2"
|
||||
(click)="resetBlacklistSyncConfig()"
|
||||
></button>
|
||||
</div>
|
||||
</p-card>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,5 @@
|
||||
/* Blacklist Sync Settings Styles */
|
||||
|
||||
@use '../styles/settings-shared.scss';
|
||||
@use '../styles/arr-shared.scss';
|
||||
@use '../settings-page/settings-page.component.scss';
|
||||
@@ -0,0 +1,337 @@
|
||||
import { Component, EventEmitter, OnDestroy, Output, effect, inject } from "@angular/core";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
|
||||
import { Subject, takeUntil } from "rxjs";
|
||||
import { BlacklistSyncConfigStore } from "./blacklist-sync-config.store";
|
||||
import { CanComponentDeactivate } from "../../core/guards";
|
||||
import { BlacklistSyncConfig } from "../../shared/models/blacklist-sync-config.model";
|
||||
|
||||
// PrimeNG Components
|
||||
import { CardModule } from "primeng/card";
|
||||
import { InputTextModule } from "primeng/inputtext";
|
||||
import { CheckboxModule } from "primeng/checkbox";
|
||||
import { ButtonModule } from "primeng/button";
|
||||
import { ToastModule } from "primeng/toast";
|
||||
import { NotificationService } from '../../core/services/notification.service';
|
||||
import { DocumentationService } from '../../core/services/documentation.service';
|
||||
import { LoadingErrorStateComponent } from "../../shared/components/loading-error-state/loading-error-state.component";
|
||||
|
||||
@Component({
|
||||
selector: "app-blacklist-sync-settings",
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
CardModule,
|
||||
InputTextModule,
|
||||
CheckboxModule,
|
||||
ButtonModule,
|
||||
ToastModule,
|
||||
LoadingErrorStateComponent,
|
||||
],
|
||||
providers: [BlacklistSyncConfigStore],
|
||||
templateUrl: "./blacklist-sync-settings.component.html",
|
||||
styleUrls: ["./blacklist-sync-settings.component.scss"],
|
||||
})
|
||||
export class BlacklistSyncSettingsComponent implements OnDestroy, CanComponentDeactivate {
|
||||
@Output() saved = new EventEmitter<void>();
|
||||
@Output() error = new EventEmitter<string>();
|
||||
|
||||
// Blacklist Sync Configuration Form
|
||||
blacklistSyncForm: FormGroup;
|
||||
|
||||
// Original form values for tracking changes
|
||||
private originalFormValues: any;
|
||||
|
||||
// Track whether the form has actual changes compared to original values
|
||||
hasActualChanges = false;
|
||||
|
||||
// Inject the necessary services
|
||||
private formBuilder = inject(FormBuilder);
|
||||
private notificationService = inject(NotificationService);
|
||||
private documentationService = inject(DocumentationService);
|
||||
private blacklistSyncConfigStore = inject(BlacklistSyncConfigStore);
|
||||
|
||||
// Signals from the store
|
||||
readonly blacklistSyncConfig = this.blacklistSyncConfigStore.config;
|
||||
readonly blacklistSyncLoading = this.blacklistSyncConfigStore.loading;
|
||||
readonly blacklistSyncSaving = this.blacklistSyncConfigStore.saving;
|
||||
readonly blacklistSyncLoadError = this.blacklistSyncConfigStore.loadError; // Only for "Not connected" state
|
||||
readonly blacklistSyncSaveError = this.blacklistSyncConfigStore.saveError; // Only for toast notifications
|
||||
|
||||
// Subject for unsubscribing from observables when component is destroyed
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
// Flag to track if form has been initially loaded to avoid showing dialog on page load
|
||||
private formInitialized = false;
|
||||
|
||||
/**
|
||||
* Check if component can be deactivated (navigation guard)
|
||||
*/
|
||||
canDeactivate(): boolean {
|
||||
return !this.blacklistSyncForm.dirty;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open field-specific documentation in a new tab
|
||||
* @param fieldName The form field name (e.g., 'enabled', 'blacklistPath')
|
||||
*/
|
||||
openFieldDocs(fieldName: string): void {
|
||||
this.documentationService.openFieldDocumentation('blacklist-sync', fieldName);
|
||||
}
|
||||
|
||||
constructor() {
|
||||
// Initialize the blacklist sync settings form
|
||||
this.blacklistSyncForm = this.formBuilder.group({
|
||||
enabled: [false],
|
||||
blacklistPath: ['', [Validators.required]],
|
||||
});
|
||||
|
||||
// Effect to handle configuration changes
|
||||
effect(() => {
|
||||
const config = this.blacklistSyncConfig();
|
||||
if (config) {
|
||||
// Reset form with the config values
|
||||
this.blacklistSyncForm.patchValue({
|
||||
enabled: config.enabled,
|
||||
blacklistPath: config.blacklistPath || '',
|
||||
});
|
||||
|
||||
// Store original values for dirty checking
|
||||
this.storeOriginalValues();
|
||||
|
||||
// Update blacklist path controls state based on loaded configuration
|
||||
const blacklistSyncEnabled = config.enabled ?? false;
|
||||
this.updateBlacklistPathControlState(blacklistSyncEnabled);
|
||||
|
||||
// Mark form as initialized to enable confirmation dialogs for user actions
|
||||
this.formInitialized = true;
|
||||
|
||||
// Mark form as pristine since we've just loaded the data
|
||||
this.blacklistSyncForm.markAsPristine();
|
||||
}
|
||||
});
|
||||
|
||||
// Effect to handle load errors - emit to LoadingErrorStateComponent for "Not connected" display
|
||||
effect(() => {
|
||||
const loadErrorMessage = this.blacklistSyncLoadError();
|
||||
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.blacklistSyncSaveError();
|
||||
if (saveErrorMessage) {
|
||||
// Always show save errors as a toast so the user sees the backend message.
|
||||
this.notificationService.showError(saveErrorMessage);
|
||||
}
|
||||
});
|
||||
|
||||
// Set up listeners for form value changes
|
||||
this.setupFormValueChangeListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up listeners for form control value changes
|
||||
*/
|
||||
private setupFormValueChangeListeners(): void {
|
||||
// Listen to all form changes to check for actual differences from original values
|
||||
this.blacklistSyncForm.valueChanges.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(() => {
|
||||
this.hasActualChanges = this.formValuesChanged();
|
||||
});
|
||||
|
||||
// Listen for changes to the 'enabled' control
|
||||
const enabledControl = this.blacklistSyncForm.get('enabled');
|
||||
if (enabledControl) {
|
||||
enabledControl.valueChanges
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((enabled: boolean) => {
|
||||
this.updateBlacklistPathControlState(enabled);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up subscriptions when component is destroyed
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current form values are different from the original values
|
||||
*/
|
||||
private formValuesChanged(): boolean {
|
||||
if (!this.originalFormValues) return false;
|
||||
|
||||
const currentValues = this.blacklistSyncForm.getRawValue();
|
||||
return !this.isEqual(currentValues, this.originalFormValues);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update blacklist path control state based on enabled value
|
||||
*/
|
||||
private updateBlacklistPathControlState(enabled: boolean): void {
|
||||
const blacklistPathControl = this.blacklistSyncForm.get('blacklistPath');
|
||||
|
||||
if (enabled) {
|
||||
blacklistPathControl?.enable({ emitEvent: false });
|
||||
} else {
|
||||
blacklistPathControl?.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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep compare two objects for equality
|
||||
*/
|
||||
private isEqual(obj1: any, obj2: any): boolean {
|
||||
if (obj1 === obj2) return true;
|
||||
|
||||
if (typeof obj1 !== 'object' || obj1 === null ||
|
||||
typeof obj2 !== 'object' || obj2 === null) {
|
||||
return obj1 === obj2;
|
||||
}
|
||||
|
||||
if (Array.isArray(obj1) && Array.isArray(obj2)) {
|
||||
if (obj1.length !== obj2.length) return false;
|
||||
for (let i = 0; i < obj1.length; i++) {
|
||||
if (!this.isEqual(obj1[i], obj2[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const keys1 = Object.keys(obj1);
|
||||
const keys2 = Object.keys(obj2);
|
||||
|
||||
if (keys1.length !== keys2.length) return false;
|
||||
|
||||
for (const key of keys1) {
|
||||
if (!keys2.includes(key)) return false;
|
||||
|
||||
if (!this.isEqual(obj1[key], obj2[key])) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store original form values for dirty checking
|
||||
*/
|
||||
private storeOriginalValues(): void {
|
||||
// Create a deep copy of the form values to ensure proper comparison
|
||||
this.originalFormValues = JSON.parse(JSON.stringify(this.blacklistSyncForm.getRawValue()));
|
||||
this.hasActualChanges = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the blacklist sync configuration
|
||||
*/
|
||||
saveBlacklistSyncConfig(): void {
|
||||
// Force validation on all controls, including disabled ones
|
||||
this.validateAllFormControls(this.blacklistSyncForm);
|
||||
|
||||
// Mark all form controls as touched to trigger validation messages
|
||||
this.markFormGroupTouched(this.blacklistSyncForm);
|
||||
|
||||
if (this.blacklistSyncForm.invalid) {
|
||||
this.notificationService.showValidationError();
|
||||
return;
|
||||
}
|
||||
|
||||
const formValues = this.blacklistSyncForm.getRawValue();
|
||||
|
||||
const config: BlacklistSyncConfig = {
|
||||
id: this.blacklistSyncConfig()?.id || '',
|
||||
enabled: formValues.enabled,
|
||||
blacklistPath: formValues.blacklistPath || undefined,
|
||||
};
|
||||
|
||||
// Save the configuration
|
||||
this.blacklistSyncConfigStore.saveConfig(config);
|
||||
|
||||
// Setup a one-time check to mark form as pristine after successful save
|
||||
const checkSaveCompletion = () => {
|
||||
const saving = this.blacklistSyncSaving();
|
||||
const saveError = this.blacklistSyncSaveError();
|
||||
|
||||
if (!saving && !saveError) {
|
||||
// Mark form as pristine after successful save
|
||||
this.blacklistSyncForm.markAsPristine();
|
||||
// Update original values reference
|
||||
this.storeOriginalValues();
|
||||
// Emit saved event
|
||||
this.saved.emit();
|
||||
// Display success message
|
||||
this.notificationService.showSuccess('Blacklist Sync configuration saved successfully.');
|
||||
} 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);
|
||||
}
|
||||
};
|
||||
|
||||
// Start checking for save completion
|
||||
checkSaveCompletion();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the blacklist sync configuration form to default values
|
||||
*/
|
||||
resetBlacklistSyncConfig(): void {
|
||||
this.blacklistSyncForm.reset({
|
||||
enabled: false,
|
||||
blacklistPath: '',
|
||||
});
|
||||
|
||||
// Update blacklist path control state after reset
|
||||
this.updateBlacklistPathControlState(false); // enabled defaults to false
|
||||
|
||||
// Mark form as dirty so the save button is enabled after reset
|
||||
this.blacklistSyncForm.markAsDirty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all controls in a form group as touched
|
||||
*/
|
||||
private markFormGroupTouched(formGroup: FormGroup): void {
|
||||
Object.values(formGroup.controls).forEach((control) => {
|
||||
control.markAsTouched();
|
||||
|
||||
if ((control as any).controls) {
|
||||
this.markFormGroupTouched(control as FormGroup);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a form control has an error after it's been touched
|
||||
*/
|
||||
hasError(controlName: string, errorName: string): boolean {
|
||||
const control = this.blacklistSyncForm.get(controlName);
|
||||
// Check for errors on both enabled and disabled controls that have been touched
|
||||
return control ? control.hasError(errorName) : false;
|
||||
}
|
||||
}
|
||||
@@ -3,28 +3,29 @@
|
||||
<h1>Download Cleaner</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">Download Cleaner Configuration</h2>
|
||||
<span class="card-subtitle">Configure automatic download cleanup</span>
|
||||
<!-- Loading/Error State Component -->
|
||||
<app-loading-error-state
|
||||
*ngIf="downloadCleanerLoading() || downloadCleanerLoadError()"
|
||||
[loading]="downloadCleanerLoading()"
|
||||
[error]="downloadCleanerLoadError()"
|
||||
loadingMessage="Loading settings..."
|
||||
errorMessage="Could not connect to server"
|
||||
></app-loading-error-state>
|
||||
|
||||
<!-- Settings Form -->
|
||||
<form *ngIf="!downloadCleanerLoading() && !downloadCleanerLoadError()" [formGroup]="downloadCleanerForm" class="p-fluid">
|
||||
|
||||
<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">Download Cleaner Configuration</h2>
|
||||
<span class="card-subtitle">Configure automatic download cleanup</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</ng-template>
|
||||
|
||||
<div class="card-content">
|
||||
<!-- Loading/Error State Component -->
|
||||
<app-loading-error-state
|
||||
*ngIf="downloadCleanerLoading() || downloadCleanerLoadError()"
|
||||
[loading]="downloadCleanerLoading()"
|
||||
[error]="downloadCleanerLoadError()"
|
||||
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="!downloadCleanerLoading() && !downloadCleanerLoadError()" [formGroup]="downloadCleanerForm" class="p-fluid">
|
||||
<div class="card-content">
|
||||
<!-- Main Settings -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
@@ -109,6 +110,35 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ignored Downloads Field -->
|
||||
<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="dc-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 by the download cleaner</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detailed Settings in Accordion -->
|
||||
<p-accordion [multiple]="false" [value]="activeAccordionIndices" styleClass="mt-3">
|
||||
<!-- Seeding Settings -->
|
||||
@@ -333,8 +363,6 @@
|
||||
multiple
|
||||
fluid
|
||||
[typeahead]="false"
|
||||
[suggestions]="unlinkedCategoriesSuggestions"
|
||||
(completeMethod)="onUnlinkedCategoriesComplete($event)"
|
||||
placeholder="Add category and press Enter"
|
||||
class="desktop-only"
|
||||
>
|
||||
@@ -369,11 +397,11 @@
|
||||
(click)="resetDownloadCleanerConfig()"
|
||||
></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</p-card>
|
||||
</div>
|
||||
</p-card>
|
||||
</form>
|
||||
|
||||
<!-- Confirmation Dialog -->
|
||||
<!-- Confirmation Dialog -->
|
||||
<p-confirmDialog
|
||||
[style]="{ width: '450px' }"
|
||||
[baseZIndex]="10000"
|
||||
|
||||
@@ -21,7 +21,6 @@ import { ButtonModule } from "primeng/button";
|
||||
import { InputNumberModule } from "primeng/inputnumber";
|
||||
import { AccordionModule } from "primeng/accordion";
|
||||
import { SelectButtonModule } from "primeng/selectbutton";
|
||||
import { ChipsModule } from "primeng/chips";
|
||||
import { ToastModule } from "primeng/toast";
|
||||
import { NotificationService } from "../../core/services/notification.service";
|
||||
import { SelectModule } from "primeng/select";
|
||||
@@ -47,7 +46,6 @@ import { DocumentationService } from "../../core/services/documentation.service"
|
||||
InputNumberModule,
|
||||
AccordionModule,
|
||||
SelectButtonModule,
|
||||
ChipsModule,
|
||||
ToastModule,
|
||||
SelectModule,
|
||||
AutoCompleteModule,
|
||||
@@ -93,9 +91,6 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
|
||||
// Flag to track if form has been initially loaded to avoid showing dialog on page load
|
||||
private formInitialized = false;
|
||||
|
||||
// Minimal autocomplete support - empty suggestions to allow manual input
|
||||
unlinkedCategoriesSuggestions: string[] = [];
|
||||
|
||||
// Get the categories form array for easier access in the template
|
||||
get categoriesFormArray(): FormArray {
|
||||
return this.downloadCleanerForm.get('categories') as FormArray;
|
||||
@@ -148,6 +143,7 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
|
||||
type: [{ value: ScheduleUnit.Minutes, disabled: true }, [Validators.required]]
|
||||
}),
|
||||
categories: this.formBuilder.array([]),
|
||||
ignoredDownloads: [{ value: [], disabled: true }],
|
||||
deletePrivate: [{ value: false, disabled: true }],
|
||||
unlinkedEnabled: [{ value: false, disabled: true }],
|
||||
unlinkedTargetCategory: [{ value: 'cleanuparr-unlinked', disabled: true }, [Validators.required]],
|
||||
@@ -290,6 +286,7 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
|
||||
useAdvancedScheduling: useAdvanced,
|
||||
cronExpression: config.cronExpression,
|
||||
deletePrivate: config.deletePrivate,
|
||||
ignoredDownloads: config.ignoredDownloads || [],
|
||||
unlinkedEnabled: config.unlinkedEnabled,
|
||||
unlinkedTargetCategory: config.unlinkedTargetCategory,
|
||||
unlinkedUseTag: config.unlinkedUseTag,
|
||||
@@ -500,11 +497,13 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
|
||||
const deletePrivateControl = this.downloadCleanerForm.get('deletePrivate');
|
||||
const unlinkedEnabledControl = this.downloadCleanerForm.get('unlinkedEnabled');
|
||||
const useAdvancedSchedulingControl = this.downloadCleanerForm.get('useAdvancedScheduling');
|
||||
const ignoredDownloadsControl = this.downloadCleanerForm.get('ignoredDownloads');
|
||||
|
||||
categoriesControl?.enable();
|
||||
deletePrivateControl?.enable();
|
||||
unlinkedEnabledControl?.enable();
|
||||
useAdvancedSchedulingControl?.enable();
|
||||
ignoredDownloadsControl?.enable();
|
||||
|
||||
// Update unlinked controls based on unlinkedEnabled value
|
||||
const unlinkedEnabled = unlinkedEnabledControl?.value;
|
||||
@@ -520,11 +519,13 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
|
||||
const deletePrivateControl = this.downloadCleanerForm.get('deletePrivate');
|
||||
const unlinkedEnabledControl = this.downloadCleanerForm.get('unlinkedEnabled');
|
||||
const useAdvancedSchedulingControl = this.downloadCleanerForm.get('useAdvancedScheduling');
|
||||
const ignoredDownloadsControl = this.downloadCleanerForm.get('ignoredDownloads');
|
||||
|
||||
categoriesControl?.disable();
|
||||
deletePrivateControl?.disable();
|
||||
unlinkedEnabledControl?.disable();
|
||||
useAdvancedSchedulingControl?.disable();
|
||||
ignoredDownloadsControl?.disable();
|
||||
|
||||
// Always disable unlinked controls when main feature is disabled
|
||||
this.updateUnlinkedControlsState(false);
|
||||
@@ -560,6 +561,7 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
|
||||
jobSchedule: formValues.jobSchedule,
|
||||
categories: formValues.categories,
|
||||
deletePrivate: formValues.deletePrivate,
|
||||
ignoredDownloads: formValues.ignoredDownloads || [],
|
||||
unlinkedEnabled: formValues.unlinkedEnabled,
|
||||
unlinkedTargetCategory: formValues.unlinkedTargetCategory,
|
||||
unlinkedUseTag: formValues.unlinkedUseTag,
|
||||
@@ -746,15 +748,6 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal complete method for autocomplete - just returns empty array to allow manual input
|
||||
*/
|
||||
onUnlinkedCategoriesComplete(event: any): void {
|
||||
// Return empty array - this allows users to type any value manually
|
||||
// PrimeNG requires this method even when we don't want suggestions
|
||||
this.unlinkedCategoriesSuggestions = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Show confirmation dialog when enabling the download cleaner
|
||||
*/
|
||||
|
||||
@@ -29,59 +29,67 @@
|
||||
</ng-template>
|
||||
|
||||
<!-- Empty state when no clients -->
|
||||
<div *ngIf="clients.length === 0" class="empty-instances-message p-3 text-center">
|
||||
<i class="pi pi-inbox empty-icon"></i>
|
||||
<p>No download clients defined. Add a client to start.</p>
|
||||
<div *ngIf="clients.length === 0" class="no-items text-center py-5 text-color-secondary">
|
||||
<i class="pi pi-download text-4xl mb-3"></i>
|
||||
<p class="m-0">No download clients configured</p>
|
||||
<small>Add a client to start downloading</small>
|
||||
</div>
|
||||
|
||||
<!-- Clients List -->
|
||||
<div *ngIf="clients.length > 0" class="instances-list">
|
||||
<div *ngFor="let client of clients" class="instance-item">
|
||||
<div class="instance-header">
|
||||
<div class="instance-title">
|
||||
<i class="pi pi-download instance-icon"></i>
|
||||
<span class="instance-name">{{ client.name }}</span>
|
||||
<div *ngIf="clients.length > 0" class="items-list">
|
||||
<div
|
||||
*ngFor="let client of clients"
|
||||
class="item-item p-3 border-round surface-border border-1 mb-3"
|
||||
[ngClass]="{ 'enabled': client.enabled }"
|
||||
>
|
||||
<div class="flex justify-content-between align-items-center">
|
||||
<div class="item-info flex-1">
|
||||
<div>
|
||||
<div class="flex align-items-center mb-2">
|
||||
<h4 class="m-0 mr-2">{{ client.name }}</h4>
|
||||
<p-tag
|
||||
[value]="getClientTypeLabel(client)"
|
||||
[severity]="'info'"
|
||||
class="mr-2"
|
||||
></p-tag>
|
||||
<p-tag
|
||||
[value]="client.enabled ? 'Enabled' : 'Disabled'"
|
||||
[severity]="client.enabled ? 'success' : 'secondary'"
|
||||
></p-tag>
|
||||
</div>
|
||||
<div class="item-details client-details text-sm text-color-secondary">
|
||||
<div *ngIf="client.host">
|
||||
<label>Host: {{ client.host }}</label>
|
||||
</div>
|
||||
<div *ngIf="client.urlBase">
|
||||
<label>URL Base: {{ client.urlBase }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="instance-actions">
|
||||
<div class="item-actions">
|
||||
<button
|
||||
pButton
|
||||
type="button"
|
||||
icon="pi pi-pencil"
|
||||
class="p-button-text p-button-sm"
|
||||
class="p-button-rounded p-button-text p-button-sm mr-1"
|
||||
pTooltip="Edit client"
|
||||
tooltipPosition="left"
|
||||
[disabled]="downloadClientSaving()"
|
||||
(click)="openEditClientModal(client)"
|
||||
pTooltip="Edit client"
|
||||
></button>
|
||||
<button
|
||||
pButton
|
||||
type="button"
|
||||
icon="pi pi-trash"
|
||||
class="p-button-text p-button-sm p-button-danger"
|
||||
class="p-button-rounded p-button-text p-button-danger p-button-sm"
|
||||
pTooltip="Delete client"
|
||||
tooltipPosition="left"
|
||||
[disabled]="downloadClientSaving()"
|
||||
(click)="deleteClient(client)"
|
||||
pTooltip="Delete client"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="instance-content">
|
||||
<div class="instance-field">
|
||||
<label>Type: {{ getClientTypeLabel(client) }}</label>
|
||||
</div>
|
||||
<div class="instance-field" *ngIf="client.host">
|
||||
<label>Host: {{ client.host }}</label>
|
||||
</div>
|
||||
<div class="instance-field" *ngIf="client.urlBase">
|
||||
<label>URL Base: {{ client.urlBase }}</label>
|
||||
</div>
|
||||
<div class="instance-field">
|
||||
<label>Status:
|
||||
<span [class]="client.enabled ? 'text-green-500' : 'text-red-500'">
|
||||
{{ client.enabled ? 'Enabled' : 'Disabled' }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,3 +1,17 @@
|
||||
@use '../styles/settings-shared.scss';
|
||||
@use '../styles/arr-shared.scss';
|
||||
@use '../settings-page/settings-page.component.scss';
|
||||
@use '../styles/item-list-styles.scss';
|
||||
@use '../settings-page/settings-page.component.scss';
|
||||
|
||||
/* Override item details styling for download clients to match original format */
|
||||
.item-details {
|
||||
div {
|
||||
margin: 0;
|
||||
|
||||
label {
|
||||
font-weight: 500;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { SelectModule } from 'primeng/select';
|
||||
import { ToastModule } from "primeng/toast";
|
||||
import { DialogModule } from "primeng/dialog";
|
||||
import { ConfirmDialogModule } from "primeng/confirmdialog";
|
||||
import { TagModule } from "primeng/tag";
|
||||
import { ConfirmationService } from "primeng/api";
|
||||
import { NotificationService } from "../../core/services/notification.service";
|
||||
import { LoadingErrorStateComponent } from "../../shared/components/loading-error-state/loading-error-state.component";
|
||||
@@ -38,6 +39,7 @@ import { UrlValidators } from "../../core/validators/url.validator";
|
||||
ToastModule,
|
||||
DialogModule,
|
||||
ConfirmDialogModule,
|
||||
TagModule,
|
||||
LoadingErrorStateComponent
|
||||
],
|
||||
providers: [DownloadClientConfigStore, ConfirmationService],
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
></app-loading-error-state>
|
||||
|
||||
<!-- Settings Form -->
|
||||
<form *ngIf="!generalLoading() && !generalLoadError()" [formGroup]="generalForm" class="p-fluid">
|
||||
<form *ngIf="!generalLoading() && !generalLoadError() && generalForm" [formGroup]="generalForm" class="p-fluid">
|
||||
|
||||
<!-- General Configuration Card -->
|
||||
<p-card styleClass="settings-card mb-4">
|
||||
@@ -194,6 +194,7 @@
|
||||
<small class="form-helper-text">Downloads matching these patterns will be ignored (e.g. hash, tag, category, label, tracker)</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</p-card>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, EventEmitter, OnDestroy, Output, effect, inject } from "@angular/core";
|
||||
import { Component, EventEmitter, OnInit, OnDestroy, Output, effect, inject } from "@angular/core";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
|
||||
import { Subject, takeUntil } from "rxjs";
|
||||
@@ -136,7 +136,6 @@ export class GeneralSettingsComponent implements OnDestroy, CanComponentDeactiva
|
||||
searchEnabled: [true],
|
||||
searchDelay: [30, [Validators.required, Validators.min(1), Validators.max(300)]],
|
||||
ignoredDownloads: [[]],
|
||||
// Nested logging configuration form group
|
||||
log: this.formBuilder.group({
|
||||
level: [LogEventLevel.Information],
|
||||
rollingSizeMB: [10, [Validators.required, Validators.min(0), Validators.max(100)]],
|
||||
@@ -146,8 +145,6 @@ export class GeneralSettingsComponent implements OnDestroy, CanComponentDeactiva
|
||||
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
|
||||
@@ -164,9 +161,8 @@ export class GeneralSettingsComponent implements OnDestroy, CanComponentDeactiva
|
||||
searchEnabled: config.searchEnabled,
|
||||
searchDelay: config.searchDelay,
|
||||
ignoredDownloads: config.ignoredDownloads || [],
|
||||
// New nested logging configuration
|
||||
log: config.log || {
|
||||
level: config.logLevel || LogEventLevel.Information, // Fall back to old property
|
||||
level: LogEventLevel.Information,
|
||||
rollingSizeMB: 10,
|
||||
retainedFileCount: 5,
|
||||
timeLimitHours: 24,
|
||||
@@ -174,8 +170,6 @@ export class GeneralSettingsComponent implements OnDestroy, CanComponentDeactiva
|
||||
archiveRetainedCount: 60,
|
||||
archiveTimeLimitHours: 720,
|
||||
},
|
||||
// Temporary backward compatibility
|
||||
logLevel: config.logLevel || config.log?.level || LogEventLevel.Information,
|
||||
});
|
||||
|
||||
// Store original values for dirty checking
|
||||
@@ -439,10 +433,7 @@ export class GeneralSettingsComponent implements OnDestroy, CanComponentDeactiva
|
||||
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
|
||||
@@ -488,7 +479,6 @@ export class GeneralSettingsComponent implements OnDestroy, CanComponentDeactiva
|
||||
searchEnabled: true,
|
||||
searchDelay: 30,
|
||||
ignoredDownloads: [],
|
||||
// Reset nested logging configuration to defaults
|
||||
log: {
|
||||
level: LogEventLevel.Information,
|
||||
rollingSizeMB: 10,
|
||||
@@ -498,8 +488,6 @@ export class GeneralSettingsComponent implements OnDestroy, CanComponentDeactiva
|
||||
archiveRetainedCount: 60,
|
||||
archiveTimeLimitHours: 720,
|
||||
},
|
||||
// Temporary backward compatibility
|
||||
logLevel: LogEventLevel.Information,
|
||||
});
|
||||
|
||||
// Update archive controls state after reset
|
||||
@@ -528,7 +516,7 @@ export class GeneralSettingsComponent implements OnDestroy, CanComponentDeactiva
|
||||
hasError(controlName: string, errorName: string): boolean {
|
||||
const control = this.generalForm.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;
|
||||
return control ? control.hasError(errorName) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -542,7 +530,7 @@ export class GeneralSettingsComponent implements OnDestroy, CanComponentDeactiva
|
||||
|
||||
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;
|
||||
return control ? control.hasError(errorName) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -77,54 +77,64 @@
|
||||
</ng-template>
|
||||
|
||||
<!-- Empty state when no instances -->
|
||||
<div *ngIf="instances.length === 0" class="empty-instances-message p-3 text-center">
|
||||
<i class="pi pi-inbox empty-icon"></i>
|
||||
<p>No Lidarr instances configured</p>
|
||||
<div *ngIf="instances.length === 0" class="no-items text-center py-5 text-color-secondary">
|
||||
<i class="pi pi-server text-4xl mb-3"></i>
|
||||
<p class="m-0">No Lidarr instances configured</p>
|
||||
<small>Add an instance to start using Lidarr integration</small>
|
||||
</div>
|
||||
|
||||
<!-- Instances List -->
|
||||
<div *ngIf="instances.length > 0" class="instances-list">
|
||||
<div *ngFor="let instance of instances" class="instance-item">
|
||||
<div class="instance-header">
|
||||
<div class="instance-title">
|
||||
<i class="pi pi-server instance-icon"></i>
|
||||
<span class="instance-name">{{ instance.name }}</span>
|
||||
<div *ngIf="instances.length > 0" class="items-list">
|
||||
<div
|
||||
*ngFor="let instance of instances"
|
||||
class="item-item p-3 border-round surface-border border-1 mb-3"
|
||||
[ngClass]="{ 'enabled': instance.enabled }"
|
||||
>
|
||||
<div class="flex justify-content-between align-items-center">
|
||||
<div class="item-info flex-1">
|
||||
<div>
|
||||
<div class="flex align-items-center mb-2">
|
||||
<h4 class="m-0 mr-2">{{ instance.name }}</h4>
|
||||
<p-tag
|
||||
value="Lidarr"
|
||||
[severity]="'info'"
|
||||
class="mr-2"
|
||||
></p-tag>
|
||||
<p-tag
|
||||
[value]="instance.enabled ? 'Enabled' : 'Disabled'"
|
||||
[severity]="instance.enabled ? 'success' : 'secondary'"
|
||||
></p-tag>
|
||||
</div>
|
||||
<div class="item-details client-details text-sm text-color-secondary">
|
||||
<div>
|
||||
<label>URL: {{ instance.url }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="instance-actions">
|
||||
<div class="item-actions">
|
||||
<button
|
||||
pButton
|
||||
type="button"
|
||||
icon="pi pi-pencil"
|
||||
class="p-button-text p-button-sm"
|
||||
class="p-button-rounded p-button-text p-button-sm mr-1"
|
||||
pTooltip="Edit instance"
|
||||
tooltipPosition="left"
|
||||
[disabled]="lidarrSaving()"
|
||||
(click)="openEditInstanceModal(instance)"
|
||||
pTooltip="Edit instance"
|
||||
></button>
|
||||
<button
|
||||
pButton
|
||||
type="button"
|
||||
icon="pi pi-trash"
|
||||
class="p-button-text p-button-sm p-button-danger"
|
||||
class="p-button-rounded p-button-text p-button-danger p-button-sm"
|
||||
pTooltip="Delete instance"
|
||||
tooltipPosition="left"
|
||||
[disabled]="lidarrSaving()"
|
||||
(click)="deleteInstance(instance)"
|
||||
pTooltip="Delete instance"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="instance-content">
|
||||
<div class="instance-field">
|
||||
<label>{{ instance.url }}</label>
|
||||
</div>
|
||||
<div class="instance-field">
|
||||
<label>Status:
|
||||
<span [class]="instance.enabled ? 'text-green-500' : 'text-red-500'">
|
||||
{{ instance.enabled ? 'Enabled' : 'Disabled' }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
|
||||
@use '../styles/settings-shared.scss';
|
||||
@use '../styles/arr-shared.scss';
|
||||
@use '../styles/item-list-styles.scss';
|
||||
@use '../settings-page/settings-page.component.scss';
|
||||