Compare commits

...

28 Commits

Author SHA1 Message Date
Flaminel
2971445090 Add handling type of malware when containing thepirateheaven.org file (#232) 2025-07-07 14:29:39 +03:00
Flaminel
55c23419cd Improve download removal to be separate from download search (#233) 2025-07-07 14:28:34 +03:00
Flaminel
c4b9d9503a Add more logs for debug (#201) 2025-07-07 14:28:15 +03:00
Flaminel
823b73d9f0 Fix arr max strikes not being used instead of global setting (#231) 2025-07-04 21:16:02 +03:00
Flaminel
31632d25a4 Add Whisparr support (#215) 2025-07-04 21:15:35 +03:00
Flaminel
c59951a39c Add Progressive Web App (PWA) support (#228) 2025-07-04 21:15:14 +03:00
Flaminel
d9140d7b5b Add support for Apprise tags (#229) 2025-07-04 21:14:40 +03:00
Flaminel
90865a73b5 Add failed import messages to logs (#230) 2025-07-04 21:14:27 +03:00
Flaminel
cc45233223 Add support for basic auth for Apprise (#221) 2025-07-03 12:43:43 +03:00
Flaminel
5d12d601ae fixed repo links in the docs 2025-07-01 22:02:14 +03:00
Flaminel
88f40438af Fix validations and increased strikes limits (#212) 2025-07-01 13:18:50 +03:00
Flaminel
0a9ec06841 removed forgotten release step from MacOS workflow 2025-07-01 11:05:00 +03:00
Flaminel
a0ca6ec4b8 Add curl to the Docker image (#211) 2025-07-01 10:06:22 +03:00
Flaminel
eb6cf96470 Fix cron expression inputs (#203) 2025-07-01 01:00:43 +03:00
Flaminel
2ca0616771 Add date on dashboard logs and events (#205) 2025-07-01 01:00:30 +03:00
Flaminel
bc85144e60 Improve deploy workflows (#206) 2025-07-01 01:00:16 +03:00
Flaminel
236e31c841 Add download client name on debug logs (#207) 2025-07-01 00:59:52 +03:00
Flaminel
7a15139aa6 Fix autocomplete input on mobile phones (#196) 2025-06-30 13:28:14 +03:00
Flaminel
fb6ccfd011 Add Readarr support (#191) 2025-06-29 19:54:15 +03:00
Flaminel
ef85e2b690 Fix docs broken links (#190) 2025-06-29 01:03:24 +03:00
Flaminel
bb734230aa Add health checks (#181) 2025-06-29 00:00:55 +03:00
Flaminel
aa31c31955 Remove right-side icons from settings cards (#183) 2025-06-29 00:00:25 +03:00
Flaminel
1a89822f36 Change icon direction for UI accordions (#182) 2025-06-29 00:00:11 +03:00
Flaminel
fc9e0eca36 Fix some small UI stuff (#185) 2025-06-28 23:59:49 +03:00
Flaminel
0010dcb1c6 Fix jobs not being scheduled according to the cron expression (#187) 2025-06-28 23:55:08 +03:00
Flaminel
0ab8611f29 removed Docker Hub reference 2025-06-28 11:52:34 +03:00
Flaminel
9e02408a7e Fix download cleaner categories not being fetched (#177) 2025-06-28 00:08:58 +03:00
Flaminel
1bd0db05e6 updated readme 2025-06-27 21:32:22 +03:00
168 changed files with 8015 additions and 864 deletions

View File

@@ -134,22 +134,4 @@ jobs:
./artifacts/*.zip
retention-days: 30
- name: Release
if: startsWith(github.ref, 'refs/tags/')
id: release
uses: softprops/action-gh-release@v2
with:
name: ${{ env.releaseVersion }}
tag_name: ${{ env.releaseVersion }}
repository: ${{ env.githubRepository }}
token: ${{ env.REPO_READONLY_PAT }}
make_latest: true
fail_on_unmatched_files: true
target_commitish: main
generate_release_notes: true
files: |
./artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-win-amd64.zip
./artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-amd64.zip
./artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-arm64.zip
./artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-amd64.zip
./artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-arm64.zip
# Removed individual release step - handled by main release workflow

View File

@@ -363,14 +363,4 @@ jobs:
path: '${{ env.pkgName }}'
retention-days: 30
- name: Release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
with:
name: ${{ env.releaseVersion }}
tag_name: ${{ env.releaseVersion }}
repository: ${{ env.githubRepository }}
token: ${{ env.REPO_READONLY_PAT }}
make_latest: true
files: |
${{ env.pkgName }}
# Removed individual release step - handled by main release workflow

View File

@@ -363,14 +363,4 @@ jobs:
path: '${{ env.pkgName }}'
retention-days: 30
- name: Release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
with:
name: ${{ env.releaseVersion }}
tag_name: ${{ env.releaseVersion }}
repository: ${{ env.githubRepository }}
token: ${{ env.REPO_READONLY_PAT }}
make_latest: true
files: |
${{ env.pkgName }}
# Removed individual release step - handled by main release workflow

View File

@@ -88,19 +88,6 @@ jobs:
run: |
dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj -c Release --runtime win-x64 --self-contained -o dist /p:PublishSingleFile=true /p:Version=${{ env.appVersion }} /p:DebugType=None /p:DebugSymbols=false
- name: Create sample configuration
shell: pwsh
run: |
# Create config directory
New-Item -ItemType Directory -Force -Path "config"
$config = @{
"HTTP_PORTS" = 11011
"BASE_PATH" = "/"
}
$config | ConvertTo-Json | Out-File -FilePath "config/cleanuparr.json" -Encoding UTF8
- name: Setup Inno Setup
shell: pwsh
run: |
@@ -158,14 +145,4 @@ jobs:
path: installer/${{ env.installerName }}
retention-days: 30
- name: Release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
with:
name: ${{ env.releaseVersion }}
tag_name: ${{ env.releaseVersion }}
repository: ${{ env.githubRepository }}
token: ${{ env.REPO_READONLY_PAT }}
make_latest: true
files: |
installer/${{ env.installerName }}
# Removed individual release step - handled by main release workflow

View File

@@ -12,34 +12,67 @@ Cleanuparr was created primarily to address malicious files, such as `*.lnk` or
> **Features:**
> - Strike system to mark bad downloads.
> - Remove and block downloads that reached a maximum number of strikes.
> - Remove and block downloads that are **failing to be imported** by the arrs. [configuration](https://cleanuparr.github.io/Cleanuparr/docs/configuration/queue-cleaner#failed-import-max-strikes)
> - Remove and block downloads that are **stalled** or in **metadata downloading** state. [configuration](https://cleanuparr.github.io/Cleanuparr/docs/configuration/queue-cleaner#stalled-max-strikes)
> - Remove and block downloads that have a **low download speed** or **high estimated completion time**. [configuration](https://cleanuparr.github.io/Cleanuparr/docs/configuration/queue-cleaner#slow-max-strikes)
> - Remove and block downloads blocked by qBittorrent or by Cleanuparr's **Content Blocker**. [configuration](https://cleanuparr.github.io/Cleanuparr/docs/configuration/content-blocker)
> - 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**.
> - Automatically trigger a search for downloads removed from the arrs.
> - Clean up downloads that have been **seeding** for a certain amount of time. [configuration](https://cleanuparr.github.io/Cleanuparr/docs/configuration/download-cleaner#seeding-settings)
> - Remove downloads that are **orphaned**/have no **hardlinks**/are not referenced by the arrs anymore (with [cross-seed](https://www.cross-seed.org/) support). [configuration](https://cleanuparr.github.io/Cleanuparr/docs/configuration/download-cleaner#enable-unlinked-downloads-management)
> - Notify on strike or download removal. [configuration](https://cleanuparr.github.io/Cleanuparr/docs/configuration/notifications)
> - 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).
> - Notify on strike or download removal.
> - Ignore certain torrent hashes, categories, tags or trackers from being processed by Cleanuparr.
Cleanuparr supports both qBittorrent's built-in exclusion features and its own blocklist-based system. Binaries for all platforms are provided, along with Docker images for easy deployment.
## 🎯 Supported Applications
## Quick Start
### *Arr Applications
- **Sonarr** (TV Shows)
- **Radarr** (Movies)
- **Lidarr** (Music)
> [!NOTE]
>
> 1. **Docker (Recommended)**
> Pull the Docker image from `ghcr.io/cleanuparr/cleanuparr:latest`.
>
> 2. **Unraid (for Unraid users)**
> Use the Unraid Community App.
>
> 3. **Manual Installation (if you're not using Docker)**
> Go to [Windows](#windows), [Linux](#linux) or [MacOS](#macos).
### Download Clients
- **qBittorrent**
- **Transmission**
- **Deluge**
# Docs
### Platforms
- **Docker** (Linux, Windows, macOS)
- **Windows** (Native installer)
- **macOS** (Intel & Apple Silicon)
- **Linux** (Portable executable)
- **Unraid** (Community Apps)
Docs can be found [here](https://cleanuparr.github.io/Cleanuparr/).
## 🚀 Quick Start
```bash
docker run -d --name cleanuparr \
--restart unless-stopped \
-p 11011:11011 \
-v /path/to/config:/config \
-e PORT=11011 \
-e PUID=1000 \
-e PGID=1000 \
-e TZ=Etc/UTC \
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).
### 🌐 Access the Web Interface
After installation, open your browser and navigate to:
```
http://localhost:11011
```
**Next Steps:** Check out the [📖 Complete Documentation](https://cleanuparr.github.io/Cleanuparr/) for detailed configuration guides and setup instructions.
## 📖 Documentation & Support
- **📚 [Complete Documentation](https://cleanuparr.github.io/Cleanuparr/)** - Installation guides, configuration, and troubleshooting
- **⚙️ [Configuration Guide](https://cleanuparr.github.io/Cleanuparr/docs/category/configuration)** - Set up download clients, *arr apps, and features
- **🔧 [Setup Scenarios](https://cleanuparr.github.io/Cleanuparr/docs/category/setup-scenarios)** - Common use cases and examples
- **💬 [Discord Community](https://discord.gg/SCtMCgtsc4)** - Get help and discuss with other users
- **🔗 [GitHub Releases](https://github.com/Cleanuparr/Cleanuparr/releases)** - Download binaries and view changelog
# <img style="vertical-align: middle;" width="24px" src="./Logo/256.png" alt="Cleanuparr"> <span style="vertical-align: middle;">Cleanuparr</span> <img src="https://raw.githubusercontent.com/FortAwesome/Font-Awesome/6.x/svgs/solid/x.svg" height="24px" width="30px" style="vertical-align: middle;"> <span style="vertical-align: middle;">Huntarr</span> <img style="vertical-align: middle;" width="24px" src="https://github.com/plexguide/Huntarr.io/blob/main/frontend/static/logo/512.png?raw=true" alt Huntarr></img>

View File

@@ -45,6 +45,7 @@ FROM mcr.microsoft.com/dotnet/aspnet:9.0-bookworm-slim
# Install required packages for user management and timezone support
RUN apt-get update && apt-get install -y \
curl \
tzdata \
gosu \
&& rm -rf /var/lib/apt/lists/*

View File

@@ -3,10 +3,10 @@ using Cleanuparr.Application.Features.Arr.Dtos;
using Cleanuparr.Application.Features.DownloadClient.Dtos;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
using Cleanuparr.Infrastructure.Logging;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Infrastructure.Utilities;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
@@ -16,7 +16,6 @@ using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Persistence.Models.Configuration.General;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Infrastructure.Services.Interfaces;
using Mapster;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -309,6 +308,42 @@ public class ConfigurationController : ControllerBase
}
}
[HttpGet("readarr")]
public async Task<IActionResult> GetReadarrConfig()
{
await DataContext.Lock.WaitAsync();
try
{
var config = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.AsNoTracking()
.FirstAsync(x => x.Type == InstanceType.Readarr);
return Ok(config.Adapt<ArrConfigDto>());
}
finally
{
DataContext.Lock.Release();
}
}
[HttpGet("whisparr")]
public async Task<IActionResult> GetWhisparrConfig()
{
await DataContext.Lock.WaitAsync();
try
{
var config = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.AsNoTracking()
.FirstAsync(x => x.Type == InstanceType.Whisparr);
return Ok(config.Adapt<ArrConfigDto>());
}
finally
{
DataContext.Lock.Release();
}
}
[HttpGet("notifications")]
public async Task<IActionResult> GetNotificationsConfig()
{
@@ -773,6 +808,68 @@ public class ConfigurationController : ControllerBase
DataContext.Lock.Release();
}
}
[HttpPut("readarr")]
public async Task<IActionResult> UpdateReadarrConfig([FromBody] UpdateReadarrConfigDto newConfigDto)
{
await DataContext.Lock.WaitAsync();
try
{
// Get existing config
var config = await _dataContext.ArrConfigs
.FirstAsync(x => x.Type == InstanceType.Readarr);
config.FailedImportMaxStrikes = newConfigDto.FailedImportMaxStrikes;
// Validate the configuration
config.Validate();
// Persist the configuration
await _dataContext.SaveChangesAsync();
return Ok(new { Message = "Readarr configuration updated successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save Readarr configuration");
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("whisparr")]
public async Task<IActionResult> UpdateWhisparrConfig([FromBody] UpdateWhisparrConfigDto newConfigDto)
{
await DataContext.Lock.WaitAsync();
try
{
// Get existing config
var config = await _dataContext.ArrConfigs
.FirstAsync(x => x.Type == InstanceType.Whisparr);
config.FailedImportMaxStrikes = newConfigDto.FailedImportMaxStrikes;
// Validate the configuration
config.Validate();
// Persist the configuration
await _dataContext.SaveChangesAsync();
return Ok(new { Message = "Whisparr configuration updated successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save Whisparr configuration");
throw;
}
finally
{
DataContext.Lock.Release();
}
}
/// <summary>
/// Updates a job schedule based on configuration changes
@@ -1137,4 +1234,224 @@ public class ConfigurationController : ControllerBase
DataContext.Lock.Release();
}
}
[HttpPost("readarr/instances")]
public async Task<IActionResult> CreateReadarrInstance([FromBody] CreateArrInstanceDto newInstance)
{
await DataContext.Lock.WaitAsync();
try
{
// Get the Readarr config to add the instance to
var config = await _dataContext.ArrConfigs
.FirstAsync(x => x.Type == InstanceType.Readarr);
// Create the new instance
var instance = new ArrInstance
{
Enabled = newInstance.Enabled,
Name = newInstance.Name,
Url = new Uri(newInstance.Url),
ApiKey = newInstance.ApiKey,
ArrConfigId = config.Id,
};
// Add to the config's instances collection
await _dataContext.ArrInstances.AddAsync(instance);
// Save changes
await _dataContext.SaveChangesAsync();
return CreatedAtAction(nameof(GetReadarrConfig), new { id = instance.Id }, instance.Adapt<ArrInstanceDto>());
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create Readarr instance");
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("readarr/instances/{id}")]
public async Task<IActionResult> UpdateReadarrInstance(Guid id, [FromBody] CreateArrInstanceDto updatedInstance)
{
await DataContext.Lock.WaitAsync();
try
{
// Get the Readarr config and find the instance
var config = await _dataContext.ArrConfigs
.Include(c => c.Instances)
.FirstAsync(x => x.Type == InstanceType.Readarr);
var instance = config.Instances.FirstOrDefault(i => i.Id == id);
if (instance == null)
{
return NotFound($"Readarr instance with ID {id} not found");
}
// Update the instance properties
instance.Enabled = updatedInstance.Enabled;
instance.Name = updatedInstance.Name;
instance.Url = new Uri(updatedInstance.Url);
instance.ApiKey = updatedInstance.ApiKey;
await _dataContext.SaveChangesAsync();
return Ok(instance.Adapt<ArrInstanceDto>());
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update Readarr instance with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpDelete("readarr/instances/{id}")]
public async Task<IActionResult> DeleteReadarrInstance(Guid id)
{
await DataContext.Lock.WaitAsync();
try
{
// Get the Readarr config and find the instance
var config = await _dataContext.ArrConfigs
.Include(c => c.Instances)
.FirstAsync(x => x.Type == InstanceType.Readarr);
var instance = config.Instances.FirstOrDefault(i => i.Id == id);
if (instance == null)
{
return NotFound($"Readarr instance with ID {id} not found");
}
// Remove the instance
config.Instances.Remove(instance);
await _dataContext.SaveChangesAsync();
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete Readarr instance with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPost("whisparr/instances")]
public async Task<IActionResult> CreateWhisparrInstance([FromBody] CreateArrInstanceDto newInstance)
{
await DataContext.Lock.WaitAsync();
try
{
// Get the Whisparr config to add the instance to
var config = await _dataContext.ArrConfigs
.FirstAsync(x => x.Type == InstanceType.Whisparr);
// Create the new instance
var instance = new ArrInstance
{
Enabled = newInstance.Enabled,
Name = newInstance.Name,
Url = new Uri(newInstance.Url),
ApiKey = newInstance.ApiKey,
ArrConfigId = config.Id,
};
// Add to the config's instances collection
await _dataContext.ArrInstances.AddAsync(instance);
// Save changes
await _dataContext.SaveChangesAsync();
return CreatedAtAction(nameof(GetWhisparrConfig), new { id = instance.Id }, instance.Adapt<ArrInstanceDto>());
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create Whisparr instance");
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("whisparr/instances/{id}")]
public async Task<IActionResult> UpdateWhisparrInstance(Guid id, [FromBody] CreateArrInstanceDto updatedInstance)
{
await DataContext.Lock.WaitAsync();
try
{
// Get the Whisparr config and find the instance
var config = await _dataContext.ArrConfigs
.Include(c => c.Instances)
.FirstAsync(x => x.Type == InstanceType.Whisparr);
var instance = config.Instances.FirstOrDefault(i => i.Id == id);
if (instance == null)
{
return NotFound($"Whisparr instance with ID {id} not found");
}
// Update the instance properties
instance.Enabled = updatedInstance.Enabled;
instance.Name = updatedInstance.Name;
instance.Url = new Uri(updatedInstance.Url);
instance.ApiKey = updatedInstance.ApiKey;
await _dataContext.SaveChangesAsync();
return Ok(instance.Adapt<ArrInstanceDto>());
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update Whisparr instance with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpDelete("whisparr/instances/{id}")]
public async Task<IActionResult> DeleteWhisparrInstance(Guid id)
{
await DataContext.Lock.WaitAsync();
try
{
// Get the Whisparr config and find the instance
var config = await _dataContext.ArrConfigs
.Include(c => c.Instances)
.FirstAsync(x => x.Type == InstanceType.Whisparr);
var instance = config.Instances.FirstOrDefault(i => i.Id == id);
if (instance == null)
{
return NotFound($"Whisparr instance with ID {id} not found");
}
// Remove the instance
config.Instances.Remove(instance);
await _dataContext.SaveChangesAsync();
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete Whisparr instance with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
}

View File

@@ -0,0 +1,125 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Diagnostics.HealthChecks;
namespace Cleanuparr.Api.Controllers;
/// <summary>
/// Health check endpoints for Docker and Kubernetes
/// </summary>
[ApiController]
[Route("[controller]")]
public class HealthController : ControllerBase
{
private readonly HealthCheckService _healthCheckService;
private readonly ILogger<HealthController> _logger;
public HealthController(HealthCheckService healthCheckService, ILogger<HealthController> logger)
{
_healthCheckService = healthCheckService;
_logger = logger;
}
/// <summary>
/// Basic liveness probe - checks if the application is running
/// Used by Docker HEALTHCHECK and Kubernetes liveness probes
/// </summary>
[HttpGet]
[Route("/health")]
public async Task<IActionResult> GetHealth()
{
try
{
var result = await _healthCheckService.CheckHealthAsync(
registration => registration.Tags.Contains("liveness"));
return result.Status == HealthStatus.Healthy
? Ok(new { status = "healthy", timestamp = DateTime.UtcNow })
: StatusCode(503, new { status = "unhealthy", timestamp = DateTime.UtcNow });
}
catch (Exception ex)
{
_logger.LogError(ex, "Health check failed");
return StatusCode(503, new { status = "unhealthy", error = "Health check failed", timestamp = DateTime.UtcNow });
}
}
/// <summary>
/// Readiness probe - checks if the application is ready to serve traffic
/// Used by Kubernetes readiness probes
/// </summary>
[HttpGet]
[Route("/health/ready")]
public async Task<IActionResult> GetReadiness()
{
try
{
var result = await _healthCheckService.CheckHealthAsync(
registration => registration.Tags.Contains("readiness"));
if (result.Status == HealthStatus.Healthy)
{
return Ok(new { status = "ready", timestamp = DateTime.UtcNow });
}
// For readiness, we consider degraded as not ready
return StatusCode(503, new {
status = "not_ready",
timestamp = DateTime.UtcNow,
details = result.Entries.Where(e => e.Value.Status != HealthStatus.Healthy)
.ToDictionary(e => e.Key, e => new {
status = e.Value.Status.ToString().ToLowerInvariant(),
description = e.Value.Description
})
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Readiness check failed");
return StatusCode(503, new { status = "not_ready", error = "Readiness check failed", timestamp = DateTime.UtcNow });
}
}
/// <summary>
/// Detailed health status - for monitoring and debugging
/// </summary>
[HttpGet]
[Route("/health/detailed")]
public async Task<IActionResult> GetDetailedHealth()
{
try
{
var result = await _healthCheckService.CheckHealthAsync();
var response = new
{
status = result.Status.ToString().ToLowerInvariant(),
timestamp = DateTime.UtcNow,
totalDuration = result.TotalDuration.TotalMilliseconds,
entries = result.Entries.ToDictionary(
e => e.Key,
e => new
{
status = e.Value.Status.ToString().ToLowerInvariant(),
description = e.Value.Description,
duration = e.Value.Duration.TotalMilliseconds,
tags = e.Value.Tags,
data = e.Value.Data,
exception = e.Value.Exception?.Message
})
};
return result.Status == HealthStatus.Healthy
? Ok(response)
: StatusCode(503, response);
}
catch (Exception ex)
{
_logger.LogError(ex, "Detailed health check failed");
return StatusCode(503, new {
status = "unhealthy",
error = "Detailed health check failed",
timestamp = DateTime.UtcNow
});
}
}
}

View File

@@ -1,6 +1,6 @@
using Cleanuparr.Api.Models;
using Cleanuparr.Infrastructure.Models;
using Infrastructure.Services.Interfaces;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace Cleanuparr.Api.Controllers;

View File

@@ -52,6 +52,10 @@ public class StatusController : ControllerBase
.Include(x => x.Instances)
.AsNoTracking()
.FirstAsync(x => x.Type == InstanceType.Lidarr);
var readarrConfig = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.AsNoTracking()
.FirstAsync(x => x.Type == InstanceType.Readarr);
var status = new
{
@@ -80,6 +84,10 @@ public class StatusController : ControllerBase
Lidarr = new
{
InstanceCount = lidarrConfig.Instances.Count
},
Readarr = new
{
InstanceCount = readarrConfig.Instances.Count
}
}
};

View File

@@ -40,9 +40,6 @@ public static class ApiDI
// Add health status broadcaster
services.AddHostedService<HealthStatusBroadcaster>();
// Add logging initializer service
services.AddHostedService<LoggingInitializer>();
services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo
@@ -142,6 +139,38 @@ public static class ApiDI
// Map SignalR hubs
app.MapHub<HealthStatusHub>("/api/hubs/health");
app.MapHub<AppHub>("/api/hubs/app");
app.MapGet("/manifest.webmanifest", (HttpContext context) =>
{
var basePath = context.Request.PathBase.HasValue
? context.Request.PathBase.Value
: "/";
var manifest = new
{
name = "Cleanuparr",
short_name = "Cleanuparr",
start_url = basePath,
display = "standalone",
background_color = "#ffffff",
theme_color = "#ffffff",
icons = new[]
{
new {
src = "assets/icons/icon-192x192.png",
sizes = "192x192",
type = "image/png"
},
new {
src = "assets/icons/icon-512x512.png",
sizes = "512x512",
type = "image/png"
}
}
};
return Results.Json(manifest, contentType: "application/manifest+json");
});
return app;
}

View File

@@ -1,11 +1,13 @@
using System.Text.Json.Serialization;
using Cleanuparr.Domain.Entities.Arr;
using Cleanuparr.Infrastructure.Features.DownloadHunter.Consumers;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Consumers;
using Cleanuparr.Infrastructure.Features.Notifications.Consumers;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Infrastructure.Health;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
using Data.Models.Arr;
using Infrastructure.Verticals.Notifications.Models;
using MassTransit;
using Microsoft.Extensions.Caching.Memory;
@@ -26,7 +28,9 @@ public static class MainDI
.AddMassTransit(config =>
{
config.AddConsumer<DownloadRemoverConsumer<SearchItem>>();
config.AddConsumer<DownloadRemoverConsumer<SonarrSearchItem>>();
config.AddConsumer<DownloadRemoverConsumer<SeriesSearchItem>>();
config.AddConsumer<DownloadHunterConsumer<SearchItem>>();
config.AddConsumer<DownloadHunterConsumer<SeriesSearchItem>>();
config.AddConsumer<NotificationConsumer<FailedImportStrikeNotification>>();
config.AddConsumer<NotificationConsumer<StalledStrikeNotification>>();
@@ -48,7 +52,15 @@ public static class MainDI
cfg.ReceiveEndpoint("download-remover-queue", e =>
{
e.ConfigureConsumer<DownloadRemoverConsumer<SearchItem>>(context);
e.ConfigureConsumer<DownloadRemoverConsumer<SonarrSearchItem>>(context);
e.ConfigureConsumer<DownloadRemoverConsumer<SeriesSearchItem>>(context);
e.ConcurrentMessageLimit = 2;
e.PrefetchCount = 2;
});
cfg.ReceiveEndpoint("download-hunter-queue", e =>
{
e.ConfigureConsumer<DownloadHunterConsumer<SearchItem>>(context);
e.ConfigureConsumer<DownloadHunterConsumer<SeriesSearchItem>>(context);
e.ConcurrentMessageLimit = 1;
e.PrefetchCount = 1;
});
@@ -83,9 +95,17 @@ public static class MainDI
/// </summary>
private static IServiceCollection AddHealthServices(this IServiceCollection services) =>
services
// Register the health check service
// Register the existing health check service for download clients
.AddSingleton<IHealthCheckService, HealthCheckService>()
// Register the background service for periodic health checks
.AddHostedService<HealthCheckBackgroundService>();
.AddHostedService<HealthCheckBackgroundService>()
// Add ASP.NET Core health checks
.AddHealthChecks()
.AddCheck<ApplicationHealthCheck>("application", tags: ["liveness"])
.AddCheck<DatabaseHealthCheck>("database", tags: ["readiness"])
.AddCheck<FileSystemHealthCheck>("filesystem", tags: ["readiness"])
.AddCheck<DownloadClientsHealthCheck>("download_clients", tags: ["readiness"])
.Services;
}

View File

@@ -5,6 +5,8 @@ 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;
using Cleanuparr.Infrastructure.Features.DownloadRemover;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces;
using Cleanuparr.Infrastructure.Features.Files;
@@ -12,9 +14,9 @@ using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.Security;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Services;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Infrastructure.Interceptors;
using Infrastructure.Services.Interfaces;
using Infrastructure.Verticals.Files;
namespace Cleanuparr.Api.DependencyInjection;
@@ -37,11 +39,14 @@ public static class ServicesDI
.AddTransient<SonarrClient>()
.AddTransient<RadarrClient>()
.AddTransient<LidarrClient>()
.AddTransient<ReadarrClient>()
.AddTransient<WhisparrClient>()
.AddTransient<ArrClientFactory>()
.AddTransient<QueueCleaner>()
.AddTransient<ContentBlocker>()
.AddTransient<DownloadCleaner>()
.AddTransient<IQueueItemRemover, QueueItemRemover>()
.AddTransient<IDownloadHunter, DownloadHunter>()
.AddTransient<IFilenameEvaluator, FilenameEvaluator>()
.AddTransient<IHardLinkFileService, HardLinkFileService>()
.AddTransient<UnixHardLinkFileService>()

View File

@@ -0,0 +1,28 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
using System.Text;
namespace Cleanuparr.Api;
/// <summary>
/// Custom health check response writers for different formats
/// </summary>
public static class HealthCheckResponseWriter
{
/// <summary>
/// Writes a minimal plain text response suitable for Docker health checks
/// </summary>
public static async Task WriteMinimalPlaintext(HttpContext context, HealthReport report)
{
context.Response.ContentType = "text/plain";
var status = report.Status switch
{
HealthStatus.Healthy => "healthy",
HealthStatus.Degraded => "degraded",
HealthStatus.Unhealthy => "unhealthy",
_ => "unknown"
};
await context.Response.WriteAsync(status, Encoding.UTF8);
}
}

View File

@@ -175,7 +175,7 @@ public class BackgroundJobManager : IHostedService
IOperableTrigger triggerObj = (IOperableTrigger)TriggerBuilder.Create()
.WithIdentity("ValidationTrigger")
.StartNow()
.WithCronSchedule(cronExpression)
.WithCronSchedule(cronExpression, x => x.WithMisfireHandlingInstructionDoNothing())
.Build();
IReadOnlyList<DateTimeOffset> nextFireTimes = TriggerUtils.ComputeFireTimes(triggerObj, null, 2);
@@ -197,26 +197,26 @@ public class BackgroundJobManager : IHostedService
}
}
// Create cron trigger
// Create main cron trigger with consistent naming (matches JobManagementService)
var trigger = TriggerBuilder.Create()
.WithIdentity($"{typeName}-trigger")
.ForJob(jobKey)
.WithCronSchedule(cronExpression, x => x.WithMisfireHandlingInstructionDoNothing())
.StartNow()
.Build();
// Create startup trigger to run immediately
// Schedule the main trigger
await _scheduler.ScheduleJob(trigger, cancellationToken);
// Trigger immediate execution for startup using a one-time trigger
var startupTrigger = TriggerBuilder.Create()
.WithIdentity($"{typeName}-startup-trigger")
.WithIdentity($"{typeName}-startup-{DateTimeOffset.UtcNow.Ticks}")
.ForJob(jobKey)
.StartNow()
.Build();
// Schedule job with both triggers
await _scheduler.ScheduleJob(trigger, cancellationToken);
await _scheduler.ScheduleJob(startupTrigger, cancellationToken);
_logger.LogInformation("Added triggers for job {name} with cron expression {CronExpression}",
_logger.LogInformation("Added trigger for job {name} with cron expression {CronExpression} and immediate startup execution",
typeName, cronExpression);
}

View File

@@ -4,6 +4,8 @@ using Cleanuparr.Api;
using Cleanuparr.Api.DependencyInjection;
using Cleanuparr.Infrastructure.Logging;
using Cleanuparr.Shared.Helpers;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Serilog;
var builder = WebApplication.CreateBuilder(args);
@@ -147,6 +149,19 @@ logConfig.WriteTo.Sink(signalRSink);
Log.Logger = logConfig.CreateLogger();
// Configure health check endpoints before the API configuration
app.MapHealthChecks("/health", new HealthCheckOptions
{
Predicate = registration => registration.Tags.Contains("liveness"),
ResponseWriter = HealthCheckResponseWriter.WriteMinimalPlaintext
});
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = registration => registration.Tags.Contains("readiness"),
ResponseWriter = HealthCheckResponseWriter.WriteMinimalPlaintext
});
app.ConfigureApi();
await app.RunAsync();

View File

@@ -0,0 +1,9 @@
namespace Cleanuparr.Application.Features.Arr.Dtos;
/// <summary>
/// DTO for updating Readarr configuration basic settings (instances managed separately)
/// </summary>
public record UpdateReadarrConfigDto
{
public short FailedImportMaxStrikes { get; init; } = -1;
}

View File

@@ -0,0 +1,9 @@
namespace Cleanuparr.Application.Features.Arr.Dtos;
/// <summary>
/// DTO for updating Whisparr configuration basic settings (instances managed separately)
/// </summary>
public record UpdateWhisparrConfigDto
{
public short FailedImportMaxStrikes { get; init; } = -1;
}

View File

@@ -1,4 +1,5 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
@@ -12,7 +13,6 @@ using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
using Cleanuparr.Persistence.Models.Configuration.General;
using Data.Models.Arr.Queue;
using MassTransit;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
@@ -52,7 +52,7 @@ public sealed class ContentBlocker : GenericHandler
var config = ContextProvider.Get<ContentBlockerConfig>();
if (!config.Sonarr.Enabled && !config.Radarr.Enabled && !config.Lidarr.Enabled)
if (!config.Sonarr.Enabled && !config.Radarr.Enabled && !config.Lidarr.Enabled && !config.Readarr.Enabled && !config.Whisparr.Enabled)
{
_logger.LogWarning("No blocklists are enabled");
return;
@@ -63,6 +63,8 @@ public sealed class ContentBlocker : GenericHandler
var sonarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Sonarr));
var radarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Radarr));
var lidarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Lidarr));
var readarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Readarr));
var whisparrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Whisparr));
if (config.Sonarr.Enabled)
{
@@ -78,6 +80,16 @@ public sealed class ContentBlocker : GenericHandler
{
await ProcessArrConfigAsync(lidarrConfig, InstanceType.Lidarr);
}
if (config.Readarr.Enabled)
{
await ProcessArrConfigAsync(readarrConfig, InstanceType.Readarr);
}
if (config.Whisparr.Enabled)
{
await ProcessArrConfigAsync(whisparrConfig, InstanceType.Whisparr);
}
}
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType)
@@ -171,6 +183,10 @@ public sealed class ContentBlocker : GenericHandler
_logger.LogWarning("Download not found in any torrent client | {title}", record.Title);
}
}
else
{
_logger.LogDebug("No torrent clients enabled");
}
}
if (!result.ShouldRemove)
@@ -194,7 +210,7 @@ public sealed class ContentBlocker : GenericHandler
record,
group.Count() > 1,
removeFromClient,
DeleteReason.AllFilesBlocked
result.DeleteReason
);
}
});

View File

@@ -1,3 +1,4 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.Arr;
@@ -10,7 +11,6 @@ using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Persistence.Models.Configuration.General;
using Data.Models.Arr.Queue;
using MassTransit;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
@@ -130,7 +130,9 @@ public sealed class DownloadCleaner : GenericHandler
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Sonarr)), InstanceType.Sonarr, true);
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Radarr)), InstanceType.Radarr, true);
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Lidarr)), InstanceType.Lidarr, true);
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Readarr)), InstanceType.Readarr, true);
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Whisparr)), InstanceType.Whisparr, true);
if (isUnlinkedEnabled && downloadServiceWithDownloads.Count > 0)
{
_logger.LogInformation("Found {count} potential downloads to change category", downloadServiceWithDownloads.Sum(x => x.Item2.Count));

View File

@@ -1,3 +1,4 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.Arr;
@@ -11,7 +12,6 @@ using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.General;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Data.Models.Arr.Queue;
using MassTransit;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
@@ -42,10 +42,14 @@ public sealed class QueueCleaner : GenericHandler
var sonarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Sonarr));
var radarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Radarr));
var lidarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Lidarr));
var readarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Readarr));
var whisparrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Whisparr));
await ProcessArrConfigAsync(sonarrConfig, InstanceType.Sonarr);
await ProcessArrConfigAsync(radarrConfig, InstanceType.Radarr);
await ProcessArrConfigAsync(lidarrConfig, InstanceType.Lidarr);
await ProcessArrConfigAsync(readarrConfig, InstanceType.Readarr);
await ProcessArrConfigAsync(whisparrConfig, InstanceType.Whisparr);
}
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType)
@@ -137,12 +141,16 @@ public sealed class QueueCleaner : GenericHandler
_logger.LogWarning("Download not found in any torrent client | {title}", record.Title);
}
}
else
{
_logger.LogDebug("No torrent clients enabled");
}
}
var config = ContextProvider.Get<QueueCleanerConfig>();
// failed import check
bool shouldRemoveFromArr = await arrClient.ShouldRemoveFromQueue(instanceType, record, downloadCheckResult.IsPrivate, config.FailedImport.MaxStrikes);
bool shouldRemoveFromArr = await arrClient.ShouldRemoveFromQueue(instanceType, record, downloadCheckResult.IsPrivate, instance.ArrConfig.FailedImportMaxStrikes);
DeleteReason deleteReason = downloadCheckResult.ShouldRemove ? downloadCheckResult.DeleteReason : DeleteReason.FailedImport;
if (!shouldRemoveFromArr && !downloadCheckResult.ShouldRemove)

View File

@@ -1,4 +1,4 @@
namespace Data.Models.Arr.Queue;
namespace Cleanuparr.Domain.Entities.Arr.Queue;
public record Image
{

View File

@@ -1,4 +1,4 @@
namespace Data.Models.Arr.Queue;
namespace Cleanuparr.Domain.Entities.Arr.Queue;
public record LidarrImage
{

View File

@@ -1,4 +1,4 @@
namespace Data.Models.Arr.Queue;
namespace Cleanuparr.Domain.Entities.Arr.Queue;
public sealed record QueueAlbum
{

View File

@@ -0,0 +1,6 @@
namespace Cleanuparr.Domain.Entities.Arr.Queue;
public sealed record QueueBook
{
public List<ReadarrImage> Images { get; init; } = [];
}

View File

@@ -1,4 +1,4 @@
namespace Data.Models.Arr.Queue;
namespace Cleanuparr.Domain.Entities.Arr.Queue;
public record QueueListResponse
{

View File

@@ -1,4 +1,4 @@
namespace Data.Models.Arr.Queue;
namespace Cleanuparr.Domain.Entities.Arr.Queue;
public sealed record QueueMovie
{

View File

@@ -1,8 +1,8 @@
namespace Data.Models.Arr.Queue;
namespace Cleanuparr.Domain.Entities.Arr.Queue;
public sealed record QueueRecord
{
// Sonarr
// Sonarr and Whisparr
public long SeriesId { get; init; }
public long EpisodeId { get; init; }
public long SeasonNumber { get; init; }
@@ -21,6 +21,13 @@ public sealed record QueueRecord
public QueueAlbum? Album { get; init; }
// Readarr
public long AuthorId { get; init; }
public long BookId { get; init; }
public QueueBook? Book { get; init; }
// common
public required string Title { get; init; }
public string Status { get; init; }

View File

@@ -1,4 +1,4 @@
namespace Data.Models.Arr.Queue;
namespace Cleanuparr.Domain.Entities.Arr.Queue;
public sealed record QueueSeries
{

View File

@@ -0,0 +1,8 @@
namespace Cleanuparr.Domain.Entities.Arr.Queue;
public sealed record ReadarrImage
{
public required string CoverType { get; init; }
public required Uri Url { get; init; }
}

View File

@@ -1,4 +1,4 @@
namespace Data.Models.Arr.Queue;
namespace Cleanuparr.Domain.Entities.Arr.Queue;
public sealed record TrackedDownloadStatusMessage
{

View File

@@ -1,16 +1,17 @@
using Cleanuparr.Domain.Enums;
using Data.Models.Arr;
namespace Data.Models.Arr;
namespace Cleanuparr.Domain.Entities.Arr;
public sealed class SonarrSearchItem : SearchItem
public sealed class SeriesSearchItem : SearchItem
{
public long SeriesId { get; set; }
public SonarrSearchType SearchType { get; set; }
public SeriesSearchType SearchType { get; set; }
public override bool Equals(object? obj)
{
if (obj is not SonarrSearchItem other)
if (obj is not SeriesSearchItem other)
{
return false;
}

View File

@@ -0,0 +1,8 @@
namespace Cleanuparr.Domain.Entities.Readarr;
public sealed record Author
{
public long Id { get; set; }
public string AuthorName { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,12 @@
namespace Cleanuparr.Domain.Entities.Readarr;
public sealed record Book
{
public required long Id { get; init; }
public required string Title { get; init; }
public long AuthorId { get; set; }
public Author Author { get; set; } = new();
}

View File

@@ -0,0 +1,8 @@
namespace Cleanuparr.Domain.Entities.Readarr;
public sealed record ReadarrCommand
{
public string Name { get; set; } = string.Empty;
public List<long> BookIds { get; set; } = [];
}

View File

@@ -12,5 +12,5 @@ public sealed record SonarrCommand
public List<long>? EpisodeIds { get; set; }
public SonarrSearchType SearchType { get; set; }
public SeriesSearchType SearchType { get; set; }
}

View File

@@ -0,0 +1,16 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Domain.Entities.Whisparr;
public sealed record WhisparrCommand
{
public string Name { get; set; }
public long? SeriesId { get; set; }
public long? SeasonNumber { get; set; }
public List<long>? EpisodeIds { get; set; }
public SeriesSearchType SearchType { get; set; }
}

View File

@@ -11,4 +11,5 @@ public enum DeleteReason
AllFilesSkipped,
AllFilesSkippedByQBit,
AllFilesBlocked,
MalwareFileFound,
}

View File

@@ -1,6 +1,6 @@
namespace Cleanuparr.Domain.Enums;
public enum SonarrSearchType
public enum SeriesSearchType
{
Episode,
Season,

View File

@@ -6,16 +6,13 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<Folder Include="Features\" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FLM.QBittorrent" Version="1.0.1" />
<PackageReference Include="FLM.Transmission" Version="1.0.3" />
<PackageReference Include="Mapster" Version="7.4.0" />
<PackageReference Include="MassTransit.Abstractions" Version="8.4.1" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.6" />

View File

@@ -1,4 +1,7 @@
using System.Dynamic;
using System.Text.Json;
using System.Text.Json.Serialization;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.Notifications;
@@ -47,7 +50,10 @@ public class EventPublisher
EventType = eventType,
Message = message,
Severity = severity,
Data = data != null ? JsonSerializer.Serialize(data) : null,
Data = data != null ? JsonSerializer.Serialize(data, new JsonSerializerOptions
{
Converters = { new JsonStringEnumConverter() }
}) : null,
TrackingId = trackingId
};
@@ -75,12 +81,37 @@ public class EventPublisher
StrikeType.SlowTime => EventType.SlowTimeStrike,
};
dynamic data;
if (strikeType is StrikeType.FailedImport)
{
QueueRecord record = ContextProvider.Get<QueueRecord>(nameof(QueueRecord));
data = new
{
hash,
itemName,
strikeCount,
strikeType,
failedImportReasons = record.StatusMessages ?? [],
};
}
else
{
data = new
{
hash,
itemName,
strikeCount,
strikeType,
};
}
// Publish the event
await PublishAsync(
eventType,
$"Item '{itemName}' has been struck {strikeCount} times for reason '{strikeType}'",
EventSeverity.Important,
data: new { hash, itemName, strikeCount, strikeType });
data: data);
// Send notification (uses ContextProvider internally)
await _notificationPublisher.NotifyStrike(strikeType, strikeCount);

View File

@@ -1,6 +1,5 @@
using Cleanuparr.Domain.Entities.Deluge.Response;
using Cleanuparr.Infrastructure.Services;
using Infrastructure.Services;
namespace Cleanuparr.Infrastructure.Extensions;

View File

@@ -1,5 +1,4 @@
using Cleanuparr.Infrastructure.Services;
using Infrastructure.Services;
using QBittorrent.Client;
namespace Cleanuparr.Infrastructure.Extensions;

View File

@@ -1,5 +1,4 @@
using Cleanuparr.Infrastructure.Services;
using Infrastructure.Services;
using Transmission.API.RPC.Entity;
namespace Cleanuparr.Infrastructure.Extensions;

View File

@@ -1,3 +1,4 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.Context;
@@ -6,7 +7,6 @@ using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Cleanuparr.Shared.Helpers;
using Data.Models.Arr;
using Data.Models.Arr.Queue;
using Infrastructure.Interceptors;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
@@ -65,7 +65,7 @@ public abstract class ArrClient : IArrClient
return queueResponse;
}
public virtual async Task<bool> ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload, ushort arrMaxStrikes)
public virtual async Task<bool> ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload, short arrMaxStrikes)
{
var queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>();
@@ -105,6 +105,12 @@ public abstract class ArrClient : IArrClient
ushort maxStrikes = arrMaxStrikes > 0 ? (ushort)arrMaxStrikes : queueCleanerConfig.FailedImport.MaxStrikes;
_logger.LogInformation(
"Item {title} has failed import status with the following reason(s):\n{messages}",
record.Title,
string.Join("\n", record.StatusMessages?.Select(JsonConvert.SerializeObject) ?? [])
);
return await _striker.StrikeAndCheckLimit(
record.DownloadId,
record.Title,
@@ -206,7 +212,7 @@ public abstract class ArrClient : IArrClient
return response;
}
private bool HasIgnoredPatterns(QueueRecord record)
private static bool HasIgnoredPatterns(QueueRecord record)
{
var queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>();

View File

@@ -8,16 +8,22 @@ public sealed class ArrClientFactory
private readonly ISonarrClient _sonarrClient;
private readonly IRadarrClient _radarrClient;
private readonly ILidarrClient _lidarrClient;
private readonly IReadarrClient _readarrClient;
private readonly IWhisparrClient _whisparrClient;
public ArrClientFactory(
SonarrClient sonarrClient,
RadarrClient radarrClient,
LidarrClient lidarrClient
LidarrClient lidarrClient,
ReadarrClient readarrClient,
WhisparrClient whisparrClient
)
{
_sonarrClient = sonarrClient;
_radarrClient = radarrClient;
_lidarrClient = lidarrClient;
_readarrClient = readarrClient;
_whisparrClient = whisparrClient;
}
public IArrClient GetClient(InstanceType type) =>
@@ -26,6 +32,8 @@ public sealed class ArrClientFactory
InstanceType.Sonarr => _sonarrClient,
InstanceType.Radarr => _radarrClient,
InstanceType.Lidarr => _lidarrClient,
InstanceType.Readarr => _readarrClient,
InstanceType.Whisparr => _whisparrClient,
_ => throw new NotImplementedException($"instance type {type} is not yet supported")
};
}

View File

@@ -1,6 +1,6 @@
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr.Queue;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.Arr;

View File

@@ -1,7 +1,7 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using Data.Models.Arr.Queue;
namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces;
@@ -9,7 +9,7 @@ public interface IArrClient
{
Task<QueueListResponse> GetQueueItemsAsync(ArrInstance arrInstance, int page);
Task<bool> ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload, ushort arrMaxStrikes);
Task<bool> ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload, short arrMaxStrikes);
Task DeleteQueueItemAsync(ArrInstance arrInstance, QueueRecord record, bool removeFromClient, DeleteReason deleteReason);

View File

@@ -0,0 +1,5 @@
namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces;
public interface IReadarrClient : IArrClient
{
}

View File

@@ -0,0 +1,5 @@
namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces;
public interface IWhisparrClient : IArrClient
{
}

View File

@@ -1,10 +1,10 @@
using System.Text;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Entities.Lidarr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using Data.Models.Arr.Queue;
using Infrastructure.Interceptors;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;

View File

@@ -1,10 +1,10 @@
using System.Text;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Entities.Radarr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using Data.Models.Arr.Queue;
using Infrastructure.Interceptors;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;

View File

@@ -0,0 +1,151 @@
using System.Text;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Entities.Readarr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using Infrastructure.Interceptors;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace Cleanuparr.Infrastructure.Features.Arr;
public class ReadarrClient : ArrClient, IReadarrClient
{
public ReadarrClient(
ILogger<ReadarrClient> logger,
IHttpClientFactory httpClientFactory,
IStriker striker,
IDryRunInterceptor dryRunInterceptor
) : base(logger, httpClientFactory, striker, dryRunInterceptor)
{
}
protected override string GetQueueUrlPath()
{
return "/api/v1/queue";
}
protected override string GetQueueUrlQuery(int page)
{
return $"page={page}&pageSize=200&includeUnknownAuthorItems=true&includeAuthor=true&includeBook=true";
}
protected override string GetQueueDeleteUrlPath(long recordId)
{
return $"/api/v1/queue/{recordId}";
}
protected override string GetQueueDeleteUrlQuery(bool removeFromClient)
{
string query = "blocklist=true&skipRedownload=true&changeCategory=false";
query += removeFromClient ? "&removeFromClient=true" : "&removeFromClient=false";
return query;
}
public override async Task SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
{
if (items?.Count is null or 0)
{
return;
}
List<long> ids = items.Select(item => item.Id).ToList();
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v1/command";
ReadarrCommand command = new()
{
Name = "BookSearch",
BookIds = ids,
};
using HttpRequestMessage request = new(HttpMethod.Post, uriBuilder.Uri);
request.Content = new StringContent(
JsonConvert.SerializeObject(command),
Encoding.UTF8,
"application/json"
);
SetApiKey(request, arrInstance.ApiKey);
string? logContext = await ComputeCommandLogContextAsync(arrInstance, command);
try
{
HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync<HttpResponseMessage>(SendRequestAsync, request);
response?.Dispose();
_logger.LogInformation("{log}", GetSearchLog(arrInstance.Url, command, true, logContext));
}
catch
{
_logger.LogError("{log}", GetSearchLog(arrInstance.Url, command, false, logContext));
throw;
}
}
public override bool IsRecordValid(QueueRecord record)
{
if (record.AuthorId is 0 || record.BookId is 0)
{
_logger.LogDebug("skip | author id and/or book id missing | {title}", record.Title);
return false;
}
return base.IsRecordValid(record);
}
private static string GetSearchLog(Uri instanceUrl, ReadarrCommand command, bool success, string? logContext)
{
string status = success ? "triggered" : "failed";
string message = logContext ?? $"book ids: {string.Join(',', command.BookIds)}";
return $"book search {status} | {instanceUrl} | {message}";
}
private async Task<string?> ComputeCommandLogContextAsync(ArrInstance arrInstance, ReadarrCommand command)
{
try
{
StringBuilder log = new();
foreach (long bookId in command.BookIds)
{
Book? book = await GetBookAsync(arrInstance, bookId);
if (book is null)
{
return null;
}
log.Append($"[{book.Title}]");
}
return log.ToString();
}
catch (Exception exception)
{
_logger.LogDebug(exception, "failed to compute log context");
}
return null;
}
private async Task<Book?> GetBookAsync(ArrInstance arrInstance, long bookId)
{
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v1/book/{bookId}";
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
string responseBody = await response.Content.ReadAsStringAsync();
return JsonConvert.DeserializeObject<Book>(responseBody);
}
}

View File

@@ -1,11 +1,12 @@
using System.Text;
using Cleanuparr.Domain.Entities.Arr;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Entities.Sonarr;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using Data.Models.Arr.Queue;
using Infrastructure.Interceptors;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
@@ -57,7 +58,7 @@ public class SonarrClient : ArrClient, ISonarrClient
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/command";
foreach (SonarrCommand command in GetSearchCommands(items.Cast<SonarrSearchItem>().ToHashSet()))
foreach (SonarrCommand command in GetSearchCommands(items.Cast<SeriesSearchItem>().ToHashSet()))
{
using HttpRequestMessage request = new(HttpMethod.Post, uriBuilder.Uri);
request.Content = new StringContent(
@@ -96,7 +97,7 @@ public class SonarrClient : ArrClient, ISonarrClient
}
private static string GetSearchLog(
SonarrSearchType searchType,
SeriesSearchType searchType,
Uri instanceUrl,
SonarrCommand command,
bool success,
@@ -107,22 +108,22 @@ public class SonarrClient : ArrClient, ISonarrClient
return searchType switch
{
SonarrSearchType.Episode =>
SeriesSearchType.Episode =>
$"episodes search {status} | {instanceUrl} | {logContext ?? $"episode ids: {string.Join(',', command.EpisodeIds)}"}",
SonarrSearchType.Season =>
SeriesSearchType.Season =>
$"season search {status} | {instanceUrl} | {logContext ?? $"season: {command.SeasonNumber} series id: {command.SeriesId}"}",
SonarrSearchType.Series => $"series search {status} | {instanceUrl} | {logContext ?? $"series id: {command.SeriesId}"}",
SeriesSearchType.Series => $"series search {status} | {instanceUrl} | {logContext ?? $"series id: {command.SeriesId}"}",
_ => throw new ArgumentOutOfRangeException(nameof(searchType), searchType, null)
};
}
private async Task<string?> ComputeCommandLogContextAsync(ArrInstance arrInstance, SonarrCommand command, SonarrSearchType searchType)
private async Task<string?> ComputeCommandLogContextAsync(ArrInstance arrInstance, SonarrCommand command, SeriesSearchType searchType)
{
try
{
StringBuilder log = new();
if (searchType is SonarrSearchType.Episode)
if (searchType is SeriesSearchType.Episode)
{
var episodes = await GetEpisodesAsync(arrInstance, command.EpisodeIds);
@@ -164,7 +165,7 @@ public class SonarrClient : ArrClient, ISonarrClient
}
}
if (searchType is SonarrSearchType.Season)
if (searchType is SeriesSearchType.Season)
{
Series? show = await GetSeriesAsync(arrInstance, command.SeriesId.Value);
@@ -176,7 +177,7 @@ public class SonarrClient : ArrClient, ISonarrClient
log.Append($"[{show.Title} season {command.SeasonNumber}]");
}
if (searchType is SonarrSearchType.Series)
if (searchType is SeriesSearchType.Series)
{
Series? show = await GetSeriesAsync(arrInstance, command.SeriesId.Value);
@@ -229,7 +230,7 @@ public class SonarrClient : ArrClient, ISonarrClient
return JsonConvert.DeserializeObject<Series>(responseBody);
}
private List<SonarrCommand> GetSearchCommands(HashSet<SonarrSearchItem> items)
private List<SonarrCommand> GetSearchCommands(HashSet<SeriesSearchItem> items)
{
const string episodeSearch = "EpisodeSearch";
const string seasonSearch = "SeasonSearch";
@@ -237,29 +238,29 @@ public class SonarrClient : ArrClient, ISonarrClient
List<SonarrCommand> commands = new();
foreach (SonarrSearchItem item in items)
foreach (SeriesSearchItem item in items)
{
SonarrCommand command = item.SearchType is SonarrSearchType.Episode
SonarrCommand command = item.SearchType is SeriesSearchType.Episode
? commands.FirstOrDefault() ?? new() { Name = episodeSearch, EpisodeIds = new() }
: new();
switch (item.SearchType)
{
case SonarrSearchType.Episode when command.EpisodeIds is null:
case SeriesSearchType.Episode when command.EpisodeIds is null:
command.EpisodeIds = [item.Id];
break;
case SonarrSearchType.Episode when command.EpisodeIds is not null:
case SeriesSearchType.Episode when command.EpisodeIds is not null:
command.EpisodeIds.Add(item.Id);
break;
case SonarrSearchType.Season:
case SeriesSearchType.Season:
command.Name = seasonSearch;
command.SeasonNumber = item.Id;
command.SeriesId = ((SonarrSearchItem)item).SeriesId;
command.SeriesId = ((SeriesSearchItem)item).SeriesId;
break;
case SonarrSearchType.Series:
case SeriesSearchType.Series:
command.Name = seriesSearch;
command.SeriesId = item.Id;
break;
@@ -268,7 +269,7 @@ public class SonarrClient : ArrClient, ISonarrClient
throw new ArgumentOutOfRangeException(nameof(item.SearchType), item.SearchType, null);
}
if (item.SearchType is SonarrSearchType.Episode && commands.Count > 0)
if (item.SearchType is SeriesSearchType.Episode && commands.Count > 0)
{
// only one command will be generated for episodes search
continue;

View File

@@ -0,0 +1,282 @@
using System.Text;
using Cleanuparr.Domain.Entities.Arr;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Entities.Sonarr;
using Cleanuparr.Domain.Entities.Whisparr;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using Infrastructure.Interceptors;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace Cleanuparr.Infrastructure.Features.Arr;
public class WhisparrClient : ArrClient, IWhisparrClient
{
public WhisparrClient(
ILogger<WhisparrClient> logger,
IHttpClientFactory httpClientFactory,
IStriker striker,
IDryRunInterceptor dryRunInterceptor
) : base(logger, httpClientFactory, striker, dryRunInterceptor)
{
}
protected override string GetQueueUrlPath()
{
return "/api/v3/queue";
}
protected override string GetQueueUrlQuery(int page)
{
return $"page={page}&pageSize=200&includeUnknownSeriesItems=true&includeSeries=true&includeEpisode=true";
}
protected override string GetQueueDeleteUrlPath(long recordId)
{
return $"/api/v3/queue/{recordId}";
}
protected override string GetQueueDeleteUrlQuery(bool removeFromClient)
{
string query = "blocklist=true&skipRedownload=true&changeCategory=false";
query += removeFromClient ? "&removeFromClient=true" : "&removeFromClient=false";
return query;
}
public override async Task SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
{
if (items?.Count is null or 0)
{
return;
}
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/command";
foreach (WhisparrCommand command in GetSearchCommands(items.Cast<SeriesSearchItem>().ToHashSet()))
{
using HttpRequestMessage request = new(HttpMethod.Post, uriBuilder.Uri);
request.Content = new StringContent(
JsonConvert.SerializeObject(command, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }),
Encoding.UTF8,
"application/json"
);
SetApiKey(request, arrInstance.ApiKey);
string? logContext = await ComputeCommandLogContextAsync(arrInstance, command, command.SearchType);
try
{
HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync<HttpResponseMessage>(SendRequestAsync, request);
response?.Dispose();
_logger.LogInformation("{log}", GetSearchLog(command.SearchType, arrInstance.Url, command, true, logContext));
}
catch
{
_logger.LogError("{log}", GetSearchLog(command.SearchType, arrInstance.Url, command, false, logContext));
throw;
}
}
}
public override bool IsRecordValid(QueueRecord record)
{
if (record.EpisodeId is 0 || record.SeriesId is 0)
{
_logger.LogDebug("skip | episode id and/or series id missing | {title}", record.Title);
return false;
}
return base.IsRecordValid(record);
}
private static string GetSearchLog(
SeriesSearchType searchType,
Uri instanceUrl,
WhisparrCommand command,
bool success,
string? logContext
)
{
string status = success ? "triggered" : "failed";
return searchType switch
{
SeriesSearchType.Episode =>
$"episodes search {status} | {instanceUrl} | {logContext ?? $"episode ids: {string.Join(',', command.EpisodeIds)}"}",
SeriesSearchType.Season =>
$"season search {status} | {instanceUrl} | {logContext ?? $"season: {command.SeasonNumber} series id: {command.SeriesId}"}",
SeriesSearchType.Series => $"series search {status} | {instanceUrl} | {logContext ?? $"series id: {command.SeriesId}"}",
_ => throw new ArgumentOutOfRangeException(nameof(searchType), searchType, null)
};
}
private async Task<string?> ComputeCommandLogContextAsync(ArrInstance arrInstance, WhisparrCommand command, SeriesSearchType searchType)
{
try
{
StringBuilder log = new();
if (searchType is SeriesSearchType.Episode)
{
var episodes = await GetEpisodesAsync(arrInstance, command.EpisodeIds);
if (episodes?.Count is null or 0)
{
return null;
}
var seriesIds = episodes
.Select(x => x.SeriesId)
.Distinct()
.ToList();
List<Series> series = [];
foreach (long id in seriesIds)
{
Series? show = await GetSeriesAsync(arrInstance, id);
if (show is null)
{
return null;
}
series.Add(show);
}
foreach (var group in command.EpisodeIds.GroupBy(id => episodes.First(x => x.Id == id).SeriesId))
{
var show = series.First(x => x.Id == group.Key);
var episode = episodes
.Where(ep => group.Any(x => x == ep.Id))
.OrderBy(x => x.SeasonNumber)
.ThenBy(x => x.EpisodeNumber)
.Select(x => $"S{x.SeasonNumber.ToString().PadLeft(2, '0')}E{x.EpisodeNumber.ToString().PadLeft(2, '0')}")
.ToList();
log.Append($"[{show.Title} {string.Join(',', episode)}]");
}
}
if (searchType is SeriesSearchType.Season)
{
Series? show = await GetSeriesAsync(arrInstance, command.SeriesId.Value);
if (show is null)
{
return null;
}
log.Append($"[{show.Title} season {command.SeasonNumber}]");
}
if (searchType is SeriesSearchType.Series)
{
Series? show = await GetSeriesAsync(arrInstance, command.SeriesId.Value);
if (show is null)
{
return null;
}
log.Append($"[{show.Title}]");
}
return log.ToString();
}
catch (Exception exception)
{
_logger.LogDebug(exception, "failed to compute log context");
}
return null;
}
private async Task<List<Episode>?> GetEpisodesAsync(ArrInstance arrInstance, List<long> episodeIds)
{
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/episode";
uriBuilder.Query = $"episodeIds={string.Join(',', episodeIds)}";
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
HttpResponseMessage response = await SendRequestAsync(request);
string responseContent = await response.Content.ReadAsStringAsync();
return JsonConvert.DeserializeObject<List<Episode>>(responseContent);
}
private async Task<Series?> GetSeriesAsync(ArrInstance arrInstance, long seriesId)
{
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/series/{seriesId}";
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
HttpResponseMessage response = await SendRequestAsync(request);
string responseContent = await response.Content.ReadAsStringAsync();
return JsonConvert.DeserializeObject<Series>(responseContent);
}
private List<WhisparrCommand> GetSearchCommands(HashSet<SeriesSearchItem> items)
{
const string episodeSearch = "EpisodeSearch";
const string seasonSearch = "SeasonSearch";
const string seriesSearch = "SeriesSearch";
List<WhisparrCommand> commands = new();
foreach (SeriesSearchItem item in items)
{
WhisparrCommand command = item.SearchType is SeriesSearchType.Episode
? commands.FirstOrDefault() ?? new() { Name = episodeSearch, EpisodeIds = new() }
: new();
switch (item.SearchType)
{
case SeriesSearchType.Episode when command.EpisodeIds is null:
command.EpisodeIds = [item.Id];
break;
case SeriesSearchType.Episode when command.EpisodeIds is not null:
command.EpisodeIds.Add(item.Id);
break;
case SeriesSearchType.Season:
command.Name = seasonSearch;
command.SeasonNumber = item.Id;
command.SeriesId = ((SeriesSearchItem)item).SeriesId;
break;
case SeriesSearchType.Series:
command.Name = seriesSearch;
command.SeriesId = item.Id;
break;
default:
throw new ArgumentOutOfRangeException(nameof(item.SearchType), item.SearchType, null);
}
if (item.SearchType is SeriesSearchType.Episode && commands.Count > 0)
{
// only one command will be generated for episodes search
continue;
}
command.SearchType = item.SearchType;
commands.Add(command);
}
return commands;
}
}

View File

@@ -95,6 +95,28 @@ public sealed class BlocklistProvider
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++;
}
if (changedCount > 0)
{
_logger.LogInformation("Successfully loaded {count} blocklists", changedCount);

View File

@@ -1,4 +1,6 @@
namespace Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Infrastructure.Features.DownloadClient;
public sealed record BlockFilesResult
{
@@ -13,4 +15,6 @@ public sealed record BlockFilesResult
public bool IsPrivate { get; set; }
public bool Found { get; set; }
public DeleteReason DeleteReason { get; set; } = DeleteReason.None;
}

View File

@@ -21,7 +21,7 @@ public partial class DelugeService
if (download?.Hash is null)
{
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
_logger.LogDebug("failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name);
return result;
}
@@ -52,7 +52,7 @@ public partial class DelugeService
}
catch (Exception exception)
{
_logger.LogDebug(exception, "failed to find torrent {hash} in the download client", hash);
_logger.LogDebug(exception, "failed to find files in the download client | {name}", download.Name);
}
if (contents is null)
@@ -75,8 +75,21 @@ public partial class DelugeService
totalFiles++;
int priority = file.Priority;
if (result.ShouldRemove)
{
return;
}
if (IsDefinitelyMalware(name))
{
_logger.LogInformation("malware file found | {file} | {title}", file.Path, download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.MalwareFileFound;
}
if (file.Priority is 0)
{
_logger.LogTrace("File is already skipped | {file}", file.Path);
totalUnwantedFiles++;
}
@@ -88,9 +101,15 @@ public partial class DelugeService
_logger.LogInformation("unwanted file found | {file}", file.Path);
}
_logger.LogTrace("File is valid | {file}", file.Path);
priorities.Add(file.Index, priority);
});
if (result.ShouldRemove)
{
return result;
}
if (!hasPriorityUpdates)
{
return result;
@@ -105,8 +124,12 @@ public partial class DelugeService
if (totalUnwantedFiles == totalFiles)
{
_logger.LogDebug("All files are blocked for {name}", download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AllFilesBlocked;
}
_logger.LogDebug("Marking {count} unwanted files as skipped for {name}", totalUnwantedFiles, download.Name);
await _dryRunInterceptor.InterceptAsync(ChangeFilesPriority, hash, sortedPriorities);

View File

@@ -1,6 +1,4 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.Deluge.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Extensions;
@@ -25,7 +23,7 @@ public partial class DelugeService
if (download?.Hash is null)
{
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
_logger.LogDebug("failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name);
return result;
}
@@ -44,7 +42,7 @@ public partial class DelugeService
}
catch (Exception exception)
{
_logger.LogDebug(exception, "failed to find torrent {hash} in the download client", hash);
_logger.LogDebug(exception, "failed to find files in the download client | {name}", download.Name);
}
@@ -61,6 +59,7 @@ public partial class DelugeService
if (shouldRemove)
{
// remove if all files are unwanted
_logger.LogTrace("all files are unwanted | removing download | {name}", download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AllFilesSkipped;
return result;
@@ -95,11 +94,13 @@ public partial class DelugeService
if (download.State is null || !download.State.Equals("Downloading", StringComparison.InvariantCultureIgnoreCase))
{
_logger.LogTrace("skip slow check | item is in {state} state | {name}", download.State, 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);
}
@@ -137,6 +138,7 @@ public partial class DelugeService
if (queueCleanerConfig.Stalled.MaxStrikes is 0)
{
_logger.LogTrace("skip stalled check | max strikes is 0 | {name}", status.Name);
return (false, DeleteReason.None);
}
@@ -149,11 +151,13 @@ public partial class DelugeService
if (status.State is null || !status.State.Equals("Downloading", StringComparison.InvariantCultureIgnoreCase))
{
_logger.LogTrace("skip stalled check | download is in {state} state | {name}", status.State, status.Name);
return (false, DeleteReason.None);
}
if (status.Eta > 0)
{
_logger.LogTrace("skip stalled check | download is not stalled | {name}", status.Name);
return (false, DeleteReason.None);
}

View File

@@ -100,6 +100,16 @@ 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

@@ -20,7 +20,7 @@ public partial class QBitService
if (download is null)
{
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
_logger.LogDebug("failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name);
return result;
}
@@ -39,7 +39,7 @@ public partial class QBitService
if (torrentProperties is null)
{
_logger.LogDebug("failed to find torrent properties {hash} in the download client", hash);
_logger.LogDebug("failed to find torrent properties {name}", download.Name);
return result;
}
@@ -62,6 +62,7 @@ public partial class QBitService
if (files is null)
{
_logger.LogDebug("torrent {hash} has no files", hash);
return result;
}
@@ -78,19 +79,30 @@ public partial class QBitService
{
if (!file.Index.HasValue)
{
_logger.LogTrace("Skipping file with no index | {file}", file.Name);
continue;
}
totalFiles++;
if (IsDefinitelyMalware(file.Name))
{
_logger.LogInformation("malware file found | {file} | {title}", file.Name, download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.MalwareFileFound;
return result;
}
if (file.Priority is TorrentContentPriority.Skip)
{
_logger.LogTrace("File is already skipped | {file}", file.Name);
totalUnwantedFiles++;
continue;
}
if (_filenameEvaluator.IsValid(file.Name, blocklistType, patterns, regexes))
{
_logger.LogTrace("File is valid | {file}", file.Name);
continue;
}
@@ -101,13 +113,18 @@ public partial class QBitService
if (unwantedFiles.Count is 0)
{
_logger.LogDebug("No unwanted files found for {name}", download.Name);
return result;
}
if (totalUnwantedFiles == totalFiles)
{
_logger.LogDebug("All files are blocked for {name}", download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AllFilesBlocked;
}
_logger.LogDebug("Marking {count} unwanted files as skipped for {name}", totalUnwantedFiles, download.Name);
foreach (int fileIndex in unwantedFiles)
{

View File

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

View File

@@ -19,7 +19,7 @@ public partial class QBitService
if (download is null)
{
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
_logger.LogDebug("failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name);
return result;
}
@@ -38,7 +38,7 @@ public partial class QBitService
if (torrentProperties is null)
{
_logger.LogDebug("failed to find torrent properties {hash} in the download client", hash);
_logger.LogDebug("failed to find torrent properties {hash}", download.Name);
return result;
}
@@ -55,11 +55,13 @@ public partial class QBitService
// if all files were blocked by qBittorrent
if (download is { CompletionOn: not null, Downloaded: null or 0 })
{
_logger.LogDebug("all files are unwanted by qBit | removing download | {name}", download.Name);
result.DeleteReason = DeleteReason.AllFilesSkippedByQBit;
return result;
}
// remove if all files are unwanted
_logger.LogDebug("all files are unwanted | removing download | {name}", download.Name);
result.DeleteReason = DeleteReason.AllFilesSkipped;
return result;
}
@@ -87,16 +89,19 @@ public partial class QBitService
if (queueCleanerConfig.Slow.MaxStrikes is 0)
{
_logger.LogDebug("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);
return (false, DeleteReason.None);
}
if (download.DownloadSpeed <= 0)
{
_logger.LogDebug("skip slow check | download speed is 0 | {name}", download.Name);
return (false, DeleteReason.None);
}
@@ -134,6 +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);
return (false, DeleteReason.None);
}
@@ -141,6 +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);
return (false, DeleteReason.None);
}
@@ -168,6 +175,7 @@ public partial class QBitService
StrikeType.DownloadingMetadata), DeleteReason.DownloadingMetadata);
}
_logger.LogDebug("skip stalled check | download is not stalled | {name}", torrent.Name);
return (false, DeleteReason.None);
}
}

View File

@@ -19,7 +19,7 @@ public partial class TransmissionService
if (download?.FileStats is null || download.FileStats.Length == 0)
{
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
_logger.LogDebug("failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name);
return result;
}
@@ -62,19 +62,30 @@ public partial class TransmissionService
{
if (download.FileStats?[i].Wanted == null)
{
_logger.LogTrace("Skipping file with no stats | {file}", download.Files[i].Name);
continue;
}
totalFiles++;
if (IsDefinitelyMalware(download.Files[i].Name))
{
_logger.LogInformation("malware file found | {file} | {title}", download.Files[i].Name, download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.MalwareFileFound;
return result;
}
if (!download.FileStats[i].Wanted.Value)
{
_logger.LogTrace("File is already skipped | {file}", download.Files[i].Name);
totalUnwantedFiles++;
continue;
}
if (_filenameEvaluator.IsValid(download.Files[i].Name, blocklistType, patterns, regexes))
{
_logger.LogTrace("File is valid | {file}", download.Files[i].Name);
continue;
}
@@ -85,15 +96,18 @@ public partial class TransmissionService
if (unwantedFiles.Count is 0)
{
_logger.LogDebug("No unwanted files found for {name}", download.Name);
return result;
}
if (totalUnwantedFiles == totalFiles)
{
_logger.LogDebug("All files are blocked for {name}", download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AllFilesBlocked;
}
_logger.LogDebug("marking {count} unwanted files as skipped for {name}", totalUnwantedFiles, download.Name);
_logger.LogDebug("Marking {count} unwanted files as skipped for {name}", totalUnwantedFiles, download.Name);
await _dryRunInterceptor.InterceptAsync(SetUnwantedFiles, download.Id, unwantedFiles.ToArray());

View File

@@ -1,6 +1,4 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Extensions;
using Cleanuparr.Infrastructure.Features.Context;
@@ -22,7 +20,7 @@ public partial class TransmissionService
if (download is null)
{
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
_logger.LogDebug("failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name);
return result;
}
@@ -56,6 +54,7 @@ public partial class TransmissionService
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;
@@ -100,11 +99,13 @@ public partial class TransmissionService
if (download.Status is not 4)
{
// not in downloading state
_logger.LogTrace("skip slow check | download is in {state} state | {name}", download.Status, download.Name);
return (false, DeleteReason.None);
}
if (download.RateDownload <= 0)
{
_logger.LogTrace("skip slow check | download speed is 0 | {name}", download.Name);
return (false, DeleteReason.None);
}
@@ -142,17 +143,20 @@ public partial class TransmissionService
if (queueCleanerConfig.Stalled.MaxStrikes is 0)
{
_logger.LogTrace("skip stalled check | max strikes is 0 | {name}", download.Name);
return (false, DeleteReason.None);
}
if (download.Status is not 4)
{
// not in downloading state
_logger.LogTrace("skip stalled check | download is in {state} state | {name}", download.Status, download.Name);
return (false, DeleteReason.None);
}
if (download.RateDownload > 0 || download.Eta > 0)
{
_logger.LogTrace("skip stalled check | download is not stalled | {name}", download.Name);
return (false, DeleteReason.None);
}

View File

@@ -0,0 +1,36 @@
using Cleanuparr.Infrastructure.Features.DownloadHunter.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadHunter.Models;
using Data.Models.Arr;
using MassTransit;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadHunter.Consumers;
public class DownloadHunterConsumer<T> : IConsumer<DownloadHuntRequest<T>>
where T : SearchItem
{
private readonly ILogger<DownloadHunterConsumer<T>> _logger;
private readonly IDownloadHunter _downloadHunter;
public DownloadHunterConsumer(ILogger<DownloadHunterConsumer<T>> logger, IDownloadHunter downloadHunter)
{
_logger = logger;
_downloadHunter = downloadHunter;
}
public async Task Consume(ConsumeContext<DownloadHuntRequest<T>> context)
{
try
{
await _downloadHunter.HuntDownloadsAsync(context.Message);
}
catch (Exception exception)
{
_logger.LogError(exception,
"failed to search for replacement | {title} | {url}",
context.Message.Record.Title,
context.Message.Instance.Url
);
}
}
}

View File

@@ -0,0 +1,42 @@
using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.DownloadHunter.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadHunter.Models;
using Cleanuparr.Persistence;
using Data.Models.Arr;
using Microsoft.EntityFrameworkCore;
namespace Cleanuparr.Infrastructure.Features.DownloadHunter;
public sealed class DownloadHunter : IDownloadHunter
{
private readonly DataContext _dataContext;
private readonly ArrClientFactory _arrClientFactory;
public DownloadHunter(
DataContext dataContext,
ArrClientFactory arrClientFactory
)
{
_dataContext = dataContext;
_arrClientFactory = arrClientFactory;
}
public async Task HuntDownloadsAsync<T>(DownloadHuntRequest<T> request)
where T : SearchItem
{
var generalConfig = await _dataContext.GeneralConfigs
.AsNoTracking()
.FirstAsync();
if (!generalConfig.SearchEnabled)
{
return;
}
var arrClient = _arrClientFactory.GetClient(request.InstanceType);
await arrClient.SearchItemsAsync(request.Instance, [request.SearchItem]);
// prevent tracker spamming
await Task.Delay(TimeSpan.FromSeconds(generalConfig.SearchDelay));
}
}

View File

@@ -0,0 +1,9 @@
using Cleanuparr.Infrastructure.Features.DownloadHunter.Models;
using Data.Models.Arr;
namespace Cleanuparr.Infrastructure.Features.DownloadHunter.Interfaces;
public interface IDownloadHunter
{
Task HuntDownloadsAsync<T>(DownloadHuntRequest<T> request) where T : SearchItem;
}

View File

@@ -0,0 +1,18 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
namespace Cleanuparr.Infrastructure.Features.DownloadHunter.Models;
public sealed record DownloadHuntRequest<T>
where T : SearchItem
{
public required InstanceType InstanceType { get; init; }
public required ArrInstance Instance { get; init; }
public required T SearchItem { get; init; }
public required QueueRecord Record { get; init; }
}

View File

@@ -30,7 +30,7 @@ public class DownloadRemoverConsumer<T> : IConsumer<QueueItemRemoveRequest<T>>
catch (Exception exception)
{
_logger.LogError(exception,
"failed to remove queue item| {title} | {url}",
"failed to remove queue item | {title} | {url}",
context.Message.Record.Title,
context.Message.Instance.Url
);

View File

@@ -1,7 +1,7 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using Data.Models.Arr.Queue;
namespace Cleanuparr.Infrastructure.Features.DownloadRemover.Models;

View File

@@ -1,34 +1,35 @@
using Cleanuparr.Domain.Enums;
using System.Net;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadHunter.Models;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Models;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using Data.Models.Arr.Queue;
using Microsoft.EntityFrameworkCore;
using MassTransit;
using Microsoft.Extensions.Caching.Memory;
namespace Cleanuparr.Infrastructure.Features.DownloadRemover;
public sealed class QueueItemRemover : IQueueItemRemover
{
private readonly DataContext _dataContext;
private readonly IBus _messageBus;
private readonly IMemoryCache _cache;
private readonly ArrClientFactory _arrClientFactory;
private readonly EventPublisher _eventPublisher;
public QueueItemRemover(
DataContext dataContext,
IBus messageBus,
IMemoryCache cache,
ArrClientFactory arrClientFactory,
EventPublisher eventPublisher
)
{
_dataContext = dataContext;
_messageBus = messageBus;
_cache = cache;
_arrClientFactory = arrClientFactory;
_eventPublisher = eventPublisher;
@@ -39,31 +40,35 @@ public sealed class QueueItemRemover : IQueueItemRemover
{
try
{
var generalConfig = await _dataContext.GeneralConfigs
.AsNoTracking()
.FirstAsync();
var arrClient = _arrClientFactory.GetClient(request.InstanceType);
await arrClient.DeleteQueueItemAsync(request.Instance, request.Record, request.RemoveFromClient, request.DeleteReason);
// Set context for EventPublisher
ContextProvider.Set("downloadName", request.Record.Title);
ContextProvider.Set("hash", request.Record.DownloadId);
ContextProvider.Set(nameof(QueueRecord), request.Record);
ContextProvider.Set(nameof(ArrInstance) + nameof(ArrInstance.Url), request.Instance.Url);
ContextProvider.Set(nameof(InstanceType), request.InstanceType);
// Use the new centralized EventPublisher method
await _eventPublisher.PublishQueueItemDeleted(request.RemoveFromClient, request.DeleteReason);
if (!generalConfig.SearchEnabled)
await _messageBus.Publish(new DownloadHuntRequest<T>
{
return;
InstanceType = request.InstanceType,
Instance = request.Instance,
SearchItem = request.SearchItem,
Record = request.Record
});
}
catch (HttpRequestException exception)
{
if (exception.StatusCode is not HttpStatusCode.NotFound)
{
throw;
}
await arrClient.SearchItemsAsync(request.Instance, [request.SearchItem]);
// prevent tracker spamming
await Task.Delay(TimeSpan.FromSeconds(generalConfig.SearchDelay));
throw new Exception($"Item might have already been deleted by your {request.InstanceType} instance", exception);
}
finally
{

View File

@@ -27,6 +27,7 @@ public sealed class Striker : IStriker
{
if (maxStrikes is 0)
{
_logger.LogTrace("skip striking for {reason} | max strikes is 0 | {name}", strikeType, itemName);
return false;
}

View File

@@ -1,3 +1,5 @@
using Cleanuparr.Domain.Entities.Arr;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.Arr;
@@ -12,7 +14,6 @@ using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Persistence.Models.Configuration.General;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Data.Models.Arr;
using Data.Models.Arr.Queue;
using MassTransit;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
@@ -52,68 +53,6 @@ public abstract class GenericHandler : IHandler
_dataContext = dataContext;
}
// /// <summary>
// /// Initialize download services based on configuration
// /// </summary>
// protected async Task<List<IDownloadService>> GetDownloadServices()
// {
// var clients = await _dataContext.DownloadClients
// .AsNoTracking()
// .ToListAsync();
//
// if (clients.Count is 0)
// {
// _logger.LogWarning("No download clients configured");
// return [];
// }
//
// var enabledClients = await _dataContext.DownloadClients
// .Where(c => c.Enabled)
// .ToListAsync();
//
// if (enabledClients.Count == 0)
// {
// _logger.LogWarning("No enabled download clients available");
// return [];
// }
//
// List<IDownloadService> downloadServices = [];
//
// // Add all enabled clients
// foreach (var client in enabledClients)
// {
// try
// {
// var service = _downloadServiceFactory.GetDownloadService(client);
// if (service != null)
// {
// await service.LoginAsync();
// downloadServices.Add(service);
// _logger.LogDebug("Initialized download client: {name}", client.Name);
// }
// else
// {
// _logger.LogWarning("Download client service not available for: {name}", client.Name);
// }
// }
// catch (Exception ex)
// {
// _logger.LogError(ex, "Failed to initialize download client: {name}", client.Name);
// }
// }
//
// if (downloadServices.Count == 0)
// {
// _logger.LogWarning("No valid download clients found");
// }
// else
// {
// _logger.LogDebug("Initialized {count} download clients", downloadServices.Count);
// }
//
// return downloadServices;
// }
public async Task ExecuteAsync()
{
await DataContext.Lock.WaitAsync();
@@ -130,9 +69,15 @@ public abstract class GenericHandler : IHandler
ContextProvider.Set(nameof(InstanceType.Lidarr), await _dataContext.ArrConfigs.AsNoTracking()
.Include(x => x.Instances)
.FirstAsync(x => x.Type == InstanceType.Lidarr));
ContextProvider.Set(nameof(InstanceType.Readarr), await _dataContext.ArrConfigs.AsNoTracking()
.Include(x => x.Instances)
.FirstAsync(x => x.Type == InstanceType.Readarr));
ContextProvider.Set(nameof(InstanceType.Whisparr), await _dataContext.ArrConfigs.AsNoTracking()
.Include(x => x.Instances)
.FirstAsync(x => x.Type == InstanceType.Whisparr));
ContextProvider.Set(nameof(QueueCleanerConfig), await _dataContext.QueueCleanerConfigs.AsNoTracking().FirstAsync());
ContextProvider.Set(nameof(ContentBlockerConfig), await _dataContext.ContentBlockerConfigs.AsNoTracking().FirstAsync());
ContextProvider.Set(nameof(DownloadCleanerConfig), await _dataContext.DownloadCleanerConfigs.AsNoTracking().FirstAsync());
ContextProvider.Set(nameof(DownloadCleanerConfig), await _dataContext.DownloadCleanerConfigs.Include(x => x.Categories).AsNoTracking().FirstAsync());
ContextProvider.Set(nameof(DownloadClientConfig), await _dataContext.DownloadClients.AsNoTracking()
.Where(x => x.Enabled)
.ToListAsync());
@@ -195,14 +140,14 @@ public abstract class GenericHandler : IHandler
return;
}
if (instanceType is InstanceType.Sonarr)
if (instanceType is InstanceType.Sonarr or InstanceType.Whisparr)
{
QueueItemRemoveRequest<SonarrSearchItem> removeRequest = new()
QueueItemRemoveRequest<SeriesSearchItem> removeRequest = new()
{
InstanceType = instanceType,
Instance = instance,
Record = record,
SearchItem = (SonarrSearchItem)GetRecordSearchItem(instanceType, record, isPack),
SearchItem = (SeriesSearchItem)GetRecordSearchItem(instanceType, record, isPack),
RemoveFromClient = removeFromClient,
DeleteReason = deleteReason
};
@@ -232,17 +177,17 @@ public abstract class GenericHandler : IHandler
{
return type switch
{
InstanceType.Sonarr when !isPack => new SonarrSearchItem
InstanceType.Sonarr when !isPack => new SeriesSearchItem
{
Id = record.EpisodeId,
SeriesId = record.SeriesId,
SearchType = SonarrSearchType.Episode
SearchType = SeriesSearchType.Episode
},
InstanceType.Sonarr when isPack => new SonarrSearchItem
InstanceType.Sonarr when isPack => new SeriesSearchItem
{
Id = record.SeasonNumber,
SeriesId = record.SeriesId,
SearchType = SonarrSearchType.Season
SearchType = SeriesSearchType.Season
},
InstanceType.Radarr => new SearchItem
{
@@ -252,6 +197,22 @@ public abstract class GenericHandler : IHandler
{
Id = record.AlbumId
},
InstanceType.Readarr => new SearchItem
{
Id = record.BookId
},
InstanceType.Whisparr when !isPack => new SeriesSearchItem
{
Id = record.EpisodeId,
SeriesId = record.SeriesId,
SearchType = SeriesSearchType.Episode
},
InstanceType.Whisparr when isPack => new SeriesSearchItem
{
Id = record.SeasonNumber,
SeriesId = record.SeriesId,
SearchType = SeriesSearchType.Season
},
_ => throw new NotImplementedException($"instance type {type} is not yet supported")
};
}

View File

@@ -0,0 +1,12 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Apprise;
public class AppriseException : Exception
{
public AppriseException(string message) : base(message)
{
}
public AppriseException(string message, Exception innerException) : base(message, innerException)
{
}
}

View File

@@ -13,6 +13,8 @@ public sealed record ApprisePayload
public string Type { get; init; } = NotificationType.Info.ToString().ToLowerInvariant();
public string Format { get; init; } = FormatType.Text.ToString().ToLowerInvariant();
public string? Tags { get; init; }
}
public enum NotificationType

View File

@@ -3,13 +3,11 @@ using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Infrastructure.Verticals.Notifications;
using Infrastructure.Verticals.Notifications.Models;
namespace Cleanuparr.Infrastructure.Features.Notifications.Apprise;
public sealed class AppriseProvider : NotificationProvider<AppriseConfig>
{
private readonly DataContext _dataContext;
private readonly IAppriseProxy _proxy;
public override string Name => "Apprise";
@@ -17,7 +15,6 @@ public sealed class AppriseProvider : NotificationProvider<AppriseConfig>
public AppriseProvider(DataContext dataContext, IAppriseProxy proxy)
: base(dataContext.AppriseConfigs)
{
_dataContext = dataContext;
_proxy = proxy;
}
@@ -51,7 +48,7 @@ public sealed class AppriseProvider : NotificationProvider<AppriseConfig>
await _proxy.SendNotification(BuildPayload(notification, NotificationType.Warning), Config);
}
private static ApprisePayload BuildPayload(ArrNotification notification, NotificationType notificationType)
private ApprisePayload BuildPayload(ArrNotification notification, NotificationType notificationType)
{
StringBuilder body = new();
body.AppendLine(notification.Description);
@@ -70,12 +67,13 @@ public sealed class AppriseProvider : NotificationProvider<AppriseConfig>
Title = notification.Title,
Body = body.ToString(),
Type = notificationType.ToString().ToLowerInvariant(),
Tags = Config.Tags,
};
return payload;
}
private static ApprisePayload BuildPayload(Notification notification, NotificationType notificationType)
private ApprisePayload BuildPayload(Notification notification, NotificationType notificationType)
{
StringBuilder body = new();
body.AppendLine(notification.Description);
@@ -91,6 +89,7 @@ public sealed class AppriseProvider : NotificationProvider<AppriseConfig>
Title = notification.Title,
Body = body.ToString(),
Type = notificationType.ToString().ToLowerInvariant(),
Tags = Config.Tags,
};
return payload;

View File

@@ -1,4 +1,5 @@
using System.Text;
using System.Net.Http.Headers;
using System.Text;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Cleanuparr.Shared.Helpers;
using Newtonsoft.Json;
@@ -17,19 +18,50 @@ public sealed class AppriseProxy : IAppriseProxy
public async Task SendNotification(ApprisePayload payload, AppriseConfig config)
{
string content = JsonConvert.SerializeObject(payload, new JsonSerializerSettings
try
{
ContractResolver = new CamelCasePropertyNamesContractResolver()
});
UriBuilder uriBuilder = new(config.Url);
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");
using HttpResponseMessage response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
string content = JsonConvert.SerializeObject(payload, new JsonSerializerSettings
{
ContractResolver = new CamelCasePropertyNamesContractResolver(),
NullValueHandling = NullValueHandling.Ignore
});
UriBuilder uriBuilder = new(config.Url.ToString());
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))
{
var byteArray = Encoding.ASCII.GetBytes(config.Url.UserInfo);
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(byteArray));
}
using HttpResponseMessage response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
}
catch (HttpRequestException exception)
{
if (exception.StatusCode is null)
{
throw new AppriseException("Unable to send notification", exception);
}
switch ((int)exception.StatusCode)
{
case 401:
throw new AppriseException("Unable to send notification | API key is invalid");
case 424:
throw new AppriseException("Your tags are not configured correctly", exception);
case 502:
case 503:
case 504:
throw new AppriseException("Unable to send notification | service unavailable", exception);
default:
throw new AppriseException("Unable to send notification", exception);
}
}
}
}

View File

@@ -1,5 +1,5 @@
using Infrastructure.Verticals.Notifications;
using Infrastructure.Verticals.Notifications.Models;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Infrastructure.Verticals.Notifications;
using MassTransit;
using Microsoft.Extensions.Logging;

View File

@@ -1,5 +1,5 @@
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Infrastructure.Verticals.Notifications.Models;
namespace Cleanuparr.Infrastructure.Features.Notifications;

View File

@@ -1,5 +1,4 @@
using Cleanuparr.Domain.Enums;
using Infrastructure.Verticals.Notifications.Models;
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;

View File

@@ -1,4 +1,4 @@
namespace Infrastructure.Verticals.Notifications.Models;
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
public sealed record CategoryChangedNotification : Notification
{

View File

@@ -1,4 +1,4 @@
namespace Infrastructure.Verticals.Notifications.Models;
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
public sealed record DownloadCleanedNotification : Notification
{

View File

@@ -1,6 +1,4 @@
using Cleanuparr.Infrastructure.Features.Notifications.Models;
namespace Infrastructure.Verticals.Notifications.Models;
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
public sealed record FailedImportStrikeNotification : ArrNotification
{

View File

@@ -1,4 +1,4 @@
namespace Infrastructure.Verticals.Notifications.Models;
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
public abstract record Notification
{

View File

@@ -1,4 +1,4 @@
namespace Infrastructure.Verticals.Notifications.Models;
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
public sealed record NotificationField
{

View File

@@ -1,4 +1,4 @@
namespace Infrastructure.Verticals.Notifications.Models;
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
public enum NotificationLevel
{

View File

@@ -1,6 +1,4 @@
using Cleanuparr.Infrastructure.Features.Notifications.Models;
namespace Infrastructure.Verticals.Notifications.Models;
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
public sealed record QueueItemDeletedNotification : ArrNotification
{

View File

@@ -1,6 +1,4 @@
using Cleanuparr.Infrastructure.Features.Notifications.Models;
namespace Infrastructure.Verticals.Notifications.Models;
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
public sealed record SlowStrikeNotification : ArrNotification
{

View File

@@ -1,6 +1,4 @@
using Cleanuparr.Infrastructure.Features.Notifications.Models;
namespace Infrastructure.Verticals.Notifications.Models;
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
public sealed record StalledStrikeNotification : ArrNotification
{

View File

@@ -2,7 +2,6 @@ using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Infrastructure.Verticals.Notifications;
using Infrastructure.Verticals.Notifications.Models;
using Mapster;
namespace Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;

View File

@@ -1,5 +1,5 @@
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Infrastructure.Verticals.Notifications.Models;
using Microsoft.EntityFrameworkCore;
namespace Cleanuparr.Infrastructure.Features.Notifications;
@@ -10,7 +10,7 @@ public abstract class NotificationProvider<T> : INotificationProvider<T>
protected readonly DbSet<T> _notificationConfig;
protected T? _config;
public T Config => _config ??= _notificationConfig.First();
public T Config => _config ??= _notificationConfig.AsNoTracking().First();
NotificationConfig INotificationProvider.Config => Config;

View File

@@ -1,11 +1,10 @@
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 Data.Models.Arr.Queue;
using Infrastructure.Interceptors;
using Infrastructure.Verticals.Notifications.Models;
using Mapster;
using MassTransit;
using Microsoft.Extensions.Logging;
@@ -166,6 +165,8 @@ public class NotificationPublisher : INotificationPublisher
InstanceType.Sonarr => record.Series?.Images?.FirstOrDefault(x => x.CoverType == "poster")?.RemoteUrl,
InstanceType.Radarr => record.Movie?.Images?.FirstOrDefault(x => x.CoverType == "poster")?.RemoteUrl,
InstanceType.Lidarr => record.Album?.Images?.FirstOrDefault(x => x.CoverType == "cover")?.Url,
InstanceType.Readarr => record.Book?.Images?.FirstOrDefault(x => x.CoverType == "cover")?.Url,
InstanceType.Whisparr => record.Series?.Images?.FirstOrDefault(x => x.CoverType == "poster")?.RemoteUrl,
_ => throw new ArgumentOutOfRangeException(nameof(instanceType))
};

View File

@@ -1,5 +1,5 @@
using Cleanuparr.Infrastructure.Features.Notifications;
using Infrastructure.Verticals.Notifications.Models;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Microsoft.Extensions.Logging;
namespace Infrastructure.Verticals.Notifications;

View File

@@ -0,0 +1,16 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
namespace Cleanuparr.Infrastructure.Health;
/// <summary>
/// Basic application health check that verifies the application is running
/// </summary>
public class ApplicationHealthCheck : IHealthCheck
{
public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
// Basic liveness check - if we can execute this, the app is running
return Task.FromResult(HealthCheckResult.Healthy("Application is running"));
}
}

View File

@@ -0,0 +1,50 @@
using Cleanuparr.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Health;
/// <summary>
/// Health check that verifies database connectivity
/// </summary>
public class DatabaseHealthCheck : IHealthCheck
{
private readonly DataContext _dataContext;
private readonly ILogger<DatabaseHealthCheck> _logger;
public DatabaseHealthCheck(DataContext dataContext, ILogger<DatabaseHealthCheck> logger)
{
_dataContext = dataContext;
_logger = logger;
}
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
try
{
// Try to execute a simple query to verify database connectivity
var canConnect = await _dataContext.Database.CanConnectAsync(cancellationToken);
if (!canConnect)
{
return HealthCheckResult.Unhealthy("Cannot connect to database");
}
// Optionally check if database schema is up to date
var pendingMigrations = await _dataContext.Database.GetPendingMigrationsAsync(cancellationToken);
if (pendingMigrations.Any())
{
_logger.LogWarning("Database has pending migrations: {migrations}", string.Join(", ", pendingMigrations));
return HealthCheckResult.Degraded($"Database has {pendingMigrations.Count()} pending migrations");
}
return HealthCheckResult.Healthy("Database connection successful");
}
catch (Exception ex)
{
_logger.LogError(ex, "Database health check failed");
return HealthCheckResult.Unhealthy("Database health check failed", ex);
}
}
}

View File

@@ -0,0 +1,60 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Health;
/// <summary>
/// Health check that verifies download clients are healthy
/// </summary>
public class DownloadClientsHealthCheck : IHealthCheck
{
private readonly IHealthCheckService _healthCheckService;
private readonly ILogger<DownloadClientsHealthCheck> _logger;
public DownloadClientsHealthCheck(IHealthCheckService healthCheckService, ILogger<DownloadClientsHealthCheck> logger)
{
_healthCheckService = healthCheckService;
_logger = logger;
}
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
try
{
// Get current health status of all clients without triggering new checks
var allClientHealth = _healthCheckService.GetAllClientHealth();
if (!allClientHealth.Any())
{
// No clients configured - this might be ok depending on the deployment
return HealthCheckResult.Healthy("No download clients configured");
}
var healthyClients = allClientHealth.Values.Where(h => h.IsHealthy).ToList();
var unhealthyClients = allClientHealth.Values.Where(h => !h.IsHealthy).ToList();
var totalClients = allClientHealth.Count;
if (unhealthyClients.Any())
{
var unhealthyNames = string.Join(", ", unhealthyClients.Select(c => c.ClientName));
var message = $"{unhealthyClients.Count}/{totalClients} download clients unhealthy: {unhealthyNames}";
// If more than half are unhealthy, consider it unhealthy
if (unhealthyClients.Count > totalClients / 2)
{
return HealthCheckResult.Unhealthy(message);
}
// Otherwise, it's degraded
return HealthCheckResult.Degraded(message);
}
return HealthCheckResult.Healthy($"All {totalClients} download clients are healthy");
}
catch (Exception ex)
{
_logger.LogError(ex, "Download clients health check failed");
return HealthCheckResult.Unhealthy("Download clients health check failed", ex);
}
}
}

View File

@@ -0,0 +1,76 @@
using Cleanuparr.Shared.Helpers;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Health;
/// <summary>
/// Health check that verifies file system access to critical directories
/// </summary>
public class FileSystemHealthCheck : IHealthCheck
{
private readonly ILogger<FileSystemHealthCheck> _logger;
public FileSystemHealthCheck(ILogger<FileSystemHealthCheck> logger)
{
_logger = logger;
}
public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
try
{
var issues = new List<string>();
// Check config directory access
var configPath = ConfigurationPathProvider.GetConfigPath();
if (!CheckDirectoryAccess(configPath, "config"))
{
issues.Add($"Cannot access config directory: {configPath}");
}
// Check current working directory
var currentDir = Directory.GetCurrentDirectory();
if (!CheckDirectoryAccess(currentDir, "working"))
{
issues.Add($"Cannot access working directory: {currentDir}");
}
if (issues.Any())
{
var message = $"File system issues detected: {string.Join(", ", issues)}";
return Task.FromResult(HealthCheckResult.Unhealthy(message));
}
return Task.FromResult(HealthCheckResult.Healthy("File system access verified"));
}
catch (Exception ex)
{
_logger.LogError(ex, "File system health check failed");
return Task.FromResult(HealthCheckResult.Unhealthy("File system health check failed", ex));
}
}
private bool CheckDirectoryAccess(string path, string description)
{
try
{
if (!Directory.Exists(path))
{
_logger.LogWarning("Directory does not exist: {path} ({description})", path, description);
return false;
}
// Try to enumerate directory contents
_ = Directory.GetFiles(path).Take(1).ToList();
return true;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Cannot access {description} directory: {path}", description, path);
return false;
}
}
}

View File

@@ -1,6 +1,5 @@
using System.Net;
using Cleanuparr.Infrastructure.Services;
using Infrastructure.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Options;

View File

@@ -1,52 +0,0 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Helpers;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Serilog.Context;
namespace Cleanuparr.Infrastructure.Logging;
// TODO remove
public class LoggingInitializer : BackgroundService
{
private readonly ILogger<LoggingInitializer> _logger;
private readonly EventPublisher _eventPublisher;
private readonly Random random = new();
public LoggingInitializer(ILogger<LoggingInitializer> logger, EventPublisher eventPublisher)
{
_logger = logger;
_eventPublisher = eventPublisher;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
return;
while (true)
{
using var _ = LogContext.PushProperty(LogProperties.Category,
random.Next(0, 100) > 50 ? InstanceType.Sonarr.ToString() : InstanceType.Radarr.ToString());
try
{
await _eventPublisher.PublishAsync(
random.Next(0, 100) > 50 ? EventType.DownloadCleaned : EventType.StalledStrike,
"This is a very long message to test how it all looks in the frontend. This is just gibberish, but helps us figure out how the layout should be to display messages properly.",
EventSeverity.Important,
data: new { Hash = "hash", Name = "name", StrikeCount = "1", Type = "stalled" });
throw new Exception("test exception");
}
catch (Exception exception)
{
_logger.LogCritical("test critical");
_logger.LogTrace("test trace");
_logger.LogDebug("test debug");
_logger.LogWarning("test warn");
_logger.LogError(exception, "This is a very long message to test how it all looks in the frontend. This is just gibberish, but helps us figure out how the layout should be to display messages properly.");
}
await Task.Delay(10000, stoppingToken);
}
}
}

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