mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2025-12-24 06:28:55 -05:00
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97f63434fd | ||
|
|
07d0cf07e3 | ||
|
|
4be107439a | ||
|
|
89ef03a859 | ||
|
|
905384034d | ||
|
|
bf826da1ae | ||
|
|
6aac35181b | ||
|
|
efbf60dcdd | ||
|
|
ebb166a7b9 | ||
|
|
7aced28262 | ||
|
|
ae3e793498 | ||
|
|
4eb98b18a1 | ||
|
|
128e7e5f11 | ||
|
|
d224b2dea0 | ||
|
|
16e823b8d3 | ||
|
|
f2f11e3472 | ||
|
|
a3549c80a9 | ||
|
|
2b9c347ed6 | ||
|
|
98ccee866d | ||
|
|
911849c6dd | ||
|
|
cce3bb2c4a | ||
|
|
bcc117cd0d | ||
|
|
8e20a68ae2 | ||
|
|
736c146f25 | ||
|
|
6398ef1cc6 | ||
|
|
83e6a289be | ||
|
|
5662118b01 | ||
|
|
22dfc7b40d | ||
|
|
a51e387453 | ||
|
|
c7d2ec7311 | ||
|
|
bb9ac5b67b | ||
|
|
f93494adb2 | ||
|
|
7201520411 | ||
|
|
2a1e65e1af | ||
|
|
da318c3339 | ||
|
|
7149b6243f | ||
|
|
11f5a28c04 | ||
|
|
9cc36c7a50 | ||
|
|
861c135cc6 |
7
.github/ISSUE_TEMPLATE/1-bug.yml
vendored
7
.github/ISSUE_TEMPLATE/1-bug.yml
vendored
@@ -7,6 +7,13 @@ 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:
|
||||
|
||||
19
.github/ISSUE_TEMPLATE/2-feature.yml
vendored
19
.github/ISSUE_TEMPLATE/2-feature.yml
vendored
@@ -7,6 +7,25 @@ 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:
|
||||
|
||||
7
.github/ISSUE_TEMPLATE/3-help.yml
vendored
7
.github/ISSUE_TEMPLATE/3-help.yml
vendored
@@ -7,6 +7,13 @@ 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:
|
||||
|
||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
8
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,2 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links: []
|
||||
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
|
||||
24
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
24
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
## 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
|
||||
30
.github/workflows/cloudflare-pages-status.yml
vendored
Normal file
30
.github/workflows/cloudflare-pages-status.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
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
|
||||
7
.github/workflows/docs.yml
vendored
7
.github/workflows/docs.yml
vendored
@@ -2,9 +2,9 @@ name: Deploy Docusaurus to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'docs/**'
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -22,6 +22,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: main
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
325
CONTRIBUTING.md
Normal file
325
CONTRIBUTING.md
Normal file
@@ -0,0 +1,325 @@
|
||||
# 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!
|
||||
17
README.md
17
README.md
@@ -23,6 +23,10 @@ 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
|
||||
@@ -59,7 +63,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).
|
||||
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/).
|
||||
|
||||
### 🌐 Access the Web Interface
|
||||
|
||||
@@ -78,6 +82,17 @@ 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
|
||||
|
||||
@@ -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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -76,63 +76,23 @@ public class JobsController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{jobType}/stop")]
|
||||
public async Task<IActionResult> StopJob(JobType jobType)
|
||||
[HttpPost("{jobType}/trigger")]
|
||||
public async Task<IActionResult> TriggerJob(JobType jobType)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _jobManagementService.StopJob(jobType);
|
||||
var result = await _jobManagementService.TriggerJobOnce(jobType);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
return BadRequest($"Failed to stop job '{jobType}'");
|
||||
return BadRequest($"Failed to trigger job '{jobType}' - job may not exist or be configured");
|
||||
}
|
||||
return Ok(new { Message = $"Job '{jobType}' stopped successfully" });
|
||||
return Ok(new { Message = $"Job '{jobType}' triggered successfully for one-time execution" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_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}'");
|
||||
_logger.LogError(ex, "Error triggering job {jobType}", jobType);
|
||||
return StatusCode(500, $"An error occurred while triggering job '{jobType}'");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
// Queue rules endpoints have moved to Cleanuparr.Api.Features.QueueCleaner.Controllers
|
||||
@@ -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,15 +15,21 @@ 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;
|
||||
});
|
||||
@@ -34,6 +40,7 @@ public static class ApiDI
|
||||
.AddSignalR()
|
||||
.AddJsonProtocol(options =>
|
||||
{
|
||||
options.PayloadSerializerOptions.PropertyNameCaseInsensitive = true;
|
||||
options.PayloadSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
});
|
||||
|
||||
@@ -67,19 +74,12 @@ public static class ApiDI
|
||||
// Serve static files with caching
|
||||
app.UseStaticFiles(new StaticFileOptions
|
||||
{
|
||||
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";
|
||||
// }
|
||||
}
|
||||
OnPrepareResponse = _ => {}
|
||||
});
|
||||
|
||||
// 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 = "assets/icons/icon-192x192.png",
|
||||
src = "icons/icon-192x192.png",
|
||||
sizes = "192x192",
|
||||
type = "image/png"
|
||||
},
|
||||
new {
|
||||
src = "assets/icons/icon-512x512.png",
|
||||
src = "icons/icon-512x512.png",
|
||||
sizes = "512x512",
|
||||
type = "image/png"
|
||||
}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Models;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Cleanuparr.Infrastructure.Logging;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using Serilog.Templates;
|
||||
using Serilog.Templates.Themes;
|
||||
|
||||
namespace Cleanuparr.Api.DependencyInjection;
|
||||
|
||||
@@ -12,82 +7,10 @@ public static class LoggingDI
|
||||
{
|
||||
public static ILoggingBuilder AddLogging(this ILoggingBuilder builder)
|
||||
{
|
||||
Log.Logger = GetDefaultLoggerConfiguration().CreateLogger();
|
||||
Log.Logger = LoggingConfigManager
|
||||
.CreateLoggerConfiguration()
|
||||
.CreateLogger();
|
||||
|
||||
return builder.ClearProviders().AddSerilog();
|
||||
}
|
||||
|
||||
public static LoggerConfiguration GetDefaultLoggerConfiguration()
|
||||
{
|
||||
LoggerConfiguration logConfig = new();
|
||||
const string categoryTemplate = "{#if Category is not null} {Concat('[',Category,']'),CAT_PAD}{#end}";
|
||||
const string jobNameTemplate = "{#if JobName is not null} {Concat('[',JobName,']'),JOB_PAD}{#end}";
|
||||
|
||||
const string consoleOutputTemplate = $"[{{@t:yyyy-MM-dd HH:mm:ss.fff}} {{@l:u3}}]{jobNameTemplate}{categoryTemplate} {{@m}}\n{{@x}}";
|
||||
const string fileOutputTemplate = $"{{@t:yyyy-MM-dd HH:mm:ss.fff zzz}} [{{@l:u3}}]{jobNameTemplate}{categoryTemplate} {{@m:lj}}\n{{@x}}";
|
||||
|
||||
// Determine job name padding
|
||||
List<string> jobNames = [nameof(JobType.QueueCleaner), nameof(JobType.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;
|
||||
}
|
||||
}
|
||||
@@ -17,14 +17,13 @@ public static class MainDI
|
||||
{
|
||||
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) =>
|
||||
services
|
||||
.AddLogging(builder => builder.ClearProviders().AddConsole())
|
||||
.AddHttpClients(configuration)
|
||||
.AddSingleton<MemoryCache>()
|
||||
.AddSingleton<IMemoryCache>(serviceProvider => serviceProvider.GetRequiredService<MemoryCache>())
|
||||
.AddServices()
|
||||
.AddHealthServices()
|
||||
.AddQuartzServices(configuration)
|
||||
.AddNotifications(configuration)
|
||||
.AddNotifications()
|
||||
.AddMassTransit(config =>
|
||||
{
|
||||
config.DisableUsageTelemetry();
|
||||
@@ -36,7 +35,8 @@ public static class MainDI
|
||||
|
||||
config.AddConsumer<NotificationConsumer<FailedImportStrikeNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<StalledStrikeNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<SlowStrikeNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<SlowSpeedStrikeNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<SlowTimeStrikeNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<QueueItemDeletedNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<DownloadCleanedNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<CategoryChangedNotification>>();
|
||||
@@ -45,6 +45,7 @@ public static class MainDI
|
||||
{
|
||||
cfg.ConfigureJsonSerializerOptions(options =>
|
||||
{
|
||||
options.PropertyNameCaseInsensitive = true;
|
||||
options.Converters.Add(new JsonStringEnumConverter());
|
||||
options.ReferenceHandler = ReferenceHandler.IgnoreCycles;
|
||||
|
||||
@@ -71,7 +72,8 @@ public static class MainDI
|
||||
{
|
||||
e.ConfigureConsumer<NotificationConsumer<FailedImportStrikeNotification>>(context);
|
||||
e.ConfigureConsumer<NotificationConsumer<StalledStrikeNotification>>(context);
|
||||
e.ConfigureConsumer<NotificationConsumer<SlowStrikeNotification>>(context);
|
||||
e.ConfigureConsumer<NotificationConsumer<SlowSpeedStrikeNotification>>(context);
|
||||
e.ConfigureConsumer<NotificationConsumer<SlowTimeStrikeNotification>>(context);
|
||||
e.ConfigureConsumer<NotificationConsumer<QueueItemDeletedNotification>>(context);
|
||||
e.ConfigureConsumer<NotificationConsumer<DownloadCleanedNotification>>(context);
|
||||
e.ConfigureConsumer<NotificationConsumer<CategoryChangedNotification>>(context);
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
using Cleanuparr.Infrastructure.Features.Notifications;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
|
||||
using Infrastructure.Verticals.Notifications;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
|
||||
|
||||
namespace Cleanuparr.Api.DependencyInjection;
|
||||
|
||||
public static class NotificationsDI
|
||||
{
|
||||
public static IServiceCollection AddNotifications(this IServiceCollection services, IConfiguration configuration) =>
|
||||
public static IServiceCollection AddNotifications(this IServiceCollection services) =>
|
||||
services
|
||||
// Notification configs are now managed through ConfigManager
|
||||
.AddTransient<INotifiarrProxy, NotifiarrProxy>()
|
||||
.AddTransient<INotificationProvider, NotifiarrProvider>()
|
||||
.AddTransient<IAppriseProxy, AppriseProxy>()
|
||||
.AddTransient<INotificationProvider, AppriseProvider>()
|
||||
.AddTransient<INotificationPublisher, NotificationPublisher>()
|
||||
.AddTransient<INotificationFactory, NotificationFactory>()
|
||||
.AddTransient<NotificationService>();
|
||||
.AddScoped<INotifiarrProxy, NotifiarrProxy>()
|
||||
.AddScoped<IAppriseProxy, AppriseProxy>()
|
||||
.AddScoped<INtfyProxy, NtfyProxy>()
|
||||
.AddScoped<INotificationConfigurationService, NotificationConfigurationService>()
|
||||
.AddScoped<INotificationProviderFactory, NotificationProviderFactory>()
|
||||
.AddScoped<NotificationProviderFactory>()
|
||||
.AddScoped<INotificationPublisher, NotificationPublisher>()
|
||||
.AddScoped<NotificationService>();
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
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;
|
||||
@@ -10,14 +8,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;
|
||||
|
||||
@@ -40,6 +38,7 @@ public static class ServicesDI
|
||||
.AddScoped<WhisparrClient>()
|
||||
.AddScoped<ArrClientFactory>()
|
||||
.AddScoped<QueueCleaner>()
|
||||
.AddScoped<BlacklistSynchronizer>()
|
||||
.AddScoped<MalwareBlocker>()
|
||||
.AddScoped<DownloadCleaner>()
|
||||
.AddScoped<IQueueItemRemover, QueueItemRemover>()
|
||||
@@ -51,6 +50,12 @@ 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<BlocklistProvider>()
|
||||
.AddSingleton<AppStatusSnapshot>()
|
||||
.AddHostedService<AppStatusRefreshService>();
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Cleanuparr.Api.Features.Arr.Contracts.Requests;
|
||||
|
||||
public sealed record UpdateArrConfigRequest
|
||||
{
|
||||
public short FailedImportMaxStrikes { get; init; } = -1;
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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; } = [];
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public record TestNotifiarrProviderRequest
|
||||
{
|
||||
public string ApiKey { get; init; } = string.Empty;
|
||||
|
||||
public string ChannelId { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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; } = [];
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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; } = [];
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Responses;
|
||||
|
||||
public sealed record NotificationProvidersResponse
|
||||
{
|
||||
public List<NotificationProviderResponse> Providers { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,708 @@
|
||||
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()
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests;
|
||||
|
||||
public sealed record StallRuleDto : QueueRuleDto
|
||||
{
|
||||
public bool ResetStrikesOnProgress { get; set; } = true;
|
||||
|
||||
public string? MinimumProgress { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
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;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.Reflection;
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
using Cleanuparr.Infrastructure.Logging;
|
||||
using Cleanuparr.Infrastructure.Services;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -6,36 +9,61 @@ namespace Cleanuparr.Api;
|
||||
|
||||
public static class HostExtensions
|
||||
{
|
||||
public static async Task<IHost> Init(this WebApplication app)
|
||||
public static 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 v{version.Major}.{version.Minor}.{version.Build}"
|
||||
: $"Cleanuparr {formattedVersion}"
|
||||
);
|
||||
|
||||
logger.LogInformation("timezone: {tz}", TimeZoneInfo.Local.DisplayName);
|
||||
|
||||
// Apply db migrations
|
||||
var scopeFactory = app.Services.GetRequiredService<IServiceScopeFactory>();
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
|
||||
await using var eventsContext = scope.ServiceProvider.GetRequiredService<EventsContext>();
|
||||
return app;
|
||||
}
|
||||
|
||||
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();
|
||||
if ((await eventsContext.Database.GetPendingMigrationsAsync()).Any())
|
||||
{
|
||||
await eventsContext.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
await using var configContext = scope.ServiceProvider.GetRequiredService<DataContext>();
|
||||
// Apply data db migrations
|
||||
await using var configContext = DataContext.CreateStaticInstance();
|
||||
if ((await configContext.Database.GetPendingMigrationsAsync()).Any())
|
||||
{
|
||||
await configContext.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
return app;
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
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;
|
||||
@@ -45,12 +43,12 @@ public class BackgroundJobManager : IHostedService
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Starting BackgroundJobManager");
|
||||
_logger.LogDebug("Starting BackgroundJobManager");
|
||||
_scheduler = await _schedulerFactory.GetScheduler(cancellationToken);
|
||||
|
||||
await InitializeJobsFromConfiguration(cancellationToken);
|
||||
|
||||
_logger.LogInformation("BackgroundJobManager started");
|
||||
_logger.LogDebug("BackgroundJobManager started");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -64,15 +62,15 @@ public class BackgroundJobManager : IHostedService
|
||||
/// </summary>
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Stopping BackgroundJobManager");
|
||||
_logger.LogDebug("Stopping BackgroundJobManager");
|
||||
|
||||
if (_scheduler != null)
|
||||
{
|
||||
// Don't shutdown the scheduler as it's managed by QuartzHostedService
|
||||
// Don't shut down the scheduler as it's managed by QuartzHostedService
|
||||
await _scheduler.Standby(cancellationToken);
|
||||
}
|
||||
|
||||
_logger.LogInformation("BackgroundJobManager stopped");
|
||||
_logger.LogDebug("BackgroundJobManager stopped");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -86,7 +84,6 @@ public class BackgroundJobManager : IHostedService
|
||||
throw new InvalidOperationException("Scheduler not initialized");
|
||||
}
|
||||
|
||||
// Use scoped DataContext to prevent memory leaks
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
await using var dataContext = scope.ServiceProvider.GetRequiredService<DataContext>();
|
||||
|
||||
@@ -100,11 +97,15 @@ public class BackgroundJobManager : IHostedService
|
||||
DownloadCleanerConfig downloadCleanerConfig = await dataContext.DownloadCleanerConfigs
|
||||
.AsNoTracking()
|
||||
.FirstAsync(cancellationToken);
|
||||
BlacklistSyncConfig blacklistSyncConfig = await dataContext.BlacklistSyncConfigs
|
||||
.AsNoTracking()
|
||||
.FirstAsync(cancellationToken);
|
||||
|
||||
// Always register jobs, regardless of enabled status
|
||||
await RegisterQueueCleanerJob(queueCleanerConfig, cancellationToken);
|
||||
await RegisterMalwareBlockerJob(malwareBlockerConfig, cancellationToken);
|
||||
await RegisterDownloadCleanerJob(downloadCleanerConfig, cancellationToken);
|
||||
await RegisterBlacklistSyncJob(blacklistSyncConfig, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -120,7 +121,7 @@ public class BackgroundJobManager : IHostedService
|
||||
// Only add triggers if the job is enabled
|
||||
if (config.Enabled)
|
||||
{
|
||||
await AddTriggersForJob<QueueCleaner>(config, config.CronExpression, cancellationToken);
|
||||
await AddTriggersForJob<QueueCleaner>(config.CronExpression, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +138,7 @@ public class BackgroundJobManager : IHostedService
|
||||
// Only add triggers if the job is enabled
|
||||
if (config.Enabled)
|
||||
{
|
||||
await AddTriggersForJob<MalwareBlocker>(config, config.CronExpression, cancellationToken);
|
||||
await AddTriggersForJob<MalwareBlocker>(config.CronExpression, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,7 +153,21 @@ public class BackgroundJobManager : IHostedService
|
||||
// Only add triggers if the job is enabled
|
||||
if (config.Enabled)
|
||||
{
|
||||
await AddTriggersForJob<DownloadCleaner>(config, config.CronExpression, cancellationToken);
|
||||
await AddTriggersForJob<DownloadCleaner>(config.CronExpression, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the BlacklistSync job and optionally adds triggers based on general configuration.
|
||||
/// </summary>
|
||||
public async Task RegisterBlacklistSyncJob(BlacklistSyncConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Always register the job definition
|
||||
await AddJobWithoutTrigger<BlacklistSynchronizer>(cancellationToken);
|
||||
|
||||
if (config.Enabled)
|
||||
{
|
||||
await AddTriggersForJob<BlacklistSynchronizer>(config.CronExpression, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,10 +175,9 @@ public class BackgroundJobManager : IHostedService
|
||||
/// Helper method to add triggers for an existing job.
|
||||
/// </summary>
|
||||
private async Task AddTriggersForJob<T>(
|
||||
IJobConfig config,
|
||||
string cronExpression,
|
||||
CancellationToken cancellationToken = default)
|
||||
where T : GenericHandler
|
||||
where T : IHandler
|
||||
{
|
||||
if (_scheduler == null)
|
||||
{
|
||||
@@ -211,16 +225,7 @@ public class BackgroundJobManager : IHostedService
|
||||
// Schedule the main trigger
|
||||
await _scheduler.ScheduleJob(trigger, cancellationToken);
|
||||
|
||||
// Trigger immediate execution for startup using a one-time trigger
|
||||
var startupTrigger = TriggerBuilder.Create()
|
||||
.WithIdentity($"{typeName}-startup-{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",
|
||||
_logger.LogInformation("Added trigger for job {name} with cron expression {CronExpression}",
|
||||
typeName, cronExpression);
|
||||
}
|
||||
|
||||
@@ -228,7 +233,7 @@ public class BackgroundJobManager : IHostedService
|
||||
/// Helper method to add a job without a trigger (for chained jobs).
|
||||
/// </summary>
|
||||
private async Task AddJobWithoutTrigger<T>(CancellationToken cancellationToken = default)
|
||||
where T : GenericHandler
|
||||
where T : IHandler
|
||||
{
|
||||
if (_scheduler == null)
|
||||
{
|
||||
@@ -254,6 +259,6 @@ public class BackgroundJobManager : IHostedService
|
||||
// Add job to scheduler
|
||||
await _scheduler.AddJob(jobDetail, true, cancellationToken);
|
||||
|
||||
_logger.LogInformation("Registered job {name} without trigger", typeName);
|
||||
_logger.LogDebug("Registered job {name} without trigger", typeName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
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;
|
||||
|
||||
@@ -24,12 +28,39 @@ 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
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;
|
||||
@@ -0,0 +1,8 @@
|
||||
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;
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
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;
|
||||
@@ -0,0 +1,8 @@
|
||||
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;
|
||||
@@ -0,0 +1,8 @@
|
||||
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;
|
||||
@@ -0,0 +1,8 @@
|
||||
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;
|
||||
@@ -0,0 +1,8 @@
|
||||
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;
|
||||
@@ -0,0 +1,8 @@
|
||||
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;
|
||||
@@ -0,0 +1,8 @@
|
||||
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;
|
||||
@@ -0,0 +1 @@
|
||||
// Moved to Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests
|
||||
@@ -0,0 +1 @@
|
||||
// Moved to Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests
|
||||
@@ -0,0 +1 @@
|
||||
// Moved to Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests
|
||||
@@ -1,53 +1,23 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
|
||||
|
||||
namespace Cleanuparr.Api.Models;
|
||||
|
||||
public class UpdateDownloadCleanerConfigDto
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
/// <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 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;
|
||||
}
|
||||
/// <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;
|
||||
@@ -2,14 +2,18 @@ using System.Runtime.InteropServices;
|
||||
using System.Text.Json.Serialization;
|
||||
using Cleanuparr.Api;
|
||||
using Cleanuparr.Api.DependencyInjection;
|
||||
using Cleanuparr.Infrastructure.Hubs;
|
||||
using Cleanuparr.Infrastructure.Logging;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Serilog;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
await builder.InitAsync();
|
||||
builder.Logging.AddLogging();
|
||||
|
||||
// Fix paths for single-file deployment on macOS
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
{
|
||||
@@ -46,6 +50,7 @@ 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());
|
||||
});
|
||||
|
||||
@@ -68,14 +73,6 @@ builder.Services.AddCors(options =>
|
||||
});
|
||||
});
|
||||
|
||||
// Register services needed for logging first
|
||||
builder.Services
|
||||
.AddScoped<LoggingConfigManager>()
|
||||
.AddSingleton<SignalRLogSink>();
|
||||
|
||||
// Add logging with proper service provider
|
||||
builder.Logging.AddLogging();
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
builder.Host.UseWindowsService(options =>
|
||||
@@ -130,28 +127,11 @@ if (basePath is not null)
|
||||
logger.LogInformation("Server configuration: PORT={port}, BASE_PATH={basePath}", port, basePath ?? "/");
|
||||
|
||||
// Initialize the host
|
||||
await app.Init();
|
||||
app.Init();
|
||||
|
||||
// Get LoggingConfigManager (will be created if not already registered)
|
||||
var scopeFactory = app.Services.GetRequiredService<IServiceScopeFactory>();
|
||||
using (var scope = scopeFactory.CreateScope())
|
||||
{
|
||||
var configManager = scope.ServiceProvider.GetRequiredService<LoggingConfigManager>();
|
||||
|
||||
// Get the dynamic level switch for controlling log levels
|
||||
var levelSwitch = configManager.GetLevelSwitch();
|
||||
|
||||
// Get the SignalRLogSink instance
|
||||
var signalRSink = app.Services.GetRequiredService<SignalRLogSink>();
|
||||
|
||||
var logConfig = LoggingDI.GetDefaultLoggerConfiguration();
|
||||
logConfig.MinimumLevel.ControlledBy(levelSwitch);
|
||||
|
||||
// Add to Serilog pipeline
|
||||
logConfig.WriteTo.Sink(signalRSink);
|
||||
|
||||
Log.Logger = logConfig.CreateLogger();
|
||||
}
|
||||
// Configure the app hub for SignalR
|
||||
var appHub = app.Services.GetRequiredService<IHubContext<AppHub>>();
|
||||
SignalRLogSink.Instance.SetAppHubContext(appHub);
|
||||
|
||||
// Configure health check endpoints before the API configuration
|
||||
app.MapHealthChecks("/health", new HealthCheckOptions
|
||||
@@ -168,4 +148,9 @@ app.MapHealthChecks("/health/ready", new HealthCheckOptions
|
||||
|
||||
app.ConfigureApi();
|
||||
|
||||
await app.RunAsync();
|
||||
await app.RunAsync();
|
||||
|
||||
await Log.CloseAndFlushAsync();
|
||||
|
||||
// Make Program class accessible for testing
|
||||
public partial class Program { }
|
||||
3
code/backend/Cleanuparr.Api/Properties/AssemblyInfo.cs
Normal file
3
code/backend/Cleanuparr.Api/Properties/AssemblyInfo.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("Cleanuparr.Api.Tests")]
|
||||
@@ -1,20 +0,0 @@
|
||||
<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>
|
||||
@@ -0,0 +1,162 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
|
||||
using Cleanuparr.Infrastructure.Features.Jobs;
|
||||
using Cleanuparr.Infrastructure.Helpers;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
|
||||
using Cleanuparr.Persistence.Models.State;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Application.Features.BlacklistSync;
|
||||
|
||||
public sealed class BlacklistSynchronizer : IHandler
|
||||
{
|
||||
private readonly ILogger<BlacklistSynchronizer> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly DownloadServiceFactory _downloadServiceFactory;
|
||||
private readonly FileReader _fileReader;
|
||||
private readonly IDryRunInterceptor _dryRunInterceptor;
|
||||
|
||||
public BlacklistSynchronizer(
|
||||
ILogger<BlacklistSynchronizer> logger,
|
||||
DataContext dataContext,
|
||||
DownloadServiceFactory downloadServiceFactory,
|
||||
FileReader fileReader,
|
||||
IDryRunInterceptor dryRunInterceptor
|
||||
)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
_downloadServiceFactory = downloadServiceFactory;
|
||||
_fileReader = fileReader;
|
||||
_dryRunInterceptor = dryRunInterceptor;
|
||||
}
|
||||
|
||||
public async Task ExecuteAsync()
|
||||
{
|
||||
BlacklistSyncConfig config = await _dataContext.BlacklistSyncConfigs
|
||||
.AsNoTracking()
|
||||
.FirstAsync();
|
||||
|
||||
if (!config.Enabled)
|
||||
{
|
||||
_logger.LogDebug("Blacklist sync is disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(config.BlacklistPath))
|
||||
{
|
||||
_logger.LogWarning("Blacklist sync path is not configured");
|
||||
return;
|
||||
}
|
||||
|
||||
string[] patterns = await _fileReader.ReadContentAsync(config.BlacklistPath);
|
||||
string excludedFileNames = string.Join('\n', patterns.Where(p => !string.IsNullOrWhiteSpace(p)));
|
||||
|
||||
string currentHash = ComputeHash(excludedFileNames);
|
||||
|
||||
await _dryRunInterceptor.InterceptAsync(SyncBlacklist, currentHash, excludedFileNames);
|
||||
await _dryRunInterceptor.InterceptAsync(RemoveOldSyncDataAsync, currentHash);
|
||||
|
||||
_logger.LogDebug("Blacklist synchronization completed");
|
||||
}
|
||||
|
||||
private async Task SyncBlacklist(string currentHash, string excludedFileNames)
|
||||
{
|
||||
List<DownloadClientConfig> qBittorrentClients = await _dataContext.DownloadClients
|
||||
.AsNoTracking()
|
||||
.Where(c => c.Enabled && c.TypeName == DownloadClientTypeName.qBittorrent)
|
||||
.ToListAsync();
|
||||
|
||||
if (qBittorrentClients.Count is 0)
|
||||
{
|
||||
_logger.LogDebug("No enabled qBittorrent clients found for blacklist sync");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Starting blacklist synchronization for {Count} qBittorrent clients", qBittorrentClients.Count);
|
||||
|
||||
// Pull existing sync history for this hash
|
||||
var alreadySynced = await _dataContext.BlacklistSyncHistory
|
||||
.AsNoTracking()
|
||||
.Where(s => s.Hash == currentHash)
|
||||
.Select(x => x.DownloadClientId)
|
||||
.ToListAsync();
|
||||
|
||||
// Only update clients not present in history for current hash
|
||||
foreach (var clientConfig in qBittorrentClients)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (alreadySynced.Contains(clientConfig.Id))
|
||||
{
|
||||
_logger.LogDebug("Client {ClientName} already synced for current blacklist, skipping", clientConfig.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
var downloadService = _downloadServiceFactory.GetDownloadService(clientConfig);
|
||||
if (downloadService is not QBitService qBitService)
|
||||
{
|
||||
_logger.LogError("Expected QBitService but got {ServiceType} for client {ClientName}", downloadService.GetType().Name, clientConfig.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await qBitService.LoginAsync();
|
||||
await qBitService.UpdateBlacklistAsync(excludedFileNames);
|
||||
|
||||
_logger.LogDebug("Successfully updated blacklist for qBittorrent client {ClientName}", clientConfig.Name);
|
||||
|
||||
// Insert history row marking this client as synced for current hash
|
||||
_dataContext.BlacklistSyncHistory.Add(new BlacklistSyncHistory
|
||||
{
|
||||
Hash = currentHash,
|
||||
DownloadClientId = clientConfig.Id
|
||||
});
|
||||
await _dataContext.SaveChangesAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update blacklist for qBittorrent client {ClientName}", clientConfig.Name);
|
||||
}
|
||||
finally
|
||||
{
|
||||
qBitService.Dispose();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create download service for client {ClientName}", clientConfig.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeHash(string excludedFileNames)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
byte[] bytes = Encoding.UTF8.GetBytes(excludedFileNames);
|
||||
byte[] hash = sha.ComputeHash(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private async Task RemoveOldSyncDataAsync(string currentHash)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _dataContext.BlacklistSyncHistory
|
||||
.Where(s => s.Hash != currentHash)
|
||||
.ExecuteDeleteAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to cleanup old blacklist sync history");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Cleanuparr.Domain.Entities.AppStatus;
|
||||
|
||||
public sealed record Status
|
||||
{
|
||||
public string? Version { get; set; }
|
||||
}
|
||||
57
code/backend/Cleanuparr.Domain/Entities/ITorrentItem.cs
Normal file
57
code/backend/Cleanuparr.Domain/Entities/ITorrentItem.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
public enum NotificationEventType
|
||||
{
|
||||
Test,
|
||||
FailedImportStrike,
|
||||
StalledStrike,
|
||||
SlowSpeedStrike,
|
||||
SlowTimeStrike,
|
||||
QueueItemDeleted,
|
||||
DownloadCleaned,
|
||||
CategoryChanged
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
public enum NotificationProviderType
|
||||
{
|
||||
Notifiarr,
|
||||
Apprise,
|
||||
Ntfy
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
public enum NtfyAuthenticationType
|
||||
{
|
||||
None,
|
||||
BasicAuth,
|
||||
AccessToken
|
||||
}
|
||||
10
code/backend/Cleanuparr.Domain/Enums/NtfyPriority.cs
Normal file
10
code/backend/Cleanuparr.Domain/Enums/NtfyPriority.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
public enum NtfyPriority
|
||||
{
|
||||
Min = 1,
|
||||
Low = 2,
|
||||
Default = 3,
|
||||
High = 4,
|
||||
Max = 5
|
||||
}
|
||||
14
code/backend/Cleanuparr.Domain/Enums/PatternMode.cs
Normal file
14
code/backend/Cleanuparr.Domain/Enums/PatternMode.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
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
|
||||
}
|
||||
11
code/backend/Cleanuparr.Domain/Enums/TorrentPrivacyType.cs
Normal file
11
code/backend/Cleanuparr.Domain/Enums/TorrentPrivacyType.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum TorrentPrivacyType
|
||||
{
|
||||
Public,
|
||||
Private,
|
||||
Both
|
||||
}
|
||||
@@ -15,6 +15,8 @@
|
||||
<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" />
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
using Cleanuparr.Domain.Entities;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.QueueCleaner;
|
||||
|
||||
public class QueueRuleMatchTests
|
||||
{
|
||||
[Fact]
|
||||
public void StallRule_WithNonZeroMinCompletion_ShouldExcludeLowerBoundary()
|
||||
{
|
||||
var rule = new StallRule
|
||||
{
|
||||
Name = "Stall",
|
||||
Enabled = true,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MaxStrikes = 3,
|
||||
MinCompletionPercentage = 20,
|
||||
MaxCompletionPercentage = 100,
|
||||
};
|
||||
|
||||
var torrentAtBoundary = CreateTorrent(isPrivate: false, completionPercentage: 20);
|
||||
var torrentAboveBoundary = CreateTorrent(isPrivate: false, completionPercentage: 20.1);
|
||||
|
||||
Assert.False(rule.MatchesTorrent(torrentAtBoundary.Object));
|
||||
Assert.True(rule.MatchesTorrent(torrentAboveBoundary.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StallRule_WithZeroMinCompletion_ShouldIncludeZero()
|
||||
{
|
||||
var rule = new StallRule
|
||||
{
|
||||
Name = "Zero",
|
||||
Enabled = true,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MaxStrikes = 3,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 20,
|
||||
};
|
||||
|
||||
var zeroTorrent = CreateTorrent(isPrivate: false, completionPercentage: 0);
|
||||
var midTorrent = CreateTorrent(isPrivate: false, completionPercentage: 10);
|
||||
|
||||
Assert.True(rule.MatchesTorrent(zeroTorrent.Object));
|
||||
Assert.True(rule.MatchesTorrent(midTorrent.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SlowRule_WithNonZeroMinCompletion_ShouldExcludeLowerBoundary()
|
||||
{
|
||||
var rule = new SlowRule
|
||||
{
|
||||
Name = "Slow",
|
||||
Enabled = true,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MaxStrikes = 3,
|
||||
MinCompletionPercentage = 40,
|
||||
MaxCompletionPercentage = 90,
|
||||
MaxTimeHours = 1,
|
||||
MinSpeed = string.Empty,
|
||||
};
|
||||
|
||||
var torrentAtBoundary = CreateTorrent(isPrivate: false, completionPercentage: 40);
|
||||
var torrentAboveBoundary = CreateTorrent(isPrivate: false, completionPercentage: 40.5);
|
||||
|
||||
Assert.False(rule.MatchesTorrent(torrentAtBoundary.Object));
|
||||
Assert.True(rule.MatchesTorrent(torrentAboveBoundary.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StallRule_PrivacyType_Both_MatchesPublic()
|
||||
{
|
||||
var rule = new StallRule
|
||||
{
|
||||
Name = "Both Rule",
|
||||
Enabled = true,
|
||||
PrivacyType = TorrentPrivacyType.Both,
|
||||
MaxStrikes = 3,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 100,
|
||||
};
|
||||
|
||||
var publicTorrent = CreateTorrent(isPrivate: false, completionPercentage: 50);
|
||||
|
||||
Assert.True(rule.MatchesTorrent(publicTorrent.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StallRule_PrivacyType_Both_MatchesPrivate()
|
||||
{
|
||||
var rule = new StallRule
|
||||
{
|
||||
Name = "Both Rule",
|
||||
Enabled = true,
|
||||
PrivacyType = TorrentPrivacyType.Both,
|
||||
MaxStrikes = 3,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 100,
|
||||
};
|
||||
|
||||
var privateTorrent = CreateTorrent(isPrivate: true, completionPercentage: 50);
|
||||
|
||||
Assert.True(rule.MatchesTorrent(privateTorrent.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SlowRule_PrivacyType_Both_MatchesPublic()
|
||||
{
|
||||
var rule = new SlowRule
|
||||
{
|
||||
Name = "Both Rule",
|
||||
Enabled = true,
|
||||
PrivacyType = TorrentPrivacyType.Both,
|
||||
MaxStrikes = 3,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 100,
|
||||
MaxTimeHours = 1,
|
||||
MinSpeed = string.Empty,
|
||||
};
|
||||
|
||||
var publicTorrent = CreateTorrent(isPrivate: false, completionPercentage: 50);
|
||||
|
||||
Assert.True(rule.MatchesTorrent(publicTorrent.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SlowRule_PrivacyType_Both_MatchesPrivate()
|
||||
{
|
||||
var rule = new SlowRule
|
||||
{
|
||||
Name = "Both Rule",
|
||||
Enabled = true,
|
||||
PrivacyType = TorrentPrivacyType.Both,
|
||||
MaxStrikes = 3,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 100,
|
||||
MaxTimeHours = 1,
|
||||
MinSpeed = string.Empty,
|
||||
};
|
||||
|
||||
var privateTorrent = CreateTorrent(isPrivate: true, completionPercentage: 50);
|
||||
|
||||
Assert.True(rule.MatchesTorrent(privateTorrent.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StallRule_CompletionPercentage_AtMaxBoundary_Matches()
|
||||
{
|
||||
var rule = new StallRule
|
||||
{
|
||||
Name = "Max Boundary",
|
||||
Enabled = true,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MaxStrikes = 3,
|
||||
MinCompletionPercentage = 20,
|
||||
MaxCompletionPercentage = 80,
|
||||
};
|
||||
|
||||
var torrentAtMax = CreateTorrent(isPrivate: false, completionPercentage: 80);
|
||||
|
||||
Assert.True(rule.MatchesTorrent(torrentAtMax.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StallRule_CompletionPercentage_BelowMin_DoesNotMatch()
|
||||
{
|
||||
var rule = new StallRule
|
||||
{
|
||||
Name = "Below Min",
|
||||
Enabled = true,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MaxStrikes = 3,
|
||||
MinCompletionPercentage = 20,
|
||||
MaxCompletionPercentage = 80,
|
||||
};
|
||||
|
||||
var torrentBelowMin = CreateTorrent(isPrivate: false, completionPercentage: 15);
|
||||
|
||||
Assert.False(rule.MatchesTorrent(torrentBelowMin.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StallRule_CompletionPercentage_AboveMax_DoesNotMatch()
|
||||
{
|
||||
var rule = new StallRule
|
||||
{
|
||||
Name = "Above Max",
|
||||
Enabled = true,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MaxStrikes = 3,
|
||||
MinCompletionPercentage = 20,
|
||||
MaxCompletionPercentage = 80,
|
||||
};
|
||||
|
||||
var torrentAboveMax = CreateTorrent(isPrivate: false, completionPercentage: 85);
|
||||
|
||||
Assert.False(rule.MatchesTorrent(torrentAboveMax.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SlowRule_CompletionPercentage_AtMaxBoundary_Matches()
|
||||
{
|
||||
var rule = new SlowRule
|
||||
{
|
||||
Name = "Max Boundary",
|
||||
Enabled = true,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MaxStrikes = 3,
|
||||
MinCompletionPercentage = 30,
|
||||
MaxCompletionPercentage = 70,
|
||||
MaxTimeHours = 1,
|
||||
MinSpeed = string.Empty,
|
||||
};
|
||||
|
||||
var torrentAtMax = CreateTorrent(isPrivate: false, completionPercentage: 70);
|
||||
|
||||
Assert.True(rule.MatchesTorrent(torrentAtMax.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SlowRule_CompletionPercentage_BelowMin_DoesNotMatch()
|
||||
{
|
||||
var rule = new SlowRule
|
||||
{
|
||||
Name = "Below Min",
|
||||
Enabled = true,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MaxStrikes = 3,
|
||||
MinCompletionPercentage = 30,
|
||||
MaxCompletionPercentage = 70,
|
||||
MaxTimeHours = 1,
|
||||
MinSpeed = string.Empty,
|
||||
};
|
||||
|
||||
var torrentBelowMin = CreateTorrent(isPrivate: false, completionPercentage: 25);
|
||||
|
||||
Assert.False(rule.MatchesTorrent(torrentBelowMin.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SlowRule_CompletionPercentage_AboveMax_DoesNotMatch()
|
||||
{
|
||||
var rule = new SlowRule
|
||||
{
|
||||
Name = "Above Max",
|
||||
Enabled = true,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MaxStrikes = 3,
|
||||
MinCompletionPercentage = 30,
|
||||
MaxCompletionPercentage = 70,
|
||||
MaxTimeHours = 1,
|
||||
MinSpeed = string.Empty,
|
||||
};
|
||||
|
||||
var torrentAboveMax = CreateTorrent(isPrivate: false, completionPercentage: 75);
|
||||
|
||||
Assert.False(rule.MatchesTorrent(torrentAboveMax.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SlowRule_IgnoreAboveSize_TorrentTooLarge_DoesNotMatch()
|
||||
{
|
||||
var rule = new SlowRule
|
||||
{
|
||||
Name = "Size Limited",
|
||||
Enabled = true,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MaxStrikes = 3,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 100,
|
||||
MaxTimeHours = 1,
|
||||
MinSpeed = string.Empty,
|
||||
IgnoreAboveSize = "50 GB",
|
||||
};
|
||||
|
||||
var largeTorrent = CreateTorrent(isPrivate: false, completionPercentage: 50, size: "100 GB");
|
||||
|
||||
Assert.False(rule.MatchesTorrent(largeTorrent.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SlowRule_IgnoreAboveSize_TorrentSizeOk_Matches()
|
||||
{
|
||||
var rule = new SlowRule
|
||||
{
|
||||
Name = "Size Limited",
|
||||
Enabled = true,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MaxStrikes = 3,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 100,
|
||||
MaxTimeHours = 1,
|
||||
MinSpeed = string.Empty,
|
||||
IgnoreAboveSize = "50 GB",
|
||||
};
|
||||
|
||||
var smallTorrent = CreateTorrent(isPrivate: false, completionPercentage: 50, size: "30 GB");
|
||||
|
||||
Assert.True(rule.MatchesTorrent(smallTorrent.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SlowRule_NoIgnoreSizeSet_AllSizesMatch()
|
||||
{
|
||||
var rule = new SlowRule
|
||||
{
|
||||
Name = "No Size Limit",
|
||||
Enabled = true,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MaxStrikes = 3,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 100,
|
||||
MaxTimeHours = 1,
|
||||
MinSpeed = string.Empty,
|
||||
IgnoreAboveSize = string.Empty,
|
||||
};
|
||||
|
||||
var hugeTorrent = CreateTorrent(isPrivate: false, completionPercentage: 50, size: "500 GB");
|
||||
|
||||
Assert.True(rule.MatchesTorrent(hugeTorrent.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StallRule_PrivacyType_Public_DoesNotMatchPrivate()
|
||||
{
|
||||
var rule = new StallRule
|
||||
{
|
||||
Name = "Public Only",
|
||||
Enabled = true,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MaxStrikes = 3,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 100,
|
||||
};
|
||||
|
||||
var privateTorrent = CreateTorrent(isPrivate: true, completionPercentage: 50);
|
||||
|
||||
Assert.False(rule.MatchesTorrent(privateTorrent.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StallRule_PrivacyType_Private_DoesNotMatchPublic()
|
||||
{
|
||||
var rule = new StallRule
|
||||
{
|
||||
Name = "Private Only",
|
||||
Enabled = true,
|
||||
PrivacyType = TorrentPrivacyType.Private,
|
||||
MaxStrikes = 3,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 100,
|
||||
};
|
||||
|
||||
var publicTorrent = CreateTorrent(isPrivate: false, completionPercentage: 50);
|
||||
|
||||
Assert.False(rule.MatchesTorrent(publicTorrent.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SlowRule_PrivacyType_Public_DoesNotMatchPrivate()
|
||||
{
|
||||
var rule = new SlowRule
|
||||
{
|
||||
Name = "Public Only",
|
||||
Enabled = true,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MaxStrikes = 3,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 100,
|
||||
MaxTimeHours = 1,
|
||||
MinSpeed = string.Empty,
|
||||
};
|
||||
|
||||
var privateTorrent = CreateTorrent(isPrivate: true, completionPercentage: 50);
|
||||
|
||||
Assert.False(rule.MatchesTorrent(privateTorrent.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SlowRule_PrivacyType_Private_DoesNotMatchPublic()
|
||||
{
|
||||
var rule = new SlowRule
|
||||
{
|
||||
Name = "Private Only",
|
||||
Enabled = true,
|
||||
PrivacyType = TorrentPrivacyType.Private,
|
||||
MaxStrikes = 3,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 100,
|
||||
MaxTimeHours = 1,
|
||||
MinSpeed = string.Empty,
|
||||
};
|
||||
|
||||
var publicTorrent = CreateTorrent(isPrivate: false, completionPercentage: 50);
|
||||
|
||||
Assert.False(rule.MatchesTorrent(publicTorrent.Object));
|
||||
}
|
||||
|
||||
private static Mock<ITorrentItem> CreateTorrent(bool isPrivate, double completionPercentage, string size = "10 GB")
|
||||
{
|
||||
var torrent = new Mock<ITorrentItem>();
|
||||
torrent.SetupGet(t => t.IsPrivate).Returns(isPrivate);
|
||||
torrent.SetupGet(t => t.CompletionPercentage).Returns(completionPercentage);
|
||||
torrent.SetupGet(t => t.Size).Returns(ByteSize.Parse(size).Bytes);
|
||||
torrent.SetupGet(t => t.Trackers).Returns(Array.Empty<string>());
|
||||
return torrent;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,967 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Cleanuparr.Domain.Entities;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Services;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Services;
|
||||
|
||||
public class RuleEvaluatorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ResetStrikes_ShouldRespectMinimumProgressThreshold()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
var stallRule = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
QueueCleanerConfigId = Guid.NewGuid(),
|
||||
Name = "Stall Rule",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 100,
|
||||
ResetStrikesOnProgress = true,
|
||||
MinimumProgress = "10 MB",
|
||||
DeletePrivateTorrentsFromClient = false,
|
||||
};
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(stallRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), StrikeType.Stalled))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
long downloadedBytes = 0;
|
||||
|
||||
var torrentMock = new Mock<ITorrentItem>();
|
||||
torrentMock.SetupGet(t => t.Hash).Returns("hash");
|
||||
torrentMock.SetupGet(t => t.Name).Returns("Example Torrent");
|
||||
torrentMock.SetupGet(t => t.IsPrivate).Returns(false);
|
||||
torrentMock.SetupGet(t => t.Size).Returns(ByteSize.Parse("100 MB").Bytes);
|
||||
torrentMock.SetupGet(t => t.CompletionPercentage).Returns(50);
|
||||
torrentMock.SetupGet(t => t.Trackers).Returns(Array.Empty<string>());
|
||||
torrentMock.SetupGet(t => t.DownloadedBytes).Returns(() => downloadedBytes);
|
||||
|
||||
// Seed cache with initial observation (no reset expected)
|
||||
await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
|
||||
strikerMock.Verify(x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), StrikeType.Stalled), Times.Never);
|
||||
|
||||
// Progress below threshold should not reset strikes
|
||||
downloadedBytes = ByteSize.Parse("1 MB").Bytes;
|
||||
await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
|
||||
strikerMock.Verify(x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), StrikeType.Stalled), Times.Never);
|
||||
|
||||
// Progress beyond threshold should trigger reset
|
||||
downloadedBytes = ByteSize.Parse("12 MB").Bytes;
|
||||
await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
|
||||
strikerMock.Verify(x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), StrikeType.Stalled), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateStallRulesAsync_NoMatchingRules_ShouldReturnFoundWithoutRemoval()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns((StallRule?)null);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
|
||||
var result = await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
|
||||
Assert.False(result.ShouldRemove);
|
||||
|
||||
strikerMock.Verify(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateStallRulesAsync_WithMatchingRule_ShouldApplyStrikeWithoutRemoval()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
var stallRule = CreateStallRule("Stall Apply", resetOnProgress: false, maxStrikes: 5);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(stallRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
|
||||
var result = await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
|
||||
Assert.False(result.ShouldRemove);
|
||||
|
||||
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)stallRule.MaxStrikes, StrikeType.Stalled), Times.Once);
|
||||
strikerMock.Verify(x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), StrikeType.Stalled), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateStallRulesAsync_WhenStrikeLimitReached_ShouldMarkForRemoval()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
var stallRule = CreateStallRule("Stall Remove", resetOnProgress: false, maxStrikes: 6);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(stallRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
|
||||
var result = await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
|
||||
Assert.True(result.ShouldRemove);
|
||||
|
||||
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)stallRule.MaxStrikes, StrikeType.Stalled), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateStallRulesAsync_WhenStrikeThrows_ShouldThrowException()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
var failingRule = CreateStallRule("Failing", resetOnProgress: false, maxStrikes: 4);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(failingRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled))
|
||||
.ThrowsAsync(new InvalidOperationException("boom"));
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => evaluator.EvaluateStallRulesAsync(torrentMock.Object));
|
||||
|
||||
strikerMock.Verify(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateSlowRulesAsync_NoMatchingRules_ShouldReturnFoundWithoutRemoval()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns((SlowRule?)null);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
|
||||
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
|
||||
Assert.False(result.ShouldRemove);
|
||||
|
||||
strikerMock.Verify(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateSlowRulesAsync_WithMatchingRule_ShouldApplyStrikeWithoutRemoval()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule("Slow Apply", resetOnProgress: false, maxStrikes: 3);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
|
||||
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
|
||||
Assert.False(result.ShouldRemove);
|
||||
|
||||
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)slowRule.MaxStrikes, StrikeType.SlowTime), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateSlowRulesAsync_WhenStrikeLimitReached_ShouldMarkForRemoval()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule("Slow Remove", resetOnProgress: false, maxStrikes: 8);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
|
||||
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
|
||||
Assert.True(result.ShouldRemove);
|
||||
|
||||
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)slowRule.MaxStrikes, StrikeType.SlowTime), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateSlowRulesAsync_TimeBasedRule_WhenEtaIsAcceptable_ShouldResetStrikes()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule("Slow Progress", resetOnProgress: true, maxStrikes: 4);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), StrikeType.SlowTime))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
torrentMock.SetupGet(t => t.Eta).Returns(1800); // ETA is 0.5 hours, below the 1 hour threshold
|
||||
|
||||
await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
|
||||
strikerMock.Verify(x => x.ResetStrikeAsync("hash", "Example Torrent", StrikeType.SlowTime), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateSlowRulesAsync_WhenStrikeThrows_ShouldThrowException()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
var failingRule = CreateSlowRule("Failing Slow", resetOnProgress: false, maxStrikes: 4);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(failingRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime))
|
||||
.ThrowsAsync(new InvalidOperationException("slow fail"));
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => evaluator.EvaluateSlowRulesAsync(torrentMock.Object));
|
||||
|
||||
strikerMock.Verify(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateSlowRulesAsync_WithSpeedBasedRule_ShouldUseSlowSpeedStrikeType()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule(
|
||||
name: "Speed Rule",
|
||||
resetOnProgress: false,
|
||||
maxStrikes: 3,
|
||||
minSpeed: "1 MB",
|
||||
maxTimeHours: 0);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowSpeed))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
|
||||
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
|
||||
Assert.True(result.ShouldRemove);
|
||||
|
||||
strikerMock.Verify(
|
||||
x => x.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)slowRule.MaxStrikes, StrikeType.SlowSpeed),
|
||||
Times.Once);
|
||||
strikerMock.Verify(
|
||||
x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<StrikeType>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateSlowRulesAsync_WithBothSpeedAndTimeConfigured_ShouldTreatAsSlowSpeed()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule(
|
||||
name: "Both Rule",
|
||||
resetOnProgress: false,
|
||||
maxStrikes: 2,
|
||||
minSpeed: "500 KB",
|
||||
maxTimeHours: 2);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowSpeed))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
|
||||
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
|
||||
Assert.True(result.ShouldRemove);
|
||||
|
||||
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)slowRule.MaxStrikes, StrikeType.SlowSpeed), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateSlowRulesAsync_WithNeitherSpeedNorTimeConfigured_ShouldNotStrike()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
// Neither minSpeed nor maxTime set (maxTimeHours = 0, minSpeed = null)
|
||||
var slowRule = CreateSlowRule(
|
||||
name: "Fallback Rule",
|
||||
resetOnProgress: false,
|
||||
maxStrikes: 1,
|
||||
minSpeed: null,
|
||||
maxTimeHours: 0);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
|
||||
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
|
||||
Assert.False(result.ShouldRemove);
|
||||
|
||||
strikerMock.Verify(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), It.IsAny<StrikeType>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateSlowRulesAsync_SpeedBasedRule_WhenSpeedIsAcceptable_ShouldResetSlowSpeedStrikes()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule(
|
||||
name: "Speed Reset",
|
||||
resetOnProgress: true,
|
||||
maxStrikes: 3,
|
||||
minSpeed: "1 MB",
|
||||
maxTimeHours: 0);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), StrikeType.SlowSpeed))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
torrentMock.SetupGet(t => t.DownloadSpeed).Returns(ByteSize.Parse("2 MB").Bytes); // Speed is above 1 MB threshold
|
||||
|
||||
await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
|
||||
strikerMock.Verify(x => x.ResetStrikeAsync("hash", "Example Torrent", StrikeType.SlowSpeed), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateSlowRulesAsync_SpeedBasedRule_WithResetDisabled_ShouldNotReset()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule(
|
||||
name: "Speed No Reset",
|
||||
resetOnProgress: false,
|
||||
maxStrikes: 3,
|
||||
minSpeed: "1 MB",
|
||||
maxTimeHours: 0);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
torrentMock.SetupGet(t => t.DownloadSpeed).Returns(ByteSize.Parse("2 MB").Bytes); // Speed is above threshold
|
||||
|
||||
await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
|
||||
strikerMock.Verify(x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<StrikeType>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateSlowRulesAsync_TimeBasedRule_WithResetDisabled_ShouldNotReset()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule(
|
||||
name: "Time No Reset",
|
||||
resetOnProgress: false,
|
||||
maxStrikes: 4,
|
||||
minSpeed: null,
|
||||
maxTimeHours: 2);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
torrentMock.SetupGet(t => t.Eta).Returns(1800); // ETA below threshold
|
||||
|
||||
await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
|
||||
strikerMock.Verify(x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<StrikeType>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateSlowRulesAsync_SpeedBased_BelowThreshold_ShouldStrike()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule(
|
||||
name: "Speed Strike",
|
||||
resetOnProgress: false,
|
||||
maxStrikes: 3,
|
||||
minSpeed: "5 MB",
|
||||
maxTimeHours: 0);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowSpeed))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
torrentMock.SetupGet(t => t.DownloadSpeed).Returns(ByteSize.Parse("1 MB").Bytes); // Speed below 5 MB threshold
|
||||
|
||||
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
|
||||
Assert.False(result.ShouldRemove);
|
||||
|
||||
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", 3, StrikeType.SlowSpeed), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateSlowRulesAsync_TimeBased_AboveThreshold_ShouldStrike()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule(
|
||||
name: "Time Strike",
|
||||
resetOnProgress: false,
|
||||
maxStrikes: 5,
|
||||
minSpeed: null,
|
||||
maxTimeHours: 1);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
torrentMock.SetupGet(t => t.Eta).Returns(7200); // 2 hours, above 1 hour threshold
|
||||
|
||||
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
|
||||
Assert.False(result.ShouldRemove);
|
||||
|
||||
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", 5, StrikeType.SlowTime), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateStallRulesAsync_WithResetDisabled_ShouldNotReset()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
var stallRule = CreateStallRule("No Reset", resetOnProgress: false, maxStrikes: 3);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(stallRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
long downloadedBytes = ByteSize.Parse("50 MB").Bytes;
|
||||
var torrentMock = CreateTorrentMock(downloadedBytesFactory: () => downloadedBytes);
|
||||
|
||||
await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
|
||||
|
||||
// Progress made but reset disabled, so no reset
|
||||
downloadedBytes = ByteSize.Parse("60 MB").Bytes;
|
||||
await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
|
||||
|
||||
strikerMock.Verify(x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), StrikeType.Stalled), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateStallRulesAsync_WithProgressAndNoMinimumThreshold_ShouldReset()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
var stallRule = CreateStallRule("Reset No Minimum", resetOnProgress: true, maxStrikes: 3, minimumProgress: null);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(stallRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), StrikeType.Stalled))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
long downloadedBytes = 0;
|
||||
var torrentMock = CreateTorrentMock(downloadedBytesFactory: () => downloadedBytes);
|
||||
|
||||
// Seed cache
|
||||
await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
|
||||
strikerMock.Verify(x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), StrikeType.Stalled), Times.Never);
|
||||
|
||||
// Any progress should trigger reset when no minimum is set
|
||||
downloadedBytes = ByteSize.Parse("1 KB").Bytes;
|
||||
await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
|
||||
strikerMock.Verify(x => x.ResetStrikeAsync("hash", "Example Torrent", StrikeType.Stalled), Times.Once);
|
||||
}
|
||||
|
||||
private static Mock<ITorrentItem> CreateTorrentMock(
|
||||
Func<long>? downloadedBytesFactory = null,
|
||||
bool isPrivate = false,
|
||||
string hash = "hash",
|
||||
string name = "Example Torrent",
|
||||
double completionPercentage = 50,
|
||||
string size = "100 MB")
|
||||
{
|
||||
var torrentMock = new Mock<ITorrentItem>();
|
||||
torrentMock.SetupGet(t => t.Hash).Returns(hash);
|
||||
torrentMock.SetupGet(t => t.Name).Returns(name);
|
||||
torrentMock.SetupGet(t => t.IsPrivate).Returns(isPrivate);
|
||||
torrentMock.SetupGet(t => t.CompletionPercentage).Returns(completionPercentage);
|
||||
torrentMock.SetupGet(t => t.Size).Returns(ByteSize.Parse(size).Bytes);
|
||||
torrentMock.SetupGet(t => t.Trackers).Returns(Array.Empty<string>());
|
||||
torrentMock.SetupGet(t => t.DownloadedBytes).Returns(() => downloadedBytesFactory?.Invoke() ?? 0);
|
||||
torrentMock.SetupGet(t => t.DownloadSpeed).Returns(0);
|
||||
torrentMock.SetupGet(t => t.Eta).Returns(7200);
|
||||
return torrentMock;
|
||||
}
|
||||
|
||||
private static StallRule CreateStallRule(string name, bool resetOnProgress, int maxStrikes, string? minimumProgress = null, bool deletePrivateTorrentsFromClient = false)
|
||||
{
|
||||
return new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
QueueCleanerConfigId = Guid.NewGuid(),
|
||||
Name = name,
|
||||
Enabled = true,
|
||||
MaxStrikes = maxStrikes,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 100,
|
||||
ResetStrikesOnProgress = resetOnProgress,
|
||||
MinimumProgress = minimumProgress,
|
||||
DeletePrivateTorrentsFromClient = deletePrivateTorrentsFromClient,
|
||||
};
|
||||
}
|
||||
|
||||
private static SlowRule CreateSlowRule(
|
||||
string name,
|
||||
bool resetOnProgress,
|
||||
int maxStrikes,
|
||||
string? minSpeed = null,
|
||||
double maxTimeHours = 1,
|
||||
bool deletePrivateTorrentsFromClient = false)
|
||||
{
|
||||
return new SlowRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
QueueCleanerConfigId = Guid.NewGuid(),
|
||||
Name = name,
|
||||
Enabled = true,
|
||||
MaxStrikes = maxStrikes,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 100,
|
||||
ResetStrikesOnProgress = resetOnProgress,
|
||||
MaxTimeHours = maxTimeHours,
|
||||
MinSpeed = minSpeed ?? string.Empty,
|
||||
IgnoreAboveSize = string.Empty,
|
||||
DeletePrivateTorrentsFromClient = deletePrivateTorrentsFromClient,
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateStallRulesAsync_WhenNoRuleMatches_ShouldReturnDeleteFromClientFalse()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns((StallRule?)null);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
|
||||
var result = await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
|
||||
|
||||
Assert.False(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.None, result.Reason);
|
||||
Assert.False(result.DeleteFromClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateStallRulesAsync_WhenRuleMatchesButNoRemoval_ShouldReturnDeleteFromClientFalse()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
var stallRule = CreateStallRule("Test Rule", resetOnProgress: false, maxStrikes: 3, deletePrivateTorrentsFromClient: true);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(stallRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
|
||||
var result = await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
|
||||
|
||||
Assert.False(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.None, result.Reason);
|
||||
Assert.False(result.DeleteFromClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateStallRulesAsync_WhenRuleMatchesAndRemovesWithDeleteFromClientTrue_ShouldReturnTrue()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
var stallRule = CreateStallRule("Delete True Rule", resetOnProgress: false, maxStrikes: 3, deletePrivateTorrentsFromClient: true);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(stallRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
|
||||
var result = await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
|
||||
|
||||
Assert.True(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.Stalled, result.Reason);
|
||||
Assert.True(result.DeleteFromClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateStallRulesAsync_WhenRuleMatchesAndRemovesWithDeleteFromClientFalse_ShouldReturnFalse()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
var stallRule = CreateStallRule("Delete False Rule", resetOnProgress: false, maxStrikes: 3, deletePrivateTorrentsFromClient: false);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(stallRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
|
||||
var result = await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
|
||||
|
||||
Assert.True(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.Stalled, result.Reason);
|
||||
Assert.False(result.DeleteFromClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateSlowRulesAsync_WhenNoRuleMatches_ShouldReturnDeleteFromClientFalse()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns((SlowRule?)null);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
|
||||
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
|
||||
|
||||
Assert.False(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.None, result.Reason);
|
||||
Assert.False(result.DeleteFromClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateSlowRulesAsync_WhenRuleMatchesAndRemovesWithDeleteFromClientTrue_ShouldReturnTrue()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule("Slow Delete True", resetOnProgress: false, maxStrikes: 3, maxTimeHours: 1, deletePrivateTorrentsFromClient: true);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
|
||||
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
|
||||
|
||||
Assert.True(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.SlowTime, result.Reason);
|
||||
Assert.True(result.DeleteFromClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateSlowRulesAsync_WhenRuleMatchesAndRemovesWithDeleteFromClientFalse_ShouldReturnFalse()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule("Slow Delete False", resetOnProgress: false, maxStrikes: 3, maxTimeHours: 1, deletePrivateTorrentsFromClient: false);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
|
||||
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
|
||||
|
||||
Assert.True(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.SlowTime, result.Reason);
|
||||
Assert.False(result.DeleteFromClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateSlowRulesAsync_SpeedBasedRuleWithDeleteFromClientTrue_ShouldReturnTrue()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule(
|
||||
"Speed Delete True",
|
||||
resetOnProgress: false,
|
||||
maxStrikes: 3,
|
||||
minSpeed: "5 MB",
|
||||
maxTimeHours: 0,
|
||||
deletePrivateTorrentsFromClient: true);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowSpeed))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
torrentMock.SetupGet(t => t.DownloadSpeed).Returns(ByteSize.Parse("1 MB").Bytes);
|
||||
|
||||
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
|
||||
|
||||
Assert.True(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.SlowSpeed, result.Reason);
|
||||
Assert.True(result.DeleteFromClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateSlowRulesAsync_WhenRuleMatchesButNoRemoval_ShouldReturnDeleteFromClientFalse()
|
||||
{
|
||||
var ruleManagerMock = new Mock<IRuleManager>();
|
||||
var strikerMock = new Mock<IStriker>();
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
|
||||
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule("Test Slow Rule", resetOnProgress: false, maxStrikes: 3, maxTimeHours: 1, deletePrivateTorrentsFromClient: true);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
|
||||
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
|
||||
|
||||
Assert.False(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.None, result.Reason);
|
||||
Assert.False(result.DeleteFromClient);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,764 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Services;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Services;
|
||||
|
||||
public class RuleIntervalValidatorTests
|
||||
{
|
||||
private readonly RuleIntervalValidator _validator;
|
||||
|
||||
public RuleIntervalValidatorTests()
|
||||
{
|
||||
var logger = Mock.Of<ILogger<RuleIntervalValidator>>();
|
||||
_validator = new RuleIntervalValidator(logger);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateStallRuleIntervals_ReturnsFailureWhenOverlapDetected()
|
||||
{
|
||||
var existingRule = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Existing",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 50
|
||||
};
|
||||
|
||||
var newRule = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "New",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 40,
|
||||
MaxCompletionPercentage = 60
|
||||
};
|
||||
|
||||
var result = _validator.ValidateStallRuleIntervals(newRule, new List<StallRule> { existingRule });
|
||||
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Details.ShouldNotBeEmpty();
|
||||
result.ErrorMessage.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateStallRuleIntervals_AllowsAdjacentRanges()
|
||||
{
|
||||
var firstRule = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "First",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 40
|
||||
};
|
||||
|
||||
var newRule = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Second",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 41,
|
||||
MaxCompletionPercentage = 80
|
||||
};
|
||||
|
||||
var result = _validator.ValidateStallRuleIntervals(newRule, new List<StallRule> { firstRule });
|
||||
|
||||
result.IsValid.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateStallRuleIntervals_AllowsTouchingRanges()
|
||||
{
|
||||
var firstRule = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "First",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 40
|
||||
};
|
||||
|
||||
var newRule = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Second",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 40,
|
||||
MaxCompletionPercentage = 80
|
||||
};
|
||||
|
||||
var result = _validator.ValidateStallRuleIntervals(newRule, new List<StallRule> { firstRule });
|
||||
|
||||
result.IsValid.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateStallRuleIntervals_AllowsZeroWidthIntervalInsideExistingRange()
|
||||
{
|
||||
var existingRule = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Existing",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 10,
|
||||
MaxCompletionPercentage = 40
|
||||
};
|
||||
|
||||
var newRule = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "ZeroWidth",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 30,
|
||||
MaxCompletionPercentage = 30
|
||||
};
|
||||
|
||||
var result = _validator.ValidateStallRuleIntervals(newRule, new List<StallRule> { existingRule });
|
||||
|
||||
result.IsValid.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindGapsInCoverage_ReturnsFullGapWhenNoRules()
|
||||
{
|
||||
var gaps = _validator.FindGapsInCoverage(new List<StallRule>());
|
||||
|
||||
gaps.ShouldNotBeEmpty();
|
||||
gaps.Count(g => g.PrivacyType == TorrentPrivacyType.Public).ShouldBe(1);
|
||||
gaps.Count(g => g.PrivacyType == TorrentPrivacyType.Private).ShouldBe(1);
|
||||
|
||||
gaps.First(g => g.PrivacyType == TorrentPrivacyType.Public).ShouldSatisfyAllConditions(
|
||||
gap => gap.Start.ShouldBe(0),
|
||||
gap => gap.End.ShouldBe(100)
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindGapsInCoverage_UsesMinimumBoundaries()
|
||||
{
|
||||
var rules = new List<StallRule>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Partial",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 40
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Upper",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 60,
|
||||
MaxCompletionPercentage = 90
|
||||
}
|
||||
};
|
||||
|
||||
var gaps = _validator.FindGapsInCoverage(rules);
|
||||
|
||||
var publicGap = gaps.FirstOrDefault(g => g.PrivacyType == TorrentPrivacyType.Public && g.Start >= 40 && g.End <= 60);
|
||||
publicGap.ShouldNotBeNull();
|
||||
publicGap!.Start.ShouldBe(40);
|
||||
publicGap.End.ShouldBe(60);
|
||||
|
||||
var privateGap = gaps.First(g => g.PrivacyType == TorrentPrivacyType.Private);
|
||||
privateGap.Start.ShouldBe(0);
|
||||
privateGap.End.ShouldBe(100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateSlowRuleIntervals_AllowsTouchingRanges()
|
||||
{
|
||||
var firstRule = new SlowRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "First Slow",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 40,
|
||||
ResetStrikesOnProgress = false,
|
||||
MaxTimeHours = 1,
|
||||
MinSpeed = "1 MB"
|
||||
};
|
||||
|
||||
var newRule = new SlowRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Second Slow",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 40,
|
||||
MaxCompletionPercentage = 80,
|
||||
ResetStrikesOnProgress = false,
|
||||
MaxTimeHours = 1,
|
||||
MinSpeed = "1 MB"
|
||||
};
|
||||
|
||||
var result = _validator.ValidateSlowRuleIntervals(newRule, new List<SlowRule> { firstRule });
|
||||
|
||||
result.IsValid.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateStallRuleIntervals_DetectsOverlapWithBothRule()
|
||||
{
|
||||
var existingRule = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Both Existing",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Both,
|
||||
MinCompletionPercentage = 20,
|
||||
MaxCompletionPercentage = 60
|
||||
};
|
||||
|
||||
var newRule = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Public New",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 50,
|
||||
MaxCompletionPercentage = 70
|
||||
};
|
||||
|
||||
var result = _validator.ValidateStallRuleIntervals(newRule, new List<StallRule> { existingRule });
|
||||
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Details.ShouldNotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateSlowRuleIntervals_DetectsOverlap()
|
||||
{
|
||||
var existingRule = new SlowRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Existing Slow",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 10,
|
||||
MaxCompletionPercentage = 50,
|
||||
ResetStrikesOnProgress = false,
|
||||
MaxTimeHours = 1,
|
||||
MinSpeed = "1 MB"
|
||||
};
|
||||
|
||||
var newRule = new SlowRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "New Slow",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 40,
|
||||
MaxCompletionPercentage = 80,
|
||||
ResetStrikesOnProgress = false,
|
||||
MaxTimeHours = 1,
|
||||
MinSpeed = "1 MB"
|
||||
};
|
||||
|
||||
var result = _validator.ValidateSlowRuleIntervals(newRule, new List<SlowRule> { existingRule });
|
||||
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Details.ShouldNotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindGapsInCoverage_NoGapsWhenFullyCovered()
|
||||
{
|
||||
var rules = new List<StallRule>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Lower",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 50
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Upper",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 50,
|
||||
MaxCompletionPercentage = 100
|
||||
}
|
||||
};
|
||||
|
||||
var gaps = _validator.FindGapsInCoverage(rules);
|
||||
|
||||
// No public gaps expected
|
||||
gaps.Count(g => g.PrivacyType == TorrentPrivacyType.Public).ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindGapsInCoverage_NoGapsWhenBothRuleCoversAll()
|
||||
{
|
||||
var rules = new List<StallRule>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "BothCoverage",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Both,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 100
|
||||
}
|
||||
};
|
||||
|
||||
var gaps = _validator.FindGapsInCoverage(rules);
|
||||
|
||||
gaps.Count(g => g.PrivacyType == TorrentPrivacyType.Public).ShouldBe(0);
|
||||
gaps.Count(g => g.PrivacyType == TorrentPrivacyType.Private).ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindGapsInCoverage_ClampsBounds()
|
||||
{
|
||||
var rules = new List<StallRule>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "OutOfRange",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 20,
|
||||
MaxCompletionPercentage = 150
|
||||
}
|
||||
};
|
||||
|
||||
var gaps = _validator.FindGapsInCoverage(rules);
|
||||
|
||||
var publicGap = gaps.FirstOrDefault(g => g.PrivacyType == TorrentPrivacyType.Public);
|
||||
publicGap.ShouldNotBeNull();
|
||||
publicGap!.Start.ShouldBe(0);
|
||||
publicGap.End.ShouldBe(20);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateStallRuleIntervals_IgnoresDisabledRules()
|
||||
{
|
||||
var existingRule = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Disabled",
|
||||
Enabled = false,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 30,
|
||||
MaxCompletionPercentage = 70
|
||||
};
|
||||
|
||||
var newRule = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "New",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 40,
|
||||
MaxCompletionPercentage = 60
|
||||
};
|
||||
|
||||
var result = _validator.ValidateStallRuleIntervals(newRule, new List<StallRule> { existingRule });
|
||||
|
||||
result.IsValid.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateStallRuleIntervals_IgnoresOverlapWhenSameRuleId()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
|
||||
var existingRule = new StallRule
|
||||
{
|
||||
Id = id,
|
||||
Name = "Existing",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 10,
|
||||
MaxCompletionPercentage = 50
|
||||
};
|
||||
|
||||
// New rule uses the same Id as the existing rule; overlaps should be ignored
|
||||
var newRule = new StallRule
|
||||
{
|
||||
Id = id,
|
||||
Name = "NewSameId",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 40,
|
||||
MaxCompletionPercentage = 80
|
||||
};
|
||||
|
||||
var result = _validator.ValidateStallRuleIntervals(newRule, new List<StallRule> { existingRule });
|
||||
|
||||
// Since both rules share the same Id, they are considered the same rule and should not be treated as overlapping
|
||||
result.IsValid.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateStallRuleIntervals_DetectsOverlapInBothPublicAndPrivate()
|
||||
{
|
||||
var existingPublic = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "ExistingPublic",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 10,
|
||||
MaxCompletionPercentage = 30
|
||||
};
|
||||
|
||||
var existingPrivate = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "ExistingPrivate",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Private,
|
||||
MinCompletionPercentage = 15,
|
||||
MaxCompletionPercentage = 35
|
||||
};
|
||||
|
||||
// New rule applies to both privacy types and overlaps both existing rules
|
||||
var newRule = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "NewBoth",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Both,
|
||||
MinCompletionPercentage = 20,
|
||||
MaxCompletionPercentage = 25
|
||||
};
|
||||
|
||||
var result = _validator.ValidateStallRuleIntervals(newRule, new List<StallRule> { existingPublic, existingPrivate });
|
||||
|
||||
result.IsValid.ShouldBeFalse();
|
||||
// Expect at least two overlap details (one for public, one for private)
|
||||
result.Details.Count.ShouldBeGreaterThanOrEqualTo(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateStallRuleIntervals_DetectsPrivateOverlap()
|
||||
{
|
||||
var existingRule = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "ExistingPrivate",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Private,
|
||||
MinCompletionPercentage = 20,
|
||||
MaxCompletionPercentage = 60
|
||||
};
|
||||
|
||||
var newRule = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "NewPrivate",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Private,
|
||||
MinCompletionPercentage = 50,
|
||||
MaxCompletionPercentage = 80
|
||||
};
|
||||
|
||||
var result = _validator.ValidateStallRuleIntervals(newRule, new List<StallRule> { existingRule });
|
||||
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Details.ShouldNotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateStallRuleIntervals_SucceedsWithOnlyNewRuleEnabled()
|
||||
{
|
||||
var newRule = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "OnlyNew",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 10,
|
||||
MaxCompletionPercentage = 90
|
||||
};
|
||||
|
||||
// No existing rules
|
||||
var result = _validator.ValidateStallRuleIntervals(newRule, new List<StallRule>());
|
||||
|
||||
result.IsValid.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateStallRuleIntervals_AllowsNonOverlappingUnsortedRules()
|
||||
{
|
||||
var r1 = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "R1",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 60,
|
||||
MaxCompletionPercentage = 70
|
||||
};
|
||||
|
||||
var r2 = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "R2",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 10
|
||||
};
|
||||
|
||||
var newRule = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "New",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 20,
|
||||
MaxCompletionPercentage = 50
|
||||
};
|
||||
|
||||
// Pass existing rules in unsorted order to exercise sorting inside the validator
|
||||
var result = _validator.ValidateStallRuleIntervals(newRule, new List<StallRule> { r1, r2 });
|
||||
|
||||
result.IsValid.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindGapsInCoverage_IgnoresInvalidIntervalsWhereMaxLessThanMin()
|
||||
{
|
||||
var rules = new List<StallRule>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Invalid",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 80,
|
||||
MaxCompletionPercentage = 20
|
||||
}
|
||||
};
|
||||
|
||||
// Invalid interval should be ignored, resulting in full gap
|
||||
var gaps = _validator.FindGapsInCoverage(rules);
|
||||
|
||||
var publicGap = gaps.First(g => g.PrivacyType == TorrentPrivacyType.Public);
|
||||
publicGap.Start.ShouldBe(0);
|
||||
publicGap.End.ShouldBe(100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindGapsInCoverage_IgnoresDisabledRulesWhenCalculatingCoverage()
|
||||
{
|
||||
var rules = new List<StallRule>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Disabled",
|
||||
Enabled = false,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 100
|
||||
}
|
||||
};
|
||||
|
||||
var gaps = _validator.FindGapsInCoverage(rules);
|
||||
|
||||
gaps.Count(g => g.PrivacyType == TorrentPrivacyType.Public).ShouldBe(1);
|
||||
gaps.Count(g => g.PrivacyType == TorrentPrivacyType.Private).ShouldBe(1);
|
||||
|
||||
var publicGap = gaps.First(g => g.PrivacyType == TorrentPrivacyType.Public);
|
||||
publicGap.Start.ShouldBe(0);
|
||||
publicGap.End.ShouldBe(100);
|
||||
|
||||
var privateGap = gaps.First(g => g.PrivacyType == TorrentPrivacyType.Private);
|
||||
privateGap.Start.ShouldBe(0);
|
||||
privateGap.End.ShouldBe(100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateStallRuleIntervals_DoesNotReportSelfOverlapWhenEnablingDisabledRule()
|
||||
{
|
||||
var ruleId = Guid.NewGuid();
|
||||
|
||||
// Simulate existing disabled rule
|
||||
var disabledRule = new StallRule
|
||||
{
|
||||
Id = ruleId,
|
||||
Name = "Rule1",
|
||||
Enabled = false,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Both,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 100
|
||||
};
|
||||
|
||||
// Simulate another existing enabled rule
|
||||
var enabledRule = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Rule2",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Both,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 100
|
||||
};
|
||||
|
||||
// Now we enable the first rule (same ID, but now enabled)
|
||||
var nowEnabledRule = new StallRule
|
||||
{
|
||||
Id = ruleId,
|
||||
Name = "Rule1",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Both,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 100
|
||||
};
|
||||
|
||||
// Validation should detect overlap with Rule2, not with itself
|
||||
var result = _validator.ValidateStallRuleIntervals(nowEnabledRule, new List<StallRule> { disabledRule, enabledRule });
|
||||
|
||||
result.IsValid.ShouldBeFalse();
|
||||
// The error should mention Rule2, not Rule1
|
||||
result.ErrorMessage.ShouldContain("Rule2");
|
||||
result.ErrorMessage.ShouldNotContain("Rule1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateStallRuleIntervals_BothPrivacyTypeDoesNotSelfOverlap()
|
||||
{
|
||||
var ruleId = Guid.NewGuid();
|
||||
|
||||
// Create a single rule with Both privacy type
|
||||
var newRule = new StallRule
|
||||
{
|
||||
Id = ruleId,
|
||||
Name = "BothRule",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Both,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 100
|
||||
};
|
||||
|
||||
// Validate with no existing rules - should pass
|
||||
var result = _validator.ValidateStallRuleIntervals(newRule, new List<StallRule>());
|
||||
|
||||
result.IsValid.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateStallRuleIntervals_DetectsMultipleOverlaps()
|
||||
{
|
||||
// Rule1 covers 0-80%
|
||||
var rule1 = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Rule1",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 80
|
||||
};
|
||||
|
||||
// Rule2 covers 80-100%
|
||||
var rule2 = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Rule2",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 80,
|
||||
MaxCompletionPercentage = 100
|
||||
};
|
||||
|
||||
// Rule3 overlaps both Rule1 and Rule2 (0-90%)
|
||||
var rule3 = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Rule3",
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 90
|
||||
};
|
||||
|
||||
var result = _validator.ValidateStallRuleIntervals(rule3, new List<StallRule> { rule1, rule2 });
|
||||
|
||||
result.IsValid.ShouldBeFalse();
|
||||
// Should detect overlaps with both Rule1 and Rule2
|
||||
result.Details.Count.ShouldBe(2);
|
||||
result.Details.ShouldContain("Rule1");
|
||||
result.Details.ShouldContain("Rule2");
|
||||
result.ErrorMessage.ShouldContain("Rule1");
|
||||
result.ErrorMessage.ShouldContain("Rule2");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
using Cleanuparr.Domain.Entities;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Services;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Services;
|
||||
|
||||
public class RuleManagerTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetMatchingStallRule_NoRules_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var loggerMock = new Mock<ILogger<RuleManager>>();
|
||||
var ruleManager = new RuleManager(loggerMock.Object);
|
||||
|
||||
ContextProvider.Set(nameof(StallRule), new List<StallRule>());
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
|
||||
// Act
|
||||
var result = ruleManager.GetMatchingStallRule(torrentMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMatchingStallRule_OneMatch_ReturnsRule()
|
||||
{
|
||||
// Arrange
|
||||
var loggerMock = new Mock<ILogger<RuleManager>>();
|
||||
var ruleManager = new RuleManager(loggerMock.Object);
|
||||
|
||||
var stallRule = CreateStallRule("Test Rule", enabled: true, privacyType: TorrentPrivacyType.Public, minCompletion: 0, maxCompletion: 100);
|
||||
ContextProvider.Set(nameof(StallRule), new List<StallRule> { stallRule });
|
||||
|
||||
var torrentMock = CreateTorrentMock(isPrivate: false, completionPercentage: 50);
|
||||
|
||||
// Act
|
||||
var result = ruleManager.GetMatchingStallRule(torrentMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(stallRule.Id, result.Id);
|
||||
Assert.Equal("Test Rule", result.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMatchingStallRule_MultipleMatches_ReturnsNull_LogsWarning()
|
||||
{
|
||||
// Arrange
|
||||
var loggerMock = new Mock<ILogger<RuleManager>>();
|
||||
var ruleManager = new RuleManager(loggerMock.Object);
|
||||
|
||||
var stallRule1 = CreateStallRule("Rule 1", enabled: true, privacyType: TorrentPrivacyType.Public, minCompletion: 0, maxCompletion: 100);
|
||||
var stallRule2 = CreateStallRule("Rule 2", enabled: true, privacyType: TorrentPrivacyType.Public, minCompletion: 0, maxCompletion: 100);
|
||||
ContextProvider.Set(nameof(StallRule), new List<StallRule> { stallRule1, stallRule2 });
|
||||
|
||||
var torrentMock = CreateTorrentMock(isPrivate: false, completionPercentage: 50);
|
||||
|
||||
// Act
|
||||
var result = ruleManager.GetMatchingStallRule(torrentMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Warning,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("multiple")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMatchingStallRule_DisabledRule_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var loggerMock = new Mock<ILogger<RuleManager>>();
|
||||
var ruleManager = new RuleManager(loggerMock.Object);
|
||||
|
||||
var stallRule = CreateStallRule("Disabled Rule", enabled: false, privacyType: TorrentPrivacyType.Public, minCompletion: 0, maxCompletion: 100);
|
||||
ContextProvider.Set(nameof(StallRule), new List<StallRule> { stallRule });
|
||||
|
||||
var torrentMock = CreateTorrentMock(isPrivate: false, completionPercentage: 50);
|
||||
|
||||
// Act
|
||||
var result = ruleManager.GetMatchingStallRule(torrentMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMatchingStallRule_PrivacyTypeMismatch_Public_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var loggerMock = new Mock<ILogger<RuleManager>>();
|
||||
var ruleManager = new RuleManager(loggerMock.Object);
|
||||
|
||||
var stallRule = CreateStallRule("Public Rule", enabled: true, privacyType: TorrentPrivacyType.Public, minCompletion: 0, maxCompletion: 100);
|
||||
ContextProvider.Set(nameof(StallRule), new List<StallRule> { stallRule });
|
||||
|
||||
var torrentMock = CreateTorrentMock(isPrivate: true, completionPercentage: 50); // Private torrent
|
||||
|
||||
// Act
|
||||
var result = ruleManager.GetMatchingStallRule(torrentMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMatchingStallRule_PrivacyTypeMismatch_Private_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var loggerMock = new Mock<ILogger<RuleManager>>();
|
||||
var ruleManager = new RuleManager(loggerMock.Object);
|
||||
|
||||
var stallRule = CreateStallRule("Private Rule", enabled: true, privacyType: TorrentPrivacyType.Private, minCompletion: 0, maxCompletion: 100);
|
||||
ContextProvider.Set(nameof(StallRule), new List<StallRule> { stallRule });
|
||||
|
||||
var torrentMock = CreateTorrentMock(isPrivate: false, completionPercentage: 50); // Public torrent
|
||||
|
||||
// Act
|
||||
var result = ruleManager.GetMatchingStallRule(torrentMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMatchingStallRule_PrivacyTypeBoth_MatchesPublic()
|
||||
{
|
||||
// Arrange
|
||||
var loggerMock = new Mock<ILogger<RuleManager>>();
|
||||
var ruleManager = new RuleManager(loggerMock.Object);
|
||||
|
||||
var stallRule = CreateStallRule("Both Rule", enabled: true, privacyType: TorrentPrivacyType.Both, minCompletion: 0, maxCompletion: 100);
|
||||
ContextProvider.Set(nameof(StallRule), new List<StallRule> { stallRule });
|
||||
|
||||
var torrentMock = CreateTorrentMock(isPrivate: false, completionPercentage: 50);
|
||||
|
||||
// Act
|
||||
var result = ruleManager.GetMatchingStallRule(torrentMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(stallRule.Id, result.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMatchingStallRule_PrivacyTypeBoth_MatchesPrivate()
|
||||
{
|
||||
// Arrange
|
||||
var loggerMock = new Mock<ILogger<RuleManager>>();
|
||||
var ruleManager = new RuleManager(loggerMock.Object);
|
||||
|
||||
var stallRule = CreateStallRule("Both Rule", enabled: true, privacyType: TorrentPrivacyType.Both, minCompletion: 0, maxCompletion: 100);
|
||||
ContextProvider.Set(nameof(StallRule), new List<StallRule> { stallRule });
|
||||
|
||||
var torrentMock = CreateTorrentMock(isPrivate: true, completionPercentage: 50);
|
||||
|
||||
// Act
|
||||
var result = ruleManager.GetMatchingStallRule(torrentMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(stallRule.Id, result.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMatchingStallRule_CompletionPercentageBelowMin_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var loggerMock = new Mock<ILogger<RuleManager>>();
|
||||
var ruleManager = new RuleManager(loggerMock.Object);
|
||||
|
||||
var stallRule = CreateStallRule("Rule 20-80", enabled: true, privacyType: TorrentPrivacyType.Public, minCompletion: 20, maxCompletion: 80);
|
||||
ContextProvider.Set(nameof(StallRule), new List<StallRule> { stallRule });
|
||||
|
||||
var torrentMock = CreateTorrentMock(isPrivate: false, completionPercentage: 10); // Below 20%
|
||||
|
||||
// Act
|
||||
var result = ruleManager.GetMatchingStallRule(torrentMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMatchingStallRule_CompletionPercentageAboveMax_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var loggerMock = new Mock<ILogger<RuleManager>>();
|
||||
var ruleManager = new RuleManager(loggerMock.Object);
|
||||
|
||||
var stallRule = CreateStallRule("Rule 20-80", enabled: true, privacyType: TorrentPrivacyType.Public, minCompletion: 20, maxCompletion: 80);
|
||||
ContextProvider.Set(nameof(StallRule), new List<StallRule> { stallRule });
|
||||
|
||||
var torrentMock = CreateTorrentMock(isPrivate: false, completionPercentage: 90); // Above 80%
|
||||
|
||||
// Act
|
||||
var result = ruleManager.GetMatchingStallRule(torrentMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMatchingStallRule_CompletionPercentageAtMinBoundary_Matches()
|
||||
{
|
||||
// Arrange
|
||||
var loggerMock = new Mock<ILogger<RuleManager>>();
|
||||
var ruleManager = new RuleManager(loggerMock.Object);
|
||||
|
||||
var stallRule = CreateStallRule("Rule 20-80", enabled: true, privacyType: TorrentPrivacyType.Public, minCompletion: 20, maxCompletion: 80);
|
||||
ContextProvider.Set(nameof(StallRule), new List<StallRule> { stallRule });
|
||||
|
||||
var torrentMock = CreateTorrentMock(isPrivate: false, completionPercentage: 20.1); // Just above 20%
|
||||
|
||||
// Act
|
||||
var result = ruleManager.GetMatchingStallRule(torrentMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(stallRule.Id, result.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMatchingStallRule_CompletionPercentageAtMaxBoundary_Matches()
|
||||
{
|
||||
// Arrange
|
||||
var loggerMock = new Mock<ILogger<RuleManager>>();
|
||||
var ruleManager = new RuleManager(loggerMock.Object);
|
||||
|
||||
var stallRule = CreateStallRule("Rule 20-80", enabled: true, privacyType: TorrentPrivacyType.Public, minCompletion: 20, maxCompletion: 80);
|
||||
ContextProvider.Set(nameof(StallRule), new List<StallRule> { stallRule });
|
||||
|
||||
var torrentMock = CreateTorrentMock(isPrivate: false, completionPercentage: 80); // Exactly at 80%
|
||||
|
||||
// Act
|
||||
var result = ruleManager.GetMatchingStallRule(torrentMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(stallRule.Id, result.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMatchingSlowRule_NoRules_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var loggerMock = new Mock<ILogger<RuleManager>>();
|
||||
var ruleManager = new RuleManager(loggerMock.Object);
|
||||
|
||||
ContextProvider.Set(nameof(SlowRule), new List<SlowRule>());
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
|
||||
// Act
|
||||
var result = ruleManager.GetMatchingSlowRule(torrentMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMatchingSlowRule_OneMatch_ReturnsRule()
|
||||
{
|
||||
// Arrange
|
||||
var loggerMock = new Mock<ILogger<RuleManager>>();
|
||||
var ruleManager = new RuleManager(loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule("Slow Rule", enabled: true, privacyType: TorrentPrivacyType.Public, minCompletion: 0, maxCompletion: 100);
|
||||
ContextProvider.Set(nameof(SlowRule), new List<SlowRule> { slowRule });
|
||||
|
||||
var torrentMock = CreateTorrentMock(isPrivate: false, completionPercentage: 50);
|
||||
|
||||
// Act
|
||||
var result = ruleManager.GetMatchingSlowRule(torrentMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(slowRule.Id, result.Id);
|
||||
Assert.Equal("Slow Rule", result.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMatchingSlowRule_MultipleMatches_ReturnsNull_LogsWarning()
|
||||
{
|
||||
// Arrange
|
||||
var loggerMock = new Mock<ILogger<RuleManager>>();
|
||||
var ruleManager = new RuleManager(loggerMock.Object);
|
||||
|
||||
var slowRule1 = CreateSlowRule("Slow 1", enabled: true, privacyType: TorrentPrivacyType.Public, minCompletion: 0, maxCompletion: 100);
|
||||
var slowRule2 = CreateSlowRule("Slow 2", enabled: true, privacyType: TorrentPrivacyType.Public, minCompletion: 0, maxCompletion: 100);
|
||||
ContextProvider.Set(nameof(SlowRule), new List<SlowRule> { slowRule1, slowRule2 });
|
||||
|
||||
var torrentMock = CreateTorrentMock(isPrivate: false, completionPercentage: 50);
|
||||
|
||||
// Act
|
||||
var result = ruleManager.GetMatchingSlowRule(torrentMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Warning,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("multiple")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMatchingSlowRule_FileSizeAboveIgnoreThreshold_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var loggerMock = new Mock<ILogger<RuleManager>>();
|
||||
var ruleManager = new RuleManager(loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule("Size Limited", enabled: true, privacyType: TorrentPrivacyType.Public, minCompletion: 0, maxCompletion: 100, ignoreAboveSize: "50 MB");
|
||||
ContextProvider.Set(nameof(SlowRule), new List<SlowRule> { slowRule });
|
||||
|
||||
var torrentMock = CreateTorrentMock(isPrivate: false, completionPercentage: 50, size: "100 MB"); // Torrent is 100 MB, above 50 MB threshold
|
||||
|
||||
// Act
|
||||
var result = ruleManager.GetMatchingSlowRule(torrentMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMatchingSlowRule_FileSizeBelowIgnoreThreshold_Matches()
|
||||
{
|
||||
// Arrange
|
||||
var loggerMock = new Mock<ILogger<RuleManager>>();
|
||||
var ruleManager = new RuleManager(loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule("Size Limited", enabled: true, privacyType: TorrentPrivacyType.Public, minCompletion: 0, maxCompletion: 100, ignoreAboveSize: "50 MB");
|
||||
ContextProvider.Set(nameof(SlowRule), new List<SlowRule> { slowRule });
|
||||
|
||||
var torrentMock = CreateTorrentMock(isPrivate: false, completionPercentage: 50, size: "30 MB"); // Torrent is 30 MB, below 50 MB threshold
|
||||
|
||||
// Act
|
||||
var result = ruleManager.GetMatchingSlowRule(torrentMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(slowRule.Id, result.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMatchingSlowRule_NoIgnoreSizeSet_Matches()
|
||||
{
|
||||
// Arrange
|
||||
var loggerMock = new Mock<ILogger<RuleManager>>();
|
||||
var ruleManager = new RuleManager(loggerMock.Object);
|
||||
|
||||
var slowRule = CreateSlowRule("No Size Limit", enabled: true, privacyType: TorrentPrivacyType.Public, minCompletion: 0, maxCompletion: 100, ignoreAboveSize: string.Empty);
|
||||
ContextProvider.Set(nameof(SlowRule), new List<SlowRule> { slowRule });
|
||||
|
||||
var torrentMock = CreateTorrentMock(isPrivate: false, completionPercentage: 50, size: "1 GB"); // Any size should match
|
||||
|
||||
// Act
|
||||
var result = ruleManager.GetMatchingSlowRule(torrentMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(slowRule.Id, result.Id);
|
||||
}
|
||||
|
||||
private static Mock<ITorrentItem> CreateTorrentMock(
|
||||
bool isPrivate = false,
|
||||
double completionPercentage = 50,
|
||||
string size = "100 MB")
|
||||
{
|
||||
var torrentMock = new Mock<ITorrentItem>();
|
||||
torrentMock.SetupGet(t => t.Hash).Returns("test-hash");
|
||||
torrentMock.SetupGet(t => t.Name).Returns("Test Torrent");
|
||||
torrentMock.SetupGet(t => t.IsPrivate).Returns(isPrivate);
|
||||
torrentMock.SetupGet(t => t.CompletionPercentage).Returns(completionPercentage);
|
||||
torrentMock.SetupGet(t => t.Size).Returns(ByteSize.Parse(size).Bytes);
|
||||
torrentMock.SetupGet(t => t.Trackers).Returns(Array.Empty<string>());
|
||||
torrentMock.SetupGet(t => t.DownloadedBytes).Returns(0);
|
||||
torrentMock.SetupGet(t => t.DownloadSpeed).Returns(0);
|
||||
torrentMock.SetupGet(t => t.Eta).Returns(3600);
|
||||
return torrentMock;
|
||||
}
|
||||
|
||||
private static StallRule CreateStallRule(
|
||||
string name,
|
||||
bool enabled,
|
||||
TorrentPrivacyType privacyType,
|
||||
ushort minCompletion,
|
||||
ushort maxCompletion)
|
||||
{
|
||||
return new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
QueueCleanerConfigId = Guid.NewGuid(),
|
||||
Name = name,
|
||||
Enabled = enabled,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = privacyType,
|
||||
MinCompletionPercentage = minCompletion,
|
||||
MaxCompletionPercentage = maxCompletion,
|
||||
ResetStrikesOnProgress = false,
|
||||
MinimumProgress = null,
|
||||
DeletePrivateTorrentsFromClient = false,
|
||||
};
|
||||
}
|
||||
|
||||
private static SlowRule CreateSlowRule(
|
||||
string name,
|
||||
bool enabled,
|
||||
TorrentPrivacyType privacyType,
|
||||
ushort minCompletion,
|
||||
ushort maxCompletion,
|
||||
string? ignoreAboveSize = null)
|
||||
{
|
||||
return new SlowRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
QueueCleanerConfigId = Guid.NewGuid(),
|
||||
Name = name,
|
||||
Enabled = enabled,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = privacyType,
|
||||
MinCompletionPercentage = minCompletion,
|
||||
MaxCompletionPercentage = maxCompletion,
|
||||
ResetStrikesOnProgress = false,
|
||||
MaxTimeHours = 1,
|
||||
MinSpeed = "1 MB",
|
||||
IgnoreAboveSize = ignoreAboveSize ?? string.Empty,
|
||||
DeletePrivateTorrentsFromClient = false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
using System.Net;
|
||||
using Cleanuparr.Domain.Entities.UTorrent.Response;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
using Newtonsoft.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Verticals.DownloadClient;
|
||||
|
||||
public class UTorrentClientTests
|
||||
{
|
||||
private readonly UTorrentClient _client;
|
||||
private readonly Mock<HttpMessageHandler> _mockHttpHandler;
|
||||
private readonly DownloadClientConfig _config;
|
||||
private readonly Mock<IUTorrentAuthenticator> _mockAuthenticator;
|
||||
private readonly Mock<IUTorrentHttpService> _mockHttpService;
|
||||
private readonly Mock<IUTorrentResponseParser> _mockResponseParser;
|
||||
private readonly Mock<ILogger<UTorrentClient>> _mockLogger;
|
||||
|
||||
public UTorrentClientTests()
|
||||
{
|
||||
_mockHttpHandler = new Mock<HttpMessageHandler>();
|
||||
_mockAuthenticator = new Mock<IUTorrentAuthenticator>();
|
||||
_mockHttpService = new Mock<IUTorrentHttpService>();
|
||||
_mockResponseParser = new Mock<IUTorrentResponseParser>();
|
||||
_mockLogger = new Mock<ILogger<UTorrentClient>>();
|
||||
|
||||
_config = new DownloadClientConfig
|
||||
{
|
||||
Name = "test",
|
||||
Type = DownloadClientType.Torrent,
|
||||
TypeName = DownloadClientTypeName.uTorrent,
|
||||
Host = new Uri("http://localhost:8080"),
|
||||
Username = "admin",
|
||||
Password = "password"
|
||||
};
|
||||
|
||||
_client = new UTorrentClient(
|
||||
_config,
|
||||
_mockAuthenticator.Object,
|
||||
_mockHttpService.Object,
|
||||
_mockResponseParser.Object,
|
||||
_mockLogger.Object
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTorrentFilesAsync_ShouldDeserializeMixedArrayCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var mockResponse = new UTorrentResponse<object>
|
||||
{
|
||||
Build = 30470,
|
||||
FilesDto = new object[]
|
||||
{
|
||||
"F0616FB199B78254474AF6D72705177E71D713ED", // Hash (string)
|
||||
new object[] // File 1
|
||||
{
|
||||
"test name",
|
||||
2604L,
|
||||
0L,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
false,
|
||||
-1,
|
||||
-1,
|
||||
-1,
|
||||
-1,
|
||||
-1,
|
||||
0
|
||||
},
|
||||
new object[] // File 2
|
||||
{
|
||||
"Dir1/Dir11/test11.zipx",
|
||||
2604L,
|
||||
0L,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
false,
|
||||
-1,
|
||||
-1,
|
||||
-1,
|
||||
-1,
|
||||
-1,
|
||||
0
|
||||
},
|
||||
new object[] // File 3
|
||||
{
|
||||
"Dir1/sample.txt",
|
||||
2604L,
|
||||
0L,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
false,
|
||||
-1,
|
||||
-1,
|
||||
-1,
|
||||
-1,
|
||||
-1,
|
||||
0
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Mock the token request
|
||||
var tokenResponse = new HttpResponseMessage
|
||||
{
|
||||
StatusCode = HttpStatusCode.OK,
|
||||
Content = new StringContent("<div id='token'>test-token</div>")
|
||||
};
|
||||
tokenResponse.Headers.Add("Set-Cookie", "GUID=test-guid; path=/");
|
||||
|
||||
// Mock the files request
|
||||
var filesResponse = new HttpResponseMessage
|
||||
{
|
||||
StatusCode = HttpStatusCode.OK,
|
||||
Content = new StringContent(JsonConvert.SerializeObject(mockResponse))
|
||||
};
|
||||
|
||||
// Setup mock to return different responses based on URL
|
||||
_mockHttpHandler
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>("SendAsync",
|
||||
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.AbsolutePath.Contains("token.html")),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(tokenResponse);
|
||||
|
||||
_mockHttpHandler
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>("SendAsync",
|
||||
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.AbsolutePath.Contains("gui") && req.RequestUri.Query.Contains("action=getfiles")),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(filesResponse);
|
||||
|
||||
// Act
|
||||
var files = await _client.GetTorrentFilesAsync("test-hash");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(files);
|
||||
Assert.Equal(3, files.Count);
|
||||
|
||||
Assert.Equal("test name", files[0].Name);
|
||||
Assert.Equal(2604L, files[0].Size);
|
||||
Assert.Equal(0L, files[0].Downloaded);
|
||||
Assert.Equal(2, files[0].Priority);
|
||||
Assert.Equal(0, files[0].Index);
|
||||
|
||||
Assert.Equal("Dir1/Dir11/test11.zipx", files[1].Name);
|
||||
Assert.Equal(2604L, files[1].Size);
|
||||
Assert.Equal(0L, files[1].Downloaded);
|
||||
Assert.Equal(2, files[1].Priority);
|
||||
Assert.Equal(1, files[1].Index);
|
||||
|
||||
Assert.Equal("Dir1/sample.txt", files[2].Name);
|
||||
Assert.Equal(2604L, files[2].Size);
|
||||
Assert.Equal(0L, files[2].Downloaded);
|
||||
Assert.Equal(2, files[2].Priority);
|
||||
Assert.Equal(2, files[2].Index);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTorrentFilesAsync_ShouldHandleEmptyResponse()
|
||||
{
|
||||
// Arrange
|
||||
var mockResponse = new UTorrentResponse<object>
|
||||
{
|
||||
Build = 30470,
|
||||
FilesDto = new object[]
|
||||
{
|
||||
"F0616FB199B78254474AF6D72705177E71D713ED" // Only hash, no files
|
||||
}
|
||||
};
|
||||
|
||||
// Mock the token request
|
||||
var tokenResponse = new HttpResponseMessage
|
||||
{
|
||||
StatusCode = HttpStatusCode.OK,
|
||||
Content = new StringContent("<div id='token'>test-token</div>")
|
||||
};
|
||||
tokenResponse.Headers.Add("Set-Cookie", "GUID=test-guid; path=/");
|
||||
|
||||
// Mock the files request
|
||||
var filesResponse = new HttpResponseMessage
|
||||
{
|
||||
StatusCode = HttpStatusCode.OK,
|
||||
Content = new StringContent(JsonConvert.SerializeObject(mockResponse))
|
||||
};
|
||||
|
||||
// Setup mock to return different responses based on URL
|
||||
_mockHttpHandler
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>("SendAsync",
|
||||
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.AbsolutePath.Contains("token.html")),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(tokenResponse);
|
||||
|
||||
_mockHttpHandler
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>("SendAsync",
|
||||
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.AbsolutePath.Contains("gui") && req.RequestUri.Query.Contains("action=getfiles")),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(filesResponse);
|
||||
|
||||
// Act
|
||||
var files = await _client.GetTorrentFilesAsync("test-hash");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(files);
|
||||
Assert.Empty(files);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTorrentFilesAsync_ShouldHandleNullResponse()
|
||||
{
|
||||
// Arrange
|
||||
var mockResponse = new UTorrentResponse<object>
|
||||
{
|
||||
Build = 30470,
|
||||
FilesDto = null
|
||||
};
|
||||
|
||||
// Mock the token request
|
||||
var tokenResponse = new HttpResponseMessage
|
||||
{
|
||||
StatusCode = HttpStatusCode.OK,
|
||||
Content = new StringContent("<div id='token'>test-token</div>")
|
||||
};
|
||||
tokenResponse.Headers.Add("Set-Cookie", "GUID=test-guid; path=/");
|
||||
|
||||
// Mock the files request
|
||||
var filesResponse = new HttpResponseMessage
|
||||
{
|
||||
StatusCode = HttpStatusCode.OK,
|
||||
Content = new StringContent(JsonConvert.SerializeObject(mockResponse))
|
||||
};
|
||||
|
||||
// Setup mock to return different responses based on URL
|
||||
_mockHttpHandler
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>("SendAsync",
|
||||
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.AbsolutePath.Contains("token.html")),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(tokenResponse);
|
||||
|
||||
_mockHttpHandler
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>("SendAsync",
|
||||
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.AbsolutePath.Contains("gui") && req.RequestUri.Query.Contains("action=getfiles")),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(filesResponse);
|
||||
|
||||
// Act
|
||||
var files = await _client.GetTorrentFilesAsync("test-hash");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(files);
|
||||
Assert.Empty(files);
|
||||
}
|
||||
}
|
||||
@@ -7,17 +7,20 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FLM.QBittorrent" Version="1.0.1" />
|
||||
<PackageReference Include="FLM.QBittorrent" Version="1.0.2" />
|
||||
<PackageReference Include="FLM.Transmission" Version="1.0.3" />
|
||||
<PackageReference Include="Mapster" Version="7.4.0" />
|
||||
<PackageReference Include="MassTransit.Abstractions" Version="8.4.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="9.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.6" />
|
||||
<PackageReference Include="Mono.Unix" Version="7.1.0-final.1.21458.1" />
|
||||
<PackageReference Include="Quartz" Version="3.14.0" />
|
||||
<PackageReference Include="Serilog.Expressions" Version="5.0.0" />
|
||||
<PackageReference Include="System.Threading.RateLimiting" Version="9.0.9" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -65,6 +65,10 @@ public class EventCleanupService : BackgroundService
|
||||
await context.Events
|
||||
.Where(e => e.Timestamp < cutoffDate)
|
||||
.ExecuteDeleteAsync();
|
||||
await context.ManualEvents
|
||||
.Where(e => e.Timestamp < cutoffDate)
|
||||
.Where(e => e.IsResolved)
|
||||
.ExecuteDeleteAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using System.Dynamic;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
@@ -6,10 +5,10 @@ using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications;
|
||||
using Cleanuparr.Infrastructure.Hubs;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Events;
|
||||
using Infrastructure.Interceptors;
|
||||
using Infrastructure.Verticals.Notifications;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -45,7 +44,7 @@ public class EventPublisher
|
||||
/// </summary>
|
||||
public async Task PublishAsync(EventType eventType, string message, EventSeverity severity, object? data = null, Guid? trackingId = null)
|
||||
{
|
||||
var eventEntity = new AppEvent
|
||||
AppEvent eventEntity = new()
|
||||
{
|
||||
EventType = eventType,
|
||||
Message = message,
|
||||
@@ -65,6 +64,27 @@ public class EventPublisher
|
||||
|
||||
_logger.LogTrace("Published event: {eventType}", eventType);
|
||||
}
|
||||
|
||||
public async Task PublishManualAsync(string message, EventSeverity severity, object? data = null)
|
||||
{
|
||||
ManualEvent eventEntity = new()
|
||||
{
|
||||
Message = message,
|
||||
Severity = severity,
|
||||
Data = data != null ? JsonSerializer.Serialize(data, new JsonSerializerOptions
|
||||
{
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
}) : null,
|
||||
};
|
||||
|
||||
// Save to database with dry run interception
|
||||
await _dryRunInterceptor.InterceptAsync(SaveManualEventToDatabase, eventEntity);
|
||||
|
||||
// Always send to SignalR clients (not affected by dry run)
|
||||
await NotifyClientsAsync(eventEntity);
|
||||
|
||||
_logger.LogTrace("Published manual event: {message}", message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes a strike event with context data and notifications
|
||||
@@ -79,6 +99,7 @@ public class EventPublisher
|
||||
StrikeType.FailedImport => EventType.FailedImportStrike,
|
||||
StrikeType.SlowSpeed => EventType.SlowSpeedStrike,
|
||||
StrikeType.SlowTime => EventType.SlowTimeStrike,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(strikeType), strikeType, null)
|
||||
};
|
||||
|
||||
dynamic data;
|
||||
@@ -163,8 +184,8 @@ public class EventPublisher
|
||||
public async Task PublishCategoryChanged(string oldCategory, string newCategory, bool isTag = false)
|
||||
{
|
||||
// Get context data for the event
|
||||
string downloadName = ContextProvider.Get<string>("downloadName") ?? "Unknown";
|
||||
string hash = ContextProvider.Get<string>("hash") ?? "Unknown";
|
||||
string downloadName = ContextProvider.Get<string>("downloadName");
|
||||
string hash = ContextProvider.Get<string>("hash");
|
||||
|
||||
// Publish the event
|
||||
await PublishAsync(
|
||||
@@ -177,11 +198,48 @@ public class EventPublisher
|
||||
await _notificationPublisher.NotifyCategoryChanged(oldCategory, newCategory, isTag);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes an event alerting that an item keeps coming back
|
||||
/// </summary>
|
||||
public async Task PublishRecurringItem(string hash, string itemName, int strikeCount)
|
||||
{
|
||||
var instanceType = (InstanceType)ContextProvider.Get<object>(nameof(InstanceType));
|
||||
var instanceUrl = ContextProvider.Get<Uri>(nameof(ArrInstance) + nameof(ArrInstance.Url));
|
||||
|
||||
// Publish the event
|
||||
await PublishManualAsync(
|
||||
"Download keeps coming back after deletion\nTo prevent further issues, please consult the prerequisites: https://cleanuparr.github.io/Cleanuparr/docs/installation/",
|
||||
EventSeverity.Important,
|
||||
data: new { itemName, hash, strikeCount, instanceType, instanceUrl }
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes an event alerting that search was not triggered for an item
|
||||
/// </summary>
|
||||
public async Task PublishSearchNotTriggered(string hash, string itemName)
|
||||
{
|
||||
var instanceType = (InstanceType)ContextProvider.Get<object>(nameof(InstanceType));
|
||||
var instanceUrl = ContextProvider.Get<Uri>(nameof(ArrInstance) + nameof(ArrInstance.Url));
|
||||
|
||||
await PublishManualAsync(
|
||||
"Replacement search was not triggered after removal because the item keeps coming back\nPlease trigger a manual search if needed",
|
||||
EventSeverity.Warning,
|
||||
data: new { itemName, hash, instanceType, instanceUrl }
|
||||
);
|
||||
}
|
||||
|
||||
private async Task SaveEventToDatabase(AppEvent eventEntity)
|
||||
{
|
||||
_context.Events.Add(eventEntity);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task SaveManualEventToDatabase(ManualEvent eventEntity)
|
||||
{
|
||||
_context.ManualEvents.Add(eventEntity);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task NotifyClientsAsync(AppEvent appEventEntity)
|
||||
{
|
||||
@@ -195,4 +253,17 @@ public class EventPublisher
|
||||
_logger.LogError(ex, "Failed to send event {eventId} to SignalR clients", appEventEntity.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task NotifyClientsAsync(ManualEvent appEventEntity)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Send to all connected clients via the unified AppHub
|
||||
await _appHubContext.Clients.All.SendAsync("ManualEventReceived", appEventEntity);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to send event {eventId} to SignalR clients", appEventEntity.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,11 @@ using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Data.Models.Arr;
|
||||
using Infrastructure.Interceptors;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
@@ -91,9 +91,8 @@ public abstract class ArrClient : IArrClient
|
||||
|
||||
if (hasWarn() && (isImportBlocked() || isImportPending() || isImportFailed()) || isFailedLidarr())
|
||||
{
|
||||
if (HasIgnoredPatterns(record))
|
||||
if (!ShouldStrikeFailedImport(queueCleanerConfig, record))
|
||||
{
|
||||
_logger.LogDebug("skip failed import check | contains ignored pattern | {name}", record.Title);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -212,19 +211,14 @@ public abstract class ArrClient : IArrClient
|
||||
return response;
|
||||
}
|
||||
|
||||
private static bool HasIgnoredPatterns(QueueRecord record)
|
||||
/// <summary>
|
||||
/// Determines whether the failed import record should be skipped
|
||||
/// </summary>
|
||||
private bool ShouldStrikeFailedImport(QueueCleanerConfig queueCleanerConfig, QueueRecord record)
|
||||
{
|
||||
var queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>();
|
||||
|
||||
if (queueCleanerConfig.FailedImport.IgnoredPatterns.Count is 0)
|
||||
{
|
||||
// no patterns are configured
|
||||
return false;
|
||||
}
|
||||
|
||||
if (record.StatusMessages?.Count is null or 0)
|
||||
{
|
||||
// no status message found
|
||||
_logger.LogWarning("skip failed import check | no status message found | {name}", record.Title);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -235,10 +229,29 @@ public abstract class ArrClient : IArrClient
|
||||
.ToList()
|
||||
.ForEach(x => messages.Add(x));
|
||||
|
||||
return messages.Any(
|
||||
m => queueCleanerConfig.FailedImport.IgnoredPatterns.Any(
|
||||
p => !string.IsNullOrWhiteSpace(p.Trim()) && m.Contains(p, StringComparison.InvariantCultureIgnoreCase)
|
||||
var patterns = queueCleanerConfig.FailedImport.Patterns;
|
||||
var patternMode = queueCleanerConfig.FailedImport.PatternMode;
|
||||
|
||||
var matched = messages.Any(
|
||||
m => patterns.Any(
|
||||
p => !string.IsNullOrWhiteSpace(p?.Trim()) && m.Contains(p, StringComparison.InvariantCultureIgnoreCase)
|
||||
)
|
||||
);
|
||||
|
||||
if (patternMode is PatternMode.Exclude && matched)
|
||||
{
|
||||
// contains an excluded/ignored pattern -> skip
|
||||
_logger.LogTrace("skip failed import check | excluded pattern matched | {name}", record.Title);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (patternMode is PatternMode.Include && (!matched || patterns.Count is 0))
|
||||
{
|
||||
// does not match any included patterns -> skip
|
||||
_logger.LogTrace("skip failed import check | no included pattern matched | {name}", record.Title);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Application.Features.Arr.Dtos;
|
||||
namespace Cleanuparr.Infrastructure.Features.Arr.Dtos;
|
||||
|
||||
public class ArrConfigDto
|
||||
{
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user