Compare commits

...

22 Commits

Author SHA1 Message Date
Flaminel
bb9ac5b67b Fix notifications migration when no event type is enabled (#290) 2025-09-03 21:12:55 +03:00
Flaminel
f93494adb2 Rework notifications system (#284) 2025-09-02 23:18:22 +03:00
Flaminel
7201520411 Add configurable log retention (#279) 2025-09-02 00:17:16 +03:00
Flaminel
2a1e65e1af Make sidebar scrollable (#285) 2025-09-02 00:16:38 +03:00
Flaminel
da318c3339 Fix HTTPS schema for Cloudflare pages links (#286) 2025-09-02 00:16:27 +03:00
Flaminel
7149b6243f Add .sql to the blacklist (#287) 2025-09-02 00:16:12 +03:00
Flaminel
11f5a28c04 Improve download client health checks (#288) 2025-09-02 00:15:09 +03:00
Flaminel
9cc36c7a50 Add qBittorrent basic auth support (#246) 2025-08-11 10:52:44 +03:00
Flaminel
861c135cc6 fixed Malware Blocker docs path 2025-08-07 11:55:46 +03:00
Flaminel
3b0275c411 Finish rebranding Content Blocker to Malware Blocker (#271) 2025-08-06 22:55:39 +03:00
Flaminel
cad1b51202 Improve logs and events ordering to be descending from the top (#270) 2025-08-06 22:51:20 +03:00
Flaminel
f50acd29f4 Disable MassTransit telemetry (#268) 2025-08-06 22:50:48 +03:00
LucasFA
af11d595d8 Fix detailed installation docs (#260)
https://cleanuparr.github.io/Cleanuparr/docs/installation/detailed
2025-08-06 22:49:14 +03:00
Flaminel
44994d5b21 Fix Notifiarr channel id input (#267) 2025-08-04 22:07:33 +03:00
Flaminel
592fd2d846 Fix Malware Blocker renaming issue (#259) 2025-08-02 15:54:26 +03:00
Flaminel
e96be1fca2 Small general fixes (#257)
* renamed ContentBlocker into MalwareBlocker in the logs

* fixed "Delete Private" input description
2025-08-02 11:36:47 +03:00
Flaminel
ee44e2b5ac Rework sidebar navigation (#255) 2025-08-02 05:31:25 +03:00
Flaminel
323bfc4d2e added major and minor tags for Docker images 2025-08-01 19:51:10 +03:00
Flaminel
dca45585ca General frontend improvements (#252) 2025-08-01 19:45:01 +03:00
Flaminel
8b5918d221 Improve malware detection for known malware (#251) 2025-08-01 19:33:35 +03:00
Flaminel
9c227c1f59 add Cloudflare static assets 2025-08-01 18:37:45 +03:00
Flaminel
2ad4499a6f Fix DownloadCleaner failing when using multiple download clients (#248) 2025-07-31 22:20:01 +03:00
163 changed files with 9657 additions and 2773 deletions

View File

@@ -29,6 +29,8 @@ jobs:
githubHeadRef=${{ env.githubHeadRef }}
latestDockerTag=""
versionDockerTag=""
majorVersionDockerTag=""
minorVersionDockerTag=""
version="0.0.1"
if [[ "$githubRef" =~ ^"refs/tags/" ]]; then
@@ -36,6 +38,12 @@ jobs:
latestDockerTag="latest"
versionDockerTag=${branch#v}
version=${branch#v}
# Extract major and minor versions for additional tags
if [[ "$versionDockerTag" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then
majorVersionDockerTag="${BASH_REMATCH[1]}"
minorVersionDockerTag="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
fi
else
# Determine if this run is for the main branch or another branch
if [[ -z "$githubHeadRef" ]]; then
@@ -58,6 +66,12 @@ jobs:
if [ -n "$versionDockerTag" ]; then
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$versionDockerTag"
fi
if [ -n "$minorVersionDockerTag" ]; then
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$minorVersionDockerTag"
fi
if [ -n "$majorVersionDockerTag" ]; then
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$majorVersionDockerTag"
fi
# set env vars
echo "branch=$branch" >> $GITHUB_ENV

36
.github/workflows/cloudflare-pages.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
name: Deploy to Cloudflare Pages
on:
push:
branches:
- main
paths:
- 'Cloudflare/**'
- 'blacklist'
- 'blacklist_permissive'
- 'whitelist'
- 'whitelist_with_subtitles'
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
name: Deploy to Cloudflare Pages
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Copy root static files to Cloudflare static directory
run: |
cp blacklist Cloudflare/static/
cp blacklist_permissive Cloudflare/static/
cp whitelist Cloudflare/static/
cp whitelist_with_subtitles Cloudflare/static/
- name: Deploy to Cloudflare Pages
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }}
workingDirectory: "Cloudflare"
command: pages deploy . --project-name=cleanuparr

3
Cloudflare/_headers Normal file
View File

@@ -0,0 +1,3 @@
# Cache static files for 5 minutes
/static/*
Cache-Control: public, max-age=300, s-maxage=300

View File

@@ -0,0 +1,2 @@
thepirateheaven.org
RARBG.work

View File

@@ -15,7 +15,8 @@ Cleanuparr was created primarily to address malicious files, such as `*.lnk` or
> - Remove and block downloads that are **failing to be imported** by the arrs.
> - Remove and block downloads that are **stalled** or in **metadata downloading** state.
> - Remove and block downloads that have a **low download speed** or **high estimated completion time**.
> - Remove and block downloads blocked by qBittorrent or by Cleanuparr's **Content Blocker**.
> - Remove and block downloads blocked by qBittorrent or by Cleanuparr's **Malware Blocker**.
> - Remove and block known malware based on patterns found by the community.
> - Automatically trigger a search for downloads removed from the arrs.
> - Clean up downloads that have been **seeding** for a certain amount of time.
> - Remove downloads that are **orphaned**/have no **hardlinks**/are not referenced by the arrs anymore (with [cross-seed](https://www.cross-seed.org/) support).

View File

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

View File

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

View File

@@ -87,10 +87,6 @@ public class EventsController : ControllerBase
.Take(pageSize)
.ToListAsync();
events = events
.OrderBy(e => e.Timestamp)
.ToList();
// Return paginated result
var result = new PaginatedResult<AppEvent>
{

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,8 @@
using Cleanuparr.Application.Features.ContentBlocker;
using Cleanuparr.Application.Features.DownloadCleaner;
using Cleanuparr.Application.Features.MalwareBlocker;
using Cleanuparr.Application.Features.QueueCleaner;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.ContentBlocker;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.DownloadHunter;
using Cleanuparr.Infrastructure.Features.DownloadHunter.Interfaces;
@@ -11,6 +10,7 @@ using Cleanuparr.Infrastructure.Features.DownloadRemover;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces;
using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Features.Security;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Services;
@@ -40,7 +40,7 @@ public static class ServicesDI
.AddScoped<WhisparrClient>()
.AddScoped<ArrClientFactory>()
.AddScoped<QueueCleaner>()
.AddScoped<ContentBlocker>()
.AddScoped<MalwareBlocker>()
.AddScoped<DownloadCleaner>()
.AddScoped<IQueueItemRemover, QueueItemRemover>()
.AddScoped<IDownloadHunter, DownloadHunter>()

View File

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

View File

@@ -1,12 +1,12 @@
using Cleanuparr.Application.Features.ContentBlocker;
using Cleanuparr.Application.Features.DownloadCleaner;
using Cleanuparr.Application.Features.MalwareBlocker;
using Cleanuparr.Application.Features.QueueCleaner;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Cleanuparr.Shared.Helpers;
using Microsoft.EntityFrameworkCore;
@@ -94,7 +94,7 @@ public class BackgroundJobManager : IHostedService
QueueCleanerConfig queueCleanerConfig = await dataContext.QueueCleanerConfigs
.AsNoTracking()
.FirstAsync(cancellationToken);
ContentBlockerConfig contentBlockerConfig = await dataContext.ContentBlockerConfigs
ContentBlockerConfig malwareBlockerConfig = await dataContext.ContentBlockerConfigs
.AsNoTracking()
.FirstAsync(cancellationToken);
DownloadCleanerConfig downloadCleanerConfig = await dataContext.DownloadCleanerConfigs
@@ -103,7 +103,7 @@ public class BackgroundJobManager : IHostedService
// Always register jobs, regardless of enabled status
await RegisterQueueCleanerJob(queueCleanerConfig, cancellationToken);
await RegisterContentBlockerJob(contentBlockerConfig, cancellationToken);
await RegisterMalwareBlockerJob(malwareBlockerConfig, cancellationToken);
await RegisterDownloadCleanerJob(downloadCleanerConfig, cancellationToken);
}
@@ -127,17 +127,17 @@ public class BackgroundJobManager : IHostedService
/// <summary>
/// Registers the QueueCleaner job and optionally adds triggers based on configuration.
/// </summary>
public async Task RegisterContentBlockerJob(
public async Task RegisterMalwareBlockerJob(
ContentBlockerConfig config,
CancellationToken cancellationToken = default)
{
// Always register the job definition
await AddJobWithoutTrigger<ContentBlocker>(cancellationToken);
await AddJobWithoutTrigger<MalwareBlocker>(cancellationToken);
// Only add triggers if the job is enabled
if (config.Enabled)
{
await AddTriggersForJob<ContentBlocker>(config, config.CronExpression, cancellationToken);
await AddTriggersForJob<MalwareBlocker>(config, config.CronExpression, cancellationToken);
}
}
@@ -190,7 +190,7 @@ public class BackgroundJobManager : IHostedService
throw new ValidationException($"{cronExpression} should have a fire time of maximum {Constants.TriggerMaxLimit.TotalHours} hours");
}
if (typeof(T) != typeof(ContentBlocker) && triggerValue < Constants.TriggerMinLimit)
if (typeof(T) != typeof(MalwareBlocker) && triggerValue < Constants.TriggerMinLimit)
{
throw new ValidationException($"{cronExpression} should have a fire time of minimum {Constants.TriggerMinLimit.TotalSeconds} seconds");
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -61,8 +61,8 @@ public sealed class DownloadCleaner : GenericHandler
IReadOnlyList<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads;
// Process each client separately
var allDownloads = new List<object>();
var downloadServiceToDownloadsMap = new Dictionary<IDownloadService, List<object>>();
foreach (var downloadService in downloadServices)
{
try
@@ -71,24 +71,24 @@ public sealed class DownloadCleaner : GenericHandler
var clientDownloads = await downloadService.GetSeedingDownloads();
if (clientDownloads?.Count > 0)
{
allDownloads.AddRange(clientDownloads);
downloadServiceToDownloadsMap[downloadService] = clientDownloads;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get seeding downloads from download client");
_logger.LogError(ex, "Failed to get seeding downloads from download client {clientName}", downloadService.ClientConfig.Name);
}
}
if (allDownloads.Count == 0)
if (downloadServiceToDownloadsMap.Count == 0)
{
_logger.LogDebug("no seeding downloads found");
return;
}
_logger.LogTrace("found {count} seeding downloads", allDownloads.Count);
var totalDownloads = downloadServiceToDownloadsMap.Values.Sum(x => x.Count);
_logger.LogTrace("found {count} seeding downloads across {clientCount} clients", totalDownloads, downloadServiceToDownloadsMap.Count);
// List<object>? downloadsToChangeCategory = null;
List<Tuple<IDownloadService, List<object>>> downloadServiceWithDownloads = [];
if (isUnlinkedEnabled)
@@ -102,24 +102,23 @@ public sealed class DownloadCleaner : GenericHandler
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create category for download client");
_logger.LogError(ex, "Failed to create category for download client {clientName}", downloadService.ClientConfig.Name);
}
}
// Get downloads to change category
foreach (var downloadService in downloadServices)
foreach (var (downloadService, clientDownloads) in downloadServiceToDownloadsMap)
{
try
{
var clientDownloads = downloadService.FilterDownloadsToChangeCategoryAsync(allDownloads, config.UnlinkedCategories);
if (clientDownloads?.Count > 0)
var downloadsToChangeCategory = downloadService.FilterDownloadsToChangeCategoryAsync(clientDownloads, config.UnlinkedCategories);
if (downloadsToChangeCategory?.Count > 0)
{
downloadServiceWithDownloads.Add(Tuple.Create(downloadService, clientDownloads));
downloadServiceWithDownloads.Add(Tuple.Create(downloadService, downloadsToChangeCategory));
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to filter downloads for category change");
_logger.LogError(ex, "Failed to filter downloads for category change for download client {clientName}", downloadService.ClientConfig.Name);
}
}
}
@@ -158,16 +157,15 @@ public sealed class DownloadCleaner : GenericHandler
return;
}
// Get downloads to clean
downloadServiceWithDownloads = [];
foreach (var downloadService in downloadServices)
foreach (var (downloadService, clientDownloads) in downloadServiceToDownloadsMap)
{
try
{
var clientDownloads = downloadService.FilterDownloadsToBeCleanedAsync(allDownloads, config.Categories);
if (clientDownloads?.Count > 0)
var downloadsToClean = downloadService.FilterDownloadsToBeCleanedAsync(clientDownloads, config.Categories);
if (downloadsToClean?.Count > 0)
{
downloadServiceWithDownloads.Add(Tuple.Create(downloadService, clientDownloads));
downloadServiceWithDownloads.Add(Tuple.Create(downloadService, downloadsToClean));
}
}
catch (Exception ex)
@@ -176,9 +174,6 @@ public sealed class DownloadCleaner : GenericHandler
}
}
// release unused objects
allDownloads = null;
_logger.LogInformation("found {count} potential downloads to clean", downloadServiceWithDownloads.Sum(x => x.Item2.Count));
// Process cleaning for each client

View File

@@ -3,29 +3,29 @@ using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.ContentBlocker;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
using Cleanuparr.Persistence.Models.Configuration.General;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using MassTransit;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using LogContext = Serilog.Context.LogContext;
namespace Cleanuparr.Application.Features.ContentBlocker;
namespace Cleanuparr.Application.Features.MalwareBlocker;
public sealed class ContentBlocker : GenericHandler
public sealed class MalwareBlocker : GenericHandler
{
private readonly BlocklistProvider _blocklistProvider;
public ContentBlocker(
ILogger<ContentBlocker> logger,
public MalwareBlocker(
ILogger<MalwareBlocker> logger,
DataContext dataContext,
IMemoryCache cache,
IBus messageBus,
@@ -66,27 +66,27 @@ public sealed class ContentBlocker : GenericHandler
var readarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Readarr));
var whisparrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Whisparr));
if (config.Sonarr.Enabled)
if (config.Sonarr.Enabled || config.DeleteKnownMalware)
{
await ProcessArrConfigAsync(sonarrConfig, InstanceType.Sonarr);
}
if (config.Radarr.Enabled)
if (config.Radarr.Enabled || config.DeleteKnownMalware)
{
await ProcessArrConfigAsync(radarrConfig, InstanceType.Radarr);
}
if (config.Lidarr.Enabled)
if (config.Lidarr.Enabled || config.DeleteKnownMalware)
{
await ProcessArrConfigAsync(lidarrConfig, InstanceType.Lidarr);
}
if (config.Readarr.Enabled)
if (config.Readarr.Enabled || config.DeleteKnownMalware)
{
await ProcessArrConfigAsync(readarrConfig, InstanceType.Readarr);
}
if (config.Whisparr.Enabled)
if (config.Whisparr.Enabled || config.DeleteKnownMalware)
{
await ProcessArrConfigAsync(whisparrConfig, InstanceType.Whisparr);
}

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
using Cleanuparr.Infrastructure.Features.ContentBlocker;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Microsoft.Extensions.Logging;
using NSubstitute;
namespace Cleanuparr.Infrastructure.Tests.Verticals.ContentBlocker;
namespace Cleanuparr.Infrastructure.Tests.Verticals.MalwareBlocker;
public class FilenameEvaluatorFixture
{

View File

@@ -4,7 +4,7 @@ using Cleanuparr.Domain.Enums;
using Shouldly;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Verticals.ContentBlocker;
namespace Cleanuparr.Infrastructure.Tests.Verticals.MalwareBlocker;
public class FilenameEvaluatorTests : IClassFixture<FilenameEvaluatorFixture>
{

View File

@@ -7,7 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FLM.QBittorrent" Version="1.0.1" />
<PackageReference Include="FLM.QBittorrent" Version="1.0.2" />
<PackageReference Include="FLM.Transmission" Version="1.0.3" />
<PackageReference Include="Mapster" Version="7.4.0" />
<PackageReference Include="MassTransit.Abstractions" Version="8.4.1" />
@@ -18,6 +18,7 @@
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.6" />
<PackageReference Include="Mono.Unix" Version="7.1.0-final.1.21458.1" />
<PackageReference Include="Quartz" Version="3.14.0" />
<PackageReference Include="Serilog.Expressions" Version="5.0.0" />
</ItemGroup>
<ItemGroup>

View File

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

View File

@@ -1,9 +1,9 @@
using Cleanuparr.Domain.Entities.Deluge.Response;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.ContentBlocker;
using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Persistence.Models.Configuration;
using Infrastructure.Interceptors;

View File

@@ -4,7 +4,7 @@ using Cleanuparr.Domain.Entities.Deluge.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Extensions;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
@@ -35,9 +35,9 @@ public partial class DelugeService
result.IsPrivate = download.Private;
var contentBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
var malwareBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
if (contentBlockerConfig.IgnorePrivate && download.Private)
if (malwareBlockerConfig.IgnorePrivate && download.Private)
{
// ignore private trackers
_logger.LogDebug("skip files check | download is private | {name}", download.Name);
@@ -69,6 +69,7 @@ public partial class DelugeService
BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType);
ConcurrentBag<string> patterns = _blocklistProvider.GetPatterns(instanceType);
ConcurrentBag<Regex> regexes = _blocklistProvider.GetRegexes(instanceType);
ConcurrentBag<string> malwarePatterns = _blocklistProvider.GetMalwarePatterns();
ProcessFiles(contents.Contents, (name, file) =>
{
@@ -80,7 +81,7 @@ public partial class DelugeService
return;
}
if (IsDefinitelyMalware(name))
if (malwareBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(name, malwarePatterns))
{
_logger.LogInformation("malware file found | {file} | {title}", file.Path, download.Name);
result.ShouldRemove = true;

View File

@@ -2,10 +2,10 @@ using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.Cache;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.ContentBlocker;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Persistence.Models.Configuration;
@@ -100,16 +100,6 @@ public abstract class DownloadService : IDownloadService
/// <inheritdoc/>
public abstract Task<BlockFilesResult> BlockUnwantedFilesAsync(string hash, IReadOnlyList<string> ignoredDownloads);
protected bool IsDefinitelyMalware(string filename)
{
if (filename.Contains("thepirateheaven.org", StringComparison.InvariantCultureIgnoreCase))
{
return true;
}
return false;
}
protected void ResetStalledStrikesOnProgress(string hash, long downloaded)
{

View File

@@ -1,8 +1,8 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.ContentBlocker;
using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Persistence.Models.Configuration;
using Infrastructure.Interceptors;

View File

@@ -1,7 +1,7 @@
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.ContentBlocker;
using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Persistence.Models.Configuration;
using Infrastructure.Interceptors;

View File

@@ -3,7 +3,7 @@ using System.Text.RegularExpressions;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Extensions;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Microsoft.Extensions.Logging;
using QBittorrent.Client;
@@ -49,9 +49,9 @@ public partial class QBitService
result.IsPrivate = isPrivate;
var contentBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
var malwareBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
if (contentBlockerConfig.IgnorePrivate && isPrivate)
if (malwareBlockerConfig.IgnorePrivate && isPrivate)
{
// ignore private trackers
_logger.LogDebug("skip files check | download is private | {name}", download.Name);
@@ -74,6 +74,7 @@ public partial class QBitService
BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType);
ConcurrentBag<string> patterns = _blocklistProvider.GetPatterns(instanceType);
ConcurrentBag<Regex> regexes = _blocklistProvider.GetRegexes(instanceType);
ConcurrentBag<string> malwarePatterns = _blocklistProvider.GetMalwarePatterns();
foreach (TorrentContent file in files)
{
@@ -85,7 +86,7 @@ public partial class QBitService
totalFiles++;
if (IsDefinitelyMalware(file.Name))
if (malwareBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(file.Name, malwarePatterns))
{
_logger.LogInformation("malware file found | {file} | {title}", file.Name, download.Name);
result.ShouldRemove = true;

View File

@@ -1,7 +1,7 @@
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.ContentBlocker;
using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Persistence.Models.Configuration;
using Infrastructure.Interceptors;

View File

@@ -3,7 +3,7 @@ using System.Text.RegularExpressions;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Extensions;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Microsoft.Extensions.Logging;
using Transmission.API.RPC.Entity;
@@ -40,9 +40,9 @@ public partial class TransmissionService
bool isPrivate = download.IsPrivate ?? false;
result.IsPrivate = isPrivate;
var contentBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
var malwareBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
if (contentBlockerConfig.IgnorePrivate && isPrivate)
if (malwareBlockerConfig.IgnorePrivate && isPrivate)
{
// ignore private trackers
_logger.LogDebug("skip files check | download is private | {name}", download.Name);
@@ -57,6 +57,7 @@ public partial class TransmissionService
BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType);
ConcurrentBag<string> patterns = _blocklistProvider.GetPatterns(instanceType);
ConcurrentBag<Regex> regexes = _blocklistProvider.GetRegexes(instanceType);
ConcurrentBag<string> malwarePatterns = _blocklistProvider.GetMalwarePatterns();
for (int i = 0; i < download.Files.Length; i++)
{
@@ -68,7 +69,7 @@ public partial class TransmissionService
totalFiles++;
if (IsDefinitelyMalware(download.Files[i].Name))
if (malwareBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(download.Files[i].Name, malwarePatterns))
{
_logger.LogInformation("malware file found | {file} | {title}", download.Files[i].Name, download.Name);
result.ShouldRemove = true;

View File

@@ -1,7 +1,7 @@
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.ContentBlocker;
using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Persistence.Models.Configuration;
using Infrastructure.Interceptors;

View File

@@ -5,7 +5,7 @@ using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Extensions;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent.Extensions;
using Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
@@ -38,9 +38,9 @@ public partial class UTorrentService
return result;
}
var contentBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
var malwareBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
if (contentBlockerConfig.IgnorePrivate && result.IsPrivate)
if (malwareBlockerConfig.IgnorePrivate && result.IsPrivate)
{
// ignore private trackers
_logger.LogDebug("skip files check | download is private | {name}", download.Name);
@@ -62,10 +62,11 @@ public partial class UTorrentService
BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType);
ConcurrentBag<string> patterns = _blocklistProvider.GetPatterns(instanceType);
ConcurrentBag<Regex> regexes = _blocklistProvider.GetRegexes(instanceType);
ConcurrentBag<string> malwarePatterns = _blocklistProvider.GetMalwarePatterns();
for (int i = 0; i < files.Count; i++)
{
if (IsDefinitelyMalware(files[i].Name))
if (malwareBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(files[i].Name, malwarePatterns))
{
_logger.LogInformation("malware file found | {file} | {title}", files[i].Name, download.Name);
result.ShouldRemove = true;

View File

@@ -9,9 +9,9 @@ using Cleanuparr.Infrastructure.Features.DownloadRemover.Models;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Persistence.Models.Configuration.General;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Data.Models.Arr;
using MassTransit;

View File

@@ -6,15 +6,14 @@ using System.Text.RegularExpressions;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Cleanuparr.Shared.Helpers;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.ContentBlocker;
namespace Cleanuparr.Infrastructure.Features.MalwareBlocker;
public sealed class BlocklistProvider
{
@@ -23,8 +22,11 @@ public sealed class BlocklistProvider
private readonly HttpClient _httpClient;
private readonly IMemoryCache _cache;
private readonly Dictionary<InstanceType, string> _configHashes = new();
private static DateTime _lastLoadTime = DateTime.MinValue;
private const int LoadIntervalHours = 4;
private readonly Dictionary<string, DateTime> _lastLoadTimes = new();
private const int DefaultLoadIntervalHours = 4;
private const int FastLoadIntervalMinutes = 5;
private const string MalwareListUrl = "https://cleanuparr.pages.dev/static/known_malware_file_name_patterns";
private const string MalwareListKey = "MALWARE_PATTERNS";
public BlocklistProvider(
ILogger<BlocklistProvider> logger,
@@ -46,78 +48,89 @@ public sealed class BlocklistProvider
await using var scope = _scopeFactory.CreateAsyncScope();
await using var dataContext = scope.ServiceProvider.GetRequiredService<DataContext>();
int changedCount = 0;
var contentBlockerConfig = await dataContext.ContentBlockerConfigs
var malwareBlockerConfig = await dataContext.ContentBlockerConfigs
.AsNoTracking()
.FirstAsync();
bool shouldReload = false;
if (_lastLoadTime.AddHours(LoadIntervalHours) < DateTime.UtcNow)
{
shouldReload = true;
_lastLoadTime = DateTime.UtcNow;
}
if (!contentBlockerConfig.Enabled)
if (!malwareBlockerConfig.Enabled)
{
_logger.LogDebug("Content blocker is disabled, skipping blocklist loading");
_logger.LogDebug("Malware Blocker is disabled, skipping blocklist loading");
return;
}
// Check and update Sonarr blocklist if needed
string sonarrHash = GenerateSettingsHash(contentBlockerConfig.Sonarr);
if (shouldReload || !_configHashes.TryGetValue(InstanceType.Sonarr, out string? oldSonarrHash) || sonarrHash != oldSonarrHash)
string sonarrHash = GenerateSettingsHash(malwareBlockerConfig.Sonarr);
var sonarrInterval = GetLoadInterval(malwareBlockerConfig.Sonarr.BlocklistPath);
var sonarrIdentifier = $"Sonarr_{malwareBlockerConfig.Sonarr.BlocklistPath}";
if (ShouldReloadBlocklist(sonarrIdentifier, sonarrInterval) || !_configHashes.TryGetValue(InstanceType.Sonarr, out string? oldSonarrHash) || sonarrHash != oldSonarrHash)
{
_logger.LogDebug("Loading Sonarr blocklist");
await LoadPatternsAndRegexesAsync(contentBlockerConfig.Sonarr, InstanceType.Sonarr);
await LoadPatternsAndRegexesAsync(malwareBlockerConfig.Sonarr, InstanceType.Sonarr);
_configHashes[InstanceType.Sonarr] = sonarrHash;
_lastLoadTimes[sonarrIdentifier] = DateTime.UtcNow;
changedCount++;
}
// Check and update Radarr blocklist if needed
string radarrHash = GenerateSettingsHash(contentBlockerConfig.Radarr);
if (shouldReload || !_configHashes.TryGetValue(InstanceType.Radarr, out string? oldRadarrHash) || radarrHash != oldRadarrHash)
string radarrHash = GenerateSettingsHash(malwareBlockerConfig.Radarr);
var radarrInterval = GetLoadInterval(malwareBlockerConfig.Radarr.BlocklistPath);
var radarrIdentifier = $"Radarr_{malwareBlockerConfig.Radarr.BlocklistPath}";
if (ShouldReloadBlocklist(radarrIdentifier, radarrInterval) || !_configHashes.TryGetValue(InstanceType.Radarr, out string? oldRadarrHash) || radarrHash != oldRadarrHash)
{
_logger.LogDebug("Loading Radarr blocklist");
await LoadPatternsAndRegexesAsync(contentBlockerConfig.Radarr, InstanceType.Radarr);
await LoadPatternsAndRegexesAsync(malwareBlockerConfig.Radarr, InstanceType.Radarr);
_configHashes[InstanceType.Radarr] = radarrHash;
_lastLoadTimes[radarrIdentifier] = DateTime.UtcNow;
changedCount++;
}
// Check and update Lidarr blocklist if needed
string lidarrHash = GenerateSettingsHash(contentBlockerConfig.Lidarr);
if (shouldReload || !_configHashes.TryGetValue(InstanceType.Lidarr, out string? oldLidarrHash) || lidarrHash != oldLidarrHash)
string lidarrHash = GenerateSettingsHash(malwareBlockerConfig.Lidarr);
var lidarrInterval = GetLoadInterval(malwareBlockerConfig.Lidarr.BlocklistPath);
var lidarrIdentifier = $"Lidarr_{malwareBlockerConfig.Lidarr.BlocklistPath}";
if (ShouldReloadBlocklist(lidarrIdentifier, lidarrInterval) || !_configHashes.TryGetValue(InstanceType.Lidarr, out string? oldLidarrHash) || lidarrHash != oldLidarrHash)
{
_logger.LogDebug("Loading Lidarr blocklist");
await LoadPatternsAndRegexesAsync(contentBlockerConfig.Lidarr, InstanceType.Lidarr);
await LoadPatternsAndRegexesAsync(malwareBlockerConfig.Lidarr, InstanceType.Lidarr);
_configHashes[InstanceType.Lidarr] = lidarrHash;
_lastLoadTimes[lidarrIdentifier] = DateTime.UtcNow;
changedCount++;
}
// Check and update Lidarr blocklist if needed
string readarrHash = GenerateSettingsHash(contentBlockerConfig.Readarr);
if (shouldReload || !_configHashes.TryGetValue(InstanceType.Readarr, out string? oldReadarrHash) || readarrHash != oldReadarrHash)
// 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(contentBlockerConfig.Readarr, InstanceType.Readarr);
await LoadPatternsAndRegexesAsync(malwareBlockerConfig.Readarr, InstanceType.Readarr);
_configHashes[InstanceType.Readarr] = readarrHash;
_lastLoadTimes[readarrIdentifier] = DateTime.UtcNow;
changedCount++;
}
// Check and update Whisparr blocklist if needed
string whisparrHash = GenerateSettingsHash(contentBlockerConfig.Whisparr);
if (shouldReload || !_configHashes.TryGetValue(InstanceType.Whisparr, out string? oldWhisparrHash) || whisparrHash != oldWhisparrHash)
string whisparrHash = GenerateSettingsHash(malwareBlockerConfig.Whisparr);
var whisparrInterval = GetLoadInterval(malwareBlockerConfig.Whisparr.BlocklistPath);
var whisparrIdentifier = $"Whisparr_{malwareBlockerConfig.Whisparr.BlocklistPath}";
if (ShouldReloadBlocklist(whisparrIdentifier, whisparrInterval) || !_configHashes.TryGetValue(InstanceType.Whisparr, out string? oldWhisparrHash) || whisparrHash != oldWhisparrHash)
{
_logger.LogDebug("Loading Whisparr blocklist");
await LoadPatternsAndRegexesAsync(contentBlockerConfig.Whisparr, InstanceType.Whisparr);
await LoadPatternsAndRegexesAsync(malwareBlockerConfig.Whisparr, InstanceType.Whisparr);
_configHashes[InstanceType.Whisparr] = whisparrHash;
_lastLoadTimes[whisparrIdentifier] = DateTime.UtcNow;
changedCount++;
}
// Always check and update malware patterns
await LoadMalwarePatternsAsync();
if (changedCount > 0)
{
_logger.LogInformation("Successfully loaded {count} blocklists", changedCount);
@@ -154,6 +167,77 @@ public sealed class BlocklistProvider
return regexes ?? [];
}
public ConcurrentBag<string> GetMalwarePatterns()
{
_cache.TryGetValue(CacheKeys.KnownMalwarePatterns(), out ConcurrentBag<string>? patterns);
return patterns ?? [];
}
private TimeSpan GetLoadInterval(string? path)
{
if (!string.IsNullOrEmpty(path) && Uri.TryCreate(path, UriKind.Absolute, out var uri))
{
if (uri.Host.Equals("cleanuparr.pages.dev", StringComparison.OrdinalIgnoreCase))
{
return TimeSpan.FromMinutes(FastLoadIntervalMinutes);
}
return TimeSpan.FromHours(DefaultLoadIntervalHours);
}
// If fast load interval for local files
return TimeSpan.FromMinutes(FastLoadIntervalMinutes);
}
private bool ShouldReloadBlocklist(string identifier, TimeSpan interval)
{
if (!_lastLoadTimes.TryGetValue(identifier, out DateTime lastLoad))
{
return true;
}
return DateTime.UtcNow - lastLoad >= interval;
}
private async Task LoadMalwarePatternsAsync()
{
var malwareInterval = TimeSpan.FromMinutes(FastLoadIntervalMinutes);
if (!ShouldReloadBlocklist(MalwareListKey, malwareInterval))
{
return;
}
try
{
_logger.LogDebug("Loading malware patterns");
string[] filePatterns = await ReadContentAsync(MalwareListUrl);
long startTime = Stopwatch.GetTimestamp();
ParallelOptions options = new() { MaxDegreeOfParallelism = 5 };
ConcurrentBag<string> patterns = [];
Parallel.ForEach(filePatterns, options, pattern =>
{
patterns.Add(pattern);
});
TimeSpan elapsed = Stopwatch.GetElapsedTime(startTime);
_cache.Set(CacheKeys.KnownMalwarePatterns(), patterns);
_lastLoadTimes[MalwareListKey] = DateTime.UtcNow;
_logger.LogDebug("loaded {count} known malware patterns", patterns.Count);
_logger.LogDebug("malware patterns loaded in {elapsed} ms", elapsed.TotalMilliseconds);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load malware patterns from {url}", MalwareListUrl);
}
}
private async Task LoadPatternsAndRegexesAsync(BlocklistSettings blocklistSettings, InstanceType instanceType)
{

View File

@@ -3,7 +3,7 @@ using System.Text.RegularExpressions;
using Cleanuparr.Domain.Enums;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.ContentBlocker;
namespace Cleanuparr.Infrastructure.Features.MalwareBlocker;
public class FilenameEvaluator : IFilenameEvaluator
{
@@ -20,6 +20,16 @@ public class FilenameEvaluator : IFilenameEvaluator
return IsValidAgainstPatterns(filename, type, patterns) && IsValidAgainstRegexes(filename, type, regexes);
}
public bool IsKnownMalware(string filename, ConcurrentBag<string> malwarePatterns)
{
if (malwarePatterns.Count is 0)
{
return false;
}
return malwarePatterns.Any(pattern => filename.Contains(pattern, StringComparison.InvariantCultureIgnoreCase));
}
private static bool IsValidAgainstPatterns(string filename, BlocklistType type, ConcurrentBag<string> patterns)
{
if (patterns.Count is 0)

View File

@@ -2,9 +2,11 @@
using System.Text.RegularExpressions;
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Infrastructure.Features.ContentBlocker;
namespace Cleanuparr.Infrastructure.Features.MalwareBlocker;
public interface IFilenameEvaluator
{
bool IsValid(string filename, BlocklistType type, ConcurrentBag<string> patterns, ConcurrentBag<Regex> regexes);
bool IsKnownMalware(string filename, ConcurrentBag<string> malwarePatterns);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,8 @@ public static class CacheKeys
public static string BlocklistPatterns(InstanceType instanceType) => $"{instanceType.ToString()}_patterns";
public static string BlocklistRegexes(InstanceType instanceType) => $"{instanceType.ToString()}_regexes";
public static string KnownMalwarePatterns() => "KNOWN_MALWARE_PATTERNS";
public static string StrikeItem(string hash, StrikeType strikeType) => $"item_{hash}_{strikeType.ToString()}";
public static string IgnoredDownloads(string name) => $"{name}_ignored";

View File

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

View File

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

View File

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

View File

@@ -1,125 +0,0 @@
# Enhanced Logging System
## Overview
The enhanced logging system provides a structured approach to logging with the following features:
- **Category-based logging**: Organize logs by functional areas (SYSTEM, API, JOBS, etc.)
- **Job name context**: Add job name to logs for background operations
- **Instance context**: Add instance names (Sonarr, Radarr, etc.) to relevant logs
- **Multiple output targets**: Console, files, and real-time SignalR streaming
- **Category-specific log files**: Separate log files for different categories
## Using the Logging System
### Adding Category to Logs
```csharp
// Using category constants
logger.WithCategory(LoggingCategoryConstants.System)
.LogInformation("This is a system log");
// Using direct category name
logger.WithCategory("API")
.LogInformation("This is an API log");
```
### Adding Job Name Context
```csharp
logger.WithCategory(LoggingCategoryConstants.Jobs)
.WithJob("ContentBlocker")
.LogInformation("Starting content blocking job");
```
### Adding Instance Name Context
```csharp
logger.WithCategory(LoggingCategoryConstants.Sonarr)
.WithInstance("Sonarr")
.LogInformation("Processing Sonarr data");
```
### Combined Context Example
```csharp
logger.WithCategory(LoggingCategoryConstants.Jobs)
.WithJob("QueueCleaner")
.WithInstance("Radarr")
.LogInformation("Cleaning Radarr queue");
```
## Log Storage
Logs are stored in the following locations:
- **Main log file**: `{config_path}/logs/Cleanuparr-.txt`
- **Category logs**: `{config_path}/logs/{category}-.txt` (e.g., `system-.txt`, `api-.txt`)
The log files use rolling file behavior:
- Daily rotation
- 10MB size limit for main log files
- 5MB size limit for category-specific logs
## SignalR Integration
The logging system includes real-time streaming via SignalR:
- **Hub URL**: `/hubs/logs`
- **Hub class**: `LogHub`
- **Event name**: `ReceiveLog`
### Requesting Recent Logs
When a client connects, it can request recent logs from the buffer:
```javascript
await connection.invoke("RequestRecentLogs");
```
### Log Message Format
Each log message contains:
- `timestamp`: The time the log was created
- `level`: Log level (Information, Warning, Error, etc.)
- `message`: The log message text
- `exception`: Exception details (if present)
- `category`: The log category
- `jobName`: The job name (if present)
- `instanceName`: The instance name (if present)
## How It All Works
1. The logging system is initialized during application startup
2. Logs are written to the console in real-time
3. Logs are written to files based on their category
4. Logs are buffered and sent to connected SignalR clients
5. New clients can request recent logs from the buffer
## Configuration Options
The logging configuration is loaded from the `Logging` section in appsettings.json:
```json
{
"Logging": {
"LogLevel": "Information",
"SignalR": {
"Enabled": true,
"BufferSize": 100
}
}
}
```
## Standard Categories
Use the `LoggingCategoryConstants` class to ensure consistent category naming:
- `LoggingCategoryConstants.System`: System-level logs
- `LoggingCategoryConstants.Api`: API-related logs
- `LoggingCategoryConstants.Jobs`: Job execution logs
- `LoggingCategoryConstants.Notifications`: User notification logs
- `LoggingCategoryConstants.Sonarr`: Sonarr-related logs
- `LoggingCategoryConstants.Radarr`: Radarr-related logs
- `LoggingCategoryConstants.Lidarr`: Lidarr-related logs

View File

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

View File

@@ -6,6 +6,6 @@ namespace Cleanuparr.Infrastructure.Models;
public enum JobType
{
QueueCleaner,
ContentBlocker,
MalwareBlocker,
DownloadCleaner
}

View File

@@ -52,7 +52,7 @@ public static class CronValidationHelper
throw new ValidationException($"{cronExpression} should have a fire time of maximum {Constants.TriggerMaxLimit.TotalHours} hours");
}
if (jobType is not JobType.ContentBlocker && triggerValue < Constants.TriggerMinLimit)
if (jobType is not JobType.MalwareBlocker && triggerValue < Constants.TriggerMinLimit)
{
throw new ValidationException($"{cronExpression} should have a fire time of minimum {Constants.TriggerMinLimit.TotalSeconds} seconds");
}

View File

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

View File

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

View File

@@ -0,0 +1,645 @@
// <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("20250801143446_AddKnownMalwareOption")]
partial class AddKnownMalwareOption
{
/// <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.ContentBlocker.ContentBlockerConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("CronExpression")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("cron_expression");
b.Property<bool>("DeleteKnownMalware")
.HasColumnType("INTEGER")
.HasColumnName("delete_known_malware");
b.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("delete_private");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<bool>("IgnorePrivate")
.HasColumnType("INTEGER")
.HasColumnName("ignore_private");
b.Property<bool>("UseAdvancedScheduling")
.HasColumnType("INTEGER")
.HasColumnName("use_advanced_scheduling");
b.ComplexProperty<Dictionary<string, object>>("Lidarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("lidarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("lidarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("lidarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Radarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("radarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("radarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("radarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Readarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("readarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("readarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("readarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Sonarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("sonarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("sonarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("sonarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Whisparr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("whisparr_blocklist_path");
b1.Property<int>("BlocklistType")
.HasColumnType("INTEGER")
.HasColumnName("whisparr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("whisparr_enabled");
});
b.HasKey("Id")
.HasName("pk_content_blocker_configs");
b.ToTable("content_blocker_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.CleanCategory", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<Guid>("DownloadCleanerConfigId")
.HasColumnType("TEXT")
.HasColumnName("download_cleaner_config_id");
b.Property<double>("MaxRatio")
.HasColumnType("REAL")
.HasColumnName("max_ratio");
b.Property<double>("MaxSeedTime")
.HasColumnType("REAL")
.HasColumnName("max_seed_time");
b.Property<double>("MinSeedTime")
.HasColumnType("REAL")
.HasColumnName("min_seed_time");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.HasKey("Id")
.HasName("pk_clean_categories");
b.HasIndex("DownloadCleanerConfigId")
.HasDatabaseName("ix_clean_categories_download_cleaner_config_id");
b.ToTable("clean_categories", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("CronExpression")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("cron_expression");
b.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("delete_private");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.PrimitiveCollection<string>("UnlinkedCategories")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("unlinked_categories");
b.Property<bool>("UnlinkedEnabled")
.HasColumnType("INTEGER")
.HasColumnName("unlinked_enabled");
b.Property<string>("UnlinkedIgnoredRootDir")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("unlinked_ignored_root_dir");
b.Property<string>("UnlinkedTargetCategory")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("unlinked_target_category");
b.Property<bool>("UnlinkedUseTag")
.HasColumnType("INTEGER")
.HasColumnName("unlinked_use_tag");
b.Property<bool>("UseAdvancedScheduling")
.HasColumnType("INTEGER")
.HasColumnName("use_advanced_scheduling");
b.HasKey("Id")
.HasName("pk_download_cleaner_configs");
b.ToTable("download_cleaner_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<string>("Host")
.HasColumnType("TEXT")
.HasColumnName("host");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<string>("Password")
.HasColumnType("TEXT")
.HasColumnName("password");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("type");
b.Property<string>("TypeName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("type_name");
b.Property<string>("UrlBase")
.HasColumnType("TEXT")
.HasColumnName("url_base");
b.Property<string>("Username")
.HasColumnType("TEXT")
.HasColumnName("username");
b.HasKey("Id")
.HasName("pk_download_clients");
b.ToTable("download_clients", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("DisplaySupportBanner")
.HasColumnType("INTEGER")
.HasColumnName("display_support_banner");
b.Property<bool>("DryRun")
.HasColumnType("INTEGER")
.HasColumnName("dry_run");
b.Property<string>("EncryptionKey")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("encryption_key");
b.Property<string>("HttpCertificateValidation")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("http_certificate_validation");
b.Property<ushort>("HttpMaxRetries")
.HasColumnType("INTEGER")
.HasColumnName("http_max_retries");
b.Property<ushort>("HttpTimeout")
.HasColumnType("INTEGER")
.HasColumnName("http_timeout");
b.PrimitiveCollection<string>("IgnoredDownloads")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("ignored_downloads");
b.Property<string>("LogLevel")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("log_level");
b.Property<ushort>("SearchDelay")
.HasColumnType("INTEGER")
.HasColumnName("search_delay");
b.Property<bool>("SearchEnabled")
.HasColumnType("INTEGER")
.HasColumnName("search_enabled");
b.HasKey("Id")
.HasName("pk_general_configs");
b.ToTable("general_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("FullUrl")
.HasColumnType("TEXT")
.HasColumnName("full_url");
b.Property<string>("Key")
.HasColumnType("TEXT")
.HasColumnName("key");
b.Property<bool>("OnCategoryChanged")
.HasColumnType("INTEGER")
.HasColumnName("on_category_changed");
b.Property<bool>("OnDownloadCleaned")
.HasColumnType("INTEGER")
.HasColumnName("on_download_cleaned");
b.Property<bool>("OnFailedImportStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_failed_import_strike");
b.Property<bool>("OnQueueItemDeleted")
.HasColumnType("INTEGER")
.HasColumnName("on_queue_item_deleted");
b.Property<bool>("OnSlowStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_slow_strike");
b.Property<bool>("OnStalledStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_stalled_strike");
b.Property<string>("Tags")
.HasColumnType("TEXT")
.HasColumnName("tags");
b.HasKey("Id")
.HasName("pk_apprise_configs");
b.ToTable("apprise_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ApiKey")
.HasColumnType("TEXT")
.HasColumnName("api_key");
b.Property<string>("ChannelId")
.HasColumnType("TEXT")
.HasColumnName("channel_id");
b.Property<bool>("OnCategoryChanged")
.HasColumnType("INTEGER")
.HasColumnName("on_category_changed");
b.Property<bool>("OnDownloadCleaned")
.HasColumnType("INTEGER")
.HasColumnName("on_download_cleaned");
b.Property<bool>("OnFailedImportStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_failed_import_strike");
b.Property<bool>("OnQueueItemDeleted")
.HasColumnType("INTEGER")
.HasColumnName("on_queue_item_deleted");
b.Property<bool>("OnSlowStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_slow_strike");
b.Property<bool>("OnStalledStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_stalled_strike");
b.HasKey("Id")
.HasName("pk_notifiarr_configs");
b.ToTable("notifiarr_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("CronExpression")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("cron_expression");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<bool>("UseAdvancedScheduling")
.HasColumnType("INTEGER")
.HasColumnName("use_advanced_scheduling");
b.ComplexProperty<Dictionary<string, object>>("FailedImport", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.FailedImport#FailedImportConfig", b1 =>
{
b1.IsRequired();
b1.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("failed_import_delete_private");
b1.Property<bool>("IgnorePrivate")
.HasColumnType("INTEGER")
.HasColumnName("failed_import_ignore_private");
b1.PrimitiveCollection<string>("IgnoredPatterns")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("failed_import_ignored_patterns");
b1.Property<ushort>("MaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("failed_import_max_strikes");
});
b.ComplexProperty<Dictionary<string, object>>("Slow", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.Slow#SlowConfig", b1 =>
{
b1.IsRequired();
b1.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("slow_delete_private");
b1.Property<string>("IgnoreAboveSize")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("slow_ignore_above_size");
b1.Property<bool>("IgnorePrivate")
.HasColumnType("INTEGER")
.HasColumnName("slow_ignore_private");
b1.Property<ushort>("MaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("slow_max_strikes");
b1.Property<double>("MaxTime")
.HasColumnType("REAL")
.HasColumnName("slow_max_time");
b1.Property<string>("MinSpeed")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("slow_min_speed");
b1.Property<bool>("ResetStrikesOnProgress")
.HasColumnType("INTEGER")
.HasColumnName("slow_reset_strikes_on_progress");
});
b.ComplexProperty<Dictionary<string, object>>("Stalled", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.Stalled#StalledConfig", b1 =>
{
b1.IsRequired();
b1.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("stalled_delete_private");
b1.Property<ushort>("DownloadingMetadataMaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("stalled_downloading_metadata_max_strikes");
b1.Property<bool>("IgnorePrivate")
.HasColumnType("INTEGER")
.HasColumnName("stalled_ignore_private");
b1.Property<ushort>("MaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("stalled_max_strikes");
b1.Property<bool>("ResetStrikesOnProgress")
.HasColumnType("INTEGER")
.HasColumnName("stalled_reset_strikes_on_progress");
});
b.HasKey("Id")
.HasName("pk_queue_cleaner_configs");
b.ToTable("queue_cleaner_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", "ArrConfig")
.WithMany("Instances")
.HasForeignKey("ArrConfigId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_arr_instances_arr_configs_arr_config_id");
b.Navigation("ArrConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.CleanCategory", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", "DownloadCleanerConfig")
.WithMany("Categories")
.HasForeignKey("DownloadCleanerConfigId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_clean_categories_download_cleaner_configs_download_cleaner_config_id");
b.Navigation("DownloadCleanerConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b =>
{
b.Navigation("Instances");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b =>
{
b.Navigation("Categories");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class AddKnownMalwareOption : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "delete_known_malware",
table: "content_blocker_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "delete_known_malware",
table: "content_blocker_configs");
}
}
}

View File

@@ -0,0 +1,674 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using Cleanuparr.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
[DbContext(typeof(DataContext))]
[Migration("20250816183837_AddAdvancedLoggingSettings")]
partial class AddAdvancedLoggingSettings
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.6");
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<short>("FailedImportMaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("failed_import_max_strikes");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_arr_configs");
b.ToTable("arr_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ApiKey")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("api_key");
b.Property<Guid>("ArrConfigId")
.HasColumnType("TEXT")
.HasColumnName("arr_config_id");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("url");
b.HasKey("Id")
.HasName("pk_arr_instances");
b.HasIndex("ArrConfigId")
.HasDatabaseName("ix_arr_instances_arr_config_id");
b.ToTable("arr_instances", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.CleanCategory", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<Guid>("DownloadCleanerConfigId")
.HasColumnType("TEXT")
.HasColumnName("download_cleaner_config_id");
b.Property<double>("MaxRatio")
.HasColumnType("REAL")
.HasColumnName("max_ratio");
b.Property<double>("MaxSeedTime")
.HasColumnType("REAL")
.HasColumnName("max_seed_time");
b.Property<double>("MinSeedTime")
.HasColumnType("REAL")
.HasColumnName("min_seed_time");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.HasKey("Id")
.HasName("pk_clean_categories");
b.HasIndex("DownloadCleanerConfigId")
.HasDatabaseName("ix_clean_categories_download_cleaner_config_id");
b.ToTable("clean_categories", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("CronExpression")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("cron_expression");
b.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("delete_private");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.PrimitiveCollection<string>("UnlinkedCategories")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("unlinked_categories");
b.Property<bool>("UnlinkedEnabled")
.HasColumnType("INTEGER")
.HasColumnName("unlinked_enabled");
b.Property<string>("UnlinkedIgnoredRootDir")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("unlinked_ignored_root_dir");
b.Property<string>("UnlinkedTargetCategory")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("unlinked_target_category");
b.Property<bool>("UnlinkedUseTag")
.HasColumnType("INTEGER")
.HasColumnName("unlinked_use_tag");
b.Property<bool>("UseAdvancedScheduling")
.HasColumnType("INTEGER")
.HasColumnName("use_advanced_scheduling");
b.HasKey("Id")
.HasName("pk_download_cleaner_configs");
b.ToTable("download_cleaner_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<string>("Host")
.HasColumnType("TEXT")
.HasColumnName("host");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<string>("Password")
.HasColumnType("TEXT")
.HasColumnName("password");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("type");
b.Property<string>("TypeName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("type_name");
b.Property<string>("UrlBase")
.HasColumnType("TEXT")
.HasColumnName("url_base");
b.Property<string>("Username")
.HasColumnType("TEXT")
.HasColumnName("username");
b.HasKey("Id")
.HasName("pk_download_clients");
b.ToTable("download_clients", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("DisplaySupportBanner")
.HasColumnType("INTEGER")
.HasColumnName("display_support_banner");
b.Property<bool>("DryRun")
.HasColumnType("INTEGER")
.HasColumnName("dry_run");
b.Property<string>("EncryptionKey")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("encryption_key");
b.Property<string>("HttpCertificateValidation")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("http_certificate_validation");
b.Property<ushort>("HttpMaxRetries")
.HasColumnType("INTEGER")
.HasColumnName("http_max_retries");
b.Property<ushort>("HttpTimeout")
.HasColumnType("INTEGER")
.HasColumnName("http_timeout");
b.PrimitiveCollection<string>("IgnoredDownloads")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("ignored_downloads");
b.Property<ushort>("SearchDelay")
.HasColumnType("INTEGER")
.HasColumnName("search_delay");
b.Property<bool>("SearchEnabled")
.HasColumnType("INTEGER")
.HasColumnName("search_enabled");
b.ComplexProperty<Dictionary<string, object>>("Log", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Log#LoggingConfig", b1 =>
{
b1.IsRequired();
b1.Property<bool>("ArchiveEnabled")
.HasColumnType("INTEGER")
.HasColumnName("log_archive_enabled");
b1.Property<ushort>("ArchiveRetainedCount")
.HasColumnType("INTEGER")
.HasColumnName("log_archive_retained_count");
b1.Property<ushort>("ArchiveTimeLimitHours")
.HasColumnType("INTEGER")
.HasColumnName("log_archive_time_limit_hours");
b1.Property<string>("Level")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("log_level");
b1.Property<ushort>("RetainedFileCount")
.HasColumnType("INTEGER")
.HasColumnName("log_retained_file_count");
b1.Property<ushort>("RollingSizeMB")
.HasColumnType("INTEGER")
.HasColumnName("log_rolling_size_mb");
b1.Property<ushort>("TimeLimitHours")
.HasColumnType("INTEGER")
.HasColumnName("log_time_limit_hours");
});
b.HasKey("Id")
.HasName("pk_general_configs");
b.ToTable("general_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("CronExpression")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("cron_expression");
b.Property<bool>("DeleteKnownMalware")
.HasColumnType("INTEGER")
.HasColumnName("delete_known_malware");
b.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("delete_private");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<bool>("IgnorePrivate")
.HasColumnType("INTEGER")
.HasColumnName("ignore_private");
b.Property<bool>("UseAdvancedScheduling")
.HasColumnType("INTEGER")
.HasColumnName("use_advanced_scheduling");
b.ComplexProperty<Dictionary<string, object>>("Lidarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("lidarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("lidarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("lidarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Radarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("radarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("radarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("radarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Readarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("readarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("readarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("readarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Sonarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("sonarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("sonarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("sonarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Whisparr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("whisparr_blocklist_path");
b1.Property<int>("BlocklistType")
.HasColumnType("INTEGER")
.HasColumnName("whisparr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("whisparr_enabled");
});
b.HasKey("Id")
.HasName("pk_content_blocker_configs");
b.ToTable("content_blocker_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("FullUrl")
.HasColumnType("TEXT")
.HasColumnName("full_url");
b.Property<string>("Key")
.HasColumnType("TEXT")
.HasColumnName("key");
b.Property<bool>("OnCategoryChanged")
.HasColumnType("INTEGER")
.HasColumnName("on_category_changed");
b.Property<bool>("OnDownloadCleaned")
.HasColumnType("INTEGER")
.HasColumnName("on_download_cleaned");
b.Property<bool>("OnFailedImportStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_failed_import_strike");
b.Property<bool>("OnQueueItemDeleted")
.HasColumnType("INTEGER")
.HasColumnName("on_queue_item_deleted");
b.Property<bool>("OnSlowStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_slow_strike");
b.Property<bool>("OnStalledStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_stalled_strike");
b.Property<string>("Tags")
.HasColumnType("TEXT")
.HasColumnName("tags");
b.HasKey("Id")
.HasName("pk_apprise_configs");
b.ToTable("apprise_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ApiKey")
.HasColumnType("TEXT")
.HasColumnName("api_key");
b.Property<string>("ChannelId")
.HasColumnType("TEXT")
.HasColumnName("channel_id");
b.Property<bool>("OnCategoryChanged")
.HasColumnType("INTEGER")
.HasColumnName("on_category_changed");
b.Property<bool>("OnDownloadCleaned")
.HasColumnType("INTEGER")
.HasColumnName("on_download_cleaned");
b.Property<bool>("OnFailedImportStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_failed_import_strike");
b.Property<bool>("OnQueueItemDeleted")
.HasColumnType("INTEGER")
.HasColumnName("on_queue_item_deleted");
b.Property<bool>("OnSlowStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_slow_strike");
b.Property<bool>("OnStalledStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_stalled_strike");
b.HasKey("Id")
.HasName("pk_notifiarr_configs");
b.ToTable("notifiarr_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("CronExpression")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("cron_expression");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<bool>("UseAdvancedScheduling")
.HasColumnType("INTEGER")
.HasColumnName("use_advanced_scheduling");
b.ComplexProperty<Dictionary<string, object>>("FailedImport", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.FailedImport#FailedImportConfig", b1 =>
{
b1.IsRequired();
b1.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("failed_import_delete_private");
b1.Property<bool>("IgnorePrivate")
.HasColumnType("INTEGER")
.HasColumnName("failed_import_ignore_private");
b1.PrimitiveCollection<string>("IgnoredPatterns")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("failed_import_ignored_patterns");
b1.Property<ushort>("MaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("failed_import_max_strikes");
});
b.ComplexProperty<Dictionary<string, object>>("Slow", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.Slow#SlowConfig", b1 =>
{
b1.IsRequired();
b1.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("slow_delete_private");
b1.Property<string>("IgnoreAboveSize")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("slow_ignore_above_size");
b1.Property<bool>("IgnorePrivate")
.HasColumnType("INTEGER")
.HasColumnName("slow_ignore_private");
b1.Property<ushort>("MaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("slow_max_strikes");
b1.Property<double>("MaxTime")
.HasColumnType("REAL")
.HasColumnName("slow_max_time");
b1.Property<string>("MinSpeed")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("slow_min_speed");
b1.Property<bool>("ResetStrikesOnProgress")
.HasColumnType("INTEGER")
.HasColumnName("slow_reset_strikes_on_progress");
});
b.ComplexProperty<Dictionary<string, object>>("Stalled", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.Stalled#StalledConfig", b1 =>
{
b1.IsRequired();
b1.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("stalled_delete_private");
b1.Property<ushort>("DownloadingMetadataMaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("stalled_downloading_metadata_max_strikes");
b1.Property<bool>("IgnorePrivate")
.HasColumnType("INTEGER")
.HasColumnName("stalled_ignore_private");
b1.Property<ushort>("MaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("stalled_max_strikes");
b1.Property<bool>("ResetStrikesOnProgress")
.HasColumnType("INTEGER")
.HasColumnName("stalled_reset_strikes_on_progress");
});
b.HasKey("Id")
.HasName("pk_queue_cleaner_configs");
b.ToTable("queue_cleaner_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", "ArrConfig")
.WithMany("Instances")
.HasForeignKey("ArrConfigId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_arr_instances_arr_configs_arr_config_id");
b.Navigation("ArrConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.CleanCategory", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", "DownloadCleanerConfig")
.WithMany("Categories")
.HasForeignKey("DownloadCleanerConfigId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_clean_categories_download_cleaner_configs_download_cleaner_config_id");
b.Navigation("DownloadCleanerConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b =>
{
b.Navigation("Instances");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b =>
{
b.Navigation("Categories");
});
#pragma warning restore 612, 618
}
}
}

View File

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

View File

@@ -0,0 +1,717 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using Cleanuparr.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
[DbContext(typeof(DataContext))]
[Migration("20250830230846_ReworkNotificationSystem")]
partial class ReworkNotificationSystem
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.6");
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<short>("FailedImportMaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("failed_import_max_strikes");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_arr_configs");
b.ToTable("arr_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ApiKey")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("api_key");
b.Property<Guid>("ArrConfigId")
.HasColumnType("TEXT")
.HasColumnName("arr_config_id");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("url");
b.HasKey("Id")
.HasName("pk_arr_instances");
b.HasIndex("ArrConfigId")
.HasDatabaseName("ix_arr_instances_arr_config_id");
b.ToTable("arr_instances", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.CleanCategory", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<Guid>("DownloadCleanerConfigId")
.HasColumnType("TEXT")
.HasColumnName("download_cleaner_config_id");
b.Property<double>("MaxRatio")
.HasColumnType("REAL")
.HasColumnName("max_ratio");
b.Property<double>("MaxSeedTime")
.HasColumnType("REAL")
.HasColumnName("max_seed_time");
b.Property<double>("MinSeedTime")
.HasColumnType("REAL")
.HasColumnName("min_seed_time");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.HasKey("Id")
.HasName("pk_clean_categories");
b.HasIndex("DownloadCleanerConfigId")
.HasDatabaseName("ix_clean_categories_download_cleaner_config_id");
b.ToTable("clean_categories", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("CronExpression")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("cron_expression");
b.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("delete_private");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.PrimitiveCollection<string>("UnlinkedCategories")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("unlinked_categories");
b.Property<bool>("UnlinkedEnabled")
.HasColumnType("INTEGER")
.HasColumnName("unlinked_enabled");
b.Property<string>("UnlinkedIgnoredRootDir")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("unlinked_ignored_root_dir");
b.Property<string>("UnlinkedTargetCategory")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("unlinked_target_category");
b.Property<bool>("UnlinkedUseTag")
.HasColumnType("INTEGER")
.HasColumnName("unlinked_use_tag");
b.Property<bool>("UseAdvancedScheduling")
.HasColumnType("INTEGER")
.HasColumnName("use_advanced_scheduling");
b.HasKey("Id")
.HasName("pk_download_cleaner_configs");
b.ToTable("download_cleaner_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<string>("Host")
.HasColumnType("TEXT")
.HasColumnName("host");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<string>("Password")
.HasColumnType("TEXT")
.HasColumnName("password");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("type");
b.Property<string>("TypeName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("type_name");
b.Property<string>("UrlBase")
.HasColumnType("TEXT")
.HasColumnName("url_base");
b.Property<string>("Username")
.HasColumnType("TEXT")
.HasColumnName("username");
b.HasKey("Id")
.HasName("pk_download_clients");
b.ToTable("download_clients", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("DisplaySupportBanner")
.HasColumnType("INTEGER")
.HasColumnName("display_support_banner");
b.Property<bool>("DryRun")
.HasColumnType("INTEGER")
.HasColumnName("dry_run");
b.Property<string>("EncryptionKey")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("encryption_key");
b.Property<string>("HttpCertificateValidation")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("http_certificate_validation");
b.Property<ushort>("HttpMaxRetries")
.HasColumnType("INTEGER")
.HasColumnName("http_max_retries");
b.Property<ushort>("HttpTimeout")
.HasColumnType("INTEGER")
.HasColumnName("http_timeout");
b.PrimitiveCollection<string>("IgnoredDownloads")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("ignored_downloads");
b.Property<string>("LogLevel")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("log_level");
b.Property<ushort>("SearchDelay")
.HasColumnType("INTEGER")
.HasColumnName("search_delay");
b.Property<bool>("SearchEnabled")
.HasColumnType("INTEGER")
.HasColumnName("search_enabled");
b.HasKey("Id")
.HasName("pk_general_configs");
b.ToTable("general_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("CronExpression")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("cron_expression");
b.Property<bool>("DeleteKnownMalware")
.HasColumnType("INTEGER")
.HasColumnName("delete_known_malware");
b.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("delete_private");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<bool>("IgnorePrivate")
.HasColumnType("INTEGER")
.HasColumnName("ignore_private");
b.Property<bool>("UseAdvancedScheduling")
.HasColumnType("INTEGER")
.HasColumnName("use_advanced_scheduling");
b.ComplexProperty<Dictionary<string, object>>("Lidarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("lidarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("lidarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("lidarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Radarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("radarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("radarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("radarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Readarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("readarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("readarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("readarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Sonarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("sonarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("sonarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("sonarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Whisparr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("whisparr_blocklist_path");
b1.Property<int>("BlocklistType")
.HasColumnType("INTEGER")
.HasColumnName("whisparr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("whisparr_enabled");
});
b.HasKey("Id")
.HasName("pk_content_blocker_configs");
b.ToTable("content_blocker_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT")
.HasColumnName("key");
b.Property<Guid>("NotificationConfigId")
.HasColumnType("TEXT")
.HasColumnName("notification_config_id");
b.Property<string>("Tags")
.HasMaxLength(255)
.HasColumnType("TEXT")
.HasColumnName("tags");
b.Property<string>("Url")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT")
.HasColumnName("url");
b.HasKey("Id")
.HasName("pk_apprise_configs");
b.HasIndex("NotificationConfigId")
.IsUnique()
.HasDatabaseName("ix_apprise_configs_notification_config_id");
b.ToTable("apprise_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ApiKey")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT")
.HasColumnName("api_key");
b.Property<string>("ChannelId")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT")
.HasColumnName("channel_id");
b.Property<Guid>("NotificationConfigId")
.HasColumnType("TEXT")
.HasColumnName("notification_config_id");
b.HasKey("Id")
.HasName("pk_notifiarr_configs");
b.HasIndex("NotificationConfigId")
.IsUnique()
.HasDatabaseName("ix_notifiarr_configs_notification_config_id");
b.ToTable("notifiarr_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<bool>("IsEnabled")
.HasColumnType("INTEGER")
.HasColumnName("is_enabled");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<bool>("OnCategoryChanged")
.HasColumnType("INTEGER")
.HasColumnName("on_category_changed");
b.Property<bool>("OnDownloadCleaned")
.HasColumnType("INTEGER")
.HasColumnName("on_download_cleaned");
b.Property<bool>("OnFailedImportStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_failed_import_strike");
b.Property<bool>("OnQueueItemDeleted")
.HasColumnType("INTEGER")
.HasColumnName("on_queue_item_deleted");
b.Property<bool>("OnSlowStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_slow_strike");
b.Property<bool>("OnStalledStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_stalled_strike");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("type");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_notification_configs");
b.HasIndex("Name")
.IsUnique()
.HasDatabaseName("ix_notification_configs_name");
b.ToTable("notification_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("CronExpression")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("cron_expression");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<bool>("UseAdvancedScheduling")
.HasColumnType("INTEGER")
.HasColumnName("use_advanced_scheduling");
b.ComplexProperty<Dictionary<string, object>>("FailedImport", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.FailedImport#FailedImportConfig", b1 =>
{
b1.IsRequired();
b1.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("failed_import_delete_private");
b1.Property<bool>("IgnorePrivate")
.HasColumnType("INTEGER")
.HasColumnName("failed_import_ignore_private");
b1.PrimitiveCollection<string>("IgnoredPatterns")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("failed_import_ignored_patterns");
b1.Property<ushort>("MaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("failed_import_max_strikes");
});
b.ComplexProperty<Dictionary<string, object>>("Slow", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.Slow#SlowConfig", b1 =>
{
b1.IsRequired();
b1.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("slow_delete_private");
b1.Property<string>("IgnoreAboveSize")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("slow_ignore_above_size");
b1.Property<bool>("IgnorePrivate")
.HasColumnType("INTEGER")
.HasColumnName("slow_ignore_private");
b1.Property<ushort>("MaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("slow_max_strikes");
b1.Property<double>("MaxTime")
.HasColumnType("REAL")
.HasColumnName("slow_max_time");
b1.Property<string>("MinSpeed")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("slow_min_speed");
b1.Property<bool>("ResetStrikesOnProgress")
.HasColumnType("INTEGER")
.HasColumnName("slow_reset_strikes_on_progress");
});
b.ComplexProperty<Dictionary<string, object>>("Stalled", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.Stalled#StalledConfig", b1 =>
{
b1.IsRequired();
b1.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("stalled_delete_private");
b1.Property<ushort>("DownloadingMetadataMaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("stalled_downloading_metadata_max_strikes");
b1.Property<bool>("IgnorePrivate")
.HasColumnType("INTEGER")
.HasColumnName("stalled_ignore_private");
b1.Property<ushort>("MaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("stalled_max_strikes");
b1.Property<bool>("ResetStrikesOnProgress")
.HasColumnType("INTEGER")
.HasColumnName("stalled_reset_strikes_on_progress");
});
b.HasKey("Id")
.HasName("pk_queue_cleaner_configs");
b.ToTable("queue_cleaner_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", "ArrConfig")
.WithMany("Instances")
.HasForeignKey("ArrConfigId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_arr_instances_arr_configs_arr_config_id");
b.Navigation("ArrConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.CleanCategory", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", "DownloadCleanerConfig")
.WithMany("Categories")
.HasForeignKey("DownloadCleanerConfigId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_clean_categories_download_cleaner_configs_download_cleaner_config_id");
b.Navigation("DownloadCleanerConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig")
.WithOne("AppriseConfiguration")
.HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", "NotificationConfigId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_apprise_configs_notification_configs_notification_config_id");
b.Navigation("NotificationConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig")
.WithOne("NotifiarrConfiguration")
.HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", "NotificationConfigId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_notifiarr_configs_notification_configs_notification_config_id");
b.Navigation("NotificationConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b =>
{
b.Navigation("Instances");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b =>
{
b.Navigation("Categories");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b =>
{
b.Navigation("AppriseConfiguration");
b.Navigation("NotifiarrConfiguration");
});
#pragma warning restore 612, 618
}
}
}

View File

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

View File

@@ -79,129 +79,6 @@ namespace Cleanuparr.Persistence.Migrations.Data
b.ToTable("arr_instances", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("CronExpression")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("cron_expression");
b.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("delete_private");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<bool>("IgnorePrivate")
.HasColumnType("INTEGER")
.HasColumnName("ignore_private");
b.Property<bool>("UseAdvancedScheduling")
.HasColumnType("INTEGER")
.HasColumnName("use_advanced_scheduling");
b.ComplexProperty<Dictionary<string, object>>("Lidarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("lidarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("lidarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("lidarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Radarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("radarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("radarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("radarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Readarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("readarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("readarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("readarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Sonarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("sonarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("sonarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("sonarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Whisparr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("whisparr_blocklist_path");
b1.Property<int>("BlocklistType")
.HasColumnType("INTEGER")
.HasColumnName("whisparr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("whisparr_enabled");
});
b.HasKey("Id")
.HasName("pk_content_blocker_configs");
b.ToTable("content_blocker_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.CleanCategory", b =>
{
b.Property<Guid>("Id")
@@ -378,11 +255,6 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("ignored_downloads");
b.Property<string>("LogLevel")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("log_level");
b.Property<ushort>("SearchDelay")
.HasColumnType("INTEGER")
.HasColumnName("search_delay");
@@ -391,12 +263,173 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("INTEGER")
.HasColumnName("search_enabled");
b.ComplexProperty<Dictionary<string, object>>("Log", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Log#LoggingConfig", b1 =>
{
b1.IsRequired();
b1.Property<bool>("ArchiveEnabled")
.HasColumnType("INTEGER")
.HasColumnName("log_archive_enabled");
b1.Property<ushort>("ArchiveRetainedCount")
.HasColumnType("INTEGER")
.HasColumnName("log_archive_retained_count");
b1.Property<ushort>("ArchiveTimeLimitHours")
.HasColumnType("INTEGER")
.HasColumnName("log_archive_time_limit_hours");
b1.Property<string>("Level")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("log_level");
b1.Property<ushort>("RetainedFileCount")
.HasColumnType("INTEGER")
.HasColumnName("log_retained_file_count");
b1.Property<ushort>("RollingSizeMB")
.HasColumnType("INTEGER")
.HasColumnName("log_rolling_size_mb");
b1.Property<ushort>("TimeLimitHours")
.HasColumnType("INTEGER")
.HasColumnName("log_time_limit_hours");
});
b.HasKey("Id")
.HasName("pk_general_configs");
b.ToTable("general_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("CronExpression")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("cron_expression");
b.Property<bool>("DeleteKnownMalware")
.HasColumnType("INTEGER")
.HasColumnName("delete_known_malware");
b.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("delete_private");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<bool>("IgnorePrivate")
.HasColumnType("INTEGER")
.HasColumnName("ignore_private");
b.Property<bool>("UseAdvancedScheduling")
.HasColumnType("INTEGER")
.HasColumnName("use_advanced_scheduling");
b.ComplexProperty<Dictionary<string, object>>("Lidarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("lidarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("lidarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("lidarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Radarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("radarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("radarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("radarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Readarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("readarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("readarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("readarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Sonarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("sonarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("sonarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("sonarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Whisparr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("whisparr_blocklist_path");
b1.Property<int>("BlocklistType")
.HasColumnType("INTEGER")
.HasColumnName("whisparr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("whisparr_enabled");
});
b.HasKey("Id")
.HasName("pk_content_blocker_configs");
b.ToTable("content_blocker_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b =>
{
b.Property<Guid>("Id")
@@ -404,45 +437,34 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("FullUrl")
.HasColumnType("TEXT")
.HasColumnName("full_url");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT")
.HasColumnName("key");
b.Property<bool>("OnCategoryChanged")
.HasColumnType("INTEGER")
.HasColumnName("on_category_changed");
b.Property<bool>("OnDownloadCleaned")
.HasColumnType("INTEGER")
.HasColumnName("on_download_cleaned");
b.Property<bool>("OnFailedImportStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_failed_import_strike");
b.Property<bool>("OnQueueItemDeleted")
.HasColumnType("INTEGER")
.HasColumnName("on_queue_item_deleted");
b.Property<bool>("OnSlowStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_slow_strike");
b.Property<bool>("OnStalledStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_stalled_strike");
b.Property<Guid>("NotificationConfigId")
.HasColumnType("TEXT")
.HasColumnName("notification_config_id");
b.Property<string>("Tags")
.HasMaxLength(255)
.HasColumnType("TEXT")
.HasColumnName("tags");
b.Property<string>("Url")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT")
.HasColumnName("url");
b.HasKey("Id")
.HasName("pk_apprise_configs");
b.HasIndex("NotificationConfigId")
.IsUnique()
.HasDatabaseName("ix_apprise_configs_notification_config_id");
b.ToTable("apprise_configs", (string)null);
});
@@ -454,13 +476,52 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnName("id");
b.Property<string>("ApiKey")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT")
.HasColumnName("api_key");
b.Property<string>("ChannelId")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT")
.HasColumnName("channel_id");
b.Property<Guid>("NotificationConfigId")
.HasColumnType("TEXT")
.HasColumnName("notification_config_id");
b.HasKey("Id")
.HasName("pk_notifiarr_configs");
b.HasIndex("NotificationConfigId")
.IsUnique()
.HasDatabaseName("ix_notifiarr_configs_notification_config_id");
b.ToTable("notifiarr_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<bool>("IsEnabled")
.HasColumnType("INTEGER")
.HasColumnName("is_enabled");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<bool>("OnCategoryChanged")
.HasColumnType("INTEGER")
.HasColumnName("on_category_changed");
@@ -485,10 +546,23 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("INTEGER")
.HasColumnName("on_stalled_strike");
b.HasKey("Id")
.HasName("pk_notifiarr_configs");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("type");
b.ToTable("notifiarr_configs", (string)null);
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_notification_configs");
b.HasIndex("Name")
.IsUnique()
.HasDatabaseName("ix_notification_configs_name");
b.ToTable("notification_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b =>
@@ -623,6 +697,30 @@ namespace Cleanuparr.Persistence.Migrations.Data
b.Navigation("DownloadCleanerConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig")
.WithOne("AppriseConfiguration")
.HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", "NotificationConfigId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_apprise_configs_notification_configs_notification_config_id");
b.Navigation("NotificationConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig")
.WithOne("NotifiarrConfiguration")
.HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", "NotificationConfigId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_notifiarr_configs_notification_configs_notification_config_id");
b.Navigation("NotificationConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b =>
{
b.Navigation("Instances");
@@ -632,6 +730,13 @@ namespace Cleanuparr.Persistence.Migrations.Data
{
b.Navigation("Categories");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b =>
{
b.Navigation("AppriseConfiguration");
b.Navigation("NotifiarrConfiguration");
});
#pragma warning restore 612, 618
}
}

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
using System.ComponentModel.DataAnnotations.Schema;
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
namespace Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
/// <summary>
/// Settings for a blocklist

View File

@@ -2,7 +2,7 @@
using System.ComponentModel.DataAnnotations.Schema;
using ValidationException = System.ComponentModel.DataAnnotations.ValidationException;
namespace Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
namespace Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
public sealed record ContentBlockerConfig : IJobConfig
{
@@ -19,6 +19,8 @@ public sealed record ContentBlockerConfig : IJobConfig
public bool IgnorePrivate { get; set; }
public bool DeletePrivate { get; set; }
public bool DeleteKnownMalware { get; set; }
public BlocklistSettings Sonarr { get; set; } = new();

View File

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

View File

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

View File

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

View File

@@ -6,11 +6,28 @@ export const routes: Routes = [
{ path: 'dashboard', loadComponent: () => import('./dashboard/dashboard-page/dashboard-page.component').then(m => m.DashboardPageComponent) },
{ path: 'logs', loadComponent: () => import('./logging/logs-viewer/logs-viewer.component').then(m => m.LogsViewerComponent) },
{ path: 'events', loadComponent: () => import('./events/events-viewer/events-viewer.component').then(m => m.EventsViewerComponent) },
{
path: 'settings',
loadComponent: () => import('./settings/settings-page/settings-page.component').then(m => m.SettingsPageComponent),
path: 'general-settings',
loadComponent: () => import('./settings/general-settings/general-settings.component').then(m => m.GeneralSettingsComponent),
canDeactivate: [pendingChangesGuard]
},
{
path: 'queue-cleaner',
loadComponent: () => import('./settings/queue-cleaner/queue-cleaner-settings.component').then(m => m.QueueCleanerSettingsComponent),
canDeactivate: [pendingChangesGuard]
},
{
path: 'malware-blocker',
loadComponent: () => import('./settings/malware-blocker/malware-blocker-settings.component').then(m => m.MalwareBlockerSettingsComponent),
canDeactivate: [pendingChangesGuard]
},
{
path: 'download-cleaner',
loadComponent: () => import('./settings/download-cleaner/download-cleaner-settings.component').then(m => m.DownloadCleanerSettingsComponent),
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) },
{ path: 'lidarr', loadComponent: () => import('./settings/lidarr/lidarr-settings.component').then(m => m.LidarrSettingsComponent) },

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ import { HttpClient } from "@angular/common/http";
import { Injectable, inject } from "@angular/core";
import { Observable, catchError, map, throwError } from "rxjs";
import { JobSchedule, QueueCleanerConfig, ScheduleUnit } from "../../shared/models/queue-cleaner-config.model";
import { ContentBlockerConfig, JobSchedule as ContentBlockerJobSchedule, ScheduleUnit as ContentBlockerScheduleUnit } from "../../shared/models/content-blocker-config.model";
import { MalwareBlockerConfig as MalwareBlockerConfig, JobSchedule as MalwareBlockerJobSchedule, ScheduleUnit as MalwareBlockerScheduleUnit } from "../../shared/models/malware-blocker-config.model";
import { SonarrConfig } from "../../shared/models/sonarr-config.model";
import { RadarrConfig } from "../../shared/models/radarr-config.model";
import { LidarrConfig } from "../../shared/models/lidarr-config.model";
@@ -81,15 +81,15 @@ export class ConfigurationService {
/**
* Get content blocker configuration
*/
getContentBlockerConfig(): Observable<ContentBlockerConfig> {
return this.http.get<ContentBlockerConfig>(this.ApplicationPathService.buildApiUrl('/configuration/content_blocker')).pipe(
getMalwareBlockerConfig(): Observable<MalwareBlockerConfig> {
return this.http.get<MalwareBlockerConfig>(this.ApplicationPathService.buildApiUrl('/configuration/malware_blocker')).pipe(
map((response) => {
response.jobSchedule = this.tryExtractContentBlockerJobScheduleFromCron(response.cronExpression);
response.jobSchedule = this.tryExtractMalwareBlockerJobScheduleFromCron(response.cronExpression);
return response;
}),
catchError((error) => {
console.error("Error fetching content blocker config:", error);
return throwError(() => new Error("Failed to load content blocker configuration"));
console.error("Error fetching Malware Blocker config:", error);
return throwError(() => new Error("Failed to load Malware Blocker configuration"));
})
);
}
@@ -97,14 +97,14 @@ export class ConfigurationService {
/**
* Update content blocker configuration
*/
updateContentBlockerConfig(config: ContentBlockerConfig): Observable<void> {
updateMalwareBlockerConfig(config: MalwareBlockerConfig): Observable<void> {
// Generate cron expression if using basic scheduling
if (!config.useAdvancedScheduling && config.jobSchedule) {
config.cronExpression = this.convertContentBlockerJobScheduleToCron(config.jobSchedule);
config.cronExpression = this.convertMalwareBlockerJobScheduleToCron(config.jobSchedule);
}
return this.http.put<void>(this.ApplicationPathService.buildApiUrl('/configuration/content_blocker'), config).pipe(
return this.http.put<void>(this.ApplicationPathService.buildApiUrl('/configuration/malware_blocker'), config).pipe(
catchError((error) => {
console.error("Error updating content blocker config:", error);
console.error("Error updating Malware Blocker config:", error);
const errorMessage = ErrorHandlerUtil.extractErrorMessage(error);
return throwError(() => new Error(errorMessage));
})
@@ -188,10 +188,10 @@ export class ConfigurationService {
}
/**
* Try to extract a ContentBlockerJobSchedule from a cron expression
* Try to extract a MalwareBlockerJobSchedule from a cron expression
* Only handles the simple cases we're generating
*/
private tryExtractContentBlockerJobScheduleFromCron(cronExpression: string): ContentBlockerJobSchedule | undefined {
private tryExtractMalwareBlockerJobScheduleFromCron(cronExpression: string): MalwareBlockerJobSchedule | undefined {
// Patterns we support:
// Seconds: */n * * ? * * * or 0/n * * ? * * * (Quartz.NET format)
// Minutes: 0 */n * ? * * * or 0 0/n * ? * * * (Quartz.NET format)
@@ -205,7 +205,7 @@ export class ConfigurationService {
if ((parts[0].startsWith("*/") || parts[0].startsWith("0/")) && parts[1] === "*") {
const seconds = parseInt(parts[0].substring(2));
if (!isNaN(seconds) && seconds > 0 && seconds < 60) {
return { every: seconds, type: ContentBlockerScheduleUnit.Seconds };
return { every: seconds, type: MalwareBlockerScheduleUnit.Seconds };
}
}
@@ -213,7 +213,7 @@ export class ConfigurationService {
if (parts[0] === "0" && (parts[1].startsWith("*/") || parts[1].startsWith("0/"))) {
const minutes = parseInt(parts[1].substring(2));
if (!isNaN(minutes) && minutes > 0 && minutes < 60) {
return { every: minutes, type: ContentBlockerScheduleUnit.Minutes };
return { every: minutes, type: MalwareBlockerScheduleUnit.Minutes };
}
}
@@ -221,7 +221,7 @@ export class ConfigurationService {
if (parts[0] === "0" && parts[1] === "0" && (parts[2].startsWith("*/") || parts[2].startsWith("0/"))) {
const hours = parseInt(parts[2].substring(2));
if (!isNaN(hours) && hours > 0 && hours < 24) {
return { every: hours, type: ContentBlockerScheduleUnit.Hours };
return { every: hours, type: MalwareBlockerScheduleUnit.Hours };
}
}
} catch (e) {
@@ -232,27 +232,27 @@ export class ConfigurationService {
}
/**
* Convert a ContentBlockerJobSchedule to a cron expression
* Convert a MalwareBlockerJobSchedule to a cron expression
*/
private convertContentBlockerJobScheduleToCron(schedule: ContentBlockerJobSchedule): string {
private convertMalwareBlockerJobScheduleToCron(schedule: MalwareBlockerJobSchedule): string {
if (!schedule || schedule.every <= 0) {
return "0/5 * * * * ?"; // Default: every 5 seconds (Quartz.NET format)
}
switch (schedule.type) {
case ContentBlockerScheduleUnit.Seconds:
case MalwareBlockerScheduleUnit.Seconds:
if (schedule.every < 60) {
return `0/${schedule.every} * * ? * * *`; // Quartz.NET format
}
break;
case ContentBlockerScheduleUnit.Minutes:
case MalwareBlockerScheduleUnit.Minutes:
if (schedule.every < 60) {
return `0 0/${schedule.every} * ? * * *`; // Quartz.NET format
}
break;
case ContentBlockerScheduleUnit.Hours:
case MalwareBlockerScheduleUnit.Hours:
if (schedule.every < 24) {
return `0 0 0/${schedule.every} ? * * *`; // Quartz.NET format
}

View File

@@ -43,7 +43,13 @@ export class DocumentationService {
'httpCertificateValidation': 'http-certificate-validation',
'searchEnabled': 'search-enabled',
'searchDelay': 'search-delay',
'logLevel': 'log-level',
'log.level': 'log-level',
'log.rollingSizeMB': 'log-rolling-size-mb',
'log.retainedFileCount': 'log-retained-file-count',
'log.timeLimitHours': 'log-time-limit-hours',
'log.archiveEnabled': 'log-archive-enabled',
'log.archiveRetainedCount': 'log-archive-retained-count',
'log.archiveTimeLimitHours': 'log-archive-time-limit-hours',
'ignoredDownloads': 'ignored-downloads'
},
'download-cleaner': {
@@ -63,14 +69,15 @@ export class DocumentationService {
'unlinkedIgnoredRootDir': 'ignored-root-directory',
'unlinkedCategories': 'unlinked-categories'
},
'content-blocker': {
'enabled': 'enable-content-blocker',
'malware-blocker': {
'enabled': 'enable-malware-blocker',
'useAdvancedScheduling': 'scheduling-mode',
'cronExpression': 'cron-expression',
'jobSchedule.every': 'run-schedule',
'jobSchedule.type': 'run-schedule',
'ignorePrivate': 'ignore-private',
'deletePrivate': 'delete-private',
'deleteKnownMalware': 'delete-known-malware',
'sonarr.enabled': 'enable-sonarr-blocklist',
'sonarr.blocklistPath': 'sonarr-blocklist-path',
'sonarr.blocklistType': 'sonarr-blocklist-type',
@@ -91,13 +98,15 @@ export class DocumentationService {
'password': 'password'
},
'notifications': {
'enabled': 'enabled',
'name': 'provider-name',
'notifiarr.apiKey': 'notifiarr-api-key',
'notifiarr.channelId': 'notifiarr-channel-id',
'apprise.fullUrl': 'apprise-url',
'apprise.url': 'apprise-url',
'apprise.key': 'apprise-key',
'apprise.tags': 'apprise-tags',
'eventTriggers': 'event-triggers'
}
// Additional sections will be added here as we implement them
};
constructor(private applicationPathService: ApplicationPathService) {}

View File

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

View File

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

View File

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

View File

@@ -10,115 +10,105 @@
</div>
<!-- Sidebar Navigation -->
<nav class="nav-menu">
<!-- Project Sponsors Link -->
<a href="https://cleanuparr.github.io/Cleanuparr/support" class="nav-item sponsor-link" target="_blank" rel="noopener noreferrer">
<!-- Show loading skeleton while determining navigation state -->
<nav class="nav-menu" *ngIf="!isNavigationReady">
<div class="nav-skeleton">
<div class="skeleton-item"
*ngFor="let item of getSkeletonItems()"
[class.sponsor-skeleton]="item.isSponsor">
</div>
</div>
</nav>
<!-- Show actual navigation when ready -->
<nav class="nav-menu"
*ngIf="isNavigationReady"
[@staggerItems]>
<!-- Project Sponsors Link (always visible) -->
<a href="https://cleanuparr.github.io/Cleanuparr/support"
class="nav-item sponsor-link"
target="_blank"
rel="noopener noreferrer">
<div class="nav-icon-wrapper heart-icon">
<i class="pi pi-heart"></i>
</div>
<span>Become A Sponsor</span>
</a>
<a [routerLink]="['/dashboard']" class="nav-item" [class.active]="router.url.includes('/dashboard')" (click)="onNavItemClick()">
<!-- Go Back button (shown when not at root level) -->
<div class="nav-item go-back-button"
*ngIf="canGoBack"
(click)="goBack()">
<div class="nav-icon-wrapper">
<i class="pi pi-home"></i>
<i class="pi pi-arrow-left"></i>
</div>
<span>Dashboard</span>
</a>
<!-- Settings Group -->
<div class="nav-group">
<div class="nav-group-title">Settings</div>
<a [routerLink]="['/sonarr']" class="nav-item" [class.active]="router.url.includes('/sonarr')" (click)="onNavItemClick()">
<div class="nav-icon-wrapper">
<i class="pi pi-play-circle"></i>
</div>
<span>Sonarr</span>
</a>
<a [routerLink]="['/radarr']" class="nav-item" [class.active]="router.url.includes('/radarr')" (click)="onNavItemClick()">
<div class="nav-icon-wrapper">
<i class="pi pi-play-circle"></i>
</div>
<span>Radarr</span>
</a>
<a [routerLink]="['/lidarr']" class="nav-item" [class.active]="router.url.includes('/lidarr')" (click)="onNavItemClick()">
<div class="nav-icon-wrapper">
<i class="pi pi-bolt"></i>
</div>
<span>Lidarr</span>
</a>
<a [routerLink]="['/readarr']" class="nav-item" [class.active]="router.url.includes('/readarr')" (click)="onNavItemClick()">
<div class="nav-icon-wrapper">
<i class="pi pi-book"></i>
</div>
<span>Readarr</span>
</a>
<a [routerLink]="['/whisparr']" class="nav-item" [class.active]="router.url.includes('/whisparr')" (click)="onNavItemClick()">
<div class="nav-icon-wrapper">
<i class="pi pi-lock"></i>
</div>
<span>Whisparr</span>
</a>
<a [routerLink]="['/download-clients']" class="nav-item" [class.active]="router.url.includes('/download-clients')" (click)="onNavItemClick()">
<div class="nav-icon-wrapper">
<i class="pi pi-download"></i>
</div>
<span>Download Clients</span>
</a>
<a [routerLink]="['/settings']" class="nav-item" [class.active]="router.url.includes('/settings')" (click)="onNavItemClick()">
<div class="nav-icon-wrapper">
<i class="pi pi-trash"></i>
</div>
<span>Cleanup</span>
</a>
<a [routerLink]="['/notifications']" class="nav-item" [class.active]="router.url.includes('/notifications')" (click)="onNavItemClick()">
<div class="nav-icon-wrapper">
<i class="pi pi-bell"></i>
</div>
<span>Notifications</span>
</a>
<span>Go Back</span>
</div>
<!-- Activity Group -->
<div class="nav-group">
<div class="nav-group-title">Activity</div>
<ng-container *ngFor="let item of menuItems">
<ng-container *ngIf="!['Dashboard', 'Settings'].includes(item.label)">
<a [routerLink]="item.route" class="nav-item" [class.active]="router.url.includes(item.route)" (click)="onNavItemClick()">
<div class="nav-icon-wrapper">
<i [class]="item.icon"></i>
</div>
<span>{{ item.label }}</span>
</a>
</ng-container>
<!-- Breadcrumb (optional, for better UX) -->
<div class="breadcrumb"
*ngIf="navigationBreadcrumb.length > 0">
<span *ngFor="let crumb of navigationBreadcrumb; let last = last; trackBy: trackByBreadcrumb">
{{ crumb.label }}
<i class="pi pi-chevron-right" *ngIf="!last"></i>
</span>
</div>
<!-- Navigation items container with container-level animation -->
<div class="navigation-items-container"
[@navigationContainer]="navigationStateKey">
<!-- Current level navigation items -->
<ng-container *ngFor="let item of currentNavigation; trackBy: trackByItemId">
<!-- Section headers for top-level sections -->
<div
class="nav-section-header"
*ngIf="item.isHeader">
<div class="nav-icon-wrapper">
<i [class]="item.icon"></i>
</div>
<span>{{ item.label }}</span>
</div>
<!-- Items with children (drill-down) - exclude top-level items -->
<div
class="nav-item nav-parent"
*ngIf="item.children && item.children.length > 0 && !item.topLevel"
(click)="navigateToLevel(item)">
<div class="nav-icon-wrapper">
<i [class]="item.icon"></i>
</div>
<span>{{ item.label }}</span>
<div class="nav-chevron">
<i class="pi pi-chevron-right"></i>
</div>
</div>
<!-- Direct navigation items -->
<a
[routerLink]="item.route"
class="nav-item"
*ngIf="!item.children && item.route && !item.isHeader"
[class.active]="router.url.includes(item.route)"
(click)="onNavItemClick()">
<div class="nav-icon-wrapper">
<i [class]="item.icon"></i>
</div>
<span>{{ item.label }}</span>
<span class="nav-badge" *ngIf="item.badge">{{ item.badge }}</span>
</a>
<!-- External links -->
<a
[href]="item.href"
class="nav-item"
*ngIf="!item.children && item.isExternal && !item.isHeader"
target="_blank"
rel="noopener noreferrer">
<div class="nav-icon-wrapper">
<i [class]="item.icon"></i>
</div>
<span>{{ item.label }}</span>
</a>
</ng-container>
</div>
<!-- Resources Group -->
<div class="nav-group">
<div class="nav-group-title">Resources</div>
<a href="https://github.com/Cleanuparr/Cleanuparr/issues" class="nav-item" target="_blank" rel="noopener noreferrer">
<div class="nav-icon-wrapper">
<i class="pi pi-github"></i>
</div>
<span>Issues and requests</span>
</a>
<a href="https://discord.gg/SCtMCgtsc4" class="nav-item" target="_blank" rel="noopener noreferrer">
<div class="nav-icon-wrapper">
<i class="pi pi-discord"></i>
</div>
<span>Discord</span>
</a>
</div>
<div class="nav-group">
<div class="nav-group-title">Recommended apps</div>
<a href="https://github.com/plexguide/Huntarr.io" class="nav-item" target="_blank" rel="noopener noreferrer">
<div class="nav-icon-wrapper">
<i class="pi pi-github"></i>
</div>
<span>Huntarr</span>
</a>
</div>
</nav>

View File

@@ -1,9 +1,19 @@
// Main container stability
:host {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden; // Prevent scrolling on the host
position: relative;
}
// Logo container
.logo-container {
display: flex;
align-items: center;
padding: 20px;
margin-top: 20px;
flex: 0 0 auto; // Prevent logo container from growing/shrinking
.logo {
display: flex;
@@ -47,10 +57,83 @@
// Navigation menu
.nav-menu {
display: flex;
flex-direction: column;
flex: 1;
gap: 1rem;
display: block;
gap: 0; // Remove gap to prevent layout shifts
transition: opacity 0.2s ease;
// Keep horizontal overflow hidden to avoid horizontal scrollbar
overflow-x: hidden;
// The nav area becomes the scrollable region. Use a calc so the
// header/logo area remains visible and the nav does not shrink
// other elements when it scrolls. Default header height can be
// overridden via the --sidebar-header-height CSS variable.
height: calc(100% - var(--sidebar-header-height, 120px));
box-sizing: border-box;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
// Navigation items container for smooth animations
.navigation-items-container {
display: flex;
flex-direction: column;
gap: 8px; // Consistent spacing between navigation items
position: relative; // Ensure proper stacking context for animations
width: 100%; // Take full width of parent
// Prevent horizontal scrollbar when items translate on hover
overflow-x: hidden;
padding-right: 8px; // leave space for the scrollbar
// Keep layout stable: do not force this element to flex-grow
// so icons and badges won't shrink when the nav scrolls.
min-height: 0;
}
// Loading skeleton
.nav-skeleton {
padding: 0;
.skeleton-item {
height: 60px; // Match actual nav-item height
padding: 10px 20px; // Match nav-item padding
margin-bottom: 8px; // Match nav-item spacing
display: flex;
align-items: center;
border-radius: 6px;
background: linear-gradient(90deg, var(--surface-200) 25%, var(--surface-300) 50%, var(--surface-200) 75%);
background-size: 200% 100%;
animation: skeleton-loading 1.5s infinite;
&:last-child {
margin-bottom: 0;
}
&.sponsor-skeleton {
margin-bottom: 15px;
}
// Add fake icon and text areas to match real content
&::before {
content: '';
width: 40px;
height: 40px;
border-radius: 8px;
background: var(--surface-300);
margin-right: 15px;
flex-shrink: 0;
}
&::after {
content: '';
height: 20px;
background: var(--surface-300);
border-radius: 4px;
flex: 1;
max-width: 120px;
}
}
}
// Sponsor link
.sponsor-link {
@@ -67,20 +150,78 @@
}
}
}
// Nav groups
.nav-group {
// Go back button styling
.go-back-button {
background-color: var(--surface-200);
border: 1px solid var(--surface-300);
margin-bottom: 15px;
cursor: pointer;
.nav-group-title {
font-size: 12px;
font-weight: 700;
color: var(--text-color-secondary);
text-transform: uppercase;
letter-spacing: 2px;
padding: 0 20px 8px;
margin: 5px 0;
border-bottom: 1px solid var(--surface-border);
&:hover {
transform: translateX(2px);
background-color: var(--surface-300);
.nav-icon-wrapper i {
transform: translateX(-2px);
}
}
}
// Breadcrumb styling
.breadcrumb {
padding: 8px 20px;
font-size: 12px;
color: var(--text-color-secondary);
border-bottom: 1px solid var(--surface-border);
margin-bottom: 10px;
overflow: hidden;
transition: all 0.25s ease;
span {
transition: all 0.2s ease;
}
i {
margin: 0 8px;
font-size: 10px;
transition: all 0.2s ease;
}
}
// Section headers for top-level sections
.nav-section-header {
display: flex;
align-items: center;
padding: 8px 20px 4px;
color: var(--text-color-secondary);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
margin: 15px 0 8px 0;
border-bottom: 1px solid var(--surface-border);
.nav-icon-wrapper {
width: 24px;
height: 24px;
display: flex;
justify-content: center;
align-items: center;
margin-right: 12px;
border-radius: 4px;
background: var(--surface-200);
flex-shrink: 0;
i {
font-size: 12px;
color: var(--text-color-secondary);
}
}
span {
font-size: 11px;
font-weight: 600;
}
}
@@ -94,7 +235,12 @@
border-radius: 0 6px 6px 0;
position: relative;
overflow: hidden;
transition: all 0.2s ease;
transition: all 0.2s cubic-bezier(0.4, 0.0, 0.2, 1);
margin-bottom: 8px; // Consistent spacing instead of gap
&:last-child {
margin-bottom: 0;
}
.nav-icon-wrapper {
width: 40px;
@@ -106,17 +252,33 @@
border-radius: 8px;
background: var(--surface-card);
border: 1px solid var(--surface-border);
transition: all 0.2s ease;
transition: all 0.2s cubic-bezier(0.4, 0.0, 0.2, 1);
flex-shrink: 0; // Prevent icon from shrinking
i {
font-size: 20px;
color: var(--text-color-secondary);
transition: all 0.2s cubic-bezier(0.4, 0.0, 0.2, 1);
}
}
span {
white-space: nowrap;
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
flex: 1; // Take available space
}
.nav-badge {
margin-left: auto;
background-color: var(--primary-color);
color: var(--primary-color-text);
border-radius: 12px;
padding: 2px 8px;
font-size: 12px;
font-weight: 600;
flex-shrink: 0;
}
&::before {
@@ -131,7 +293,9 @@
}
&:hover {
transform: translateX(4px);
background-color: var(--surface-hover);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
.nav-icon-wrapper {
background-color: rgba(var(--primary-500-rgb), 0.1);
@@ -161,6 +325,38 @@
}
}
}
// Parent navigation items (with children)
.nav-parent {
cursor: pointer;
position: relative;
.nav-chevron {
margin-left: auto;
opacity: 0.6;
transition: transform 0.3s cubic-bezier(0.4, 0.0, 0.2, 1);
flex-shrink: 0;
i {
font-size: 16px;
transition: transform 0.2s ease;
}
}
&:hover .nav-chevron i {
transform: translateX(3px) scale(1.1);
}
}
}
// Loading skeleton animation
@keyframes skeleton-loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
// Animation keyframes

Some files were not shown because too many files have changed in this diff Show More