Compare commits

...

29 Commits

Author SHA1 Message Date
Flaminel
22dfc7b40d Fix blocklist provider reporting wrong number of loaded blocklists (#293) 2025-09-04 22:14:46 +03:00
Flaminel
a51e387453 Fix log level change not taking effect (#292) 2025-09-04 22:12:57 +03:00
Flaminel
c7d2ec7311 Fix notification provider update (#291) 2025-09-03 23:48:02 +03:00
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
Flaminel
33a5bf9ab3 Add uTorrent support (#240) 2025-07-28 23:09:19 +03:00
Flaminel
de06d1c2d3 Fix download client type being sent as number instead of string (#245) 2025-07-27 14:23:48 +03:00
Flaminel
72855bc030 small fix on how_it_works page of the docs 2025-07-24 18:41:05 +03:00
eatsleepcoderepeat-gl
b185ea6899 Added new whitelist which includes subtitles (#243) 2025-07-24 12:50:03 +03:00
214 changed files with 12585 additions and 3591 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).
@@ -25,21 +26,24 @@ Cleanuparr was created primarily to address malicious files, such as `*.lnk` or
## 🎯 Supported Applications
### *Arr Applications
- **Sonarr** (TV Shows)
- **Radarr** (Movies)
- **Lidarr** (Music)
- **Sonarr**
- **Radarr**
- **Lidarr**
- **Readarr**
- **Whisparr**
### Download Clients
- **qBittorrent**
- **Transmission**
- **Deluge**
- **µTorrent**
### Platforms
- **Docker** (Linux, Windows, macOS)
- **Windows** (Native installer)
- **macOS** (Intel & Apple Silicon)
- **Linux** (Portable executable)
- **Unraid** (Community Apps)
- **Docker**
- **Windows**
- **macOS**
- **Linux**
- **Unraid**
## 🚀 Quick Start
@@ -55,7 +59,7 @@ docker run -d --name cleanuparr \
ghcr.io/cleanuparr/cleanuparr:latest
```
For Docker Compose, health checks, and other installation methods, see our [Complete Installation Guide](https://cleanuparr.github.io/Cleanuparr/docs/installation/detailed).
For Docker Compose, health checks, and other installation methods, see the [Complete Installation Guide](https://cleanuparr.github.io/Cleanuparr/docs/installation/detailed).
### 🌐 Access the Web Interface

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,448 @@ public class ConfigurationController : ControllerBase
}
}
[HttpPost("notification_providers/apprise")]
public async Task<IActionResult> CreateAppriseProvider([FromBody] CreateAppriseProviderDto newProvider)
{
await DataContext.Lock.WaitAsync();
try
{
// Validate required fields
if (string.IsNullOrWhiteSpace(newProvider.Name))
{
return BadRequest("Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs.CountAsync(x => x.Name == newProvider.Name);
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
}
// Create provider-specific configuration with validation
var appriseConfig = new AppriseConfig
{
Url = newProvider.Url,
Key = newProvider.Key,
Tags = newProvider.Tags
};
// Validate the configuration
appriseConfig.Validate();
// Create the provider entity
var provider = new NotificationConfig
{
Name = newProvider.Name,
Type = NotificationProviderType.Apprise,
IsEnabled = newProvider.IsEnabled,
OnFailedImportStrike = newProvider.OnFailedImportStrike,
OnStalledStrike = newProvider.OnStalledStrike,
OnSlowStrike = newProvider.OnSlowStrike,
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
OnDownloadCleaned = newProvider.OnDownloadCleaned,
OnCategoryChanged = newProvider.OnCategoryChanged,
AppriseConfiguration = appriseConfig
};
// Add the new provider to the database
_dataContext.NotificationConfigs.Add(provider);
await _dataContext.SaveChangesAsync();
// Clear cache to ensure fresh data on next request
await _notificationConfigurationService.InvalidateCacheAsync();
// Return the provider in DTO format to match frontend expectations
var providerDto = new NotificationProviderDto
{
Id = provider.Id,
Name = provider.Name,
Type = provider.Type,
IsEnabled = provider.IsEnabled,
Events = new NotificationEventFlags
{
OnFailedImportStrike = provider.OnFailedImportStrike,
OnStalledStrike = provider.OnStalledStrike,
OnSlowStrike = provider.OnSlowStrike,
OnQueueItemDeleted = provider.OnQueueItemDeleted,
OnDownloadCleaned = provider.OnDownloadCleaned,
OnCategoryChanged = provider.OnCategoryChanged
},
Configuration = provider.AppriseConfiguration ?? new object()
};
return CreatedAtAction(nameof(GetNotificationProviders), new { id = provider.Id }, providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create Apprise provider");
throw;
}
finally
{
DataContext.Lock.Release();
}
}
// Provider-specific UPDATE endpoints
[HttpPut("notification_providers/notifiarr/{id}")]
public async Task<IActionResult> UpdateNotifiarrProvider(Guid id, [FromBody] UpdateNotifiarrProviderDto updatedProvider)
{
await DataContext.Lock.WaitAsync();
try
{
// Find the existing notification provider
var existingProvider = await _dataContext.NotificationConfigs
.Include(p => p.NotifiarrConfiguration)
.FirstOrDefaultAsync(p => p.Id == id && p.Type == NotificationProviderType.Notifiarr);
if (existingProvider == null)
{
return NotFound($"Notifiarr provider with ID {id} not found");
}
// Validate required fields
if (string.IsNullOrWhiteSpace(updatedProvider.Name))
{
return BadRequest("Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs
.Where(x => x.Id != id)
.Where(x => x.Name == updatedProvider.Name)
.CountAsync();
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
}
// Create provider-specific configuration with validation
var notifiarrConfig = new NotifiarrConfig
{
ApiKey = updatedProvider.ApiKey,
ChannelId = updatedProvider.ChannelId
};
// Preserve the existing ID if updating
if (existingProvider.NotifiarrConfiguration != null)
{
notifiarrConfig = notifiarrConfig with { Id = existingProvider.NotifiarrConfiguration.Id };
}
// Validate the configuration
notifiarrConfig.Validate();
// Create a new provider entity with updated values (records are immutable)
var newProvider = existingProvider with
{
Name = updatedProvider.Name,
IsEnabled = updatedProvider.IsEnabled,
OnFailedImportStrike = updatedProvider.OnFailedImportStrike,
OnStalledStrike = updatedProvider.OnStalledStrike,
OnSlowStrike = updatedProvider.OnSlowStrike,
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
OnCategoryChanged = updatedProvider.OnCategoryChanged,
NotifiarrConfiguration = notifiarrConfig,
UpdatedAt = DateTime.UtcNow
};
// Remove old and add new (EF handles this as an update)
_dataContext.NotificationConfigs.Remove(existingProvider);
_dataContext.NotificationConfigs.Add(newProvider);
// Persist the configuration
await _dataContext.SaveChangesAsync();
// Clear cache to ensure fresh data on next request
await _notificationConfigurationService.InvalidateCacheAsync();
// Return the provider in DTO format to match frontend expectations
var providerDto = new NotificationProviderDto
{
Id = newProvider.Id,
Name = newProvider.Name,
Type = newProvider.Type,
IsEnabled = newProvider.IsEnabled,
Events = new NotificationEventFlags
{
OnFailedImportStrike = newProvider.OnFailedImportStrike,
OnStalledStrike = newProvider.OnStalledStrike,
OnSlowStrike = newProvider.OnSlowStrike,
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
OnDownloadCleaned = newProvider.OnDownloadCleaned,
OnCategoryChanged = newProvider.OnCategoryChanged
},
Configuration = newProvider.NotifiarrConfiguration ?? new object()
};
return Ok(providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update Notifiarr provider with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("notification_providers/apprise/{id}")]
public async Task<IActionResult> UpdateAppriseProvider(Guid id, [FromBody] UpdateAppriseProviderDto updatedProvider)
{
await DataContext.Lock.WaitAsync();
try
{
// Find the existing notification provider
var existingProvider = await _dataContext.NotificationConfigs
.Include(p => p.AppriseConfiguration)
.FirstOrDefaultAsync(p => p.Id == id && p.Type == NotificationProviderType.Apprise);
if (existingProvider == null)
{
return NotFound($"Apprise provider with ID {id} not found");
}
// Validate required fields
if (string.IsNullOrWhiteSpace(updatedProvider.Name))
{
return BadRequest("Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs
.Where(x => x.Id != id)
.Where(x => x.Name == updatedProvider.Name)
.CountAsync();
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
}
// Create provider-specific configuration with validation
var appriseConfig = new AppriseConfig
{
Url = updatedProvider.Url,
Key = updatedProvider.Key,
Tags = updatedProvider.Tags
};
// Preserve the existing ID if updating
if (existingProvider.AppriseConfiguration != null)
{
appriseConfig = appriseConfig with { Id = existingProvider.AppriseConfiguration.Id };
}
// Validate the configuration
appriseConfig.Validate();
// Create a new provider entity with updated values (records are immutable)
var newProvider = existingProvider with
{
Name = updatedProvider.Name,
IsEnabled = updatedProvider.IsEnabled,
OnFailedImportStrike = updatedProvider.OnFailedImportStrike,
OnStalledStrike = updatedProvider.OnStalledStrike,
OnSlowStrike = updatedProvider.OnSlowStrike,
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
OnCategoryChanged = updatedProvider.OnCategoryChanged,
AppriseConfiguration = appriseConfig,
UpdatedAt = DateTime.UtcNow
};
// Remove old and add new (EF handles this as an update)
_dataContext.NotificationConfigs.Remove(existingProvider);
_dataContext.NotificationConfigs.Add(newProvider);
// Persist the configuration
await _dataContext.SaveChangesAsync();
// Clear cache to ensure fresh data on next request
await _notificationConfigurationService.InvalidateCacheAsync();
// Return the provider in DTO format to match frontend expectations
var providerDto = new NotificationProviderDto
{
Id = newProvider.Id,
Name = newProvider.Name,
Type = newProvider.Type,
IsEnabled = newProvider.IsEnabled,
Events = new NotificationEventFlags
{
OnFailedImportStrike = newProvider.OnFailedImportStrike,
OnStalledStrike = newProvider.OnStalledStrike,
OnSlowStrike = newProvider.OnSlowStrike,
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
OnDownloadCleaned = newProvider.OnDownloadCleaned,
OnCategoryChanged = newProvider.OnCategoryChanged
},
Configuration = newProvider.AppriseConfiguration ?? new object()
};
return Ok(providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update Apprise provider with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpDelete("notification_providers/{id}")]
public async Task<IActionResult> DeleteNotificationProvider(Guid id)
{
await DataContext.Lock.WaitAsync();
try
{
// Find the existing notification provider
var existingProvider = await _dataContext.NotificationConfigs
.Include(p => p.NotifiarrConfiguration)
.Include(p => p.AppriseConfiguration)
.FirstOrDefaultAsync(p => p.Id == id);
if (existingProvider == null)
{
return NotFound($"Notification provider with ID {id} not found");
}
// Remove the provider from the database
_dataContext.NotificationConfigs.Remove(existingProvider);
await _dataContext.SaveChangesAsync();
// Clear cache to ensure fresh data on next request
await _notificationConfigurationService.InvalidateCacheAsync();
_logger.LogInformation("Removed notification provider {ProviderName} with ID {ProviderId}",
existingProvider.Name, existingProvider.Id);
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete notification provider with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
// Provider-specific TEST endpoints (no ID required)
[HttpPost("notification_providers/notifiarr/test")]
public async Task<IActionResult> TestNotifiarrProvider([FromBody] TestNotifiarrProviderDto testRequest)
{
try
{
// Create configuration for testing with validation
var notifiarrConfig = new NotifiarrConfig
{
ApiKey = testRequest.ApiKey,
ChannelId = testRequest.ChannelId
};
// Validate the configuration
notifiarrConfig.Validate();
// Create a temporary provider DTO for the test service
var providerDto = new NotificationProviderDto
{
Id = Guid.NewGuid(), // Temporary ID for testing
Name = "Test Provider",
Type = NotificationProviderType.Notifiarr,
IsEnabled = true,
Events = new NotificationEventFlags
{
OnFailedImportStrike = true, // Enable for test
OnStalledStrike = false,
OnSlowStrike = false,
OnQueueItemDeleted = false,
OnDownloadCleaned = false,
OnCategoryChanged = false
},
Configuration = notifiarrConfig
};
// Test the notification provider
await _notificationService.SendTestNotificationAsync(providerDto);
return Ok(new { Message = "Test notification sent successfully", Success = true });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Notifiarr provider");
throw;
}
}
[HttpPost("notification_providers/apprise/test")]
public async Task<IActionResult> TestAppriseProvider([FromBody] TestAppriseProviderDto testRequest)
{
try
{
// Create configuration for testing with validation
var appriseConfig = new AppriseConfig
{
Url = testRequest.Url,
Key = testRequest.Key,
Tags = testRequest.Tags
};
// Validate the configuration
appriseConfig.Validate();
// Create a temporary provider DTO for the test service
var providerDto = new NotificationProviderDto
{
Id = Guid.NewGuid(), // Temporary ID for testing
Name = "Test Provider",
Type = NotificationProviderType.Apprise,
IsEnabled = true,
Events = new NotificationEventFlags
{
OnFailedImportStrike = true, // Enable for test
OnStalledStrike = false,
OnSlowStrike = false,
OnQueueItemDeleted = false,
OnDownloadCleaned = false,
OnCategoryChanged = false
},
Configuration = appriseConfig
};
// Test the notification provider
await _notificationService.SendTestNotificationAsync(providerDto);
return Ok(new { Message = "Test notification sent successfully", Success = true });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Apprise provider");
throw;
}
}
[HttpPut("queue_cleaner")]
public async Task<IActionResult> UpdateQueueCleanerConfig([FromBody] QueueCleanerConfig newConfig)
{
@@ -483,8 +969,8 @@ public class ConfigurationController : ControllerBase
}
}
[HttpPut("content_blocker")]
public async Task<IActionResult> UpdateContentBlockerConfig([FromBody] ContentBlockerConfig newConfig)
[HttpPut("malware_blocker")]
public async Task<IActionResult> UpdateMalwareBlockerConfig([FromBody] ContentBlockerConfig newConfig)
{
await DataContext.Lock.WaitAsync();
try
@@ -495,7 +981,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 +999,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
@@ -687,6 +1173,9 @@ public class ConfigurationController : ControllerBase
}
}
// Handle logging configuration changes
var loggingChanged = HasLoggingConfigurationChanged(oldConfig.Log, newConfig.Log);
newConfig.Adapt(oldConfig, config);
// Persist the configuration
@@ -699,9 +1188,17 @@ public class ConfigurationController : ControllerBase
dynamicHttpClientFactory.UpdateAllClientsFromGeneralConfig(oldConfig);
_logger.LogInformation("Updated all HTTP client configurations with new general settings");
// Set the logging level based on the new configuration
_loggingConfigManager.SetLogLevel(newConfig.LogLevel);
if (loggingChanged.LevelOnly)
{
_logger.LogCritical("Setting global log level to {level}", newConfig.Log.Level);
LoggingConfigManager.SetLogLevel(newConfig.Log.Level);
}
else if (loggingChanged.FullReconfiguration)
{
_logger.LogCritical("Reconfiguring logger due to configuration changes");
LoggingConfigManager.ReconfigureLogging(newConfig);
}
return Ok(new { Message = "General configuration updated successfully" });
}
@@ -1454,4 +1951,40 @@ public class ConfigurationController : ControllerBase
DataContext.Lock.Release();
}
}
/// <summary>
/// Determines what type of logging reconfiguration is needed based on configuration changes
/// </summary>
/// <param name="oldConfig">The previous logging configuration</param>
/// <param name="newConfig">The new logging configuration</param>
/// <returns>A tuple indicating the type of reconfiguration needed</returns>
private static (bool LevelOnly, bool FullReconfiguration) HasLoggingConfigurationChanged(LoggingConfig oldConfig, LoggingConfig newConfig)
{
// Check if only the log level changed
bool levelChanged = oldConfig.Level != newConfig.Level;
// Check if other logging properties changed that require full reconfiguration
bool otherPropertiesChanged =
oldConfig.RollingSizeMB != newConfig.RollingSizeMB ||
oldConfig.RetainedFileCount != newConfig.RetainedFileCount ||
oldConfig.TimeLimitHours != newConfig.TimeLimitHours ||
oldConfig.ArchiveEnabled != newConfig.ArchiveEnabled ||
oldConfig.ArchiveRetainedCount != newConfig.ArchiveRetainedCount ||
oldConfig.ArchiveTimeLimitHours != newConfig.ArchiveTimeLimitHours;
if (otherPropertiesChanged)
{
// Full reconfiguration needed (includes level change if any)
return (false, true);
}
if (levelChanged)
{
// Only level changed, simple level update is sufficient
return (true, false);
}
// No logging configuration changes
return (false, false);
}
}

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,69 @@
namespace Cleanuparr.Domain.Entities.UTorrent.Request;
/// <summary>
/// Represents a request to the µTorrent Web UI API
/// </summary>
public sealed class UTorrentRequest
{
/// <summary>
/// The API action to perform
/// </summary>
public string Action { get; set; } = string.Empty;
/// <summary>
/// Authentication token (required for CSRF protection)
/// </summary>
public string Token { get; set; } = string.Empty;
/// <summary>
/// Additional parameters for the request
/// </summary>
public List<(string Name, string Value)> Parameters { get; set; } = new();
/// <summary>
/// Constructs the query string for the API call
/// </summary>
/// <returns>The complete query string including token and action</returns>
public string ToQueryString()
{
var queryParams = new List<string>
{
$"token={Token}",
Action
};
foreach (var param in Parameters)
{
queryParams.Add($"{Uri.EscapeDataString(param.Name)}={Uri.EscapeDataString(param.Value)}");
}
return string.Join("&", queryParams);
}
/// <summary>
/// Creates a new request with the specified action
/// </summary>
/// <param name="action">The API action</param>
/// <param name="token">Authentication token</param>
/// <returns>A new UTorrentRequest instance</returns>
public static UTorrentRequest Create(string action, string token)
{
return new UTorrentRequest
{
Action = action,
Token = token
};
}
/// <summary>
/// Adds a parameter to the request
/// </summary>
/// <param name="key">Parameter name</param>
/// <param name="value">Parameter value</param>
/// <returns>This instance for method chaining</returns>
public UTorrentRequest WithParameter(string key, string value)
{
Parameters.Add((key, value));
return this;
}
}

View File

@@ -0,0 +1,28 @@
using Newtonsoft.Json;
namespace Cleanuparr.Domain.Entities.UTorrent.Response;
/// <summary>
/// Specific response type for file list API calls
/// Replaces the generic UTorrentResponse<T> for file listings
/// </summary>
public sealed class FileListResponse
{
/// <summary>
/// Raw file data from the API
/// </summary>
[JsonProperty(PropertyName = "files")]
public object[]? FilesRaw { get; set; }
/// <summary>
/// Torrent hash for which files are listed
/// </summary>
[JsonIgnore]
public string Hash { get; set; } = string.Empty;
/// <summary>
/// Parsed files as strongly-typed objects
/// </summary>
[JsonIgnore]
public List<UTorrentFile> Files { get; set; } = new();
}

View File

@@ -0,0 +1,22 @@
using Newtonsoft.Json;
namespace Cleanuparr.Domain.Entities.UTorrent.Response;
/// <summary>
/// Specific response type for label list API calls
/// Replaces the generic UTorrentResponse<T> for label listings
/// </summary>
public sealed class LabelListResponse
{
/// <summary>
/// Raw label data from the API
/// </summary>
[JsonProperty(PropertyName = "label")]
public object[][]? LabelsRaw { get; set; }
/// <summary>
/// Parsed labels as string list
/// </summary>
[JsonIgnore]
public List<string> Labels { get; set; } = new();
}

View File

@@ -0,0 +1,22 @@
using Newtonsoft.Json;
namespace Cleanuparr.Domain.Entities.UTorrent.Response;
/// <summary>
/// Specific response type for torrent properties API calls
/// Replaces the generic UTorrentResponse<T> for properties retrieval
/// </summary>
public sealed class PropertiesResponse
{
/// <summary>
/// Raw properties data from the API
/// </summary>
[JsonProperty(PropertyName = "props")]
public object[]? PropertiesRaw { get; set; }
/// <summary>
/// Parsed properties as strongly-typed object
/// </summary>
[JsonIgnore]
public UTorrentProperties Properties { get; set; } = new();
}

View File

@@ -0,0 +1,40 @@
using Newtonsoft.Json;
namespace Cleanuparr.Domain.Entities.UTorrent.Response;
/// <summary>
/// Specific response type for torrent list API calls
/// Replaces the generic UTorrentResponse<T> for torrent listings
/// </summary>
public sealed class TorrentListResponse
{
/// <summary>
/// µTorrent build number
/// </summary>
[JsonProperty(PropertyName = "build")]
public int Build { get; set; }
/// <summary>
/// List of torrent data from the API
/// </summary>
[JsonProperty(PropertyName = "torrents")]
public object[][]? TorrentsRaw { get; set; }
/// <summary>
/// Label data from the API
/// </summary>
[JsonProperty(PropertyName = "label")]
public object[][]? LabelsRaw { get; set; }
/// <summary>
/// Parsed torrents as strongly-typed objects
/// </summary>
[JsonIgnore]
public List<UTorrentItem> Torrents { get; set; } = new();
/// <summary>
/// Parsed labels as string list
/// </summary>
[JsonIgnore]
public List<string> Labels { get; set; } = new();
}

View File

@@ -0,0 +1,18 @@
namespace Cleanuparr.Domain.Entities.UTorrent.Response;
/// <summary>
/// Represents a file within a torrent from µTorrent Web UI API
/// Based on the files array structure from the API documentation
/// </summary>
public sealed class UTorrentFile
{
public string Name { get; set; } = string.Empty;
public long Size { get; set; }
public long Downloaded { get; set; }
public int Priority { get; set; }
public int Index { get; set; }
}

View File

@@ -0,0 +1,181 @@
using Newtonsoft.Json;
namespace Cleanuparr.Domain.Entities.UTorrent.Response;
/// <summary>
/// Represents a torrent from µTorrent Web UI API
/// Based on the torrent array structure from the API documentation
/// </summary>
public sealed class UTorrentItem
{
/// <summary>
/// Torrent hash (index 0)
/// </summary>
public string Hash { get; set; } = string.Empty;
/// <summary>
/// Status bitfield (index 1)
/// </summary>
public int Status { get; set; }
/// <summary>
/// Torrent name (index 2)
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Total size in bytes (index 3)
/// </summary>
public long Size { get; set; }
/// <summary>
/// Progress in permille (1000 = 100%) (index 4)
/// </summary>
public int Progress { get; set; }
/// <summary>
/// Downloaded bytes (index 5)
/// </summary>
public long Downloaded { get; set; }
/// <summary>
/// Uploaded bytes (index 6)
/// </summary>
public long Uploaded { get; set; }
/// <summary>
/// Ratio * 1000 (index 7)
/// </summary>
public int RatioRaw { get; set; }
/// <summary>
/// Upload speed in bytes/sec (index 8)
/// </summary>
public int UploadSpeed { get; set; }
/// <summary>
/// Download speed in bytes/sec (index 9)
/// </summary>
public int DownloadSpeed { get; set; }
/// <summary>
/// ETA in seconds (index 10)
/// </summary>
public int ETA { get; set; }
/// <summary>
/// Label (index 11)
/// </summary>
public string Label { get; set; } = string.Empty;
/// <summary>
/// Connected peers (index 12)
/// </summary>
public int PeersConnected { get; set; }
/// <summary>
/// Peers in swarm (index 13)
/// </summary>
public int PeersInSwarm { get; set; }
/// <summary>
/// Connected seeds (index 14)
/// </summary>
public int SeedsConnected { get; set; }
/// <summary>
/// Seeds in swarm (index 15)
/// </summary>
public int SeedsInSwarm { get; set; }
/// <summary>
/// Availability (index 16)
/// </summary>
public int Availability { get; set; }
/// <summary>
/// Queue order (index 17)
/// </summary>
public int QueueOrder { get; set; }
/// <summary>
/// Remaining bytes (index 18)
/// </summary>
public long Remaining { get; set; }
/// <summary>
/// Download URL (index 19)
/// </summary>
public string DownloadUrl { get; set; } = string.Empty;
/// <summary>
/// RSS feed URL (index 20)
/// </summary>
public string RssFeedUrl { get; set; } = string.Empty;
/// <summary>
/// Status message (index 21)
/// </summary>
public string StatusMessage { get; set; } = string.Empty;
/// <summary>
/// Stream ID (index 22)
/// </summary>
public string StreamId { get; set; } = string.Empty;
/// <summary>
/// Date added as Unix timestamp (index 23)
/// </summary>
public long DateAdded { get; set; }
/// <summary>
/// Date completed as Unix timestamp (index 24)
/// </summary>
public long DateCompleted { get; set; }
/// <summary>
/// App update URL (index 25)
/// </summary>
public string AppUpdateUrl { get; set; } = string.Empty;
/// <summary>
/// Save path (index 26)
/// </summary>
public string SavePath { get; set; } = string.Empty;
/// <summary>
/// Calculated ratio value (RatioRaw / 1000.0)
/// </summary>
[JsonIgnore]
public double Ratio => RatioRaw / 1000.0;
/// <summary>
/// Progress as percentage (0.0 to 1.0)
/// </summary>
[JsonIgnore]
public double ProgressPercent => Progress / 1000.0;
/// <summary>
/// Date completed as DateTime (or null if not completed)
/// </summary>
[JsonIgnore]
public DateTime? DateCompletedDateTime =>
DateCompleted > 0 ? DateTimeOffset.FromUnixTimeSeconds(DateCompleted).DateTime : null;
/// <summary>
/// Seeding time in seconds (calculated from DateCompleted to now)
/// </summary>
[JsonIgnore]
public TimeSpan? SeedingTime
{
get
{
if (DateCompletedDateTime.HasValue)
{
return DateTime.UtcNow - DateCompletedDateTime.Value;
}
return null;
}
}
}

View File

@@ -0,0 +1,85 @@
namespace Cleanuparr.Domain.Entities.UTorrent.Response;
/// <summary>
/// Represents torrent properties from µTorrent Web UI API getprops action
/// Based on the properties structure from the API documentation
/// </summary>
public sealed class UTorrentProperties
{
/// <summary>
/// Torrent hash
/// </summary>
public string Hash { get; set; } = string.Empty;
/// <summary>
/// Trackers list (newlines are represented by \r\n)
/// </summary>
public string Trackers { get; set; } = string.Empty;
public List<string> TrackerList => Trackers
.Split(new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries)
.Select(x => x.Trim())
.Where(x => !string.IsNullOrWhiteSpace(x))
.ToList();
/// <summary>
/// Upload limit in bytes per second
/// </summary>
public int UploadLimit { get; set; }
/// <summary>
/// Download limit in bytes per second
/// </summary>
public int DownloadLimit { get; set; }
/// <summary>
/// Initial seeding / Super seeding
/// -1 = Not allowed, 0 = Disabled, 1 = Enabled
/// </summary>
public int SuperSeed { get; set; }
/// <summary>
/// Use DHT
/// -1 = Not allowed, 0 = Disabled, 1 = Enabled
/// </summary>
public int Dht { get; set; }
/// <summary>
/// Use PEX (Peer Exchange)
/// -1 = Not allowed (indicates private torrent), 0 = Disabled, 1 = Enabled
/// </summary>
public int Pex { get; set; }
/// <summary>
/// Override queueing
/// -1 = Not allowed, 0 = Disabled, 1 = Enabled
/// </summary>
public int SeedOverride { get; set; }
/// <summary>
/// Seed ratio in per mils (1000 = 1.0 ratio)
/// </summary>
public int SeedRatio { get; set; }
/// <summary>
/// Seeding time in seconds
/// 0 = No minimum seeding time
/// </summary>
public int SeedTime { get; set; }
/// <summary>
/// Upload slots
/// </summary>
public int UploadSlots { get; set; }
/// <summary>
/// Whether this torrent is private (based on PEX value)
/// Private torrents have PEX = -1 (not allowed)
/// </summary>
public bool IsPrivate => Pex == -1;
/// <summary>
/// Calculated seed ratio value (SeedRatio / 1000.0)
/// </summary>
public double SeedRatioValue => SeedRatio / 1000.0;
}

View File

@@ -0,0 +1,61 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Cleanuparr.Domain.Entities.UTorrent.Response;
/// <summary>
/// Base response wrapper for µTorrent Web UI API calls
/// </summary>
public sealed record UTorrentResponse<T>
{
[JsonProperty(PropertyName = "build")]
public int Build { get; set; }
[JsonProperty(PropertyName = "label")]
public object[][]? Labels { get; set; }
[JsonProperty(PropertyName = "torrents")]
public T? Torrents { get; set; }
[JsonProperty(PropertyName = "torrentp")]
public object[]? TorrentProperties { get; set; }
[JsonProperty(PropertyName = "files")]
public object[]? FilesDto { get; set; }
[JsonIgnore]
public List<UTorrentFile>? Files
{
get
{
if (FilesDto is null || FilesDto.Length < 2)
{
return null;
}
var files = new List<UTorrentFile>();
if (FilesDto[1] is JArray jArray)
{
foreach (var jToken in jArray)
{
var fileTokenArray = (JArray)jToken;
var fileArray = fileTokenArray.ToObject<object[]>() ?? [];
files.Add(new UTorrentFile
{
Name = fileArray[0].ToString() ?? string.Empty,
Size = Convert.ToInt64(fileArray[1]),
Downloaded = Convert.ToInt64(fileArray[2]),
Priority = Convert.ToInt32(fileArray[3]),
});
}
}
return files;
}
}
[JsonProperty(PropertyName = "props")]
public UTorrentProperties[]? Properties { get; set; }
}

View File

@@ -2,7 +2,8 @@
public enum DownloadClientTypeName
{
QBittorrent,
qBittorrent,
Deluge,
Transmission,
}
uTorrent,
}

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,4 +1,4 @@
namespace Data.Models.Deluge.Exceptions;
namespace Cleanuparr.Domain.Exceptions;
public class DelugeClientException : Exception
{

View File

@@ -1,4 +1,4 @@
namespace Data.Models.Deluge.Exceptions;
namespace Cleanuparr.Domain.Exceptions;
public sealed class DelugeLoginException : DelugeClientException
{

View File

@@ -1,4 +1,4 @@
namespace Data.Models.Deluge.Exceptions;
namespace Cleanuparr.Domain.Exceptions;
public sealed class DelugeLogoutException : DelugeClientException
{

View File

@@ -0,0 +1,15 @@
namespace Cleanuparr.Domain.Exceptions;
/// <summary>
/// Exception thrown when µTorrent authentication fails
/// </summary>
public class UTorrentAuthenticationException : UTorrentException
{
public UTorrentAuthenticationException(string message) : base(message)
{
}
public UTorrentAuthenticationException(string message, Exception innerException) : base(message, innerException)
{
}
}

View File

@@ -0,0 +1,12 @@
namespace Cleanuparr.Domain.Exceptions;
public class UTorrentException : Exception
{
public UTorrentException(string message) : base(message)
{
}
public UTorrentException(string message, Exception innerException) : base(message, innerException)
{
}
}

View File

@@ -0,0 +1,22 @@
namespace Cleanuparr.Domain.Exceptions;
/// <summary>
/// Exception thrown when µTorrent response parsing fails
/// </summary>
public class UTorrentParsingException : UTorrentException
{
/// <summary>
/// The raw response that failed to parse
/// </summary>
public string RawResponse { get; }
public UTorrentParsingException(string message, string rawResponse) : base(message)
{
RawResponse = rawResponse;
}
public UTorrentParsingException(string message, string rawResponse, Exception innerException) : base(message, innerException)
{
RawResponse = rawResponse;
}
}

View File

@@ -16,6 +16,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Serilog" Version="4.3.0" />
<PackageReference Include="Serilog.Expressions" Version="5.0.0" />

View File

@@ -0,0 +1,266 @@
using System.Net;
using Cleanuparr.Domain.Entities.UTorrent.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
using Cleanuparr.Persistence.Models.Configuration;
using Microsoft.Extensions.Logging;
using Moq;
using Moq.Protected;
using Newtonsoft.Json;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Verticals.DownloadClient;
public class UTorrentClientTests
{
private readonly UTorrentClient _client;
private readonly Mock<HttpMessageHandler> _mockHttpHandler;
private readonly DownloadClientConfig _config;
private readonly Mock<IUTorrentAuthenticator> _mockAuthenticator;
private readonly Mock<IUTorrentHttpService> _mockHttpService;
private readonly Mock<IUTorrentResponseParser> _mockResponseParser;
private readonly Mock<ILogger<UTorrentClient>> _mockLogger;
public UTorrentClientTests()
{
_mockHttpHandler = new Mock<HttpMessageHandler>();
_mockAuthenticator = new Mock<IUTorrentAuthenticator>();
_mockHttpService = new Mock<IUTorrentHttpService>();
_mockResponseParser = new Mock<IUTorrentResponseParser>();
_mockLogger = new Mock<ILogger<UTorrentClient>>();
_config = new DownloadClientConfig
{
Name = "test",
Type = DownloadClientType.Torrent,
TypeName = DownloadClientTypeName.uTorrent,
Host = new Uri("http://localhost:8080"),
Username = "admin",
Password = "password"
};
_client = new UTorrentClient(
_config,
_mockAuthenticator.Object,
_mockHttpService.Object,
_mockResponseParser.Object,
_mockLogger.Object
);
}
[Fact]
public async Task GetTorrentFilesAsync_ShouldDeserializeMixedArrayCorrectly()
{
// Arrange
var mockResponse = new UTorrentResponse<object>
{
Build = 30470,
FilesDto = new object[]
{
"F0616FB199B78254474AF6D72705177E71D713ED", // Hash (string)
new object[] // File 1
{
"test name",
2604L,
0L,
2,
0,
1,
false,
-1,
-1,
-1,
-1,
-1,
0
},
new object[] // File 2
{
"Dir1/Dir11/test11.zipx",
2604L,
0L,
2,
0,
1,
false,
-1,
-1,
-1,
-1,
-1,
0
},
new object[] // File 3
{
"Dir1/sample.txt",
2604L,
0L,
2,
0,
1,
false,
-1,
-1,
-1,
-1,
-1,
0
}
}
};
// Mock the token request
var tokenResponse = new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent("<div id='token'>test-token</div>")
};
tokenResponse.Headers.Add("Set-Cookie", "GUID=test-guid; path=/");
// Mock the files request
var filesResponse = new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(JsonConvert.SerializeObject(mockResponse))
};
// Setup mock to return different responses based on URL
_mockHttpHandler
.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.AbsolutePath.Contains("token.html")),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(tokenResponse);
_mockHttpHandler
.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.AbsolutePath.Contains("gui") && req.RequestUri.Query.Contains("action=getfiles")),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(filesResponse);
// Act
var files = await _client.GetTorrentFilesAsync("test-hash");
// Assert
Assert.NotNull(files);
Assert.Equal(3, files.Count);
Assert.Equal("test name", files[0].Name);
Assert.Equal(2604L, files[0].Size);
Assert.Equal(0L, files[0].Downloaded);
Assert.Equal(2, files[0].Priority);
Assert.Equal(0, files[0].Index);
Assert.Equal("Dir1/Dir11/test11.zipx", files[1].Name);
Assert.Equal(2604L, files[1].Size);
Assert.Equal(0L, files[1].Downloaded);
Assert.Equal(2, files[1].Priority);
Assert.Equal(1, files[1].Index);
Assert.Equal("Dir1/sample.txt", files[2].Name);
Assert.Equal(2604L, files[2].Size);
Assert.Equal(0L, files[2].Downloaded);
Assert.Equal(2, files[2].Priority);
Assert.Equal(2, files[2].Index);
}
[Fact]
public async Task GetTorrentFilesAsync_ShouldHandleEmptyResponse()
{
// Arrange
var mockResponse = new UTorrentResponse<object>
{
Build = 30470,
FilesDto = new object[]
{
"F0616FB199B78254474AF6D72705177E71D713ED" // Only hash, no files
}
};
// Mock the token request
var tokenResponse = new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent("<div id='token'>test-token</div>")
};
tokenResponse.Headers.Add("Set-Cookie", "GUID=test-guid; path=/");
// Mock the files request
var filesResponse = new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(JsonConvert.SerializeObject(mockResponse))
};
// Setup mock to return different responses based on URL
_mockHttpHandler
.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.AbsolutePath.Contains("token.html")),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(tokenResponse);
_mockHttpHandler
.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.AbsolutePath.Contains("gui") && req.RequestUri.Query.Contains("action=getfiles")),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(filesResponse);
// Act
var files = await _client.GetTorrentFilesAsync("test-hash");
// Assert
Assert.NotNull(files);
Assert.Empty(files);
}
[Fact]
public async Task GetTorrentFilesAsync_ShouldHandleNullResponse()
{
// Arrange
var mockResponse = new UTorrentResponse<object>
{
Build = 30470,
FilesDto = null
};
// Mock the token request
var tokenResponse = new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent("<div id='token'>test-token</div>")
};
tokenResponse.Headers.Add("Set-Cookie", "GUID=test-guid; path=/");
// Mock the files request
var filesResponse = new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(JsonConvert.SerializeObject(mockResponse))
};
// Setup mock to return different responses based on URL
_mockHttpHandler
.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.AbsolutePath.Contains("token.html")),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(tokenResponse);
_mockHttpHandler
.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.AbsolutePath.Contains("gui") && req.RequestUri.Query.Contains("action=getfiles")),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(filesResponse);
// Act
var files = await _client.GetTorrentFilesAsync("test-hash");
// Assert
Assert.NotNull(files);
Assert.Empty(files);
}
}

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

@@ -4,7 +4,6 @@ using Cleanuparr.Domain.Entities.Deluge.Response;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Features.DownloadClient.Deluge.Extensions;
using Cleanuparr.Persistence.Models.Configuration;
using Data.Models.Deluge.Exceptions;
using Newtonsoft.Json;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;

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;
@@ -12,6 +12,7 @@ using Microsoft.Extensions.Logging;
using DelugeService = Cleanuparr.Infrastructure.Features.DownloadClient.Deluge.DelugeService;
using QBitService = Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent.QBitService;
using TransmissionService = Cleanuparr.Infrastructure.Features.DownloadClient.Transmission.TransmissionService;
using UTorrentService = Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent.UTorrentService;
namespace Cleanuparr.Infrastructure.Features.DownloadClient;
@@ -46,9 +47,10 @@ public sealed class DownloadServiceFactory
return downloadClientConfig.TypeName switch
{
DownloadClientTypeName.QBittorrent => CreateQBitService(downloadClientConfig),
DownloadClientTypeName.qBittorrent => CreateQBitService(downloadClientConfig),
DownloadClientTypeName.Deluge => CreateDelugeService(downloadClientConfig),
DownloadClientTypeName.Transmission => CreateTransmissionService(downloadClientConfig),
DownloadClientTypeName.uTorrent => CreateUTorrentService(downloadClientConfig),
_ => throw new NotSupportedException($"Download client type {downloadClientConfig.TypeName} is not supported")
};
}
@@ -115,4 +117,26 @@ public sealed class DownloadServiceFactory
return service;
}
private UTorrentService CreateUTorrentService(DownloadClientConfig downloadClientConfig)
{
var logger = _serviceProvider.GetRequiredService<ILogger<UTorrentService>>();
var cache = _serviceProvider.GetRequiredService<IMemoryCache>();
var filenameEvaluator = _serviceProvider.GetRequiredService<IFilenameEvaluator>();
var striker = _serviceProvider.GetRequiredService<IStriker>();
var dryRunInterceptor = _serviceProvider.GetRequiredService<IDryRunInterceptor>();
var hardLinkFileService = _serviceProvider.GetRequiredService<IHardLinkFileService>();
var httpClientProvider = _serviceProvider.GetRequiredService<IDynamicHttpClientProvider>();
var eventPublisher = _serviceProvider.GetRequiredService<EventPublisher>();
var blocklistProvider = _serviceProvider.GetRequiredService<BlocklistProvider>();
var loggerFactory = _serviceProvider.GetRequiredService<ILoggerFactory>();
// Create the UTorrentService instance
UTorrentService service = new(
logger, cache, filenameEvaluator, striker, dryRunInterceptor,
hardLinkFileService, httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, loggerFactory
);
return service;
}
}

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;
@@ -39,7 +39,7 @@ public partial class QBitService
if (torrentProperties is null)
{
_logger.LogDebug("failed to find torrent properties {name}", download.Name);
_logger.LogError("Failed to find torrent properties {name}", download.Name);
return result;
}
@@ -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);
@@ -60,9 +60,9 @@ public partial class QBitService
IReadOnlyList<TorrentContent>? files = await _client.GetTorrentContentsAsync(hash);
if (files is null)
if (files?.Count is null or 0)
{
_logger.LogDebug("torrent {hash} has no files", hash);
_logger.LogDebug("skip files check | no files found | {name}", download.Name);
return result;
}
@@ -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

@@ -97,7 +97,7 @@ public partial class QBitService
if (torrentProperties is null)
{
_logger.LogDebug("failed to find torrent properties | {name}", download.Name);
_logger.LogError("Failed to find torrent properties | {name}", download.Name);
return;
}

View File

@@ -38,7 +38,7 @@ public partial class QBitService
if (torrentProperties is null)
{
_logger.LogDebug("failed to find torrent properties {hash}", download.Name);
_logger.LogError("Failed to find torrent properties for {name}", download.Name);
return result;
}
@@ -89,32 +89,32 @@ public partial class QBitService
if (queueCleanerConfig.Slow.MaxStrikes is 0)
{
_logger.LogDebug("skip slow check | max strikes is 0 | {name}", download.Name);
_logger.LogTrace("skip slow check | max strikes is 0 | {name}", download.Name);
return (false, DeleteReason.None);
}
if (download.State is not (TorrentState.Downloading or TorrentState.ForcedDownload))
{
_logger.LogDebug("skip slow check | download is in {state} state | {name}", download.State, download.Name);
_logger.LogTrace("skip slow check | download is in {state} state | {name}", download.State, download.Name);
return (false, DeleteReason.None);
}
if (download.DownloadSpeed <= 0)
{
_logger.LogDebug("skip slow check | download speed is 0 | {name}", download.Name);
_logger.LogTrace("skip slow check | download speed is 0 | {name}", download.Name);
return (false, DeleteReason.None);
}
if (queueCleanerConfig.Slow.IgnorePrivate && isPrivate)
{
// ignore private trackers
_logger.LogDebug("skip slow check | download is private | {name}", download.Name);
_logger.LogTrace("skip slow check | download is private | {name}", download.Name);
return (false, DeleteReason.None);
}
if (download.Size > (queueCleanerConfig.Slow.IgnoreAboveSizeByteSize?.Bytes ?? long.MaxValue))
{
_logger.LogDebug("skip slow check | download is too large | {name}", download.Name);
_logger.LogTrace("skip slow check | download is too large | {name}", download.Name);
return (false, DeleteReason.None);
}
@@ -139,7 +139,7 @@ public partial class QBitService
if (queueCleanerConfig.Stalled.MaxStrikes is 0 && queueCleanerConfig.Stalled.DownloadingMetadataMaxStrikes is 0)
{
_logger.LogDebug("skip stalled check | max strikes is 0 | {name}", torrent.Name);
_logger.LogTrace("skip stalled check | max strikes is 0 | {name}", torrent.Name);
return (false, DeleteReason.None);
}
@@ -147,7 +147,7 @@ public partial class QBitService
and not TorrentState.ForcedFetchingMetadata)
{
// ignore other states
_logger.LogDebug("skip stalled check | download is in {state} state | {name}", torrent.State, torrent.Name);
_logger.LogTrace("skip stalled check | download is in {state} state | {name}", torrent.State, torrent.Name);
return (false, DeleteReason.None);
}
@@ -156,7 +156,7 @@ public partial class QBitService
if (queueCleanerConfig.Stalled.IgnorePrivate && isPrivate)
{
// ignore private trackers
_logger.LogDebug("skip stalled check | download is private | {name}", torrent.Name);
_logger.LogTrace("skip stalled check | download is private | {name}", torrent.Name);
}
else
{
@@ -175,7 +175,7 @@ public partial class QBitService
StrikeType.DownloadingMetadata), DeleteReason.DownloadingMetadata);
}
_logger.LogDebug("skip stalled check | download is not stalled | {name}", torrent.Name);
_logger.LogTrace("skip stalled check | download is not stalled | {name}", torrent.Name);
return (false, DeleteReason.None);
}
}

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

@@ -0,0 +1,77 @@
using Cleanuparr.Domain.Entities.UTorrent.Response;
using Cleanuparr.Infrastructure.Services;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent.Extensions;
/// <summary>
/// Extension methods for µTorrent entities and status checking
/// </summary>
public static class UTorrentExtensions
{
/// <summary>
/// Checks if the torrent is currently seeding
/// </summary>
public static bool IsSeeding(this UTorrentItem item)
{
return IsDownloading(item.Status) && item.DateCompleted > 0;
}
/// <summary>
/// Checks if the torrent is currently downloading
/// </summary>
public static bool IsDownloading(this UTorrentItem item)
{
return IsDownloading(item.Status);
}
/// <summary>
/// Checks if the status indicates downloading
/// </summary>
public static bool IsDownloading(int status)
{
return (status & UTorrentStatus.Started) != 0 &&
(status & UTorrentStatus.Checked) != 0 &&
(status & UTorrentStatus.Error) == 0;
}
/// <summary>
/// Checks if a torrent should be ignored based on the ignored patterns
/// </summary>
public static bool ShouldIgnore(this UTorrentItem download, IReadOnlyList<string> ignoredDownloads)
{
foreach (string value in ignoredDownloads)
{
if (download.Hash.Equals(value, StringComparison.InvariantCultureIgnoreCase))
{
return true;
}
if (download.Label.Equals(value, StringComparison.InvariantCultureIgnoreCase))
{
return true;
}
}
return false;
}
public static bool ShouldIgnore(this string tracker, IReadOnlyList<string> ignoredDownloads)
{
string? trackerUrl = UriService.GetDomain(tracker);
if (trackerUrl is null)
{
return false;
}
foreach (string value in ignoredDownloads)
{
if (trackerUrl.EndsWith(value, StringComparison.InvariantCultureIgnoreCase))
{
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,46 @@
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
/// <summary>
/// Interface for µTorrent authentication management with caching support
/// Handles token management and session state with multi-client support
/// </summary>
public interface IUTorrentAuthenticator
{
/// <summary>
/// Ensures that the client is authenticated and the token is valid
/// </summary>
/// <returns>True if authentication is successful</returns>
Task<bool> EnsureAuthenticatedAsync();
/// <summary>
/// Gets a valid authentication token, refreshing if necessary
/// </summary>
/// <returns>Valid authentication token</returns>
Task<string> GetValidTokenAsync();
/// <summary>
/// Gets a valid GUID cookie, refreshing if necessary
/// </summary>
/// <returns>Valid GUID cookie</returns>
Task<string> GetValidGuidCookieAsync();
/// <summary>
/// Forces a refresh of the authentication session
/// </summary>
Task RefreshSessionAsync();
/// <summary>
/// Invalidates the cached authentication session
/// </summary>
Task InvalidateSessionAsync();
/// <summary>
/// Gets whether the client is currently authenticated
/// </summary>
bool IsAuthenticated { get; }
/// <summary>
/// Gets the GUID cookie for the current session
/// </summary>
string GuidCookie { get; }
}

View File

@@ -0,0 +1,24 @@
using Cleanuparr.Domain.Entities.UTorrent.Request;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
/// <summary>
/// Interface for raw HTTP communication with µTorrent Web UI API
/// Handles low-level HTTP requests and authentication token retrieval
/// </summary>
public interface IUTorrentHttpService
{
/// <summary>
/// Sends a raw HTTP request to the µTorrent API
/// </summary>
/// <param name="request">The request to send</param>
/// <param name="guidCookie">The GUID cookie for authentication</param>
/// <returns>Raw JSON response from the API</returns>
Task<string> SendRawRequestAsync(UTorrentRequest request, string guidCookie);
/// <summary>
/// Retrieves authentication token and GUID cookie from µTorrent
/// </summary>
/// <returns>Tuple containing the authentication token and GUID cookie</returns>
Task<(string token, string guidCookie)> GetTokenAndCookieAsync();
}

View File

@@ -0,0 +1,38 @@
using Cleanuparr.Domain.Entities.UTorrent.Response;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
/// <summary>
/// Interface for parsing µTorrent API responses
/// Provides endpoint-specific parsing methods for different response types
/// </summary>
public interface IUTorrentResponseParser
{
/// <summary>
/// Parses a torrent list response from JSON
/// </summary>
/// <param name="json">Raw JSON response from the API</param>
/// <returns>Parsed torrent list response</returns>
TorrentListResponse ParseTorrentList(string json);
/// <summary>
/// Parses a file list response from JSON
/// </summary>
/// <param name="json">Raw JSON response from the API</param>
/// <returns>Parsed file list response</returns>
FileListResponse ParseFileList(string json);
/// <summary>
/// Parses a properties response from JSON
/// </summary>
/// <param name="json">Raw JSON response from the API</param>
/// <returns>Parsed properties response</returns>
PropertiesResponse ParseProperties(string json);
/// <summary>
/// Parses a label list response from JSON
/// </summary>
/// <param name="json">Raw JSON response from the API</param>
/// <returns>Parsed label list response</returns>
LabelListResponse ParseLabelList(string json);
}

View File

@@ -0,0 +1,8 @@
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
/// <summary>
/// Interface for µTorrent download service
/// </summary>
public interface IUTorrentService : IDownloadService
{
}

View File

@@ -0,0 +1,16 @@
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
/// <summary>
/// Represents cached authentication data for a µTorrent client instance
/// </summary>
public sealed class UTorrentAuthCache
{
public string AuthToken { get; init; } = string.Empty;
public string GuidCookie { get; init; } = string.Empty;
public DateTime CreatedAt { get; init; }
public DateTime ExpiresAt { get; init; }
public bool IsValid => DateTime.UtcNow < ExpiresAt &&
!string.IsNullOrEmpty(AuthToken) &&
!string.IsNullOrEmpty(GuidCookie);
}

View File

@@ -0,0 +1,237 @@
using System.Collections.Concurrent;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Shared.Helpers;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
/// <summary>
/// Implementation of µTorrent authentication management with IMemoryCache-based token sharing
/// Handles concurrent authentication requests and provides thread-safe token caching per client
/// </summary>
public class UTorrentAuthenticator : IUTorrentAuthenticator
{
private readonly IMemoryCache _cache;
private readonly IUTorrentHttpService _httpService;
private readonly DownloadClientConfig _config;
private readonly ILogger<UTorrentAuthenticator> _logger;
// Use a static concurrent dictionary to ensure same client instances share the same semaphore
// This prevents multiple instances of the same client from authenticating simultaneously
private readonly SemaphoreSlim _authSemaphore;
private readonly string _clientKey;
// Cache configuration - conservative timings to avoid token expiration issues
private static readonly TimeSpan TokenExpiryDuration = TimeSpan.FromMinutes(20);
private static readonly TimeSpan CacheAbsoluteExpiration = TimeSpan.FromMinutes(25);
public UTorrentAuthenticator(
IMemoryCache cache,
IUTorrentHttpService httpService,
DownloadClientConfig config,
ILogger<UTorrentAuthenticator> logger)
{
_cache = cache;
_httpService = httpService;
_config = config;
_logger = logger;
// Create unique client key based on connection details
// This ensures different µTorrent instances don't share auth tokens
_clientKey = GetClientKey();
// Get or create semaphore for this specific client configuration
if (_cache.TryGetValue<SemaphoreSlim>(_clientKey, out var authSemaphore) && authSemaphore is not null)
{
_authSemaphore = authSemaphore;
return;
}
_authSemaphore = new SemaphoreSlim(1, 1);
_cache.Set(_clientKey, _authSemaphore, Constants.DefaultCacheEntryOptions);
}
/// <inheritdoc/>
public bool IsAuthenticated
{
get
{
var cacheKey = CacheKeys.UTorrent.GetAuthTokenKey(_clientKey);
return _cache.TryGetValue(cacheKey, out UTorrentAuthCache? cachedAuth) &&
cachedAuth?.IsValid == true;
}
}
/// <inheritdoc/>
public string GuidCookie
{
get
{
var cacheKey = CacheKeys.UTorrent.GetAuthTokenKey(_clientKey);
if (_cache.TryGetValue(cacheKey, out UTorrentAuthCache? cachedAuth) &&
cachedAuth?.IsValid == true)
{
return cachedAuth.GuidCookie;
}
return string.Empty;
}
}
/// <inheritdoc/>
public async Task<bool> EnsureAuthenticatedAsync()
{
var cacheKey = CacheKeys.UTorrent.GetAuthTokenKey(_clientKey);
// Fast path: Check if we have valid cached auth
if (_cache.TryGetValue(cacheKey, out UTorrentAuthCache? cachedAuth) &&
cachedAuth?.IsValid == true)
{
return true;
}
// Slow path: Need to refresh authentication with concurrency control
return await RefreshAuthenticationWithLockAsync();
}
/// <inheritdoc/>
public async Task<string> GetValidTokenAsync()
{
if (!await EnsureAuthenticatedAsync())
{
throw new UTorrentAuthenticationException($"Failed to authenticate with µTorrent client '{_config.Name}'");
}
var cacheKey = CacheKeys.UTorrent.GetAuthTokenKey(_clientKey);
if (_cache.TryGetValue(cacheKey, out UTorrentAuthCache? cachedAuth) &&
cachedAuth?.IsValid == true)
{
return cachedAuth.AuthToken;
}
throw new UTorrentAuthenticationException($"Authentication token not available for µTorrent client '{_config.Name}'");
}
/// <inheritdoc/>
public async Task<string> GetValidGuidCookieAsync()
{
if (!await EnsureAuthenticatedAsync())
{
throw new UTorrentAuthenticationException($"Failed to authenticate with µTorrent client '{_config.Name}'");
}
var cacheKey = CacheKeys.UTorrent.GetAuthTokenKey(_clientKey);
if (_cache.TryGetValue(cacheKey, out UTorrentAuthCache? cachedAuth) &&
cachedAuth?.IsValid == true)
{
return cachedAuth.GuidCookie;
}
throw new UTorrentAuthenticationException($"GUID cookie not available for µTorrent client '{_config.Name}'");
}
/// <inheritdoc/>
public async Task RefreshSessionAsync()
{
const int maxRetries = 3;
var retryCount = 0;
var backoffDelay = TimeSpan.FromMilliseconds(500);
while (retryCount < maxRetries)
{
try
{
var (token, guidCookie) = await _httpService.GetTokenAndCookieAsync();
var authCache = new UTorrentAuthCache
{
AuthToken = token,
GuidCookie = guidCookie,
CreatedAt = DateTime.UtcNow,
ExpiresAt = DateTime.UtcNow.Add(TokenExpiryDuration)
};
// Cache with both sliding and absolute expiration
var cacheOptions = new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = CacheAbsoluteExpiration,
SlidingExpiration = TokenExpiryDuration,
Priority = CacheItemPriority.High
};
var cacheKey = CacheKeys.UTorrent.GetAuthTokenKey(_clientKey);
_cache.Set(cacheKey, authCache, cacheOptions);
return;
}
catch (Exception ex) when (retryCount < maxRetries - 1)
{
retryCount++;
_logger.LogWarning(ex, "Authentication attempt {Attempt} failed for µTorrent client '{ClientName}', retrying in {Delay}ms",
retryCount, _config.Name, backoffDelay.TotalMilliseconds);
await Task.Delay(backoffDelay);
backoffDelay = TimeSpan.FromMilliseconds(backoffDelay.TotalMilliseconds * 1.5); // Exponential backoff
}
catch (Exception ex)
{
// Invalidate any existing cache entry on failure
await InvalidateSessionAsync();
throw new UTorrentAuthenticationException($"Failed to refresh authentication session after {maxRetries} attempts: {ex.Message}", ex);
}
}
}
/// <inheritdoc/>
public async Task InvalidateSessionAsync()
{
var cacheKey = CacheKeys.UTorrent.GetAuthTokenKey(_clientKey);
_cache.Remove(cacheKey);
await Task.CompletedTask;
}
/// <summary>
/// Refreshes authentication with concurrency control to prevent multiple simultaneous auth requests
/// </summary>
private async Task<bool> RefreshAuthenticationWithLockAsync()
{
var cacheKey = CacheKeys.UTorrent.GetAuthTokenKey(_clientKey);
// Wait for our turn to authenticate (per client instance)
await _authSemaphore.WaitAsync();
try
{
// Double-check: another thread might have refreshed while we were waiting
if (_cache.TryGetValue(cacheKey, out UTorrentAuthCache? cachedAuth) &&
cachedAuth?.IsValid == true)
{
return true;
}
// Actually refresh the authentication
await RefreshSessionAsync();
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to refresh authentication for µTorrent client '{ClientName}'", _config.Name);
return false;
}
finally
{
_authSemaphore.Release();
}
}
/// <summary>
/// Creates a unique client key based on connection details
/// This ensures different µTorrent instances don't share auth tokens
/// </summary>
private string GetClientKey()
{
return _config.Url.ToString();
}
}

View File

@@ -0,0 +1,280 @@
using Cleanuparr.Domain.Entities.UTorrent.Request;
using Cleanuparr.Domain.Entities.UTorrent.Response;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Persistence.Models.Configuration;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
public sealed class UTorrentClient
{
private readonly DownloadClientConfig _config;
private readonly IUTorrentAuthenticator _authenticator;
private readonly IUTorrentHttpService _httpService;
private readonly IUTorrentResponseParser _responseParser;
private readonly ILogger<UTorrentClient> _logger;
public UTorrentClient(
DownloadClientConfig config,
IUTorrentAuthenticator authenticator,
IUTorrentHttpService httpService,
IUTorrentResponseParser responseParser,
ILogger<UTorrentClient> logger
)
{
_config = config;
_authenticator = authenticator;
_httpService = httpService;
_responseParser = responseParser;
_logger = logger;
}
/// <summary>
/// Authenticates with µTorrent and retrieves the authentication token
/// </summary>
/// <returns>True if authentication was successful</returns>
public async Task<bool> LoginAsync()
{
try
{
// Use the cache-aware authentication
var token = await _authenticator.GetValidTokenAsync();
return !string.IsNullOrEmpty(token);
}
catch (Exception ex)
{
_logger.LogError(ex, "Login failed for µTorrent client '{ClientName}'", _config.Name);
throw new UTorrentException($"Failed to authenticate with µTorrent: {ex.Message}", ex);
}
}
/// <summary>
/// Tests the authentication and basic API connectivity
/// </summary>
/// <returns>True if authentication and basic API call works</returns>
public async Task<bool> TestConnectionAsync()
{
try
{
var torrents = await GetTorrentsAsync();
return true; // If we can get torrents, authentication is working
}
catch
{
return false;
}
}
/// <summary>
/// Gets all torrents from µTorrent
/// </summary>
/// <returns>List of torrents</returns>
public async Task<List<UTorrentItem>> GetTorrentsAsync()
{
try
{
var request = UTorrentRequestFactory.CreateTorrentListRequest();
var json = await SendAuthenticatedRequestAsync(request);
var response = _responseParser.ParseTorrentList(json);
return response.Torrents;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get torrents from µTorrent client '{ClientName}'", _config.Name);
throw new UTorrentException($"Failed to get torrents: {ex.Message}", ex);
}
}
/// <summary>
/// Gets a specific torrent by hash
/// </summary>
/// <param name="hash">Torrent hash</param>
/// <returns>The torrent or null if not found</returns>
public async Task<UTorrentItem?> GetTorrentAsync(string hash)
{
try
{
var torrents = await GetTorrentsAsync();
return torrents.FirstOrDefault(t =>
string.Equals(t.Hash, hash, StringComparison.OrdinalIgnoreCase));
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get torrent {Hash} from µTorrent client '{ClientName}'", hash, _config.Name);
throw new UTorrentException($"Failed to get torrent {hash}: {ex.Message}", ex);
}
}
/// <summary>
/// Gets files for a specific torrent
/// </summary>
/// <param name="hash">Torrent hash</param>
/// <returns>List of files in the torrent</returns>
public async Task<List<UTorrentFile>?> GetTorrentFilesAsync(string hash)
{
try
{
var request = UTorrentRequestFactory.CreateFileListRequest(hash);
var json = await SendAuthenticatedRequestAsync(request);
var response = _responseParser.ParseFileList(json);
return response.Files;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get files for torrent {Hash} from µTorrent client '{ClientName}'", hash, _config.Name);
throw new UTorrentException($"Failed to get files for torrent {hash}: {ex.Message}", ex);
}
}
/// <summary>
/// Gets torrent properties including private/public status
/// </summary>
/// <param name="hash">Torrent hash</param>
/// <returns>UTorrentProperties object or null if not found</returns>
public async Task<UTorrentProperties> GetTorrentPropertiesAsync(string hash)
{
try
{
var request = UTorrentRequestFactory.CreatePropertiesRequest(hash);
var json = await SendAuthenticatedRequestAsync(request);
var response = _responseParser.ParseProperties(json);
return response.Properties;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get properties for torrent {Hash} from µTorrent client '{ClientName}'", hash, _config.Name);
throw new UTorrentException($"Failed to get properties for torrent {hash}: {ex.Message}", ex);
}
}
/// <summary>
/// Gets all labels from µTorrent
/// </summary>
/// <returns>List of label names</returns>
public async Task<List<string>> GetLabelsAsync()
{
try
{
var request = UTorrentRequestFactory.CreateLabelListRequest();
var json = await SendAuthenticatedRequestAsync(request);
var response = _responseParser.ParseLabelList(json);
return response.Labels;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get labels from µTorrent client '{ClientName}'", _config.Name);
throw new UTorrentException($"Failed to get labels: {ex.Message}", ex);
}
}
/// <summary>
/// Sets the label for a torrent
/// </summary>
/// <param name="hash">Torrent hash</param>
/// <param name="label">Label to set</param>
public async Task SetTorrentLabelAsync(string hash, string label)
{
try
{
var request = UTorrentRequestFactory.CreateSetLabelRequest(hash, label);
await SendAuthenticatedRequestAsync(request);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to set label '{Label}' for torrent {Hash} in µTorrent client '{ClientName}'", label, hash, _config.Name);
throw new UTorrentException($"Failed to set label '{label}' for torrent {hash}: {ex.Message}", ex);
}
}
/// <summary>
/// Sets file priorities for a torrent
/// </summary>
/// <param name="hash">Torrent hash</param>
/// <param name="fileIndexes">Index of the file to set priority for</param>
/// <param name="priority">File priority (0=skip, 1=low, 2=normal, 3=high)</param>
public async Task SetFilesPriorityAsync(string hash, List<int> fileIndexes, int priority)
{
try
{
var request = UTorrentRequestFactory.CreateSetFilePrioritiesRequest(hash, fileIndexes, priority);
await SendAuthenticatedRequestAsync(request);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to set file priority for torrent {Hash} in µTorrent client '{ClientName}'", hash, _config.Name);
throw new UTorrentException($"Failed to set file priority for torrent {hash}: {ex.Message}", ex);
}
}
/// <summary>
/// Removes torrents from µTorrent
/// </summary>
/// <param name="hashes">List of torrent hashes to remove</param>
public async Task RemoveTorrentsAsync(List<string> hashes)
{
try
{
foreach (var hash in hashes)
{
var request = UTorrentRequestFactory.CreateRemoveTorrentWithDataRequest(hash);
await SendAuthenticatedRequestAsync(request);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to remove torrents from µTorrent client '{ClientName}'", _config.Name);
throw new UTorrentException($"Failed to remove torrents: {ex.Message}", ex);
}
}
/// <summary>
/// Creates a new label in µTorrent
/// </summary>
/// <param name="label">Label name to create</param>
public static async Task CreateLabel(string label)
{
// µTorrent doesn't have an explicit "create label" API
// Labels are created automatically when you assign them to a torrent
// So this is a no-op for µTorrent
await Task.CompletedTask;
}
/// <summary>
/// Sends an authenticated request to the µTorrent API
/// Handles automatic authentication and retry logic
/// </summary>
/// <param name="request">The request to send</param>
/// <returns>Raw JSON response from the API</returns>
private async Task<string> SendAuthenticatedRequestAsync(UTorrentRequest request)
{
try
{
// Get valid token and cookie from cache-aware authenticator
var token = await _authenticator.GetValidTokenAsync();
var guidCookie = await _authenticator.GetValidGuidCookieAsync();
request.Token = token;
return await _httpService.SendRawRequestAsync(request, guidCookie);
}
catch (UTorrentAuthenticationException)
{
// On authentication failure, invalidate cache and retry once
try
{
await _authenticator.InvalidateSessionAsync();
var token = await _authenticator.GetValidTokenAsync();
var guidCookie = await _authenticator.GetValidGuidCookieAsync();
request.Token = token;
return await _httpService.SendRawRequestAsync(request, guidCookie);
}
catch (Exception ex)
{
_logger.LogError(ex, "Authentication retry failed for µTorrent client '{ClientName}'", _config.Name);
throw new UTorrentAuthenticationException($"Authentication retry failed: {ex.Message}", ex);
}
}
}
}

View File

@@ -0,0 +1,181 @@
using System.Net;
using System.Text;
using System.Text.RegularExpressions;
using Cleanuparr.Domain.Entities.UTorrent.Request;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Persistence.Models.Configuration;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
/// <summary>
/// Implementation of HTTP service for µTorrent Web UI API communication
/// Handles low-level HTTP requests and authentication token retrieval
/// </summary>
public class UTorrentHttpService : IUTorrentHttpService
{
private readonly HttpClient _httpClient;
private readonly DownloadClientConfig _config;
private readonly ILogger<UTorrentHttpService> _logger;
// Regex pattern to extract token from µTorrent Web UI HTML
private static readonly Regex TokenRegex = new(@"<div[^>]*id=['""]token['""][^>]*>([^<]+)</div>",
RegexOptions.IgnoreCase);
public UTorrentHttpService(
HttpClient httpClient,
DownloadClientConfig config,
ILogger<UTorrentHttpService> logger)
{
_httpClient = httpClient;
_config = config;
_logger = logger;
}
/// <inheritdoc/>
public async Task<string> SendRawRequestAsync(UTorrentRequest request, string guidCookie)
{
if (string.IsNullOrEmpty(guidCookie))
{
throw new UTorrentAuthenticationException("GUID cookie is required for API requests");
}
try
{
var queryString = request.ToQueryString();
UriBuilder uriBuilder = new UriBuilder(_config.Url)
{
Query = queryString
};
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/gui/";
var httpRequest = new HttpRequestMessage(HttpMethod.Get, uriBuilder.Uri);
httpRequest.Headers.Add("Cookie", guidCookie);
var credentials = Convert.ToBase64String(
Encoding.UTF8.GetBytes($"{_config.Username}:{_config.Password}"));
httpRequest.Headers.Add("Authorization", $"Basic {credentials}");
var response = await _httpClient.SendAsync(httpRequest);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
_logger.LogError("UTorrent API request failed: {StatusCode} - {Content}", response.StatusCode, errorContent);
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
throw new UTorrentAuthenticationException("Authentication failed - invalid credentials or token expired");
}
throw new UTorrentException($"HTTP request failed: {response.StatusCode} - {errorContent}");
}
var jsonResponse = await response.Content.ReadAsStringAsync();
if (string.IsNullOrEmpty(jsonResponse))
{
throw new UTorrentException("Empty response received from µTorrent API");
}
return jsonResponse;
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "HTTP request failed for UTorrent API: {Action}", request.Action);
throw new UTorrentException($"HTTP request failed: {ex.Message}", ex);
}
catch (TaskCanceledException ex)
{
_logger.LogError(ex, "HTTP request timeout for UTorrent API: {Action}", request.Action);
throw new UTorrentException($"HTTP request timeout: {ex.Message}", ex);
}
}
/// <inheritdoc/>
public async Task<(string token, string guidCookie)> GetTokenAndCookieAsync()
{
try
{
UriBuilder uriBuilder = new UriBuilder(_config.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/gui/token.html";
var credentials = Convert.ToBase64String(
Encoding.UTF8.GetBytes($"{_config.Username}:{_config.Password}"));
var request = new HttpRequestMessage(HttpMethod.Get, uriBuilder.Uri);
request.Headers.Add("Authorization", $"Basic {credentials}");
var response = await _httpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
_logger.LogError("Failed to retrieve authentication token: {StatusCode} - {Content}",
response.StatusCode, errorContent);
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
throw new UTorrentAuthenticationException("Authentication failed - check username and password");
}
throw new UTorrentException($"Token retrieval failed: {response.StatusCode} - {errorContent}");
}
var html = await response.Content.ReadAsStringAsync();
// Extract token from HTML
var tokenMatch = TokenRegex.Match(html);
if (!tokenMatch.Success)
{
_logger.LogError("Failed to extract token from HTML response: {Html}", html);
throw new UTorrentAuthenticationException("Failed to extract authentication token from response");
}
var token = tokenMatch.Groups[1].Value;
// Extract GUID from cookies
var guidCookie = ExtractGuidCookie(response.Headers);
if (string.IsNullOrEmpty(guidCookie))
{
throw new UTorrentAuthenticationException("Failed to extract GUID cookie from response");
}
return (token, guidCookie);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "HTTP request failed while retrieving authentication token");
throw new UTorrentAuthenticationException($"Token retrieval failed: {ex.Message}", ex);
}
catch (TaskCanceledException ex)
{
_logger.LogError(ex, "HTTP request timeout while retrieving authentication token");
throw new UTorrentAuthenticationException($"Token retrieval timeout: {ex.Message}", ex);
}
}
/// <summary>
/// Extracts the GUID cookie from HTTP response headers
/// </summary>
/// <param name="headers">HTTP response headers</param>
/// <returns>GUID cookie string or empty string if not found</returns>
private static string ExtractGuidCookie(System.Net.Http.Headers.HttpResponseHeaders headers)
{
if (!headers.TryGetValues("Set-Cookie", out var cookies))
{
return string.Empty;
}
foreach (var cookie in cookies)
{
if (cookie.Contains("GUID="))
{
return cookie.Split(';')[0]; // Get just the GUID part, ignore expires, path, etc.
}
}
return string.Empty;
}
}

View File

@@ -0,0 +1,96 @@
using Cleanuparr.Domain.Entities.UTorrent.Request;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
/// <summary>
/// Factory for creating type-safe UTorrent API requests
/// Provides specific methods for each supported API endpoint
/// </summary>
public static class UTorrentRequestFactory
{
/// <summary>
/// Creates a request to get the list of all torrents
/// </summary>
/// <returns>Request for torrent list API call</returns>
public static UTorrentRequest CreateTorrentListRequest()
{
return UTorrentRequest.Create("list=1", string.Empty);
}
/// <summary>
/// Creates a request to get files for a specific torrent
/// </summary>
/// <param name="hash">Torrent hash</param>
/// <returns>Request for file list API call</returns>
public static UTorrentRequest CreateFileListRequest(string hash)
{
return UTorrentRequest.Create("action=getfiles", string.Empty)
.WithParameter("hash", hash);
}
/// <summary>
/// Creates a request to get properties for a specific torrent
/// </summary>
/// <param name="hash">Torrent hash</param>
/// <returns>Request for properties API call</returns>
public static UTorrentRequest CreatePropertiesRequest(string hash)
{
return UTorrentRequest.Create("action=getprops", string.Empty)
.WithParameter("hash", hash);
}
/// <summary>
/// Creates a request to get all labels
/// </summary>
/// <returns>Request for label list API call</returns>
public static UTorrentRequest CreateLabelListRequest()
{
return UTorrentRequest.Create("list=1", string.Empty);
}
/// <summary>
/// Creates a request to remove a torrent and its data
/// </summary>
/// <param name="hash">Torrent hash</param>
/// <returns>Request for remove torrent with data API call</returns>
public static UTorrentRequest CreateRemoveTorrentWithDataRequest(string hash)
{
return UTorrentRequest.Create("action=removedatatorrent", string.Empty)
.WithParameter("hash", hash);
}
/// <summary>
/// Creates a request to set file priorities for a torrent
/// </summary>
/// <param name="hash">Torrent hash</param>
/// <param name="fileIndexes"></param>
/// <param name="filePriority"></param>
/// <returns>Request for set file priorities API call</returns>
public static UTorrentRequest CreateSetFilePrioritiesRequest(string hash, List<int> fileIndexes, int filePriority)
{
var request = UTorrentRequest.Create("action=setprio", string.Empty)
.WithParameter("hash", hash)
.WithParameter("p", filePriority.ToString());
foreach (int fileIndex in fileIndexes)
{
request.WithParameter("f", fileIndex.ToString());
}
return request;
}
/// <summary>
/// Creates a request to set a torrent's label
/// </summary>
/// <param name="hash">Torrent hash</param>
/// <param name="label">Label to set</param>
/// <returns>Request for set label API call</returns>
public static UTorrentRequest CreateSetLabelRequest(string hash, string label)
{
return UTorrentRequest.Create("action=setprops", string.Empty)
.WithParameter("hash", hash)
.WithParameter("s", "label")
.WithParameter("v", label);
}
}

View File

@@ -0,0 +1,237 @@
using Cleanuparr.Domain.Entities.UTorrent.Response;
using Cleanuparr.Domain.Exceptions;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
/// <summary>
/// Implementation of µTorrent response parser
/// Handles endpoint-specific parsing of API responses with proper error handling
/// </summary>
public class UTorrentResponseParser : IUTorrentResponseParser
{
private readonly ILogger<UTorrentResponseParser> _logger;
public UTorrentResponseParser(ILogger<UTorrentResponseParser> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc/>
public TorrentListResponse ParseTorrentList(string json)
{
try
{
var response = JsonConvert.DeserializeObject<TorrentListResponse>(json);
if (response == null)
{
throw new UTorrentParsingException("Failed to deserialize torrent list response", json);
}
// Parse torrents
if (response.TorrentsRaw != null)
{
foreach (var data in response.TorrentsRaw)
{
if (data is { Length: >= 27 })
{
response.Torrents.Add(new UTorrentItem
{
Hash = data[0].ToString() ?? string.Empty,
Status = Convert.ToInt32(data[1]),
Name = data[2].ToString() ?? string.Empty,
Size = Convert.ToInt64(data[3]),
Progress = Convert.ToInt32(data[4]),
Downloaded = Convert.ToInt64(data[5]),
Uploaded = Convert.ToInt64(data[6]),
RatioRaw = Convert.ToInt32(data[7]),
UploadSpeed = Convert.ToInt32(data[8]),
DownloadSpeed = Convert.ToInt32(data[9]),
ETA = Convert.ToInt32(data[10]),
Label = data[11].ToString() ?? string.Empty,
PeersConnected = Convert.ToInt32(data[12]),
PeersInSwarm = Convert.ToInt32(data[13]),
SeedsConnected = Convert.ToInt32(data[14]),
SeedsInSwarm = Convert.ToInt32(data[15]),
Availability = Convert.ToInt32(data[16]),
QueueOrder = Convert.ToInt32(data[17]),
Remaining = Convert.ToInt64(data[18]),
DownloadUrl = data[19].ToString() ?? string.Empty,
RssFeedUrl = data[20].ToString() ?? string.Empty,
StatusMessage = data[21].ToString() ?? string.Empty,
StreamId = data[22].ToString() ?? string.Empty,
DateAdded = Convert.ToInt64(data[23]),
DateCompleted = Convert.ToInt64(data[24]),
AppUpdateUrl = data[25].ToString() ?? string.Empty,
SavePath = data[26].ToString() ?? string.Empty
});
}
}
}
// Parse labels
if (response.LabelsRaw != null)
{
foreach (var labelData in response.LabelsRaw)
{
if (labelData is { Length: > 0 })
{
var labelName = labelData[0].ToString();
if (!string.IsNullOrEmpty(labelName))
{
response.Labels.Add(labelName);
}
}
}
}
return response;
}
catch (JsonException ex)
{
_logger.LogError(ex, "Failed to parse torrent list JSON response");
throw new UTorrentParsingException($"Failed to parse torrent list response: {ex.Message}", json, ex);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error parsing torrent list response");
throw new UTorrentParsingException($"Unexpected error parsing torrent list response: {ex.Message}", json, ex);
}
}
/// <inheritdoc/>
public FileListResponse ParseFileList(string json)
{
try
{
var rawResponse = JsonConvert.DeserializeObject<FileListResponse>(json);
if (rawResponse == null)
{
throw new UTorrentParsingException("Failed to deserialize file list response", json);
}
var response = new FileListResponse();
// Parse files from the nested array structure
if (rawResponse.FilesRaw is { Length: >= 2 })
{
response.Hash = rawResponse.FilesRaw[0].ToString() ?? string.Empty;
if (rawResponse.FilesRaw[1] is JArray jArray)
{
foreach (var jToken in jArray)
{
if (jToken is JArray fileArray)
{
var fileData = fileArray.ToObject<object[]>() ?? Array.Empty<object>();
if (fileData.Length >= 4)
{
response.Files.Add(new UTorrentFile
{
Name = fileData[0]?.ToString() ?? string.Empty,
Size = Convert.ToInt64(fileData[1]),
Downloaded = Convert.ToInt64(fileData[2]),
Priority = Convert.ToInt32(fileData[3]),
});
}
}
}
}
}
return response;
}
catch (JsonException ex)
{
_logger.LogError(ex, "Failed to parse file list JSON response");
throw new UTorrentParsingException($"Failed to parse file list response: {ex.Message}", json, ex);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error parsing file list response");
throw new UTorrentParsingException($"Unexpected error parsing file list response: {ex.Message}", json, ex);
}
}
/// <inheritdoc/>
public PropertiesResponse ParseProperties(string json)
{
try
{
var rawResponse = JsonConvert.DeserializeObject<PropertiesResponse>(json);
if (rawResponse == null)
{
throw new UTorrentParsingException("Failed to deserialize properties response", json);
}
var response = new PropertiesResponse();
// Parse properties from the array structure
if (rawResponse.PropertiesRaw is { Length: > 0 })
{
response.Properties = JsonConvert.DeserializeObject<UTorrentProperties>(rawResponse.PropertiesRaw.FirstOrDefault()?.ToString());
}
return response;
}
catch (JsonException ex)
{
_logger.LogError(ex, "Failed to parse properties JSON response");
throw new UTorrentParsingException($"Failed to parse properties response: {ex.Message}", json, ex);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error parsing properties response");
throw new UTorrentParsingException($"Unexpected error parsing properties response: {ex.Message}", json, ex);
}
}
/// <inheritdoc/>
public LabelListResponse ParseLabelList(string json)
{
try
{
var response = JsonConvert.DeserializeObject<LabelListResponse>(json);
if (response == null)
{
throw new UTorrentParsingException("Failed to deserialize label list response", json);
}
// Parse labels
if (response.LabelsRaw != null)
{
foreach (var labelData in response.LabelsRaw)
{
if (labelData is { Length: > 0 })
{
var labelName = labelData[0]?.ToString();
if (!string.IsNullOrEmpty(labelName))
{
response.Labels.Add(labelName);
}
}
}
}
return response;
}
catch (JsonException ex)
{
_logger.LogError(ex, "Failed to parse label list JSON response");
throw new UTorrentParsingException($"Failed to parse label list response: {ex.Message}", json, ex);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error parsing label list response");
throw new UTorrentParsingException($"Unexpected error parsing label list response: {ex.Message}", json, ex);
}
}
}

View File

@@ -0,0 +1,128 @@
using Cleanuparr.Infrastructure.Events;
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;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
/// <summary>
/// µTorrent download service implementation
/// Provides business logic layer on top of UTorrentClient
/// </summary>
public partial class UTorrentService : DownloadService, IUTorrentService
{
private readonly UTorrentClient _client;
public UTorrentService(
ILogger<UTorrentService> logger,
IMemoryCache cache,
IFilenameEvaluator filenameEvaluator,
IStriker striker,
IDryRunInterceptor dryRunInterceptor,
IHardLinkFileService hardLinkFileService,
IDynamicHttpClientProvider httpClientProvider,
EventPublisher eventPublisher,
BlocklistProvider blocklistProvider,
DownloadClientConfig downloadClientConfig,
ILoggerFactory loggerFactory
) : base(
logger, cache,
filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig
)
{
// Create the new layered client with dependency injection
var httpService = new UTorrentHttpService(_httpClient, downloadClientConfig, loggerFactory.CreateLogger<UTorrentHttpService>());
var authenticator = new UTorrentAuthenticator(
cache,
httpService,
downloadClientConfig,
loggerFactory.CreateLogger<UTorrentAuthenticator>()
);
var responseParser = new UTorrentResponseParser(loggerFactory.CreateLogger<UTorrentResponseParser>());
_client = new UTorrentClient(
downloadClientConfig,
authenticator,
httpService,
responseParser,
loggerFactory.CreateLogger<UTorrentClient>()
);
}
public override void Dispose()
{
}
/// <summary>
/// Authenticates with µTorrent Web UI
/// </summary>
public override async Task LoginAsync()
{
try
{
var loginSuccess = await _client.LoginAsync();
if (!loginSuccess)
{
throw new InvalidOperationException("Failed to authenticate with µTorrent Web UI");
}
_logger.LogDebug("Successfully logged in to µTorrent client {clientId}", _downloadClientConfig.Id);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to login to µTorrent client {clientId}", _downloadClientConfig.Id);
throw;
}
}
/// <summary>
/// Performs health check for µTorrent service
/// </summary>
public override async Task<HealthCheckResult> HealthCheckAsync()
{
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
try
{
// Test authentication and basic connectivity
await _client.LoginAsync();
// Test API connectivity with a simple request
var connectionOk = await _client.TestConnectionAsync();
if (!connectionOk)
{
throw new InvalidOperationException("API connection test failed");
}
_logger.LogDebug("Health check: Successfully connected to µTorrent client {clientId}", _downloadClientConfig.Id);
stopwatch.Stop();
return new HealthCheckResult
{
IsHealthy = true,
ResponseTime = stopwatch.Elapsed
};
}
catch (Exception ex)
{
stopwatch.Stop();
_logger.LogError(ex, "Health check failed for µTorrent client {clientId}", _downloadClientConfig.Id);
return new HealthCheckResult
{
IsHealthy = false,
ResponseTime = stopwatch.Elapsed,
ErrorMessage = ex.Message
};
}
}
}

View File

@@ -0,0 +1,116 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using Cleanuparr.Domain.Entities.UTorrent.Response;
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.MalwareBlocker;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
public partial class UTorrentService
{
/// <inheritdoc/>
public override async Task<BlockFilesResult> BlockUnwantedFilesAsync(string hash, IReadOnlyList<string> ignoredDownloads)
{
hash = hash.ToLowerInvariant();
UTorrentItem? download = await _client.GetTorrentAsync(hash);
BlockFilesResult result = new();
if (download?.Hash is null)
{
_logger.LogDebug("Failed to find torrent {hash} in the download client", hash);
return result;
}
result.Found = true;
var properties = await _client.GetTorrentPropertiesAsync(hash);
result.IsPrivate = properties.IsPrivate;
if (ignoredDownloads.Count > 0 &&
(download.ShouldIgnore(ignoredDownloads) || properties.TrackerList.Any(x => x.ShouldIgnore(ignoredDownloads))))
{
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
return result;
}
var malwareBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
if (malwareBlockerConfig.IgnorePrivate && result.IsPrivate)
{
// ignore private trackers
_logger.LogDebug("skip files check | download is private | {name}", download.Name);
return result;
}
List<UTorrentFile>? files = await _client.GetTorrentFilesAsync(hash);
if (files?.Count is null or 0)
{
_logger.LogDebug("skip files check | no files found | {name}", download.Name);
return result;
}
List<int> fileIndexes = new(files.Count);
long totalUnwantedFiles = 0;
InstanceType instanceType = (InstanceType)ContextProvider.Get<object>(nameof(InstanceType));
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 (malwareBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(files[i].Name, malwarePatterns))
{
_logger.LogInformation("malware file found | {file} | {title}", files[i].Name, download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.MalwareFileFound;
return result;
}
var file = files[i];
if (file.Priority == 0) // Already skipped
{
totalUnwantedFiles++;
continue;
}
if (file.Priority != 0 && !_filenameEvaluator.IsValid(file.Name, blocklistType, patterns, regexes))
{
totalUnwantedFiles++;
fileIndexes.Add(i);
_logger.LogInformation("unwanted file found | {file}", file.Name);
}
}
if (fileIndexes.Count is 0)
{
return result;
}
_logger.LogDebug("changing priorities | torrent {hash}", hash);
if (totalUnwantedFiles == files.Count)
{
_logger.LogDebug("All files are blocked for {name}", download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AllFilesBlocked;
}
await _dryRunInterceptor.InterceptAsync(ChangeFilesPriority, hash, fileIndexes);
return result;
}
protected virtual async Task ChangeFilesPriority(string hash, List<int> fileIndexes)
{
await _client.SetFilesPriorityAsync(hash, fileIndexes, 0);
}
}

View File

@@ -0,0 +1,234 @@
using Cleanuparr.Domain.Entities.UTorrent.Response;
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.DownloadCleaner;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
public partial class UTorrentService
{
public override async Task<List<object>?> GetSeedingDownloads()
{
var torrents = await _client.GetTorrentsAsync();
return torrents
.Where(x => !string.IsNullOrEmpty(x.Hash))
.Where(x => x.IsSeeding())
.Cast<object>()
.ToList();
}
public override List<object>? FilterDownloadsToBeCleanedAsync(List<object>? downloads, List<CleanCategory> categories) =>
downloads
?.Cast<UTorrentItem>()
.Where(x => categories.Any(cat => cat.Name.Equals(x.Label, StringComparison.InvariantCultureIgnoreCase)))
.Cast<object>()
.ToList();
public override List<object>? FilterDownloadsToChangeCategoryAsync(List<object>? downloads, List<string> categories) =>
downloads
?.Cast<UTorrentItem>()
.Where(x => !string.IsNullOrEmpty(x.Hash))
.Where(x => categories.Any(cat => cat.Equals(x.Label, StringComparison.InvariantCultureIgnoreCase)))
.Cast<object>()
.ToList();
/// <inheritdoc/>
public override async Task CleanDownloadsAsync(List<object>? downloads, List<CleanCategory> categoriesToClean, HashSet<string> excludedHashes,
IReadOnlyList<string> ignoredDownloads)
{
if (downloads?.Count is null or 0)
{
return;
}
foreach (UTorrentItem download in downloads.Cast<UTorrentItem>())
{
if (string.IsNullOrEmpty(download.Hash))
{
continue;
}
if (excludedHashes.Any(x => x.Equals(download.Hash, StringComparison.InvariantCultureIgnoreCase)))
{
_logger.LogDebug("skip | download is used by an arr | {name}", download.Name);
continue;
}
var properties = await _client.GetTorrentPropertiesAsync(download.Hash);
if (ignoredDownloads.Count > 0 &&
(download.ShouldIgnore(ignoredDownloads) || properties.TrackerList.Any(x => x.ShouldIgnore(ignoredDownloads))))
{
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
continue;
}
CleanCategory? category = categoriesToClean
.FirstOrDefault(x => x.Name.Equals(download.Label, StringComparison.InvariantCultureIgnoreCase));
if (category is null)
{
continue;
}
var downloadCleanerConfig = ContextProvider.Get<DownloadCleanerConfig>(nameof(DownloadCleanerConfig));
if (!downloadCleanerConfig.DeletePrivate && properties.IsPrivate)
{
_logger.LogDebug("skip | download is private | {name}", download.Name);
continue;
}
ContextProvider.Set("downloadName", download.Name);
ContextProvider.Set("hash", download.Hash);
TimeSpan? seedingTime = download.SeedingTime;
if (seedingTime == null)
{
_logger.LogDebug("skip | could not determine seeding time | {name}", download.Name);
continue;
}
SeedingCheckResult result = ShouldCleanDownload(download.Ratio, seedingTime.Value, category);
if (!result.ShouldClean)
{
continue;
}
await _dryRunInterceptor.InterceptAsync(DeleteDownload, download.Hash);
_logger.LogInformation(
"download cleaned | {reason} reached | {name}",
result.Reason is CleanReason.MaxRatioReached
? "MAX_RATIO & MIN_SEED_TIME"
: "MAX_SEED_TIME",
download.Name
);
await _eventPublisher.PublishDownloadCleaned(download.Ratio, seedingTime.Value, category.Name, result.Reason);
}
}
public override async Task CreateCategoryAsync(string name)
{
var existingLabels = await _client.GetLabelsAsync();
if (existingLabels.Contains(name, StringComparer.InvariantCultureIgnoreCase))
{
return;
}
_logger.LogDebug("Creating category {name}", name);
await _dryRunInterceptor.InterceptAsync(CreateLabel, name);
}
public override async Task ChangeCategoryForNoHardLinksAsync(List<object>? downloads, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads)
{
if (downloads?.Count is null or 0)
{
return;
}
var downloadCleanerConfig = ContextProvider.Get<DownloadCleanerConfig>(nameof(DownloadCleanerConfig));
if (!string.IsNullOrEmpty(downloadCleanerConfig.UnlinkedIgnoredRootDir))
{
_hardLinkFileService.PopulateFileCounts(downloadCleanerConfig.UnlinkedIgnoredRootDir);
}
foreach (UTorrentItem download in downloads.Cast<UTorrentItem>())
{
if (string.IsNullOrEmpty(download.Hash) || string.IsNullOrEmpty(download.Name) || string.IsNullOrEmpty(download.Label))
{
continue;
}
if (excludedHashes.Any(x => x.Equals(download.Hash, StringComparison.InvariantCultureIgnoreCase)))
{
_logger.LogDebug("skip | download is used by an arr | {name}", download.Name);
continue;
}
var properties = await _client.GetTorrentPropertiesAsync(download.Hash);
if (ignoredDownloads.Count > 0 &&
(download.ShouldIgnore(ignoredDownloads) || properties.TrackerList.Any(x => x.ShouldIgnore(ignoredDownloads))))
{
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
continue;
}
ContextProvider.Set("downloadName", download.Name);
ContextProvider.Set("hash", download.Hash);
List<UTorrentFile>? files = await _client.GetTorrentFilesAsync(download.Hash);
bool hasHardlinks = false;
foreach (var file in files ?? [])
{
string filePath = string.Join(Path.DirectorySeparatorChar, Path.Combine(download.SavePath, file.Name).Split(['\\', '/']));
if (file.Priority <= 0)
{
_logger.LogDebug("skip | file is not downloaded | {file}", filePath);
continue;
}
long hardlinkCount = _hardLinkFileService
.GetHardLinkCount(filePath, !string.IsNullOrEmpty(downloadCleanerConfig.UnlinkedIgnoredRootDir));
if (hardlinkCount < 0)
{
_logger.LogDebug("skip | could not get file properties | {file}", filePath);
hasHardlinks = true;
break;
}
if (hardlinkCount > 0)
{
hasHardlinks = true;
break;
}
}
if (hasHardlinks)
{
_logger.LogDebug("skip | download has hardlinks | {name}", download.Name);
continue;
}
//TODO change label on download object
await _dryRunInterceptor.InterceptAsync(ChangeLabel, download.Hash, downloadCleanerConfig.UnlinkedTargetCategory);
await _eventPublisher.PublishCategoryChanged(download.Label, downloadCleanerConfig.UnlinkedTargetCategory);
_logger.LogInformation("category changed for {name}", download.Name);
download.Label = downloadCleanerConfig.UnlinkedTargetCategory;
}
}
/// <inheritdoc/>
public override async Task DeleteDownload(string hash)
{
hash = hash.ToLowerInvariant();
await _client.RemoveTorrentsAsync([hash]);
}
protected async Task CreateLabel(string name)
{
await UTorrentClient.CreateLabel(name);
}
protected virtual async Task ChangeLabel(string hash, string newLabel)
{
await _client.SetTorrentLabelAsync(hash, newLabel);
}
}

View File

@@ -0,0 +1,174 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.UTorrent.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent.Extensions;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
public partial class UTorrentService
{
/// <inheritdoc/>
public override async Task<DownloadCheckResult> ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList<string> ignoredDownloads)
{
List<UTorrentFile>? files = null;
DownloadCheckResult result = new();
UTorrentItem? download = await _client.GetTorrentAsync(hash);
if (download?.Hash is null)
{
_logger.LogDebug("Failed to find torrent {hash} in the download client", hash);
return result;
}
result.Found = true;
var properties = await _client.GetTorrentPropertiesAsync(hash);
result.IsPrivate = properties.IsPrivate;
if (ignoredDownloads.Count > 0 &&
(download.ShouldIgnore(ignoredDownloads) || properties.TrackerList.Any(x => x.ShouldIgnore(ignoredDownloads))))
{
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
return result;
}
try
{
files = await _client.GetTorrentFilesAsync(hash);
}
catch (Exception exception)
{
_logger.LogDebug(exception, "Failed to get files for torrent {hash} in the download client", hash);
}
bool shouldRemove = files?.Count > 0;
foreach (var file in files ?? [])
{
if (file.Priority > 0) // 0 = skip, >0 = wanted
{
shouldRemove = false;
break;
}
}
if (shouldRemove)
{
// remove if all files are unwanted
_logger.LogDebug("all files are unwanted | removing download | {name}", download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AllFilesSkipped;
return result;
}
// remove if download is stuck
(result.ShouldRemove, result.DeleteReason) = await EvaluateDownloadRemoval(download, result.IsPrivate);
return result;
}
private async Task<(bool, DeleteReason)> EvaluateDownloadRemoval(UTorrentItem torrent, bool isPrivate)
{
(bool ShouldRemove, DeleteReason Reason) result = await CheckIfSlow(torrent, isPrivate);
if (result.ShouldRemove)
{
return result;
}
return await CheckIfStuck(torrent, isPrivate);
}
private async Task<(bool ShouldRemove, DeleteReason Reason)> CheckIfSlow(UTorrentItem download, bool isPrivate)
{
var queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>(nameof(QueueCleanerConfig));
if (queueCleanerConfig.Slow.MaxStrikes is 0)
{
_logger.LogTrace("skip slow check | max strikes is 0 | {name}", download.Name);
return (false, DeleteReason.None);
}
if (!download.IsDownloading())
{
_logger.LogTrace("skip slow check | download is in {state} state | {name}", download.StatusMessage, download.Name);
return (false, DeleteReason.None);
}
if (download.DownloadSpeed <= 0)
{
_logger.LogTrace("skip slow check | download speed is 0 | {name}", download.Name);
return (false, DeleteReason.None);
}
if (queueCleanerConfig.Slow.IgnorePrivate && isPrivate)
{
// ignore private trackers
_logger.LogTrace("skip slow check | download is private | {name}", download.Name);
return (false, DeleteReason.None);
}
if (download.Size > (queueCleanerConfig.Slow.IgnoreAboveSizeByteSize?.Bytes ?? long.MaxValue))
{
_logger.LogTrace("skip slow check | download is too large | {name}", download.Name);
return (false, DeleteReason.None);
}
ByteSize minSpeed = queueCleanerConfig.Slow.MinSpeedByteSize;
ByteSize currentSpeed = new ByteSize(download.DownloadSpeed);
SmartTimeSpan maxTime = SmartTimeSpan.FromHours(queueCleanerConfig.Slow.MaxTime);
SmartTimeSpan currentTime = SmartTimeSpan.FromSeconds(download.ETA);
return await CheckIfSlow(
download.Hash,
download.Name,
minSpeed,
currentSpeed,
maxTime,
currentTime
);
}
private async Task<(bool ShouldRemove, DeleteReason Reason)> CheckIfStuck(UTorrentItem download, bool isPrivate)
{
var queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>(nameof(QueueCleanerConfig));
if (queueCleanerConfig.Stalled.MaxStrikes is 0)
{
_logger.LogTrace("skip stalled check | max strikes is 0 | {name}", download.Name);
return (false, DeleteReason.None);
}
if (queueCleanerConfig.Stalled.IgnorePrivate && isPrivate)
{
_logger.LogDebug("skip stalled check | download is private | {name}", download.Name);
return (false, DeleteReason.None);
}
if (!download.IsDownloading())
{
_logger.LogTrace("skip stalled check | download is in {state} state | {name}", download.StatusMessage, download.Name);
return (false, DeleteReason.None);
}
if (download.DateCompleted > 0)
{
_logger.LogTrace("skip stalled check | download is completed | {name}", download.Name);
return (false, DeleteReason.None);
}
if (download.DownloadSpeed > 0 || download.ETA > 0)
{
_logger.LogTrace("skip stalled check | download is not stalled | {name}", download.Name);
return (false, DeleteReason.None);
}
ResetStalledStrikesOnProgress(download.Hash, download.Downloaded);
return (await _striker.StrikeAndCheckLimit(download.Hash, download.Name, queueCleanerConfig.Stalled.MaxStrikes, StrikeType.Stalled), DeleteReason.Stalled);
}
}

View File

@@ -0,0 +1,17 @@
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
/// <summary>
/// µTorrent status bitfield constants
/// Based on the µTorrent Web UI API documentation
/// </summary>
public static class UTorrentStatus
{
public const int Started = 1; // 1 << 0
public const int Checking = 2; // 1 << 1
public const int StartAfterCheck = 4; // 1 << 2
public const int Checked = 8; // 1 << 3
public const int Error = 16; // 1 << 4
public const int Paused = 32; // 1 << 5
public const int Queued = 64; // 1 << 6
public const int Loaded = 128; // 1 << 7
}

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,
@@ -39,6 +41,29 @@ public sealed class BlocklistProvider
_httpClient = httpClientFactory.CreateClient(Constants.HttpClientWithRetryName);
}
private async Task<bool> EnsureInstanceLoadedAsync(BlocklistSettings settings, InstanceType instanceType)
{
if (!settings.Enabled || string.IsNullOrEmpty(settings.BlocklistPath))
{
return false;
}
string hash = GenerateSettingsHash(settings);
var interval = GetLoadInterval(settings.BlocklistPath);
var identifier = $"{instanceType}_{settings.BlocklistPath}";
if (ShouldReloadBlocklist(identifier, interval) || !_configHashes.TryGetValue(instanceType, out string? oldHash) || hash != oldHash)
{
_logger.LogDebug("Loading {instance} blocklist", instanceType);
await LoadPatternsAndRegexesAsync(settings, instanceType);
_configHashes[instanceType] = hash;
_lastLoadTimes[identifier] = DateTime.UtcNow;
return true;
}
return false;
}
public async Task LoadBlocklistsAsync()
{
try
@@ -46,77 +71,35 @@ 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)
var instances = new Dictionary<InstanceType, BlocklistSettings>
{
_logger.LogDebug("Loading Sonarr blocklist");
await LoadPatternsAndRegexesAsync(contentBlockerConfig.Sonarr, InstanceType.Sonarr);
_configHashes[InstanceType.Sonarr] = sonarrHash;
changedCount++;
{ InstanceType.Sonarr, malwareBlockerConfig.Sonarr },
{ InstanceType.Radarr, malwareBlockerConfig.Radarr },
{ InstanceType.Lidarr, malwareBlockerConfig.Lidarr },
{ InstanceType.Readarr, malwareBlockerConfig.Readarr },
{ InstanceType.Whisparr, malwareBlockerConfig.Whisparr }
};
foreach (var kv in instances)
{
if (await EnsureInstanceLoadedAsync(kv.Value, kv.Key))
{
changedCount++;
}
}
// Check and update Radarr blocklist if needed
string radarrHash = GenerateSettingsHash(contentBlockerConfig.Radarr);
if (shouldReload || !_configHashes.TryGetValue(InstanceType.Radarr, out string? oldRadarrHash) || radarrHash != oldRadarrHash)
{
_logger.LogDebug("Loading Radarr blocklist");
await LoadPatternsAndRegexesAsync(contentBlockerConfig.Radarr, InstanceType.Radarr);
_configHashes[InstanceType.Radarr] = radarrHash;
changedCount++;
}
// Check and update Lidarr blocklist if needed
string lidarrHash = GenerateSettingsHash(contentBlockerConfig.Lidarr);
if (shouldReload || !_configHashes.TryGetValue(InstanceType.Lidarr, out string? oldLidarrHash) || lidarrHash != oldLidarrHash)
{
_logger.LogDebug("Loading Lidarr blocklist");
await LoadPatternsAndRegexesAsync(contentBlockerConfig.Lidarr, InstanceType.Lidarr);
_configHashes[InstanceType.Lidarr] = lidarrHash;
changedCount++;
}
// Check and update Lidarr blocklist if needed
string readarrHash = GenerateSettingsHash(contentBlockerConfig.Readarr);
if (shouldReload || !_configHashes.TryGetValue(InstanceType.Readarr, out string? oldReadarrHash) || readarrHash != oldReadarrHash)
{
_logger.LogDebug("Loading Readarr blocklist");
await LoadPatternsAndRegexesAsync(contentBlockerConfig.Readarr, InstanceType.Readarr);
_configHashes[InstanceType.Readarr] = readarrHash;
changedCount++;
}
// Check and update Whisparr blocklist if needed
string whisparrHash = GenerateSettingsHash(contentBlockerConfig.Whisparr);
if (shouldReload || !_configHashes.TryGetValue(InstanceType.Whisparr, out string? oldWhisparrHash) || whisparrHash != oldWhisparrHash)
{
_logger.LogDebug("Loading Whisparr blocklist");
await LoadPatternsAndRegexesAsync(contentBlockerConfig.Whisparr, InstanceType.Whisparr);
_configHashes[InstanceType.Whisparr] = whisparrHash;
changedCount++;
}
// Always check and update malware patterns
await LoadMalwarePatternsAsync();
if (changedCount > 0)
{
@@ -154,6 +137,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)
{
@@ -231,7 +285,7 @@ public sealed class BlocklistProvider
private string GenerateSettingsHash(BlocklistSettings blocklistSettings)
{
// Create a string that represents the relevant blocklist configuration
var configStr = $"{blocklistSettings.BlocklistPath ?? string.Empty}|{blocklistSettings.BlocklistType}";
var configStr = $"{blocklistSettings.Enabled}|{blocklistSettings.BlocklistPath ?? string.Empty}|{blocklistSettings.BlocklistType}";
// Create SHA256 hash of the configuration string
using var sha = SHA256.Create();

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

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