Compare commits

..

1 Commits

Author SHA1 Message Date
Flaminel
b834078c11 updated qBit package 2025-08-14 23:32:08 +03:00
439 changed files with 12473 additions and 38875 deletions

View File

@@ -7,13 +7,6 @@ body:
attributes:
value: |
Thanks for taking the time to improve Cleanuparr!
- type: checkboxes
id: duplicate-check
attributes:
label: "Duplicate check"
options:
- label: I have searched for existing issues and confirmed this is not a duplicate.
required: true
- type: checkboxes
id: init
attributes:

View File

@@ -7,25 +7,6 @@ body:
attributes:
value: |
Thanks for taking the time to improve Cleanuparr!
- type: checkboxes
id: duplicate-check
attributes:
label: "Duplicate check"
options:
- label: I have searched for existing issues and confirmed this is not a duplicate.
required: true
- type: checkboxes
id: init
attributes:
label: Implementation & testing support
description: The requester should help answer questions, provide support for the implementation and test changes.
options:
- label: I understand I must be available to assist with implementation questions and to test the feature before being released.
required: true
- label: I understand that joining the Discord server may be necessary for better coordination and faster communication.
required: true
- label: I understand that failure to assist in the development process of my request will result in the request being closed.
required: true
- type: textarea
id: description
attributes:

View File

@@ -7,13 +7,6 @@ body:
attributes:
value: |
If you are experiencing unexpected behavior, please consider submitting a bug report instead.
- type: checkboxes
id: duplicate-check
attributes:
label: "Duplicate check"
options:
- label: I have searched for existing issues and confirmed this is not a duplicate.
required: true
- type: checkboxes
id: init
attributes:

View File

@@ -1,8 +1,2 @@
blank_issues_enabled: false
contact_links:
- name: Discord Community
url: https://discord.gg/SCtMCgtsc4
about: Join our Discord for real-time help and discussions
- name: Documentation
url: https://cleanuparr.github.io/Cleanuparr/
about: Check the documentation for configurations and usage guidelines
contact_links: []

View File

@@ -1,24 +0,0 @@
## Description
<!-- Brief description of what this PR does -->
## Related Issue
Closes #ISSUE_NUMBER
## Type of Change
- [ ] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Documentation update
## Testing
<!-- Describe how you tested your changes -->
## Screenshots (if applicable)
<!-- Add screenshots here -->
## Checklist
- [ ] I have read the [Contributing Guide](../CONTRIBUTING.md)
- [ ] I have announced my intent to work on this and received approval
- [ ] My code follows the project's code standards
- [ ] I have tested my changes thoroughly
- [ ] I have updated relevant documentation

View File

@@ -1,30 +0,0 @@
name: Deploy to Cloudflare Pages
on:
push:
tags:
- "v*.*.*"
jobs:
deploy:
runs-on: ubuntu-latest
name: Deploy to Cloudflare Pages
steps:
- name: Create status files
run: |
mkdir -p status
echo "{ \"version\": \"${GITHUB_REF_NAME}\" }" > status/status.json
# Cache static files for 10 minutes
cat > status/_headers << 'EOF'
/*
Cache-Control: public, max-age=600, s-maxage=600
EOF
- name: Deploy to Cloudflare Pages
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }}
workingDirectory: "status"
command: pages deploy . --project-name=cleanuparr-status

View File

@@ -2,9 +2,9 @@ name: Deploy Docusaurus to GitHub Pages
on:
push:
tags:
- "v*.*.*"
workflow_dispatch: {}
branches: [main]
paths:
- 'docs/**'
permissions:
contents: read
@@ -22,7 +22,6 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: main
- name: Set up Node.js
uses: actions/setup-node@v4

View File

@@ -1,325 +0,0 @@
# Contributing to Cleanuparr
Thanks for your interest in contributing to Cleanuparr! This guide will help you get started with development.
## Before You Start
### Announce Your Intent
Before starting any work, please let us know what you want to contribute:
- For existing issues: Comment on the issue stating you'd like to work on it
- For new features/changes: Create a new issue first and mention that you want to work on it
This helps us avoid redundant work, git conflicts, and contributions that may not align with the project's direction.
**Wait for approval from the maintainers before proceeding with your contribution.**
## Development Setup
### Prerequisites
- [.NET 9.0 SDK](https://dotnet.microsoft.com/download/dotnet/9.0)
- [Node.js 18+](https://nodejs.org/)
- [Git](https://git-scm.com/)
- (Optional) [Make](https://www.gnu.org/software/make/) for database migrations
- (Optional) IDE: [JetBrains Rider](https://www.jetbrains.com/rider/) or [Visual Studio](https://visualstudio.microsoft.com/)
### Repository Setup
1. Fork the repository on GitHub
2. Clone your fork locally:
```bash
git clone https://github.com/YOUR_USERNAME/Cleanuparr.git
cd Cleanuparr
```
3. Add the upstream repository:
```bash
git remote add upstream https://github.com/Cleanuparr/Cleanuparr.git
```
## Backend Development
### Initial Setup
#### 1. Create a GitHub Personal Access Token (PAT)
Cleanuparr uses GitHub Packages for NuGet dependencies. You'll need a PAT with `read:packages` permission:
1. Go to [GitHub Settings > Developer Settings > Personal Access Tokens > Tokens (classic)](https://github.com/settings/tokens)
2. Click "Generate new token" → "Generate new token (classic)"
3. Give it a descriptive name (e.g., "Cleanuparr NuGet Access")
4. Set an expiration (recommend 90 days or longer for development)
5. Select only the `read:packages` scope
6. Click "Generate token" and copy it
#### 2. Configure NuGet Source
Add the Cleanuparr NuGet repository:
```bash
dotnet nuget add source \
--username YOUR_GITHUB_USERNAME \
--password YOUR_GITHUB_PAT \
--store-password-in-clear-text \
--name Cleanuparr \
https://nuget.pkg.github.com/Cleanuparr/index.json
```
Replace `YOUR_GITHUB_USERNAME` and `YOUR_GITHUB_PAT` with your GitHub username and the PAT you created.
### Running the Backend
#### Option 1: Using .NET CLI
Navigate to the backend directory:
```bash
cd code/backend
```
Build the application:
```bash
dotnet build Cleanuparr.Api/Cleanuparr.Api.csproj
```
Run the application:
```bash
dotnet run --project Cleanuparr.Api/Cleanuparr.Api.csproj
```
Run tests:
```bash
dotnet test
```
The API will be available at http://localhost:5000
#### Option 2: Using an IDE
For JetBrains Rider or Visual Studio:
1. Open the solution file: `code/backend/cleanuparr.sln`
2. Set `Cleanuparr.Api` as the startup project
3. Press `F5` to start the application
### Database Migrations
Cleanuparr uses two separate database contexts: `DataContext` and `EventsContext`.
#### Prerequisites
Install Make if not already installed:
- Windows: Install via [Chocolatey](https://chocolatey.org/) (`choco install make`) or use [WSL](https://docs.microsoft.com/windows/wsl/)
- macOS: Install via Homebrew (`brew install make`)
- Linux: Usually pre-installed, or install via package manager (`apt install make`, `yum install make`, etc.)
#### Creating Migrations
From the `code` directory:
For data migrations (DataContext):
```bash
make migrate-data name=YourMigrationName
```
For events migrations (EventsContext):
```bash
make migrate-events name=YourMigrationName
```
Example:
```bash
make migrate-data name=AddUserPreferences
make migrate-events name=AddAuditLogEvents
```
## Frontend Development
### Setup
1. Navigate to the frontend directory:
```bash
cd code/frontend
```
2. Install dependencies:
```bash
npm install
```
3. Start the development server:
```bash
npm start
```
The UI will be available at http://localhost:4200
## Documentation Development
### Setup
1. Navigate to the docs directory:
```bash
cd docs
```
2. Install dependencies:
```bash
npm install
```
3. Start the development server:
```bash
npm start
```
The documentation site will be available at http://localhost:3000
## Building with Docker
### Building a Local Docker Image
To build the Docker image locally for testing:
1. Navigate to the `code` directory:
```bash
cd code
```
2. Build the image:
```bash
docker build \
--build-arg PACKAGES_USERNAME=YOUR_GITHUB_USERNAME \
--build-arg PACKAGES_PAT=YOUR_GITHUB_PAT \
-t cleanuparr:local \
-f Dockerfile .
```
Replace `YOUR_GITHUB_USERNAME` and `YOUR_GITHUB_PAT` with your credentials.
3. Run the container:
```bash
docker run -d \
--name cleanuparr-dev \
-p 11011:11011 \
-v /path/to/config:/config \
-e PUID=1000 \
-e PGID=1000 \
-e TZ=Etc/UTC \
cleanuparr:local
```
4. Access the application at http://localhost:11011
### Building for Multiple Architectures
Use Docker Buildx for multi-platform builds:
```bash
docker buildx build \
--platform linux/amd64,linux/arm64 \
--build-arg PACKAGES_USERNAME=YOUR_GITHUB_USERNAME \
--build-arg PACKAGES_PAT=YOUR_GITHUB_PAT \
-t cleanuparr:local \
-f Dockerfile .
```
## Code Standards
### Backend (.NET/C#)
- Follow existing conventions and [Microsoft C# Coding Conventions](https://docs.microsoft.com/dotnet/csharp/fundamentals/coding-style/coding-conventions)
- Use meaningful variable and method names
- Add XML documentation comments for public APIs
- Write unit tests whenever possible
### Frontend (Angular/TypeScript)
- Follow existing conventions and the [Angular Style Guide](https://angular.io/guide/styleguide)
- Use TypeScript strict mode
- Write unit tests whenever possible
### Documentation
- Use clear, concise language
- Include code examples where appropriate
- Update relevant documentation when adding/changing features
- Check for spelling and grammar
## Submitting Your Contribution
### 1. Create a Feature Branch
```bash
git checkout -b feature/your-feature-name
# or
git checkout -b fix/your-bug-fix-name
```
### 2. Make Your Changes
- Write clean, well-documented code
- Follow the code standards outlined above
- **Test your changes thoroughly!**
### 3. Commit Your Changes
Write clear, descriptive commit messages:
```bash
git add .
git commit -m "Add feature: brief description of your changes"
```
### 4. Keep Your Branch Updated
```bash
git fetch upstream
git rebase upstream/main
```
### 5. Push to Your Fork
```bash
git push origin feature/your-feature-name
```
### 6. Create a Pull Request
1. Go to the [Cleanuparr repository](https://github.com/Cleanuparr/Cleanuparr)
2. Click "New Pull Request"
3. Select your fork and branch
4. Fill out the PR template with:
- A descriptive title (e.g., "Add support for Prowlarr integration" or "Fix memory leak in download client polling")
- Description of changes
- Related issue number
- Testing performed
- Screenshots (if applicable)
### 7. Code Review Process
- Maintainers will review your PR
- Address any feedback or requested changes
- Once approved, your PR will be merged
## Other Ways to Contribute
### Help Test New Features
We're always looking for testers to help validate new features before they are released. If you'd like to help test upcoming changes:
1. Join our [Discord community](https://discord.gg/SCtMCgtsc4)
2. Let us know you're interested in testing
3. We'll provide you with pre-release builds and testing instructions
Your feedback helps us catch issues early and deliver better releases.
## Getting Help
- Discord: Join our [Discord community](https://discord.gg/SCtMCgtsc4) for real-time help
- Issues: Check existing [GitHub issues](https://github.com/Cleanuparr/Cleanuparr/issues) or create a new one
- Documentation: Review the [complete documentation](https://cleanuparr.github.io/Cleanuparr/)
## License
By contributing to Cleanuparr, you agree that your contributions will be licensed under the same license as the project.
---
Thanks for contributing to Cleanuparr!

View File

@@ -23,10 +23,6 @@ Cleanuparr was created primarily to address malicious files, such as `*.lnk` or
> - Notify on strike or download removal.
> - Ignore certain torrent hashes, categories, tags or trackers from being processed by Cleanuparr.
## Screenshots
https://cleanuparr.github.io/Cleanuparr/docs/screenshots
## 🎯 Supported Applications
### *Arr Applications
@@ -63,7 +59,7 @@ docker run -d --name cleanuparr \
ghcr.io/cleanuparr/cleanuparr:latest
```
For Docker Compose, health checks, and other installation methods, see the [Complete Installation Guide](https://cleanuparr.github.io/Cleanuparr/docs/installation/detailed), but not before reading the [Prerequisites](https://cleanuparr.github.io/Cleanuparr/docs/installation/).
For Docker Compose, health checks, and other installation methods, see the [Complete Installation Guide](https://cleanuparr.github.io/Cleanuparr/docs/installation/detailed).
### 🌐 Access the Web Interface
@@ -82,17 +78,6 @@ http://localhost:11011
- **💬 [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
## 🤝 Contributing
We welcome contributions from the community! Whether it's bug fixes, new features, documentation improvements, or testing, your help is appreciated.
**Before contributing:** Please read our [Contributing Guide](CONTRIBUTING.md) and announce your intent to work on an issue before starting.
- **[Contributing Guide](CONTRIBUTING.md)** - Learn how to set up your development environment and submit contributions
- **[Report Issues](https://github.com/Cleanuparr/Cleanuparr/issues/new/choose)** - Found a bug? Let us know!
- **[Feature Requests](https://github.com/Cleanuparr/Cleanuparr/issues/new/choose)** - Share your ideas for new features
- **[Help Test Features](https://discord.gg/SCtMCgtsc4)** - Join Discord to test pre-release features and provide feedback
# <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>
Think of **Cleanuparr** as the janitor of your server; it keeps your download queue spotless, removes clutter, and blocks malicious files. Now imagine combining that with **Huntarr**, the compulsive librarian who finds missing and upgradable media to complete your collection

View File

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

View File

@@ -19,11 +19,11 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Cleanuparr.Application\Cleanuparr.Application.csproj" />
<ProjectReference Include="..\Cleanuparr.Infrastructure\Cleanuparr.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MassTransit" Version="8.4.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.6">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View File

File diff suppressed because it is too large Load Diff

View File

@@ -76,23 +76,63 @@ public class JobsController : ControllerBase
}
}
[HttpPost("{jobType}/trigger")]
public async Task<IActionResult> TriggerJob(JobType jobType)
[HttpPost("{jobType}/stop")]
public async Task<IActionResult> StopJob(JobType jobType)
{
try
{
var result = await _jobManagementService.TriggerJobOnce(jobType);
var result = await _jobManagementService.StopJob(jobType);
if (!result)
{
return BadRequest($"Failed to trigger job '{jobType}' - job may not exist or be configured");
return BadRequest($"Failed to stop job '{jobType}'");
}
return Ok(new { Message = $"Job '{jobType}' triggered successfully for one-time execution" });
return Ok(new { Message = $"Job '{jobType}' stopped successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error triggering job {jobType}", jobType);
return StatusCode(500, $"An error occurred while triggering job '{jobType}'");
_logger.LogError(ex, "Error stopping job {jobType}", jobType);
return StatusCode(500, $"An error occurred while stopping job '{jobType}'");
}
}
[HttpPost("{jobType}/pause")]
public async Task<IActionResult> PauseJob(JobType jobType)
{
try
{
var result = await _jobManagementService.PauseJob(jobType);
if (!result)
{
return BadRequest($"Failed to pause job '{jobType}'");
}
return Ok(new { Message = $"Job '{jobType}' paused successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error pausing job {jobType}", jobType);
return StatusCode(500, $"An error occurred while pausing job '{jobType}'");
}
}
[HttpPost("{jobType}/resume")]
public async Task<IActionResult> ResumeJob(JobType jobType)
{
try
{
var result = await _jobManagementService.ResumeJob(jobType);
if (!result)
{
return BadRequest($"Failed to resume job '{jobType}'");
}
return Ok(new { Message = $"Job '{jobType}' resumed successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error resuming job {jobType}", jobType);
return StatusCode(500, $"An error occurred while resuming job '{jobType}'");
}
}

View File

@@ -1,180 +0,0 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Events;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Cleanuparr.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
public class ManualEventsController : ControllerBase
{
private readonly EventsContext _context;
public ManualEventsController(EventsContext context)
{
_context = context;
}
/// <summary>
/// Gets manual events with pagination and filtering
/// </summary>
[HttpGet]
public async Task<ActionResult<PaginatedResult<ManualEvent>>> GetManualEvents(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 100,
[FromQuery] bool? isResolved = null,
[FromQuery] string? severity = null,
[FromQuery] DateTime? fromDate = null,
[FromQuery] DateTime? toDate = null,
[FromQuery] string? search = null)
{
// Validate pagination parameters
if (page < 1) page = 1;
if (pageSize < 1) pageSize = 100;
if (pageSize > 1000) pageSize = 1000; // Cap at 1000 for performance
var query = _context.ManualEvents.AsQueryable();
// Apply filters
if (isResolved.HasValue)
{
query = query.Where(e => e.IsResolved == isResolved.Value);
}
if (!string.IsNullOrWhiteSpace(severity))
{
if (Enum.TryParse<EventSeverity>(severity, true, out var severityEnum))
query = query.Where(e => e.Severity == severityEnum);
}
// Apply date range filters
if (fromDate.HasValue)
{
query = query.Where(e => e.Timestamp >= fromDate.Value);
}
if (toDate.HasValue)
{
query = query.Where(e => e.Timestamp <= toDate.Value);
}
// Apply search filter if provided
if (!string.IsNullOrWhiteSpace(search))
{
string pattern = EventsContext.GetLikePattern(search);
query = query.Where(e =>
EF.Functions.Like(e.Message, pattern) ||
EF.Functions.Like(e.Data, pattern)
);
}
// Count total matching records for pagination
var totalCount = await query.CountAsync();
// Calculate pagination
var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
var skip = (page - 1) * pageSize;
// Get paginated data
var events = await query
.OrderByDescending(e => e.Timestamp)
.Skip(skip)
.Take(pageSize)
.ToListAsync();
// Return paginated result
var result = new PaginatedResult<ManualEvent>
{
Items = events,
Page = page,
PageSize = pageSize,
TotalCount = totalCount,
TotalPages = totalPages
};
return Ok(result);
}
/// <summary>
/// Gets a specific manual event by ID
/// </summary>
[HttpGet("{id}")]
public async Task<ActionResult<ManualEvent>> GetManualEvent(Guid id)
{
var eventEntity = await _context.ManualEvents.FindAsync(id);
if (eventEntity == null)
return NotFound();
return Ok(eventEntity);
}
/// <summary>
/// Marks a manual event as resolved
/// </summary>
[HttpPost("{id}/resolve")]
public async Task<ActionResult> ResolveManualEvent(Guid id)
{
var eventEntity = await _context.ManualEvents.FindAsync(id);
if (eventEntity == null)
return NotFound();
eventEntity.IsResolved = true;
await _context.SaveChangesAsync();
return Ok();
}
/// <summary>
/// Gets manual event statistics
/// </summary>
[HttpGet("stats")]
public async Task<ActionResult<object>> GetManualEventStats()
{
var stats = new
{
TotalEvents = await _context.ManualEvents.CountAsync(),
UnresolvedEvents = await _context.ManualEvents.CountAsync(e => !e.IsResolved),
ResolvedEvents = await _context.ManualEvents.CountAsync(e => e.IsResolved),
EventsBySeverity = await _context.ManualEvents
.GroupBy(e => e.Severity)
.Select(g => new { Severity = g.Key.ToString(), Count = g.Count() })
.ToListAsync(),
UnresolvedBySeverity = await _context.ManualEvents
.Where(e => !e.IsResolved)
.GroupBy(e => e.Severity)
.Select(g => new { Severity = g.Key.ToString(), Count = g.Count() })
.ToListAsync()
};
return Ok(stats);
}
/// <summary>
/// Gets unique severities for manual events
/// </summary>
[HttpGet("severities")]
public async Task<ActionResult<List<string>>> GetSeverities()
{
var severities = Enum.GetNames(typeof(EventSeverity)).ToList();
return Ok(severities);
}
/// <summary>
/// Manually triggers cleanup of old resolved events
/// </summary>
[HttpPost("cleanup")]
public async Task<ActionResult<object>> CleanupOldResolvedEvents([FromQuery] int retentionDays = 30)
{
var cutoffDate = DateTime.UtcNow.AddDays(-retentionDays);
var deletedCount = await _context.ManualEvents
.Where(e => e.IsResolved && e.Timestamp < cutoffDate)
.ExecuteDeleteAsync();
return Ok(new { DeletedCount = deletedCount });
}
}

View File

@@ -1 +0,0 @@
// Queue rules endpoints have moved to Cleanuparr.Api.Features.QueueCleaner.Controllers

View File

@@ -1,11 +1,11 @@
using System.Text.Json.Serialization;
using Cleanuparr.Api.Middleware;
using Cleanuparr.Infrastructure.Health;
using Cleanuparr.Infrastructure.Hubs;
using Cleanuparr.Infrastructure.Logging;
using Microsoft.AspNetCore.Http.Json;
using Microsoft.OpenApi.Models;
using System.Text;
using Cleanuparr.Api.Middleware;
using Microsoft.Extensions.Options;
namespace Cleanuparr.Api.DependencyInjection;
@@ -15,21 +15,15 @@ public static class ApiDI
{
services.Configure<JsonOptions>(options =>
{
options.SerializerOptions.PropertyNameCaseInsensitive = true;
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
options.SerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
});
// Make JsonSerializerOptions available for injection
services.AddSingleton(sp =>
sp.GetRequiredService<IOptions<JsonOptions>>().Value.SerializerOptions);
// Add API-specific services
services
.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.PropertyNameCaseInsensitive = true;
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
});
@@ -40,7 +34,6 @@ public static class ApiDI
.AddSignalR()
.AddJsonProtocol(options =>
{
options.PayloadSerializerOptions.PropertyNameCaseInsensitive = true;
options.PayloadSerializerOptions.Converters.Add(new JsonStringEnumConverter());
});
@@ -74,12 +67,19 @@ public static class ApiDI
// Serve static files with caching
app.UseStaticFiles(new StaticFileOptions
{
OnPrepareResponse = _ => {}
OnPrepareResponse = ctx =>
{
// Cache static assets for 30 days
// if (ctx.File.Name.EndsWith(".js") || ctx.File.Name.EndsWith(".css"))
// {
// ctx.Context.Response.Headers.CacheControl = "public,max-age=2592000";
// }
}
});
// Add the global exception handling middleware first
app.UseMiddleware<ExceptionMiddleware>();
app.UseCors("Any");
app.UseRouting();
@@ -157,12 +157,12 @@ public static class ApiDI
icons = new[]
{
new {
src = "icons/icon-192x192.png",
src = "assets/icons/icon-192x192.png",
sizes = "192x192",
type = "image/png"
},
new {
src = "icons/icon-512x512.png",
src = "assets/icons/icon-512x512.png",
sizes = "512x512",
type = "image/png"
}

View File

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

View File

@@ -17,13 +17,14 @@ public static class MainDI
{
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) =>
services
.AddLogging(builder => builder.ClearProviders().AddConsole())
.AddHttpClients(configuration)
.AddSingleton<MemoryCache>()
.AddSingleton<IMemoryCache>(serviceProvider => serviceProvider.GetRequiredService<MemoryCache>())
.AddServices()
.AddHealthServices()
.AddQuartzServices(configuration)
.AddNotifications()
.AddNotifications(configuration)
.AddMassTransit(config =>
{
config.DisableUsageTelemetry();
@@ -35,8 +36,7 @@ public static class MainDI
config.AddConsumer<NotificationConsumer<FailedImportStrikeNotification>>();
config.AddConsumer<NotificationConsumer<StalledStrikeNotification>>();
config.AddConsumer<NotificationConsumer<SlowSpeedStrikeNotification>>();
config.AddConsumer<NotificationConsumer<SlowTimeStrikeNotification>>();
config.AddConsumer<NotificationConsumer<SlowStrikeNotification>>();
config.AddConsumer<NotificationConsumer<QueueItemDeletedNotification>>();
config.AddConsumer<NotificationConsumer<DownloadCleanedNotification>>();
config.AddConsumer<NotificationConsumer<CategoryChangedNotification>>();
@@ -45,7 +45,6 @@ public static class MainDI
{
cfg.ConfigureJsonSerializerOptions(options =>
{
options.PropertyNameCaseInsensitive = true;
options.Converters.Add(new JsonStringEnumConverter());
options.ReferenceHandler = ReferenceHandler.IgnoreCycles;
@@ -72,8 +71,7 @@ public static class MainDI
{
e.ConfigureConsumer<NotificationConsumer<FailedImportStrikeNotification>>(context);
e.ConfigureConsumer<NotificationConsumer<StalledStrikeNotification>>(context);
e.ConfigureConsumer<NotificationConsumer<SlowSpeedStrikeNotification>>(context);
e.ConfigureConsumer<NotificationConsumer<SlowTimeStrikeNotification>>(context);
e.ConfigureConsumer<NotificationConsumer<SlowStrikeNotification>>(context);
e.ConfigureConsumer<NotificationConsumer<QueueItemDeletedNotification>>(context);
e.ConfigureConsumer<NotificationConsumer<DownloadCleanedNotification>>(context);
e.ConfigureConsumer<NotificationConsumer<CategoryChangedNotification>>(context);

View File

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

View File

@@ -1,6 +1,8 @@
using Cleanuparr.Application.Features.DownloadCleaner;
using Cleanuparr.Application.Features.MalwareBlocker;
using Cleanuparr.Application.Features.QueueCleaner;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.BlacklistSync;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.DownloadHunter;
using Cleanuparr.Infrastructure.Features.DownloadHunter.Interfaces;
@@ -8,14 +10,14 @@ using Cleanuparr.Infrastructure.Features.DownloadRemover;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces;
using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Features.Security;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Services;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Infrastructure.Interceptors;
using Infrastructure.Verticals.Files;
namespace Cleanuparr.Api.DependencyInjection;
@@ -38,7 +40,6 @@ public static class ServicesDI
.AddScoped<WhisparrClient>()
.AddScoped<ArrClientFactory>()
.AddScoped<QueueCleaner>()
.AddScoped<BlacklistSynchronizer>()
.AddScoped<MalwareBlocker>()
.AddScoped<DownloadCleaner>()
.AddScoped<IQueueItemRemover, QueueItemRemover>()
@@ -50,12 +51,6 @@ public static class ServicesDI
.AddScoped<ArrQueueIterator>()
.AddScoped<DownloadServiceFactory>()
.AddScoped<IStriker, Striker>()
.AddScoped<FileReader>()
.AddScoped<IRuleManager, RuleManager>()
.AddScoped<IRuleEvaluator, RuleEvaluator>()
.AddScoped<IRuleIntervalValidator, RuleIntervalValidator>()
.AddSingleton<IJobManagementService, JobManagementService>()
.AddSingleton<BlocklistProvider>()
.AddSingleton<AppStatusSnapshot>()
.AddHostedService<AppStatusRefreshService>();
.AddSingleton<BlocklistProvider>();
}

View File

@@ -1,37 +0,0 @@
using System;
using System.ComponentModel.DataAnnotations;
using Cleanuparr.Persistence.Models.Configuration.Arr;
namespace Cleanuparr.Api.Features.Arr.Contracts.Requests;
public sealed record ArrInstanceRequest
{
public bool Enabled { get; init; } = true;
[Required]
public required string Name { get; init; }
[Required]
public required string Url { get; init; }
[Required]
public required string ApiKey { get; init; }
public ArrInstance ToEntity(Guid configId) => new()
{
Enabled = Enabled,
Name = Name,
Url = new Uri(Url),
ApiKey = ApiKey,
ArrConfigId = configId,
};
public void ApplyTo(ArrInstance instance)
{
instance.Enabled = Enabled;
instance.Name = Name;
instance.Url = new Uri(Url);
instance.ApiKey = ApiKey;
}
}

View File

@@ -1,6 +0,0 @@
namespace Cleanuparr.Api.Features.Arr.Contracts.Requests;
public sealed record UpdateArrConfigRequest
{
public short FailedImportMaxStrikes { get; init; } = -1;
}

View File

@@ -1,272 +0,0 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Cleanuparr.Api.Features.Arr.Contracts.Requests;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Dtos;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Mapster;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Api.Features.Arr.Controllers;
[ApiController]
[Route("api/configuration")]
public sealed class ArrConfigController : ControllerBase
{
private readonly ILogger<ArrConfigController> _logger;
private readonly DataContext _dataContext;
public ArrConfigController(
ILogger<ArrConfigController> logger,
DataContext dataContext)
{
_logger = logger;
_dataContext = dataContext;
}
[HttpGet("sonarr")]
public Task<IActionResult> GetSonarrConfig() => GetArrConfig(InstanceType.Sonarr);
[HttpGet("radarr")]
public Task<IActionResult> GetRadarrConfig() => GetArrConfig(InstanceType.Radarr);
[HttpGet("lidarr")]
public Task<IActionResult> GetLidarrConfig() => GetArrConfig(InstanceType.Lidarr);
[HttpGet("readarr")]
public Task<IActionResult> GetReadarrConfig() => GetArrConfig(InstanceType.Readarr);
[HttpGet("whisparr")]
public Task<IActionResult> GetWhisparrConfig() => GetArrConfig(InstanceType.Whisparr);
[HttpPut("sonarr")]
public Task<IActionResult> UpdateSonarrConfig([FromBody] UpdateArrConfigRequest request)
=> UpdateArrConfig(InstanceType.Sonarr, request);
[HttpPut("radarr")]
public Task<IActionResult> UpdateRadarrConfig([FromBody] UpdateArrConfigRequest request)
=> UpdateArrConfig(InstanceType.Radarr, request);
[HttpPut("lidarr")]
public Task<IActionResult> UpdateLidarrConfig([FromBody] UpdateArrConfigRequest request)
=> UpdateArrConfig(InstanceType.Lidarr, request);
[HttpPut("readarr")]
public Task<IActionResult> UpdateReadarrConfig([FromBody] UpdateArrConfigRequest request)
=> UpdateArrConfig(InstanceType.Readarr, request);
[HttpPut("whisparr")]
public Task<IActionResult> UpdateWhisparrConfig([FromBody] UpdateArrConfigRequest request)
=> UpdateArrConfig(InstanceType.Whisparr, request);
[HttpPost("sonarr/instances")]
public Task<IActionResult> CreateSonarrInstance([FromBody] ArrInstanceRequest request)
=> CreateArrInstance(InstanceType.Sonarr, request);
[HttpPut("sonarr/instances/{id}")]
public Task<IActionResult> UpdateSonarrInstance(Guid id, [FromBody] ArrInstanceRequest request)
=> UpdateArrInstance(InstanceType.Sonarr, id, request);
[HttpDelete("sonarr/instances/{id}")]
public Task<IActionResult> DeleteSonarrInstance(Guid id)
=> DeleteArrInstance(InstanceType.Sonarr, id);
[HttpPost("radarr/instances")]
public Task<IActionResult> CreateRadarrInstance([FromBody] ArrInstanceRequest request)
=> CreateArrInstance(InstanceType.Radarr, request);
[HttpPut("radarr/instances/{id}")]
public Task<IActionResult> UpdateRadarrInstance(Guid id, [FromBody] ArrInstanceRequest request)
=> UpdateArrInstance(InstanceType.Radarr, id, request);
[HttpDelete("radarr/instances/{id}")]
public Task<IActionResult> DeleteRadarrInstance(Guid id)
=> DeleteArrInstance(InstanceType.Radarr, id);
[HttpPost("lidarr/instances")]
public Task<IActionResult> CreateLidarrInstance([FromBody] ArrInstanceRequest request)
=> CreateArrInstance(InstanceType.Lidarr, request);
[HttpPut("lidarr/instances/{id}")]
public Task<IActionResult> UpdateLidarrInstance(Guid id, [FromBody] ArrInstanceRequest request)
=> UpdateArrInstance(InstanceType.Lidarr, id, request);
[HttpDelete("lidarr/instances/{id}")]
public Task<IActionResult> DeleteLidarrInstance(Guid id)
=> DeleteArrInstance(InstanceType.Lidarr, id);
[HttpPost("readarr/instances")]
public Task<IActionResult> CreateReadarrInstance([FromBody] ArrInstanceRequest request)
=> CreateArrInstance(InstanceType.Readarr, request);
[HttpPut("readarr/instances/{id}")]
public Task<IActionResult> UpdateReadarrInstance(Guid id, [FromBody] ArrInstanceRequest request)
=> UpdateArrInstance(InstanceType.Readarr, id, request);
[HttpDelete("readarr/instances/{id}")]
public Task<IActionResult> DeleteReadarrInstance(Guid id)
=> DeleteArrInstance(InstanceType.Readarr, id);
[HttpPost("whisparr/instances")]
public Task<IActionResult> CreateWhisparrInstance([FromBody] ArrInstanceRequest request)
=> CreateArrInstance(InstanceType.Whisparr, request);
[HttpPut("whisparr/instances/{id}")]
public Task<IActionResult> UpdateWhisparrInstance(Guid id, [FromBody] ArrInstanceRequest request)
=> UpdateArrInstance(InstanceType.Whisparr, id, request);
[HttpDelete("whisparr/instances/{id}")]
public Task<IActionResult> DeleteWhisparrInstance(Guid id)
=> DeleteArrInstance(InstanceType.Whisparr, id);
private async Task<IActionResult> GetArrConfig(InstanceType type)
{
await DataContext.Lock.WaitAsync();
try
{
var config = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.AsNoTracking()
.FirstAsync(x => x.Type == type);
config.Instances = config.Instances
.OrderBy(i => i.Name)
.ToList();
return Ok(config.Adapt<ArrConfigDto>());
}
finally
{
DataContext.Lock.Release();
}
}
private async Task<IActionResult> UpdateArrConfig(InstanceType type, UpdateArrConfigRequest request)
{
await DataContext.Lock.WaitAsync();
try
{
var config = await _dataContext.ArrConfigs
.FirstAsync(x => x.Type == type);
config.FailedImportMaxStrikes = request.FailedImportMaxStrikes;
config.Validate();
await _dataContext.SaveChangesAsync();
return Ok(new { Message = $"{type} configuration updated successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save {Type} configuration", type);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
private async Task<IActionResult> CreateArrInstance(InstanceType type, ArrInstanceRequest request)
{
await DataContext.Lock.WaitAsync();
try
{
var config = await _dataContext.ArrConfigs
.FirstAsync(x => x.Type == type);
var instance = request.ToEntity(config.Id);
await _dataContext.ArrInstances.AddAsync(instance);
await _dataContext.SaveChangesAsync();
return CreatedAtAction(GetConfigActionName(type), new { id = instance.Id }, instance.Adapt<ArrInstanceDto>());
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create {Type} instance", type);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
private async Task<IActionResult> UpdateArrInstance(InstanceType type, Guid id, ArrInstanceRequest request)
{
await DataContext.Lock.WaitAsync();
try
{
var config = await _dataContext.ArrConfigs
.Include(c => c.Instances)
.FirstAsync(x => x.Type == type);
var instance = config.Instances.FirstOrDefault(i => i.Id == id);
if (instance is null)
{
return NotFound($"{type} instance with ID {id} not found");
}
request.ApplyTo(instance);
await _dataContext.SaveChangesAsync();
return Ok(instance.Adapt<ArrInstanceDto>());
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update {Type} instance with ID {Id}", type, id);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
private async Task<IActionResult> DeleteArrInstance(InstanceType type, Guid id)
{
await DataContext.Lock.WaitAsync();
try
{
var config = await _dataContext.ArrConfigs
.Include(c => c.Instances)
.FirstAsync(x => x.Type == type);
var instance = config.Instances.FirstOrDefault(i => i.Id == id);
if (instance is null)
{
return NotFound($"{type} instance with ID {id} not found");
}
config.Instances.Remove(instance);
await _dataContext.SaveChangesAsync();
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete {Type} instance with ID {Id}", type, id);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
private static string GetConfigActionName(InstanceType type) => type switch
{
InstanceType.Sonarr => nameof(GetSonarrConfig),
InstanceType.Radarr => nameof(GetRadarrConfig),
InstanceType.Lidarr => nameof(GetLidarrConfig),
InstanceType.Readarr => nameof(GetReadarrConfig),
InstanceType.Whisparr => nameof(GetWhisparrConfig),
_ => nameof(GetSonarrConfig),
};
}

View File

@@ -1,26 +0,0 @@
using System;
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
namespace Cleanuparr.Api.Features.BlacklistSync.Contracts.Requests;
public sealed record UpdateBlacklistSyncConfigRequest
{
public bool Enabled { get; init; }
public string? BlacklistPath { get; init; }
/// <summary>
/// Applies the request to the provided configuration instance.
/// </summary>
public BlacklistSyncConfig ApplyTo(BlacklistSyncConfig config)
{
config.Enabled = Enabled;
config.BlacklistPath = BlacklistPath;
return config;
}
public bool HasPathChanged(string? currentPath)
=> !string.Equals(currentPath, BlacklistPath, StringComparison.InvariantCultureIgnoreCase);
}

View File

@@ -1,100 +0,0 @@
using System;
using System.Threading.Tasks;
using Cleanuparr.Api.Features.BlacklistSync.Contracts.Requests;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Api.Features.BlacklistSync.Controllers;
[ApiController]
[Route("api/configuration")]
public sealed class BlacklistSyncConfigController : ControllerBase
{
private readonly ILogger<BlacklistSyncConfigController> _logger;
private readonly DataContext _dataContext;
private readonly IJobManagementService _jobManagementService;
public BlacklistSyncConfigController(
ILogger<BlacklistSyncConfigController> logger,
DataContext dataContext,
IJobManagementService jobManagementService)
{
_logger = logger;
_dataContext = dataContext;
_jobManagementService = jobManagementService;
}
[HttpGet("blacklist_sync")]
public async Task<IActionResult> GetBlacklistSyncConfig()
{
await DataContext.Lock.WaitAsync();
try
{
var config = await _dataContext.BlacklistSyncConfigs
.AsNoTracking()
.FirstAsync();
return Ok(config);
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("blacklist_sync")]
public async Task<IActionResult> UpdateBlacklistSyncConfig([FromBody] UpdateBlacklistSyncConfigRequest request)
{
await DataContext.Lock.WaitAsync();
try
{
var config = await _dataContext.BlacklistSyncConfigs
.FirstAsync();
bool enabledChanged = config.Enabled != request.Enabled;
bool becameEnabled = !config.Enabled && request.Enabled;
bool pathChanged = request.HasPathChanged(config.BlacklistPath);
request.ApplyTo(config);
config.Validate();
await _dataContext.SaveChangesAsync();
if (enabledChanged)
{
if (becameEnabled)
{
_logger.LogInformation("BlacklistSynchronizer enabled, starting job");
await _jobManagementService.StartJob(JobType.BlacklistSynchronizer, null, config.CronExpression);
await _jobManagementService.TriggerJobOnce(JobType.BlacklistSynchronizer);
}
else
{
_logger.LogInformation("BlacklistSynchronizer disabled, stopping the job");
await _jobManagementService.StopJob(JobType.BlacklistSynchronizer);
}
}
else if (pathChanged && config.Enabled)
{
_logger.LogDebug("BlacklistSynchronizer path changed");
await _jobManagementService.TriggerJobOnce(JobType.BlacklistSynchronizer);
}
return Ok(new { Message = "BlacklistSynchronizer configuration updated successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save BlacklistSync configuration");
throw;
}
finally
{
DataContext.Lock.Release();
}
}
}

View File

@@ -1,55 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
public record UpdateDownloadCleanerConfigRequest
{
public bool Enabled { get; init; }
public string CronExpression { get; init; } = "0 0 * * * ?";
/// <summary>
/// Indicates whether to use the CronExpression directly or convert from a user-friendly schedule.
/// </summary>
public bool UseAdvancedScheduling { get; init; }
public List<CleanCategoryRequest> Categories { get; init; } = [];
public bool DeletePrivate { get; init; }
/// <summary>
/// Indicates whether unlinked download handling is enabled.
/// </summary>
public bool UnlinkedEnabled { get; init; }
public string UnlinkedTargetCategory { get; init; } = "cleanuparr-unlinked";
public bool UnlinkedUseTag { get; init; }
public string UnlinkedIgnoredRootDir { get; init; } = string.Empty;
public List<string> UnlinkedCategories { get; init; } = [];
public List<string> IgnoredDownloads { get; init; } = [];
}
public record CleanCategoryRequest
{
[Required]
public string Name { get; init; } = string.Empty;
/// <summary>
/// Max ratio before removing a download.
/// </summary>
public double MaxRatio { get; init; } = -1;
/// <summary>
/// Min number of hours to seed before removing a download, if the ratio has been met.
/// </summary>
public double MinSeedTime { get; init; }
/// <summary>
/// Number of hours to seed before removing a download.
/// </summary>
public double MaxSeedTime { get; init; } = -1;
}

View File

@@ -1,197 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.IO;
using System.Linq;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Infrastructure.Utilities;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Api.Features.DownloadCleaner.Controllers;
[ApiController]
[Route("api/configuration")]
public sealed class DownloadCleanerConfigController : ControllerBase
{
private readonly ILogger<DownloadCleanerConfigController> _logger;
private readonly DataContext _dataContext;
private readonly IJobManagementService _jobManagementService;
public DownloadCleanerConfigController(
ILogger<DownloadCleanerConfigController> logger,
DataContext dataContext,
IJobManagementService jobManagementService)
{
_logger = logger;
_dataContext = dataContext;
_jobManagementService = jobManagementService;
}
[HttpGet("download_cleaner")]
public async Task<IActionResult> GetDownloadCleanerConfig()
{
await DataContext.Lock.WaitAsync();
try
{
var config = await _dataContext.DownloadCleanerConfigs
.Include(x => x.Categories)
.AsNoTracking()
.FirstAsync();
return Ok(config);
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("download_cleaner")]
public async Task<IActionResult> UpdateDownloadCleanerConfig([FromBody] UpdateDownloadCleanerConfigRequest newConfigDto)
{
await DataContext.Lock.WaitAsync();
try
{
if (newConfigDto is null)
{
throw new ValidationException("Request body cannot be null");
}
if (!string.IsNullOrEmpty(newConfigDto.CronExpression))
{
CronValidationHelper.ValidateCronExpression(newConfigDto.CronExpression);
}
if (newConfigDto.Enabled && newConfigDto.Categories.Any())
{
if (newConfigDto.Categories.GroupBy(x => x.Name).Any(x => x.Count() > 1))
{
throw new ValidationException("Duplicate category names found");
}
foreach (var categoryDto in newConfigDto.Categories)
{
if (string.IsNullOrWhiteSpace(categoryDto.Name))
{
throw new ValidationException("Category name cannot be empty");
}
if (categoryDto is { MaxRatio: < 0, MaxSeedTime: < 0 })
{
throw new ValidationException("Either max ratio or max seed time must be enabled");
}
if (categoryDto.MinSeedTime < 0)
{
throw new ValidationException("Min seed time cannot be negative");
}
}
}
if (newConfigDto.UnlinkedEnabled)
{
if (string.IsNullOrWhiteSpace(newConfigDto.UnlinkedTargetCategory))
{
throw new ValidationException("Unlinked target category cannot be empty");
}
if (newConfigDto.UnlinkedCategories?.Count is null or 0)
{
throw new ValidationException("Unlinked categories cannot be empty");
}
if (newConfigDto.UnlinkedCategories.Contains(newConfigDto.UnlinkedTargetCategory))
{
throw new ValidationException("The unlinked target category should not be present in unlinked categories");
}
if (newConfigDto.UnlinkedCategories.Any(string.IsNullOrWhiteSpace))
{
throw new ValidationException("Empty unlinked category filter found");
}
if (!string.IsNullOrEmpty(newConfigDto.UnlinkedIgnoredRootDir) && !Directory.Exists(newConfigDto.UnlinkedIgnoredRootDir))
{
throw new ValidationException($"{newConfigDto.UnlinkedIgnoredRootDir} root directory does not exist");
}
}
var oldConfig = await _dataContext.DownloadCleanerConfigs
.Include(x => x.Categories)
.FirstAsync();
oldConfig.Enabled = newConfigDto.Enabled;
oldConfig.CronExpression = newConfigDto.CronExpression;
oldConfig.UseAdvancedScheduling = newConfigDto.UseAdvancedScheduling;
oldConfig.DeletePrivate = newConfigDto.DeletePrivate;
oldConfig.UnlinkedEnabled = newConfigDto.UnlinkedEnabled;
oldConfig.UnlinkedTargetCategory = newConfigDto.UnlinkedTargetCategory;
oldConfig.UnlinkedUseTag = newConfigDto.UnlinkedUseTag;
oldConfig.UnlinkedIgnoredRootDir = newConfigDto.UnlinkedIgnoredRootDir;
oldConfig.UnlinkedCategories = newConfigDto.UnlinkedCategories;
oldConfig.IgnoredDownloads = newConfigDto.IgnoredDownloads;
_dataContext.CleanCategories.RemoveRange(oldConfig.Categories);
_dataContext.DownloadCleanerConfigs.Update(oldConfig);
foreach (var categoryDto in newConfigDto.Categories)
{
_dataContext.CleanCategories.Add(new CleanCategory
{
Name = categoryDto.Name,
MaxRatio = categoryDto.MaxRatio,
MinSeedTime = categoryDto.MinSeedTime,
MaxSeedTime = categoryDto.MaxSeedTime,
DownloadCleanerConfigId = oldConfig.Id
});
}
await _dataContext.SaveChangesAsync();
await UpdateJobSchedule(oldConfig, JobType.DownloadCleaner);
return Ok(new { Message = "DownloadCleaner configuration updated successfully" });
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save DownloadCleaner configuration");
throw;
}
finally
{
DataContext.Lock.Release();
}
}
private async Task UpdateJobSchedule(IJobConfig config, JobType jobType)
{
if (config.Enabled)
{
if (!string.IsNullOrEmpty(config.CronExpression))
{
_logger.LogInformation("{name} is enabled, updating job schedule with cron expression: {CronExpression}",
jobType.ToString(), config.CronExpression);
await _jobManagementService.StartJob(jobType, null, config.CronExpression);
}
else
{
_logger.LogWarning("{name} is enabled, but no cron expression was found in the configuration", jobType.ToString());
}
return;
}
_logger.LogInformation("{name} is disabled, stopping the job", jobType.ToString());
await _jobManagementService.StopJob(jobType);
}
}

View File

@@ -1,51 +0,0 @@
using System;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Persistence.Models.Configuration;
namespace Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
public sealed record CreateDownloadClientRequest
{
public bool Enabled { get; init; }
public string Name { get; init; } = string.Empty;
public DownloadClientTypeName TypeName { get; init; }
public DownloadClientType Type { get; init; }
public Uri? Host { get; init; }
public string? Username { get; init; }
public string? Password { get; init; }
public string? UrlBase { get; init; }
public void Validate()
{
if (string.IsNullOrWhiteSpace(Name))
{
throw new ValidationException("Client name cannot be empty");
}
if (Host is null)
{
throw new ValidationException("Host cannot be empty");
}
}
public DownloadClientConfig ToEntity() => new()
{
Enabled = Enabled,
Name = Name,
TypeName = TypeName,
Type = Type,
Host = Host,
Username = Username,
Password = Password,
UrlBase = UrlBase,
};
}

View File

@@ -1,51 +0,0 @@
using System;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Persistence.Models.Configuration;
namespace Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
public sealed record UpdateDownloadClientRequest
{
public bool Enabled { get; init; }
public string Name { get; init; } = string.Empty;
public DownloadClientTypeName TypeName { get; init; }
public DownloadClientType Type { get; init; }
public Uri? Host { get; init; }
public string? Username { get; init; }
public string? Password { get; init; }
public string? UrlBase { get; init; }
public void Validate()
{
if (string.IsNullOrWhiteSpace(Name))
{
throw new ValidationException("Client name cannot be empty");
}
if (Host is null)
{
throw new ValidationException("Host cannot be empty");
}
}
public DownloadClientConfig ApplyTo(DownloadClientConfig existing) => existing with
{
Enabled = Enabled,
Name = Name,
TypeName = TypeName,
Type = Type,
Host = Host,
Username = Username,
Password = Password,
UrlBase = UrlBase,
};
}

View File

@@ -1,149 +0,0 @@
using System;
using System.Linq;
using Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Api.Features.DownloadClient.Controllers;
[ApiController]
[Route("api/configuration")]
public sealed class DownloadClientController : ControllerBase
{
private readonly ILogger<DownloadClientController> _logger;
private readonly DataContext _dataContext;
private readonly IDynamicHttpClientFactory _dynamicHttpClientFactory;
public DownloadClientController(
ILogger<DownloadClientController> logger,
DataContext dataContext,
IDynamicHttpClientFactory dynamicHttpClientFactory)
{
_logger = logger;
_dataContext = dataContext;
_dynamicHttpClientFactory = dynamicHttpClientFactory;
}
[HttpGet("download_client")]
public async Task<IActionResult> GetDownloadClientConfig()
{
await DataContext.Lock.WaitAsync();
try
{
var clients = await _dataContext.DownloadClients
.AsNoTracking()
.ToListAsync();
clients = clients
.OrderBy(c => c.TypeName)
.ThenBy(c => c.Name)
.ToList();
return Ok(new { clients });
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPost("download_client")]
public async Task<IActionResult> CreateDownloadClientConfig([FromBody] CreateDownloadClientRequest newClient)
{
await DataContext.Lock.WaitAsync();
try
{
newClient.Validate();
var clientConfig = newClient.ToEntity();
_dataContext.DownloadClients.Add(clientConfig);
await _dataContext.SaveChangesAsync();
return CreatedAtAction(nameof(GetDownloadClientConfig), new { id = clientConfig.Id }, clientConfig);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create download client");
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("download_client/{id}")]
public async Task<IActionResult> UpdateDownloadClientConfig(Guid id, [FromBody] UpdateDownloadClientRequest updatedClient)
{
await DataContext.Lock.WaitAsync();
try
{
updatedClient.Validate();
var existingClient = await _dataContext.DownloadClients
.FirstOrDefaultAsync(c => c.Id == id);
if (existingClient is null)
{
return NotFound($"Download client with ID {id} not found");
}
var clientToPersist = updatedClient.ApplyTo(existingClient);
_dataContext.Entry(existingClient).CurrentValues.SetValues(clientToPersist);
await _dataContext.SaveChangesAsync();
return Ok(clientToPersist);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update download client with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpDelete("download_client/{id}")]
public async Task<IActionResult> DeleteDownloadClientConfig(Guid id)
{
await DataContext.Lock.WaitAsync();
try
{
var existingClient = await _dataContext.DownloadClients
.FirstOrDefaultAsync(c => c.Id == id);
if (existingClient is null)
{
return NotFound($"Download client with ID {id} not found");
}
_dataContext.DownloadClients.Remove(existingClient);
await _dataContext.SaveChangesAsync();
var clientName = $"DownloadClient_{id}";
_dynamicHttpClientFactory.UnregisterConfiguration(clientName);
_logger.LogInformation("Removed HTTP client configuration for deleted download client {ClientName}", clientName);
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete download client with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
}

View File

@@ -1,131 +0,0 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
using Cleanuparr.Infrastructure.Logging;
using Cleanuparr.Persistence.Models.Configuration.General;
using Cleanuparr.Shared.Helpers;
using Serilog.Events;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Api.Features.General.Contracts.Requests;
public sealed record UpdateGeneralConfigRequest
{
public bool DisplaySupportBanner { get; init; } = true;
public bool DryRun { get; init; }
public ushort HttpMaxRetries { get; init; }
public ushort HttpTimeout { get; init; } = 100;
public CertificateValidationType HttpCertificateValidation { get; init; } = CertificateValidationType.Enabled;
public bool SearchEnabled { get; init; } = true;
public ushort SearchDelay { get; init; } = Constants.DefaultSearchDelaySeconds;
public string EncryptionKey { get; init; } = Guid.NewGuid().ToString();
public List<string> IgnoredDownloads { get; init; } = [];
public UpdateLoggingConfigRequest Log { get; init; } = new();
public GeneralConfig ApplyTo(GeneralConfig existingConfig, IServiceProvider services, ILogger logger)
{
existingConfig.DisplaySupportBanner = DisplaySupportBanner;
existingConfig.DryRun = DryRun;
existingConfig.HttpMaxRetries = HttpMaxRetries;
existingConfig.HttpTimeout = HttpTimeout;
existingConfig.HttpCertificateValidation = HttpCertificateValidation;
existingConfig.SearchEnabled = SearchEnabled;
existingConfig.SearchDelay = SearchDelay;
existingConfig.EncryptionKey = EncryptionKey;
existingConfig.IgnoredDownloads = IgnoredDownloads;
bool loggingChanged = Log.ApplyTo(existingConfig.Log);
Validate(existingConfig);
ApplySideEffects(existingConfig, services, logger, loggingChanged);
return existingConfig;
}
private static void Validate(GeneralConfig config)
{
if (config.HttpTimeout is 0)
{
throw new ValidationException("HTTP_TIMEOUT must be greater than 0");
}
config.Log.Validate();
}
private void ApplySideEffects(GeneralConfig config, IServiceProvider services, ILogger logger, bool loggingChanged)
{
var dynamicHttpClientFactory = services.GetRequiredService<IDynamicHttpClientFactory>();
dynamicHttpClientFactory.UpdateAllClientsFromGeneralConfig(config);
logger.LogInformation("Updated all HTTP client configurations with new general settings");
if (!loggingChanged)
{
return;
}
if (Log.LevelOnlyChange)
{
logger.LogCritical("Setting global log level to {level}", config.Log.Level);
LoggingConfigManager.SetLogLevel(config.Log.Level);
return;
}
logger.LogCritical("Reconfiguring logger due to configuration changes");
LoggingConfigManager.ReconfigureLogging(config);
}
}
public sealed record UpdateLoggingConfigRequest
{
public LogEventLevel Level { get; init; } = LogEventLevel.Information;
public ushort RollingSizeMB { get; init; } = 10;
public ushort RetainedFileCount { get; init; } = 5;
public ushort TimeLimitHours { get; init; } = 24;
public bool ArchiveEnabled { get; init; } = true;
public ushort ArchiveRetainedCount { get; init; } = 60;
public ushort ArchiveTimeLimitHours { get; init; } = 24 * 30;
public bool ApplyTo(LoggingConfig existingConfig)
{
bool levelChanged = existingConfig.Level != Level;
bool otherPropertiesChanged =
existingConfig.RollingSizeMB != RollingSizeMB ||
existingConfig.RetainedFileCount != RetainedFileCount ||
existingConfig.TimeLimitHours != TimeLimitHours ||
existingConfig.ArchiveEnabled != ArchiveEnabled ||
existingConfig.ArchiveRetainedCount != ArchiveRetainedCount ||
existingConfig.ArchiveTimeLimitHours != ArchiveTimeLimitHours;
existingConfig.Level = Level;
existingConfig.RollingSizeMB = RollingSizeMB;
existingConfig.RetainedFileCount = RetainedFileCount;
existingConfig.TimeLimitHours = TimeLimitHours;
existingConfig.ArchiveEnabled = ArchiveEnabled;
existingConfig.ArchiveRetainedCount = ArchiveRetainedCount;
existingConfig.ArchiveTimeLimitHours = ArchiveTimeLimitHours;
existingConfig.Validate();
LevelOnlyChange = levelChanged && !otherPropertiesChanged;
return levelChanged || otherPropertiesChanged;
}
public bool LevelOnlyChange { get; private set; }
}

View File

@@ -1,115 +0,0 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Cleanuparr.Api.Features.General.Contracts.Requests;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.General;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Api.Features.General.Controllers;
[ApiController]
[Route("api/configuration")]
public sealed class GeneralConfigController : ControllerBase
{
private readonly ILogger<GeneralConfigController> _logger;
private readonly DataContext _dataContext;
private readonly MemoryCache _cache;
public GeneralConfigController(
ILogger<GeneralConfigController> logger,
DataContext dataContext,
MemoryCache cache)
{
_logger = logger;
_dataContext = dataContext;
_cache = cache;
}
[HttpGet("general")]
public async Task<IActionResult> GetGeneralConfig()
{
await DataContext.Lock.WaitAsync();
try
{
var config = await _dataContext.GeneralConfigs
.AsNoTracking()
.FirstAsync();
return Ok(config);
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("general")]
public async Task<IActionResult> UpdateGeneralConfig([FromBody] UpdateGeneralConfigRequest request)
{
await DataContext.Lock.WaitAsync();
try
{
var config = await _dataContext.GeneralConfigs
.FirstAsync();
bool wasDryRun = config.DryRun;
request.ApplyTo(config, HttpContext.RequestServices, _logger);
await _dataContext.SaveChangesAsync();
ClearStrikesCacheIfNeeded(wasDryRun, config.DryRun);
return Ok(new { Message = "General configuration updated successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save General configuration");
throw;
}
finally
{
DataContext.Lock.Release();
}
}
private void ClearStrikesCacheIfNeeded(bool wasDryRun, bool isDryRun)
{
if (!wasDryRun || isDryRun)
{
return;
}
List<object> keys;
// Remove strikes
foreach (string strikeType in Enum.GetNames(typeof(StrikeType)))
{
keys = _cache.Keys
.Where(key => key.ToString()?.StartsWith(strikeType, StringComparison.InvariantCultureIgnoreCase) is true)
.ToList();
foreach (object key in keys)
{
_cache.Remove(key);
}
_logger.LogTrace("Removed all cache entries for strike type: {StrikeType}", strikeType);
}
// Remove strike cache items
keys = _cache.Keys
.Where(key => key.ToString()?.StartsWith("item_", StringComparison.InvariantCultureIgnoreCase) is true)
.ToList();
foreach (object key in keys)
{
_cache.Remove(key);
}
}
}

View File

@@ -1,50 +0,0 @@
using System.Collections.Generic;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
namespace Cleanuparr.Api.Features.MalwareBlocker.Contracts.Requests;
public sealed record UpdateMalwareBlockerConfigRequest
{
public bool Enabled { get; init; }
public string CronExpression { get; init; } = "0/5 * * * * ?";
public bool UseAdvancedScheduling { get; init; }
public bool IgnorePrivate { get; init; }
public bool DeletePrivate { get; init; }
public bool DeleteKnownMalware { get; init; }
public BlocklistSettings Sonarr { get; init; } = new();
public BlocklistSettings Radarr { get; init; } = new();
public BlocklistSettings Lidarr { get; init; } = new();
public BlocklistSettings Readarr { get; init; } = new();
public BlocklistSettings Whisparr { get; init; } = new();
public List<string> IgnoredDownloads { get; init; } = [];
public ContentBlockerConfig ApplyTo(ContentBlockerConfig config)
{
config.Enabled = Enabled;
config.CronExpression = CronExpression;
config.UseAdvancedScheduling = UseAdvancedScheduling;
config.IgnorePrivate = IgnorePrivate;
config.DeletePrivate = DeletePrivate;
config.DeleteKnownMalware = DeleteKnownMalware;
config.Sonarr = Sonarr;
config.Radarr = Radarr;
config.Lidarr = Lidarr;
config.Readarr = Readarr;
config.Whisparr = Whisparr;
config.IgnoredDownloads = IgnoredDownloads;
return config;
}
}

View File

@@ -1,112 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using Cleanuparr.Api.Features.MalwareBlocker.Contracts.Requests;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Infrastructure.Utilities;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Api.Features.MalwareBlocker.Controllers;
[ApiController]
[Route("api/configuration")]
public sealed class MalwareBlockerConfigController : ControllerBase
{
private readonly ILogger<MalwareBlockerConfigController> _logger;
private readonly DataContext _dataContext;
private readonly IJobManagementService _jobManagementService;
public MalwareBlockerConfigController(
ILogger<MalwareBlockerConfigController> logger,
DataContext dataContext,
IJobManagementService jobManagementService)
{
_logger = logger;
_dataContext = dataContext;
_jobManagementService = jobManagementService;
}
[HttpGet("malware_blocker")]
public async Task<IActionResult> GetMalwareBlockerConfig()
{
await DataContext.Lock.WaitAsync();
try
{
var config = await _dataContext.ContentBlockerConfigs
.AsNoTracking()
.FirstAsync();
return Ok(config);
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("malware_blocker")]
public async Task<IActionResult> UpdateMalwareBlockerConfig([FromBody] UpdateMalwareBlockerConfigRequest request)
{
await DataContext.Lock.WaitAsync();
try
{
if (!string.IsNullOrEmpty(request.CronExpression))
{
CronValidationHelper.ValidateCronExpression(request.CronExpression, JobType.MalwareBlocker);
}
var config = await _dataContext.ContentBlockerConfigs
.FirstAsync();
request.ApplyTo(config);
config.Validate();
await _dataContext.SaveChangesAsync();
await UpdateJobSchedule(config, JobType.MalwareBlocker);
return Ok(new { Message = "MalwareBlocker configuration updated successfully" });
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save MalwareBlocker configuration");
throw;
}
finally
{
DataContext.Lock.Release();
}
}
private async Task UpdateJobSchedule(IJobConfig config, JobType jobType)
{
if (config.Enabled)
{
if (!string.IsNullOrEmpty(config.CronExpression))
{
_logger.LogInformation("{name} is enabled, updating job schedule with cron expression: {CronExpression}",
jobType.ToString(), config.CronExpression);
await _jobManagementService.StartJob(jobType, null, config.CronExpression);
}
else
{
_logger.LogWarning("{name} is enabled, but no cron expression was found in the configuration", jobType.ToString());
}
return;
}
_logger.LogInformation("{name} is disabled, stopping the job", jobType.ToString());
await _jobManagementService.StopJob(jobType);
}
}

View File

@@ -1,10 +0,0 @@
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record CreateAppriseProviderRequest : CreateNotificationProviderRequestBase
{
public string Url { get; init; } = string.Empty;
public string Key { get; init; } = string.Empty;
public string Tags { get; init; } = string.Empty;
}

View File

@@ -1,8 +0,0 @@
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record CreateNotifiarrProviderRequest : CreateNotificationProviderRequestBase
{
public string ApiKey { get; init; } = string.Empty;
public string ChannelId { get; init; } = string.Empty;
}

View File

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

View File

@@ -1,22 +0,0 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record CreateNtfyProviderRequest : CreateNotificationProviderRequestBase
{
public string ServerUrl { get; init; } = string.Empty;
public List<string> Topics { get; init; } = [];
public NtfyAuthenticationType AuthenticationType { get; init; } = NtfyAuthenticationType.None;
public string Username { get; init; } = string.Empty;
public string Password { get; init; } = string.Empty;
public string AccessToken { get; init; } = string.Empty;
public NtfyPriority Priority { get; init; } = NtfyPriority.Default;
public List<string> Tags { get; init; } = [];
}

View File

@@ -1,10 +0,0 @@
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record TestAppriseProviderRequest
{
public string Url { get; init; } = string.Empty;
public string Key { get; init; } = string.Empty;
public string Tags { get; init; } = string.Empty;
}

View File

@@ -1,8 +0,0 @@
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record TestNotifiarrProviderRequest
{
public string ApiKey { get; init; } = string.Empty;
public string ChannelId { get; init; } = string.Empty;
}

View File

@@ -1,22 +0,0 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record TestNtfyProviderRequest
{
public string ServerUrl { get; init; } = string.Empty;
public List<string> Topics { get; init; } = [];
public NtfyAuthenticationType AuthenticationType { get; init; } = NtfyAuthenticationType.None;
public string Username { get; init; } = string.Empty;
public string Password { get; init; } = string.Empty;
public string AccessToken { get; init; } = string.Empty;
public NtfyPriority Priority { get; init; } = NtfyPriority.Default;
public List<string> Tags { get; init; } = [];
}

View File

@@ -1,10 +0,0 @@
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record UpdateAppriseProviderRequest : UpdateNotificationProviderRequestBase
{
public string Url { get; init; } = string.Empty;
public string Key { get; init; } = string.Empty;
public string Tags { get; init; } = string.Empty;
}

View File

@@ -1,8 +0,0 @@
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record UpdateNotifiarrProviderRequest : UpdateNotificationProviderRequestBase
{
public string ApiKey { get; init; } = string.Empty;
public string ChannelId { get; init; } = string.Empty;
}

View File

@@ -1,20 +0,0 @@
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public abstract record UpdateNotificationProviderRequestBase
{
public string Name { get; init; } = string.Empty;
public bool IsEnabled { get; init; }
public bool OnFailedImportStrike { get; init; }
public bool OnStalledStrike { get; init; }
public bool OnSlowStrike { get; init; }
public bool OnQueueItemDeleted { get; init; }
public bool OnDownloadCleaned { get; init; }
public bool OnCategoryChanged { get; init; }
}

View File

@@ -1,22 +0,0 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record UpdateNtfyProviderRequest : UpdateNotificationProviderRequestBase
{
public string ServerUrl { get; init; } = string.Empty;
public List<string> Topics { get; init; } = [];
public NtfyAuthenticationType AuthenticationType { get; init; } = NtfyAuthenticationType.None;
public string Username { get; init; } = string.Empty;
public string Password { get; init; } = string.Empty;
public string AccessToken { get; init; } = string.Empty;
public NtfyPriority Priority { get; init; } = NtfyPriority.Default;
public List<string> Tags { get; init; } = [];
}

View File

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

View File

@@ -1,6 +0,0 @@
namespace Cleanuparr.Api.Features.Notifications.Contracts.Responses;
public sealed record NotificationProvidersResponse
{
public List<NotificationProviderResponse> Providers { get; init; } = [];
}

View File

@@ -1,708 +0,0 @@
using Cleanuparr.Api.Features.Notifications.Contracts.Requests;
using Cleanuparr.Api.Features.Notifications.Contracts.Responses;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Api.Features.Notifications.Controllers;
[ApiController]
[Route("api/configuration/notification_providers")]
public sealed class NotificationProvidersController : ControllerBase
{
private readonly ILogger<NotificationProvidersController> _logger;
private readonly DataContext _dataContext;
private readonly INotificationConfigurationService _notificationConfigurationService;
private readonly NotificationService _notificationService;
public NotificationProvidersController(
ILogger<NotificationProvidersController> logger,
DataContext dataContext,
INotificationConfigurationService notificationConfigurationService,
NotificationService notificationService)
{
_logger = logger;
_dataContext = dataContext;
_notificationConfigurationService = notificationConfigurationService;
_notificationService = notificationService;
}
[HttpGet]
public async Task<IActionResult> GetNotificationProviders()
{
await DataContext.Lock.WaitAsync();
try
{
var providers = await _dataContext.NotificationConfigs
.Include(p => p.NotifiarrConfiguration)
.Include(p => p.AppriseConfiguration)
.Include(p => p.NtfyConfiguration)
.AsNoTracking()
.ToListAsync();
var providerDtos = providers
.Select(p => new NotificationProviderResponse
{
Id = p.Id,
Name = p.Name,
Type = p.Type,
IsEnabled = p.IsEnabled,
Events = new NotificationEventFlags
{
OnFailedImportStrike = p.OnFailedImportStrike,
OnStalledStrike = p.OnStalledStrike,
OnSlowStrike = p.OnSlowStrike,
OnQueueItemDeleted = p.OnQueueItemDeleted,
OnDownloadCleaned = p.OnDownloadCleaned,
OnCategoryChanged = p.OnCategoryChanged
},
Configuration = p.Type switch
{
NotificationProviderType.Notifiarr => p.NotifiarrConfiguration ?? new object(),
NotificationProviderType.Apprise => p.AppriseConfiguration ?? new object(),
NotificationProviderType.Ntfy => p.NtfyConfiguration ?? new object(),
_ => new object()
}
})
.OrderBy(x => x.Type.ToString())
.ThenBy(x => x.Name)
.ToList();
var response = new NotificationProvidersResponse { Providers = providerDtos };
return Ok(response);
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPost("notifiarr")]
public async Task<IActionResult> CreateNotifiarrProvider([FromBody] CreateNotifiarrProviderRequest newProvider)
{
await DataContext.Lock.WaitAsync();
try
{
if (string.IsNullOrWhiteSpace(newProvider.Name))
{
return BadRequest("Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs.CountAsync(x => x.Name == newProvider.Name);
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
}
var notifiarrConfig = new NotifiarrConfig
{
ApiKey = newProvider.ApiKey,
ChannelId = newProvider.ChannelId
};
notifiarrConfig.Validate();
var provider = new NotificationConfig
{
Name = newProvider.Name,
Type = NotificationProviderType.Notifiarr,
IsEnabled = newProvider.IsEnabled,
OnFailedImportStrike = newProvider.OnFailedImportStrike,
OnStalledStrike = newProvider.OnStalledStrike,
OnSlowStrike = newProvider.OnSlowStrike,
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
OnDownloadCleaned = newProvider.OnDownloadCleaned,
OnCategoryChanged = newProvider.OnCategoryChanged,
NotifiarrConfiguration = notifiarrConfig
};
_dataContext.NotificationConfigs.Add(provider);
await _dataContext.SaveChangesAsync();
await _notificationConfigurationService.InvalidateCacheAsync();
var providerDto = MapProvider(provider);
return CreatedAtAction(nameof(GetNotificationProviders), new { id = provider.Id }, providerDto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create Notifiarr provider");
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPost("apprise")]
public async Task<IActionResult> CreateAppriseProvider([FromBody] CreateAppriseProviderRequest newProvider)
{
await DataContext.Lock.WaitAsync();
try
{
if (string.IsNullOrWhiteSpace(newProvider.Name))
{
return BadRequest("Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs.CountAsync(x => x.Name == newProvider.Name);
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
}
var appriseConfig = new AppriseConfig
{
Url = newProvider.Url,
Key = newProvider.Key,
Tags = newProvider.Tags
};
appriseConfig.Validate();
var provider = new NotificationConfig
{
Name = newProvider.Name,
Type = NotificationProviderType.Apprise,
IsEnabled = newProvider.IsEnabled,
OnFailedImportStrike = newProvider.OnFailedImportStrike,
OnStalledStrike = newProvider.OnStalledStrike,
OnSlowStrike = newProvider.OnSlowStrike,
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
OnDownloadCleaned = newProvider.OnDownloadCleaned,
OnCategoryChanged = newProvider.OnCategoryChanged,
AppriseConfiguration = appriseConfig
};
_dataContext.NotificationConfigs.Add(provider);
await _dataContext.SaveChangesAsync();
await _notificationConfigurationService.InvalidateCacheAsync();
var providerDto = MapProvider(provider);
return CreatedAtAction(nameof(GetNotificationProviders), new { id = provider.Id }, providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create Apprise provider");
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPost("ntfy")]
public async Task<IActionResult> CreateNtfyProvider([FromBody] CreateNtfyProviderRequest newProvider)
{
await DataContext.Lock.WaitAsync();
try
{
if (string.IsNullOrWhiteSpace(newProvider.Name))
{
return BadRequest("Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs.CountAsync(x => x.Name == newProvider.Name);
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
}
var ntfyConfig = new NtfyConfig
{
ServerUrl = newProvider.ServerUrl,
Topics = newProvider.Topics,
AuthenticationType = newProvider.AuthenticationType,
Username = newProvider.Username,
Password = newProvider.Password,
AccessToken = newProvider.AccessToken,
Priority = newProvider.Priority,
Tags = newProvider.Tags
};
ntfyConfig.Validate();
var provider = new NotificationConfig
{
Name = newProvider.Name,
Type = NotificationProviderType.Ntfy,
IsEnabled = newProvider.IsEnabled,
OnFailedImportStrike = newProvider.OnFailedImportStrike,
OnStalledStrike = newProvider.OnStalledStrike,
OnSlowStrike = newProvider.OnSlowStrike,
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
OnDownloadCleaned = newProvider.OnDownloadCleaned,
OnCategoryChanged = newProvider.OnCategoryChanged,
NtfyConfiguration = ntfyConfig
};
_dataContext.NotificationConfigs.Add(provider);
await _dataContext.SaveChangesAsync();
await _notificationConfigurationService.InvalidateCacheAsync();
var providerDto = MapProvider(provider);
return CreatedAtAction(nameof(GetNotificationProviders), new { id = provider.Id }, providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create Ntfy provider");
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("notifiarr/{id:guid}")]
public async Task<IActionResult> UpdateNotifiarrProvider(Guid id, [FromBody] UpdateNotifiarrProviderRequest updatedProvider)
{
await DataContext.Lock.WaitAsync();
try
{
var existingProvider = await _dataContext.NotificationConfigs
.Include(p => p.NotifiarrConfiguration)
.FirstOrDefaultAsync(p => p.Id == id && p.Type == NotificationProviderType.Notifiarr);
if (existingProvider == null)
{
return NotFound($"Notifiarr provider with ID {id} not found");
}
if (string.IsNullOrWhiteSpace(updatedProvider.Name))
{
return BadRequest("Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs
.Where(x => x.Id != id)
.Where(x => x.Name == updatedProvider.Name)
.CountAsync();
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
}
var notifiarrConfig = new NotifiarrConfig
{
ApiKey = updatedProvider.ApiKey,
ChannelId = updatedProvider.ChannelId
};
if (existingProvider.NotifiarrConfiguration != null)
{
notifiarrConfig = notifiarrConfig with { Id = existingProvider.NotifiarrConfiguration.Id };
}
notifiarrConfig.Validate();
var newProvider = existingProvider with
{
Name = updatedProvider.Name,
IsEnabled = updatedProvider.IsEnabled,
OnFailedImportStrike = updatedProvider.OnFailedImportStrike,
OnStalledStrike = updatedProvider.OnStalledStrike,
OnSlowStrike = updatedProvider.OnSlowStrike,
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
OnCategoryChanged = updatedProvider.OnCategoryChanged,
NotifiarrConfiguration = notifiarrConfig,
UpdatedAt = DateTime.UtcNow
};
_dataContext.NotificationConfigs.Remove(existingProvider);
_dataContext.NotificationConfigs.Add(newProvider);
await _dataContext.SaveChangesAsync();
await _notificationConfigurationService.InvalidateCacheAsync();
var providerDto = MapProvider(newProvider);
return Ok(providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update Notifiarr provider with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("apprise/{id:guid}")]
public async Task<IActionResult> UpdateAppriseProvider(Guid id, [FromBody] UpdateAppriseProviderRequest updatedProvider)
{
await DataContext.Lock.WaitAsync();
try
{
var existingProvider = await _dataContext.NotificationConfigs
.Include(p => p.AppriseConfiguration)
.FirstOrDefaultAsync(p => p.Id == id && p.Type == NotificationProviderType.Apprise);
if (existingProvider == null)
{
return NotFound($"Apprise provider with ID {id} not found");
}
if (string.IsNullOrWhiteSpace(updatedProvider.Name))
{
return BadRequest("Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs
.Where(x => x.Id != id)
.Where(x => x.Name == updatedProvider.Name)
.CountAsync();
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
}
var appriseConfig = new AppriseConfig
{
Url = updatedProvider.Url,
Key = updatedProvider.Key,
Tags = updatedProvider.Tags
};
if (existingProvider.AppriseConfiguration != null)
{
appriseConfig = appriseConfig with { Id = existingProvider.AppriseConfiguration.Id };
}
appriseConfig.Validate();
var newProvider = existingProvider with
{
Name = updatedProvider.Name,
IsEnabled = updatedProvider.IsEnabled,
OnFailedImportStrike = updatedProvider.OnFailedImportStrike,
OnStalledStrike = updatedProvider.OnStalledStrike,
OnSlowStrike = updatedProvider.OnSlowStrike,
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
OnCategoryChanged = updatedProvider.OnCategoryChanged,
AppriseConfiguration = appriseConfig,
UpdatedAt = DateTime.UtcNow
};
_dataContext.NotificationConfigs.Remove(existingProvider);
_dataContext.NotificationConfigs.Add(newProvider);
await _dataContext.SaveChangesAsync();
await _notificationConfigurationService.InvalidateCacheAsync();
var providerDto = MapProvider(newProvider);
return Ok(providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update Apprise provider with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("ntfy/{id:guid}")]
public async Task<IActionResult> UpdateNtfyProvider(Guid id, [FromBody] UpdateNtfyProviderRequest updatedProvider)
{
await DataContext.Lock.WaitAsync();
try
{
var existingProvider = await _dataContext.NotificationConfigs
.Include(p => p.NtfyConfiguration)
.FirstOrDefaultAsync(p => p.Id == id && p.Type == NotificationProviderType.Ntfy);
if (existingProvider == null)
{
return NotFound($"Ntfy provider with ID {id} not found");
}
if (string.IsNullOrWhiteSpace(updatedProvider.Name))
{
return BadRequest("Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs
.Where(x => x.Id != id)
.Where(x => x.Name == updatedProvider.Name)
.CountAsync();
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
}
var ntfyConfig = new NtfyConfig
{
ServerUrl = updatedProvider.ServerUrl,
Topics = updatedProvider.Topics,
AuthenticationType = updatedProvider.AuthenticationType,
Username = updatedProvider.Username,
Password = updatedProvider.Password,
AccessToken = updatedProvider.AccessToken,
Priority = updatedProvider.Priority,
Tags = updatedProvider.Tags
};
if (existingProvider.NtfyConfiguration != null)
{
ntfyConfig = ntfyConfig with { Id = existingProvider.NtfyConfiguration.Id };
}
ntfyConfig.Validate();
var newProvider = existingProvider with
{
Name = updatedProvider.Name,
IsEnabled = updatedProvider.IsEnabled,
OnFailedImportStrike = updatedProvider.OnFailedImportStrike,
OnStalledStrike = updatedProvider.OnStalledStrike,
OnSlowStrike = updatedProvider.OnSlowStrike,
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
OnCategoryChanged = updatedProvider.OnCategoryChanged,
NtfyConfiguration = ntfyConfig,
UpdatedAt = DateTime.UtcNow
};
_dataContext.NotificationConfigs.Remove(existingProvider);
_dataContext.NotificationConfigs.Add(newProvider);
await _dataContext.SaveChangesAsync();
await _notificationConfigurationService.InvalidateCacheAsync();
var providerDto = MapProvider(newProvider);
return Ok(providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update Ntfy provider with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> DeleteNotificationProvider(Guid id)
{
await DataContext.Lock.WaitAsync();
try
{
var existingProvider = await _dataContext.NotificationConfigs
.Include(p => p.NotifiarrConfiguration)
.Include(p => p.AppriseConfiguration)
.Include(p => p.NtfyConfiguration)
.FirstOrDefaultAsync(p => p.Id == id);
if (existingProvider == null)
{
return NotFound($"Notification provider with ID {id} not found");
}
_dataContext.NotificationConfigs.Remove(existingProvider);
await _dataContext.SaveChangesAsync();
await _notificationConfigurationService.InvalidateCacheAsync();
_logger.LogInformation("Removed notification provider {ProviderName} with ID {ProviderId}",
existingProvider.Name, existingProvider.Id);
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete notification provider with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPost("notifiarr/test")]
public async Task<IActionResult> TestNotifiarrProvider([FromBody] TestNotifiarrProviderRequest testRequest)
{
try
{
var notifiarrConfig = new NotifiarrConfig
{
ApiKey = testRequest.ApiKey,
ChannelId = testRequest.ChannelId
};
notifiarrConfig.Validate();
var providerDto = new NotificationProviderDto
{
Id = Guid.NewGuid(),
Name = "Test Provider",
Type = NotificationProviderType.Notifiarr,
IsEnabled = true,
Events = new NotificationEventFlags
{
OnFailedImportStrike = true,
OnStalledStrike = false,
OnSlowStrike = false,
OnQueueItemDeleted = false,
OnDownloadCleaned = false,
OnCategoryChanged = false
},
Configuration = notifiarrConfig
};
await _notificationService.SendTestNotificationAsync(providerDto);
return Ok(new { Message = "Test notification sent successfully", Success = true });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Notifiarr provider");
throw;
}
}
[HttpPost("apprise/test")]
public async Task<IActionResult> TestAppriseProvider([FromBody] TestAppriseProviderRequest testRequest)
{
try
{
var appriseConfig = new AppriseConfig
{
Url = testRequest.Url,
Key = testRequest.Key,
Tags = testRequest.Tags
};
appriseConfig.Validate();
var providerDto = new NotificationProviderDto
{
Id = Guid.NewGuid(),
Name = "Test Provider",
Type = NotificationProviderType.Apprise,
IsEnabled = true,
Events = new NotificationEventFlags
{
OnFailedImportStrike = true,
OnStalledStrike = false,
OnSlowStrike = false,
OnQueueItemDeleted = false,
OnDownloadCleaned = false,
OnCategoryChanged = false
},
Configuration = appriseConfig
};
await _notificationService.SendTestNotificationAsync(providerDto);
return Ok(new { Message = "Test notification sent successfully", Success = true });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Apprise provider");
throw;
}
}
[HttpPost("ntfy/test")]
public async Task<IActionResult> TestNtfyProvider([FromBody] TestNtfyProviderRequest testRequest)
{
try
{
var ntfyConfig = new NtfyConfig
{
ServerUrl = testRequest.ServerUrl,
Topics = testRequest.Topics,
AuthenticationType = testRequest.AuthenticationType,
Username = testRequest.Username,
Password = testRequest.Password,
AccessToken = testRequest.AccessToken,
Priority = testRequest.Priority,
Tags = testRequest.Tags
};
ntfyConfig.Validate();
var providerDto = new NotificationProviderDto
{
Id = Guid.NewGuid(),
Name = "Test Provider",
Type = NotificationProviderType.Ntfy,
IsEnabled = true,
Events = new NotificationEventFlags
{
OnFailedImportStrike = true,
OnStalledStrike = false,
OnSlowStrike = false,
OnQueueItemDeleted = false,
OnDownloadCleaned = false,
OnCategoryChanged = false
},
Configuration = ntfyConfig
};
await _notificationService.SendTestNotificationAsync(providerDto);
return Ok(new { Message = "Test notification sent successfully", Success = true });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Ntfy provider");
throw;
}
}
private static NotificationProviderResponse MapProvider(NotificationConfig provider)
{
return new NotificationProviderResponse
{
Id = provider.Id,
Name = provider.Name,
Type = provider.Type,
IsEnabled = provider.IsEnabled,
Events = new NotificationEventFlags
{
OnFailedImportStrike = provider.OnFailedImportStrike,
OnStalledStrike = provider.OnStalledStrike,
OnSlowStrike = provider.OnSlowStrike,
OnQueueItemDeleted = provider.OnQueueItemDeleted,
OnDownloadCleaned = provider.OnDownloadCleaned,
OnCategoryChanged = provider.OnCategoryChanged
},
Configuration = provider.Type switch
{
NotificationProviderType.Notifiarr => provider.NotifiarrConfiguration ?? new object(),
NotificationProviderType.Apprise => provider.AppriseConfiguration ?? new object(),
NotificationProviderType.Ntfy => provider.NtfyConfiguration ?? new object(),
_ => new object()
}
};
}
}

View File

@@ -1,27 +0,0 @@
using System.ComponentModel.DataAnnotations;
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests;
public abstract record QueueRuleDto
{
public Guid? Id { get; set; }
[Required]
public string Name { get; set; } = string.Empty;
public bool Enabled { get; set; } = true;
[Range(3, int.MaxValue, ErrorMessage = "Max strikes must be at least 3")]
public int MaxStrikes { get; set; } = 3;
public TorrentPrivacyType PrivacyType { get; set; } = TorrentPrivacyType.Public;
[Range(0, 100, ErrorMessage = "Minimum completion percentage must be between 0 and 100")]
public ushort MinCompletionPercentage { get; set; }
[Range(0, 100, ErrorMessage = "Maximum completion percentage must be between 0 and 100")]
public ushort MaxCompletionPercentage { get; set; }
public bool DeletePrivateTorrentsFromClient { get; set; } = false;
}

View File

@@ -1,15 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests;
public sealed record SlowRuleDto : QueueRuleDto
{
public bool ResetStrikesOnProgress { get; set; } = true;
public string MinSpeed { get; set; } = string.Empty;
[Range(0, double.MaxValue, ErrorMessage = "Maximum time cannot be negative")]
public double MaxTimeHours { get; set; } = 0;
public string? IgnoreAboveSize { get; set; }
}

View File

@@ -1,8 +0,0 @@
namespace Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests;
public sealed record StallRuleDto : QueueRuleDto
{
public bool ResetStrikesOnProgress { get; set; } = true;
public string? MinimumProgress { get; set; }
}

View File

@@ -1,18 +0,0 @@
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
namespace Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests;
public sealed record UpdateQueueCleanerConfigRequest
{
public bool Enabled { get; init; }
public string CronExpression { get; init; } = "0 0/5 * * * ?";
public bool UseAdvancedScheduling { get; init; }
public FailedImportConfig FailedImport { get; init; } = new();
public ushort DownloadingMetadataMaxStrikes { get; init; }
public List<string> IgnoredDownloads { get; set; } = [];
}

View File

@@ -1,117 +0,0 @@
using System.ComponentModel.DataAnnotations;
using Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Infrastructure.Utilities;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Api.Features.QueueCleaner.Controllers;
[ApiController]
[Route("api/configuration")]
public sealed class QueueCleanerConfigController : ControllerBase
{
private readonly ILogger<QueueCleanerConfigController> _logger;
private readonly DataContext _dataContext;
private readonly IJobManagementService _jobManagementService;
public QueueCleanerConfigController(
ILogger<QueueCleanerConfigController> logger,
DataContext dataContext,
IJobManagementService jobManagementService)
{
_logger = logger;
_dataContext = dataContext;
_jobManagementService = jobManagementService;
}
[HttpGet("queue_cleaner")]
public async Task<IActionResult> GetQueueCleanerConfig()
{
await DataContext.Lock.WaitAsync();
try
{
var config = await _dataContext.QueueCleanerConfigs
.AsNoTracking()
.FirstAsync();
return Ok(config);
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("queue_cleaner")]
public async Task<IActionResult> UpdateQueueCleanerConfig([FromBody] UpdateQueueCleanerConfigRequest newConfigDto)
{
await DataContext.Lock.WaitAsync();
try
{
if (!string.IsNullOrEmpty(newConfigDto.CronExpression))
{
CronValidationHelper.ValidateCronExpression(newConfigDto.CronExpression);
}
newConfigDto.FailedImport.Validate();
var oldConfig = await _dataContext.QueueCleanerConfigs
.FirstAsync();
oldConfig.Enabled = newConfigDto.Enabled;
oldConfig.CronExpression = newConfigDto.CronExpression;
oldConfig.UseAdvancedScheduling = newConfigDto.UseAdvancedScheduling;
oldConfig.FailedImport = newConfigDto.FailedImport;
oldConfig.DownloadingMetadataMaxStrikes = newConfigDto.DownloadingMetadataMaxStrikes;
oldConfig.IgnoredDownloads = newConfigDto.IgnoredDownloads;
await _dataContext.SaveChangesAsync();
await UpdateJobSchedule(oldConfig, JobType.QueueCleaner);
return Ok(new { Message = "QueueCleaner configuration updated successfully" });
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save QueueCleaner configuration");
throw;
}
finally
{
DataContext.Lock.Release();
}
}
private async Task UpdateJobSchedule(IJobConfig config, JobType jobType)
{
if (config.Enabled)
{
if (!string.IsNullOrEmpty(config.CronExpression))
{
_logger.LogInformation("{name} is enabled, updating job schedule with cron expression: {CronExpression}",
jobType.ToString(), config.CronExpression);
await _jobManagementService.StartJob(jobType, null, config.CronExpression);
}
else
{
_logger.LogWarning("{name} is enabled, but no cron expression was found in the configuration", jobType.ToString());
}
return;
}
_logger.LogInformation("{name} is disabled, stopping the job", jobType.ToString());
await _jobManagementService.StopJob(jobType);
}
}

View File

@@ -1,437 +0,0 @@
using Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Api.Features.QueueCleaner.Controllers;
[ApiController]
[Route("api/queue-rules")]
public class QueueRulesController : ControllerBase
{
private readonly ILogger<QueueRulesController> _logger;
private readonly DataContext _dataContext;
private readonly IRuleIntervalValidator _ruleIntervalValidator;
public QueueRulesController(
ILogger<QueueRulesController> logger,
DataContext dataContext,
IRuleIntervalValidator ruleIntervalValidator)
{
_logger = logger;
_dataContext = dataContext;
_ruleIntervalValidator = ruleIntervalValidator;
}
[HttpGet("stall")]
public async Task<IActionResult> GetStallRules()
{
await DataContext.Lock.WaitAsync();
try
{
var rules = await _dataContext.StallRules
.OrderBy(r => r.MinCompletionPercentage)
.ThenBy(r => r.Name)
.AsNoTracking()
.ToListAsync();
return Ok(rules);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to retrieve stall rules");
return StatusCode(500, new { Message = "Failed to retrieve stall rules", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPost("stall")]
public async Task<IActionResult> CreateStallRule([FromBody] StallRuleDto ruleDto)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
await DataContext.Lock.WaitAsync();
try
{
var queueCleanerConfig = await _dataContext.QueueCleanerConfigs
.FirstAsync();
var existingRule = await _dataContext.StallRules
.FirstOrDefaultAsync(r => r.Name.ToLower() == ruleDto.Name.ToLower());
if (existingRule != null)
{
return BadRequest(new { Message = "A stall rule with this name already exists" });
}
var rule = new StallRule
{
Id = Guid.NewGuid(),
QueueCleanerConfigId = queueCleanerConfig.Id,
Name = ruleDto.Name.Trim(),
Enabled = ruleDto.Enabled,
MaxStrikes = ruleDto.MaxStrikes,
PrivacyType = ruleDto.PrivacyType,
MinCompletionPercentage = ruleDto.MinCompletionPercentage,
MaxCompletionPercentage = ruleDto.MaxCompletionPercentage,
ResetStrikesOnProgress = ruleDto.ResetStrikesOnProgress,
DeletePrivateTorrentsFromClient = ruleDto.DeletePrivateTorrentsFromClient,
MinimumProgress = ruleDto.MinimumProgress?.Trim(),
};
var existingRules = await _dataContext.StallRules.ToListAsync();
var intervalValidationResult = _ruleIntervalValidator.ValidateStallRuleIntervals(rule, existingRules);
if (!intervalValidationResult.IsValid)
{
return BadRequest(new { Message = intervalValidationResult.ErrorMessage });
}
rule.Validate();
_dataContext.StallRules.Add(rule);
await _dataContext.SaveChangesAsync();
_logger.LogInformation("Created stall rule: {RuleName} with ID: {RuleId}", rule.Name, rule.Id);
return CreatedAtAction(nameof(GetStallRules), new { id = rule.Id }, rule);
}
catch (ValidationException ex)
{
_logger.LogWarning("Validation failed for stall rule creation: {Message}", ex.Message);
return BadRequest(new { Message = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create stall rule: {RuleName}", ruleDto.Name);
return StatusCode(500, new { Message = "Failed to create stall rule", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("stall/{id}")]
public async Task<IActionResult> UpdateStallRule(Guid id, [FromBody] StallRuleDto ruleDto)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
await DataContext.Lock.WaitAsync();
try
{
var existingRule = await _dataContext.StallRules
.FirstOrDefaultAsync(r => r.Id == id);
if (existingRule == null)
{
return NotFound(new { Message = $"Stall rule with ID {id} not found" });
}
var duplicateRule = await _dataContext.StallRules
.FirstOrDefaultAsync(r => r.Id != id && r.Name.ToLower() == ruleDto.Name.ToLower());
if (duplicateRule != null)
{
return BadRequest(new { Message = "A stall rule with this name already exists" });
}
var updatedRule = existingRule with
{
Name = ruleDto.Name.Trim(),
Enabled = ruleDto.Enabled,
MaxStrikes = ruleDto.MaxStrikes,
PrivacyType = ruleDto.PrivacyType,
MinCompletionPercentage = ruleDto.MinCompletionPercentage,
MaxCompletionPercentage = ruleDto.MaxCompletionPercentage,
ResetStrikesOnProgress = ruleDto.ResetStrikesOnProgress,
DeletePrivateTorrentsFromClient = ruleDto.DeletePrivateTorrentsFromClient,
MinimumProgress = ruleDto.MinimumProgress?.Trim(),
};
var existingRules = await _dataContext.StallRules
.Where(r => r.Id != id)
.ToListAsync();
var intervalValidationResult = _ruleIntervalValidator.ValidateStallRuleIntervals(updatedRule, existingRules);
if (!intervalValidationResult.IsValid)
{
return BadRequest(new { Message = intervalValidationResult.ErrorMessage });
}
updatedRule.Validate();
_dataContext.Entry(existingRule).CurrentValues.SetValues(updatedRule);
await _dataContext.SaveChangesAsync();
_logger.LogInformation("Updated stall rule: {RuleName} with ID: {RuleId}", updatedRule.Name, id);
return Ok(updatedRule);
}
catch (ValidationException ex)
{
_logger.LogWarning("Validation failed for stall rule update: {Message}", ex.Message);
return BadRequest(new { Message = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update stall rule with ID: {RuleId}", id);
return StatusCode(500, new { Message = "Failed to update stall rule", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
}
}
[HttpDelete("stall/{id}")]
public async Task<IActionResult> DeleteStallRule(Guid id)
{
await DataContext.Lock.WaitAsync();
try
{
var existingRule = await _dataContext.StallRules
.FirstOrDefaultAsync(r => r.Id == id);
if (existingRule == null)
{
return NotFound(new { Message = $"Stall rule with ID {id} not found" });
}
_dataContext.StallRules.Remove(existingRule);
await _dataContext.SaveChangesAsync();
_logger.LogInformation("Deleted stall rule: {RuleName} with ID: {RuleId}", existingRule.Name, id);
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete stall rule with ID: {RuleId}", id);
return StatusCode(500, new { Message = "Failed to delete stall rule", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
}
}
[HttpGet("slow")]
public async Task<IActionResult> GetSlowRules()
{
await DataContext.Lock.WaitAsync();
try
{
var rules = await _dataContext.SlowRules
.OrderBy(r => r.MinCompletionPercentage)
.ThenBy(r => r.Name)
.AsNoTracking()
.ToListAsync();
return Ok(rules);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to retrieve slow rules");
return StatusCode(500, new { Message = "Failed to retrieve slow rules", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPost("slow")]
public async Task<IActionResult> CreateSlowRule([FromBody] SlowRuleDto ruleDto)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
await DataContext.Lock.WaitAsync();
try
{
var queueCleanerConfig = await _dataContext.QueueCleanerConfigs
.FirstAsync();
var existingRule = await _dataContext.SlowRules
.FirstOrDefaultAsync(r => r.Name.ToLower() == ruleDto.Name.ToLower());
if (existingRule != null)
{
return BadRequest(new { Message = "A slow rule with this name already exists" });
}
var rule = new SlowRule
{
Id = Guid.NewGuid(),
QueueCleanerConfigId = queueCleanerConfig.Id,
Name = ruleDto.Name.Trim(),
Enabled = ruleDto.Enabled,
MaxStrikes = ruleDto.MaxStrikes,
PrivacyType = ruleDto.PrivacyType,
MinCompletionPercentage = ruleDto.MinCompletionPercentage,
MaxCompletionPercentage = ruleDto.MaxCompletionPercentage,
ResetStrikesOnProgress = ruleDto.ResetStrikesOnProgress,
MinSpeed = ruleDto.MinSpeed?.Trim() ?? string.Empty,
MaxTimeHours = ruleDto.MaxTimeHours,
IgnoreAboveSize = ruleDto.IgnoreAboveSize,
DeletePrivateTorrentsFromClient = ruleDto.DeletePrivateTorrentsFromClient,
};
var existingRules = await _dataContext.SlowRules.ToListAsync();
var intervalValidationResult = _ruleIntervalValidator.ValidateSlowRuleIntervals(rule, existingRules);
if (!intervalValidationResult.IsValid)
{
return BadRequest(new { Message = intervalValidationResult.ErrorMessage });
}
rule.Validate();
_dataContext.SlowRules.Add(rule);
await _dataContext.SaveChangesAsync();
_logger.LogInformation("Created slow rule: {RuleName} with ID: {RuleId}", rule.Name, rule.Id);
return CreatedAtAction(nameof(GetSlowRules), new { id = rule.Id }, rule);
}
catch (ValidationException ex)
{
_logger.LogWarning("Validation failed for slow rule creation: {Message}", ex.Message);
return BadRequest(new { Message = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create slow rule: {RuleName}", ruleDto.Name);
return StatusCode(500, new { Message = "Failed to create slow rule", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("slow/{id}")]
public async Task<IActionResult> UpdateSlowRule(Guid id, [FromBody] SlowRuleDto ruleDto)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
await DataContext.Lock.WaitAsync();
try
{
var existingRule = await _dataContext.SlowRules
.FirstOrDefaultAsync(r => r.Id == id);
if (existingRule == null)
{
return NotFound(new { Message = $"Slow rule with ID {id} not found" });
}
var duplicateRule = await _dataContext.SlowRules
.FirstOrDefaultAsync(r => r.Id != id && r.Name.ToLower() == ruleDto.Name.ToLower());
if (duplicateRule != null)
{
return BadRequest(new { Message = "A slow rule with this name already exists" });
}
var updatedRule = existingRule with
{
Name = ruleDto.Name.Trim(),
Enabled = ruleDto.Enabled,
MaxStrikes = ruleDto.MaxStrikes,
PrivacyType = ruleDto.PrivacyType,
MinCompletionPercentage = ruleDto.MinCompletionPercentage,
MaxCompletionPercentage = ruleDto.MaxCompletionPercentage,
ResetStrikesOnProgress = ruleDto.ResetStrikesOnProgress,
MinSpeed = ruleDto.MinSpeed?.Trim() ?? string.Empty,
MaxTimeHours = ruleDto.MaxTimeHours,
IgnoreAboveSize = ruleDto.IgnoreAboveSize,
DeletePrivateTorrentsFromClient = ruleDto.DeletePrivateTorrentsFromClient,
};
var existingRules = await _dataContext.SlowRules
.Where(r => r.Id != id)
.ToListAsync();
var intervalValidationResult = _ruleIntervalValidator.ValidateSlowRuleIntervals(updatedRule, existingRules);
if (!intervalValidationResult.IsValid)
{
return BadRequest(new { Message = intervalValidationResult.ErrorMessage });
}
updatedRule.Validate();
_dataContext.Entry(existingRule).CurrentValues.SetValues(updatedRule);
await _dataContext.SaveChangesAsync();
_logger.LogInformation("Updated slow rule: {RuleName} with ID: {RuleId}", updatedRule.Name, id);
return Ok(updatedRule);
}
catch (ValidationException ex)
{
_logger.LogWarning("Validation failed for slow rule update: {Message}", ex.Message);
return BadRequest(new { Message = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update slow rule with ID: {RuleId}", id);
return StatusCode(500, new { Message = "Failed to update slow rule", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
}
}
[HttpDelete("slow/{id}")]
public async Task<IActionResult> DeleteSlowRule(Guid id)
{
await DataContext.Lock.WaitAsync();
try
{
var existingRule = await _dataContext.SlowRules
.FirstOrDefaultAsync(r => r.Id == id);
if (existingRule == null)
{
return NotFound(new { Message = $"Slow rule with ID {id} not found" });
}
_dataContext.SlowRules.Remove(existingRule);
await _dataContext.SaveChangesAsync();
_logger.LogInformation("Deleted slow rule: {RuleName} with ID: {RuleId}", existingRule.Name, id);
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete slow rule with ID: {RuleId}", id);
return StatusCode(500, new { Message = "Failed to delete slow rule", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
}
}
}

View File

@@ -1,7 +1,4 @@
using System.Reflection;
using Cleanuparr.Infrastructure.Health;
using Cleanuparr.Infrastructure.Logging;
using Cleanuparr.Infrastructure.Services;
using Cleanuparr.Persistence;
using Microsoft.EntityFrameworkCore;
@@ -9,61 +6,36 @@ namespace Cleanuparr.Api;
public static class HostExtensions
{
public static IHost Init(this WebApplication app)
public static async Task<IHost> Init(this WebApplication app)
{
ILogger<Program> logger = app.Services.GetRequiredService<ILogger<Program>>();
AppStatusSnapshot statusSnapshot = app.Services.GetRequiredService<AppStatusSnapshot>();
Version? version = Assembly.GetExecutingAssembly().GetName().Version;
string? formattedVersion = FormatVersion(version);
if (statusSnapshot.UpdateCurrentVersion(formattedVersion, out _))
{
logger.LogDebug("App status current version set to {Version}", formattedVersion);
}
logger.LogInformation(
version is null
? "Cleanuparr version not detected"
: $"Cleanuparr {formattedVersion}"
: $"Cleanuparr v{version.Major}.{version.Minor}.{version.Build}"
);
logger.LogInformation("timezone: {tz}", TimeZoneInfo.Local.DisplayName);
return app;
}
private static string? FormatVersion(Version? version)
{
if (version is null)
{
return null;
}
if (version.Build >= 0)
{
return $"v{version.Major}.{version.Minor}.{version.Build}";
}
return $"v{version.Major}.{version.Minor}";
}
public static async Task<WebApplicationBuilder> InitAsync(this WebApplicationBuilder builder)
{
// Apply events db migrations
await using var eventsContext = EventsContext.CreateStaticInstance();
// Apply db migrations
var scopeFactory = app.Services.GetRequiredService<IServiceScopeFactory>();
await using var scope = scopeFactory.CreateAsyncScope();
await using var eventsContext = scope.ServiceProvider.GetRequiredService<EventsContext>();
if ((await eventsContext.Database.GetPendingMigrationsAsync()).Any())
{
await eventsContext.Database.MigrateAsync();
}
// Apply data db migrations
await using var configContext = DataContext.CreateStaticInstance();
await using var configContext = scope.ServiceProvider.GetRequiredService<DataContext>();
if ((await configContext.Database.GetPendingMigrationsAsync()).Any())
{
await configContext.Database.MigrateAsync();
}
return builder;
return app;
}
}

View File

@@ -1,11 +1,13 @@
using Cleanuparr.Application.Features.DownloadCleaner;
using Cleanuparr.Application.Features.MalwareBlocker;
using Cleanuparr.Application.Features.QueueCleaner;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Features.BlacklistSync;
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
using Cleanuparr.Shared.Helpers;
using Microsoft.EntityFrameworkCore;
using Quartz;
@@ -43,12 +45,12 @@ public class BackgroundJobManager : IHostedService
{
try
{
_logger.LogDebug("Starting BackgroundJobManager");
_logger.LogInformation("Starting BackgroundJobManager");
_scheduler = await _schedulerFactory.GetScheduler(cancellationToken);
await InitializeJobsFromConfiguration(cancellationToken);
_logger.LogDebug("BackgroundJobManager started");
_logger.LogInformation("BackgroundJobManager started");
}
catch (Exception ex)
{
@@ -62,15 +64,15 @@ public class BackgroundJobManager : IHostedService
/// </summary>
public async Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogDebug("Stopping BackgroundJobManager");
_logger.LogInformation("Stopping BackgroundJobManager");
if (_scheduler != null)
{
// Don't shut down the scheduler as it's managed by QuartzHostedService
// Don't shutdown the scheduler as it's managed by QuartzHostedService
await _scheduler.Standby(cancellationToken);
}
_logger.LogDebug("BackgroundJobManager stopped");
_logger.LogInformation("BackgroundJobManager stopped");
}
/// <summary>
@@ -84,6 +86,7 @@ public class BackgroundJobManager : IHostedService
throw new InvalidOperationException("Scheduler not initialized");
}
// Use scoped DataContext to prevent memory leaks
await using var scope = _scopeFactory.CreateAsyncScope();
await using var dataContext = scope.ServiceProvider.GetRequiredService<DataContext>();
@@ -97,15 +100,11 @@ public class BackgroundJobManager : IHostedService
DownloadCleanerConfig downloadCleanerConfig = await dataContext.DownloadCleanerConfigs
.AsNoTracking()
.FirstAsync(cancellationToken);
BlacklistSyncConfig blacklistSyncConfig = await dataContext.BlacklistSyncConfigs
.AsNoTracking()
.FirstAsync(cancellationToken);
// Always register jobs, regardless of enabled status
await RegisterQueueCleanerJob(queueCleanerConfig, cancellationToken);
await RegisterMalwareBlockerJob(malwareBlockerConfig, cancellationToken);
await RegisterDownloadCleanerJob(downloadCleanerConfig, cancellationToken);
await RegisterBlacklistSyncJob(blacklistSyncConfig, cancellationToken);
}
/// <summary>
@@ -121,7 +120,7 @@ public class BackgroundJobManager : IHostedService
// Only add triggers if the job is enabled
if (config.Enabled)
{
await AddTriggersForJob<QueueCleaner>(config.CronExpression, cancellationToken);
await AddTriggersForJob<QueueCleaner>(config, config.CronExpression, cancellationToken);
}
}
@@ -138,7 +137,7 @@ public class BackgroundJobManager : IHostedService
// Only add triggers if the job is enabled
if (config.Enabled)
{
await AddTriggersForJob<MalwareBlocker>(config.CronExpression, cancellationToken);
await AddTriggersForJob<MalwareBlocker>(config, config.CronExpression, cancellationToken);
}
}
@@ -153,21 +152,7 @@ public class BackgroundJobManager : IHostedService
// Only add triggers if the job is enabled
if (config.Enabled)
{
await AddTriggersForJob<DownloadCleaner>(config.CronExpression, cancellationToken);
}
}
/// <summary>
/// Registers the BlacklistSync job and optionally adds triggers based on general configuration.
/// </summary>
public async Task RegisterBlacklistSyncJob(BlacklistSyncConfig config, CancellationToken cancellationToken = default)
{
// Always register the job definition
await AddJobWithoutTrigger<BlacklistSynchronizer>(cancellationToken);
if (config.Enabled)
{
await AddTriggersForJob<BlacklistSynchronizer>(config.CronExpression, cancellationToken);
await AddTriggersForJob<DownloadCleaner>(config, config.CronExpression, cancellationToken);
}
}
@@ -175,9 +160,10 @@ public class BackgroundJobManager : IHostedService
/// Helper method to add triggers for an existing job.
/// </summary>
private async Task AddTriggersForJob<T>(
IJobConfig config,
string cronExpression,
CancellationToken cancellationToken = default)
where T : IHandler
where T : GenericHandler
{
if (_scheduler == null)
{
@@ -225,7 +211,16 @@ public class BackgroundJobManager : IHostedService
// Schedule the main trigger
await _scheduler.ScheduleJob(trigger, cancellationToken);
_logger.LogInformation("Added trigger for job {name} with cron expression {CronExpression}",
// Trigger immediate execution for startup using a one-time trigger
var startupTrigger = TriggerBuilder.Create()
.WithIdentity($"{typeName}-startup-{DateTimeOffset.UtcNow.Ticks}")
.ForJob(jobKey)
.StartNow()
.Build();
await _scheduler.ScheduleJob(startupTrigger, cancellationToken);
_logger.LogInformation("Added trigger for job {name} with cron expression {CronExpression} and immediate startup execution",
typeName, cronExpression);
}
@@ -233,7 +228,7 @@ public class BackgroundJobManager : IHostedService
/// Helper method to add a job without a trigger (for chained jobs).
/// </summary>
private async Task AddJobWithoutTrigger<T>(CancellationToken cancellationToken = default)
where T : IHandler
where T : GenericHandler
{
if (_scheduler == null)
{
@@ -259,6 +254,6 @@ public class BackgroundJobManager : IHostedService
// Add job to scheduler
await _scheduler.AddJob(jobDetail, true, cancellationToken);
_logger.LogDebug("Registered job {name} without trigger", typeName);
_logger.LogInformation("Registered job {name} without trigger", typeName);
}
}

View File

@@ -1,8 +1,4 @@
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Infrastructure.Hubs;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Microsoft.AspNetCore.SignalR;
using Quartz;
using Serilog.Context;
@@ -28,39 +24,12 @@ public sealed class GenericJob<T> : IJob
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
var hubContext = scope.ServiceProvider.GetRequiredService<IHubContext<AppHub>>();
var jobManagementService = scope.ServiceProvider.GetRequiredService<IJobManagementService>();
await BroadcastJobStatus(hubContext, jobManagementService, false);
var handler = scope.ServiceProvider.GetRequiredService<T>();
await handler.ExecuteAsync();
await BroadcastJobStatus(hubContext, jobManagementService, true);
}
catch (Exception ex)
{
_logger.LogError(ex, "{name} failed", typeof(T).Name);
}
}
private async Task BroadcastJobStatus(IHubContext<AppHub> hubContext, IJobManagementService jobManagementService, bool isFinished)
{
try
{
JobType jobType = Enum.Parse<JobType>(typeof(T).Name);
JobInfo jobInfo = await jobManagementService.GetJob(jobType);
if (isFinished)
{
jobInfo.Status = "Scheduled";
}
await hubContext.Clients.All.SendAsync("JobStatusUpdate", jobInfo);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to broadcast job status update");
}
}
}

View File

@@ -1,8 +0,0 @@
using System;
namespace Cleanuparr.Api.Models.NotificationProviders;
using CreateAppriseProviderRequest = Cleanuparr.Api.Features.Notifications.Contracts.Requests.CreateAppriseProviderRequest;
[Obsolete("Use Cleanuparr.Api.Features.Notifications.Contracts.Requests.CreateAppriseProviderRequest instead.")]
public sealed record CreateAppriseProviderDto : CreateAppriseProviderRequest;

View File

@@ -1,8 +0,0 @@
using System;
namespace Cleanuparr.Api.Models.NotificationProviders;
using CreateNotifiarrProviderRequest = Cleanuparr.Api.Features.Notifications.Contracts.Requests.CreateNotifiarrProviderRequest;
[Obsolete("Use Cleanuparr.Api.Features.Notifications.Contracts.Requests.CreateNotifiarrProviderRequest instead.")]
public sealed record CreateNotifiarrProviderDto : CreateNotifiarrProviderRequest;

View File

@@ -1,8 +0,0 @@
using System;
namespace Cleanuparr.Api.Models.NotificationProviders;
using CreateNtfyProviderRequest = Cleanuparr.Api.Features.Notifications.Contracts.Requests.CreateNtfyProviderRequest;
[Obsolete("Use Cleanuparr.Api.Features.Notifications.Contracts.Requests.CreateNtfyProviderRequest instead.")]
public sealed record CreateNtfyProviderDto : CreateNtfyProviderRequest;

View File

@@ -1,8 +0,0 @@
using System;
namespace Cleanuparr.Api.Models.NotificationProviders;
using TestAppriseProviderRequest = Cleanuparr.Api.Features.Notifications.Contracts.Requests.TestAppriseProviderRequest;
[Obsolete("Use Cleanuparr.Api.Features.Notifications.Contracts.Requests.TestAppriseProviderRequest instead.")]
public sealed record TestAppriseProviderDto : TestAppriseProviderRequest;

View File

@@ -1,8 +0,0 @@
using System;
namespace Cleanuparr.Api.Models.NotificationProviders;
using TestNotifiarrProviderRequest = Cleanuparr.Api.Features.Notifications.Contracts.Requests.TestNotifiarrProviderRequest;
[Obsolete("Use Cleanuparr.Api.Features.Notifications.Contracts.Requests.TestNotifiarrProviderRequest instead.")]
public sealed record TestNotifiarrProviderDto : TestNotifiarrProviderRequest;

View File

@@ -1,8 +0,0 @@
using System;
namespace Cleanuparr.Api.Models.NotificationProviders;
using TestNtfyProviderRequest = Cleanuparr.Api.Features.Notifications.Contracts.Requests.TestNtfyProviderRequest;
[Obsolete("Use Cleanuparr.Api.Features.Notifications.Contracts.Requests.TestNtfyProviderRequest instead.")]
public sealed record TestNtfyProviderDto : TestNtfyProviderRequest;

View File

@@ -1,8 +0,0 @@
using System;
namespace Cleanuparr.Api.Models.NotificationProviders;
using UpdateAppriseProviderRequest = Cleanuparr.Api.Features.Notifications.Contracts.Requests.UpdateAppriseProviderRequest;
[Obsolete("Use Cleanuparr.Api.Features.Notifications.Contracts.Requests.UpdateAppriseProviderRequest instead.")]
public sealed record UpdateAppriseProviderDto : UpdateAppriseProviderRequest;

View File

@@ -1,8 +0,0 @@
using System;
namespace Cleanuparr.Api.Models.NotificationProviders;
using UpdateNotifiarrProviderRequest = Cleanuparr.Api.Features.Notifications.Contracts.Requests.UpdateNotifiarrProviderRequest;
[Obsolete("Use Cleanuparr.Api.Features.Notifications.Contracts.Requests.UpdateNotifiarrProviderRequest instead.")]
public sealed record UpdateNotifiarrProviderDto : UpdateNotifiarrProviderRequest;

View File

@@ -1,8 +0,0 @@
using System;
namespace Cleanuparr.Api.Models.NotificationProviders;
using UpdateNtfyProviderRequest = Cleanuparr.Api.Features.Notifications.Contracts.Requests.UpdateNtfyProviderRequest;
[Obsolete("Use Cleanuparr.Api.Features.Notifications.Contracts.Requests.UpdateNtfyProviderRequest instead.")]
public sealed record UpdateNtfyProviderDto : UpdateNtfyProviderRequest;

View File

@@ -1 +0,0 @@
// Moved to Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests

View File

@@ -1 +0,0 @@
// Moved to Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests

View File

@@ -1 +0,0 @@
// Moved to Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests

View File

@@ -1,23 +1,53 @@
using System.Diagnostics.CodeAnalysis;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
using System.ComponentModel.DataAnnotations;
namespace Cleanuparr.Api.Models;
/// <summary>
/// Legacy namespace shim; prefer <see cref="UpdateDownloadCleanerConfigRequest"/> from
/// <c>Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests</c>.
/// </summary>
[Obsolete("Use Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests.UpdateDownloadCleanerConfigRequest instead")]
[SuppressMessage("Design", "CA1000", Justification = "Temporary alias during refactor")]
[SuppressMessage("Usage", "CA2225", Justification = "Alias type")]
public record UpdateDownloadCleanerConfigDto : UpdateDownloadCleanerConfigRequest;
public class UpdateDownloadCleanerConfigDto
{
public bool Enabled { get; set; }
/// <summary>
/// Legacy namespace shim; prefer <see cref="CleanCategoryRequest"/> from
/// <c>Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests</c>.
/// </summary>
[Obsolete("Use Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests.CleanCategoryRequest instead")]
[SuppressMessage("Design", "CA1000", Justification = "Temporary alias during refactor")]
[SuppressMessage("Usage", "CA2225", Justification = "Alias type")]
public record CleanCategoryDto : CleanCategoryRequest;
public string CronExpression { get; set; } = "0 0 * * * ?";
/// <summary>
/// Indicates whether to use the CronExpression directly or convert from a user-friendly schedule
/// </summary>
public bool UseAdvancedScheduling { get; set; }
public List<CleanCategoryDto> Categories { get; set; } = [];
public bool DeletePrivate { get; set; }
/// <summary>
/// Indicates whether unlinked download handling is enabled
/// </summary>
public bool UnlinkedEnabled { get; set; } = false;
public string UnlinkedTargetCategory { get; set; } = "cleanuparr-unlinked";
public bool UnlinkedUseTag { get; set; }
public string UnlinkedIgnoredRootDir { get; set; } = string.Empty;
public List<string> UnlinkedCategories { get; set; } = [];
}
public class CleanCategoryDto
{
[Required]
public string Name { get; set; } = string.Empty;
/// <summary>
/// Max ratio before removing a download.
/// </summary>
public double MaxRatio { get; set; } = -1;
/// <summary>
/// Min number of hours to seed before removing a download, if the ratio has been met.
/// </summary>
public double MinSeedTime { get; set; }
/// <summary>
/// Number of hours to seed before removing a download.
/// </summary>
public double MaxSeedTime { get; set; } = -1;
}

View File

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

View File

@@ -1,3 +0,0 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Cleanuparr.Api.Tests")]

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Cleanuparr.Domain\Cleanuparr.Domain.csproj" />
<ProjectReference Include="..\Cleanuparr.Infrastructure\Cleanuparr.Infrastructure.csproj" />
<ProjectReference Include="..\Cleanuparr.Persistence\Cleanuparr.Persistence.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MassTransit" Version="8.4.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.6" />
</ItemGroup>
</Project>

View File

@@ -1,6 +1,6 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Infrastructure.Features.Arr.Dtos;
namespace Cleanuparr.Application.Features.Arr.Dtos;
public class ArrConfigDto
{

View File

@@ -1,6 +1,6 @@
using System.ComponentModel.DataAnnotations;
namespace Cleanuparr.Infrastructure.Features.Arr.Dtos;
namespace Cleanuparr.Application.Features.Arr.Dtos;
/// <summary>
/// DTO for creating new Arr instances without requiring an ID

View File

@@ -1,4 +1,4 @@
namespace Cleanuparr.Infrastructure.Features.Arr.Dtos;
namespace Cleanuparr.Application.Features.Arr.Dtos;
/// <summary>
/// DTO for updating Lidarr configuration basic settings (instances managed separately)

View File

@@ -1,4 +1,4 @@
namespace Cleanuparr.Infrastructure.Features.Arr.Dtos;
namespace Cleanuparr.Application.Features.Arr.Dtos;
/// <summary>
/// DTO for updating Radarr configuration basic settings (instances managed separately)

View File

@@ -1,4 +1,4 @@
namespace Cleanuparr.Infrastructure.Features.Arr.Dtos;
namespace Cleanuparr.Application.Features.Arr.Dtos;
/// <summary>
/// DTO for updating Readarr configuration basic settings (instances managed separately)

View File

@@ -1,6 +1,6 @@
using System.ComponentModel.DataAnnotations;
namespace Cleanuparr.Infrastructure.Features.Arr.Dtos;
namespace Cleanuparr.Application.Features.Arr.Dtos;
/// <summary>
/// DTO for updating Sonarr configuration basic settings (instances managed separately)

View File

@@ -1,4 +1,4 @@
namespace Cleanuparr.Infrastructure.Features.Arr.Dtos;
namespace Cleanuparr.Application.Features.Arr.Dtos;
/// <summary>
/// DTO for updating Whisparr configuration basic settings (instances managed separately)

View File

@@ -1,162 +0,0 @@
using System.Security.Cryptography;
using System.Text;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
using Cleanuparr.Persistence.Models.State;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Application.Features.BlacklistSync;
public sealed class BlacklistSynchronizer : IHandler
{
private readonly ILogger<BlacklistSynchronizer> _logger;
private readonly DataContext _dataContext;
private readonly DownloadServiceFactory _downloadServiceFactory;
private readonly FileReader _fileReader;
private readonly IDryRunInterceptor _dryRunInterceptor;
public BlacklistSynchronizer(
ILogger<BlacklistSynchronizer> logger,
DataContext dataContext,
DownloadServiceFactory downloadServiceFactory,
FileReader fileReader,
IDryRunInterceptor dryRunInterceptor
)
{
_logger = logger;
_dataContext = dataContext;
_downloadServiceFactory = downloadServiceFactory;
_fileReader = fileReader;
_dryRunInterceptor = dryRunInterceptor;
}
public async Task ExecuteAsync()
{
BlacklistSyncConfig config = await _dataContext.BlacklistSyncConfigs
.AsNoTracking()
.FirstAsync();
if (!config.Enabled)
{
_logger.LogDebug("Blacklist sync is disabled");
return;
}
if (string.IsNullOrWhiteSpace(config.BlacklistPath))
{
_logger.LogWarning("Blacklist sync path is not configured");
return;
}
string[] patterns = await _fileReader.ReadContentAsync(config.BlacklistPath);
string excludedFileNames = string.Join('\n', patterns.Where(p => !string.IsNullOrWhiteSpace(p)));
string currentHash = ComputeHash(excludedFileNames);
await _dryRunInterceptor.InterceptAsync(SyncBlacklist, currentHash, excludedFileNames);
await _dryRunInterceptor.InterceptAsync(RemoveOldSyncDataAsync, currentHash);
_logger.LogDebug("Blacklist synchronization completed");
}
private async Task SyncBlacklist(string currentHash, string excludedFileNames)
{
List<DownloadClientConfig> qBittorrentClients = await _dataContext.DownloadClients
.AsNoTracking()
.Where(c => c.Enabled && c.TypeName == DownloadClientTypeName.qBittorrent)
.ToListAsync();
if (qBittorrentClients.Count is 0)
{
_logger.LogDebug("No enabled qBittorrent clients found for blacklist sync");
return;
}
_logger.LogDebug("Starting blacklist synchronization for {Count} qBittorrent clients", qBittorrentClients.Count);
// Pull existing sync history for this hash
var alreadySynced = await _dataContext.BlacklistSyncHistory
.AsNoTracking()
.Where(s => s.Hash == currentHash)
.Select(x => x.DownloadClientId)
.ToListAsync();
// Only update clients not present in history for current hash
foreach (var clientConfig in qBittorrentClients)
{
try
{
if (alreadySynced.Contains(clientConfig.Id))
{
_logger.LogDebug("Client {ClientName} already synced for current blacklist, skipping", clientConfig.Name);
continue;
}
var downloadService = _downloadServiceFactory.GetDownloadService(clientConfig);
if (downloadService is not QBitService qBitService)
{
_logger.LogError("Expected QBitService but got {ServiceType} for client {ClientName}", downloadService.GetType().Name, clientConfig.Name);
continue;
}
try
{
await qBitService.LoginAsync();
await qBitService.UpdateBlacklistAsync(excludedFileNames);
_logger.LogDebug("Successfully updated blacklist for qBittorrent client {ClientName}", clientConfig.Name);
// Insert history row marking this client as synced for current hash
_dataContext.BlacklistSyncHistory.Add(new BlacklistSyncHistory
{
Hash = currentHash,
DownloadClientId = clientConfig.Id
});
await _dataContext.SaveChangesAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update blacklist for qBittorrent client {ClientName}", clientConfig.Name);
}
finally
{
qBitService.Dispose();
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create download service for client {ClientName}", clientConfig.Name);
}
}
}
private static string ComputeHash(string excludedFileNames)
{
using var sha = SHA256.Create();
byte[] bytes = Encoding.UTF8.GetBytes(excludedFileNames);
byte[] hash = sha.ComputeHash(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private async Task RemoveOldSyncDataAsync(string currentHash)
{
try
{
await _dataContext.BlacklistSyncHistory
.Where(s => s.Hash != currentHash)
.ExecuteDeleteAsync();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to cleanup old blacklist sync history");
}
}
}

View File

@@ -1,4 +1,3 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
@@ -6,6 +5,7 @@ using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Arr;
@@ -16,7 +16,7 @@ using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using LogContext = Serilog.Context.LogContext;
namespace Cleanuparr.Infrastructure.Features.Jobs;
namespace Cleanuparr.Application.Features.DownloadCleaner;
public sealed class DownloadCleaner : GenericHandler
{
@@ -59,11 +59,10 @@ public sealed class DownloadCleaner : GenericHandler
return;
}
List<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads;
ignoredDownloads.AddRange(ContextProvider.Get<DownloadCleanerConfig>().IgnoredDownloads);
IReadOnlyList<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads;
var downloadServiceToDownloadsMap = new Dictionary<IDownloadService, List<object>>();
var downloadServiceToDownloadsMap = new Dictionary<IDownloadService, List<ITorrentItem>>();
foreach (var downloadService in downloadServices)
{
try
@@ -86,11 +85,11 @@ public sealed class DownloadCleaner : GenericHandler
_logger.LogDebug("no seeding downloads found");
return;
}
var totalDownloads = downloadServiceToDownloadsMap.Values.Sum(x => x.Count);
_logger.LogTrace("found {count} seeding downloads across {clientCount} clients", totalDownloads, downloadServiceToDownloadsMap.Count);
List<Tuple<IDownloadService, List<ITorrentItem>>> downloadServiceWithDownloads = [];
List<Tuple<IDownloadService, List<object>>> downloadServiceWithDownloads = [];
if (isUnlinkedEnabled)
{

View File

@@ -0,0 +1,66 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Exceptions;
namespace Cleanuparr.Application.Features.DownloadClient.Dtos;
/// <summary>
/// DTO for creating a new download client (without ID)
/// </summary>
public sealed record CreateDownloadClientDto
{
/// <summary>
/// Whether this client is enabled
/// </summary>
public bool Enabled { get; init; } = false;
/// <summary>
/// Friendly name for this client
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Type name of download client
/// </summary>
public required DownloadClientTypeName TypeName { get; init; }
/// <summary>
/// Type of download client
/// </summary>
public required DownloadClientType Type { get; init; }
/// <summary>
/// Host address for the download client
/// </summary>
public Uri? Host { get; init; }
/// <summary>
/// Username for authentication
/// </summary>
public string? Username { get; init; }
/// <summary>
/// Password for authentication
/// </summary>
public string? Password { get; init; }
/// <summary>
/// The base URL path component, used by clients like Transmission and Deluge
/// </summary>
public string? UrlBase { get; init; }
/// <summary>
/// Validates the configuration
/// </summary>
public void Validate()
{
if (string.IsNullOrWhiteSpace(Name))
{
throw new ValidationException("Client name cannot be empty");
}
if (Host is null)
{
throw new ValidationException("Host cannot be empty");
}
}
}

View File

@@ -5,6 +5,7 @@ using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Persistence;
@@ -17,7 +18,7 @@ using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using LogContext = Serilog.Context.LogContext;
namespace Cleanuparr.Infrastructure.Features.Jobs;
namespace Cleanuparr.Application.Features.MalwareBlocker;
public sealed class MalwareBlocker : GenericHandler
{
@@ -93,8 +94,7 @@ public sealed class MalwareBlocker : GenericHandler
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType)
{
List<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads;
ignoredDownloads.AddRange(ContextProvider.Get<ContentBlockerConfig>().IgnoredDownloads);
IReadOnlyList<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>().IgnoredDownloads;
using var _ = LogContext.PushProperty(LogProperties.Category, instanceType.ToString());
@@ -146,9 +146,8 @@ public sealed class MalwareBlocker : GenericHandler
ContextProvider.Set(nameof(QueueRecord), record);
BlockFilesResult result = new();
bool isTorrent = record.Protocol.Contains("torrent", StringComparison.InvariantCultureIgnoreCase);
if (isTorrent)
if (record.Protocol is "torrent")
{
var torrentClients = downloadServices
.Where(x => x.ClientConfig.Type is DownloadClientType.Torrent)

View File

@@ -5,6 +5,7 @@ using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
@@ -12,12 +13,11 @@ using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.General;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using MassTransit;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using LogContext = Serilog.Context.LogContext;
namespace Cleanuparr.Infrastructure.Features.Jobs;
namespace Cleanuparr.Application.Features.QueueCleaner;
public sealed class QueueCleaner : GenericHandler
{
@@ -39,34 +39,6 @@ public sealed class QueueCleaner : GenericHandler
protected override async Task ExecuteInternalAsync()
{
List<StallRule> stallRules = await _dataContext.StallRules
.Where(r => r.Enabled)
.OrderByDescending(r => r.MaxCompletionPercentage)
.ThenByDescending(r => r.MinCompletionPercentage)
.AsNoTracking()
.ToListAsync();
if (stallRules.Count is 0)
{
_logger.LogDebug("No active stall rules found");
}
ContextProvider.Set(nameof(StallRule), stallRules);
List<SlowRule> slowRules = await _dataContext.SlowRules
.Where(r => r.Enabled)
.OrderByDescending(r => r.MaxCompletionPercentage)
.ThenByDescending(r => r.MinCompletionPercentage)
.AsNoTracking()
.ToListAsync();
if (slowRules.Count is 0)
{
_logger.LogDebug("No active slow rules found");
}
ContextProvider.Set(nameof(SlowRule), slowRules);
var sonarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Sonarr));
var radarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Radarr));
var lidarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Lidarr));
@@ -82,9 +54,7 @@ public sealed class QueueCleaner : GenericHandler
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType)
{
List<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads;
QueueCleanerConfig queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>();
ignoredDownloads.AddRange(queueCleanerConfig.IgnoredDownloads);
IReadOnlyList<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>().IgnoredDownloads;
using var _ = LogContext.PushProperty(LogProperties.Category, instanceType.ToString());
@@ -95,10 +65,6 @@ public sealed class QueueCleaner : GenericHandler
ContextProvider.Set(nameof(InstanceType), instanceType);
IReadOnlyList<IDownloadService> downloadServices = await GetInitializedDownloadServicesAsync();
bool hasEnabledTorrentClients = ContextProvider
.Get<List<DownloadClientConfig>>(nameof(DownloadClientConfig))
.Where(x => x.Type == DownloadClientType.Torrent)
.Any(x => x.Enabled);
await _arrArrQueueIterator.Iterate(arrClient, instance, async items =>
{
@@ -140,9 +106,8 @@ public sealed class QueueCleaner : GenericHandler
ContextProvider.Set(nameof(QueueRecord), record);
DownloadCheckResult downloadCheckResult = new();
bool isTorrent = record.Protocol.Contains("torrent", StringComparison.InvariantCultureIgnoreCase);
if (isTorrent)
if (record.Protocol.Contains("torrent", StringComparison.InvariantCultureIgnoreCase))
{
var torrentClients = downloadServices
.Where(x => x.ClientConfig.Type is DownloadClientType.Torrent)
@@ -155,7 +120,7 @@ public sealed class QueueCleaner : GenericHandler
{
try
{
// Get torrent info from download service for rule evaluation
// stalled download check
downloadCheckResult = await downloadService
.ShouldRemoveFromArrQueueAsync(record.DownloadId, ignoredDownloads);
@@ -176,54 +141,56 @@ public sealed class QueueCleaner : GenericHandler
_logger.LogWarning("Download not found in any torrent client | {title}", record.Title);
}
}
}
if (downloadCheckResult.ShouldRemove)
{
bool removeFromClient = !downloadCheckResult.IsPrivate || downloadCheckResult.DeleteFromClient;
await PublishQueueItemRemoveRequest(
downloadRemovalKey,
instanceType,
instance,
record,
group.Count() > 1,
removeFromClient,
downloadCheckResult.DeleteReason
);
continue;
}
// Skip failed import check if torrent is not found in client and skipIfNotFoundInClient is enabled
if (isTorrent && hasEnabledTorrentClients && !downloadCheckResult.Found && queueCleanerConfig.FailedImport.SkipIfNotFoundInClient)
{
_logger.LogInformation("skip | torrent not found in any torrent client | {title}", record.Title);
continue;
}
// Failed import check
bool shouldRemoveFromArr = await arrClient
.ShouldRemoveFromQueue(instanceType, record, downloadCheckResult.IsPrivate, instance.ArrConfig.FailedImportMaxStrikes);
if (shouldRemoveFromArr)
{
bool removeFromClient = !downloadCheckResult.IsPrivate || queueCleanerConfig.FailedImport.DeletePrivate;
await PublishQueueItemRemoveRequest(
downloadRemovalKey,
instanceType,
instance,
record,
group.Count() > 1,
removeFromClient,
DeleteReason.FailedImport
);
continue;
else
{
_logger.LogDebug("No torrent clients enabled");
}
}
_logger.LogDebug("skip | {title}", record.Title);
var config = ContextProvider.Get<QueueCleanerConfig>();
// failed import check
bool shouldRemoveFromArr = await arrClient.ShouldRemoveFromQueue(instanceType, record, downloadCheckResult.IsPrivate, instance.ArrConfig.FailedImportMaxStrikes);
DeleteReason deleteReason = downloadCheckResult.ShouldRemove ? downloadCheckResult.DeleteReason : DeleteReason.FailedImport;
if (!shouldRemoveFromArr && !downloadCheckResult.ShouldRemove)
{
_logger.LogInformation("skip | {title}", record.Title);
continue;
}
bool removeFromClient = true;
if (downloadCheckResult.IsPrivate)
{
bool isStalledWithoutPruneFlag =
downloadCheckResult.DeleteReason is DeleteReason.Stalled &&
!config.Stalled.DeletePrivate;
bool isSlowWithoutPruneFlag =
downloadCheckResult.DeleteReason is DeleteReason.SlowSpeed or DeleteReason.SlowTime &&
!config.Slow.DeletePrivate;
bool shouldKeepDueToDeleteRules = downloadCheckResult.ShouldRemove &&
(isStalledWithoutPruneFlag || isSlowWithoutPruneFlag);
bool shouldKeepDueToImportRules = shouldRemoveFromArr && !config.FailedImport.DeletePrivate;
if (shouldKeepDueToDeleteRules || shouldKeepDueToImportRules)
{
removeFromClient = false;
}
}
await PublishQueueItemRemoveRequest(
downloadRemovalKey,
instanceType,
instance,
record,
group.Count() > 1,
removeFromClient,
deleteReason
);
}
});
}

View File

@@ -1,6 +0,0 @@
namespace Cleanuparr.Domain.Entities.AppStatus;
public sealed record Status
{
public string? Version { get; set; }
}

View File

@@ -1,57 +0,0 @@
namespace Cleanuparr.Domain.Entities;
/// <summary>
/// Universal abstraction for a torrent item across all download clients.
/// Provides a unified interface for accessing torrent properties and state.
/// </summary>
public interface ITorrentItem
{
// Basic identification
string Hash { get; }
string Name { get; }
// Privacy and tracking
bool IsPrivate { get; }
IReadOnlyList<string> Trackers { get; }
// Size and progress
long Size { get; }
double CompletionPercentage { get; }
long DownloadedBytes { get; }
long TotalUploaded { get; }
// Speed and transfer rates
long DownloadSpeed { get; }
long UploadSpeed { get; }
double Ratio { get; }
// Time tracking
long Eta { get; }
DateTime? DateAdded { get; }
DateTime? DateCompleted { get; }
long SeedingTimeSeconds { get; }
// Categories and tags
string? Category { get; }
IReadOnlyList<string> Tags { get; }
// State checking methods
bool IsDownloading();
bool IsStalled();
bool IsSeeding();
bool IsCompleted();
bool IsPaused();
bool IsQueued();
bool IsChecking();
bool IsAllocating();
bool IsMetadataDownloading();
// Filtering methods
/// <summary>
/// Determines if this torrent should be ignored based on the provided patterns.
/// Checks if any pattern matches the torrent name, hash, or tracker.
/// </summary>
/// <param name="ignoredDownloads">List of patterns to check against</param>
/// <returns>True if the torrent matches any ignore pattern</returns>
bool IsIgnored(IReadOnlyList<string> ignoredDownloads);
}

View File

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

View File

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

View File

@@ -1,8 +0,0 @@
namespace Cleanuparr.Domain.Enums;
public enum NtfyAuthenticationType
{
None,
BasicAuth,
AccessToken
}

View File

@@ -1,10 +0,0 @@
namespace Cleanuparr.Domain.Enums;
public enum NtfyPriority
{
Min = 1,
Low = 2,
Default = 3,
High = 4,
Max = 5
}

View File

@@ -1,14 +0,0 @@
namespace Cleanuparr.Domain.Enums;
public enum PatternMode
{
/// <summary>
/// Delete all except those that match the patterns
/// </summary>
Exclude,
/// <summary>
/// Delete only those that match the patterns
/// </summary>
Include
}

View File

@@ -1,11 +0,0 @@
using System.Text.Json.Serialization;
namespace Cleanuparr.Domain.Enums;
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum TorrentPrivacyType
{
Public,
Private,
Both
}

View File

@@ -15,8 +15,6 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.6" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="NSubstitute" Version="5.3.0" />

View File

@@ -1,269 +0,0 @@
using Cleanuparr.Domain.Entities.Deluge.Response;
using Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
using Shouldly;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class DelugeItemTests
{
[Fact]
public void Constructor_WithNullDownloadStatus_ThrowsArgumentNullException()
{
// Act & Assert
Should.Throw<ArgumentNullException>(() => new DelugeItem(null!));
}
[Fact]
public void Hash_ReturnsCorrectValue()
{
// Arrange
var expectedHash = "test-hash-123";
var downloadStatus = new DownloadStatus
{
Hash = expectedHash,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItem(downloadStatus);
// Act
var result = wrapper.Hash;
// Assert
result.ShouldBe(expectedHash);
}
[Fact]
public void Hash_WithNullValue_ReturnsEmptyString()
{
// Arrange
var downloadStatus = new DownloadStatus
{
Hash = null,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItem(downloadStatus);
// Act
var result = wrapper.Hash;
// Assert
result.ShouldBe(string.Empty);
}
[Fact]
public void Name_ReturnsCorrectValue()
{
// Arrange
var expectedName = "Test Torrent";
var downloadStatus = new DownloadStatus
{
Name = expectedName,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItem(downloadStatus);
// Act
var result = wrapper.Name;
// Assert
result.ShouldBe(expectedName);
}
[Fact]
public void Name_WithNullValue_ReturnsEmptyString()
{
// Arrange
var downloadStatus = new DownloadStatus
{
Name = null,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItem(downloadStatus);
// Act
var result = wrapper.Name;
// Assert
result.ShouldBe(string.Empty);
}
[Fact]
public void IsPrivate_ReturnsCorrectValue()
{
// Arrange
var downloadStatus = new DownloadStatus
{
Private = true,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItem(downloadStatus);
// Act
var result = wrapper.IsPrivate;
// Assert
result.ShouldBeTrue();
}
[Fact]
public void Size_ReturnsCorrectValue()
{
// Arrange
var expectedSize = 1024L * 1024 * 1024; // 1GB
var downloadStatus = new DownloadStatus
{
Size = expectedSize,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItem(downloadStatus);
// Act
var result = wrapper.Size;
// Assert
result.ShouldBe(expectedSize);
}
[Theory]
[InlineData(0, 1024, 0.0)]
[InlineData(512, 1024, 50.0)]
[InlineData(768, 1024, 75.0)]
[InlineData(1024, 1024, 100.0)]
[InlineData(0, 0, 0.0)] // Edge case: zero size
public void CompletionPercentage_ReturnsCorrectValue(long totalDone, long size, double expectedPercentage)
{
// Arrange
var downloadStatus = new DownloadStatus
{
TotalDone = totalDone,
Size = size,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItem(downloadStatus);
// Act
var result = wrapper.CompletionPercentage;
// Assert
result.ShouldBe(expectedPercentage);
}
[Fact]
public void Trackers_WithValidUrls_ReturnsHostNames()
{
// Arrange
var downloadStatus = new DownloadStatus
{
Trackers = new List<Tracker>
{
new() { Url = "http://tracker1.example.com:8080/announce" },
new() { Url = "https://tracker2.example.com/announce" },
new() { Url = "udp://tracker3.example.com:1337/announce" }
},
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItem(downloadStatus);
// Act
var result = wrapper.Trackers;
// Assert
result.Count.ShouldBe(3);
result.ShouldContain("tracker1.example.com");
result.ShouldContain("tracker2.example.com");
result.ShouldContain("tracker3.example.com");
}
[Fact]
public void Trackers_WithDuplicateHosts_ReturnsDistinctHosts()
{
// Arrange
var downloadStatus = new DownloadStatus
{
Trackers = new List<Tracker>
{
new() { Url = "http://tracker1.example.com:8080/announce" },
new() { Url = "https://tracker1.example.com/announce" },
new() { Url = "udp://tracker1.example.com:1337/announce" }
},
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItem(downloadStatus);
// Act
var result = wrapper.Trackers;
// Assert
result.Count.ShouldBe(1);
result.ShouldContain("tracker1.example.com");
}
[Fact]
public void Trackers_WithInvalidUrls_SkipsInvalidEntries()
{
// Arrange
var downloadStatus = new DownloadStatus
{
Trackers = new List<Tracker>
{
new() { Url = "http://valid.example.com/announce" },
new() { Url = "invalid-url" },
new() { Url = "" },
new() { Url = null! }
},
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItem(downloadStatus);
// Act
var result = wrapper.Trackers;
// Assert
result.Count.ShouldBe(1);
result.ShouldContain("valid.example.com");
}
[Fact]
public void Trackers_WithEmptyList_ReturnsEmptyList()
{
// Arrange
var downloadStatus = new DownloadStatus
{
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItem(downloadStatus);
// Act
var result = wrapper.Trackers;
// Assert
result.ShouldBeEmpty();
}
[Fact]
public void Trackers_WithNullTrackers_ReturnsEmptyList()
{
// Arrange
var downloadStatus = new DownloadStatus
{
Trackers = null!,
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItem(downloadStatus);
// Act
var result = wrapper.Trackers;
// Assert
result.ShouldBeEmpty();
}
}

View File

@@ -1,496 +0,0 @@
using Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
using QBittorrent.Client;
using Shouldly;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class QBitItemTests
{
[Fact]
public void Constructor_WithNullTorrentInfo_ThrowsArgumentNullException()
{
// Arrange
var trackers = new List<TorrentTracker>();
// Act & Assert
Should.Throw<ArgumentNullException>(() => new QBitItem(null!, trackers, false));
}
[Fact]
public void Constructor_WithNullTrackers_ThrowsArgumentNullException()
{
// Arrange
var torrentInfo = new TorrentInfo();
// Act & Assert
Should.Throw<ArgumentNullException>(() => new QBitItem(torrentInfo, null!, false));
}
[Fact]
public void Hash_ReturnsCorrectValue()
{
// Arrange
var expectedHash = "test-hash-123";
var torrentInfo = new TorrentInfo { Hash = expectedHash };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
// Act
var result = wrapper.Hash;
// Assert
result.ShouldBe(expectedHash);
}
[Fact]
public void Hash_WithNullValue_ReturnsEmptyString()
{
// Arrange
var torrentInfo = new TorrentInfo { Hash = null };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
// Act
var result = wrapper.Hash;
// Assert
result.ShouldBe(string.Empty);
}
[Fact]
public void Name_ReturnsCorrectValue()
{
// Arrange
var expectedName = "Test Torrent";
var torrentInfo = new TorrentInfo { Name = expectedName };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
// Act
var result = wrapper.Name;
// Assert
result.ShouldBe(expectedName);
}
[Fact]
public void Name_WithNullValue_ReturnsEmptyString()
{
// Arrange
var torrentInfo = new TorrentInfo { Name = null };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
// Act
var result = wrapper.Name;
// Assert
result.ShouldBe(string.Empty);
}
[Fact]
public void IsPrivate_ReturnsCorrectValue()
{
// Arrange
var torrentInfo = new TorrentInfo();
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, true);
// Act
var result = wrapper.IsPrivate;
// Assert
result.ShouldBeTrue();
}
[Fact]
public void Size_ReturnsCorrectValue()
{
// Arrange
var expectedSize = 1024L * 1024 * 1024; // 1GB
var torrentInfo = new TorrentInfo { Size = expectedSize };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
// Act
var result = wrapper.Size;
// Assert
result.ShouldBe(expectedSize);
}
[Fact]
public void Size_WithZeroValue_ReturnsZero()
{
// Arrange
var torrentInfo = new TorrentInfo { Size = 0 };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
// Act
var result = wrapper.Size;
// Assert
result.ShouldBe(0);
}
[Theory]
[InlineData(0.0, 0.0)]
[InlineData(0.5, 50.0)]
[InlineData(0.75, 75.0)]
[InlineData(1.0, 100.0)]
public void CompletionPercentage_ReturnsCorrectValue(double progress, double expectedPercentage)
{
// Arrange
var torrentInfo = new TorrentInfo { Progress = progress };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
// Act
var result = wrapper.CompletionPercentage;
// Assert
result.ShouldBe(expectedPercentage);
}
[Fact]
public void Trackers_WithValidUrls_ReturnsHostNames()
{
// Arrange
var torrentInfo = new TorrentInfo();
var trackers = new List<TorrentTracker>
{
new() { Url = "http://tracker1.example.com:8080/announce" },
new() { Url = "https://tracker2.example.com/announce" },
new() { Url = "udp://tracker3.example.com:1337/announce" }
};
var wrapper = new QBitItem(torrentInfo, trackers, false);
// Act
var result = wrapper.Trackers;
// Assert
result.Count.ShouldBe(3);
result.ShouldContain("tracker1.example.com");
result.ShouldContain("tracker2.example.com");
result.ShouldContain("tracker3.example.com");
}
[Fact]
public void Trackers_WithDuplicateHosts_ReturnsDistinctHosts()
{
// Arrange
var torrentInfo = new TorrentInfo();
var trackers = new List<TorrentTracker>
{
new() { Url = "http://tracker1.example.com:8080/announce" },
new() { Url = "https://tracker1.example.com/announce" },
new() { Url = "udp://tracker1.example.com:1337/announce" }
};
var wrapper = new QBitItem(torrentInfo, trackers, false);
// Act
var result = wrapper.Trackers;
// Assert
result.Count.ShouldBe(1);
result.ShouldContain("tracker1.example.com");
}
[Fact]
public void Trackers_WithInvalidUrls_SkipsInvalidEntries()
{
// Arrange
var torrentInfo = new TorrentInfo();
var trackers = new List<TorrentTracker>
{
new() { Url = "http://valid.example.com/announce" },
new() { Url = "invalid-url" },
new() { Url = "" },
new() { Url = null }
};
var wrapper = new QBitItem(torrentInfo, trackers, false);
// Act
var result = wrapper.Trackers;
// Assert
result.Count.ShouldBe(1);
result.ShouldContain("valid.example.com");
}
[Fact]
public void Trackers_WithEmptyList_ReturnsEmptyList()
{
// Arrange
var torrentInfo = new TorrentInfo();
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
// Act
var result = wrapper.Trackers;
// Assert
result.ShouldBeEmpty();
}
// State checking method tests
[Theory]
[InlineData(TorrentState.Downloading, true)]
[InlineData(TorrentState.ForcedDownload, true)]
[InlineData(TorrentState.StalledDownload, false)]
[InlineData(TorrentState.Uploading, false)]
[InlineData(TorrentState.PausedDownload, false)]
public void IsDownloading_ReturnsCorrectValue(TorrentState state, bool expected)
{
// Arrange
var torrentInfo = new TorrentInfo { State = state };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
// Act
var result = wrapper.IsDownloading();
// Assert
result.ShouldBe(expected);
}
[Theory]
[InlineData(TorrentState.StalledDownload, true)]
[InlineData(TorrentState.Downloading, false)]
[InlineData(TorrentState.ForcedDownload, false)]
[InlineData(TorrentState.Uploading, false)]
public void IsStalled_ReturnsCorrectValue(TorrentState state, bool expected)
{
// Arrange
var torrentInfo = new TorrentInfo { State = state };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
// Act
var result = wrapper.IsStalled();
// Assert
result.ShouldBe(expected);
}
[Theory]
[InlineData(TorrentState.Uploading, true)]
[InlineData(TorrentState.ForcedUpload, true)]
[InlineData(TorrentState.StalledUpload, true)]
[InlineData(TorrentState.Downloading, false)]
[InlineData(TorrentState.PausedUpload, false)]
public void IsSeeding_ReturnsCorrectValue(TorrentState state, bool expected)
{
// Arrange
var torrentInfo = new TorrentInfo { State = state };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
// Act
var result = wrapper.IsSeeding();
// Assert
result.ShouldBe(expected);
}
[Theory]
[InlineData(0.0, false)]
[InlineData(0.5, false)]
[InlineData(0.99, false)]
[InlineData(1.0, true)]
public void IsCompleted_ReturnsCorrectValue(double progress, bool expected)
{
// Arrange
var torrentInfo = new TorrentInfo { Progress = progress };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
// Act
var result = wrapper.IsCompleted();
// Assert
result.ShouldBe(expected);
}
[Theory]
[InlineData(TorrentState.PausedDownload, true)]
[InlineData(TorrentState.PausedUpload, true)]
[InlineData(TorrentState.Downloading, false)]
[InlineData(TorrentState.Uploading, false)]
public void IsPaused_ReturnsCorrectValue(TorrentState state, bool expected)
{
// Arrange
var torrentInfo = new TorrentInfo { State = state };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
// Act
var result = wrapper.IsPaused();
// Assert
result.ShouldBe(expected);
}
[Theory]
[InlineData(TorrentState.QueuedDownload, true)]
[InlineData(TorrentState.QueuedUpload, true)]
[InlineData(TorrentState.Downloading, false)]
[InlineData(TorrentState.Uploading, false)]
public void IsQueued_ReturnsCorrectValue(TorrentState state, bool expected)
{
// Arrange
var torrentInfo = new TorrentInfo { State = state };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
// Act
var result = wrapper.IsQueued();
// Assert
result.ShouldBe(expected);
}
[Theory]
[InlineData(TorrentState.CheckingDownload, true)]
[InlineData(TorrentState.CheckingUpload, true)]
[InlineData(TorrentState.CheckingResumeData, true)]
[InlineData(TorrentState.Downloading, false)]
[InlineData(TorrentState.Uploading, false)]
public void IsChecking_ReturnsCorrectValue(TorrentState state, bool expected)
{
// Arrange
var torrentInfo = new TorrentInfo { State = state };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
// Act
var result = wrapper.IsChecking();
// Assert
result.ShouldBe(expected);
}
[Theory]
[InlineData(TorrentState.Allocating, true)]
[InlineData(TorrentState.Downloading, false)]
[InlineData(TorrentState.Uploading, false)]
public void IsAllocating_ReturnsCorrectValue(TorrentState state, bool expected)
{
// Arrange
var torrentInfo = new TorrentInfo { State = state };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
// Act
var result = wrapper.IsAllocating();
// Assert
result.ShouldBe(expected);
}
[Theory]
[InlineData(TorrentState.FetchingMetadata, true)]
[InlineData(TorrentState.ForcedFetchingMetadata, true)]
[InlineData(TorrentState.Downloading, false)]
[InlineData(TorrentState.StalledDownload, false)]
public void IsMetadataDownloading_ReturnsCorrectValue(TorrentState state, bool expected)
{
// Arrange
var torrentInfo = new TorrentInfo { State = state };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
// Act
var result = wrapper.IsMetadataDownloading();
// Assert
result.ShouldBe(expected);
}
[Fact]
public void IsIgnored_WithEmptyList_ReturnsFalse()
{
// Arrange
var torrentInfo = new TorrentInfo { Name = "Test Torrent", Hash = "abc123" };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
// Act
var result = wrapper.IsIgnored(Array.Empty<string>());
// Assert
result.ShouldBeFalse();
}
[Fact]
public void IsIgnored_MatchingName_ReturnsTrue()
{
// Arrange
var torrentInfo = new TorrentInfo { Name = "Test Torrent", Hash = "abc123" };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
var ignoredDownloads = new[] { "test" };
// Act
var result = wrapper.IsIgnored(ignoredDownloads);
// Assert
result.ShouldBeTrue();
}
[Fact]
public void IsIgnored_MatchingHash_ReturnsTrue()
{
// Arrange
var torrentInfo = new TorrentInfo { Name = "Test Torrent", Hash = "abc123" };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
var ignoredDownloads = new[] { "abc123" };
// Act
var result = wrapper.IsIgnored(ignoredDownloads);
// Assert
result.ShouldBeTrue();
}
[Fact]
public void IsIgnored_MatchingTracker_ReturnsTrue()
{
// Arrange
var torrentInfo = new TorrentInfo { Name = "Test Torrent", Hash = "abc123" };
var trackers = new List<TorrentTracker>
{
new() { Url = "http://tracker.example.com/announce" }
};
var wrapper = new QBitItem(torrentInfo, trackers, false);
var ignoredDownloads = new[] { "tracker.example.com" };
// Act
var result = wrapper.IsIgnored(ignoredDownloads);
// Assert
result.ShouldBeTrue();
}
[Fact]
public void IsIgnored_NotMatching_ReturnsFalse()
{
// Arrange
var torrentInfo = new TorrentInfo { Name = "Test Torrent", Hash = "abc123" };
var trackers = new List<TorrentTracker>
{
new() { Url = "http://tracker.example.com/announce" }
};
var wrapper = new QBitItem(torrentInfo, trackers, false);
var ignoredDownloads = new[] { "notmatching" };
// Act
var result = wrapper.IsIgnored(ignoredDownloads);
// Assert
result.ShouldBeFalse();
}
}

View File

@@ -1,239 +0,0 @@
using Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
using Shouldly;
using Transmission.API.RPC.Entity;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class TransmissionItemTests
{
[Fact]
public void Constructor_WithNullTorrentInfo_ThrowsArgumentNullException()
{
// Act & Assert
Should.Throw<ArgumentNullException>(() => new TransmissionItem(null!));
}
[Fact]
public void Hash_ReturnsCorrectValue()
{
// Arrange
var expectedHash = "test-hash-123";
var torrentInfo = new TorrentInfo { HashString = expectedHash };
var wrapper = new TransmissionItem(torrentInfo);
// Act
var result = wrapper.Hash;
// Assert
result.ShouldBe(expectedHash);
}
[Fact]
public void Hash_WithNullValue_ReturnsEmptyString()
{
// Arrange
var torrentInfo = new TorrentInfo { HashString = null };
var wrapper = new TransmissionItem(torrentInfo);
// Act
var result = wrapper.Hash;
// Assert
result.ShouldBe(string.Empty);
}
[Fact]
public void Name_ReturnsCorrectValue()
{
// Arrange
var expectedName = "Test Torrent";
var torrentInfo = new TorrentInfo { Name = expectedName };
var wrapper = new TransmissionItem(torrentInfo);
// Act
var result = wrapper.Name;
// Assert
result.ShouldBe(expectedName);
}
[Fact]
public void Name_WithNullValue_ReturnsEmptyString()
{
// Arrange
var torrentInfo = new TorrentInfo { Name = null };
var wrapper = new TransmissionItem(torrentInfo);
// Act
var result = wrapper.Name;
// Assert
result.ShouldBe(string.Empty);
}
[Theory]
[InlineData(true, true)]
[InlineData(false, false)]
[InlineData(null, false)]
public void IsPrivate_ReturnsCorrectValue(bool? isPrivate, bool expected)
{
// Arrange
var torrentInfo = new TorrentInfo { IsPrivate = isPrivate };
var wrapper = new TransmissionItem(torrentInfo);
// Act
var result = wrapper.IsPrivate;
// Assert
result.ShouldBe(expected);
}
[Theory]
[InlineData(1024L * 1024 * 1024, 1024L * 1024 * 1024)] // 1GB
[InlineData(0L, 0L)]
[InlineData(null, 0L)]
public void Size_ReturnsCorrectValue(long? totalSize, long expected)
{
// Arrange
var torrentInfo = new TorrentInfo { TotalSize = totalSize };
var wrapper = new TransmissionItem(torrentInfo);
// Act
var result = wrapper.Size;
// Assert
result.ShouldBe(expected);
}
[Theory]
[InlineData(0L, 1024L, 0.0)]
[InlineData(512L, 1024L, 50.0)]
[InlineData(768L, 1024L, 75.0)]
[InlineData(1024L, 1024L, 100.0)]
[InlineData(0L, 0L, 0.0)] // Edge case: zero size
[InlineData(null, 1024L, 0.0)] // Edge case: null downloaded
[InlineData(512L, null, 0.0)] // Edge case: null total size
public void CompletionPercentage_ReturnsCorrectValue(long? downloadedEver, long? totalSize, double expectedPercentage)
{
// Arrange
var torrentInfo = new TorrentInfo
{
DownloadedEver = downloadedEver,
TotalSize = totalSize
};
var wrapper = new TransmissionItem(torrentInfo);
// Act
var result = wrapper.CompletionPercentage;
// Assert
result.ShouldBe(expectedPercentage);
}
[Fact]
public void Trackers_WithValidUrls_ReturnsHostNames()
{
// Arrange
var torrentInfo = new TorrentInfo
{
Trackers = new TransmissionTorrentTrackers[]
{
new() { Announce = "http://tracker1.example.com:8080/announce" },
new() { Announce = "https://tracker2.example.com/announce" },
new() { Announce = "udp://tracker3.example.com:1337/announce" }
}
};
var wrapper = new TransmissionItem(torrentInfo);
// Act
var result = wrapper.Trackers;
// Assert
result.Count.ShouldBe(3);
result.ShouldContain("tracker1.example.com");
result.ShouldContain("tracker2.example.com");
result.ShouldContain("tracker3.example.com");
}
[Fact]
public void Trackers_WithDuplicateHosts_ReturnsDistinctHosts()
{
// Arrange
var torrentInfo = new TorrentInfo
{
Trackers = new TransmissionTorrentTrackers[]
{
new() { Announce = "http://tracker1.example.com:8080/announce" },
new() { Announce = "https://tracker1.example.com/announce" },
new() { Announce = "udp://tracker1.example.com:1337/announce" }
}
};
var wrapper = new TransmissionItem(torrentInfo);
// Act
var result = wrapper.Trackers;
// Assert
result.Count.ShouldBe(1);
result.ShouldContain("tracker1.example.com");
}
[Fact]
public void Trackers_WithInvalidUrls_SkipsInvalidEntries()
{
// Arrange
var torrentInfo = new TorrentInfo
{
Trackers = new TransmissionTorrentTrackers[]
{
new() { Announce = "http://valid.example.com/announce" },
new() { Announce = "invalid-url" },
new() { Announce = "" },
new() { Announce = null }
}
};
var wrapper = new TransmissionItem(torrentInfo);
// Act
var result = wrapper.Trackers;
// Assert
result.Count.ShouldBe(1);
result.ShouldContain("valid.example.com");
}
[Fact]
public void Trackers_WithEmptyList_ReturnsEmptyList()
{
// Arrange
var torrentInfo = new TorrentInfo
{
Trackers = new TransmissionTorrentTrackers[0]
};
var wrapper = new TransmissionItem(torrentInfo);
// Act
var result = wrapper.Trackers;
// Assert
result.ShouldBeEmpty();
}
[Fact]
public void Trackers_WithNullTrackers_ReturnsEmptyList()
{
// Arrange
var torrentInfo = new TorrentInfo
{
Trackers = null
};
var wrapper = new TransmissionItem(torrentInfo);
// Act
var result = wrapper.Trackers;
// Assert
result.ShouldBeEmpty();
}
}

View File

@@ -1,203 +0,0 @@
using Cleanuparr.Domain.Entities.UTorrent.Response;
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
using Shouldly;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class UTorrentItemWrapperTests
{
[Fact]
public void Constructor_WithNullTorrentItem_ThrowsArgumentNullException()
{
// Arrange
var torrentProperties = new UTorrentProperties();
// Act & Assert
Should.Throw<ArgumentNullException>(() => new UTorrentItemWrapper(null!, torrentProperties));
}
[Fact]
public void Constructor_WithNullTorrentProperties_ThrowsArgumentNullException()
{
// Arrange
var torrentItem = new UTorrentItem();
// Act & Assert
Should.Throw<ArgumentNullException>(() => new UTorrentItemWrapper(torrentItem, null!));
}
[Fact]
public void Hash_ReturnsCorrectValue()
{
// Arrange
var expectedHash = "test-hash-123";
var torrentItem = new UTorrentItem { Hash = expectedHash };
var torrentProperties = new UTorrentProperties();
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
// Act
var result = wrapper.Hash;
// Assert
result.ShouldBe(expectedHash);
}
[Fact]
public void Name_ReturnsCorrectValue()
{
// Arrange
var expectedName = "Test Torrent";
var torrentItem = new UTorrentItem { Name = expectedName };
var torrentProperties = new UTorrentProperties();
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
// Act
var result = wrapper.Name;
// Assert
result.ShouldBe(expectedName);
}
[Fact]
public void IsPrivate_ReturnsCorrectValue()
{
// Arrange
var torrentItem = new UTorrentItem();
var torrentProperties = new UTorrentProperties { Pex = -1 }; // -1 means private torrent
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
// Act
var result = wrapper.IsPrivate;
// Assert
result.ShouldBeTrue();
}
[Fact]
public void Size_ReturnsCorrectValue()
{
// Arrange
var expectedSize = 1024L * 1024 * 1024; // 1GB
var torrentItem = new UTorrentItem { Size = expectedSize };
var torrentProperties = new UTorrentProperties();
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
// Act
var result = wrapper.Size;
// Assert
result.ShouldBe(expectedSize);
}
[Theory]
[InlineData(0, 0.0)] // 0 permille = 0%
[InlineData(500, 50.0)] // 500 permille = 50%
[InlineData(750, 75.0)] // 750 permille = 75%
[InlineData(1000, 100.0)] // 1000 permille = 100%
public void CompletionPercentage_ReturnsCorrectValue(int progress, double expectedPercentage)
{
// Arrange
var torrentItem = new UTorrentItem { Progress = progress };
var torrentProperties = new UTorrentProperties();
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
// Act
var result = wrapper.CompletionPercentage;
// Assert
result.ShouldBe(expectedPercentage);
}
[Fact]
public void Trackers_WithValidUrls_ReturnsHostNames()
{
// Arrange
var torrentItem = new UTorrentItem();
var torrentProperties = new UTorrentProperties
{
Trackers = "http://tracker1.example.com:8080/announce\r\nhttps://tracker2.example.com/announce\r\nudp://tracker3.example.com:1337/announce"
};
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
// Act
var result = wrapper.Trackers;
// Assert
result.Count.ShouldBe(3);
result.ShouldContain("tracker1.example.com");
result.ShouldContain("tracker2.example.com");
result.ShouldContain("tracker3.example.com");
}
[Fact]
public void Trackers_WithDuplicateHosts_ReturnsDistinctHosts()
{
// Arrange
var torrentItem = new UTorrentItem();
var torrentProperties = new UTorrentProperties
{
Trackers = "http://tracker1.example.com:8080/announce\r\nhttps://tracker1.example.com/announce\r\nudp://tracker1.example.com:1337/announce"
};
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
// Act
var result = wrapper.Trackers;
// Assert
result.Count.ShouldBe(1);
result.ShouldContain("tracker1.example.com");
}
[Fact]
public void Trackers_WithInvalidUrls_SkipsInvalidEntries()
{
// Arrange
var torrentItem = new UTorrentItem();
var torrentProperties = new UTorrentProperties
{
Trackers = "http://valid.example.com/announce\r\ninvalid-url\r\n\r\n "
};
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
// Act
var result = wrapper.Trackers;
// Assert
result.Count.ShouldBe(1);
result.ShouldContain("valid.example.com");
}
[Fact]
public void Trackers_WithEmptyList_ReturnsEmptyList()
{
// Arrange
var torrentItem = new UTorrentItem();
var torrentProperties = new UTorrentProperties
{
Trackers = ""
};
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
// Act
var result = wrapper.Trackers;
// Assert
result.ShouldBeEmpty();
}
[Fact]
public void Trackers_WithNullTrackerList_ReturnsEmptyList()
{
// Arrange
var torrentItem = new UTorrentItem();
var torrentProperties = new UTorrentProperties(); // Trackers defaults to empty string
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
// Act
var result = wrapper.Trackers;
// Assert
result.ShouldBeEmpty();
}
}

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