Compare commits

...
19 Commits
Author SHA1 Message Date
Flaminel 3360b7a849 Fix db migration not being applied (#552) 2026-04-07 11:51:07 +03:00
Flaminel 80b46df8e5 Add search event reason (#546) 2026-04-06 09:59:31 +03:00
Flaminel 88aa71c343 Fix duplicated grabbed items reported for Sonarr on season packs (#549) 2026-04-05 22:11:48 +03:00
Flaminel 7b80e038cc Fix rTorrent path evaluation for items without a subdirectory (#548) 2026-04-05 22:11:22 +03:00
Flaminel ef280ec398 Fix queue rules upper bound to never allow 0 (#543) 2026-04-03 16:11:12 +03:00
Flaminel 81f6de03e7 Improve Seeker's total number description (#542) 2026-04-03 15:59:23 +03:00
Flaminel 17c3a6b02a Fix duplicated event search when changing instance for Seeker stats (#537) 2026-04-01 13:29:14 +03:00
Flaminel 4903b3137b Add hide unmonitored toggle for quality scores (#535) 2026-04-01 13:28:43 +03:00
Flaminel 868406c95c Fix tags not being excluded by Seeker (#533) 2026-03-31 12:22:45 +03:00
Flaminel 7122b16a7a Add support for separate Download Cleaner settings per download client (#531) 2026-03-30 18:31:48 +03:00
Flaminel b9fbac4ddc Add performance mode description (#532) 2026-03-30 18:30:37 +03:00
Flaminel 33e948d1e7 Update frontend packages (#528) 2026-03-29 17:36:02 +03:00
Flaminel 9f551f151e Fix duplicated events on the dashboard (#527) 2026-03-29 17:22:58 +03:00
Flaminel 9447cb37c0 Fix round robin toggle not changing state correctly (#526) 2026-03-29 17:22:27 +03:00
Flaminel 8183b324a0 Fix Seeker being scheduled when disabled (#523) 2026-03-28 00:25:49 +02:00
Flaminel a6a25de19c Fix Seeker not grouping season packs when checking active downloads (#522) 2026-03-28 00:18:11 +02:00
Flaminel 51a2a1b391 Add missing and upgrade search (#507) 2026-03-27 19:40:09 +02:00
Flaminel d7ab81ddcf Fix Firefox missing the bookmark icon (#520) 2026-03-26 02:14:39 +02:00
Flaminel 8da07d4e93 Fix dashboard logs initial order (#519) 2026-03-26 01:16:04 +02:00
276 changed files with 29308 additions and 4003 deletions

No files matched your search

+104 -265
View File
@@ -1,83 +1,122 @@
# Cleanuparr - Claude AI Rules
## 🚨 Critical Guidelines
## Rules
**READ THIS FIRST:**
1. ⚠️ **DO NOT break existing functionality** - All features are critical and must continue to work
2. **When in doubt, ASK** - Always clarify before implementing uncertain changes
3. 📋 **Follow existing patterns** - Study the codebase style before making changes
4. 🆕 **Ask before introducing new patterns** - Use current coding standards or get approval first
1. **DO NOT break existing functionality** - All features are critical and must continue to work
2. **When in doubt, ASK** - Don't assume, clarify with the maintainer first
3. **Always read existing code before making changes** - Understand the current architecture and patterns
4. **Follow existing patterns** - Study the codebase style and match it exactly
5. **Ask before introducing new patterns** - Use current coding standards or get approval first
6. **Prefer editing existing files over creating new ones** - Build on existing work
7. **Flag potential gotchas or issues immediately** - Document and report anything unexpected
8. **If unsure about an approach, ask before implementing**
## Project Overview
Cleanuparr is a tool for automating the cleanup of unwanted or blocked files in Sonarr, Radarr, Lidarr, Readarr, Whisparr and supported download clients like qBittorrent, Transmission, Deluge, µTorrent and rTorrent. It provides malware protection, automated cleanup, and queue management for *arr applications.
Cleanuparr is a tool for automating the cleanup of unwanted or blocked files in Sonarr, Radarr, Lidarr, Readarr, Whisparr and supported download clients (qBittorrent, Transmission, Deluge, uTorrent, rTorrent). It provides malware protection, automated cleanup, and queue management for *arr applications.
**Key Features:**
- Strike system for bad downloads
- Malware detection and blocking
- Automatic search triggering after removal
- Automatic search triggering after removal (Seeker)
- Missing and upgrade search
- Orphaned download cleanup with cross-seed support
- Support for multiple notification providers (Discord, etc.)
- Authentication (OIDC, 2FA)
- Notification providers (Apprise, Discord, Gotify, Notifiarr, Ntfy, Pushover, Telegram)
## Architecture & Tech Stack
### Backend
- **.NET 10.0** (C#) with ASP.NET Core
- **Architecture**: Clean Architecture pattern
- `Cleanuparr.Domain` - Domain models and business logic
- **Architecture**: Clean Architecture with `Features/` subdirectory pattern
- `Cleanuparr.Api` - REST API and web host (`Features/` for endpoint groups)
- `Cleanuparr.Application` - Application services and use cases
- `Cleanuparr.Infrastructure` - External integrations (*arr apps, download clients)
- `Cleanuparr.Domain` - Domain models (Entities, Enums, Exceptions)
- `Cleanuparr.Infrastructure` - External integrations (`Features/` for Arr, DownloadClient, Notifications, etc.)
- `Cleanuparr.Persistence` - Data access with EF Core (SQLite)
- `Cleanuparr.Api` - REST API and web host
- `Cleanuparr.Shared` - Shared utilities
- **Database**: SQLite with Entity Framework Core 10.0
- Two separate contexts: `DataContext` and `EventsContext`
- **Database**: SQLite with Entity Framework Core
- Three separate contexts: `DataContext`, `EventsContext`, `UsersContext`
- **Key Libraries**:
- MassTransit (messaging)
- Quartz.NET (scheduling)
- Serilog (logging)
- SignalR (real-time communication)
- **Testing**: xUnit + NSubstitute + Shouldly
- Always use **NSubstitute** for mocking in new tests (Moq is being phased out)
### Frontend
- **Angular 21** with TypeScript 5.9 (standalone components, zoneless, OnPush)
- **UI**: Custom glassmorphism design system (no external UI frameworks)
- **UI**: Custom glassmorphism design system with 33 custom components — no external UI frameworks
- **Icons**: @ng-icons/core + @ng-icons/tabler-icons
- **Design System**: 3-layer SCSS (`_variables` `_tokens` `_themes`), dark/light themes
- **Design System**: 3-layer SCSS (`_variables` -> `_tokens` -> `_themes`), dark/light themes
- **State Management**: @ngrx/signals (Angular signals-based)
- **Real-time Updates**: SignalR (@microsoft/signalr)
- **Real-time Updates**: @microsoft/signalr 10.0.0
- **PWA**: Service Worker support enabled
### Documentation
- **Docusaurus** (TypeScript-based static site)
- Hosted at https://cleanuparr.github.io/Cleanuparr/
## Project Structure
### Deployment
- **Docker** (primary distribution method)
- Standalone executables for Windows, macOS, and Linux
- Platform installers for Windows (.exe) and macOS (.pkg)
## Development Setup
### Prerequisites
- .NET 10.0 SDK
- Node.js 18+
- Git
- (Optional) Make for database migrations
- (Optional) JetBrains Rider or Visual Studio
### GitHub Packages Authentication
Cleanuparr uses GitHub Packages for NuGet dependencies. Configure access:
```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
```
Cleanuparr/
├── code/
│ ├── backend/
│ │ ├── Cleanuparr.Api/ # REST API (Features/ for endpoint groups)
│ │ ├── Cleanuparr.Api.Tests/ # API layer tests
│ │ ├── Cleanuparr.Application/ # Business logic layer
│ │ ├── Cleanuparr.Domain/ # Domain models
│ │ ├── Cleanuparr.Infrastructure/ # External integrations (Features/ subdirs)
│ │ ├── Cleanuparr.Infrastructure.Tests/
│ │ ├── Cleanuparr.Persistence/ # SQLite data access
│ │ ├── Cleanuparr.Persistence.Tests/
│ │ └── Cleanuparr.Shared/ # Shared utilities
│ ├── frontend/ # Angular 21 application
│ ├── e2e/ # Playwright E2E tests
│ ├── Dockerfile # Multi-stage Docker build
│ ├── entrypoint.sh # Docker entrypoint
│ └── Makefile # Build & migration helpers
├── docs/ # Docusaurus documentation
├── .github/workflows/ # CI/CD pipelines
├── blacklist # Default malware patterns (strict)
├── blacklist_permissive # Less strict malware patterns
├── whitelist # Safe file extensions
└── whitelist_with_subtitles # Includes subtitle formats
```
You need a GitHub PAT with `read:packages` permission.
## Code Standards & Conventions
**IMPORTANT:** Always study existing code in the relevant area before making changes. Match the existing style exactly.
### Backend (C#)
- Follow Microsoft C# Coding Conventions
- Use nullable reference types (`<Nullable>enable</Nullable>`)
- Add XML documentation comments for public APIs
- Use meaningful names - avoid abbreviations unless widely understood
- Keep services focused - single responsibility principle
- New integrations go under `Features/` subdirectories (e.g., `Infrastructure/Features/Arr/`)
### Frontend (TypeScript/Angular)
- All components must be **standalone** with **ChangeDetectionStrategy.OnPush**
- Use `input()` / `output()` function APIs (not `@Input()` / `@Output()` decorators)
- Use Angular **signals** for reactive state (`signal()`, `computed()`, `effect()`)
- Follow the 3-layer SCSS design system (`_variables` -> `_tokens` -> `_themes`)
- **Do not introduce external UI frameworks** (no PrimeNG, Material, Tailwind, etc.)
- Component naming: `{feature}.component.ts`
- Service naming: `{feature}.service.ts`
- **Look at similar existing components before creating new ones**
### Testing
- **Backend**: xUnit + NSubstitute + Shouldly
- Always use **NSubstitute** for mocking (Moq is being phased out)
- Write unit tests for new features and bug fixes
- Use descriptive test names that explain what is being tested
- No frontend unit tests currently
### Git Commit Messages
- Use clear, descriptive messages in imperative mood
- Examples: "Add Discord notification support", "Fix memory leak in download client polling"
- Reference issue numbers when applicable: "Fix #123: Handle null response from Radarr API"
## Development Setup
### Running the Backend
```bash
@@ -101,250 +140,50 @@ cd code/backend
dotnet test
```
### Running Documentation
```bash
cd docs
npm install
npm start
```
Docs run at http://localhost:3000
## Project Structure
```
Cleanuparr/
├── code/
│ ├── backend/
│ │ ├── Cleanuparr.Api/ # API entry point
│ │ ├── Cleanuparr.Application/ # Business logic layer
│ │ ├── Cleanuparr.Domain/ # Domain models
│ │ ├── Cleanuparr.Infrastructure/ # External integrations
│ │ ├── Cleanuparr.Persistence/ # Database & EF Core
│ │ ├── Cleanuparr.Shared/ # Shared utilities
│ │ └── *.Tests/ # Unit tests
│ ├── frontend/ # Angular 21 application
│ ├── ui/ # Built frontend assets
│ ├── Dockerfile # Multi-stage Docker build
│ ├── entrypoint.sh # Docker entrypoint
│ └── Makefile # Build & migration helpers
├── docs/ # Docusaurus documentation
├── Logo/ # Branding assets
├── .github/workflows/ # CI/CD pipelines
├── blacklist # Default malware patterns
├── blacklist_permissive # Alternative blacklist
├── whitelist # Safe file patterns
└── CONTRIBUTING.md # Contribution guidelines
```
## Code Standards & Conventions
**IMPORTANT:** Always study existing code in the relevant area before making changes. Match the existing style exactly.
### Backend (C#)
- Follow [Microsoft C# Coding Conventions](https://docs.microsoft.com/dotnet/csharp/fundamentals/coding-style/coding-conventions)
- Use nullable reference types (`<Nullable>enable</Nullable>`)
- Add XML documentation comments for public APIs
- Write unit tests for business logic
- Use meaningful names - avoid abbreviations unless widely understood
- Keep services focused - single responsibility principle
- **Study existing service implementations before creating new ones**
### Frontend (TypeScript/Angular)
- Follow [Angular Style Guide](https://angular.io/guide/styleguide)
- Use TypeScript strict mode
- All components must be **standalone** (no NgModules) with **ChangeDetectionStrategy.OnPush**
- Use `input()` / `output()` function APIs (not `@Input()` / `@Output()` decorators)
- Use Angular **signals** for reactive state (`signal()`, `computed()`, `effect()`)
- Follow the 3-layer SCSS design system (`_variables``_tokens``_themes`) for styling
- Component naming: `{feature}.component.ts`
- Service naming: `{feature}.service.ts`
- **Look at similar existing components before creating new ones**
### Testing
- Write unit tests for new features and bug fixes
- Use descriptive test names that explain what is being tested
- Backend: xUnit or NUnit conventions
- Frontend: Jasmine/Karma
- **Test that existing functionality still works after changes**
### Git Commit Messages
- Use clear, descriptive messages in imperative mood
- Examples: "Add Discord notification support", "Fix memory leak in download client polling"
- Reference issue numbers when applicable: "Fix #123: Handle null response from Radarr API"
### Discovering Issues
If you encounter potential gotchas, common mistakes, or areas that need special attention during development:
- **Flag them to the maintainer immediately**
- Document them if confirmed
- Consider if they should be added to this guide
## Database Migrations
Cleanuparr uses two separate database contexts:
- **DataContext**: Main application data
- **EventsContext**: Event logging and audit trail
### Creating Migrations
From the `code` directory:
Three separate database contexts, all commands run from the `code` directory:
```bash
# Data migrations
# Data migrations (DataContext)
make migrate-data name=YourMigrationName
# Events migrations
# Events migrations (EventsContext)
make migrate-events name=YourMigrationName
```
Example:
```bash
make migrate-data name=AddDownloadClientConfig
make migrate-events name=AddStrikeEvents
# Users migrations (UsersContext)
make migrate-users name=YourMigrationName
```
## Common Development Workflows
### Adding a New *arr Application Integration
1. Add integration in `Cleanuparr.Infrastructure/Arr/`
1. Add integration in `Cleanuparr.Infrastructure/Features/Arr/`
2. Update domain models in `Cleanuparr.Domain/`
3. Create/update services in `Cleanuparr.Application/`
4. Add API endpoints in `Cleanuparr.Api/`
4. Add API endpoints in `Cleanuparr.Api/Features/Arr/`
5. Update frontend in `code/frontend/src/app/`
6. Document in `docs/docs/`
### Adding a New Download Client
1. Add client implementation in `Cleanuparr.Infrastructure/DownloadClients/`
1. Add client implementation in `Cleanuparr.Infrastructure/Features/DownloadClient/`
2. Follow existing patterns (qBittorrent, Transmission, etc.)
3. Add configuration models to `Cleanuparr.Domain/`
4. Update API and frontend as above
### Adding a New Notification Provider
1. Add provider in `Cleanuparr.Infrastructure/Notifications/`
1. Add provider in `Cleanuparr.Infrastructure/Features/Notifications/`
2. Update configuration models
3. Add UI configuration in frontend
4. Test with actual service
## Important Files
### Configuration Files
- `code/backend/Cleanuparr.Api/appsettings.json` - Backend configuration
- `code/frontend/angular.json` - Angular build configuration
- `code/Dockerfile` - Docker multi-stage build
- `docs/docusaurus.config.ts` - Documentation site config
### CI/CD Workflows
- `.github/workflows/test.yml` - Run tests
- `.github/workflows/build-docker.yml` - Build Docker images
- `.github/workflows/build-executable.yml` - Build standalone executables
- `.github/workflows/release.yml` - Create releases
- `.github/workflows/docs.yml` - Deploy documentation
### Malware Protection
- `blacklist` - Default malware file patterns (strict)
- `blacklist_permissive` - Less strict patterns
- `whitelist` - Known safe file extensions
- `whitelist_with_subtitles` - Includes subtitle formats
## Contributing Guidelines
### Before Starting Work
1. **Announce your intent** - Comment on an issue or create a new one
2. **Wait for approval** from maintainers
3. Fork the repository and create a feature branch
4. Make your changes following code standards
5. Test thoroughly (both manual and automated tests)
6. Submit a PR with clear description and testing notes
### Pull Request Requirements
- Link to related issue
- Clear description of changes
- Evidence of testing
- Updated documentation if needed
- No breaking changes without discussion
## Docker Development
### Build Local Docker Image
```bash
cd code
docker build \
--build-arg PACKAGES_USERNAME=YOUR_GITHUB_USERNAME \
--build-arg PACKAGES_PAT=YOUR_GITHUB_PAT \
-t cleanuparr:local \
-f Dockerfile .
```
### Multi-Architecture Build
```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 .
```
## Environment Variables
When running via Docker:
- `PORT` - API port (default: 11011)
- `PUID` - User ID for file permissions
- `PGID` - Group ID for file permissions
- `TZ` - Timezone (e.g., `America/New_York`)
## Security & Safety
- Never commit sensitive data (API keys, tokens, passwords)
- All *arr and download client credentials are stored encrypted
- The malware detection system uses pattern matching on file extensions and names
- Always validate user input on both frontend and backend
- Follow OWASP guidelines for web application security
## Additional Resources
- **Documentation**: https://cleanuparr.github.io/Cleanuparr/
- **Discord**: https://discord.gg/SCtMCgtsc4
- **GitHub Issues**: https://github.com/Cleanuparr/Cleanuparr/issues
- **Releases**: https://github.com/Cleanuparr/Cleanuparr/releases
## Working with Claude - IMPORTANT
### Core Principles
1. **When in doubt, ASK** - Don't assume, clarify with the maintainer first
2. **Don't break existing functionality** - Everything is important and needs to work
3. **Follow existing coding style** - Study the codebase patterns before making changes
4. **Use current coding standards** - If you want to introduce something new, ask first
### When Modifying Code
- **ALWAYS read existing files before suggesting changes**
- Understand the current architecture and patterns
- Prefer editing existing files over creating new ones
- Follow the established conventions in the codebase exactly
- Test changes locally when possible
- **If you're unsure about an approach, ask before implementing**
### When Adding Features
- Review similar existing features first to understand patterns
- Maintain consistency with existing UI/UX patterns
- Update both backend and frontend together
- Add/update documentation
- Consider backwards compatibility
- **Ask about architectural decisions before implementing new patterns**
### When Fixing Bugs
- Understand the root cause before proposing a fix
- **Be careful not to break other functionality** - test related areas
- Add tests to prevent regression
- Update relevant documentation if behavior changes
- Consider if other parts of the codebase might have similar issues
- **Flag any potential gotchas or issues you discover**
## Notes
## Key Gotchas
- **Custom glassmorphism design system** - Do not introduce external UI frameworks (no PrimeNG, Material, Tailwind)
- **All frontend components** must be standalone with OnPush change detection
- **Database migrations** require awareness of all three contexts (Data, Events, Users)
- **Malware blocker** is a critical security feature - changes require careful testing
- **Cross-seed integration** allows keeping torrents that are actively seeding
- **Real-time updates** use SignalR - maintain websocket patterns when adding features
- Use `@ng-icons/core` + `@ng-icons/tabler-icons` for icons (NOT `angular-tabler-icons` which doesn't support Angular 21)
- **Sidebar** stays dark purple in both themes - uses sidebar-specific CSS variables
- The project uses **Clean Architecture** - respect layer boundaries
- Database migrations require both contexts - don't forget EventsContext
- Frontend uses a **custom glassmorphism design system** - don't introduce external UI frameworks (no PrimeNG, Material, etc.)
- All frontend components are **standalone** with **OnPush** change detection
- All downloads from *arr apps are processed through a **strike system**
- The malware blocker is a critical security feature - changes require careful testing
- Cross-seed integration allows keeping torrents that are actively seeding
- Real-time updates use **SignalR** - maintain websocket patterns when adding features
- **Settings dirty tracking** uses JSON snapshot comparison (`buildSnapshot()` + `hasPendingChanges()`)
+3
View File
@@ -23,6 +23,9 @@ Cleanuparr was created primarily to address malicious files, such as `*.lnk` or
> - Remove and block downloads blocked by qBittorrent or by Cleanuparr's **Malware Blocker**.
> - Remove and block known malware based on patterns found by the community.
> - Automatically trigger a search for downloads removed from the arrs.
> - Proactively search for **missing** items across your Radarr and Sonarr libraries.
> - Search for **quality upgrades** for items that haven't met their quality profile's cutoff (a.k.a. **Cutoff Unmet**).
> - Search for **custom format score upgrades** with automatic score tracking.
> - Clean up downloads that have been **seeding** for a certain amount of time.
> - Remove downloads that are **orphaned**/have no **hardlinks**/are not referenced by the arrs anymore (with [cross-seed](https://www.cross-seed.org/) support).
> - Notify on strike or download removal.
@@ -11,6 +11,7 @@
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
@@ -1,6 +1,9 @@
using System.Diagnostics;
using System.Net;
using System.Net.Http.Json;
using Cleanuparr.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
namespace Cleanuparr.Api.Tests.Features.Auth;
@@ -83,14 +86,14 @@ public class LoginTimingTests : IClassFixture<TimingTestWebApplicationFactory>
[Fact, TestPriority(4)]
public async Task Login_LockedOutUser_StillCallsPasswordVerification()
{
// Trigger lockout by making several failed login attempts
for (var i = 0; i < 5; i++)
// Set lockout state directly in the database to avoid timing sensitivity
using (var scope = _factory.Services.CreateScope())
{
await _client.PostAsJsonAsync("/api/auth/login", new
{
username = "timingtest",
password = "WrongPassword!"
});
var context = scope.ServiceProvider.GetRequiredService<UsersContext>();
var user = await context.Users.FirstAsync();
user.FailedLoginAttempts = 5;
user.LockoutEnd = DateTime.UtcNow.AddMinutes(5);
await context.SaveChangesAsync();
}
_factory.TrackingPasswordService.Reset();
@@ -103,6 +106,16 @@ public class LoginTimingTests : IClassFixture<TimingTestWebApplicationFactory>
response.StatusCode.ShouldBe(HttpStatusCode.TooManyRequests);
_factory.TrackingPasswordService.VerifyPasswordCallCount.ShouldBeGreaterThanOrEqualTo(1);
// Reset lockout for subsequent tests
using (var scope = _factory.Services.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<UsersContext>();
var user = await context.Users.FirstAsync();
user.FailedLoginAttempts = 0;
user.LockoutEnd = null;
await context.SaveChangesAsync();
}
}
[Fact, TestPriority(5)]
@@ -0,0 +1,379 @@
using System.Text.Json;
using Cleanuparr.Api.Features.Seeker.Contracts.Responses;
using Cleanuparr.Api.Features.Seeker.Controllers;
using Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.State;
using Microsoft.AspNetCore.Mvc;
using Shouldly;
namespace Cleanuparr.Api.Tests.Features.Seeker;
public class CustomFormatScoreControllerTests : IDisposable
{
private readonly DataContext _dataContext;
private readonly CustomFormatScoreController _controller;
public CustomFormatScoreControllerTests()
{
_dataContext = SeekerTestDataFactory.CreateDataContext();
_controller = new CustomFormatScoreController(_dataContext);
}
public void Dispose()
{
_dataContext.Dispose();
GC.SuppressFinalize(this);
}
private static JsonElement GetResponseBody(IActionResult result)
{
var okResult = result.ShouldBeOfType<OkObjectResult>();
var json = JsonSerializer.Serialize(okResult.Value);
return JsonDocument.Parse(json).RootElement;
}
#region GetCustomFormatScores Tests
[Fact]
public async Task GetCustomFormatScores_WithPageBelowMinimum_ClampsToOne()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
AddScoreEntry(radarr.Id, 1, "Movie A", currentScore: 100, cutoffScore: 500);
AddScoreEntry(radarr.Id, 2, "Movie B", currentScore: 200, cutoffScore: 500);
var result = await _controller.GetCustomFormatScores(page: -5, pageSize: 50);
var body = GetResponseBody(result);
body.GetProperty("Page").GetInt32().ShouldBe(1);
body.GetProperty("Items").GetArrayLength().ShouldBe(2);
}
[Fact]
public async Task GetCustomFormatScores_WithPageSizeAboveMaximum_ClampsToHundred()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
AddScoreEntry(radarr.Id, 1, "Movie A", currentScore: 100, cutoffScore: 500);
var result = await _controller.GetCustomFormatScores(page: 1, pageSize: 999);
var body = GetResponseBody(result);
body.GetProperty("PageSize").GetInt32().ShouldBe(100);
}
[Fact]
public async Task GetCustomFormatScores_WithHideMetTrue_ExcludesItemsAtOrAboveCutoff()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
AddScoreEntry(radarr.Id, 1, "Below Cutoff", currentScore: 100, cutoffScore: 500);
AddScoreEntry(radarr.Id, 2, "At Cutoff", currentScore: 500, cutoffScore: 500);
AddScoreEntry(radarr.Id, 3, "Above Cutoff", currentScore: 600, cutoffScore: 500);
var result = await _controller.GetCustomFormatScores(hideMet: true);
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("Below Cutoff");
}
[Fact]
public async Task GetCustomFormatScores_WithHideUnmonitoredTrue_ExcludesUnmonitoredItems()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
AddScoreEntry(radarr.Id, 1, "Monitored Movie", currentScore: 100, cutoffScore: 500, isMonitored: true);
AddScoreEntry(radarr.Id, 2, "Unmonitored Movie", currentScore: 200, cutoffScore: 500, isMonitored: false);
AddScoreEntry(radarr.Id, 3, "Another Monitored", currentScore: 300, cutoffScore: 500, isMonitored: true);
var result = await _controller.GetCustomFormatScores(hideUnmonitored: true);
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(2);
var items = body.GetProperty("Items");
items.GetArrayLength().ShouldBe(2);
items[0].GetProperty("Title").GetString().ShouldBe("Another Monitored");
items[1].GetProperty("Title").GetString().ShouldBe("Monitored Movie");
}
[Fact]
public async Task GetCustomFormatScores_WithSearchFilter_ReturnsMatchingTitlesOnly()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
AddScoreEntry(radarr.Id, 1, "The Matrix", currentScore: 100, cutoffScore: 500);
AddScoreEntry(radarr.Id, 2, "Inception", currentScore: 200, cutoffScore: 500);
AddScoreEntry(radarr.Id, 3, "The Matrix Reloaded", currentScore: 300, cutoffScore: 500);
var result = await _controller.GetCustomFormatScores(search: "matrix");
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(2);
}
[Fact]
public async Task GetCustomFormatScores_WithSortByDate_OrdersByLastSyncedDescending()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
AddScoreEntry(radarr.Id, 1, "Older", currentScore: 100, cutoffScore: 500,
lastSynced: DateTime.UtcNow.AddHours(-2));
AddScoreEntry(radarr.Id, 2, "Newer", currentScore: 200, cutoffScore: 500,
lastSynced: DateTime.UtcNow.AddHours(-1));
var result = await _controller.GetCustomFormatScores(sortBy: "date");
var body = GetResponseBody(result);
body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("Newer");
body.GetProperty("Items")[1].GetProperty("Title").GetString().ShouldBe("Older");
}
[Fact]
public async Task GetCustomFormatScores_WithInstanceIdFilter_ReturnsOnlyThatInstance()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
var sonarr = SeekerTestDataFactory.AddSonarrInstance(_dataContext);
AddScoreEntry(radarr.Id, 1, "Movie", currentScore: 100, cutoffScore: 500);
AddScoreEntry(sonarr.Id, 2, "Series", currentScore: 200, cutoffScore: 500,
itemType: InstanceType.Sonarr);
var result = await _controller.GetCustomFormatScores(instanceId: radarr.Id);
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("Movie");
}
[Fact]
public async Task GetCustomFormatScores_ReturnsCorrectTotalPagesCalculation()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
for (int i = 1; i <= 7; i++)
{
AddScoreEntry(radarr.Id, i, $"Movie {i}", currentScore: 100, cutoffScore: 500);
}
var result = await _controller.GetCustomFormatScores(page: 1, pageSize: 3);
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(7);
body.GetProperty("TotalPages").GetInt32().ShouldBe(3); // ceil(7/3) = 3
body.GetProperty("Items").GetArrayLength().ShouldBe(3);
}
#endregion
#region GetRecentUpgrades Tests
[Fact]
public async Task GetRecentUpgrades_WithNoHistory_ReturnsEmptyList()
{
var result = await _controller.GetRecentUpgrades();
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(0);
body.GetProperty("Items").GetArrayLength().ShouldBe(0);
}
[Fact]
public async Task GetRecentUpgrades_WithSingleEntryPerItem_ReturnsNoUpgrades()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-1));
var result = await _controller.GetRecentUpgrades();
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(0);
}
[Fact]
public async Task GetRecentUpgrades_WithScoreIncrease_DetectsUpgrade()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-2));
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 250, recordedAt: DateTime.UtcNow.AddDays(-1));
var result = await _controller.GetRecentUpgrades();
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
var upgrade = body.GetProperty("Items")[0];
upgrade.GetProperty("PreviousScore").GetInt32().ShouldBe(100);
upgrade.GetProperty("NewScore").GetInt32().ShouldBe(250);
}
[Fact]
public async Task GetRecentUpgrades_WithScoreDecrease_DoesNotCountAsUpgrade()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 300, recordedAt: DateTime.UtcNow.AddDays(-2));
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 150, recordedAt: DateTime.UtcNow.AddDays(-1));
var result = await _controller.GetRecentUpgrades();
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(0);
}
[Fact]
public async Task GetRecentUpgrades_WithMultipleUpgradesInSameGroup_CountsEach()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
// 100 -> 200 -> 300 = two upgrades for the same item
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 200, recordedAt: DateTime.UtcNow.AddDays(-2));
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 300, recordedAt: DateTime.UtcNow.AddDays(-1));
var result = await _controller.GetRecentUpgrades();
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(2);
}
[Fact]
public async Task GetRecentUpgrades_WithDaysFilter_ExcludesOlderHistory()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
// Old upgrade (outside 7-day window)
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-20));
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 250, recordedAt: DateTime.UtcNow.AddDays(-15));
// Recent upgrade (inside 7-day window)
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 300, recordedAt: DateTime.UtcNow.AddDays(-1));
var result = await _controller.GetRecentUpgrades(days: 7);
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
}
[Fact]
public async Task GetRecentUpgrades_ReturnsSortedByMostRecentFirst()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
// Item 1: upgrade happened 5 days ago
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-6));
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 200, recordedAt: DateTime.UtcNow.AddDays(-5));
// Item 2: upgrade happened 1 day ago
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 100, recordedAt: DateTime.UtcNow.AddDays(-2));
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 300, recordedAt: DateTime.UtcNow.AddDays(-1));
var result = await _controller.GetRecentUpgrades();
var body = GetResponseBody(result);
var items = body.GetProperty("Items");
items.GetArrayLength().ShouldBe(2);
// Most recent upgrade (item 2) should be first
items[0].GetProperty("NewScore").GetInt32().ShouldBe(300);
items[1].GetProperty("NewScore").GetInt32().ShouldBe(200);
}
#endregion
#region GetStats Tests
[Fact]
public async Task GetStats_WithNoEntries_ReturnsZeroes()
{
var result = await _controller.GetStats();
var okResult = result.ShouldBeOfType<OkObjectResult>();
var stats = okResult.Value.ShouldBeOfType<CustomFormatScoreStatsResponse>();
stats.TotalTracked.ShouldBe(0);
stats.BelowCutoff.ShouldBe(0);
stats.AtOrAboveCutoff.ShouldBe(0);
stats.RecentUpgrades.ShouldBe(0);
}
[Fact]
public async Task GetStats_CorrectlyCategorizesBelowAndAboveCutoff()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
AddScoreEntry(radarr.Id, 1, "Below", currentScore: 100, cutoffScore: 500);
AddScoreEntry(radarr.Id, 2, "At", currentScore: 500, cutoffScore: 500);
AddScoreEntry(radarr.Id, 3, "Above", currentScore: 600, cutoffScore: 500);
var result = await _controller.GetStats();
var okResult = result.ShouldBeOfType<OkObjectResult>();
var stats = okResult.Value.ShouldBeOfType<CustomFormatScoreStatsResponse>();
stats.TotalTracked.ShouldBe(3);
stats.BelowCutoff.ShouldBe(1);
stats.AtOrAboveCutoff.ShouldBe(2);
}
[Fact]
public async Task GetStats_CountsRecentUpgradesFromLast7Days()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
AddScoreEntry(radarr.Id, 1, "Movie", currentScore: 300, cutoffScore: 500);
// Upgrade within 7 days
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 300, recordedAt: DateTime.UtcNow.AddDays(-1));
// Upgrade outside 7 days (should not be counted)
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 50, recordedAt: DateTime.UtcNow.AddDays(-20));
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 200, recordedAt: DateTime.UtcNow.AddDays(-15));
var result = await _controller.GetStats();
var okResult = result.ShouldBeOfType<OkObjectResult>();
var stats = okResult.Value.ShouldBeOfType<CustomFormatScoreStatsResponse>();
stats.RecentUpgrades.ShouldBe(1);
}
#endregion
#region Helpers
private void AddScoreEntry(
Guid arrInstanceId,
long externalItemId,
string title,
int currentScore,
int cutoffScore,
InstanceType itemType = InstanceType.Radarr,
DateTime? lastSynced = null,
bool isMonitored = true)
{
_dataContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
{
ArrInstanceId = arrInstanceId,
ExternalItemId = externalItemId,
EpisodeId = 0,
ItemType = itemType,
Title = title,
FileId = externalItemId * 10,
CurrentScore = currentScore,
CutoffScore = cutoffScore,
QualityProfileName = "HD",
IsMonitored = isMonitored,
LastSyncedAt = lastSynced ?? DateTime.UtcNow
});
_dataContext.SaveChanges();
}
private void AddHistoryEntry(
Guid arrInstanceId,
long externalItemId,
int score,
DateTime recordedAt,
long episodeId = 0,
int cutoffScore = 500,
InstanceType itemType = InstanceType.Radarr)
{
_dataContext.CustomFormatScoreHistory.Add(new CustomFormatScoreHistory
{
ArrInstanceId = arrInstanceId,
ExternalItemId = externalItemId,
EpisodeId = episodeId,
ItemType = itemType,
Title = $"Item {externalItemId}",
Score = score,
CutoffScore = cutoffScore,
RecordedAt = recordedAt
});
_dataContext.SaveChanges();
}
#endregion
}
@@ -0,0 +1,200 @@
using System.Text.Json;
using Cleanuparr.Api.Features.Seeker.Controllers;
using Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Events;
using Microsoft.AspNetCore.Mvc;
using Shouldly;
namespace Cleanuparr.Api.Tests.Features.Seeker;
public class SearchStatsControllerTests : IDisposable
{
private readonly DataContext _dataContext;
private readonly EventsContext _eventsContext;
private readonly SearchStatsController _controller;
public SearchStatsControllerTests()
{
_dataContext = SeekerTestDataFactory.CreateDataContext();
_eventsContext = SeekerTestDataFactory.CreateEventsContext();
_controller = new SearchStatsController(_dataContext, _eventsContext);
}
public void Dispose()
{
_dataContext.Dispose();
_eventsContext.Dispose();
GC.SuppressFinalize(this);
}
private static JsonElement GetResponseBody(IActionResult result)
{
var okResult = result.ShouldBeOfType<OkObjectResult>();
var json = JsonSerializer.Serialize(okResult.Value);
return JsonDocument.Parse(json).RootElement;
}
#region GetEvents with SearchEventData
[Fact]
public async Task GetEvents_WithNoSearchEventData_ReturnsUnknownDefaults()
{
AddSearchEvent();
var result = await _controller.GetEvents();
var body = GetResponseBody(result);
var item = body.GetProperty("Items")[0];
item.GetProperty("ItemTitle").GetString().ShouldBe("Unknown");
}
[Fact]
public async Task GetEvents_WithSearchEventData_ReturnsAllFields()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
AddSearchEvent(
arrInstanceId: radarr.Id,
itemTitle: "Movie A",
searchType: SeekerSearchType.Proactive,
searchReason: SeekerSearchReason.Missing,
grabbedItems: ["Movie A (2024)"]);
var result = await _controller.GetEvents();
var body = GetResponseBody(result);
var item = body.GetProperty("Items")[0];
item.GetProperty("ArrInstanceId").GetString().ShouldBe(radarr.Id.ToString());
item.GetProperty("InstanceType").GetString().ShouldBe(nameof(InstanceType.Radarr));
item.GetProperty("ItemTitle").GetString().ShouldBe("Movie A");
item.GetProperty("SearchType").GetString().ShouldBe(nameof(SeekerSearchType.Proactive));
item.GetProperty("SearchReason").GetString().ShouldBe(nameof(SeekerSearchReason.Missing));
item.GetProperty("GrabbedItems")[0].GetString().ShouldBe("Movie A (2024)");
}
[Fact]
public async Task GetEvents_WithReplacementSearchType_ParsesCorrectEnum()
{
AddSearchEvent(
itemTitle: "Series A",
searchType: SeekerSearchType.Replacement,
searchReason: SeekerSearchReason.Replacement);
var result = await _controller.GetEvents();
var body = GetResponseBody(result);
var item = body.GetProperty("Items")[0];
item.GetProperty("SearchType").GetString().ShouldBe(nameof(SeekerSearchType.Replacement));
item.GetProperty("SearchReason").GetString().ShouldBe(nameof(SeekerSearchReason.Replacement));
}
#endregion
#region GetEvents Filtering
[Fact]
public async Task GetEvents_WithInstanceIdFilter_FiltersByArrInstanceId()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
var sonarr = SeekerTestDataFactory.AddSonarrInstance(_dataContext);
AddSearchEvent(arrInstanceId: radarr.Id, itemTitle: "Radarr Movie");
AddSearchEvent(arrInstanceId: sonarr.Id, itemTitle: "Sonarr Series");
var result = await _controller.GetEvents(instanceId: radarr.Id);
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
body.GetProperty("Items")[0].GetProperty("ArrInstanceId").GetString().ShouldBe(radarr.Id.ToString());
}
[Fact]
public async Task GetEvents_WithCycleIdFilter_ReturnsOnlyMatchingCycle()
{
var cycleA = Guid.NewGuid();
var cycleB = Guid.NewGuid();
AddSearchEvent(cycleId: cycleA, itemTitle: "Cycle A Movie");
AddSearchEvent(cycleId: cycleB, itemTitle: "Cycle B Movie");
var result = await _controller.GetEvents(cycleId: cycleA);
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
body.GetProperty("Items")[0].GetProperty("ItemTitle").GetString().ShouldBe("Cycle A Movie");
}
[Fact]
public async Task GetEvents_WithSearchFilter_FiltersOnItemTitle()
{
AddSearchEvent(itemTitle: "The Matrix");
AddSearchEvent(itemTitle: "Breaking Bad");
var result = await _controller.GetEvents(search: "matrix");
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
}
[Fact]
public async Task GetEvents_WithPagination_ReturnsCorrectPageAndCount()
{
for (int i = 0; i < 5; i++)
{
AddSearchEvent(itemTitle: $"Event {i}");
}
var result = await _controller.GetEvents(page: 2, pageSize: 2);
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(5);
body.GetProperty("TotalPages").GetInt32().ShouldBe(3);
body.GetProperty("Page").GetInt32().ShouldBe(2);
body.GetProperty("Items").GetArrayLength().ShouldBe(2);
}
#endregion
#region Helpers
private void AddSearchEvent(
string? itemTitle = null,
SeekerSearchType searchType = SeekerSearchType.Proactive,
SeekerSearchReason searchReason = SeekerSearchReason.Missing,
List<string>? grabbedItems = null,
Guid? arrInstanceId = null,
Guid? cycleId = null,
SearchCommandStatus? searchStatus = null)
{
var appEvent = new AppEvent
{
EventType = EventType.SearchTriggered,
Message = "Search triggered",
Severity = EventSeverity.Information,
ArrInstanceId = arrInstanceId,
CycleId = cycleId,
SearchStatus = searchStatus,
Timestamp = DateTime.UtcNow
};
_eventsContext.Events.Add(appEvent);
_eventsContext.SaveChanges();
if (itemTitle is not null)
{
_eventsContext.SearchEventData.Add(new SearchEventData
{
AppEventId = appEvent.Id,
ItemTitle = itemTitle,
SearchType = searchType,
SearchReason = searchReason,
GrabbedItems = grabbedItems ?? [],
});
_eventsContext.SaveChanges();
}
}
#endregion
}
@@ -0,0 +1,324 @@
using Cleanuparr.Api.Features.Seeker.Contracts.Requests;
using Cleanuparr.Api.Features.Seeker.Contracts.Responses;
using Cleanuparr.Api.Features.Seeker.Controllers;
using Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Seeker;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Shouldly;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Api.Tests.Features.Seeker;
public class SeekerConfigControllerTests : IDisposable
{
private readonly DataContext _dataContext;
private readonly ILogger<SeekerConfigController> _logger;
private readonly IJobManagementService _jobManagementService;
private readonly SeekerConfigController _controller;
public SeekerConfigControllerTests()
{
_dataContext = SeekerTestDataFactory.CreateDataContext();
_logger = Substitute.For<ILogger<SeekerConfigController>>();
_jobManagementService = Substitute.For<IJobManagementService>();
_controller = new SeekerConfigController(_logger, _dataContext, _jobManagementService);
}
public void Dispose()
{
_dataContext.Dispose();
GC.SuppressFinalize(this);
}
#region GetSeekerConfig Tests
[Fact]
public async Task GetSeekerConfig_WithNoSeekerInstanceConfigs_ReturnsDefaults()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
var result = await _controller.GetSeekerConfig();
var okResult = result.ShouldBeOfType<OkObjectResult>();
var response = okResult.Value.ShouldBeOfType<SeekerConfigResponse>();
var instance = response.Instances.ShouldHaveSingleItem();
instance.ArrInstanceId.ShouldBe(radarr.Id);
instance.Enabled.ShouldBeFalse();
instance.SkipTags.ShouldBeEmpty();
instance.ActiveDownloadLimit.ShouldBe(3);
instance.MinCycleTimeDays.ShouldBe(7);
}
[Fact]
public async Task GetSeekerConfig_OnlyReturnsSonarrAndRadarrInstances()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
var sonarr = SeekerTestDataFactory.AddSonarrInstance(_dataContext);
var lidarr = SeekerTestDataFactory.AddLidarrInstance(_dataContext);
var result = await _controller.GetSeekerConfig();
var okResult = result.ShouldBeOfType<OkObjectResult>();
var response = okResult.Value.ShouldBeOfType<SeekerConfigResponse>();
response.Instances.Count.ShouldBe(2);
response.Instances.ShouldContain(i => i.ArrInstanceId == radarr.Id);
response.Instances.ShouldContain(i => i.ArrInstanceId == sonarr.Id);
response.Instances.ShouldNotContain(i => i.ArrInstanceId == lidarr.Id);
}
#endregion
#region UpdateSeekerConfig Tests
[Fact]
public async Task UpdateSeekerConfig_WithProactiveEnabledAndNoInstancesEnabled_ThrowsValidationException()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
var request = new UpdateSeekerConfigRequest
{
SearchEnabled = true,
SearchInterval = 5,
ProactiveSearchEnabled = true,
Instances =
[
new UpdateSeekerInstanceConfigRequest
{
ArrInstanceId = radarr.Id,
Enabled = false // No instances enabled
}
]
};
await Should.ThrowAsync<ValidationException>(() => _controller.UpdateSeekerConfig(request));
}
[Fact]
public async Task UpdateSeekerConfig_WhenIntervalChanges_ReschedulesSeeker()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarr.Id,
Enabled = true
});
await _dataContext.SaveChangesAsync();
// Default interval is 3, change to 5
var request = new UpdateSeekerConfigRequest
{
SearchEnabled = true,
SearchInterval = 5,
ProactiveSearchEnabled = true,
Instances =
[
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true }
]
};
await _controller.UpdateSeekerConfig(request);
await _jobManagementService.Received(1)
.StartJob(JobType.Seeker, null, Arg.Any<string>());
}
[Fact]
public async Task UpdateSeekerConfig_WhenIntervalUnchanged_DoesNotReschedule()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarr.Id,
Enabled = true
});
await _dataContext.SaveChangesAsync();
// Keep interval at default (3)
var request = new UpdateSeekerConfigRequest
{
SearchEnabled = true,
SearchInterval = 3,
ProactiveSearchEnabled = true,
Instances =
[
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true }
]
};
await _controller.UpdateSeekerConfig(request);
await _jobManagementService.DidNotReceive()
.StartJob(Arg.Any<JobType>(), null, Arg.Any<string>());
}
[Fact]
public async Task UpdateSeekerConfig_WhenCustomFormatScoreEnabled_StartsAndTriggersSyncerJob()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarr.Id,
Enabled = true
});
await _dataContext.SaveChangesAsync();
// UseCustomFormatScore was false (default), now enable it
var request = new UpdateSeekerConfigRequest
{
SearchEnabled = true,
SearchInterval = 3,
ProactiveSearchEnabled = true,
UseCustomFormatScore = true,
Instances =
[
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true }
]
};
await _controller.UpdateSeekerConfig(request);
await _jobManagementService.Received(1)
.StartJob(JobType.CustomFormatScoreSyncer, null, Arg.Any<string>());
await _jobManagementService.Received(1)
.TriggerJobOnce(JobType.CustomFormatScoreSyncer);
}
[Fact]
public async Task UpdateSeekerConfig_WhenCustomFormatScoreDisabled_StopsSyncerJob()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarr.Id,
Enabled = true
});
await _dataContext.SaveChangesAsync();
// First enable CF score
var config = await _dataContext.SeekerConfigs.FirstAsync();
config.UseCustomFormatScore = true;
await _dataContext.SaveChangesAsync();
// Now disable it
var request = new UpdateSeekerConfigRequest
{
SearchEnabled = true,
SearchInterval = 3,
ProactiveSearchEnabled = true,
UseCustomFormatScore = false,
Instances =
[
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true }
]
};
await _controller.UpdateSeekerConfig(request);
await _jobManagementService.Received(1)
.StopJob(JobType.CustomFormatScoreSyncer);
}
[Fact]
public async Task UpdateSeekerConfig_WhenSearchReenabledWithCustomFormatActive_TriggersSyncerOnce()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarr.Id,
Enabled = true
});
await _dataContext.SaveChangesAsync();
// Set up state: CF score already enabled, search currently disabled
var config = await _dataContext.SeekerConfigs.FirstAsync();
config.UseCustomFormatScore = true;
config.SearchEnabled = false;
await _dataContext.SaveChangesAsync();
// Re-enable search
var request = new UpdateSeekerConfigRequest
{
SearchEnabled = true,
SearchInterval = 3,
ProactiveSearchEnabled = false,
UseCustomFormatScore = true,
Instances =
[
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true }
]
};
await _controller.UpdateSeekerConfig(request);
await _jobManagementService.Received(1)
.TriggerJobOnce(JobType.CustomFormatScoreSyncer);
}
[Fact]
public async Task UpdateSeekerConfig_SyncsExistingAndCreatesNewInstanceConfigs()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
var sonarr = SeekerTestDataFactory.AddSonarrInstance(_dataContext);
// Radarr already has a config
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarr.Id,
Enabled = false,
SkipTags = ["old-tag"],
ActiveDownloadLimit = 2,
MinCycleTimeDays = 5
});
await _dataContext.SaveChangesAsync();
var request = new UpdateSeekerConfigRequest
{
SearchEnabled = true,
SearchInterval = 3,
ProactiveSearchEnabled = true,
Instances =
[
// Update existing radarr config
new UpdateSeekerInstanceConfigRequest
{
ArrInstanceId = radarr.Id,
Enabled = true,
SkipTags = ["new-tag"],
ActiveDownloadLimit = 5,
MinCycleTimeDays = 14
},
// Create new sonarr config
new UpdateSeekerInstanceConfigRequest
{
ArrInstanceId = sonarr.Id,
Enabled = true,
SkipTags = ["sonarr-tag"],
ActiveDownloadLimit = 3,
MinCycleTimeDays = 7
}
]
};
await _controller.UpdateSeekerConfig(request);
var configs = await _dataContext.SeekerInstanceConfigs.ToListAsync();
configs.Count.ShouldBe(2);
var radarrConfig = configs.First(c => c.ArrInstanceId == radarr.Id);
radarrConfig.Enabled.ShouldBeTrue();
radarrConfig.SkipTags.ShouldContain("new-tag");
radarrConfig.ActiveDownloadLimit.ShouldBe(5);
radarrConfig.MinCycleTimeDays.ShouldBe(14);
var sonarrConfig = configs.First(c => c.ArrInstanceId == sonarr.Id);
sonarrConfig.Enabled.ShouldBeTrue();
sonarrConfig.SkipTags.ShouldContain("sonarr-tag");
}
#endregion
}
@@ -0,0 +1,162 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Persistence.Models.Configuration.General;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Cleanuparr.Persistence.Models.Configuration.Seeker;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
/// <summary>
/// Factory for creating SQLite in-memory contexts for Seeker controller tests
/// </summary>
public static class SeekerTestDataFactory
{
public static DataContext CreateDataContext()
{
var connection = new SqliteConnection("DataSource=:memory:");
connection.Open();
var options = new DbContextOptionsBuilder<DataContext>()
.UseSqlite(connection)
.Options;
var context = new DataContext(options);
context.Database.EnsureCreated();
SeedDefaultData(context);
return context;
}
public static EventsContext CreateEventsContext()
{
var connection = new SqliteConnection("DataSource=:memory:");
connection.Open();
var options = new DbContextOptionsBuilder<EventsContext>()
.UseSqlite(connection)
.Options;
var context = new EventsContext(options);
context.Database.EnsureCreated();
return context;
}
private static void SeedDefaultData(DataContext context)
{
context.GeneralConfigs.Add(new GeneralConfig
{
Id = Guid.NewGuid(),
DryRun = false,
IgnoredDownloads = [],
Log = new LoggingConfig()
});
context.ArrConfigs.AddRange(
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Sonarr, Instances = [], FailedImportMaxStrikes = 3 },
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Radarr, Instances = [], FailedImportMaxStrikes = 3 },
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Lidarr, Instances = [], FailedImportMaxStrikes = 3 },
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Readarr, Instances = [], FailedImportMaxStrikes = 3 },
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Whisparr, Instances = [], FailedImportMaxStrikes = 3 }
);
context.QueueCleanerConfigs.Add(new QueueCleanerConfig
{
Id = Guid.NewGuid(),
IgnoredDownloads = [],
FailedImport = new FailedImportConfig()
});
context.ContentBlockerConfigs.Add(new ContentBlockerConfig
{
Id = Guid.NewGuid(),
IgnoredDownloads = [],
DeletePrivate = false,
Sonarr = new BlocklistSettings { Enabled = false },
Radarr = new BlocklistSettings { Enabled = false },
Lidarr = new BlocklistSettings { Enabled = false },
Readarr = new BlocklistSettings { Enabled = false },
Whisparr = new BlocklistSettings { Enabled = false }
});
context.DownloadCleanerConfigs.Add(new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
IgnoredDownloads = []
});
context.SeekerConfigs.Add(new SeekerConfig
{
Id = Guid.NewGuid(),
SearchEnabled = true,
ProactiveSearchEnabled = false
});
context.SaveChanges();
}
public static ArrInstance AddSonarrInstance(DataContext context, bool enabled = true)
{
var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Sonarr);
var instance = new ArrInstance
{
Id = Guid.NewGuid(),
Name = "Test Sonarr",
Url = new Uri("http://sonarr:8989"),
ApiKey = "test-api-key",
Enabled = enabled,
ArrConfigId = arrConfig.Id,
ArrConfig = arrConfig
};
arrConfig.Instances.Add(instance);
context.ArrInstances.Add(instance);
context.SaveChanges();
return instance;
}
public static ArrInstance AddRadarrInstance(DataContext context, bool enabled = true)
{
var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Radarr);
var instance = new ArrInstance
{
Id = Guid.NewGuid(),
Name = "Test Radarr",
Url = new Uri("http://radarr:7878"),
ApiKey = "test-api-key",
Enabled = enabled,
ArrConfigId = arrConfig.Id,
ArrConfig = arrConfig
};
arrConfig.Instances.Add(instance);
context.ArrInstances.Add(instance);
context.SaveChanges();
return instance;
}
public static ArrInstance AddLidarrInstance(DataContext context, bool enabled = true)
{
var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Lidarr);
var instance = new ArrInstance
{
Id = Guid.NewGuid(),
Name = "Test Lidarr",
Url = new Uri("http://lidarr:8686"),
ApiKey = "test-api-key",
Enabled = enabled,
ArrConfigId = arrConfig.Id,
ArrConfig = arrConfig
};
arrConfig.Instances.Add(instance);
context.ArrInstances.Add(instance);
context.SaveChanges();
return instance;
}
}
@@ -90,8 +90,6 @@ public class EventsController : ControllerBase
EF.Functions.Like(e.Message, pattern) ||
EF.Functions.Like(e.Data, pattern) ||
EF.Functions.Like(e.TrackingId.ToString(), pattern) ||
EF.Functions.Like(e.InstanceUrl, pattern) ||
EF.Functions.Like(e.DownloadClientName, pattern) ||
EF.Functions.Like(e.JobRunId.ToString(), pattern)
);
}
@@ -57,8 +57,13 @@ public class JobsController : ControllerBase
}
[HttpPost("{jobType}/start")]
public async Task<IActionResult> StartJob(JobType jobType, [FromBody] ScheduleRequest scheduleRequest = null)
public async Task<IActionResult> StartJob(JobType jobType, [FromBody] ScheduleRequest scheduleRequest)
{
if (jobType == JobType.Seeker)
{
return BadRequest("The Seeker job cannot be manually controlled");
}
try
{
// Get the schedule from the request body if provided
@@ -82,6 +87,11 @@ public class JobsController : ControllerBase
[HttpPost("{jobType}/trigger")]
public async Task<IActionResult> TriggerJob(JobType jobType)
{
if (jobType == JobType.Seeker)
{
return BadRequest("The Seeker job cannot be manually triggered");
}
try
{
var result = await _jobManagementService.TriggerJobOnce(jobType);
@@ -102,6 +112,11 @@ public class JobsController : ControllerBase
[HttpPut("{jobType}/schedule")]
public async Task<IActionResult> UpdateJobSchedule(JobType jobType, [FromBody] ScheduleRequest scheduleRequest)
{
if (jobType == JobType.Seeker)
{
return BadRequest("The Seeker job schedule cannot be manually modified");
}
if (scheduleRequest?.Schedule == null)
{
return BadRequest("Schedule is required");
@@ -68,9 +68,7 @@ public class ManualEventsController : ControllerBase
string pattern = EventsContext.GetLikePattern(search);
query = query.Where(e =>
EF.Functions.Like(e.Message, pattern) ||
EF.Functions.Like(e.Data, pattern) ||
EF.Functions.Like(e.InstanceUrl, pattern) ||
EF.Functions.Like(e.DownloadClientName, pattern)
EF.Functions.Like(e.Data, pattern)
);
}
@@ -1,13 +1,11 @@
using System.Text.Json.Serialization;
using Cleanuparr.Domain.Entities.Arr;
using Cleanuparr.Infrastructure.Features.DownloadHunter.Consumers;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Consumers;
using Cleanuparr.Infrastructure.Features.Notifications.Consumers;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Infrastructure.Health;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
using Data.Models.Arr;
using MassTransit;
using Microsoft.Extensions.Caching.Memory;
@@ -30,9 +28,6 @@ public static class MainDI
config.AddConsumer<DownloadRemoverConsumer<SearchItem>>();
config.AddConsumer<DownloadRemoverConsumer<SeriesSearchItem>>();
config.AddConsumer<DownloadHunterConsumer<SearchItem>>();
config.AddConsumer<DownloadHunterConsumer<SeriesSearchItem>>();
config.AddConsumer<NotificationConsumer<FailedImportStrikeNotification>>();
config.AddConsumer<NotificationConsumer<StalledStrikeNotification>>();
config.AddConsumer<NotificationConsumer<SlowSpeedStrikeNotification>>();
@@ -60,14 +55,6 @@ public static class MainDI
e.PrefetchCount = 1;
});
cfg.ReceiveEndpoint("download-hunter-queue", e =>
{
e.ConfigureConsumer<DownloadHunterConsumer<SearchItem>>(context);
e.ConfigureConsumer<DownloadHunterConsumer<SeriesSearchItem>>(context);
e.ConcurrentMessageLimit = 1;
e.PrefetchCount = 1;
});
cfg.ReceiveEndpoint("notification-queue", e =>
{
e.ConfigureConsumer<NotificationConsumer<FailedImportStrikeNotification>>(context);
@@ -5,8 +5,6 @@ using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.Auth;
using Cleanuparr.Infrastructure.Features.BlacklistSync;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.DownloadHunter;
using Cleanuparr.Infrastructure.Features.DownloadHunter.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadRemover;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces;
using Cleanuparr.Infrastructure.Features.Files;
@@ -49,8 +47,9 @@ public static class ServicesDI
.AddScoped<BlacklistSynchronizer>()
.AddScoped<MalwareBlocker>()
.AddScoped<DownloadCleaner>()
.AddScoped<Seeker>()
.AddScoped<CustomFormatScoreSyncer>()
.AddScoped<IQueueItemRemover, QueueItemRemover>()
.AddScoped<IDownloadHunter, DownloadHunter>()
.AddScoped<IFilenameEvaluator, FilenameEvaluator>()
.AddScoped<IHardLinkFileService, HardLinkFileService>()
.AddScoped<IUnixHardLinkFileService, UnixHardLinkFileService>()
@@ -67,5 +66,6 @@ public static class ServicesDI
.AddSingleton<IBlocklistProvider, BlocklistProvider>()
.AddSingleton(TimeProvider.System)
.AddSingleton<AppStatusSnapshot>()
.AddHostedService<AppStatusRefreshService>();
.AddHostedService<AppStatusRefreshService>()
.AddHostedService<SeekerCommandMonitor>();
}
@@ -0,0 +1,18 @@
namespace Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
public sealed record UnlinkedConfigRequest
{
public bool Enabled { get; init; }
public string TargetCategory { get; init; } = "cleanuparr-unlinked";
public bool UseTag { get; init; }
public List<string> IgnoredRootDirs { get; init; } = [];
public List<string> Categories { get; init; } = [];
public string? DownloadDirectorySource { get; init; }
public string? DownloadDirectoryTarget { get; init; }
}
@@ -11,20 +11,5 @@ public sealed record UpdateDownloadCleanerConfigRequest
/// </summary>
public bool UseAdvancedScheduling { get; init; }
public List<SeedingRuleRequest> Categories { 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 List<string> UnlinkedIgnoredRootDirs { get; init; } = [];
public List<string> UnlinkedCategories { get; init; } = [];
public List<string> IgnoredDownloads { get; init; } = [];
}
@@ -1,6 +1,4 @@
using System.ComponentModel.DataAnnotations;
using System.IO;
using System.Linq;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
using Cleanuparr.Domain.Enums;
@@ -42,10 +40,67 @@ public sealed class DownloadCleanerConfigController : ControllerBase
try
{
var config = await _dataContext.DownloadCleanerConfigs
.Include(x => x.Categories)
.AsNoTracking()
.FirstAsync();
return Ok(config);
var downloadClients = await _dataContext.DownloadClients
.AsNoTracking()
.ToListAsync();
var allQBitRules = await _dataContext.QBitSeedingRules.AsNoTracking().ToListAsync();
var allDelugeRules = await _dataContext.DelugeSeedingRules.AsNoTracking().ToListAsync();
var allTransmissionRules = await _dataContext.TransmissionSeedingRules.AsNoTracking().ToListAsync();
var allUTorrentRules = await _dataContext.UTorrentSeedingRules.AsNoTracking().ToListAsync();
var allRTorrentRules = await _dataContext.RTorrentSeedingRules.AsNoTracking().ToListAsync();
var allUnlinkedConfigs = await _dataContext.UnlinkedConfigs.AsNoTracking().ToListAsync();
var clients = new List<object>();
foreach (var client in downloadClients)
{
var seedingRules = SeedingRuleHelper.FilterForClient(
client, allQBitRules, allDelugeRules, allTransmissionRules, allUTorrentRules, allRTorrentRules);
var unlinkedConfig = allUnlinkedConfigs.FirstOrDefault(u => u.DownloadClientConfigId == client.Id);
clients.Add(new
{
downloadClientId = client.Id,
downloadClientName = client.Name,
downloadClientEnabled = client.Enabled,
downloadClientTypeName = client.TypeName,
seedingRules = seedingRules.Select(r => new
{
id = r.Id,
name = r.Name,
privacyType = r.PrivacyType,
maxRatio = r.MaxRatio,
minSeedTime = r.MinSeedTime,
maxSeedTime = r.MaxSeedTime,
deleteSourceFiles = r.DeleteSourceFiles,
}),
unlinkedConfig = unlinkedConfig is not null
? new
{
enabled = unlinkedConfig.Enabled,
targetCategory = unlinkedConfig.TargetCategory,
useTag = unlinkedConfig.UseTag,
ignoredRootDirs = unlinkedConfig.IgnoredRootDirs,
categories = unlinkedConfig.Categories,
downloadDirectorySource = unlinkedConfig.DownloadDirectorySource,
downloadDirectoryTarget = unlinkedConfig.DownloadDirectoryTarget,
}
: null,
});
}
return Ok(new
{
config.Enabled,
config.CronExpression,
config.UseAdvancedScheduling,
config.IgnoredDownloads,
clients,
});
}
finally
{
@@ -70,40 +125,13 @@ public sealed class DownloadCleanerConfigController : ControllerBase
CronValidationHelper.ValidateCronExpression(newConfigDto.CronExpression);
}
// Get existing configuration
var oldConfig = await _dataContext.DownloadCleanerConfigs
.Include(x => x.Categories)
.FirstAsync();
// Update global config only
var oldConfig = await _dataContext.DownloadCleanerConfigs.FirstAsync();
oldConfig.Enabled = newConfigDto.Enabled;
oldConfig.CronExpression = newConfigDto.CronExpression;
oldConfig.UseAdvancedScheduling = newConfigDto.UseAdvancedScheduling;
oldConfig.UnlinkedEnabled = newConfigDto.UnlinkedEnabled;
oldConfig.UnlinkedTargetCategory = newConfigDto.UnlinkedTargetCategory;
oldConfig.UnlinkedUseTag = newConfigDto.UnlinkedUseTag;
oldConfig.UnlinkedIgnoredRootDirs = newConfigDto.UnlinkedIgnoredRootDirs;
oldConfig.UnlinkedCategories = newConfigDto.UnlinkedCategories;
oldConfig.IgnoredDownloads = newConfigDto.IgnoredDownloads;
oldConfig.Categories.Clear();
_dataContext.SeedingRules.RemoveRange(oldConfig.Categories);
_dataContext.DownloadCleanerConfigs.Update(oldConfig);
foreach (var categoryDto in newConfigDto.Categories)
{
_dataContext.SeedingRules.Add(new SeedingRule
{
Name = categoryDto.Name,
PrivacyType = categoryDto.PrivacyType,
MaxRatio = categoryDto.MaxRatio,
MinSeedTime = categoryDto.MinSeedTime,
MaxSeedTime = categoryDto.MaxSeedTime,
DeleteSourceFiles = categoryDto.DeleteSourceFiles,
DownloadCleanerConfigId = oldConfig.Id
});
}
oldConfig.Validate();
await _dataContext.SaveChangesAsync();
@@ -0,0 +1,354 @@
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Api.Features.DownloadCleaner.Controllers;
[ApiController]
[Route("api/seeding-rules")]
[Authorize]
public class SeedingRulesController : ControllerBase
{
private readonly ILogger<SeedingRulesController> _logger;
private readonly DataContext _dataContext;
public SeedingRulesController(
ILogger<SeedingRulesController> logger,
DataContext dataContext)
{
_logger = logger;
_dataContext = dataContext;
}
[HttpGet("{downloadClientId}")]
public async Task<IActionResult> GetSeedingRules(Guid downloadClientId)
{
await DataContext.Lock.WaitAsync();
try
{
var client = await _dataContext.DownloadClients
.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == downloadClientId);
if (client is null)
{
return NotFound(new { Message = $"Download client with ID {downloadClientId} not found" });
}
var rules = await SeedingRuleHelper.GetForClientAsync(_dataContext, client);
return Ok(rules);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to retrieve seeding rules for client {ClientId}", downloadClientId);
return StatusCode(500, new { Message = "Failed to retrieve seeding rules", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPost("{downloadClientId}")]
public async Task<IActionResult> CreateSeedingRule(Guid downloadClientId, [FromBody] SeedingRuleRequest ruleDto)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
await DataContext.Lock.WaitAsync();
try
{
var client = await _dataContext.DownloadClients
.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == downloadClientId);
if (client is null)
{
return NotFound(new { Message = $"Download client with ID {downloadClientId} not found" });
}
var existingRules = await SeedingRuleHelper.GetForClientAsync(_dataContext, client);
var duplicate = existingRules.FirstOrDefault(r =>
r.Name.Equals(ruleDto.Name.Trim(), StringComparison.OrdinalIgnoreCase) &&
r.PrivacyType == ruleDto.PrivacyType);
if (duplicate is not null)
{
return BadRequest(new { Message = "A seeding rule with this name and privacy type already exists for this client" });
}
var overlapError = GetPrivacyTypeOverlapError(ruleDto.Name.Trim(), ruleDto.PrivacyType, existingRules, excludeId: null);
if (overlapError is not null)
{
return BadRequest(new { Message = overlapError });
}
var rule = CreateRule(client.TypeName, client.Id, ruleDto);
rule.Validate();
AddRuleToDbSet(rule);
await _dataContext.SaveChangesAsync();
_logger.LogInformation("Created seeding rule: {RuleName} with ID: {RuleId} for client {ClientId}",
rule.Name, rule.Id, downloadClientId);
return CreatedAtAction(nameof(GetSeedingRules), new { downloadClientId }, rule);
}
catch (ValidationException ex)
{
_logger.LogWarning("Validation failed for seeding rule creation: {Message}", ex.Message);
return BadRequest(new { Message = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create seeding rule: {RuleName} for client {ClientId}",
ruleDto.Name, downloadClientId);
return StatusCode(500, new { Message = "Failed to create seeding rule", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("{id}")]
public async Task<IActionResult> UpdateSeedingRule(Guid id, [FromBody] SeedingRuleRequest ruleDto)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
await DataContext.Lock.WaitAsync();
try
{
var (existingRule, _) = await SeedingRuleHelper.FindByIdAsync(_dataContext, id);
if (existingRule is null)
{
return NotFound(new { Message = $"Seeding rule with ID {id} not found" });
}
// Check for duplicate name+privacyType on the same client, excluding this rule
var clientRules = await SeedingRuleHelper.GetForClientIdAsync(_dataContext, existingRule.DownloadClientConfigId);
var duplicate = clientRules.FirstOrDefault(r =>
r.Id != id &&
r.Name.Equals(ruleDto.Name.Trim(), StringComparison.OrdinalIgnoreCase) &&
r.PrivacyType == ruleDto.PrivacyType);
if (duplicate is not null)
{
return BadRequest(new { Message = "A seeding rule with this name and privacy type already exists for this client" });
}
var overlapError = GetPrivacyTypeOverlapError(ruleDto.Name.Trim(), ruleDto.PrivacyType, clientRules, excludeId: id);
if (overlapError is not null)
{
return BadRequest(new { Message = overlapError });
}
existingRule.Name = ruleDto.Name.Trim();
existingRule.PrivacyType = ruleDto.PrivacyType;
existingRule.MaxRatio = ruleDto.MaxRatio;
existingRule.MinSeedTime = ruleDto.MinSeedTime;
existingRule.MaxSeedTime = ruleDto.MaxSeedTime;
existingRule.DeleteSourceFiles = ruleDto.DeleteSourceFiles;
existingRule.Validate();
await _dataContext.SaveChangesAsync();
_logger.LogInformation("Updated seeding rule: {RuleName} with ID: {RuleId}", existingRule.Name, id);
return Ok(existingRule);
}
catch (ValidationException ex)
{
_logger.LogWarning("Validation failed for seeding rule update: {Message}", ex.Message);
return BadRequest(new { Message = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update seeding rule with ID: {RuleId}", id);
return StatusCode(500, new { Message = "Failed to update seeding rule", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
}
}
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteSeedingRule(Guid id)
{
await DataContext.Lock.WaitAsync();
try
{
var (existingRule, _) = await SeedingRuleHelper.FindByIdAsync(_dataContext, id);
if (existingRule is null)
{
return NotFound(new { Message = $"Seeding rule with ID {id} not found" });
}
RemoveRuleFromDbSet(existingRule);
await _dataContext.SaveChangesAsync();
_logger.LogInformation("Deleted seeding rule: {RuleName} with ID: {RuleId}", existingRule.Name, id);
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete seeding rule with ID: {RuleId}", id);
return StatusCode(500, new { Message = "Failed to delete seeding rule", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
}
}
private static string? GetPrivacyTypeOverlapError(
string name,
TorrentPrivacyType privacyType,
IEnumerable<ISeedingRule> existingRules,
Guid? excludeId)
{
if (privacyType == TorrentPrivacyType.Both)
{
var hasConflict = existingRules.Any(r =>
r.Id != excludeId &&
r.Name.Equals(name, StringComparison.OrdinalIgnoreCase) &&
r.PrivacyType != TorrentPrivacyType.Both);
return hasConflict
? "A 'Both' rule cannot coexist with a Public or Private rule for the same category"
: null;
}
else
{
var hasConflict = existingRules.Any(r =>
r.Id != excludeId &&
r.Name.Equals(name, StringComparison.OrdinalIgnoreCase) &&
r.PrivacyType == TorrentPrivacyType.Both);
return hasConflict
? "A Public or Private rule cannot coexist with a 'Both' rule for the same category"
: null;
}
}
private ISeedingRule CreateRule(DownloadClientTypeName typeName, Guid clientId, SeedingRuleRequest dto)
{
return typeName switch
{
DownloadClientTypeName.qBittorrent => new QBitSeedingRule
{
DownloadClientConfigId = clientId,
Name = dto.Name.Trim(),
PrivacyType = dto.PrivacyType,
MaxRatio = dto.MaxRatio,
MinSeedTime = dto.MinSeedTime,
MaxSeedTime = dto.MaxSeedTime,
DeleteSourceFiles = dto.DeleteSourceFiles,
},
DownloadClientTypeName.Deluge => new DelugeSeedingRule
{
DownloadClientConfigId = clientId,
Name = dto.Name.Trim(),
PrivacyType = dto.PrivacyType,
MaxRatio = dto.MaxRatio,
MinSeedTime = dto.MinSeedTime,
MaxSeedTime = dto.MaxSeedTime,
DeleteSourceFiles = dto.DeleteSourceFiles,
},
DownloadClientTypeName.Transmission => new TransmissionSeedingRule
{
DownloadClientConfigId = clientId,
Name = dto.Name.Trim(),
PrivacyType = dto.PrivacyType,
MaxRatio = dto.MaxRatio,
MinSeedTime = dto.MinSeedTime,
MaxSeedTime = dto.MaxSeedTime,
DeleteSourceFiles = dto.DeleteSourceFiles,
},
DownloadClientTypeName.uTorrent => new UTorrentSeedingRule
{
DownloadClientConfigId = clientId,
Name = dto.Name.Trim(),
PrivacyType = dto.PrivacyType,
MaxRatio = dto.MaxRatio,
MinSeedTime = dto.MinSeedTime,
MaxSeedTime = dto.MaxSeedTime,
DeleteSourceFiles = dto.DeleteSourceFiles,
},
DownloadClientTypeName.rTorrent => new RTorrentSeedingRule
{
DownloadClientConfigId = clientId,
Name = dto.Name.Trim(),
PrivacyType = dto.PrivacyType,
MaxRatio = dto.MaxRatio,
MinSeedTime = dto.MinSeedTime,
MaxSeedTime = dto.MaxSeedTime,
DeleteSourceFiles = dto.DeleteSourceFiles,
},
_ => throw new ArgumentOutOfRangeException(nameof(typeName), typeName, "Unsupported download client type")
};
}
private void AddRuleToDbSet(ISeedingRule rule)
{
switch (rule)
{
case QBitSeedingRule qbit:
_dataContext.QBitSeedingRules.Add(qbit);
break;
case DelugeSeedingRule deluge:
_dataContext.DelugeSeedingRules.Add(deluge);
break;
case TransmissionSeedingRule transmission:
_dataContext.TransmissionSeedingRules.Add(transmission);
break;
case UTorrentSeedingRule utorrent:
_dataContext.UTorrentSeedingRules.Add(utorrent);
break;
case RTorrentSeedingRule rtorrent:
_dataContext.RTorrentSeedingRules.Add(rtorrent);
break;
}
}
private void RemoveRuleFromDbSet(ISeedingRule rule)
{
switch (rule)
{
case QBitSeedingRule qbit:
_dataContext.QBitSeedingRules.Remove(qbit);
break;
case DelugeSeedingRule deluge:
_dataContext.DelugeSeedingRules.Remove(deluge);
break;
case TransmissionSeedingRule transmission:
_dataContext.TransmissionSeedingRules.Remove(transmission);
break;
case UTorrentSeedingRule utorrent:
_dataContext.UTorrentSeedingRules.Remove(utorrent);
break;
case RTorrentSeedingRule rtorrent:
_dataContext.RTorrentSeedingRules.Remove(rtorrent);
break;
}
}
}
@@ -0,0 +1,123 @@
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Api.Features.DownloadCleaner.Controllers;
[ApiController]
[Route("api/unlinked-config")]
[Authorize]
public class UnlinkedConfigController : ControllerBase
{
private readonly ILogger<UnlinkedConfigController> _logger;
private readonly DataContext _dataContext;
public UnlinkedConfigController(
ILogger<UnlinkedConfigController> logger,
DataContext dataContext)
{
_logger = logger;
_dataContext = dataContext;
}
[HttpGet("{downloadClientId}")]
public async Task<IActionResult> GetUnlinkedConfig(Guid downloadClientId)
{
await DataContext.Lock.WaitAsync();
try
{
var client = await _dataContext.DownloadClients
.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == downloadClientId);
if (client is null)
{
return NotFound(new { Message = $"Download client with ID {downloadClientId} not found" });
}
var config = await _dataContext.UnlinkedConfigs
.AsNoTracking()
.FirstOrDefaultAsync(u => u.DownloadClientConfigId == downloadClientId);
return Ok(config);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to retrieve unlinked config for client {ClientId}", downloadClientId);
return StatusCode(500, new { Message = "Failed to retrieve unlinked config", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("{downloadClientId}")]
public async Task<IActionResult> UpdateUnlinkedConfig(Guid downloadClientId, [FromBody] UnlinkedConfigRequest dto)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
await DataContext.Lock.WaitAsync();
try
{
var client = await _dataContext.DownloadClients
.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == downloadClientId);
if (client is null)
{
return NotFound(new { Message = $"Download client with ID {downloadClientId} not found" });
}
var existing = await _dataContext.UnlinkedConfigs
.FirstOrDefaultAsync(u => u.DownloadClientConfigId == downloadClientId);
if (existing is null)
{
existing = new UnlinkedConfig
{
DownloadClientConfigId = downloadClientId,
};
_dataContext.UnlinkedConfigs.Add(existing);
}
existing.Enabled = dto.Enabled;
existing.TargetCategory = dto.TargetCategory;
existing.UseTag = dto.UseTag;
existing.IgnoredRootDirs = dto.IgnoredRootDirs;
existing.Categories = dto.Categories;
existing.DownloadDirectorySource = dto.DownloadDirectorySource;
existing.DownloadDirectoryTarget = dto.DownloadDirectoryTarget;
existing.Validate();
await _dataContext.SaveChangesAsync();
_logger.LogInformation("Updated unlinked config for client {ClientId}", downloadClientId);
return Ok(existing);
}
catch (ValidationException ex)
{
_logger.LogWarning("Validation failed for unlinked config update: {Message}", ex.Message);
return BadRequest(new { Message = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update unlinked config for client {ClientId}", downloadClientId);
return StatusCode(500, new { Message = "Failed to update unlinked config", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
}
}
}
@@ -0,0 +1,90 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Microsoft.EntityFrameworkCore;
namespace Cleanuparr.Api.Features.DownloadCleaner;
internal static class SeedingRuleHelper
{
/// <summary>
/// Queries the appropriate per-type seeding rules table for a single client.
/// </summary>
public static async Task<List<ISeedingRule>> GetForClientAsync(DataContext ctx, DownloadClientConfig client)
{
return client.TypeName switch
{
DownloadClientTypeName.qBittorrent => (await ctx.QBitSeedingRules
.Where(r => r.DownloadClientConfigId == client.Id).AsNoTracking().ToListAsync()).Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.Deluge => (await ctx.DelugeSeedingRules
.Where(r => r.DownloadClientConfigId == client.Id).AsNoTracking().ToListAsync()).Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.Transmission => (await ctx.TransmissionSeedingRules
.Where(r => r.DownloadClientConfigId == client.Id).AsNoTracking().ToListAsync()).Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.uTorrent => (await ctx.UTorrentSeedingRules
.Where(r => r.DownloadClientConfigId == client.Id).AsNoTracking().ToListAsync()).Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.rTorrent => (await ctx.RTorrentSeedingRules
.Where(r => r.DownloadClientConfigId == client.Id).AsNoTracking().ToListAsync()).Cast<ISeedingRule>().ToList(),
_ => [],
};
}
/// <summary>
/// Loads the client by ID then queries its seeding rules.
/// </summary>
public static async Task<List<ISeedingRule>> GetForClientIdAsync(DataContext ctx, Guid clientId)
{
var client = await ctx.DownloadClients
.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == clientId);
return client is null ? [] : await GetForClientAsync(ctx, client);
}
/// <summary>
/// Filters seeding rules for a client from pre-loaded in-memory lists.
/// Use this in bulk-load scenarios to avoid N+1 queries.
/// </summary>
public static List<ISeedingRule> FilterForClient(
DownloadClientConfig client,
List<QBitSeedingRule> qbitRules,
List<DelugeSeedingRule> delugeRules,
List<TransmissionSeedingRule> transmissionRules,
List<UTorrentSeedingRule> utorrentRules,
List<RTorrentSeedingRule> rtorrentRules)
{
return client.TypeName switch
{
DownloadClientTypeName.qBittorrent => qbitRules.Where(r => r.DownloadClientConfigId == client.Id).Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.Deluge => delugeRules.Where(r => r.DownloadClientConfigId == client.Id).Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.Transmission => transmissionRules.Where(r => r.DownloadClientConfigId == client.Id).Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.uTorrent => utorrentRules.Where(r => r.DownloadClientConfigId == client.Id).Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.rTorrent => rtorrentRules.Where(r => r.DownloadClientConfigId == client.Id).Cast<ISeedingRule>().ToList(),
_ => [],
};
}
/// <summary>
/// Searches all five per-type seeding rule tables for a rule with the given ID.
/// Returns the rule and a sentinel string identifying its type, or (null, null) if not found.
/// </summary>
public static async Task<(ISeedingRule? rule, object? dbSet)> FindByIdAsync(DataContext ctx, Guid id)
{
var qbit = await ctx.QBitSeedingRules.FirstOrDefaultAsync(r => r.Id == id);
if (qbit is not null) return (qbit, ctx.QBitSeedingRules);
var deluge = await ctx.DelugeSeedingRules.FirstOrDefaultAsync(r => r.Id == id);
if (deluge is not null) return (deluge, ctx.DelugeSeedingRules);
var transmission = await ctx.TransmissionSeedingRules.FirstOrDefaultAsync(r => r.Id == id);
if (transmission is not null) return (transmission, ctx.TransmissionSeedingRules);
var utorrent = await ctx.UTorrentSeedingRules.FirstOrDefaultAsync(r => r.Id == id);
if (utorrent is not null) return (utorrent, ctx.UTorrentSeedingRules);
var rtorrent = await ctx.RTorrentSeedingRules.FirstOrDefaultAsync(r => r.Id == id);
if (rtorrent is not null) return (rtorrent, ctx.RTorrentSeedingRules);
return (null, null);
}
}
@@ -2,7 +2,6 @@ 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;
@@ -20,10 +19,6 @@ public sealed record UpdateGeneralConfigRequest
public CertificateValidationType HttpCertificateValidation { get; init; } = CertificateValidationType.Enabled;
public bool SearchEnabled { get; init; } = true;
public ushort SearchDelay { get; init; } = Constants.DefaultSearchDelaySeconds;
public bool StatusCheckEnabled { get; init; } = true;
public string EncryptionKey { get; init; } = Guid.NewGuid().ToString();
@@ -43,8 +38,6 @@ public sealed record UpdateGeneralConfigRequest
existingConfig.HttpMaxRetries = HttpMaxRetries;
existingConfig.HttpTimeout = HttpTimeout;
existingConfig.HttpCertificateValidation = HttpCertificateValidation;
existingConfig.SearchEnabled = SearchEnabled;
existingConfig.SearchDelay = SearchDelay;
existingConfig.StatusCheckEnabled = StatusCheckEnabled;
existingConfig.EncryptionKey = EncryptionKey;
existingConfig.IgnoredDownloads = IgnoredDownloads;
@@ -80,10 +80,14 @@ public sealed class GeneralConfigController : ControllerBase
.Where(d => !d.Strikes.Any())
.ExecuteDeleteAsync();
var deletedHistory = await _dataContext.SeekerHistory
.Where(h => h.IsDryRun)
.ExecuteDeleteAsync();
_logger.LogWarning(
"Dry run disabled — purged dry-run data: {Strikes} strikes, {Events} events, {ManualEvents} manual events, {Items} orphaned download items removed",
deletedStrikes, deletedEvents, deletedManualEvents, deletedItems);
"Dry run disabled — purged dry-run data: {Strikes} strikes, {Events} events, {ManualEvents} manual events, {Items} orphaned download items, {History} search history entries removed",
deletedStrikes, deletedEvents, deletedManualEvents, deletedItems, deletedHistory);
await transaction.CommitAsync();
}
catch
@@ -17,4 +17,6 @@ public abstract record CreateNotificationProviderRequestBase
public bool OnDownloadCleaned { get; init; }
public bool OnCategoryChanged { get; init; }
public bool OnSearchTriggered { get; init; }
}
@@ -17,4 +17,6 @@ public abstract record UpdateNotificationProviderRequestBase
public bool OnDownloadCleaned { get; init; }
public bool OnCategoryChanged { get; init; }
public bool OnSearchTriggered { get; init; }
}
@@ -74,7 +74,8 @@ public sealed class NotificationProvidersController : ControllerBase
OnSlowStrike = p.OnSlowStrike,
OnQueueItemDeleted = p.OnQueueItemDeleted,
OnDownloadCleaned = p.OnDownloadCleaned,
OnCategoryChanged = p.OnCategoryChanged
OnCategoryChanged = p.OnCategoryChanged,
OnSearchTriggered = p.OnSearchTriggered
},
Configuration = p.Type switch
{
@@ -153,6 +154,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
OnDownloadCleaned = newProvider.OnDownloadCleaned,
OnCategoryChanged = newProvider.OnCategoryChanged,
OnSearchTriggered = newProvider.OnSearchTriggered,
NotifiarrConfiguration = notifiarrConfig
};
@@ -223,6 +225,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
OnDownloadCleaned = newProvider.OnDownloadCleaned,
OnCategoryChanged = newProvider.OnCategoryChanged,
OnSearchTriggered = newProvider.OnSearchTriggered,
AppriseConfiguration = appriseConfig
};
@@ -300,6 +303,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
OnDownloadCleaned = newProvider.OnDownloadCleaned,
OnCategoryChanged = newProvider.OnCategoryChanged,
OnSearchTriggered = newProvider.OnSearchTriggered,
NtfyConfiguration = ntfyConfig
};
@@ -368,6 +372,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
OnDownloadCleaned = newProvider.OnDownloadCleaned,
OnCategoryChanged = newProvider.OnCategoryChanged,
OnSearchTriggered = newProvider.OnSearchTriggered,
TelegramConfiguration = telegramConfig
};
@@ -447,6 +452,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
OnCategoryChanged = updatedProvider.OnCategoryChanged,
OnSearchTriggered = updatedProvider.OnSearchTriggered,
NotifiarrConfiguration = notifiarrConfig,
UpdatedAt = DateTime.UtcNow
};
@@ -533,6 +539,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
OnCategoryChanged = updatedProvider.OnCategoryChanged,
OnSearchTriggered = updatedProvider.OnSearchTriggered,
AppriseConfiguration = appriseConfig,
UpdatedAt = DateTime.UtcNow
};
@@ -622,6 +629,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
OnCategoryChanged = updatedProvider.OnCategoryChanged,
OnSearchTriggered = updatedProvider.OnSearchTriggered,
NtfyConfiguration = ntfyConfig,
UpdatedAt = DateTime.UtcNow
};
@@ -705,6 +713,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
OnCategoryChanged = updatedProvider.OnCategoryChanged,
OnSearchTriggered = updatedProvider.OnSearchTriggered,
TelegramConfiguration = telegramConfig,
UpdatedAt = DateTime.UtcNow
};
@@ -815,7 +824,8 @@ public sealed class NotificationProvidersController : ControllerBase
OnSlowStrike = false,
OnQueueItemDeleted = false,
OnDownloadCleaned = false,
OnCategoryChanged = false
OnCategoryChanged = false,
OnSearchTriggered = false
},
Configuration = notifiarrConfig
};
@@ -882,7 +892,8 @@ public sealed class NotificationProvidersController : ControllerBase
OnSlowStrike = false,
OnQueueItemDeleted = false,
OnDownloadCleaned = false,
OnCategoryChanged = false
OnCategoryChanged = false,
OnSearchTriggered = false
},
Configuration = appriseConfig
};
@@ -956,7 +967,8 @@ public sealed class NotificationProvidersController : ControllerBase
OnSlowStrike = false,
OnQueueItemDeleted = false,
OnDownloadCleaned = false,
OnCategoryChanged = false
OnCategoryChanged = false,
OnSearchTriggered = false
},
Configuration = ntfyConfig
};
@@ -1013,7 +1025,8 @@ public sealed class NotificationProvidersController : ControllerBase
OnSlowStrike = false,
OnQueueItemDeleted = false,
OnDownloadCleaned = false,
OnCategoryChanged = false
OnCategoryChanged = false,
OnSearchTriggered = false
},
Configuration = telegramConfig
};
@@ -1048,7 +1061,8 @@ public sealed class NotificationProvidersController : ControllerBase
OnSlowStrike = provider.OnSlowStrike,
OnQueueItemDeleted = provider.OnQueueItemDeleted,
OnDownloadCleaned = provider.OnDownloadCleaned,
OnCategoryChanged = provider.OnCategoryChanged
OnCategoryChanged = provider.OnCategoryChanged,
OnSearchTriggered = provider.OnSearchTriggered
},
Configuration = provider.Type switch
{
@@ -1105,6 +1119,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
OnDownloadCleaned = newProvider.OnDownloadCleaned,
OnCategoryChanged = newProvider.OnCategoryChanged,
OnSearchTriggered = newProvider.OnSearchTriggered,
DiscordConfiguration = discordConfig
};
@@ -1185,6 +1200,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
OnCategoryChanged = updatedProvider.OnCategoryChanged,
OnSearchTriggered = updatedProvider.OnSearchTriggered,
DiscordConfiguration = discordConfig,
UpdatedAt = DateTime.UtcNow
};
@@ -1254,7 +1270,8 @@ public sealed class NotificationProvidersController : ControllerBase
OnSlowStrike = false,
OnQueueItemDeleted = false,
OnDownloadCleaned = false,
OnCategoryChanged = false
OnCategoryChanged = false,
OnSearchTriggered = false
},
Configuration = discordConfig
};
@@ -1325,6 +1342,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
OnDownloadCleaned = newProvider.OnDownloadCleaned,
OnCategoryChanged = newProvider.OnCategoryChanged,
OnSearchTriggered = newProvider.OnSearchTriggered,
PushoverConfiguration = pushoverConfig
};
@@ -1412,6 +1430,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
OnCategoryChanged = updatedProvider.OnCategoryChanged,
OnSearchTriggered = updatedProvider.OnSearchTriggered,
PushoverConfiguration = pushoverConfig,
UpdatedAt = DateTime.UtcNow
};
@@ -1495,7 +1514,8 @@ public sealed class NotificationProvidersController : ControllerBase
OnSlowStrike = false,
OnQueueItemDeleted = false,
OnDownloadCleaned = false,
OnCategoryChanged = false
OnCategoryChanged = false,
OnSearchTriggered = false
},
Configuration = pushoverConfig
};
@@ -1551,6 +1571,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
OnDownloadCleaned = newProvider.OnDownloadCleaned,
OnCategoryChanged = newProvider.OnCategoryChanged,
OnSearchTriggered = newProvider.OnSearchTriggered,
GotifyConfiguration = gotifyConfig
};
@@ -1631,6 +1652,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
OnCategoryChanged = updatedProvider.OnCategoryChanged,
OnSearchTriggered = updatedProvider.OnSearchTriggered,
GotifyConfiguration = gotifyConfig,
UpdatedAt = DateTime.UtcNow
};
@@ -1698,7 +1720,8 @@ public sealed class NotificationProvidersController : ControllerBase
OnSlowStrike = false,
OnQueueItemDeleted = false,
OnDownloadCleaned = false,
OnCategoryChanged = false
OnCategoryChanged = false,
OnSearchTriggered = false
},
Configuration = gotifyConfig
};
@@ -20,7 +20,7 @@ public abstract record QueueRuleDto
[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")]
[Range(1, 100, ErrorMessage = "Maximum completion percentage must be between 1 and 100")]
public ushort MaxCompletionPercentage { get; set; }
public bool DeletePrivateTorrentsFromClient { get; set; } = false;
@@ -0,0 +1,42 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence.Models.Configuration.Seeker;
namespace Cleanuparr.Api.Features.Seeker.Contracts.Requests;
public sealed record UpdateSeekerConfigRequest
{
public bool SearchEnabled { get; init; } = true;
public ushort SearchInterval { get; init; } = 3;
public bool ProactiveSearchEnabled { get; init; }
public SelectionStrategy SelectionStrategy { get; init; } = SelectionStrategy.BalancedWeighted;
public bool MonitoredOnly { get; init; } = true;
public bool UseCutoff { get; init; }
public bool UseCustomFormatScore { get; init; }
public bool UseRoundRobin { get; init; } = true;
public int PostReleaseGraceHours { get; init; } = 6;
public List<UpdateSeekerInstanceConfigRequest> Instances { get; init; } = [];
public SeekerConfig ApplyTo(SeekerConfig config)
{
config.SearchEnabled = SearchEnabled;
config.SearchInterval = SearchInterval;
config.ProactiveSearchEnabled = ProactiveSearchEnabled;
config.SelectionStrategy = SelectionStrategy;
config.MonitoredOnly = MonitoredOnly;
config.UseCutoff = UseCutoff;
config.UseCustomFormatScore = UseCustomFormatScore;
config.UseRoundRobin = UseRoundRobin;
config.PostReleaseGraceHours = PostReleaseGraceHours;
return config;
}
}
@@ -0,0 +1,14 @@
namespace Cleanuparr.Api.Features.Seeker.Contracts.Requests;
public sealed record UpdateSeekerInstanceConfigRequest
{
public Guid ArrInstanceId { get; init; }
public bool Enabled { get; init; } = true;
public List<string> SkipTags { get; init; } = [];
public int ActiveDownloadLimit { get; init; } = 3;
public int MinCycleTimeDays { get; init; } = 7;
}
@@ -0,0 +1,20 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses;
public sealed record CustomFormatScoreEntryResponse
{
public Guid Id { get; init; }
public Guid ArrInstanceId { get; init; }
public long ExternalItemId { get; init; }
public long EpisodeId { get; init; }
public InstanceType ItemType { get; init; }
public string Title { get; init; } = string.Empty;
public long FileId { get; init; }
public int CurrentScore { get; init; }
public int CutoffScore { get; init; }
public string QualityProfileName { get; init; } = string.Empty;
public bool IsBelowCutoff { get; init; }
public bool IsMonitored { get; init; }
public DateTime LastSyncedAt { get; init; }
}
@@ -0,0 +1,8 @@
namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses;
public sealed record CustomFormatScoreHistoryEntryResponse
{
public int Score { get; init; }
public int CutoffScore { get; init; }
public DateTime RecordedAt { get; init; }
}
@@ -0,0 +1,25 @@
namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses;
public sealed record CustomFormatScoreStatsResponse
{
public int TotalTracked { get; init; }
public int BelowCutoff { get; init; }
public int AtOrAboveCutoff { get; init; }
public int Monitored { get; init; }
public int Unmonitored { get; init; }
public int RecentUpgrades { get; init; }
public List<InstanceCfScoreStat> PerInstanceStats { get; init; } = [];
}
public sealed record InstanceCfScoreStat
{
public Guid InstanceId { get; init; }
public string InstanceName { get; init; } = string.Empty;
public string InstanceType { get; init; } = string.Empty;
public int TotalTracked { get; init; }
public int BelowCutoff { get; init; }
public int AtOrAboveCutoff { get; init; }
public int Monitored { get; init; }
public int Unmonitored { get; init; }
public int RecentUpgrades { get; init; }
}
@@ -0,0 +1,16 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses;
public sealed record CustomFormatScoreUpgradeResponse
{
public Guid ArrInstanceId { get; init; }
public long ExternalItemId { get; init; }
public long EpisodeId { get; init; }
public InstanceType ItemType { get; init; }
public string Title { get; init; } = string.Empty;
public int PreviousScore { get; init; }
public int NewScore { get; init; }
public int CutoffScore { get; init; }
public DateTime UpgradedAt { get; init; }
}
@@ -0,0 +1,16 @@
namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses;
public sealed record InstanceSearchStat
{
public Guid InstanceId { get; init; }
public string InstanceName { get; init; } = string.Empty;
public string InstanceType { get; init; } = string.Empty;
public int ItemsTracked { get; init; }
public int TotalSearchCount { get; init; }
public DateTime? LastSearchedAt { get; init; }
public DateTime? LastProcessedAt { get; init; }
public Guid? CurrentCycleId { get; init; }
public int CycleItemsSearched { get; init; }
public int CycleItemsTotal { get; init; }
public DateTime? CycleStartedAt { get; init; }
}
@@ -0,0 +1,19 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses;
public sealed record SearchEventResponse
{
public Guid Id { get; init; }
public DateTime Timestamp { get; init; }
public Guid? ArrInstanceId { get; init; }
public string? InstanceType { get; init; }
public string ItemTitle { get; init; } = string.Empty;
public SeekerSearchType SearchType { get; init; }
public SeekerSearchReason? SearchReason { get; init; }
public SearchCommandStatus? SearchStatus { get; init; }
public DateTime? CompletedAt { get; init; }
public List<string> GrabbedItems { get; init; } = [];
public Guid? CycleId { get; init; }
public bool IsDryRun { get; init; }
}
@@ -0,0 +1,12 @@
namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses;
public sealed record SearchStatsSummaryResponse
{
public int TotalSearchesAllTime { get; init; }
public int SearchesLast7Days { get; init; }
public int SearchesLast30Days { get; init; }
public int UniqueItemsSearched { get; init; }
public int PendingReplacementSearches { get; init; }
public int EnabledInstances { get; init; }
public List<InstanceSearchStat> PerInstanceStats { get; init; } = [];
}
@@ -0,0 +1,26 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses;
public sealed record SeekerConfigResponse
{
public bool SearchEnabled { get; init; }
public ushort SearchInterval { get; init; }
public bool ProactiveSearchEnabled { get; init; }
public SelectionStrategy SelectionStrategy { get; init; }
public bool MonitoredOnly { get; init; }
public bool UseCutoff { get; init; }
public bool UseCustomFormatScore { get; init; }
public bool UseRoundRobin { get; init; }
public int PostReleaseGraceHours { get; init; }
public List<SeekerInstanceConfigResponse> Instances { get; init; } = [];
}
@@ -0,0 +1,24 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses;
public sealed record SeekerInstanceConfigResponse
{
public Guid ArrInstanceId { get; init; }
public string InstanceName { get; init; } = string.Empty;
public InstanceType InstanceType { get; init; }
public bool Enabled { get; init; }
public List<string> SkipTags { get; init; } = [];
public DateTime? LastProcessedAt { get; init; }
public bool ArrInstanceEnabled { get; init; }
public int ActiveDownloadLimit { get; init; }
public int MinCycleTimeDays { get; init; }
}
@@ -0,0 +1,317 @@
using Cleanuparr.Api.Features.Seeker.Contracts.Responses;
using Cleanuparr.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Cleanuparr.Api.Features.Seeker.Controllers;
[ApiController]
[Route("api/seeker/cf-scores")]
[Authorize]
public sealed class CustomFormatScoreController : ControllerBase
{
private readonly DataContext _dataContext;
public CustomFormatScoreController(DataContext dataContext)
{
_dataContext = dataContext;
}
/// <summary>
/// Gets current CF scores with pagination, optionally filtered by instance.
/// </summary>
[HttpGet]
public async Task<IActionResult> GetCustomFormatScores(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 50,
[FromQuery] Guid? instanceId = null,
[FromQuery] string? search = null,
[FromQuery] string sortBy = "title",
[FromQuery] bool hideMet = false,
[FromQuery] bool hideUnmonitored = false)
{
if (page < 1) page = 1;
if (pageSize < 1) pageSize = 50;
if (pageSize > 100) pageSize = 100;
var query = _dataContext.CustomFormatScoreEntries
.AsNoTracking()
.AsQueryable();
if (instanceId.HasValue)
{
query = query.Where(e => e.ArrInstanceId == instanceId.Value);
}
if (!string.IsNullOrWhiteSpace(search))
{
query = query.Where(e => e.Title.ToLower().Contains(search.ToLower()));
}
if (hideMet)
{
query = query.Where(e => e.CurrentScore < e.CutoffScore);
}
if (hideUnmonitored)
{
query = query.Where(e => e.IsMonitored);
}
int totalCount = await query.CountAsync();
var items = await (sortBy == "date"
? query.OrderByDescending(e => e.LastSyncedAt)
: query.OrderBy(e => e.Title))
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(e => new CustomFormatScoreEntryResponse
{
Id = e.Id,
ArrInstanceId = e.ArrInstanceId,
ExternalItemId = e.ExternalItemId,
EpisodeId = e.EpisodeId,
ItemType = e.ItemType,
Title = e.Title,
FileId = e.FileId,
CurrentScore = e.CurrentScore,
CutoffScore = e.CutoffScore,
QualityProfileName = e.QualityProfileName,
IsBelowCutoff = e.CurrentScore < e.CutoffScore,
IsMonitored = e.IsMonitored,
LastSyncedAt = e.LastSyncedAt,
})
.ToListAsync();
return Ok(new
{
Items = items,
Page = page,
PageSize = pageSize,
TotalCount = totalCount,
TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize),
});
}
/// <summary>
/// Gets recent CF score upgrades (where score improved in history).
/// </summary>
[HttpGet("upgrades")]
public async Task<IActionResult> GetRecentUpgrades(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] Guid? instanceId = null,
[FromQuery] int days = 30)
{
if (page < 1) page = 1;
if (pageSize < 1) pageSize = 20;
if (pageSize > 100) pageSize = 100;
// Find history entries where a newer entry has a higher score than an older one
// We group by item and look for score increases between consecutive records
var query = _dataContext.CustomFormatScoreHistory
.AsNoTracking()
.AsQueryable();
if (instanceId.HasValue)
{
query = query.Where(h => h.ArrInstanceId == instanceId.Value);
}
var allHistory = await query
.Where(h => h.RecordedAt >= DateTime.UtcNow.AddDays(-days))
.OrderByDescending(h => h.RecordedAt)
.ToListAsync();
var upgrades = new List<CustomFormatScoreUpgradeResponse>();
// Group by (ArrInstanceId, ExternalItemId, EpisodeId) and find score increases
var grouped = allHistory
.GroupBy(h => new { h.ArrInstanceId, h.ExternalItemId, h.EpisodeId });
foreach (var group in grouped)
{
var entries = group.OrderBy(h => h.RecordedAt).ToList();
for (int i = 1; i < entries.Count; i++)
{
if (entries[i].Score > entries[i - 1].Score)
{
upgrades.Add(new CustomFormatScoreUpgradeResponse
{
ArrInstanceId = entries[i].ArrInstanceId,
ExternalItemId = entries[i].ExternalItemId,
EpisodeId = entries[i].EpisodeId,
ItemType = entries[i].ItemType,
Title = entries[i].Title,
PreviousScore = entries[i - 1].Score,
NewScore = entries[i].Score,
CutoffScore = entries[i].CutoffScore,
UpgradedAt = entries[i].RecordedAt,
});
}
}
}
// Sort by most recent upgrade first
upgrades = upgrades.OrderByDescending(u => u.UpgradedAt).ToList();
int totalCount = upgrades.Count;
var paged = upgrades
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToList();
return Ok(new
{
Items = paged,
Page = page,
PageSize = pageSize,
TotalCount = totalCount,
TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize),
});
}
[HttpGet("instances")]
public async Task<IActionResult> GetInstances()
{
var instances = await _dataContext.CustomFormatScoreEntries
.AsNoTracking()
.Select(e => new { e.ArrInstanceId, e.ItemType })
.Distinct()
.Join(
_dataContext.ArrInstances.AsNoTracking(),
e => e.ArrInstanceId,
a => a.Id,
(e, a) => new
{
Id = e.ArrInstanceId,
a.Name,
e.ItemType,
})
.OrderBy(x => x.Name)
.ToListAsync();
return Ok(new { Instances = instances });
}
/// <summary>
/// Gets summary statistics for CF score tracking.
/// </summary>
[HttpGet("stats")]
public async Task<IActionResult> GetStats()
{
var entries = await _dataContext.CustomFormatScoreEntries
.AsNoTracking()
.ToListAsync();
int totalTracked = entries.Count;
int belowCutoff = entries.Count(e => e.CurrentScore < e.CutoffScore);
int atOrAboveCutoff = totalTracked - belowCutoff;
int monitored = entries.Count(e => e.IsMonitored);
int unmonitored = totalTracked - monitored;
// Count upgrades in the last 7 days
var sevenDaysAgo = DateTime.UtcNow.AddDays(-7);
var recentHistory = await _dataContext.CustomFormatScoreHistory
.AsNoTracking()
.Where(h => h.RecordedAt >= sevenDaysAgo)
.OrderBy(h => h.RecordedAt)
.ToListAsync();
int recentUpgrades = 0;
var recentGrouped = recentHistory
.GroupBy(h => new { h.ArrInstanceId, h.ExternalItemId, h.EpisodeId });
foreach (var group in recentGrouped)
{
var ordered = group.OrderBy(h => h.RecordedAt).ToList();
for (int i = 1; i < ordered.Count; i++)
{
if (ordered[i].Score > ordered[i - 1].Score)
recentUpgrades++;
}
}
// Per-instance stats
var instanceIds = entries.Select(e => e.ArrInstanceId).Distinct().ToList();
var instances = await _dataContext.ArrInstances
.AsNoTracking()
.Include(a => a.ArrConfig)
.Where(a => instanceIds.Contains(a.Id))
.ToListAsync();
var perInstanceStats = instanceIds.Select(instanceId =>
{
var instanceEntries = entries.Where(e => e.ArrInstanceId == instanceId).ToList();
int instTracked = instanceEntries.Count;
int instBelow = instanceEntries.Count(e => e.CurrentScore < e.CutoffScore);
int instMonitored = instanceEntries.Count(e => e.IsMonitored);
int instUpgrades = 0;
var instHistory = recentGrouped
.Where(g => g.Key.ArrInstanceId == instanceId);
foreach (var group in instHistory)
{
var ordered = group.OrderBy(h => h.RecordedAt).ToList();
for (int i = 1; i < ordered.Count; i++)
{
if (ordered[i].Score > ordered[i - 1].Score)
instUpgrades++;
}
}
var instance = instances.FirstOrDefault(a => a.Id == instanceId);
return new InstanceCfScoreStat
{
InstanceId = instanceId,
InstanceName = instance?.Name ?? "Unknown",
InstanceType = instance?.ArrConfig.Type.ToString() ?? "Unknown",
TotalTracked = instTracked,
BelowCutoff = instBelow,
AtOrAboveCutoff = instTracked - instBelow,
Monitored = instMonitored,
Unmonitored = instTracked - instMonitored,
RecentUpgrades = instUpgrades,
};
}).OrderBy(s => s.InstanceName).ToList();
return Ok(new CustomFormatScoreStatsResponse
{
TotalTracked = totalTracked,
BelowCutoff = belowCutoff,
AtOrAboveCutoff = atOrAboveCutoff,
Monitored = monitored,
Unmonitored = unmonitored,
RecentUpgrades = recentUpgrades,
PerInstanceStats = perInstanceStats,
});
}
/// <summary>
/// Gets CF score history for a specific item.
/// </summary>
[HttpGet("{instanceId}/{itemId}/history")]
public async Task<IActionResult> GetItemHistory(
Guid instanceId,
long itemId,
[FromQuery] long episodeId = 0)
{
var history = await _dataContext.CustomFormatScoreHistory
.AsNoTracking()
.Where(h => h.ArrInstanceId == instanceId
&& h.ExternalItemId == itemId
&& h.EpisodeId == episodeId)
.OrderByDescending(h => h.RecordedAt)
.Select(h => new CustomFormatScoreHistoryEntryResponse
{
Score = h.Score,
CutoffScore = h.CutoffScore,
RecordedAt = h.RecordedAt,
})
.ToListAsync();
return Ok(new { Entries = history });
}
}
@@ -0,0 +1,209 @@
using Cleanuparr.Api.Features.Seeker.Contracts.Responses;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Seeker;
using Cleanuparr.Persistence.Models.State;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Cleanuparr.Api.Features.Seeker.Controllers;
[ApiController]
[Route("api/seeker/search-stats")]
[Authorize]
public sealed class SearchStatsController : ControllerBase
{
private readonly DataContext _dataContext;
private readonly EventsContext _eventsContext;
public SearchStatsController(DataContext dataContext, EventsContext eventsContext)
{
_dataContext = dataContext;
_eventsContext = eventsContext;
}
/// <summary>
/// Gets aggregate search statistics across all instances.
/// </summary>
[HttpGet("summary")]
public async Task<IActionResult> GetSummary()
{
DateTime sevenDaysAgo = DateTime.UtcNow.AddDays(-7);
DateTime thirtyDaysAgo = DateTime.UtcNow.AddDays(-30);
// Event counts from EventsContext
var searchEvents = _eventsContext.Events
.AsNoTracking()
.Where(e => e.EventType == EventType.SearchTriggered);
int totalSearchesAllTime = await searchEvents.CountAsync();
int searchesLast7Days = await searchEvents.CountAsync(e => e.Timestamp >= sevenDaysAgo);
int searchesLast30Days = await searchEvents.CountAsync(e => e.Timestamp >= thirtyDaysAgo);
// History stats from DataContext
int uniqueItemsSearched = await _dataContext.SeekerHistory
.AsNoTracking()
.Select(h => h.ExternalItemId)
.Distinct()
.CountAsync();
int pendingReplacementSearches = await _dataContext.SearchQueue.CountAsync();
// Per-instance stats
List<SeekerInstanceConfig> instanceConfigs = await _dataContext.SeekerInstanceConfigs
.AsNoTracking()
.Include(s => s.ArrInstance)
.ThenInclude(a => a.ArrConfig)
.Where(s => s.Enabled && s.ArrInstance.Enabled)
.ToListAsync();
var historyByInstance = await _dataContext.SeekerHistory
.AsNoTracking()
.GroupBy(h => h.ArrInstanceId)
.Select(g => new
{
InstanceId = g.Key,
ItemsTracked = g.Select(h => h.ExternalItemId).Distinct().Count(),
LastSearchedAt = g.Max(h => h.LastSearchedAt),
TotalSearchCount = g.Sum(h => h.SearchCount),
})
.ToListAsync();
// Count items searched in current cycle per instance
List<Guid> currentCycleIds = instanceConfigs.Select(ic => ic.CurrentCycleId).ToList();
var cycleItemsByInstance = await _dataContext.SeekerHistory
.AsNoTracking()
.Where(h => currentCycleIds.Contains(h.CycleId))
.GroupBy(h => h.ArrInstanceId)
.Select(g => new
{
InstanceId = g.Key,
CycleItemsSearched = g.Select(h => h.ExternalItemId).Distinct().Count(),
CycleStartedAt = (DateTime?)g.Min(h => h.LastSearchedAt),
})
.ToListAsync();
var perInstanceStats = instanceConfigs.Select(ic =>
{
var history = historyByInstance.FirstOrDefault(h => h.InstanceId == ic.ArrInstanceId);
var cycleProgress = cycleItemsByInstance.FirstOrDefault(c => c.InstanceId == ic.ArrInstanceId);
return new InstanceSearchStat
{
InstanceId = ic.ArrInstanceId,
InstanceName = ic.ArrInstance.Name,
InstanceType = ic.ArrInstance.ArrConfig.Type.ToString(),
ItemsTracked = history?.ItemsTracked ?? 0,
TotalSearchCount = history?.TotalSearchCount ?? 0,
LastSearchedAt = history?.LastSearchedAt,
LastProcessedAt = ic.LastProcessedAt,
CurrentCycleId = ic.CurrentCycleId,
CycleItemsSearched = cycleProgress?.CycleItemsSearched ?? 0,
CycleItemsTotal = ic.TotalEligibleItems,
CycleStartedAt = cycleProgress?.CycleStartedAt,
};
}).ToList();
return Ok(new SearchStatsSummaryResponse
{
TotalSearchesAllTime = totalSearchesAllTime,
SearchesLast7Days = searchesLast7Days,
SearchesLast30Days = searchesLast30Days,
UniqueItemsSearched = uniqueItemsSearched,
PendingReplacementSearches = pendingReplacementSearches,
EnabledInstances = instanceConfigs.Count,
PerInstanceStats = perInstanceStats,
});
}
/// <summary>
/// Gets paginated search-triggered events
/// </summary>
[HttpGet("events")]
public async Task<IActionResult> GetEvents(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 50,
[FromQuery] Guid? instanceId = null,
[FromQuery] Guid? cycleId = null,
[FromQuery] string? search = null)
{
if (page < 1) page = 1;
if (pageSize < 1) pageSize = 50;
if (pageSize > 100) pageSize = 100;
var query = _eventsContext.Events
.AsNoTracking()
.Include(e => e.SearchEventData)
.Where(e => e.EventType == EventType.SearchTriggered);
// Filter by instance ID
if (instanceId.HasValue)
{
query = query.Where(e => e.ArrInstanceId == instanceId.Value);
}
// Filter by cycle ID
if (cycleId.HasValue)
{
query = query.Where(e => e.CycleId == cycleId.Value);
}
// Search by item title in SearchEventData
if (!string.IsNullOrWhiteSpace(search))
{
string pattern = EventsContext.GetLikePattern(search);
query = query.Where(e => e.SearchEventData != null
&& EF.Functions.Like(e.SearchEventData.ItemTitle, pattern));
}
int totalCount = await query.CountAsync();
var rawEvents = await query
.OrderByDescending(e => e.Timestamp)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
// Resolve instance types from DataContext via ArrInstanceId
var arrInstanceIds = rawEvents
.Where(e => e.ArrInstanceId.HasValue)
.Select(e => e.ArrInstanceId!.Value)
.Distinct()
.ToList();
var instanceTypeMap = arrInstanceIds.Count > 0
? await _dataContext.ArrInstances
.AsNoTracking()
.Include(a => a.ArrConfig)
.Where(a => arrInstanceIds.Contains(a.Id))
.ToDictionaryAsync(a => a.Id, a => a.ArrConfig.Type)
: new Dictionary<Guid, InstanceType>();
var items = rawEvents.Select(e => new SearchEventResponse
{
Id = e.Id,
Timestamp = e.Timestamp,
ArrInstanceId = e.ArrInstanceId,
InstanceType = e.ArrInstanceId.HasValue && instanceTypeMap.TryGetValue(e.ArrInstanceId.Value, out var it)
? it.ToString()
: null,
ItemTitle = e.SearchEventData?.ItemTitle ?? "Unknown",
SearchType = e.SearchEventData?.SearchType ?? SeekerSearchType.Proactive,
SearchReason = e.SearchEventData?.SearchReason,
SearchStatus = e.SearchStatus,
CompletedAt = e.CompletedAt,
GrabbedItems = e.SearchEventData?.GrabbedItems ?? [],
CycleId = e.CycleId,
IsDryRun = e.IsDryRun,
}).ToList();
return Ok(new
{
Items = items,
Page = page,
PageSize = pageSize,
TotalCount = totalCount,
TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize),
});
}
}
@@ -0,0 +1,206 @@
using Cleanuparr.Api.Features.Seeker.Contracts.Requests;
using Cleanuparr.Shared.Helpers;
using Cleanuparr.Api.Features.Seeker.Contracts.Responses;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Seeker;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Cleanuparr.Api.Features.Seeker.Controllers;
[ApiController]
[Route("api/configuration")]
[Authorize]
public sealed class SeekerConfigController : ControllerBase
{
private readonly ILogger<SeekerConfigController> _logger;
private readonly DataContext _dataContext;
private readonly IJobManagementService _jobManagementService;
public SeekerConfigController(
ILogger<SeekerConfigController> logger,
DataContext dataContext,
IJobManagementService jobManagementService)
{
_logger = logger;
_dataContext = dataContext;
_jobManagementService = jobManagementService;
}
[HttpGet("seeker")]
public async Task<IActionResult> GetSeekerConfig()
{
var config = await _dataContext.SeekerConfigs
.AsNoTracking()
.FirstAsync();
// Get all Sonarr/Radarr instances with their seeker configs
var arrInstances = await _dataContext.ArrInstances
.AsNoTracking()
.Include(a => a.ArrConfig)
.Where(a => a.ArrConfig.Type == InstanceType.Sonarr || a.ArrConfig.Type == InstanceType.Radarr)
.ToListAsync();
var arrInstanceIds = arrInstances.Select(a => a.Id).ToHashSet();
var seekerInstanceConfigs = await _dataContext.SeekerInstanceConfigs
.AsNoTracking()
.Where(s => arrInstanceIds.Contains(s.ArrInstanceId))
.ToListAsync();
var instanceResponses = arrInstances.Select(instance =>
{
var seekerConfig = seekerInstanceConfigs.FirstOrDefault(s => s.ArrInstanceId == instance.Id);
return new SeekerInstanceConfigResponse
{
ArrInstanceId = instance.Id,
InstanceName = instance.Name,
InstanceType = instance.ArrConfig.Type,
Enabled = seekerConfig?.Enabled ?? false,
SkipTags = seekerConfig?.SkipTags ?? [],
LastProcessedAt = seekerConfig?.LastProcessedAt,
ArrInstanceEnabled = instance.Enabled,
ActiveDownloadLimit = seekerConfig?.ActiveDownloadLimit ?? 3,
MinCycleTimeDays = seekerConfig?.MinCycleTimeDays ?? 7,
};
}).ToList();
var response = new SeekerConfigResponse
{
SearchEnabled = config.SearchEnabled,
SearchInterval = config.SearchInterval,
ProactiveSearchEnabled = config.ProactiveSearchEnabled,
SelectionStrategy = config.SelectionStrategy,
MonitoredOnly = config.MonitoredOnly,
UseCutoff = config.UseCutoff,
UseCustomFormatScore = config.UseCustomFormatScore,
UseRoundRobin = config.UseRoundRobin,
PostReleaseGraceHours = config.PostReleaseGraceHours,
Instances = instanceResponses,
};
return Ok(response);
}
[HttpPut("seeker")]
public async Task<IActionResult> UpdateSeekerConfig([FromBody] UpdateSeekerConfigRequest request)
{
if (!await DataContext.Lock.WaitAsync(TimeSpan.FromSeconds(30)))
{
return StatusCode(503, "Database is busy, please try again");
}
try
{
var config = await _dataContext.SeekerConfigs.FirstAsync();
ushort previousInterval = config.SearchInterval;
bool previousUseCustomFormatScore = config.UseCustomFormatScore;
bool previousSearchEnabled = config.SearchEnabled;
bool previousProactiveSearchEnabled = config.ProactiveSearchEnabled;
request.ApplyTo(config);
config.Validate();
if (request.ProactiveSearchEnabled && !request.Instances.Any(i => i.Enabled))
{
throw new Domain.Exceptions.ValidationException(
"At least one instance must be enabled when proactive search is enabled");
}
// Sync instance configs
var existingInstanceConfigs = await _dataContext.SeekerInstanceConfigs.ToListAsync();
foreach (var instanceReq in request.Instances)
{
var existing = existingInstanceConfigs
.FirstOrDefault(e => e.ArrInstanceId == instanceReq.ArrInstanceId);
if (existing is not null)
{
existing.Enabled = instanceReq.Enabled;
existing.SkipTags = instanceReq.SkipTags;
existing.ActiveDownloadLimit = instanceReq.ActiveDownloadLimit;
existing.MinCycleTimeDays = instanceReq.MinCycleTimeDays;
}
else
{
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = instanceReq.ArrInstanceId,
Enabled = instanceReq.Enabled,
SkipTags = instanceReq.SkipTags,
ActiveDownloadLimit = instanceReq.ActiveDownloadLimit,
MinCycleTimeDays = instanceReq.MinCycleTimeDays,
});
}
}
await _dataContext.SaveChangesAsync();
// Start/stop Seeker based on SearchEnabled toggle
if (config.SearchEnabled != previousSearchEnabled)
{
if (config.SearchEnabled)
{
_logger.LogInformation("SearchEnabled turned on, starting Seeker job");
await _jobManagementService.StartJob(JobType.Seeker, null, config.ToCronExpression());
}
else
{
_logger.LogInformation("SearchEnabled turned off, stopping Seeker job");
await _jobManagementService.StopJob(JobType.Seeker);
}
}
// Update Quartz trigger if SearchInterval changed (only while search is enabled)
else if (config.SearchEnabled && config.SearchInterval != previousInterval)
{
_logger.LogInformation("Search interval changed from {Old} to {New} minutes, updating Seeker schedule",
previousInterval, config.SearchInterval);
await _jobManagementService.StartJob(JobType.Seeker, null, config.ToCronExpression());
}
// Toggle CustomFormatScoreSyncer job when UseCustomFormatScore changes
if (config.UseCustomFormatScore != previousUseCustomFormatScore)
{
if (config.UseCustomFormatScore)
{
_logger.LogInformation("UseCustomFormatScore enabled, starting CustomFormatScoreSyncer job");
await _jobManagementService.StartJob(JobType.CustomFormatScoreSyncer, null, Constants.CustomFormatScoreSyncerCron);
await _jobManagementService.TriggerJobOnce(JobType.CustomFormatScoreSyncer);
}
else
{
_logger.LogInformation("UseCustomFormatScore disabled, stopping CustomFormatScoreSyncer job");
await _jobManagementService.StopJob(JobType.CustomFormatScoreSyncer);
}
}
// Trigger CustomFormatScoreSyncer once when search or proactive search is re-enabled with custom format scores active
if (previousUseCustomFormatScore && config.UseCustomFormatScore)
{
bool searchJustEnabled = !previousSearchEnabled && config.SearchEnabled;
bool proactiveJustEnabled = !previousProactiveSearchEnabled && config.ProactiveSearchEnabled;
if (searchJustEnabled || proactiveJustEnabled)
{
_logger.LogInformation("Search re-enabled with UseCustomFormatScore active, triggering CustomFormatScoreSyncer");
await _jobManagementService.TriggerJobOnce(JobType.CustomFormatScoreSyncer);
}
}
return Ok(new { Message = "Seeker configuration updated successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save Seeker configuration");
throw;
}
finally
{
DataContext.Lock.Release();
}
}
}
@@ -48,6 +48,14 @@ public static class HostExtensions
public static async Task<WebApplicationBuilder> InitAsync(this WebApplicationBuilder builder)
{
// Apply data db migrations first — events migrations may ATTACH cleanuparr.db
// and reference its schema, so it must be up to date before events migrate.
await using var configContext = DataContext.CreateStaticInstance();
if ((await configContext.Database.GetPendingMigrationsAsync()).Any())
{
await configContext.Database.MigrateAsync();
}
// Apply events db migrations
await using var eventsContext = EventsContext.CreateStaticInstance();
if ((await eventsContext.Database.GetPendingMigrationsAsync()).Any())
@@ -55,13 +63,6 @@ public static class HostExtensions
await eventsContext.Database.MigrateAsync();
}
// Apply data db migrations
await using var configContext = DataContext.CreateStaticInstance();
if ((await configContext.Database.GetPendingMigrationsAsync()).Any())
{
await configContext.Database.MigrateAsync();
}
// Apply users db migrations
await using var usersContext = UsersContext.CreateStaticInstance();
if ((await usersContext.Database.GetPendingMigrationsAsync()).Any())
@@ -6,6 +6,8 @@ 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.Persistence.Models.Configuration.Seeker;
using SeekerJob = Cleanuparr.Infrastructure.Features.Jobs.Seeker;
using Cleanuparr.Shared.Helpers;
using Microsoft.EntityFrameworkCore;
using Quartz;
@@ -100,12 +102,17 @@ public class BackgroundJobManager : IHostedService
BlacklistSyncConfig blacklistSyncConfig = await dataContext.BlacklistSyncConfigs
.AsNoTracking()
.FirstAsync(cancellationToken);
SeekerConfig seekerConfig = await dataContext.SeekerConfigs
.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);
await RegisterSeekerJob(seekerConfig, cancellationToken);
await RegisterCustomFormatScoreSyncJob(seekerConfig, cancellationToken);
}
/// <summary>
@@ -171,6 +178,33 @@ public class BackgroundJobManager : IHostedService
}
}
/// <summary>
/// Registers the Seeker job with a trigger based on SearchInterval.
/// The Seeker is always running.
/// </summary>
public async Task RegisterSeekerJob(SeekerConfig config, CancellationToken cancellationToken = default)
{
await AddJobWithoutTrigger<SeekerJob>(cancellationToken);
if (config.SearchEnabled)
{
await AddTriggersForJob<SeekerJob>(config.ToCronExpression(), cancellationToken);
}
}
/// <summary>
/// Registers the CustomFormatScoreSyncer job. Only adds triggers when UseCustomFormatScore is enabled.
/// Runs every 30 minutes to sync custom format scores from arr instances.
/// </summary>
public async Task RegisterCustomFormatScoreSyncJob(SeekerConfig config, CancellationToken cancellationToken = default)
{
await AddJobWithoutTrigger<CustomFormatScoreSyncer>(cancellationToken);
if (config.UseCustomFormatScore)
{
await AddTriggersForJob<CustomFormatScoreSyncer>(Constants.CustomFormatScoreSyncerCron, cancellationToken);
}
}
/// <summary>
/// Helper method to add triggers for an existing job.
/// </summary>
@@ -204,7 +238,11 @@ public class BackgroundJobManager : IHostedService
throw new ValidationException($"{cronExpression} should have a fire time of maximum {Constants.TriggerMaxLimit.TotalHours} hours");
}
if (typeof(T) != typeof(MalwareBlocker) && triggerValue < Constants.TriggerMinLimit)
if (typeof(T) == typeof(SeekerJob) && triggerValue < Constants.SeekerMinLimit)
{
throw new ValidationException($"{cronExpression} should have a fire time of minimum {Constants.SeekerMinLimit.TotalMinutes} minutes");
}
else if (typeof(T) != typeof(MalwareBlocker) && triggerValue < Constants.TriggerMinLimit)
{
throw new ValidationException($"{cronExpression} should have a fire time of minimum {Constants.TriggerMinLimit.TotalSeconds} seconds");
}
@@ -51,7 +51,7 @@ public sealed class GenericJob<T> : IJob
await BroadcastJobStatus(hubContext, jobManagementService, jobType, false);
var handler = scope.ServiceProvider.GetRequiredService<T>();
await handler.ExecuteAsync();
await handler.ExecuteAsync(context.CancellationToken);
status = JobRunStatus.Completed;
await BroadcastJobStatus(hubContext, jobManagementService, jobType, true);
@@ -38,7 +38,7 @@ public sealed class BlacklistSynchronizer : IHandler
_dryRunInterceptor = dryRunInterceptor;
}
public async Task ExecuteAsync()
public async Task ExecuteAsync(CancellationToken cancellationToken = default)
{
BlacklistSyncConfig config = await _dataContext.BlacklistSyncConfigs
.AsNoTracking()
@@ -73,7 +73,7 @@ public sealed class BlacklistSynchronizer : IHandler
.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");
@@ -0,0 +1,3 @@
namespace Cleanuparr.Domain.Entities.Arr;
public sealed record ArrCommandStatus(long Id, string Status, string? Message);
@@ -0,0 +1,10 @@
namespace Cleanuparr.Domain.Entities.Arr;
public sealed record ArrEpisodeFile
{
public long Id { get; init; }
public bool QualityCutoffNotMet { get; init; }
public int CustomFormatScore { get; init; }
}
@@ -0,0 +1,10 @@
namespace Cleanuparr.Domain.Entities.Arr;
public sealed record ArrQualityProfile
{
public int Id { get; init; }
public string Name { get; init; } = string.Empty;
public int CutoffFormatScore { get; init; }
}
@@ -0,0 +1,11 @@
namespace Cleanuparr.Domain.Entities.Arr;
/// <summary>
/// Represents the custom format score data from a movie/episode file API response
/// </summary>
public sealed record MediaFileScore
{
public long Id { get; init; }
public int CustomFormatScore { get; init; }
}
@@ -0,0 +1,8 @@
namespace Cleanuparr.Domain.Entities.Arr;
public sealed record MovieFileInfo
{
public long Id { get; init; }
public bool QualityCutoffNotMet { get; init; }
}
@@ -37,4 +37,5 @@ public sealed record QueueRecord
public required string DownloadId { get; init; }
public required string Protocol { get; init; }
public required long Id { get; init; }
public long SizeLeft { get; init; }
}
@@ -1,4 +1,4 @@
namespace Data.Models.Arr;
namespace Cleanuparr.Domain.Entities.Arr;
public class SearchItem
{
@@ -0,0 +1,18 @@
namespace Cleanuparr.Domain.Entities.Arr;
public sealed record SearchableEpisode
{
public long Id { get; init; }
public int SeasonNumber { get; init; }
public int EpisodeNumber { get; init; }
public bool Monitored { get; init; }
public DateTime? AirDateUtc { get; init; }
public bool HasFile { get; init; }
public long EpisodeFileId { get; init; }
}
@@ -0,0 +1,28 @@
namespace Cleanuparr.Domain.Entities.Arr;
public sealed record SearchableMovie
{
public long Id { get; init; }
public string Title { get; init; } = string.Empty;
public bool Monitored { get; init; }
public bool HasFile { get; init; }
public MovieFileInfo? MovieFile { get; init; }
public List<long> Tags { get; init; } = [];
public int QualityProfileId { get; init; }
public string Status { get; init; } = string.Empty;
public DateTime? Added { get; init; }
public DateTime? DigitalRelease { get; init; }
public DateTime? PhysicalRelease { get; init; }
public DateTime? InCinemas { get; init; }
}
@@ -0,0 +1,20 @@
namespace Cleanuparr.Domain.Entities.Arr;
public sealed record SearchableSeries
{
public long Id { get; init; }
public string Title { get; init; } = string.Empty;
public int QualityProfileId { get; init; }
public bool Monitored { get; init; }
public List<long> Tags { get; init; } = [];
public DateTime? Added { get; init; }
public string Status { get; init; } = string.Empty;
public SeriesStatistics? Statistics { get; init; }
}
@@ -1,5 +1,4 @@
using Cleanuparr.Domain.Enums;
using Data.Models.Arr;
namespace Cleanuparr.Domain.Entities.Arr;
@@ -0,0 +1,8 @@
namespace Cleanuparr.Domain.Entities.Arr;
public sealed record SeriesStatistics
{
public int EpisodeFileCount { get; init; }
public int EpisodeCount { get; init; }
}
@@ -0,0 +1,8 @@
namespace Cleanuparr.Domain.Entities.Arr;
public sealed record Tag
{
public required long Id { get; init; }
public required string Label { get; init; }
}
@@ -61,10 +61,17 @@ public sealed record RTorrentTorrent
public string? Label { get; init; }
/// <summary>
/// Base path where the torrent data is stored
/// Base path where the torrent data is stored.
/// For multi-file torrents this is the torrent directory; for single-file torrents this is the full file path.
/// </summary>
public string? BasePath { get; init; }
/// <summary>
/// Directory containing the torrent data (from d.directory).
/// Unlike BasePath, this always points to a directory for both single-file and multi-file torrents.
/// </summary>
public string? Directory { get; init; }
/// <summary>
/// List of tracker URLs for this torrent
/// </summary>
@@ -10,5 +10,6 @@ public enum EventType
QueueItemDeleted,
DownloadCleaned,
CategoryChanged,
DownloadMarkedForDeletion
DownloadMarkedForDeletion,
SearchTriggered,
}
@@ -6,4 +6,6 @@ public enum JobType
MalwareBlocker,
DownloadCleaner,
BlacklistSynchronizer,
Seeker,
CustomFormatScoreSyncer,
}
@@ -9,5 +9,6 @@ public enum NotificationEventType
SlowTimeStrike,
QueueItemDeleted,
DownloadCleaned,
CategoryChanged
CategoryChanged,
SearchTriggered
}
@@ -0,0 +1,13 @@
using System.Text.Json.Serialization;
namespace Cleanuparr.Domain.Enums;
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum SearchCommandStatus
{
Pending,
Started,
Completed,
Failed,
TimedOut
}
@@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
namespace Cleanuparr.Domain.Enums;
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum SeekerSearchReason
{
Missing,
QualityCutoffNotMet,
CustomFormatScoreBelowCutoff,
Replacement,
}
@@ -0,0 +1,10 @@
using System.Text.Json.Serialization;
namespace Cleanuparr.Domain.Enums;
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum SeekerSearchType
{
Proactive,
Replacement
}
@@ -0,0 +1,43 @@
namespace Cleanuparr.Domain.Enums;
public enum SelectionStrategy
{
/// <summary>
/// Weighted random selection combining search recency and add date.
/// Items that are both recently added and haven't been searched
/// get the highest priority. Best all-around strategy for mixed libraries.
/// </summary>
BalancedWeighted,
/// <summary>
/// Deterministic selection of items with the oldest (or no) search history first.
/// Provides systematic, sequential coverage of your entire library.
/// </summary>
OldestSearchFirst,
/// <summary>
/// Weighted random selection based on search recency.
/// Items that haven't been searched recently are ranked higher and more likely to be selected,
/// while recently-searched items still have a chance proportional to their rank.
/// </summary>
OldestSearchWeighted,
/// <summary>
/// Deterministic selection of the most recently added items first.
/// Always picks the newest content in your library.
/// </summary>
NewestFirst,
/// <summary>
/// Weighted random selection based on when items were added.
/// Recently added items are ranked higher and more likely to be selected,
/// while older items still have a chance proportional to their rank.
/// </summary>
NewestWeighted,
/// <summary>
/// Pure random selection with no weighting or bias.
/// Every eligible item has an equal chance of being selected.
/// </summary>
Random,
}
@@ -1,8 +1,8 @@
namespace Cleanuparr.Domain.Enums;
namespace Cleanuparr.Domain.Enums;
public enum SeriesSearchType
{
Episode,
Season,
Series
}
}
@@ -9,4 +9,8 @@ public sealed class ValidationException : Exception
public ValidationException(string message) : base(message)
{
}
public ValidationException(string message, Exception inner) : base(message, inner)
{
}
}
@@ -5,12 +5,13 @@ 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 Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Logging;
using Moq;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Events;
@@ -18,11 +19,10 @@ namespace Cleanuparr.Infrastructure.Tests.Events;
public class EventPublisherTests : IDisposable
{
private readonly EventsContext _context;
private readonly Mock<IHubContext<AppHub>> _hubContextMock;
private readonly Mock<ILogger<EventPublisher>> _loggerMock;
private readonly Mock<INotificationPublisher> _notificationPublisherMock;
private readonly Mock<IDryRunInterceptor> _dryRunInterceptorMock;
private readonly Mock<IClientProxy> _clientProxyMock;
private readonly IHubContext<AppHub> _hubContext;
private readonly INotificationPublisher _notificationPublisher;
private readonly IDryRunInterceptor _dryRunInterceptor;
private readonly IClientProxy _clientProxy;
private readonly EventPublisher _publisher;
public EventPublisherTests()
@@ -30,30 +30,30 @@ public class EventPublisherTests : IDisposable
// Setup in-memory database
var options = new DbContextOptionsBuilder<EventsContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning))
.Options;
_context = new EventsContext(options);
// Setup mocks
_hubContextMock = new Mock<IHubContext<AppHub>>();
_loggerMock = new Mock<ILogger<EventPublisher>>();
_notificationPublisherMock = new Mock<INotificationPublisher>();
_dryRunInterceptorMock = new Mock<IDryRunInterceptor>();
_clientProxyMock = new Mock<IClientProxy>();
_hubContext = Substitute.For<IHubContext<AppHub>>();
_notificationPublisher = Substitute.For<INotificationPublisher>();
_dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
_clientProxy = Substitute.For<IClientProxy>();
// Setup HubContext to return client proxy
var clientsMock = new Mock<IHubClients>();
clientsMock.Setup(c => c.All).Returns(_clientProxyMock.Object);
_hubContextMock.Setup(h => h.Clients).Returns(clientsMock.Object);
var clients = Substitute.For<IHubClients>();
clients.All.Returns(_clientProxy);
_hubContext.Clients.Returns(clients);
// Setup dry run interceptor to report dry run as disabled by default
_dryRunInterceptorMock.Setup(d => d.IsDryRunEnabled()).ReturnsAsync(false);
_dryRunInterceptor.IsDryRunEnabled().Returns(false);
_publisher = new EventPublisher(
_context,
_hubContextMock.Object,
_loggerMock.Object,
_notificationPublisherMock.Object,
_dryRunInterceptorMock.Object);
_hubContext,
Substitute.For<ILogger<EventPublisher>>(),
_notificationPublisher,
_dryRunInterceptor);
// Setup JobRunId in context for tests
ContextProvider.SetJobRunId(Guid.NewGuid());
@@ -136,10 +136,10 @@ public class EventPublisherTests : IDisposable
await _publisher.PublishAsync(eventType, message, severity);
// Assert
_clientProxyMock.Verify(c => c.SendCoreAsync(
await _clientProxy.Received(1).SendCoreAsync(
"EventReceived",
It.Is<object[]>(args => args.Length == 1 && args[0] is AppEvent),
It.IsAny<CancellationToken>()), Times.Once);
Arg.Is<object[]>(args => args.Length == 1 && args[0] is AppEvent),
Arg.Any<CancellationToken>());
}
[Fact]
@@ -150,10 +150,10 @@ public class EventPublisherTests : IDisposable
var message = "Test message";
var severity = EventSeverity.Important;
_clientProxyMock.Setup(c => c.SendCoreAsync(
It.IsAny<string>(),
It.IsAny<object[]>(),
It.IsAny<CancellationToken>()))
_clientProxy.SendCoreAsync(
Arg.Any<string>(),
Arg.Any<object[]>(),
Arg.Any<CancellationToken>())
.ThrowsAsync(new Exception("SignalR connection failed"));
// Act - should not throw
@@ -232,10 +232,10 @@ public class EventPublisherTests : IDisposable
await _publisher.PublishManualAsync(message, severity);
// Assert
_clientProxyMock.Verify(c => c.SendCoreAsync(
await _clientProxy.Received(1).SendCoreAsync(
"ManualEventReceived",
It.Is<object[]>(args => args.Length == 1 && args[0] is ManualEvent),
It.IsAny<CancellationToken>()), Times.Once);
Arg.Is<object[]>(args => args.Length == 1 && args[0] is ManualEvent),
Arg.Any<CancellationToken>());
}
#endregion
@@ -254,7 +254,7 @@ public class EventPublisherTests : IDisposable
await _publisher.PublishAsync(eventType, message, severity);
// Assert
_dryRunInterceptorMock.Verify(d => d.IsDryRunEnabled(), Times.Once);
await _dryRunInterceptor.Received(1).IsDryRunEnabled();
}
[Fact]
@@ -268,14 +268,14 @@ public class EventPublisherTests : IDisposable
await _publisher.PublishManualAsync(message, severity);
// Assert
_dryRunInterceptorMock.Verify(d => d.IsDryRunEnabled(), Times.Once);
await _dryRunInterceptor.Received(1).IsDryRunEnabled();
}
[Fact]
public async Task PublishAsync_WhenDryRunEnabled_SetsIsDryRunTrue()
{
// Arrange
_dryRunInterceptorMock.Setup(d => d.IsDryRunEnabled()).ReturnsAsync(true);
_dryRunInterceptor.IsDryRunEnabled().Returns(true);
var eventType = EventType.StalledStrike;
var message = "Dry run event";
var severity = EventSeverity.Warning;
@@ -310,7 +310,7 @@ public class EventPublisherTests : IDisposable
public async Task PublishManualAsync_WhenDryRunEnabled_SetsIsDryRunTrue()
{
// Arrange
_dryRunInterceptorMock.Setup(d => d.IsDryRunEnabled()).ReturnsAsync(true);
_dryRunInterceptor.IsDryRunEnabled().Returns(true);
var message = "Dry run manual event";
var severity = EventSeverity.Important;
@@ -327,7 +327,7 @@ public class EventPublisherTests : IDisposable
public async Task PublishAsync_WhenDryRunEnabled_StillSavesToDatabase()
{
// Arrange
_dryRunInterceptorMock.Setup(d => d.IsDryRunEnabled()).ReturnsAsync(true);
_dryRunInterceptor.IsDryRunEnabled().Returns(true);
var eventType = EventType.StalledStrike;
var message = "Should be saved";
var severity = EventSeverity.Warning;
@@ -425,7 +425,7 @@ public class EventPublisherTests : IDisposable
await _publisher.PublishQueueItemDeleted(removeFromClient: false, DeleteReason.FailedImport);
// Assert
_notificationPublisherMock.Verify(n => n.NotifyQueueItemDeleted(false, DeleteReason.FailedImport), Times.Once);
await _notificationPublisher.Received(1).NotifyQueueItemDeleted(false, DeleteReason.FailedImport);
}
#endregion
@@ -474,7 +474,7 @@ public class EventPublisherTests : IDisposable
await _publisher.PublishDownloadCleaned(ratio, seedingTime, categoryName, reason);
// Assert
_notificationPublisherMock.Verify(n => n.NotifyDownloadCleaned(ratio, seedingTime, categoryName, reason), Times.Once);
await _notificationPublisher.Received(1).NotifyDownloadCleaned(ratio, seedingTime, categoryName, reason);
}
#endregion
@@ -574,7 +574,213 @@ public class EventPublisherTests : IDisposable
await _publisher.PublishCategoryChanged("old", "new", isTag: true);
// Assert
_notificationPublisherMock.Verify(n => n.NotifyCategoryChanged("old", "new", true), Times.Once);
await _notificationPublisher.Received(1).NotifyCategoryChanged("old", "new", true);
}
#endregion
#region PublishSearchTriggered Tests
[Fact]
public async Task PublishSearchTriggered_SavesEventWithCorrectType()
{
// Act
await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing);
// Assert
var savedEvent = await _context.Events.FirstOrDefaultAsync();
Assert.NotNull(savedEvent);
Assert.Equal(EventType.SearchTriggered, savedEvent.EventType);
Assert.Equal(EventSeverity.Information, savedEvent.Severity);
}
[Fact]
public async Task PublishSearchTriggered_SetsSearchStatusToPending()
{
// Act
await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing);
// Assert
var savedEvent = await _context.Events.FirstOrDefaultAsync();
Assert.NotNull(savedEvent);
Assert.Equal(SearchCommandStatus.Pending, savedEvent.SearchStatus);
}
[Fact]
public async Task PublishSearchTriggered_SetsCycleId()
{
// Arrange
var cycleId = Guid.NewGuid();
// Act
await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing, cycleId);
// Assert
var savedEvent = await _context.Events.FirstOrDefaultAsync();
Assert.NotNull(savedEvent);
Assert.Equal(cycleId, savedEvent.CycleId);
}
[Fact]
public async Task PublishSearchTriggered_ReturnsEventId()
{
// Act
Guid eventId = await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing);
// Assert
Assert.NotEqual(Guid.Empty, eventId);
var savedEvent = await _context.Events.FindAsync(eventId);
Assert.NotNull(savedEvent);
}
[Fact]
public async Task PublishSearchTriggered_CreatesSearchEventData()
{
// Act
await _publisher.PublishSearchTriggered("Series A", SeekerSearchType.Replacement, SeekerSearchReason.Replacement);
// Assert
var savedEvent = await _context.Events.FirstOrDefaultAsync();
Assert.NotNull(savedEvent);
var searchData = await _context.SearchEventData.FirstOrDefaultAsync(s => s.AppEventId == savedEvent.Id);
Assert.NotNull(searchData);
Assert.Equal("Series A", searchData.ItemTitle);
Assert.Equal(SeekerSearchType.Replacement, searchData.SearchType);
Assert.Equal(SeekerSearchReason.Replacement, searchData.SearchReason);
}
[Fact]
public async Task PublishSearchTriggered_NotifiesSignalRClients()
{
// Act
await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing);
// Assert
await _clientProxy.Received(1).SendCoreAsync(
"EventReceived",
Arg.Is<object[]>(args => args.Length == 1 && args[0] is AppEvent),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task PublishSearchTriggered_SendsNotification()
{
// Act
await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing);
// Assert
await _notificationPublisher.Received(1).NotifySearchTriggered(
"Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing);
}
[Fact]
public async Task PublishSearchTriggered_IncludesItemTitleInMessage()
{
// Act
await _publisher.PublishSearchTriggered("The Matrix", SeekerSearchType.Proactive, SeekerSearchReason.Missing);
// Assert
var savedEvent = await _context.Events.FirstOrDefaultAsync();
Assert.NotNull(savedEvent);
Assert.Contains("The Matrix", savedEvent.Message);
}
#endregion
#region PublishSearchCompleted Tests
[Fact]
public async Task PublishSearchCompleted_UpdatesEventStatus()
{
// Arrange — create a search event first
Guid eventId = await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing);
// Act
await _publisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed);
// Assert
var updatedEvent = await _context.Events.FindAsync(eventId);
Assert.NotNull(updatedEvent);
Assert.Equal(SearchCommandStatus.Completed, updatedEvent.SearchStatus);
}
[Fact]
public async Task PublishSearchCompleted_SetsCompletedAt()
{
// Arrange
Guid eventId = await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing);
// Act
await _publisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed);
// Assert
var updatedEvent = await _context.Events.FindAsync(eventId);
Assert.NotNull(updatedEvent);
Assert.NotNull(updatedEvent.CompletedAt);
}
[Fact]
public async Task PublishSearchCompleted_UpdatesGrabbedItemsOnSearchEventData()
{
// Arrange
Guid eventId = await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing);
var grabbedItems = new List<string> { "Movie A (2024)" };
// Act
await _publisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed, grabbedItems);
// Assert
var searchData = await _context.SearchEventData.FirstOrDefaultAsync(s => s.AppEventId == eventId);
Assert.NotNull(searchData);
Assert.Contains("Movie A (2024)", searchData.GrabbedItems);
}
[Fact]
public async Task PublishSearchCompleted_WithNullGrabbedItems_DoesNotModifySearchEventData()
{
// Arrange
Guid eventId = await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing);
// Act
await _publisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed);
// Assert
var searchData = await _context.SearchEventData.FirstOrDefaultAsync(s => s.AppEventId == eventId);
Assert.NotNull(searchData);
Assert.Empty(searchData.GrabbedItems);
}
[Fact]
public async Task PublishSearchCompleted_EventNotFound_LogsWarningAndReturns()
{
// Act — use a non-existent event ID
await _publisher.PublishSearchCompleted(Guid.NewGuid(), SearchCommandStatus.Completed);
// Assert — should not throw, and the log warning is the important behavior
// (no exception thrown is the assertion)
var eventCount = await _context.Events.CountAsync();
Assert.Equal(0, eventCount);
}
[Fact]
public async Task PublishSearchCompleted_NotifiesSignalRClients()
{
// Arrange
Guid eventId = await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing);
// Reset mock to only capture the completion call
_clientProxy.ClearReceivedCalls();
// Act
await _publisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed);
// Assert
await _clientProxy.Received(1).SendCoreAsync(
"EventReceived",
Arg.Is<object[]>(args => args.Length == 1 && args[0] is AppEvent),
Arg.Any<CancellationToken>());
}
#endregion
@@ -1,7 +1,6 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.Deluge.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Moq;
@@ -134,10 +133,10 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
new DelugeItemWrapper(new DownloadStatus { Hash = "hash3", Label = "music", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
};
var categories = new List<SeedingRule>
var categories = new List<ISeedingRule>
{
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
new SeedingRule { Name = "tv", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
new DelugeSeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
new DelugeSeedingRule { Name = "tv", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
@@ -161,9 +160,9 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Label = "Movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
};
var categories = new List<SeedingRule>
var categories = new List<ISeedingRule>
{
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
new DelugeSeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
@@ -185,9 +184,9 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Label = "music", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
};
var categories = new List<SeedingRule>
var categories = new List<ISeedingRule>
{
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
new DelugeSeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
@@ -218,7 +217,7 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
};
// Act
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new UnlinkedConfig { Categories = ["movies"] });
// Assert
Assert.NotNull(result);
@@ -238,7 +237,7 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
};
// Act
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new UnlinkedConfig { Categories = ["movies"] });
// Assert
Assert.NotNull(result);
@@ -258,13 +257,32 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
};
// Act
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new UnlinkedConfig { Categories = ["movies"] });
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.Equal("hash1", result[0].Hash);
}
[Fact]
public void ReturnsEmpty_WhenNoCategoriesMatch()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Label = "tv", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
};
// Act
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new UnlinkedConfig { Categories = ["movies"] });
// Assert
Assert.NotNull(result);
Assert.Empty(result);
}
}
public class CreateCategoryAsync_Tests : DelugeServiceDCTests
@@ -414,15 +432,14 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(null);
await sut.ChangeCategoryForNoHardLinksAsync(null, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
@@ -434,15 +451,14 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(new List<Domain.Entities.ITorrentItemWrapper>());
await sut.ChangeCategoryForNoHardLinksAsync(new List<Domain.Entities.ITorrentItemWrapper>(), unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
@@ -454,12 +470,11 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
@@ -467,7 +482,7 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
};
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
@@ -479,12 +494,11 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
@@ -492,7 +506,7 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
};
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
@@ -504,12 +518,11 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
@@ -517,7 +530,7 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
};
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
@@ -529,12 +542,11 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
@@ -546,7 +558,7 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
.ThrowsAsync(new InvalidOperationException("Failed to get files"));
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
@@ -558,12 +570,11 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
@@ -585,7 +596,7 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(
@@ -599,12 +610,11 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
@@ -626,7 +636,7 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
.Returns(2);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
@@ -638,12 +648,11 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
@@ -665,7 +674,7 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
.Returns(-1);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
@@ -677,12 +686,11 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
@@ -705,7 +713,7 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.HardLinkFileService.Verify(
@@ -719,12 +727,11 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
@@ -746,7 +753,7 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert - EventPublisher is not mocked, so we just verify the method completed
_fixture.ClientWrapper.Verify(
@@ -1,6 +1,5 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Moq;
@@ -215,10 +214,10 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
new QBitItemWrapper(new TorrentInfo { Hash = "hash3", Category = "music" }, Array.Empty<TorrentTracker>(), false)
};
var categories = new List<SeedingRule>
var categories = new List<ISeedingRule>
{
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
new SeedingRule { Name = "tv", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
new QBitSeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
new QBitSeedingRule { Name = "tv", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
@@ -241,9 +240,9 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
new QBitItemWrapper(new TorrentInfo { Hash = "hash1", Category = "Movies" }, Array.Empty<TorrentTracker>(), false)
};
var categories = new List<SeedingRule>
var categories = new List<ISeedingRule>
{
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
new QBitSeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
@@ -265,9 +264,9 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
new QBitItemWrapper(new TorrentInfo { Hash = "hash1", Category = "movies" }, Array.Empty<TorrentTracker>(), false)
};
var categories = new List<SeedingRule>
var categories = new List<ISeedingRule>
{
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
new QBitSeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
@@ -289,9 +288,9 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
new QBitItemWrapper(new TorrentInfo { Hash = "hash1", Category = "music" }, Array.Empty<TorrentTracker>(), false)
};
var categories = new List<SeedingRule>
var categories = new List<ISeedingRule>
{
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
new QBitSeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
@@ -319,7 +318,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
SeedingTime = TimeSpan.FromHours(10)
}, Array.Empty<TorrentTracker>(), isPrivate);
private static SeedingRule CreateRule(string name, TorrentPrivacyType privacyType) =>
private static QBitSeedingRule CreateRule(string name, TorrentPrivacyType privacyType) =>
new()
{
Name = name,
@@ -348,7 +347,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
{
CreateTorrent("hash1", "movies", isPrivate: true)
};
var rules = new List<SeedingRule> { CreateRule("movies", TorrentPrivacyType.Public) };
var rules = new List<ISeedingRule> { CreateRule("movies", TorrentPrivacyType.Public) };
// Act
await sut.CleanDownloadsAsync(downloads, rules);
@@ -370,7 +369,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
{
CreateTorrent("hash1", "movies", isPrivate: false)
};
var rules = new List<SeedingRule> { CreateRule("movies", TorrentPrivacyType.Public) };
var rules = new List<ISeedingRule> { CreateRule("movies", TorrentPrivacyType.Public) };
// Act
await sut.CleanDownloadsAsync(downloads, rules);
@@ -392,7 +391,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
{
CreateTorrent("hash1", "movies", isPrivate: false)
};
var rules = new List<SeedingRule> { CreateRule("movies", TorrentPrivacyType.Private) };
var rules = new List<ISeedingRule> { CreateRule("movies", TorrentPrivacyType.Private) };
// Act
await sut.CleanDownloadsAsync(downloads, rules);
@@ -414,7 +413,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
{
CreateTorrent("hash1", "movies", isPrivate: true)
};
var rules = new List<SeedingRule> { CreateRule("movies", TorrentPrivacyType.Private) };
var rules = new List<ISeedingRule> { CreateRule("movies", TorrentPrivacyType.Private) };
// Act
await sut.CleanDownloadsAsync(downloads, rules);
@@ -436,7 +435,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
{
CreateTorrent("hash1", "movies", isPrivate: false)
};
var rules = new List<SeedingRule> { CreateRule("movies", TorrentPrivacyType.Both) };
var rules = new List<ISeedingRule> { CreateRule("movies", TorrentPrivacyType.Both) };
// Act
await sut.CleanDownloadsAsync(downloads, rules);
@@ -458,7 +457,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
{
CreateTorrent("hash1", "movies", isPrivate: true)
};
var rules = new List<SeedingRule> { CreateRule("movies", TorrentPrivacyType.Both) };
var rules = new List<ISeedingRule> { CreateRule("movies", TorrentPrivacyType.Both) };
// Act
await sut.CleanDownloadsAsync(downloads, rules);
@@ -481,7 +480,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
CreateTorrent("public-hash", "movies", isPrivate: false),
CreateTorrent("private-hash", "movies", isPrivate: true)
};
var rules = new List<SeedingRule>
var rules = new List<ISeedingRule>
{
CreateRule("movies", TorrentPrivacyType.Public),
CreateRule("movies", TorrentPrivacyType.Private)
@@ -512,13 +511,13 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedUseTag = true,
UnlinkedTargetCategory = "unlinked"
UseTag = true,
TargetCategory = "unlinked",
Categories = ["movies"]
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var torrentInfo1 = new TorrentInfo { Hash = "hash1", Category = "movies", Tags = new[] { "unlinked" } };
var torrentInfo2 = new TorrentInfo { Hash = "hash2", Category = "movies", Tags = Array.Empty<string>() };
@@ -530,7 +529,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
};
// Act
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, unlinkedConfig);
// Assert
Assert.NotNull(result);
@@ -544,13 +543,13 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedUseTag = false,
UnlinkedTargetCategory = "unlinked"
UseTag = false,
TargetCategory = "unlinked",
Categories = ["movies"]
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
@@ -559,7 +558,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
};
// Act
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, unlinkedConfig);
// Assert
Assert.NotNull(result);
@@ -572,12 +571,12 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedUseTag = false
UseTag = false,
Categories = ["movies"]
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
@@ -585,7 +584,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
};
// Act
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, unlinkedConfig);
// Assert
Assert.NotNull(result);
@@ -598,12 +597,12 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedUseTag = false
UseTag = false,
Categories = ["movies"]
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
@@ -612,13 +611,32 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
};
// Act
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, unlinkedConfig);
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.Equal("hash1", result[0].Hash);
}
[Fact]
public void ReturnsEmpty_WhenNoCategoriesMatch()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new QBitItemWrapper(new TorrentInfo { Hash = "hash1", Category = "tv" }, Array.Empty<TorrentTracker>(), false)
};
// Act
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new UnlinkedConfig { Categories = ["movies"] });
// Assert
Assert.NotNull(result);
Assert.Empty(result);
}
}
public class CreateCategoryAsync_Tests : QBitServiceDCTests
@@ -752,16 +770,15 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedUseTag = false,
UnlinkedTargetCategory = "unlinked"
UseTag = false,
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(null);
await sut.ChangeCategoryForNoHardLinksAsync(null, unlinkedConfig);
// Assert - no exceptions thrown
_fixture.ClientWrapper.Verify(x => x.SetTorrentCategoryAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<string>()), Times.Never);
@@ -773,16 +790,15 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedUseTag = false,
UnlinkedTargetCategory = "unlinked"
UseTag = false,
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(new List<Domain.Entities.ITorrentItemWrapper>());
await sut.ChangeCategoryForNoHardLinksAsync(new List<Domain.Entities.ITorrentItemWrapper>(), unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentCategoryAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<string>()), Times.Never);
@@ -794,13 +810,12 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedUseTag = false,
UnlinkedTargetCategory = "unlinked"
UseTag = false,
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
@@ -808,7 +823,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
};
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentCategoryAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<string>()), Times.Never);
@@ -820,13 +835,12 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedUseTag = false,
UnlinkedTargetCategory = "unlinked"
UseTag = false,
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
@@ -834,7 +848,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
};
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentCategoryAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<string>()), Times.Never);
@@ -846,13 +860,12 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedUseTag = false,
UnlinkedTargetCategory = "unlinked"
UseTag = false,
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
@@ -860,7 +873,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
};
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentCategoryAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<string>()), Times.Never);
@@ -872,13 +885,12 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedUseTag = false,
UnlinkedTargetCategory = "unlinked"
UseTag = false,
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
@@ -890,7 +902,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
.ReturnsAsync((IReadOnlyList<TorrentContent>?)null);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentCategoryAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<string>()), Times.Never);
@@ -902,13 +914,12 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedUseTag = false,
UnlinkedTargetCategory = "unlinked"
UseTag = false,
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
@@ -927,7 +938,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(
@@ -941,13 +952,12 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedUseTag = true,
UnlinkedTargetCategory = "unlinked"
UseTag = true,
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
@@ -966,7 +976,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(
@@ -983,13 +993,12 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedUseTag = false,
UnlinkedTargetCategory = "unlinked"
UseTag = false,
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
@@ -1008,7 +1017,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
.Returns(2); // Has hardlinks
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentCategoryAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<string>()), Times.Never);
@@ -1020,13 +1029,12 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedUseTag = false,
UnlinkedTargetCategory = "unlinked"
UseTag = false,
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
@@ -1045,7 +1053,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
.Returns(-1); // Error
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentCategoryAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<string>()), Times.Never);
@@ -1057,13 +1065,12 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedUseTag = false,
UnlinkedTargetCategory = "unlinked"
UseTag = false,
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
@@ -1083,7 +1090,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.HardLinkFileService.Verify(
@@ -1097,13 +1104,12 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedUseTag = false,
UnlinkedTargetCategory = "unlinked"
UseTag = false,
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
@@ -1118,7 +1124,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
});
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentCategoryAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<string>()), Times.Never);
@@ -1130,13 +1136,12 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedUseTag = false,
UnlinkedTargetCategory = "unlinked"
UseTag = false,
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
@@ -1155,7 +1160,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert - EventPublisher is not mocked, so we just verify the method completed
_fixture.ClientWrapper.Verify(
@@ -1169,13 +1174,12 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedUseTag = true,
UnlinkedTargetCategory = "unlinked"
UseTag = true,
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
@@ -1194,7 +1198,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert - EventPublisher is not mocked, so we just verify the method completed
_fixture.ClientWrapper.Verify(
@@ -1,7 +1,5 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.RTorrent.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Moq;
@@ -112,10 +110,10 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH3", Name = "Torrent 3", Label = "music" })
};
var categories = new List<SeedingRule>
var categories = new List<ISeedingRule>
{
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
new SeedingRule { Name = "tv", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
new RTorrentSeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
new RTorrentSeedingRule { Name = "tv", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
@@ -139,9 +137,9 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Torrent 1", Label = "Movies" })
};
var categories = new List<SeedingRule>
var categories = new List<ISeedingRule>
{
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
new RTorrentSeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
@@ -163,9 +161,9 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Torrent 1", Label = "music" })
};
var categories = new List<SeedingRule>
var categories = new List<ISeedingRule>
{
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
new RTorrentSeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
@@ -182,9 +180,9 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var categories = new List<SeedingRule>
var categories = new List<ISeedingRule>
{
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
new RTorrentSeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
@@ -214,10 +212,10 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH3", Name = "Torrent 3", Label = "music" })
};
var categories = new List<string> { "movies", "tv" };
var unlinkedConfig = new UnlinkedConfig { Categories = ["movies", "tv"] };
// Act
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, categories);
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, unlinkedConfig);
// Assert
Assert.NotNull(result);
@@ -236,16 +234,37 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Valid Hash", Label = "movies" })
};
var categories = new List<string> { "movies" };
var unlinkedConfig = new UnlinkedConfig { Categories = ["movies"] };
// Act
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, categories);
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, unlinkedConfig);
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.Equal("HASH1", result[0].Hash);
}
[Fact]
public void ReturnsEmpty_WhenNoCategoriesMatch()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<ITorrentItemWrapper>
{
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Torrent 1", Label = "tv" })
};
var unlinkedConfig = new UnlinkedConfig { Categories = ["movies"] };
// Act
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, unlinkedConfig);
// Assert
Assert.NotNull(result);
Assert.Empty(result);
}
}
public class DeleteDownload_Tests : RTorrentServiceDCTests
@@ -311,15 +330,14 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(null);
await sut.ChangeCategoryForNoHardLinksAsync(null, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(
@@ -333,15 +351,14 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(new List<ITorrentItemWrapper>());
await sut.ChangeCategoryForNoHardLinksAsync(new List<ITorrentItemWrapper>(), unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(
@@ -355,12 +372,11 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<ITorrentItemWrapper>
{
@@ -368,7 +384,7 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
};
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(
@@ -382,12 +398,11 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<ITorrentItemWrapper>
{
@@ -395,7 +410,7 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
};
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(
@@ -409,12 +424,11 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<ITorrentItemWrapper>
{
@@ -422,7 +436,7 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
};
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(
@@ -436,12 +450,11 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<ITorrentItemWrapper>
{
@@ -453,7 +466,7 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
.ThrowsAsync(new Exception("XML-RPC error"));
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(
@@ -467,12 +480,11 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<ITorrentItemWrapper>
{
@@ -492,7 +504,7 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert - only called for file2.mkv (the active file)
_fixture.HardLinkFileService.Verify(
@@ -506,12 +518,11 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<ITorrentItemWrapper>
{
@@ -530,7 +541,7 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert - rTorrent uses SetLabelAsync (not SetTorrentCategoryAsync)
_fixture.ClientWrapper.Verify(
@@ -544,12 +555,11 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<ITorrentItemWrapper>
{
@@ -568,7 +578,7 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
.Returns(2); // Has hardlinks
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(
@@ -582,12 +592,11 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<ITorrentItemWrapper>
{
@@ -606,7 +615,7 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
.Returns(-1); // Error / file not found
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(
@@ -620,12 +629,11 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<ITorrentItemWrapper>
{
@@ -644,7 +652,7 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.EventPublisher.Verify(
@@ -652,18 +660,106 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
Times.Once);
}
[Fact]
public async Task UsesDirectoryOverBasePathForFilePath()
{
// Arrange
var sut = _fixture.CreateSut();
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
TargetCategory = "unlinked"
};
// Single-file torrent: BasePath is the full file path, Directory is the containing dir
var downloads = new List<ITorrentItemWrapper>
{
new RTorrentItemWrapper(new RTorrentTorrent
{
Hash = "HASH1", Name = "movie.mkv", Label = "movies",
BasePath = "/downloads/movie.mkv",
Directory = "/downloads"
})
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync("HASH1"))
.ReturnsAsync(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "movie.mkv", Priority = 1 }
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert - path should use Directory (/downloads), not BasePath (/downloads/movie.mkv)
var expectedPath = string.Join(Path.DirectorySeparatorChar,
Path.Combine("/downloads", "movie.mkv").Split('\\', '/'));
_fixture.HardLinkFileService.Verify(
x => x.GetHardLinkCount(expectedPath, false),
Times.Once);
}
[Fact]
public async Task FallsBackToBasePathWhenDirectoryNull()
{
// Arrange
var sut = _fixture.CreateSut();
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
TargetCategory = "unlinked"
};
var downloads = new List<ITorrentItemWrapper>
{
new RTorrentItemWrapper(new RTorrentTorrent
{
Hash = "HASH1", Name = "Test", Label = "movies",
BasePath = "/downloads",
Directory = null
})
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync("HASH1"))
.ReturnsAsync(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file1.mkv", Priority = 1 }
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert - path should fall back to BasePath
var expectedPath = string.Join(Path.DirectorySeparatorChar,
Path.Combine("/downloads", "file1.mkv").Split('\\', '/'));
_fixture.HardLinkFileService.Verify(
x => x.GetHardLinkCount(expectedPath, false),
Times.Once);
}
[Fact]
public async Task UpdatesCategoryOnWrapper()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var wrapper = new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Test", Label = "movies", BasePath = "/downloads" });
var downloads = new List<ITorrentItemWrapper> { wrapper };
@@ -680,7 +776,7 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
Assert.Equal("unlinked", wrapper.Category);
@@ -1,5 +1,3 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Moq;
@@ -138,10 +136,10 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash3", DownloadDir = "/downloads/music" })
};
var categories = new List<SeedingRule>
var categories = new List<ISeedingRule>
{
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
new SeedingRule { Name = "tv", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
new TransmissionSeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
new TransmissionSeedingRule { Name = "tv", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
@@ -165,9 +163,9 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", DownloadDir = "/downloads/Movies" })
};
var categories = new List<SeedingRule>
var categories = new List<ISeedingRule>
{
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
new TransmissionSeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
@@ -189,9 +187,9 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", DownloadDir = "/downloads/music" })
};
var categories = new List<SeedingRule>
var categories = new List<ISeedingRule>
{
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
new TransmissionSeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
@@ -222,7 +220,7 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
};
// Act
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new UnlinkedConfig { Categories = ["movies"] });
// Assert
Assert.NotNull(result);
@@ -242,7 +240,7 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
};
// Act
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new UnlinkedConfig { Categories = ["movies"] });
// Assert
Assert.NotNull(result);
@@ -262,13 +260,32 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
};
// Act
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new UnlinkedConfig { Categories = ["movies"] });
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.Equal("hash1", result[0].Hash);
}
[Fact]
public void ReturnsEmpty_WhenNoCategoriesMatch()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", DownloadDir = "/downloads/tv" })
};
// Act
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new UnlinkedConfig { Categories = ["movies"] });
// Assert
Assert.NotNull(result);
Assert.Empty(result);
}
}
public class CreateCategoryAsync_Tests : TransmissionServiceDCTests
@@ -376,15 +393,14 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(null);
await sut.ChangeCategoryForNoHardLinksAsync(null, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
@@ -396,15 +412,14 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(new List<Domain.Entities.ITorrentItemWrapper>());
await sut.ChangeCategoryForNoHardLinksAsync(new List<Domain.Entities.ITorrentItemWrapper>(), unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
@@ -416,12 +431,11 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
@@ -429,7 +443,7 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
};
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
@@ -441,12 +455,11 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
@@ -454,7 +467,7 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
};
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
@@ -466,12 +479,11 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
@@ -479,7 +491,7 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
};
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
@@ -491,12 +503,11 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
@@ -504,7 +515,7 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
};
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
@@ -516,12 +527,11 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
@@ -536,7 +546,7 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
};
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
@@ -548,12 +558,11 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var baseDownloadDir = Path.Combine("downloads", "movies");
var expectedNewLocation = string.Join(Path.DirectorySeparatorChar,
@@ -577,7 +586,7 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(
@@ -591,12 +600,11 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
@@ -616,7 +624,7 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
.Returns(2);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
@@ -628,12 +636,11 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
@@ -653,7 +660,7 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
.Returns(-1);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
@@ -665,12 +672,11 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
@@ -698,7 +704,7 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.HardLinkFileService.Verify(
@@ -712,12 +718,11 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var baseDownloadDir = Path.Combine("downloads", "movies");
var expectedNewLocation = string.Join(Path.DirectorySeparatorChar,
@@ -741,7 +746,7 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert - EventPublisher is not mocked, so we just verify the method completed
_fixture.ClientWrapper.Verify(
@@ -755,12 +760,11 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var baseDownloadDir = Path.Combine("downloads", "movies", "subfolder");
var expectedNewLocation = string.Join(Path.DirectorySeparatorChar,
@@ -784,7 +788,7 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(
@@ -1,7 +1,5 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.UTorrent.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Moq;
@@ -127,10 +125,10 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
new UTorrentItemWrapper(new UTorrentItem { Hash = "hash3", Label = "music" }, new UTorrentProperties { Hash = "hash3", Pex = 1, Trackers = "" })
};
var categories = new List<SeedingRule>
var categories = new List<ISeedingRule>
{
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
new SeedingRule { Name = "tv", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
new UTorrentSeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
new UTorrentSeedingRule { Name = "tv", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
@@ -154,9 +152,9 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
new UTorrentItemWrapper(new UTorrentItem { Hash = "hash1", Label = "Movies" }, new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
};
var categories = new List<SeedingRule>
var categories = new List<ISeedingRule>
{
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
new UTorrentSeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
@@ -178,9 +176,9 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
new UTorrentItemWrapper(new UTorrentItem { Hash = "hash1", Label = "music" }, new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
};
var categories = new List<SeedingRule>
var categories = new List<ISeedingRule>
{
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
new UTorrentSeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
@@ -211,7 +209,7 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
};
// Act
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new UnlinkedConfig { Categories = ["movies"] });
// Assert
Assert.NotNull(result);
@@ -231,7 +229,7 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
};
// Act
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new UnlinkedConfig { Categories = ["movies"] });
// Assert
Assert.NotNull(result);
@@ -251,13 +249,32 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
};
// Act
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new UnlinkedConfig { Categories = ["movies"] });
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.Equal("hash1", result[0].Hash);
}
[Fact]
public void ReturnsEmpty_WhenNoCategoriesMatch()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new UTorrentItemWrapper(new UTorrentItem { Hash = "hash1", Label = "tv" }, new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
};
// Act
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new UnlinkedConfig { Categories = ["movies"] });
// Assert
Assert.NotNull(result);
Assert.Empty(result);
}
}
public class CreateCategoryAsync_Tests : UTorrentServiceDCTests
@@ -364,15 +381,14 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(null);
await sut.ChangeCategoryForNoHardLinksAsync(null, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
@@ -384,15 +400,14 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(new List<Domain.Entities.ITorrentItemWrapper>());
await sut.ChangeCategoryForNoHardLinksAsync(new List<Domain.Entities.ITorrentItemWrapper>(), unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
@@ -404,12 +419,11 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
@@ -419,7 +433,7 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
};
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
@@ -431,12 +445,11 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
@@ -446,7 +459,7 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
};
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
@@ -458,12 +471,11 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
@@ -473,7 +485,7 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
};
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
@@ -485,12 +497,11 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
@@ -511,7 +522,7 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(
@@ -525,12 +536,11 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
@@ -551,7 +561,7 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
.Returns(2);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
@@ -563,12 +573,11 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
@@ -589,7 +598,7 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
.Returns(-1);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
@@ -601,12 +610,11 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
@@ -628,7 +636,7 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.HardLinkFileService.Verify(
@@ -642,12 +650,11 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
@@ -668,7 +675,7 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert - EventPublisher is not mocked, so we just verify the method completed
_fixture.ClientWrapper.Verify(
@@ -682,12 +689,11 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
TargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
@@ -701,7 +707,7 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
.ReturnsAsync((List<UTorrentFile>?)null);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert - When files is null, it uses empty collection and proceeds to change label
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync("hash1", "unlinked"), Times.Once);
@@ -1,166 +0,0 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.DownloadHunter.Consumers;
using Cleanuparr.Infrastructure.Features.DownloadHunter.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadHunter.Models;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using MassTransit;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadHunter.Consumers;
public class DownloadHunterConsumerTests
{
private readonly Mock<ILogger<DownloadHunterConsumer<SearchItem>>> _loggerMock;
private readonly Mock<IDownloadHunter> _downloadHunterMock;
private readonly DownloadHunterConsumer<SearchItem> _consumer;
public DownloadHunterConsumerTests()
{
_loggerMock = new Mock<ILogger<DownloadHunterConsumer<SearchItem>>>();
_downloadHunterMock = new Mock<IDownloadHunter>();
_consumer = new DownloadHunterConsumer<SearchItem>(_loggerMock.Object, _downloadHunterMock.Object);
}
#region Consume Tests
[Fact]
public async Task Consume_CallsHuntDownloadsAsync()
{
// Arrange
var request = CreateHuntRequest();
var contextMock = CreateConsumeContextMock(request);
_downloadHunterMock
.Setup(h => h.HuntDownloadsAsync(It.IsAny<DownloadHuntRequest<SearchItem>>()))
.Returns(Task.CompletedTask);
// Act
await _consumer.Consume(contextMock.Object);
// Assert
_downloadHunterMock.Verify(h => h.HuntDownloadsAsync(request), Times.Once);
}
[Fact]
public async Task Consume_WhenHunterThrows_LogsErrorAndDoesNotRethrow()
{
// Arrange
var request = CreateHuntRequest();
var contextMock = CreateConsumeContextMock(request);
_downloadHunterMock
.Setup(h => h.HuntDownloadsAsync(It.IsAny<DownloadHuntRequest<SearchItem>>()))
.ThrowsAsync(new Exception("Hunt failed"));
// Act - Should not throw
await _consumer.Consume(contextMock.Object);
// Assert
_loggerMock.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("failed to search for replacement")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
public async Task Consume_PassesCorrectRequestToHunter()
{
// Arrange
var request = CreateHuntRequest();
var contextMock = CreateConsumeContextMock(request);
DownloadHuntRequest<SearchItem>? capturedRequest = null;
_downloadHunterMock
.Setup(h => h.HuntDownloadsAsync(It.IsAny<DownloadHuntRequest<SearchItem>>()))
.Callback<DownloadHuntRequest<SearchItem>>(r => capturedRequest = r)
.Returns(Task.CompletedTask);
// Act
await _consumer.Consume(contextMock.Object);
// Assert
Assert.NotNull(capturedRequest);
Assert.Equal(request.InstanceType, capturedRequest.InstanceType);
Assert.Equal(request.SearchItem.Id, capturedRequest.SearchItem.Id);
}
[Fact]
public async Task Consume_WithDifferentInstanceTypes_HandlesCorrectly()
{
// Arrange
var request = new DownloadHuntRequest<SearchItem>
{
InstanceType = InstanceType.Lidarr,
Instance = CreateArrInstance(),
SearchItem = new SearchItem { Id = 999 },
Record = CreateQueueRecord(),
JobRunId = Guid.NewGuid()
};
var contextMock = CreateConsumeContextMock(request);
_downloadHunterMock
.Setup(h => h.HuntDownloadsAsync(It.IsAny<DownloadHuntRequest<SearchItem>>()))
.Returns(Task.CompletedTask);
// Act
await _consumer.Consume(contextMock.Object);
// Assert
_downloadHunterMock.Verify(h => h.HuntDownloadsAsync(
It.Is<DownloadHuntRequest<SearchItem>>(r => r.InstanceType == InstanceType.Lidarr)), Times.Once);
}
#endregion
#region Helper Methods
private static DownloadHuntRequest<SearchItem> CreateHuntRequest()
{
return new DownloadHuntRequest<SearchItem>
{
InstanceType = InstanceType.Radarr,
Instance = CreateArrInstance(),
SearchItem = new SearchItem { Id = 123 },
Record = CreateQueueRecord(),
JobRunId = Guid.NewGuid()
};
}
private static ArrInstance CreateArrInstance()
{
return new ArrInstance
{
Name = "Test Instance",
Url = new Uri("http://radarr.local"),
ApiKey = "test-api-key"
};
}
private static QueueRecord CreateQueueRecord()
{
return new QueueRecord
{
Id = 1,
Title = "Test Record",
Protocol = "torrent",
DownloadId = "ABC123"
};
}
private static Mock<ConsumeContext<DownloadHuntRequest<SearchItem>>> CreateConsumeContextMock(DownloadHuntRequest<SearchItem> message)
{
var mock = new Mock<ConsumeContext<DownloadHuntRequest<SearchItem>>>();
mock.Setup(c => c.Message).Returns(message);
return mock;
}
#endregion
}
@@ -1,313 +0,0 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadHunter.Models;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.General;
using Cleanuparr.Shared.Helpers;
using Data.Models.Arr;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Time.Testing;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadHunter;
public class DownloadHunterTests : IDisposable
{
private readonly DataContext _dataContext;
private readonly Mock<IArrClientFactory> _arrClientFactoryMock;
private readonly Mock<IArrClient> _arrClientMock;
private readonly FakeTimeProvider _fakeTimeProvider;
private readonly Infrastructure.Features.DownloadHunter.DownloadHunter _downloadHunter;
private readonly SqliteConnection _connection;
public DownloadHunterTests()
{
// Use SQLite in-memory with shared connection to support complex types
_connection = new SqliteConnection("DataSource=:memory:");
_connection.Open();
var options = new DbContextOptionsBuilder<DataContext>()
.UseSqlite(_connection)
.Options;
_dataContext = new DataContext(options);
_dataContext.Database.EnsureCreated();
_arrClientFactoryMock = new Mock<IArrClientFactory>();
_arrClientMock = new Mock<IArrClient>();
_fakeTimeProvider = new FakeTimeProvider();
_arrClientFactoryMock
.Setup(f => f.GetClient(It.IsAny<InstanceType>(), It.IsAny<float>()))
.Returns(_arrClientMock.Object);
_downloadHunter = new Infrastructure.Features.DownloadHunter.DownloadHunter(
_dataContext,
_arrClientFactoryMock.Object,
_fakeTimeProvider
);
}
public void Dispose()
{
_dataContext.Dispose();
_connection.Dispose();
}
#region HuntDownloadsAsync - Search Disabled Tests
[Fact]
public async Task HuntDownloadsAsync_WhenSearchDisabled_DoesNotCallArrClient()
{
// Arrange
await SetupGeneralConfig(searchEnabled: false);
var request = CreateHuntRequest();
// Act
await _downloadHunter.HuntDownloadsAsync(request);
// Assert
_arrClientFactoryMock.Verify(f => f.GetClient(It.IsAny<InstanceType>(), It.IsAny<float>()), Times.Never);
_arrClientMock.Verify(c => c.SearchItemsAsync(It.IsAny<ArrInstance>(), It.IsAny<HashSet<SearchItem>>()), Times.Never);
}
[Fact]
public async Task HuntDownloadsAsync_WhenSearchDisabled_ReturnsImmediately()
{
// Arrange
await SetupGeneralConfig(searchEnabled: false);
var request = CreateHuntRequest();
// Act
var task = _downloadHunter.HuntDownloadsAsync(request);
// Assert - Should complete without needing to advance time
var completedTask = await Task.WhenAny(task, Task.Delay(100));
Assert.Same(task, completedTask);
}
#endregion
#region HuntDownloadsAsync - Search Enabled Tests
[Fact]
public async Task HuntDownloadsAsync_WhenSearchEnabled_CallsArrClientFactory()
{
// Arrange
await SetupGeneralConfig(searchEnabled: true, searchDelay: Constants.MinSearchDelaySeconds);
var request = CreateHuntRequest();
// Act - Start the task and advance time
var task = _downloadHunter.HuntDownloadsAsync(request);
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.MinSearchDelaySeconds));
await task;
// Assert
_arrClientFactoryMock.Verify(f => f.GetClient(request.InstanceType, It.IsAny<float>()), Times.Once);
}
[Fact]
public async Task HuntDownloadsAsync_WhenSearchEnabled_CallsSearchItemsAsync()
{
// Arrange
await SetupGeneralConfig(searchEnabled: true, searchDelay: Constants.MinSearchDelaySeconds);
var request = CreateHuntRequest();
// Act
var task = _downloadHunter.HuntDownloadsAsync(request);
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.MinSearchDelaySeconds));
await task;
// Assert
_arrClientMock.Verify(
c => c.SearchItemsAsync(
request.Instance,
It.Is<HashSet<SearchItem>>(s => s.Contains(request.SearchItem))),
Times.Once);
}
[Theory]
[InlineData(InstanceType.Sonarr)]
[InlineData(InstanceType.Radarr)]
[InlineData(InstanceType.Lidarr)]
[InlineData(InstanceType.Readarr)]
[InlineData(InstanceType.Whisparr)]
public async Task HuntDownloadsAsync_UsesCorrectInstanceType(InstanceType instanceType)
{
// Arrange
await SetupGeneralConfig(searchEnabled: true, searchDelay: Constants.MinSearchDelaySeconds);
var request = CreateHuntRequest(instanceType);
// Act
var task = _downloadHunter.HuntDownloadsAsync(request);
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.MinSearchDelaySeconds));
await task;
// Assert
_arrClientFactoryMock.Verify(f => f.GetClient(instanceType, It.IsAny<float>()), Times.Once);
}
#endregion
#region HuntDownloadsAsync - Delay Tests
[Fact]
public async Task HuntDownloadsAsync_WaitsForConfiguredDelay()
{
// Arrange
const ushort configuredDelay = 120;
await SetupGeneralConfig(searchEnabled: true, searchDelay: configuredDelay);
var request = CreateHuntRequest();
// Act
var task = _downloadHunter.HuntDownloadsAsync(request);
// Assert - Task should not complete before advancing time
Assert.False(task.IsCompleted);
// Advance partial time - should still not complete
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(configuredDelay - 1));
await Task.Delay(10); // Give the task a chance to complete if it would
Assert.False(task.IsCompleted);
// Advance remaining time - should now complete
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(1));
await task;
Assert.True(task.IsCompletedSuccessfully);
}
[Fact]
public async Task HuntDownloadsAsync_WhenDelayBelowMinimum_UsesDefaultDelay()
{
// Arrange - Set delay below minimum (simulating manual DB edit)
const ushort belowMinDelay = 10; // Below MinSearchDelaySeconds (60)
await SetupGeneralConfig(searchEnabled: true, searchDelay: belowMinDelay);
var request = CreateHuntRequest();
// Act
var task = _downloadHunter.HuntDownloadsAsync(request);
// Advance by the below-min value - should NOT complete because it should use default
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(belowMinDelay));
await Task.Delay(10);
Assert.False(task.IsCompleted);
// Advance to default delay - should now complete
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.DefaultSearchDelaySeconds - belowMinDelay));
await task;
Assert.True(task.IsCompletedSuccessfully);
}
[Fact]
public async Task HuntDownloadsAsync_WhenDelayIsZero_UsesDefaultDelay()
{
// Arrange
await SetupGeneralConfig(searchEnabled: true, searchDelay: 0);
var request = CreateHuntRequest();
// Act
var task = _downloadHunter.HuntDownloadsAsync(request);
// Assert - Should not complete immediately
Assert.False(task.IsCompleted);
// Advance to default delay
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.DefaultSearchDelaySeconds));
await task;
Assert.True(task.IsCompletedSuccessfully);
}
[Fact]
public async Task HuntDownloadsAsync_WhenDelayAtMinimum_UsesConfiguredDelay()
{
// Arrange - Set delay exactly at minimum
await SetupGeneralConfig(searchEnabled: true, searchDelay: Constants.MinSearchDelaySeconds);
var request = CreateHuntRequest();
// Act
var task = _downloadHunter.HuntDownloadsAsync(request);
// Advance by minimum - should complete
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.MinSearchDelaySeconds));
await task;
Assert.True(task.IsCompletedSuccessfully);
}
[Fact]
public async Task HuntDownloadsAsync_WhenDelayAboveMinimum_UsesConfiguredDelay()
{
// Arrange - Set delay above minimum
const ushort aboveMinDelay = 180;
await SetupGeneralConfig(searchEnabled: true, searchDelay: aboveMinDelay);
var request = CreateHuntRequest();
// Act
var task = _downloadHunter.HuntDownloadsAsync(request);
// Advance by minimum - should NOT complete yet
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.MinSearchDelaySeconds));
await Task.Delay(10);
Assert.False(task.IsCompleted);
// Advance remaining time
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(aboveMinDelay - Constants.MinSearchDelaySeconds));
await task;
Assert.True(task.IsCompletedSuccessfully);
}
#endregion
#region Helper Methods
private async Task SetupGeneralConfig(bool searchEnabled, ushort searchDelay = Constants.DefaultSearchDelaySeconds)
{
var generalConfig = new GeneralConfig
{
SearchEnabled = searchEnabled,
SearchDelay = searchDelay
};
_dataContext.GeneralConfigs.Add(generalConfig);
await _dataContext.SaveChangesAsync();
}
private static DownloadHuntRequest<SearchItem> CreateHuntRequest(InstanceType instanceType = InstanceType.Sonarr)
{
return new DownloadHuntRequest<SearchItem>
{
InstanceType = instanceType,
Instance = CreateArrInstance(),
SearchItem = new SearchItem { Id = 123 },
Record = CreateQueueRecord(),
JobRunId = Guid.NewGuid()
};
}
private static ArrInstance CreateArrInstance()
{
return new ArrInstance
{
Name = "Test Instance",
Url = new Uri("http://arr.local"),
ApiKey = "test-api-key",
Version = 0
};
}
private static QueueRecord CreateQueueRecord()
{
return new QueueRecord
{
Id = 1,
Title = "Test Record",
Protocol = "torrent",
DownloadId = "ABC123"
};
}
#endregion
}
@@ -1,10 +1,10 @@
using Cleanuparr.Domain.Entities.Arr;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Consumers;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Models;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using MassTransit;
using Microsoft.Extensions.Logging;
using Moq;
@@ -88,7 +88,7 @@ public class DownloadRemoverConsumerTests
// Assert
Assert.NotNull(capturedRequest);
Assert.Equal(request.InstanceType, capturedRequest.InstanceType);
Assert.Equal(request.Instance.ArrConfig.Type, capturedRequest.Instance.ArrConfig.Type);
Assert.Equal(request.SearchItem.Id, capturedRequest.SearchItem.Id);
Assert.Equal(request.RemoveFromClient, capturedRequest.RemoveFromClient);
Assert.Equal(request.DeleteReason, capturedRequest.DeleteReason);
@@ -100,8 +100,7 @@ public class DownloadRemoverConsumerTests
// Arrange
var request = new QueueItemRemoveRequest<SearchItem>
{
InstanceType = InstanceType.Sonarr,
Instance = CreateArrInstance(),
Instance = CreateArrInstance(InstanceType.Sonarr),
SearchItem = new SearchItem { Id = 456 },
Record = CreateQueueRecord(),
RemoveFromClient = true,
@@ -130,8 +129,7 @@ public class DownloadRemoverConsumerTests
// Arrange
var request = new QueueItemRemoveRequest<SearchItem>
{
InstanceType = InstanceType.Radarr,
Instance = CreateArrInstance(),
Instance = CreateArrInstance(InstanceType.Radarr),
SearchItem = new SearchItem { Id = 789 },
Record = CreateQueueRecord(),
RemoveFromClient = false,
@@ -159,8 +157,7 @@ public class DownloadRemoverConsumerTests
// Arrange
var request = new QueueItemRemoveRequest<SearchItem>
{
InstanceType = InstanceType.Readarr,
Instance = CreateArrInstance(),
Instance = CreateArrInstance(InstanceType.Readarr),
SearchItem = new SearchItem { Id = 111 },
Record = CreateQueueRecord(),
RemoveFromClient = true,
@@ -178,7 +175,7 @@ public class DownloadRemoverConsumerTests
// Assert
_queueItemRemoverMock.Verify(r => r.RemoveQueueItemAsync(
It.Is<QueueItemRemoveRequest<SearchItem>>(req => req.InstanceType == InstanceType.Readarr)), Times.Once);
It.Is<QueueItemRemoveRequest<SearchItem>>(req => req.Instance.ArrConfig.Type == InstanceType.Readarr)), Times.Once);
}
#endregion
@@ -189,8 +186,7 @@ public class DownloadRemoverConsumerTests
{
return new QueueItemRemoveRequest<SearchItem>
{
InstanceType = InstanceType.Radarr,
Instance = CreateArrInstance(),
Instance = CreateArrInstance(InstanceType.Radarr),
SearchItem = new SearchItem { Id = 123 },
Record = CreateQueueRecord(),
RemoveFromClient = true,
@@ -199,13 +195,14 @@ public class DownloadRemoverConsumerTests
};
}
private static ArrInstance CreateArrInstance()
private static ArrInstance CreateArrInstance(InstanceType instanceType = InstanceType.Radarr)
{
return new ArrInstance
{
Name = "Test Instance",
Url = new Uri("http://radarr.local"),
ApiKey = "test-api-key"
ApiKey = "test-api-key",
ArrConfig = new ArrConfig { Type = instanceType }
};
}
@@ -1,10 +1,10 @@
using System.Net;
using Cleanuparr.Domain.Entities.Arr;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadHunter.Models;
using Cleanuparr.Infrastructure.Features.DownloadRemover;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Models;
using Cleanuparr.Infrastructure.Features.ItemStriker;
@@ -14,40 +14,40 @@ using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using MassTransit;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Shouldly;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadRemover;
public class QueueItemRemoverTests : IDisposable
{
private readonly Mock<ILogger<QueueItemRemover>> _loggerMock;
private readonly Mock<IBus> _busMock;
private readonly ILogger<QueueItemRemover> _logger;
private readonly MemoryCache _memoryCache;
private readonly Mock<IArrClientFactory> _arrClientFactoryMock;
private readonly Mock<IArrClient> _arrClientMock;
private readonly IArrClientFactory _arrClientFactory;
private readonly IArrClient _arrClient;
private readonly EventPublisher _eventPublisher;
private readonly EventsContext _eventsContext;
private readonly DataContext _dataContext;
private readonly QueueItemRemover _queueItemRemover;
private readonly Guid _jobRunId;
public QueueItemRemoverTests()
{
_loggerMock = new Mock<ILogger<QueueItemRemover>>();
_busMock = new Mock<IBus>();
_logger = Substitute.For<ILogger<QueueItemRemover>>();
_memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions()));
_arrClientFactoryMock = new Mock<IArrClientFactory>();
_arrClientMock = new Mock<IArrClient>();
_arrClientFactory = Substitute.For<IArrClientFactory>();
_arrClient = Substitute.For<IArrClient>();
_arrClientFactoryMock
.Setup(f => f.GetClient(It.IsAny<InstanceType>(), It.IsAny<float>()))
.Returns(_arrClientMock.Object);
_arrClientFactory
.GetClient(Arg.Any<InstanceType>(), Arg.Any<float>())
.Returns(_arrClient);
// Create real EventPublisher with mocked dependencies
_eventsContext = TestEventsContextFactory.Create();
@@ -58,32 +58,35 @@ public class QueueItemRemoverTests : IDisposable
_eventsContext.SaveChanges();
ContextProvider.SetJobRunId(_jobRunId);
var hubContextMock = new Mock<IHubContext<AppHub>>();
var clientsMock = new Mock<IHubClients>();
clientsMock.Setup(c => c.All).Returns(Mock.Of<IClientProxy>());
hubContextMock.Setup(h => h.Clients).Returns(clientsMock.Object);
var hubContext = Substitute.For<IHubContext<AppHub>>();
var clients = Substitute.For<IHubClients>();
clients.All.Returns(Substitute.For<IClientProxy>());
hubContext.Clients.Returns(clients);
var dryRunInterceptorMock = new Mock<IDryRunInterceptor>();
dryRunInterceptorMock.Setup(d => d.IsDryRunEnabled()).ReturnsAsync(false);
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
dryRunInterceptor.IsDryRunEnabled().Returns(false);
// Setup interceptor for other uses (e.g., ArrClient deletion)
dryRunInterceptorMock
.Setup(d => d.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
dryRunInterceptor
.InterceptAsync(Arg.Any<Delegate>(), Arg.Any<object[]>())
.Returns(Task.CompletedTask);
_eventPublisher = new EventPublisher(
_eventsContext,
hubContextMock.Object,
Mock.Of<ILogger<EventPublisher>>(),
Mock.Of<INotificationPublisher>(),
dryRunInterceptorMock.Object);
hubContext,
Substitute.For<ILogger<EventPublisher>>(),
Substitute.For<INotificationPublisher>(),
dryRunInterceptor);
// Create in-memory DataContext with seeded SeekerConfig
_dataContext = TestDataContextFactory.Create();
_queueItemRemover = new QueueItemRemover(
_loggerMock.Object,
_busMock.Object,
_logger,
_memoryCache,
_arrClientFactoryMock.Object,
_arrClientFactory,
_eventPublisher,
_eventsContext
_eventsContext,
_dataContext
);
// Clear static RecurringHashes before each test
@@ -94,6 +97,7 @@ public class QueueItemRemoverTests : IDisposable
{
_memoryCache.Dispose();
_eventsContext.Dispose();
_dataContext.Dispose();
Striker.RecurringHashes.Clear();
}
@@ -105,57 +109,32 @@ public class QueueItemRemoverTests : IDisposable
// Arrange
var request = CreateRemoveRequest();
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.Returns(Task.CompletedTask);
// Act
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
_arrClientMock.Verify(c => c.DeleteQueueItemAsync(
await _arrClient.Received(1).DeleteQueueItemAsync(
request.Instance,
request.Record,
request.RemoveFromClient,
request.DeleteReason), Times.Once);
request.DeleteReason);
}
[Fact]
public async Task RemoveQueueItemAsync_Success_PublishesDownloadHuntRequest()
public async Task RemoveQueueItemAsync_Success_AddsSearchQueueItem()
{
// Arrange
var request = CreateRemoveRequest();
DownloadHuntRequest<SearchItem>? capturedRequest = null;
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.Returns(Task.CompletedTask);
_busMock
.Setup(b => b.Publish(It.IsAny<DownloadHuntRequest<SearchItem>>(), It.IsAny<CancellationToken>()))
.Callback<DownloadHuntRequest<SearchItem>, CancellationToken>((r, _) => capturedRequest = r)
.Returns(Task.CompletedTask);
// Act
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
_busMock.Verify(b => b.Publish(
It.IsAny<DownloadHuntRequest<SearchItem>>(),
It.IsAny<CancellationToken>()), Times.Once);
Assert.NotNull(capturedRequest);
Assert.Equal(request.InstanceType, capturedRequest!.InstanceType);
Assert.Equal(request.Instance, capturedRequest.Instance);
Assert.Equal(request.SearchItem.Id, capturedRequest.SearchItem.Id);
var queueItems = await _dataContext.SearchQueue.ToListAsync();
queueItems.Count.ShouldBe(1);
queueItems[0].ArrInstanceId.ShouldBe(request.Instance.Id);
queueItems[0].ItemId.ShouldBe(request.SearchItem.Id);
queueItems[0].Title.ShouldBe(request.Record.Title);
}
[Fact]
@@ -166,19 +145,11 @@ public class QueueItemRemoverTests : IDisposable
var cacheKey = $"remove_{request.Record.DownloadId.ToLowerInvariant()}_{request.Instance.Url}";
_memoryCache.Set(cacheKey, true);
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.Returns(Task.CompletedTask);
// Act
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
Assert.False(_memoryCache.TryGetValue(cacheKey, out _));
_memoryCache.TryGetValue(cacheKey, out _).ShouldBeFalse();
}
[Theory]
@@ -192,19 +163,11 @@ public class QueueItemRemoverTests : IDisposable
// Arrange
var request = CreateRemoveRequest(instanceType);
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.Returns(Task.CompletedTask);
// Act
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
_arrClientFactoryMock.Verify(f => f.GetClient(instanceType, It.IsAny<float>()), Times.Once);
_arrClientFactory.Received(1).GetClient(instanceType, Arg.Any<float>());
}
#endregion
@@ -212,28 +175,19 @@ public class QueueItemRemoverTests : IDisposable
#region RemoveQueueItemAsync - Recurring Hash Tests
[Fact]
public async Task RemoveQueueItemAsync_WhenHashIsRecurring_DoesNotPublishHuntRequest()
public async Task RemoveQueueItemAsync_WhenHashIsRecurring_DoesNotAddSearchQueueItem()
{
// Arrange
var request = CreateRemoveRequest();
var hash = request.Record.DownloadId.ToLowerInvariant();
Striker.RecurringHashes.TryAdd(hash, null);
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.Returns(Task.CompletedTask);
// Act
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
_busMock.Verify(b => b.Publish(
It.IsAny<DownloadHuntRequest<SearchItem>>(),
It.IsAny<CancellationToken>()), Times.Never);
var queueItems = await _dataContext.SearchQueue.ToListAsync();
queueItems.ShouldBeEmpty();
}
[Fact]
@@ -244,42 +198,25 @@ public class QueueItemRemoverTests : IDisposable
var hash = request.Record.DownloadId.ToLowerInvariant();
Striker.RecurringHashes.TryAdd(hash, null);
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.Returns(Task.CompletedTask);
// Act
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
Assert.False(Striker.RecurringHashes.ContainsKey(hash));
Striker.RecurringHashes.ContainsKey(hash).ShouldBeFalse();
}
[Fact]
public async Task RemoveQueueItemAsync_WhenHashIsNotRecurring_PublishesHuntRequest()
public async Task RemoveQueueItemAsync_WhenHashIsNotRecurring_AddsSearchQueueItem()
{
// Arrange
var request = CreateRemoveRequest();
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.Returns(Task.CompletedTask);
// Act
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
_busMock.Verify(b => b.Publish(
It.IsAny<DownloadHuntRequest<SearchItem>>(),
It.IsAny<CancellationToken>()), Times.Once);
var queueItems = await _dataContext.SearchQueue.ToListAsync();
queueItems.Count.ShouldBe(1);
}
#endregion
@@ -287,26 +224,17 @@ public class QueueItemRemoverTests : IDisposable
#region RemoveQueueItemAsync - SkipSearch Tests
[Fact]
public async Task RemoveQueueItemAsync_WhenSkipSearch_DoesNotPublishHuntRequest()
public async Task RemoveQueueItemAsync_WhenSkipSearch_DoesNotAddSearchQueueItem()
{
// Arrange
var request = CreateRemoveRequest(skipSearch: true);
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.Returns(Task.CompletedTask);
// Act
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
_busMock.Verify(b => b.Publish(
It.IsAny<DownloadHuntRequest<SearchItem>>(),
It.IsAny<CancellationToken>()), Times.Never);
var queueItems = await _dataContext.SearchQueue.ToListAsync();
queueItems.ShouldBeEmpty();
}
[Fact]
@@ -316,19 +244,33 @@ public class QueueItemRemoverTests : IDisposable
var request = CreateRemoveRequest(skipSearch: true);
var hash = request.Record.DownloadId.ToLowerInvariant();
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.Returns(Task.CompletedTask);
// Act
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert - hash was never in recurring, should still not be there
Assert.False(Striker.RecurringHashes.ContainsKey(hash));
Striker.RecurringHashes.ContainsKey(hash).ShouldBeFalse();
}
#endregion
#region RemoveQueueItemAsync - SearchEnabled Tests
[Fact]
public async Task RemoveQueueItemAsync_WhenSearchDisabled_DoesNotAddSearchQueueItem()
{
// Arrange
var seekerConfig = await _dataContext.SeekerConfigs.FirstAsync();
seekerConfig.SearchEnabled = false;
await _dataContext.SaveChangesAsync();
var request = CreateRemoveRequest();
// Act
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
var queueItems = await _dataContext.SearchQueue.ToListAsync();
queueItems.ShouldBeEmpty();
}
#endregion
@@ -341,20 +283,20 @@ public class QueueItemRemoverTests : IDisposable
// Arrange
var request = CreateRemoveRequest();
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
_arrClient
.DeleteQueueItemAsync(
Arg.Any<ArrInstance>(),
Arg.Any<QueueRecord>(),
Arg.Any<bool>(),
Arg.Any<DeleteReason>())
.ThrowsAsync(new HttpRequestException("Not found", null, HttpStatusCode.NotFound));
// Act & Assert
var exception = await Assert.ThrowsAsync<Exception>(
var exception = await Should.ThrowAsync<Exception>(
() => _queueItemRemover.RemoveQueueItemAsync(request));
Assert.Contains("might have already been deleted", exception.Message);
Assert.Contains(request.InstanceType.ToString(), exception.Message);
exception.Message.ShouldContain("might have already been deleted");
exception.Message.ShouldContain(request.Instance.ArrConfig.Type.ToString());
}
[Fact]
@@ -365,20 +307,20 @@ public class QueueItemRemoverTests : IDisposable
var cacheKey = $"remove_{request.Record.DownloadId.ToLowerInvariant()}_{request.Instance.Url}";
_memoryCache.Set(cacheKey, true);
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
_arrClient
.DeleteQueueItemAsync(
Arg.Any<ArrInstance>(),
Arg.Any<QueueRecord>(),
Arg.Any<bool>(),
Arg.Any<DeleteReason>())
.ThrowsAsync(new HttpRequestException("Not found", null, HttpStatusCode.NotFound));
// Act & Assert
await Assert.ThrowsAsync<Exception>(
await Should.ThrowAsync<Exception>(
() => _queueItemRemover.RemoveQueueItemAsync(request));
// Cache should be cleared in finally block
Assert.False(_memoryCache.TryGetValue(cacheKey, out _));
_memoryCache.TryGetValue(cacheKey, out _).ShouldBeFalse();
}
[Fact]
@@ -388,19 +330,19 @@ public class QueueItemRemoverTests : IDisposable
var request = CreateRemoveRequest();
var originalException = new HttpRequestException("Server error", null, HttpStatusCode.InternalServerError);
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
_arrClient
.DeleteQueueItemAsync(
Arg.Any<ArrInstance>(),
Arg.Any<QueueRecord>(),
Arg.Any<bool>(),
Arg.Any<DeleteReason>())
.ThrowsAsync(originalException);
// Act & Assert
var exception = await Assert.ThrowsAsync<HttpRequestException>(
var exception = await Should.ThrowAsync<HttpRequestException>(
() => _queueItemRemover.RemoveQueueItemAsync(request));
Assert.Same(originalException, exception);
exception.ShouldBeSameAs(originalException);
}
[Fact]
@@ -410,19 +352,19 @@ public class QueueItemRemoverTests : IDisposable
var request = CreateRemoveRequest();
var originalException = new InvalidOperationException("Some other error");
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
_arrClient
.DeleteQueueItemAsync(
Arg.Any<ArrInstance>(),
Arg.Any<QueueRecord>(),
Arg.Any<bool>(),
Arg.Any<DeleteReason>())
.ThrowsAsync(originalException);
// Act & Assert
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
var exception = await Should.ThrowAsync<InvalidOperationException>(
() => _queueItemRemover.RemoveQueueItemAsync(request));
Assert.Same(originalException, exception);
exception.ShouldBeSameAs(originalException);
}
#endregion
@@ -440,23 +382,15 @@ public class QueueItemRemoverTests : IDisposable
// Arrange
var request = CreateRemoveRequest(deleteReason: deleteReason);
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.Returns(Task.CompletedTask);
// Act
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
_arrClientMock.Verify(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
deleteReason), Times.Once);
await _arrClient.Received(1).DeleteQueueItemAsync(
Arg.Any<ArrInstance>(),
Arg.Any<QueueRecord>(),
Arg.Any<bool>(),
deleteReason);
}
[Theory]
@@ -467,23 +401,15 @@ public class QueueItemRemoverTests : IDisposable
// Arrange
var request = CreateRemoveRequest(removeFromClient: removeFromClient);
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.Returns(Task.CompletedTask);
// Act
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
_arrClientMock.Verify(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
await _arrClient.Received(1).DeleteQueueItemAsync(
Arg.Any<ArrInstance>(),
Arg.Any<QueueRecord>(),
removeFromClient,
It.IsAny<DeleteReason>()), Times.Once);
Arg.Any<DeleteReason>());
}
#endregion
@@ -496,10 +422,12 @@ public class QueueItemRemoverTests : IDisposable
DeleteReason deleteReason = DeleteReason.Stalled,
bool skipSearch = false)
{
// Use an ArrInstance that exists in the DB to satisfy FK constraint on SearchQueueItem
var instance = GetOrCreateArrInstance(instanceType);
return new QueueItemRemoveRequest<SearchItem>
{
InstanceType = instanceType,
Instance = CreateArrInstance(),
Instance = instance,
SearchItem = new SearchItem { Id = 123 },
Record = CreateQueueRecord(),
RemoveFromClient = removeFromClient,
@@ -509,13 +437,16 @@ public class QueueItemRemoverTests : IDisposable
};
}
private static ArrInstance CreateArrInstance()
private ArrInstance GetOrCreateArrInstance(InstanceType instanceType)
{
return new ArrInstance
return instanceType switch
{
Name = "Test Instance",
Url = new Uri("http://arr.local"),
ApiKey = "test-api-key"
InstanceType.Sonarr => TestDataContextFactory.AddSonarrInstance(_dataContext),
InstanceType.Radarr => TestDataContextFactory.AddRadarrInstance(_dataContext),
InstanceType.Lidarr => TestDataContextFactory.AddLidarrInstance(_dataContext),
InstanceType.Readarr => TestDataContextFactory.AddReadarrInstance(_dataContext),
InstanceType.Whisparr => TestDataContextFactory.AddWhisparrInstance(_dataContext),
_ => TestDataContextFactory.AddSonarrInstance(_dataContext),
};
}
@@ -0,0 +1,868 @@
using Cleanuparr.Domain.Entities.Arr;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Hubs;
using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.Seeker;
using Cleanuparr.Persistence.Models.State;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
using CustomFormatScoreSyncer = Cleanuparr.Infrastructure.Features.Jobs.CustomFormatScoreSyncer;
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs;
[Collection(JobHandlerCollection.Name)]
public class CustomFormatScoreSyncerTests : IDisposable
{
private readonly JobHandlerFixture _fixture;
private readonly Mock<ILogger<CustomFormatScoreSyncer>> _logger;
private readonly Mock<IRadarrClient> _radarrClient;
private readonly Mock<ISonarrClient> _sonarrClient;
private readonly Mock<IHubContext<AppHub>> _hubContext;
public CustomFormatScoreSyncerTests(JobHandlerFixture fixture)
{
_fixture = fixture;
_fixture.RecreateDataContext();
_fixture.ResetMocks();
_logger = new Mock<ILogger<CustomFormatScoreSyncer>>();
_radarrClient = new Mock<IRadarrClient>();
_sonarrClient = new Mock<ISonarrClient>();
_hubContext = new Mock<IHubContext<AppHub>>();
var mockClients = new Mock<IHubClients>();
var mockClientProxy = new Mock<IClientProxy>();
mockClients.Setup(c => c.All).Returns(mockClientProxy.Object);
_hubContext.Setup(h => h.Clients).Returns(mockClients.Object);
}
public void Dispose()
{
GC.SuppressFinalize(this);
}
private CustomFormatScoreSyncer CreateSut()
{
return new CustomFormatScoreSyncer(
_logger.Object,
_fixture.DataContext,
_radarrClient.Object,
_sonarrClient.Object,
_fixture.TimeProvider,
_hubContext.Object
);
}
#region ExecuteAsync Tests
[Fact]
public async Task ExecuteAsync_WhenCustomFormatScoreDisabled_ReturnsEarly()
{
// Arrange — UseCustomFormatScore is false by default in seed data
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.UseCustomFormatScore = false;
await _fixture.DataContext.SaveChangesAsync();
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — no API calls made
_radarrClient.Verify(
x => x.GetAllMoviesAsync(It.IsAny<ArrInstance>()),
Times.Never);
_sonarrClient.Verify(
x => x.GetAllSeriesAsync(It.IsAny<ArrInstance>()),
Times.Never);
}
[Fact]
public async Task ExecuteAsync_WhenNoEnabledInstances_ReturnsEarly()
{
// Arrange — enable CF scoring but add no SeekerInstanceConfigs
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.UseCustomFormatScore = true;
await _fixture.DataContext.SaveChangesAsync();
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — no API calls
_radarrClient.Verify(
x => x.GetAllMoviesAsync(It.IsAny<ArrInstance>()),
Times.Never);
_sonarrClient.Verify(
x => x.GetAllSeriesAsync(It.IsAny<ArrInstance>()),
Times.Never);
}
[Fact]
public async Task ExecuteAsync_SyncsRadarrMovieScores()
{
// Arrange
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.UseCustomFormatScore = true;
await _fixture.DataContext.SaveChangesAsync();
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarrInstance.Id,
ArrInstance = radarrInstance,
Enabled = true
});
await _fixture.DataContext.SaveChangesAsync();
// Mock quality profiles
_radarrClient
.Setup(x => x.GetQualityProfilesAsync(radarrInstance))
.ReturnsAsync([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]);
// Mock movies with files
_radarrClient
.Setup(x => x.GetAllMoviesAsync(radarrInstance))
.ReturnsAsync([
new SearchableMovie
{
Id = 10,
Title = "Test Movie",
HasFile = true,
MovieFile = new MovieFileInfo { Id = 100, QualityCutoffNotMet = false },
QualityProfileId = 1,
Status = "released",
Monitored = true
}
]);
// Mock file scores
_radarrClient
.Setup(x => x.GetMovieFileScoresAsync(radarrInstance, It.Is<List<long>>(ids => ids.Contains(100))))
.ReturnsAsync(new Dictionary<long, int> { { 100, 250 } });
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — CF score entry was saved
var entries = await _fixture.DataContext.CustomFormatScoreEntries.ToListAsync();
Assert.Single(entries);
var entry = entries[0];
Assert.Equal(radarrInstance.Id, entry.ArrInstanceId);
Assert.Equal(10, entry.ExternalItemId);
Assert.Equal(250, entry.CurrentScore);
Assert.Equal(500, entry.CutoffScore);
Assert.Equal("HD", entry.QualityProfileName);
Assert.Equal(InstanceType.Radarr, entry.ItemType);
Assert.True(entry.IsMonitored);
// Initial history entry should also be created
var history = await _fixture.DataContext.CustomFormatScoreHistory.ToListAsync();
Assert.Single(history);
Assert.Equal(250, history[0].Score);
}
[Fact]
public async Task ExecuteAsync_RecordsHistoryOnScoreChange()
{
// Arrange
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.UseCustomFormatScore = true;
await _fixture.DataContext.SaveChangesAsync();
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarrInstance.Id,
ArrInstance = radarrInstance,
Enabled = true
});
// Pre-existing CF score entry with a different score
_fixture.DataContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
{
ArrInstanceId = radarrInstance.Id,
ExternalItemId = 10,
EpisodeId = 0,
ItemType = InstanceType.Radarr,
Title = "Test Movie",
FileId = 100,
CurrentScore = 200,
CutoffScore = 500,
QualityProfileName = "HD",
LastSyncedAt = DateTime.UtcNow.AddHours(-1)
});
await _fixture.DataContext.SaveChangesAsync();
// Mock quality profiles
_radarrClient
.Setup(x => x.GetQualityProfilesAsync(radarrInstance))
.ReturnsAsync([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]);
// Mock movies — same movie but score changed from 200 to 350
_radarrClient
.Setup(x => x.GetAllMoviesAsync(radarrInstance))
.ReturnsAsync([
new SearchableMovie
{
Id = 10,
Title = "Test Movie",
HasFile = true,
MovieFile = new MovieFileInfo { Id = 100, QualityCutoffNotMet = false },
QualityProfileId = 1,
Status = "released",
Monitored = true
}
]);
_radarrClient
.Setup(x => x.GetMovieFileScoresAsync(radarrInstance, It.Is<List<long>>(ids => ids.Contains(100))))
.ReturnsAsync(new Dictionary<long, int> { { 100, 350 } });
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — existing entry should be updated
var entries = await _fixture.DataContext.CustomFormatScoreEntries.ToListAsync();
Assert.Single(entries);
Assert.Equal(350, entries[0].CurrentScore);
// History entry should be created because score changed (200 -> 350)
var history = await _fixture.DataContext.CustomFormatScoreHistory.ToListAsync();
Assert.Single(history);
Assert.Equal(350, history[0].Score);
Assert.Equal(InstanceType.Radarr, history[0].ItemType);
}
[Fact]
public async Task ExecuteAsync_TracksUnmonitoredMovie()
{
// Arrange
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.UseCustomFormatScore = true;
await _fixture.DataContext.SaveChangesAsync();
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarrInstance.Id,
ArrInstance = radarrInstance,
Enabled = true
});
await _fixture.DataContext.SaveChangesAsync();
_radarrClient
.Setup(x => x.GetQualityProfilesAsync(radarrInstance))
.ReturnsAsync([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]);
_radarrClient
.Setup(x => x.GetAllMoviesAsync(radarrInstance))
.ReturnsAsync([
new SearchableMovie
{
Id = 10,
Title = "Unmonitored Movie",
HasFile = true,
MovieFile = new MovieFileInfo { Id = 100, QualityCutoffNotMet = false },
QualityProfileId = 1,
Status = "released",
Monitored = false
}
]);
_radarrClient
.Setup(x => x.GetMovieFileScoresAsync(radarrInstance, It.Is<List<long>>(ids => ids.Contains(100))))
.ReturnsAsync(new Dictionary<long, int> { { 100, 250 } });
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — entry should be saved with IsMonitored = false
var entries = await _fixture.DataContext.CustomFormatScoreEntries.ToListAsync();
Assert.Single(entries);
Assert.False(entries[0].IsMonitored);
}
[Fact]
public async Task ExecuteAsync_UpdatesMonitoredStatusOnSync()
{
// Arrange
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.UseCustomFormatScore = true;
await _fixture.DataContext.SaveChangesAsync();
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarrInstance.Id,
ArrInstance = radarrInstance,
Enabled = true
});
// Pre-existing entry that was monitored
_fixture.DataContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
{
ArrInstanceId = radarrInstance.Id,
ExternalItemId = 10,
EpisodeId = 0,
ItemType = InstanceType.Radarr,
Title = "Test Movie",
FileId = 100,
CurrentScore = 250,
CutoffScore = 500,
QualityProfileName = "HD",
IsMonitored = true,
LastSyncedAt = DateTime.UtcNow.AddHours(-1)
});
await _fixture.DataContext.SaveChangesAsync();
_radarrClient
.Setup(x => x.GetQualityProfilesAsync(radarrInstance))
.ReturnsAsync([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]);
// Movie is now unmonitored
_radarrClient
.Setup(x => x.GetAllMoviesAsync(radarrInstance))
.ReturnsAsync([
new SearchableMovie
{
Id = 10, Title = "Test Movie", HasFile = true,
MovieFile = new MovieFileInfo { Id = 100, QualityCutoffNotMet = false },
QualityProfileId = 1, Status = "released", Monitored = false
}
]);
_radarrClient
.Setup(x => x.GetMovieFileScoresAsync(radarrInstance, It.IsAny<List<long>>()))
.ReturnsAsync(new Dictionary<long, int> { { 100, 250 } });
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — IsMonitored should be updated to false
var entries = await _fixture.DataContext.CustomFormatScoreEntries.ToListAsync();
Assert.Single(entries);
Assert.False(entries[0].IsMonitored);
}
#endregion
#region Sonarr Sync Tests
[Fact]
public async Task ExecuteAsync_SyncsSonarrEpisodeScores()
{
// Arrange
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.UseCustomFormatScore = true;
await _fixture.DataContext.SaveChangesAsync();
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = sonarrInstance.Id,
ArrInstance = sonarrInstance,
Enabled = true
});
await _fixture.DataContext.SaveChangesAsync();
// Mock quality profiles
_sonarrClient
.Setup(x => x.GetQualityProfilesAsync(sonarrInstance))
.ReturnsAsync([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]);
// Mock series
_sonarrClient
.Setup(x => x.GetAllSeriesAsync(sonarrInstance))
.ReturnsAsync([
new SearchableSeries { Id = 10, Title = "Test Series", QualityProfileId = 1, Monitored = true }
]);
// Mock episodes — one with a file, one without
_sonarrClient
.Setup(x => x.GetEpisodesAsync(sonarrInstance, 10))
.ReturnsAsync([
new SearchableEpisode { Id = 100, SeasonNumber = 1, EpisodeNumber = 1, EpisodeFileId = 500, HasFile = true, Monitored = true },
new SearchableEpisode { Id = 101, SeasonNumber = 1, EpisodeNumber = 2, EpisodeFileId = 0, HasFile = false }
]);
// Mock episode files with CF scores
_sonarrClient
.Setup(x => x.GetEpisodeFilesAsync(sonarrInstance, 10))
.ReturnsAsync([
new ArrEpisodeFile { Id = 500, CustomFormatScore = 300, QualityCutoffNotMet = false }
]);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — only the episode with a file should have an entry
var entries = await _fixture.DataContext.CustomFormatScoreEntries.ToListAsync();
Assert.Single(entries);
var entry = entries[0];
Assert.Equal(sonarrInstance.Id, entry.ArrInstanceId);
Assert.Equal(10, entry.ExternalItemId);
Assert.Equal(100, entry.EpisodeId);
Assert.Equal(300, entry.CurrentScore);
Assert.Equal(500, entry.CutoffScore);
Assert.Equal(InstanceType.Sonarr, entry.ItemType);
Assert.True(entry.IsMonitored);
Assert.Contains("S01E01", entry.Title);
// Initial history should be created
var history = await _fixture.DataContext.CustomFormatScoreHistory.ToListAsync();
Assert.Single(history);
Assert.Equal(300, history[0].Score);
}
[Fact]
public async Task ExecuteAsync_SonarrSync_SkipsEpisodesWithoutFiles()
{
// Arrange
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.UseCustomFormatScore = true;
await _fixture.DataContext.SaveChangesAsync();
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = sonarrInstance.Id,
ArrInstance = sonarrInstance,
Enabled = true
});
await _fixture.DataContext.SaveChangesAsync();
_sonarrClient
.Setup(x => x.GetQualityProfilesAsync(sonarrInstance))
.ReturnsAsync([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]);
_sonarrClient
.Setup(x => x.GetAllSeriesAsync(sonarrInstance))
.ReturnsAsync([
new SearchableSeries { Id = 10, Title = "Test Series", QualityProfileId = 1, Monitored = true }
]);
// All episodes have EpisodeFileId = 0 (no file)
_sonarrClient
.Setup(x => x.GetEpisodesAsync(sonarrInstance, 10))
.ReturnsAsync([
new SearchableEpisode { Id = 100, SeasonNumber = 1, EpisodeNumber = 1, EpisodeFileId = 0, HasFile = false }
]);
_sonarrClient
.Setup(x => x.GetEpisodeFilesAsync(sonarrInstance, 10))
.ReturnsAsync([]);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — no entries created
var entries = await _fixture.DataContext.CustomFormatScoreEntries.ToListAsync();
Assert.Empty(entries);
}
#endregion
#region Score Unchanged Tests
[Fact]
public async Task ExecuteAsync_ScoreUnchanged_DoesNotRecordHistory()
{
// Arrange
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.UseCustomFormatScore = true;
await _fixture.DataContext.SaveChangesAsync();
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarrInstance.Id,
ArrInstance = radarrInstance,
Enabled = true
});
// Pre-existing entry with score = 250 (same as what will be returned)
_fixture.DataContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
{
ArrInstanceId = radarrInstance.Id,
ExternalItemId = 10,
EpisodeId = 0,
ItemType = InstanceType.Radarr,
Title = "Test Movie",
FileId = 100,
CurrentScore = 250,
CutoffScore = 500,
QualityProfileName = "HD",
LastSyncedAt = DateTime.UtcNow.AddHours(-1)
});
await _fixture.DataContext.SaveChangesAsync();
_radarrClient
.Setup(x => x.GetQualityProfilesAsync(radarrInstance))
.ReturnsAsync([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]);
_radarrClient
.Setup(x => x.GetAllMoviesAsync(radarrInstance))
.ReturnsAsync([
new SearchableMovie
{
Id = 10, Title = "Test Movie", HasFile = true,
MovieFile = new MovieFileInfo { Id = 100, QualityCutoffNotMet = false },
QualityProfileId = 1, Status = "released", Monitored = true
}
]);
// Score unchanged: still 250
_radarrClient
.Setup(x => x.GetMovieFileScoresAsync(radarrInstance, It.Is<List<long>>(ids => ids.Contains(100))))
.ReturnsAsync(new Dictionary<long, int> { { 100, 250 } });
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — no history entries (score didn't change)
var history = await _fixture.DataContext.CustomFormatScoreHistory.ToListAsync();
Assert.Empty(history);
// Entry should still be updated (LastSyncedAt changes)
var entries = await _fixture.DataContext.CustomFormatScoreEntries.ToListAsync();
Assert.Single(entries);
Assert.Equal(250, entries[0].CurrentScore);
}
#endregion
#region Stale Entry Cleanup Tests
[Fact]
public async Task ExecuteAsync_CleansUpEntriesForRemovedMovies()
{
// Arrange
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.UseCustomFormatScore = true;
await _fixture.DataContext.SaveChangesAsync();
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarrInstance.Id,
ArrInstance = radarrInstance,
Enabled = true
});
// Pre-existing entry for a movie that no longer exists in library
_fixture.DataContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
{
ArrInstanceId = radarrInstance.Id,
ExternalItemId = 999,
EpisodeId = 0,
ItemType = InstanceType.Radarr,
Title = "Deleted Movie",
FileId = 999,
CurrentScore = 100,
CutoffScore = 500,
QualityProfileName = "HD",
LastSyncedAt = new DateTime(1999, 1, 1, 0, 0, 0, DateTimeKind.Utc)
});
await _fixture.DataContext.SaveChangesAsync();
_radarrClient
.Setup(x => x.GetQualityProfilesAsync(radarrInstance))
.ReturnsAsync([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]);
// Library now only has movie 10 (not 999)
_radarrClient
.Setup(x => x.GetAllMoviesAsync(radarrInstance))
.ReturnsAsync([
new SearchableMovie
{
Id = 10, Title = "Current Movie", HasFile = true,
MovieFile = new MovieFileInfo { Id = 100, QualityCutoffNotMet = false },
QualityProfileId = 1, Status = "released", Monitored = true
}
]);
_radarrClient
.Setup(x => x.GetMovieFileScoresAsync(radarrInstance, It.IsAny<List<long>>()))
.ReturnsAsync(new Dictionary<long, int> { { 100, 250 } });
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — entry for removed movie 999 should be deleted
var entries = await _fixture.DataContext.CustomFormatScoreEntries.ToListAsync();
Assert.Single(entries);
Assert.Equal(10, entries[0].ExternalItemId);
}
[Fact]
public async Task ExecuteAsync_PreservesEntryWhenMovieExistsButHasNoFile()
{
// Arrange — simulates an RSS upgrade where the old file was removed
// but the new file hasn't been imported yet
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.UseCustomFormatScore = true;
await _fixture.DataContext.SaveChangesAsync();
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarrInstance.Id,
ArrInstance = radarrInstance,
Enabled = true
});
// Pre-existing entry with score history
_fixture.DataContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
{
ArrInstanceId = radarrInstance.Id,
ExternalItemId = 10,
EpisodeId = 0,
ItemType = InstanceType.Radarr,
Title = "Mario Bros",
FileId = 100,
CurrentScore = 250,
CutoffScore = 500,
QualityProfileName = "HD",
LastSyncedAt = DateTime.UtcNow.AddHours(-1)
});
_fixture.DataContext.CustomFormatScoreHistory.Add(new CustomFormatScoreHistory
{
ArrInstanceId = radarrInstance.Id,
ExternalItemId = 10,
EpisodeId = 0,
ItemType = InstanceType.Radarr,
Title = "Mario Bros",
Score = 250,
CutoffScore = 500,
RecordedAt = DateTime.UtcNow.AddHours(-1)
});
await _fixture.DataContext.SaveChangesAsync();
_radarrClient
.Setup(x => x.GetQualityProfilesAsync(radarrInstance))
.ReturnsAsync([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]);
// Movie still exists in Radarr but HasFile is false (RSS upgrade in progress)
_radarrClient
.Setup(x => x.GetAllMoviesAsync(radarrInstance))
.ReturnsAsync([
new SearchableMovie
{
Id = 10, Title = "Mario Bros", HasFile = false,
MovieFile = null,
QualityProfileId = 1, Status = "released", Monitored = true
}
]);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — entry and history should be preserved since the movie still exists
var entries = await _fixture.DataContext.CustomFormatScoreEntries.ToListAsync();
Assert.Single(entries);
Assert.Equal(10, entries[0].ExternalItemId);
Assert.Equal(250, entries[0].CurrentScore);
var history = await _fixture.DataContext.CustomFormatScoreHistory.ToListAsync();
Assert.Single(history);
Assert.Equal(250, history[0].Score);
}
[Fact]
public async Task ExecuteAsync_PreservesEntryWhenMovieFileScoreNotReturned()
{
// Arrange — simulates a newly imported file that doesn't have CF scores calculated yet
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.UseCustomFormatScore = true;
await _fixture.DataContext.SaveChangesAsync();
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarrInstance.Id,
ArrInstance = radarrInstance,
Enabled = true
});
// Pre-existing entry with history
_fixture.DataContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
{
ArrInstanceId = radarrInstance.Id,
ExternalItemId = 10,
EpisodeId = 0,
ItemType = InstanceType.Radarr,
Title = "Mario Bros",
FileId = 100,
CurrentScore = 250,
CutoffScore = 500,
QualityProfileName = "HD",
LastSyncedAt = DateTime.UtcNow.AddHours(-1)
});
_fixture.DataContext.CustomFormatScoreHistory.Add(new CustomFormatScoreHistory
{
ArrInstanceId = radarrInstance.Id,
ExternalItemId = 10,
EpisodeId = 0,
ItemType = InstanceType.Radarr,
Title = "Mario Bros",
Score = 250,
CutoffScore = 500,
RecordedAt = DateTime.UtcNow.AddHours(-1)
});
await _fixture.DataContext.SaveChangesAsync();
_radarrClient
.Setup(x => x.GetQualityProfilesAsync(radarrInstance))
.ReturnsAsync([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]);
// Movie has a new file (different FileId) after RSS upgrade
_radarrClient
.Setup(x => x.GetAllMoviesAsync(radarrInstance))
.ReturnsAsync([
new SearchableMovie
{
Id = 10, Title = "Mario Bros", HasFile = true,
MovieFile = new MovieFileInfo { Id = 200, QualityCutoffNotMet = false },
QualityProfileId = 1, Status = "released", Monitored = true
}
]);
// New file returns no score (not yet calculated by Radarr)
_radarrClient
.Setup(x => x.GetMovieFileScoresAsync(radarrInstance, It.IsAny<List<long>>()))
.ReturnsAsync(new Dictionary<long, int>());
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — entry and history should be preserved since the movie still exists
var entries = await _fixture.DataContext.CustomFormatScoreEntries.ToListAsync();
Assert.Single(entries);
Assert.Equal(10, entries[0].ExternalItemId);
Assert.Equal(250, entries[0].CurrentScore);
var history = await _fixture.DataContext.CustomFormatScoreHistory.ToListAsync();
Assert.Single(history);
}
[Fact]
public async Task ExecuteAsync_Sonarr_PreservesEntryWhenEpisodeTemporarilyWithoutFile()
{
// Arrange — simulates a Sonarr episode whose file was replaced via RSS
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.UseCustomFormatScore = true;
await _fixture.DataContext.SaveChangesAsync();
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = sonarrInstance.Id,
ArrInstance = sonarrInstance,
Enabled = true
});
// Pre-existing CF score entry for an episode
_fixture.DataContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
{
ArrInstanceId = sonarrInstance.Id,
ExternalItemId = 10,
EpisodeId = 100,
ItemType = InstanceType.Sonarr,
Title = "Test Series S01E01",
FileId = 500,
CurrentScore = 300,
CutoffScore = 500,
QualityProfileName = "HD",
LastSyncedAt = DateTime.UtcNow.AddHours(-1)
});
_fixture.DataContext.CustomFormatScoreHistory.Add(new CustomFormatScoreHistory
{
ArrInstanceId = sonarrInstance.Id,
ExternalItemId = 10,
EpisodeId = 100,
ItemType = InstanceType.Sonarr,
Title = "Test Series S01E01",
Score = 300,
CutoffScore = 500,
RecordedAt = DateTime.UtcNow.AddHours(-1)
});
await _fixture.DataContext.SaveChangesAsync();
_sonarrClient
.Setup(x => x.GetQualityProfilesAsync(sonarrInstance))
.ReturnsAsync([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]);
_sonarrClient
.Setup(x => x.GetAllSeriesAsync(sonarrInstance))
.ReturnsAsync([
new SearchableSeries { Id = 10, Title = "Test Series", QualityProfileId = 1, Monitored = true }
]);
// Episode exists but has no file currently (RSS upgrade in progress)
_sonarrClient
.Setup(x => x.GetEpisodesAsync(sonarrInstance, 10))
.ReturnsAsync([
new SearchableEpisode { Id = 100, SeasonNumber = 1, EpisodeNumber = 1, EpisodeFileId = 0, HasFile = false, Monitored = true }
]);
_sonarrClient
.Setup(x => x.GetEpisodeFilesAsync(sonarrInstance, 10))
.ReturnsAsync([]);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — entry and history should be preserved
var entries = await _fixture.DataContext.CustomFormatScoreEntries.ToListAsync();
Assert.Single(entries);
Assert.Equal(10, entries[0].ExternalItemId);
Assert.Equal(100, entries[0].EpisodeId);
Assert.Equal(300, entries[0].CurrentScore);
var history = await _fixture.DataContext.CustomFormatScoreHistory.ToListAsync();
Assert.Single(history);
}
#endregion
}
@@ -313,7 +313,7 @@ public class DownloadCleanerTests : IDisposable
mockDownloadService
.Setup(x => x.FilterDownloadsToBeCleanedAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<SeedingRule>>()
It.IsAny<List<ISeedingRule>>()
))
.Returns([]);
@@ -358,14 +358,9 @@ public class DownloadCleanerTests : IDisposable
public async Task ChangeUnlinkedCategoriesAsync_WhenIgnoredRootDirsConfigured_PopulatesFileCountsOnce()
{
// Arrange
var downloadCleanerConfig = _fixture.DataContext.DownloadCleanerConfigs.First();
downloadCleanerConfig.UnlinkedEnabled = true;
downloadCleanerConfig.UnlinkedTargetCategory = "unlinked";
downloadCleanerConfig.UnlinkedCategories = ["completed"];
downloadCleanerConfig.UnlinkedIgnoredRootDirs = ["/media/library"];
_fixture.DataContext.SaveChanges();
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
TestDataContextFactory.AddUnlinkedConfig(_fixture.DataContext,
ignoredRootDirs: ["/media/library"]);
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
@@ -373,21 +368,23 @@ public class DownloadCleanerTests : IDisposable
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
mockTorrent.Setup(x => x.Category).Returns("completed");
var dbClient = _fixture.DataContext.DownloadClients.First();
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService.Setup(x => x.ClientConfig).Returns(dbClient);
mockDownloadService
.Setup(x => x.GetSeedingDownloads())
.ReturnsAsync([mockTorrent.Object]);
mockDownloadService
.Setup(x => x.FilterDownloadsToChangeCategoryAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<string>>()
It.IsAny<UnlinkedConfig>()
))
.Returns([mockTorrent.Object]);
mockDownloadService
.Setup(x => x.CreateCategoryAsync(It.IsAny<string>()))
.Returns(Task.CompletedTask);
mockDownloadService
.Setup(x => x.ChangeCategoryForNoHardLinksAsync(It.IsAny<List<ITorrentItemWrapper>>()))
.Setup(x => x.ChangeCategoryForNoHardLinksAsync(It.IsAny<List<ITorrentItemWrapper>>(), It.IsAny<UnlinkedConfig>()))
.Returns(Task.CompletedTask);
_fixture.DownloadServiceFactory
@@ -410,14 +407,9 @@ public class DownloadCleanerTests : IDisposable
public async Task ChangeUnlinkedCategoriesAsync_WhenNoIgnoredRootDirsConfigured_DoesNotPopulateFileCounts()
{
// Arrange
var downloadCleanerConfig = _fixture.DataContext.DownloadCleanerConfigs.First();
downloadCleanerConfig.UnlinkedEnabled = true;
downloadCleanerConfig.UnlinkedTargetCategory = "unlinked";
downloadCleanerConfig.UnlinkedCategories = ["completed"];
downloadCleanerConfig.UnlinkedIgnoredRootDirs = []; // Empty list
_fixture.DataContext.SaveChanges();
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
TestDataContextFactory.AddUnlinkedConfig(_fixture.DataContext,
ignoredRootDirs: []);
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
@@ -425,21 +417,23 @@ public class DownloadCleanerTests : IDisposable
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
mockTorrent.Setup(x => x.Category).Returns("completed");
var dbClient = _fixture.DataContext.DownloadClients.First();
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService.Setup(x => x.ClientConfig).Returns(dbClient);
mockDownloadService
.Setup(x => x.GetSeedingDownloads())
.ReturnsAsync([mockTorrent.Object]);
mockDownloadService
.Setup(x => x.FilterDownloadsToChangeCategoryAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<string>>()
It.IsAny<UnlinkedConfig>()
))
.Returns([mockTorrent.Object]);
mockDownloadService
.Setup(x => x.CreateCategoryAsync(It.IsAny<string>()))
.Returns(Task.CompletedTask);
mockDownloadService
.Setup(x => x.ChangeCategoryForNoHardLinksAsync(It.IsAny<List<ITorrentItemWrapper>>()))
.Setup(x => x.ChangeCategoryForNoHardLinksAsync(It.IsAny<List<ITorrentItemWrapper>>(), It.IsAny<UnlinkedConfig>()))
.Returns(Task.CompletedTask);
_fixture.DownloadServiceFactory
@@ -459,19 +453,28 @@ public class DownloadCleanerTests : IDisposable
}
[Fact]
public async Task ChangeUnlinkedCategoriesAsync_WithMultipleDownloadClients_PopulatesFileCountsOnlyOnce()
public async Task ChangeUnlinkedCategoriesAsync_WithMultipleDownloadClients_PopulatesFileCountsPerClient()
{
// Arrange
var downloadCleanerConfig = _fixture.DataContext.DownloadCleanerConfigs.First();
downloadCleanerConfig.UnlinkedEnabled = true;
downloadCleanerConfig.UnlinkedTargetCategory = "unlinked";
downloadCleanerConfig.UnlinkedCategories = ["completed"];
downloadCleanerConfig.UnlinkedIgnoredRootDirs = ["/media/library"];
_fixture.DataContext.SaveChanges();
TestDataContextFactory.AddDownloadClient(_fixture.DataContext, "Client 1");
TestDataContextFactory.AddDownloadClient(_fixture.DataContext, "Client 2");
// Add unlinked config for each client
var clients = _fixture.DataContext.DownloadClients.ToList();
foreach (var client in clients)
{
_fixture.DataContext.UnlinkedConfigs.Add(new UnlinkedConfig
{
Id = Guid.NewGuid(),
DownloadClientConfigId = client.Id,
Enabled = true,
TargetCategory = "unlinked",
Categories = ["completed"],
IgnoredRootDirs = ["/media/library"]
});
}
_fixture.DataContext.SaveChanges();
var mockTorrent1 = new Mock<ITorrentItemWrapper>();
mockTorrent1.Setup(x => x.Hash).Returns("test-hash-1");
mockTorrent1.Setup(x => x.Name).Returns("Test Download 1");
@@ -485,37 +488,39 @@ public class DownloadCleanerTests : IDisposable
mockTorrent2.Setup(x => x.Category).Returns("completed");
var mockDownloadService1 = _fixture.CreateMockDownloadService("Client 1");
mockDownloadService1.Setup(x => x.ClientConfig).Returns(clients[0]);
mockDownloadService1
.Setup(x => x.GetSeedingDownloads())
.ReturnsAsync([mockTorrent1.Object]);
mockDownloadService1
.Setup(x => x.FilterDownloadsToChangeCategoryAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<string>>()
It.IsAny<UnlinkedConfig>()
))
.Returns([mockTorrent1.Object]);
mockDownloadService1
.Setup(x => x.CreateCategoryAsync(It.IsAny<string>()))
.Returns(Task.CompletedTask);
mockDownloadService1
.Setup(x => x.ChangeCategoryForNoHardLinksAsync(It.IsAny<List<ITorrentItemWrapper>>()))
.Setup(x => x.ChangeCategoryForNoHardLinksAsync(It.IsAny<List<ITorrentItemWrapper>>(), It.IsAny<UnlinkedConfig>()))
.Returns(Task.CompletedTask);
var mockDownloadService2 = _fixture.CreateMockDownloadService("Client 2");
mockDownloadService2.Setup(x => x.ClientConfig).Returns(clients[1]);
mockDownloadService2
.Setup(x => x.GetSeedingDownloads())
.ReturnsAsync([mockTorrent2.Object]);
mockDownloadService2
.Setup(x => x.FilterDownloadsToChangeCategoryAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<string>>()
It.IsAny<UnlinkedConfig>()
))
.Returns([mockTorrent2.Object]);
mockDownloadService2
.Setup(x => x.CreateCategoryAsync(It.IsAny<string>()))
.Returns(Task.CompletedTask);
mockDownloadService2
.Setup(x => x.ChangeCategoryForNoHardLinksAsync(It.IsAny<List<ITorrentItemWrapper>>()))
.Setup(x => x.ChangeCategoryForNoHardLinksAsync(It.IsAny<List<ITorrentItemWrapper>>(), It.IsAny<UnlinkedConfig>()))
.Returns(Task.CompletedTask);
var callCount = 0;
@@ -532,19 +537,19 @@ public class DownloadCleanerTests : IDisposable
// Act
await ExecuteWithTimeAdvance(sut);
// Assert - PopulateFileCounts should be called exactly once, not once per client
// Assert - PopulateFileCounts is called once per client with ignored root dirs
_fixture.HardLinkFileService.Verify(
x => x.PopulateFileCounts(It.IsAny<IEnumerable<string>>()),
Times.Once
Times.Exactly(2)
);
// Verify both clients had their ChangeCategoryForNoHardLinksAsync called
mockDownloadService1.Verify(
x => x.ChangeCategoryForNoHardLinksAsync(It.IsAny<List<ITorrentItemWrapper>>()),
x => x.ChangeCategoryForNoHardLinksAsync(It.IsAny<List<ITorrentItemWrapper>>(), It.IsAny<UnlinkedConfig>()),
Times.Once
);
mockDownloadService2.Verify(
x => x.ChangeCategoryForNoHardLinksAsync(It.IsAny<List<ITorrentItemWrapper>>()),
x => x.ChangeCategoryForNoHardLinksAsync(It.IsAny<List<ITorrentItemWrapper>>(), It.IsAny<UnlinkedConfig>()),
Times.Once
);
}
@@ -553,13 +558,8 @@ public class DownloadCleanerTests : IDisposable
public async Task ExecuteInternalAsync_WhenUnlinkedEnabled_EvaluatesDownloadsForHardlinks()
{
// Arrange
var downloadCleanerConfig = _fixture.DataContext.DownloadCleanerConfigs.First();
downloadCleanerConfig.UnlinkedEnabled = true;
downloadCleanerConfig.UnlinkedTargetCategory = "unlinked";
downloadCleanerConfig.UnlinkedCategories = ["completed"];
_fixture.DataContext.SaveChanges();
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
TestDataContextFactory.AddUnlinkedConfig(_fixture.DataContext);
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
@@ -567,21 +567,23 @@ public class DownloadCleanerTests : IDisposable
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
mockTorrent.Setup(x => x.Category).Returns("completed");
var dbClient = _fixture.DataContext.DownloadClients.First();
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService.Setup(x => x.ClientConfig).Returns(dbClient);
mockDownloadService
.Setup(x => x.GetSeedingDownloads())
.ReturnsAsync([mockTorrent.Object]);
mockDownloadService
.Setup(x => x.FilterDownloadsToChangeCategoryAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<string>>()
It.IsAny<UnlinkedConfig>()
))
.Returns([mockTorrent.Object]);
mockDownloadService
.Setup(x => x.CreateCategoryAsync(It.IsAny<string>()))
.Returns(Task.CompletedTask);
mockDownloadService
.Setup(x => x.ChangeCategoryForNoHardLinksAsync(It.IsAny<List<ITorrentItemWrapper>>()))
.Setup(x => x.ChangeCategoryForNoHardLinksAsync(It.IsAny<List<ITorrentItemWrapper>>(), It.IsAny<UnlinkedConfig>()))
.Returns(Task.CompletedTask);
_fixture.DownloadServiceFactory
@@ -623,20 +625,22 @@ public class DownloadCleanerTests : IDisposable
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
mockTorrent.Setup(x => x.Category).Returns("completed");
var dbClient = _fixture.DataContext.DownloadClients.First();
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService.Setup(x => x.ClientConfig).Returns(dbClient);
mockDownloadService
.Setup(x => x.GetSeedingDownloads())
.ReturnsAsync([mockTorrent.Object]);
mockDownloadService
.Setup(x => x.FilterDownloadsToBeCleanedAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<SeedingRule>>()
It.IsAny<List<ISeedingRule>>()
))
.Returns([mockTorrent.Object]);
mockDownloadService
.Setup(x => x.CleanDownloadsAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<SeedingRule>>()
It.IsAny<List<ISeedingRule>>()
))
.Returns(Task.CompletedTask);
@@ -688,7 +692,7 @@ public class DownloadCleanerTests : IDisposable
mockDownloadService
.Setup(x => x.FilterDownloadsToBeCleanedAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<SeedingRule>>()
It.IsAny<List<ISeedingRule>>()
))
.Returns([]);
@@ -787,13 +791,8 @@ public class DownloadCleanerTests : IDisposable
public async Task ChangeUnlinkedCategoriesAsync_WhenFilterDownloadsThrows_LogsErrorAndContinues()
{
// Arrange
var downloadCleanerConfig = _fixture.DataContext.DownloadCleanerConfigs.First();
downloadCleanerConfig.UnlinkedEnabled = true;
downloadCleanerConfig.UnlinkedTargetCategory = "unlinked";
downloadCleanerConfig.UnlinkedCategories = ["completed"];
_fixture.DataContext.SaveChanges();
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
TestDataContextFactory.AddUnlinkedConfig(_fixture.DataContext);
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
@@ -801,14 +800,16 @@ public class DownloadCleanerTests : IDisposable
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
mockTorrent.Setup(x => x.Category).Returns("completed");
var dbClient = _fixture.DataContext.DownloadClients.First();
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService.Setup(x => x.ClientConfig).Returns(dbClient);
mockDownloadService
.Setup(x => x.GetSeedingDownloads())
.ReturnsAsync([mockTorrent.Object]);
mockDownloadService
.Setup(x => x.FilterDownloadsToChangeCategoryAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<string>>()
It.IsAny<UnlinkedConfig>()
))
.Throws(new Exception("Filter failed"));
@@ -826,7 +827,7 @@ public class DownloadCleanerTests : IDisposable
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to filter downloads for hardlinks evaluation")),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to process unlinked downloads for")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
@@ -838,13 +839,8 @@ public class DownloadCleanerTests : IDisposable
public async Task ChangeUnlinkedCategoriesAsync_WhenCreateCategoryThrows_LogsErrorAndContinues()
{
// Arrange
var downloadCleanerConfig = _fixture.DataContext.DownloadCleanerConfigs.First();
downloadCleanerConfig.UnlinkedEnabled = true;
downloadCleanerConfig.UnlinkedTargetCategory = "unlinked";
downloadCleanerConfig.UnlinkedCategories = ["completed"];
_fixture.DataContext.SaveChanges();
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
TestDataContextFactory.AddUnlinkedConfig(_fixture.DataContext);
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
@@ -852,14 +848,16 @@ public class DownloadCleanerTests : IDisposable
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
mockTorrent.Setup(x => x.Category).Returns("completed");
var dbClient = _fixture.DataContext.DownloadClients.First();
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService.Setup(x => x.ClientConfig).Returns(dbClient);
mockDownloadService
.Setup(x => x.GetSeedingDownloads())
.ReturnsAsync([mockTorrent.Object]);
mockDownloadService
.Setup(x => x.FilterDownloadsToChangeCategoryAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<string>>()
It.IsAny<UnlinkedConfig>()
))
.Returns([mockTorrent.Object]);
mockDownloadService
@@ -892,13 +890,8 @@ public class DownloadCleanerTests : IDisposable
public async Task ChangeUnlinkedCategoriesAsync_WhenChangeCategoryThrows_LogsErrorAndContinues()
{
// Arrange
var downloadCleanerConfig = _fixture.DataContext.DownloadCleanerConfigs.First();
downloadCleanerConfig.UnlinkedEnabled = true;
downloadCleanerConfig.UnlinkedTargetCategory = "unlinked";
downloadCleanerConfig.UnlinkedCategories = ["completed"];
_fixture.DataContext.SaveChanges();
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
TestDataContextFactory.AddUnlinkedConfig(_fixture.DataContext);
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
@@ -906,21 +899,23 @@ public class DownloadCleanerTests : IDisposable
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
mockTorrent.Setup(x => x.Category).Returns("completed");
var dbClient = _fixture.DataContext.DownloadClients.First();
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService.Setup(x => x.ClientConfig).Returns(dbClient);
mockDownloadService
.Setup(x => x.GetSeedingDownloads())
.ReturnsAsync([mockTorrent.Object]);
mockDownloadService
.Setup(x => x.FilterDownloadsToChangeCategoryAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<string>>()
It.IsAny<UnlinkedConfig>()
))
.Returns([mockTorrent.Object]);
mockDownloadService
.Setup(x => x.CreateCategoryAsync(It.IsAny<string>()))
.Returns(Task.CompletedTask);
mockDownloadService
.Setup(x => x.ChangeCategoryForNoHardLinksAsync(It.IsAny<List<ITorrentItemWrapper>>()))
.Setup(x => x.ChangeCategoryForNoHardLinksAsync(It.IsAny<List<ITorrentItemWrapper>>(), It.IsAny<UnlinkedConfig>()))
.ThrowsAsync(new Exception("Change category failed"));
_fixture.DownloadServiceFactory
@@ -937,7 +932,7 @@ public class DownloadCleanerTests : IDisposable
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to change category for download client")),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to process unlinked downloads for")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
@@ -958,14 +953,16 @@ public class DownloadCleanerTests : IDisposable
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
mockTorrent.Setup(x => x.Category).Returns("completed");
var dbClient = _fixture.DataContext.DownloadClients.First();
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService.Setup(x => x.ClientConfig).Returns(dbClient);
mockDownloadService
.Setup(x => x.GetSeedingDownloads())
.ReturnsAsync([mockTorrent.Object]);
mockDownloadService
.Setup(x => x.FilterDownloadsToBeCleanedAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<SeedingRule>>()
It.IsAny<List<ISeedingRule>>()
))
.Throws(new Exception("Filter failed"));
@@ -983,7 +980,7 @@ public class DownloadCleanerTests : IDisposable
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to filter downloads for cleaning")),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to clean downloads for")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
@@ -1004,20 +1001,22 @@ public class DownloadCleanerTests : IDisposable
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
mockTorrent.Setup(x => x.Category).Returns("completed");
var dbClient = _fixture.DataContext.DownloadClients.First();
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService.Setup(x => x.ClientConfig).Returns(dbClient);
mockDownloadService
.Setup(x => x.GetSeedingDownloads())
.ReturnsAsync([mockTorrent.Object]);
mockDownloadService
.Setup(x => x.FilterDownloadsToBeCleanedAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<SeedingRule>>()
It.IsAny<List<ISeedingRule>>()
))
.Returns([mockTorrent.Object]);
mockDownloadService
.Setup(x => x.CleanDownloadsAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<SeedingRule>>()
It.IsAny<List<ISeedingRule>>()
))
.ThrowsAsync(new Exception("Clean failed"));
@@ -1035,7 +1034,7 @@ public class DownloadCleanerTests : IDisposable
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to clean downloads for download client")),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to clean downloads for")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
@@ -1064,7 +1063,7 @@ public class DownloadCleanerTests : IDisposable
mockDownloadService
.Setup(x => x.FilterDownloadsToBeCleanedAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<SeedingRule>>()
It.IsAny<List<ISeedingRule>>()
))
.Returns([]);
@@ -1109,4 +1108,126 @@ public class DownloadCleanerTests : IDisposable
}
#endregion
#region Per-Client Config Tests
[Fact]
public async Task ExecuteInternalAsync_ClientWithNoSeedingRules_SkipsCleanup()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
// No seeding rules added — only unlinked config disabled
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
mockTorrent.Setup(x => x.Name).Returns("Test Download");
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
mockTorrent.Setup(x => x.Category).Returns("completed");
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.Setup(x => x.GetSeedingDownloads())
.ReturnsAsync([mockTorrent.Object]);
_fixture.DownloadServiceFactory
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
.Returns(mockDownloadService.Object);
var sut = CreateSut();
// Act
await ExecuteWithTimeAdvance(sut);
// Assert - CleanDownloadsAsync should never be called
mockDownloadService.Verify(
x => x.CleanDownloadsAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<ISeedingRule>>()
),
Times.Never
);
}
[Fact]
public async Task ExecuteInternalAsync_ClientWithDisabledUnlinkedConfig_SkipsUnlinkedProcessing()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
TestDataContextFactory.AddUnlinkedConfig(_fixture.DataContext, enabled: false);
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
mockTorrent.Setup(x => x.Name).Returns("Test Download");
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
mockTorrent.Setup(x => x.Category).Returns("completed");
var dbClient = _fixture.DataContext.DownloadClients.First();
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService.Setup(x => x.ClientConfig).Returns(dbClient);
mockDownloadService
.Setup(x => x.GetSeedingDownloads())
.ReturnsAsync([mockTorrent.Object]);
_fixture.DownloadServiceFactory
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
.Returns(mockDownloadService.Object);
var sut = CreateSut();
// Act
await ExecuteWithTimeAdvance(sut);
// Assert - FilterDownloadsToChangeCategoryAsync should never be called
mockDownloadService.Verify(
x => x.FilterDownloadsToChangeCategoryAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<UnlinkedConfig>()
),
Times.Never
);
}
[Fact]
public async Task ExecuteInternalAsync_UnlinkedEnabledButNoCategories_LogsWarning()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
TestDataContextFactory.AddUnlinkedConfig(_fixture.DataContext, enabled: true, categories: []);
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
mockTorrent.Setup(x => x.Name).Returns("Test Download");
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
mockTorrent.Setup(x => x.Category).Returns("completed");
var dbClient = _fixture.DataContext.DownloadClients.First();
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService.Setup(x => x.ClientConfig).Returns(dbClient);
mockDownloadService
.Setup(x => x.GetSeedingDownloads())
.ReturnsAsync([mockTorrent.Object]);
_fixture.DownloadServiceFactory
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
.Returns(mockDownloadService.Object);
var sut = CreateSut();
// Act
await ExecuteWithTimeAdvance(sut);
// Assert - should log warning about no categories
_logger.Verify(
x => x.Log(
LogLevel.Warning,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("no categories are configured")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
}
#endregion
}
@@ -0,0 +1,351 @@
using System.Text.Json;
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Shouldly;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs.Integration;
[Collection(IntegrationTestCollection.Name)]
public class DownloadCleanerIntegrationTests : IDisposable
{
private readonly IntegrationTestFixture _fixture;
public DownloadCleanerIntegrationTests(IntegrationTestFixture fixture)
{
_fixture = fixture;
_fixture.Reset();
}
public void Dispose()
{
Striker.RecurringHashes.Clear();
}
private DownloadCleaner CreateSut()
{
return new DownloadCleaner(
Substitute.For<ILogger<DownloadCleaner>>(),
_fixture.DataContext,
_fixture.Cache,
_fixture.MessageBus,
_fixture.ArrClientFactory,
_fixture.ArrQueueIterator,
_fixture.DownloadServiceFactory,
_fixture.EventPublisher,
_fixture.TimeProvider,
_fixture.HardLinkFileService);
}
/// <summary>
/// Creates a mock download service that uses the actual DB config (so seeding rules match by ID).
/// </summary>
private static IDownloadService CreateMockDownloadServiceWithDbConfig(DownloadClientConfig dbConfig)
{
var mock = Substitute.For<IDownloadService>();
mock.ClientConfig.Returns(dbConfig);
mock.LoginAsync().Returns(Task.CompletedTask);
return mock;
}
[Fact]
public async Task ArrManagedDownloads_AreExcludedFromCleanup()
{
// Arrange
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var downloadClient = TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
TestDataContextFactory.AddSeedingRule(_fixture.DataContext);
string arrManagedHash = "arr_managed_hash_123";
string orphanedHash = "orphaned_hash_456";
var arrManagedDownload = CreateMockTorrentItem(arrManagedHash, "Managed.Show.S01E01");
var orphanedDownload = CreateMockTorrentItem(orphanedHash, "Orphaned.Movie.2024");
var mockDownloadService = CreateMockDownloadServiceWithDbConfig(downloadClient);
mockDownloadService.GetSeedingDownloads()
.Returns([arrManagedDownload, orphanedDownload]);
_fixture.DownloadServiceFactory.GetDownloadService(Arg.Any<DownloadClientConfig>())
.Returns(mockDownloadService);
// Setup arr queue iterator to return the arr-managed hash
var queueRecord = new QueueRecord
{
Id = 1,
Title = "Managed.Show.S01E01",
Protocol = "torrent",
DownloadId = arrManagedHash
};
_fixture.SetupArrQueueIterator(queueRecord);
var sut = CreateSut();
// Act - advance time past the 10s delay
var executeTask = sut.ExecuteAsync();
_fixture.TimeProvider.Advance(TimeSpan.FromSeconds(15));
await executeTask;
// Assert: Only the orphaned download should be passed to filter/clean
mockDownloadService.Received().FilterDownloadsToBeCleanedAsync(
Arg.Is<List<ITorrentItemWrapper>>(list =>
list.Count == 1 && list[0].Hash == orphanedHash),
Arg.Any<List<ISeedingRule>>());
}
[Fact]
public async Task IgnoredDownloads_AreExcludedFromCleanup()
{
// Arrange
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var downloadClient = TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
TestDataContextFactory.AddSeedingRule(_fixture.DataContext);
// Add a download name to the ignored list
var generalConfig = await _fixture.DataContext.GeneralConfigs.FirstAsync();
generalConfig.IgnoredDownloads.Add("ignored_download");
await _fixture.DataContext.SaveChangesAsync();
var ignoredDownload = CreateMockTorrentItem("some_hash", "ignored_download");
var normalDownload = CreateMockTorrentItem("normal_hash", "Normal.Movie.2024");
var mockDownloadService = CreateMockDownloadServiceWithDbConfig(downloadClient);
mockDownloadService.GetSeedingDownloads()
.Returns([ignoredDownload, normalDownload]);
_fixture.DownloadServiceFactory.GetDownloadService(Arg.Any<DownloadClientConfig>())
.Returns(mockDownloadService);
// No arr-managed downloads
_fixture.ArrQueueIterator.Iterate(
Arg.Any<IArrClient>(), Arg.Any<ArrInstance>(),
Arg.Any<Func<IReadOnlyList<QueueRecord>, Task>>())
.Returns(ci =>
{
var callback = ci.Arg<Func<IReadOnlyList<QueueRecord>, Task>>();
return callback(Array.Empty<QueueRecord>());
});
var sut = CreateSut();
// Act
var executeTask = sut.ExecuteAsync();
_fixture.TimeProvider.Advance(TimeSpan.FromSeconds(15));
await executeTask;
// Assert: Only the non-ignored download should be processed
mockDownloadService.Received().FilterDownloadsToBeCleanedAsync(
Arg.Is<List<ITorrentItemWrapper>>(list =>
list.Count == 1 && list[0].Hash == "normal_hash"),
Arg.Any<List<ISeedingRule>>());
}
[Fact]
public async Task NoDownloadClients_ExitsEarly()
{
// Arrange: No download clients configured (default seed has none)
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert: No download service interactions, no events
_fixture.DownloadServiceFactory.DidNotReceive()
.GetDownloadService(Arg.Any<DownloadClientConfig>());
var events = await _fixture.EventsContext.Events.ToListAsync();
events.ShouldBeEmpty();
}
[Fact]
public async Task CleanedDownload_PublishesDownloadCleanedEvent()
{
// Arrange
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var downloadClient = TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
TestDataContextFactory.AddSeedingRule(_fixture.DataContext);
var torrent = CreateMockTorrentItem("cleaned_hash_abc", "Completed.Movie.2024");
var mockDownloadService = CreateMockDownloadServiceWithDbConfig(downloadClient);
mockDownloadService.GetSeedingDownloads().Returns([torrent]);
mockDownloadService.FilterDownloadsToBeCleanedAsync(
Arg.Any<List<ITorrentItemWrapper>>(), Arg.Any<List<ISeedingRule>>())
.Returns(ci => ci.Arg<List<ITorrentItemWrapper>>());
// Configure CleanDownloadsAsync to simulate what real DownloadService does:
// set ContextProvider keys and call real EventPublisher
mockDownloadService.CleanDownloadsAsync(
Arg.Any<List<ITorrentItemWrapper>>(), Arg.Any<List<ISeedingRule>>())
.Returns(async ci =>
{
ContextProvider.Set(ContextProvider.Keys.ItemName, "Completed.Movie.2024");
ContextProvider.Set(ContextProvider.Keys.Hash, "cleaned_hash_abc");
ContextProvider.Set(ContextProvider.Keys.DownloadClientUrl, downloadClient.ExternalOrInternalUrl);
ContextProvider.Set(ContextProvider.Keys.DownloadClientId, downloadClient.Id);
ContextProvider.Set(ContextProvider.Keys.DownloadClientType, downloadClient.TypeName);
ContextProvider.Set(ContextProvider.Keys.DownloadClientName, downloadClient.Name);
await _fixture.EventPublisher.PublishDownloadCleaned(
1.5, TimeSpan.FromHours(24), "completed", CleanReason.MaxRatioReached);
});
_fixture.DownloadServiceFactory.GetDownloadService(Arg.Any<DownloadClientConfig>())
.Returns(mockDownloadService);
// No arr-managed downloads
_fixture.ArrQueueIterator.Iterate(
Arg.Any<IArrClient>(), Arg.Any<ArrInstance>(),
Arg.Any<Func<IReadOnlyList<QueueRecord>, Task>>())
.Returns(ci =>
{
var callback = ci.Arg<Func<IReadOnlyList<QueueRecord>, Task>>();
return callback(Array.Empty<QueueRecord>());
});
var sut = CreateSut();
// Act
var executeTask = sut.ExecuteAsync();
_fixture.TimeProvider.Advance(TimeSpan.FromSeconds(15));
await executeTask;
// Assert: Full DownloadCleaned event property verification
var events = await _fixture.EventsContext.Events.ToListAsync();
events.Count.ShouldBe(1);
var cleanedEvent = events.First(e => e.EventType == EventType.DownloadCleaned);
cleanedEvent.Message.ShouldBe("Cleaned item from download client with reason: MaxRatioReached");
cleanedEvent.Severity.ShouldBe(EventSeverity.Important);
cleanedEvent.JobRunId.ShouldBe(_fixture.JobRunId);
cleanedEvent.ArrInstanceId.ShouldBeNull();
cleanedEvent.DownloadClientId.ShouldBe(downloadClient.Id);
cleanedEvent.IsDryRun.ShouldBe(false);
cleanedEvent.StrikeId.ShouldBeNull();
cleanedEvent.TrackingId.ShouldBeNull();
cleanedEvent.SearchStatus.ShouldBeNull();
cleanedEvent.CompletedAt.ShouldBeNull();
cleanedEvent.CycleId.ShouldBeNull();
cleanedEvent.Data.ShouldNotBeNull();
using (var data = JsonDocument.Parse(cleanedEvent.Data!))
{
data.RootElement.GetProperty("itemName").GetString().ShouldBe("Completed.Movie.2024");
data.RootElement.GetProperty("hash").GetString().ShouldBe("cleaned_hash_abc");
data.RootElement.GetProperty("categoryName").GetString().ShouldBe("completed");
data.RootElement.GetProperty("ratio").GetDouble().ShouldBe(1.5);
data.RootElement.GetProperty("seedingTime").GetDouble().ShouldBe(24.0);
data.RootElement.GetProperty("reason").GetString().ShouldBe("MaxRatioReached");
}
// Assert: Notification sent
await _fixture.NotificationPublisher.Received(1)
.NotifyDownloadCleaned(1.5, TimeSpan.FromHours(24), "completed", CleanReason.MaxRatioReached);
}
[Fact]
public async Task UnlinkedDownload_PublishesCategoryChangedEvent()
{
// Arrange
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var downloadClient = TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
TestDataContextFactory.AddUnlinkedConfig(_fixture.DataContext,
enabled: true, targetCategory: "unlinked", categories: ["completed"]);
var torrent = CreateMockTorrentItem("unlinked_hash_xyz", "NoLinks.Movie.2024", category: "completed");
var mockDownloadService = CreateMockDownloadServiceWithDbConfig(downloadClient);
mockDownloadService.GetSeedingDownloads().Returns([torrent]);
mockDownloadService.FilterDownloadsToChangeCategoryAsync(
Arg.Any<List<ITorrentItemWrapper>>(), Arg.Any<UnlinkedConfig>())
.Returns(ci => ci.Arg<List<ITorrentItemWrapper>>());
// Configure ChangeCategoryForNoHardLinksAsync to simulate what real DownloadService does
mockDownloadService.ChangeCategoryForNoHardLinksAsync(
Arg.Any<List<ITorrentItemWrapper>>(), Arg.Any<UnlinkedConfig>())
.Returns(async ci =>
{
ContextProvider.Set(ContextProvider.Keys.ItemName, "NoLinks.Movie.2024");
ContextProvider.Set(ContextProvider.Keys.Hash, "unlinked_hash_xyz");
ContextProvider.Set(ContextProvider.Keys.DownloadClientUrl, downloadClient.ExternalOrInternalUrl);
ContextProvider.Set(ContextProvider.Keys.DownloadClientId, downloadClient.Id);
ContextProvider.Set(ContextProvider.Keys.DownloadClientType, downloadClient.TypeName);
ContextProvider.Set(ContextProvider.Keys.DownloadClientName, downloadClient.Name);
await _fixture.EventPublisher.PublishCategoryChanged("completed", "unlinked");
});
_fixture.DownloadServiceFactory.GetDownloadService(Arg.Any<DownloadClientConfig>())
.Returns(mockDownloadService);
// No arr-managed downloads
_fixture.ArrQueueIterator.Iterate(
Arg.Any<IArrClient>(), Arg.Any<ArrInstance>(),
Arg.Any<Func<IReadOnlyList<QueueRecord>, Task>>())
.Returns(ci =>
{
var callback = ci.Arg<Func<IReadOnlyList<QueueRecord>, Task>>();
return callback(Array.Empty<QueueRecord>());
});
var sut = CreateSut();
// Act
var executeTask = sut.ExecuteAsync();
_fixture.TimeProvider.Advance(TimeSpan.FromSeconds(15));
await executeTask;
// Assert: Full CategoryChanged event property verification
var events = await _fixture.EventsContext.Events.ToListAsync();
events.Count.ShouldBe(1);
var categoryEvent = events.First(e => e.EventType == EventType.CategoryChanged);
categoryEvent.Message.ShouldBe("Category changed from 'completed' to 'unlinked'");
categoryEvent.Severity.ShouldBe(EventSeverity.Information);
categoryEvent.JobRunId.ShouldBe(_fixture.JobRunId);
categoryEvent.ArrInstanceId.ShouldBeNull();
categoryEvent.DownloadClientId.ShouldBe(downloadClient.Id);
categoryEvent.IsDryRun.ShouldBe(false);
categoryEvent.StrikeId.ShouldBeNull();
categoryEvent.TrackingId.ShouldBeNull();
categoryEvent.SearchStatus.ShouldBeNull();
categoryEvent.CompletedAt.ShouldBeNull();
categoryEvent.CycleId.ShouldBeNull();
categoryEvent.Data.ShouldNotBeNull();
using (var data = JsonDocument.Parse(categoryEvent.Data!))
{
data.RootElement.GetProperty("itemName").GetString().ShouldBe("NoLinks.Movie.2024");
data.RootElement.GetProperty("hash").GetString().ShouldBe("unlinked_hash_xyz");
data.RootElement.GetProperty("oldCategory").GetString().ShouldBe("completed");
data.RootElement.GetProperty("newCategory").GetString().ShouldBe("unlinked");
data.RootElement.GetProperty("isTag").GetBoolean().ShouldBe(false);
}
// Assert: Notification sent
await _fixture.NotificationPublisher.Received(1)
.NotifyCategoryChanged("completed", "unlinked", false);
}
private static ITorrentItemWrapper CreateMockTorrentItem(string hash, string name, string? category = null)
{
var mock = Substitute.For<ITorrentItemWrapper>();
mock.Hash.Returns(hash);
mock.Name.Returns(name);
mock.Category.Returns(category);
mock.IsIgnored(Arg.Any<List<string>>()).Returns(ci =>
{
var ignoredList = ci.Arg<List<string>>();
return ignoredList.Contains(name, StringComparer.InvariantCultureIgnoreCase);
});
return mock;
}
}
@@ -0,0 +1,9 @@
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs.Integration;
[CollectionDefinition(Name)]
public class IntegrationTestCollection : ICollectionFixture<IntegrationTestFixture>
{
public const string Name = "JobHandlerIntegration";
}
@@ -0,0 +1,279 @@
using Cleanuparr.Domain.Entities.Arr;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Events.Interfaces;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.DownloadRemover;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Models;
using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Infrastructure.Hubs;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.State;
using MassTransit;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Time.Testing;
using NSubstitute;
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs.Integration;
/// <summary>
/// Shared fixture for integration tests that wires up real services (EventPublisher, QueueItemRemover)
/// with NSubstitute mocks at external boundaries (Arr clients, download clients, notifications).
/// </summary>
public class IntegrationTestFixture : IDisposable
{
// Real services
public DataContext DataContext { get; private set; }
public EventsContext EventsContext { get; private set; }
public MemoryCache Cache { get; private set; }
public EventPublisher EventPublisher { get; private set; } = null!;
public QueueItemRemover QueueItemRemover { get; private set; } = null!;
public Striker Striker { get; private set; } = null!;
public FakeTimeProvider TimeProvider { get; private set; }
// Mocks
public IBus MessageBus { get; private set; }
public IArrClientFactory ArrClientFactory { get; private set; }
public IArrClient ArrClient { get; private set; }
public IArrQueueIterator ArrQueueIterator { get; private set; }
public IDownloadServiceFactory DownloadServiceFactory { get; private set; }
public IBlocklistProvider BlocklistProvider { get; private set; }
public IHardLinkFileService HardLinkFileService { get; private set; }
public INotificationPublisher NotificationPublisher { get; private set; }
public IDryRunInterceptor DryRunInterceptor { get; private set; }
public IEventPublisher EventPublisherInterface { get; private set; } = null!;
public IHubContext<AppHub> HubContext { get; private set; }
// State
public Guid JobRunId { get; private set; }
public List<object> CapturedMessages { get; } = [];
public IntegrationTestFixture()
{
DataContext = TestDataContextFactory.Create();
EventsContext = TestEventsContextFactory.Create();
Cache = new MemoryCache(new MemoryCacheOptions());
TimeProvider = new FakeTimeProvider();
MessageBus = Substitute.For<IBus>();
ArrClientFactory = Substitute.For<IArrClientFactory>();
ArrClient = Substitute.For<IArrClient>();
ArrQueueIterator = Substitute.For<IArrQueueIterator>();
DownloadServiceFactory = Substitute.For<IDownloadServiceFactory>();
BlocklistProvider = Substitute.For<IBlocklistProvider>();
HardLinkFileService = Substitute.For<IHardLinkFileService>();
NotificationPublisher = Substitute.For<INotificationPublisher>();
DryRunInterceptor = Substitute.For<IDryRunInterceptor>();
HubContext = CreateMockHubContext();
SetupDefaults();
BuildRealServices();
}
private void SetupDefaults()
{
// ArrClientFactory returns the shared ArrClient mock by default
ArrClientFactory.GetClient(Arg.Any<InstanceType>(), Arg.Any<float>()).Returns(ArrClient);
// DryRunInterceptor returns false (not dry run) by default
DryRunInterceptor.IsDryRunEnabled().Returns(false);
DryRunInterceptor.InterceptAsync(Arg.Any<Delegate>(), Arg.Any<object[]>()).Returns(Task.CompletedTask);
// Capture messages published to IBus (generic Publish<T> overloads)
MessageBus.Publish(Arg.Any<QueueItemRemoveRequest<SearchItem>>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask)
.AndDoes(ci => CapturedMessages.Add(ci[0]));
MessageBus.Publish(Arg.Any<QueueItemRemoveRequest<SeriesSearchItem>>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask)
.AndDoes(ci => CapturedMessages.Add(ci[0]));
// Seed a JobRun so EventPublisher FK constraints are satisfied
JobRunId = Guid.NewGuid();
EventsContext.JobRuns.Add(new JobRun { Id = JobRunId, Type = JobType.QueueCleaner });
EventsContext.SaveChanges();
ContextProvider.SetJobRunId(JobRunId);
}
private void BuildRealServices()
{
EventPublisher = new EventPublisher(
EventsContext,
HubContext,
Substitute.For<ILogger<EventPublisher>>(),
NotificationPublisher,
DryRunInterceptor);
// Expose EventPublisher as both concrete and interface
EventPublisherInterface = EventPublisher;
Striker = new Striker(
Substitute.For<ILogger<Striker>>(),
EventsContext,
EventPublisher,
DryRunInterceptor);
QueueItemRemover = new QueueItemRemover(
Substitute.For<ILogger<QueueItemRemover>>(),
Cache,
ArrClientFactory,
EventPublisher,
EventsContext,
DataContext);
}
/// <summary>
/// Gets distinct remove requests from captured messages (NSubstitute may capture duplicates
/// when both generic type setups match).
/// </summary>
public List<object> GetCapturedRemoveRequests()
{
return CapturedMessages
.Where(m => m is QueueItemRemoveRequest<SearchItem> or QueueItemRemoveRequest<SeriesSearchItem>)
.DistinctBy(m => m switch
{
QueueItemRemoveRequest<SearchItem> r => r.Record.DownloadId,
QueueItemRemoveRequest<SeriesSearchItem> r => r.Record.DownloadId,
_ => ""
})
.ToList();
}
/// <summary>
/// Processes all captured IBus messages through the real QueueItemRemover pipeline.
/// This simulates what MassTransit consumers would do. Deduplicates to handle
/// NSubstitute's generic type matching behavior.
/// </summary>
public async Task ProcessCapturedRemoveRequestsAsync()
{
foreach (var message in GetCapturedRemoveRequests())
{
switch (message)
{
case QueueItemRemoveRequest<SearchItem> request:
await QueueItemRemover.RemoveQueueItemAsync(request);
break;
case QueueItemRemoveRequest<SeriesSearchItem> request:
await QueueItemRemover.RemoveQueueItemAsync(request);
break;
}
}
}
/// <summary>
/// Configures the IArrQueueIterator to invoke the callback with the given records
/// when Iterate is called for any instance.
/// </summary>
public void SetupArrQueueIterator(params QueueRecord[] records)
{
ArrQueueIterator.Iterate(
Arg.Any<IArrClient>(),
Arg.Any<ArrInstance>(),
Arg.Any<Func<IReadOnlyList<QueueRecord>, Task>>())
.Returns(ci =>
{
var callback = ci.Arg<Func<IReadOnlyList<QueueRecord>, Task>>();
return callback(records);
});
}
/// <summary>
/// Creates a NSubstitute IDownloadService mock with default configuration.
/// </summary>
public IDownloadService CreateMockDownloadService(
string clientName = "Test qBittorrent",
DownloadClientTypeName typeName = DownloadClientTypeName.qBittorrent,
DownloadClientType type = DownloadClientType.Torrent)
{
var mock = Substitute.For<IDownloadService>();
mock.ClientConfig.Returns(new DownloadClientConfig
{
Id = Guid.NewGuid(),
Name = clientName,
TypeName = typeName,
Type = type,
Enabled = true,
Host = new Uri("http://localhost:8080"),
Username = "admin",
Password = "admin"
});
mock.LoginAsync().Returns(Task.CompletedTask);
return mock;
}
/// <summary>
/// Registers mock download services with the factory, matched by their ClientConfig.
/// </summary>
public void SetupDownloadServices(params IDownloadService[] services)
{
foreach (var service in services)
{
DownloadServiceFactory.GetDownloadService(service.ClientConfig).Returns(service);
}
}
/// <summary>
/// Recreates DataContext, EventsContext, cache, and resets all mocks for a clean test.
/// </summary>
public void Reset()
{
DataContext?.Dispose();
EventsContext?.Dispose();
Cache?.Dispose();
DataContext = TestDataContextFactory.Create();
EventsContext = TestEventsContextFactory.Create();
Cache = new MemoryCache(new MemoryCacheOptions());
TimeProvider = new FakeTimeProvider();
CapturedMessages.Clear();
// Recreate all NSubstitute mocks to clear received call state
MessageBus = Substitute.For<IBus>();
ArrClientFactory = Substitute.For<IArrClientFactory>();
ArrClient = Substitute.For<IArrClient>();
ArrQueueIterator = Substitute.For<IArrQueueIterator>();
DownloadServiceFactory = Substitute.For<IDownloadServiceFactory>();
BlocklistProvider = Substitute.For<IBlocklistProvider>();
HardLinkFileService = Substitute.For<IHardLinkFileService>();
NotificationPublisher = Substitute.For<INotificationPublisher>();
DryRunInterceptor = Substitute.For<IDryRunInterceptor>();
HubContext = CreateMockHubContext();
// Re-setup defaults and rebuild real services
SetupDefaults();
BuildRealServices();
// Clear static state
Striker.RecurringHashes.Clear();
}
private static IHubContext<AppHub> CreateMockHubContext()
{
var hubContext = Substitute.For<IHubContext<AppHub>>();
var clients = Substitute.For<IHubClients>();
var clientProxy = Substitute.For<IClientProxy>();
clients.All.Returns(clientProxy);
hubContext.Clients.Returns(clients);
return hubContext;
}
public void Dispose()
{
DataContext?.Dispose();
EventsContext?.Dispose();
Cache?.Dispose();
Striker.RecurringHashes.Clear();
GC.SuppressFinalize(this);
}
}
@@ -0,0 +1,268 @@
using System.Text.Json;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Shouldly;
using Xunit;
using MalwareBlockerJob = Cleanuparr.Infrastructure.Features.Jobs.MalwareBlocker;
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs.Integration;
[Collection(IntegrationTestCollection.Name)]
public class MalwareBlockerIntegrationTests : IDisposable
{
private readonly IntegrationTestFixture _fixture;
public MalwareBlockerIntegrationTests(IntegrationTestFixture fixture)
{
_fixture = fixture;
_fixture.Reset();
}
public void Dispose()
{
Striker.RecurringHashes.Clear();
}
private MalwareBlockerJob CreateSut()
{
return new MalwareBlockerJob(
Substitute.For<ILogger<MalwareBlockerJob>>(),
_fixture.DataContext,
_fixture.Cache,
_fixture.MessageBus,
_fixture.ArrClientFactory,
_fixture.ArrQueueIterator,
_fixture.DownloadServiceFactory,
_fixture.BlocklistProvider,
_fixture.EventPublisher);
}
[Fact]
public async Task MalwareDetected_RemovesFromArr_SavesEvent_SendsNotification()
{
// Arrange
var instance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
var downloadClient = TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
// Enable Radarr blocklist
var contentBlockerConfig = await _fixture.DataContext.ContentBlockerConfigs.FirstAsync();
contentBlockerConfig.Radarr = new BlocklistSettings { Enabled = true };
await _fixture.DataContext.SaveChangesAsync();
var record = CreateQueueRecord(movieId: 77);
_fixture.SetupArrQueueIterator(record);
_fixture.ArrClient.IsRecordValid(Arg.Any<QueueRecord>()).Returns(true);
_fixture.ArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService.BlockUnwantedFilesAsync(Arg.Any<string>(), Arg.Any<IReadOnlyList<string>>())
.Returns(new BlockFilesResult
{
Found = true,
ShouldRemove = true,
DeleteReason = DeleteReason.AllFilesBlocked,
IsPrivate = false
});
_fixture.DownloadServiceFactory.GetDownloadService(Arg.Any<Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig>())
.Returns(mockDownloadService);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert Phase 1: Remove request was published
var removeRequests = _fixture.GetCapturedRemoveRequests();
removeRequests.Count.ShouldBe(1);
// Process through real QueueItemRemover
_fixture.ArrClient.DeleteQueueItemAsync(
Arg.Any<ArrInstance>(), Arg.Any<QueueRecord>(), Arg.Any<bool>(), Arg.Any<DeleteReason>())
.Returns(Task.CompletedTask);
await _fixture.ProcessCapturedRemoveRequestsAsync();
// Assert: Arr client was told to delete with AllFilesBlocked reason
await _fixture.ArrClient.Received(1).DeleteQueueItemAsync(
Arg.Is<ArrInstance>(i => i.Id == instance.Id),
Arg.Is<QueueRecord>(r => r.DownloadId == record.DownloadId),
true,
DeleteReason.AllFilesBlocked);
// Assert: Full event property verification
var events = await _fixture.EventsContext.Events.ToListAsync();
events.Count.ShouldBe(2);
// DownloadMarkedForDeletion event
var markedEvent = events.First(e => e.EventType == EventType.DownloadMarkedForDeletion);
markedEvent.Message.ShouldBe("Download marked for deletion");
markedEvent.Severity.ShouldBe(EventSeverity.Important);
markedEvent.JobRunId.ShouldBe(_fixture.JobRunId);
markedEvent.ArrInstanceId.ShouldBe(instance.Id);
markedEvent.DownloadClientId.ShouldBe(mockDownloadService.ClientConfig.Id);
markedEvent.IsDryRun.ShouldBe(false);
markedEvent.StrikeId.ShouldBeNull();
markedEvent.TrackingId.ShouldBeNull();
markedEvent.SearchStatus.ShouldBeNull();
markedEvent.CompletedAt.ShouldBeNull();
markedEvent.CycleId.ShouldBeNull();
markedEvent.Data.ShouldNotBeNull();
using (var markedData = JsonDocument.Parse(markedEvent.Data!))
{
markedData.RootElement.GetProperty("itemName").GetString().ShouldBe("Suspicious.Movie.2024.1080p");
markedData.RootElement.GetProperty("hash").GetString().ShouldBe("MALWARE_HASH_789");
}
// QueueItemDeleted event
var deletedEvent = events.First(e => e.EventType == EventType.QueueItemDeleted);
deletedEvent.Message.ShouldBe("Deleting item from queue with reason: AllFilesBlocked");
deletedEvent.Severity.ShouldBe(EventSeverity.Important);
deletedEvent.JobRunId.ShouldBe(_fixture.JobRunId);
deletedEvent.ArrInstanceId.ShouldBe(instance.Id);
deletedEvent.DownloadClientId.ShouldBe(mockDownloadService.ClientConfig.Id);
deletedEvent.IsDryRun.ShouldBe(false);
deletedEvent.StrikeId.ShouldBeNull();
deletedEvent.TrackingId.ShouldBeNull();
deletedEvent.SearchStatus.ShouldBeNull();
deletedEvent.CompletedAt.ShouldBeNull();
deletedEvent.CycleId.ShouldBeNull();
deletedEvent.Data.ShouldNotBeNull();
using (var deletedData = JsonDocument.Parse(deletedEvent.Data!))
{
deletedData.RootElement.GetProperty("itemName").GetString().ShouldBe("Suspicious.Movie.2024.1080p");
deletedData.RootElement.GetProperty("hash").GetString().ShouldBe("MALWARE_HASH_789");
deletedData.RootElement.GetProperty("removeFromClient").GetBoolean().ShouldBe(true);
deletedData.RootElement.GetProperty("deleteReason").GetString().ShouldBe("AllFilesBlocked");
}
// Assert: Notification sent
await _fixture.NotificationPublisher.Received(1)
.NotifyQueueItemDeleted(true, DeleteReason.AllFilesBlocked);
// Assert: Search queue item added for replacement search
var searchItems = await _fixture.DataContext.SearchQueue.ToListAsync();
searchItems.Count.ShouldBe(1);
searchItems[0].ItemId.ShouldBe(77);
}
[Fact]
public async Task NoBlocklistsEnabled_ExitsEarly_NothingProcessed()
{
// Arrange: Default seed data has all blocklists disabled
TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert: Blocklists were never loaded, no processing happened
await _fixture.BlocklistProvider.DidNotReceive().LoadBlocklistsAsync();
_fixture.GetCapturedRemoveRequests().ShouldBeEmpty();
var events = await _fixture.EventsContext.Events.ToListAsync();
events.ShouldBeEmpty();
}
[Fact]
public async Task PrivateTorrent_DeletePrivateFalse_RemoveFromClientIsFalse()
{
// Arrange
var instance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
// Enable Radarr blocklist, but keep DeletePrivate = false (default)
var contentBlockerConfig = await _fixture.DataContext.ContentBlockerConfigs.FirstAsync();
contentBlockerConfig.Radarr = new BlocklistSettings { Enabled = true };
contentBlockerConfig.DeletePrivate = false;
await _fixture.DataContext.SaveChangesAsync();
var record = CreateQueueRecord(movieId: 88);
_fixture.SetupArrQueueIterator(record);
_fixture.ArrClient.IsRecordValid(Arg.Any<QueueRecord>()).Returns(true);
_fixture.ArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService.BlockUnwantedFilesAsync(Arg.Any<string>(), Arg.Any<IReadOnlyList<string>>())
.Returns(new BlockFilesResult
{
Found = true,
ShouldRemove = true,
DeleteReason = DeleteReason.AllFilesBlocked,
IsPrivate = true
});
_fixture.DownloadServiceFactory.GetDownloadService(Arg.Any<Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig>())
.Returns(mockDownloadService);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert: Remove request has RemoveFromClient = false
_fixture.GetCapturedRemoveRequests().Count.ShouldBe(1);
_fixture.ArrClient.DeleteQueueItemAsync(
Arg.Any<ArrInstance>(), Arg.Any<QueueRecord>(), Arg.Any<bool>(), Arg.Any<DeleteReason>())
.Returns(Task.CompletedTask);
await _fixture.ProcessCapturedRemoveRequestsAsync();
await _fixture.ArrClient.Received(1).DeleteQueueItemAsync(
Arg.Any<ArrInstance>(),
Arg.Any<QueueRecord>(),
false,
DeleteReason.AllFilesBlocked);
// Full event property verification
var events = await _fixture.EventsContext.Events.ToListAsync();
var deletedEvent = events.First(e => e.EventType == EventType.QueueItemDeleted);
deletedEvent.Message.ShouldBe("Deleting item from queue with reason: AllFilesBlocked");
deletedEvent.Severity.ShouldBe(EventSeverity.Important);
deletedEvent.JobRunId.ShouldBe(_fixture.JobRunId);
deletedEvent.ArrInstanceId.ShouldBe(instance.Id);
deletedEvent.IsDryRun.ShouldBe(false);
deletedEvent.StrikeId.ShouldBeNull();
deletedEvent.SearchStatus.ShouldBeNull();
deletedEvent.Data.ShouldNotBeNull();
using (var data = JsonDocument.Parse(deletedEvent.Data!))
{
data.RootElement.GetProperty("itemName").GetString().ShouldBe("Suspicious.Movie.2024.1080p");
data.RootElement.GetProperty("hash").GetString().ShouldBe("MALWARE_HASH_789");
data.RootElement.GetProperty("removeFromClient").GetBoolean().ShouldBe(false);
data.RootElement.GetProperty("deleteReason").GetString().ShouldBe("AllFilesBlocked");
}
await _fixture.NotificationPublisher.Received(1)
.NotifyQueueItemDeleted(false, DeleteReason.AllFilesBlocked);
}
private static QueueRecord CreateQueueRecord(
long movieId = 1,
string downloadId = "MALWARE_HASH_789",
string title = "Suspicious.Movie.2024.1080p")
{
return new QueueRecord
{
Id = 1,
Title = title,
Protocol = "torrent",
DownloadId = downloadId,
MovieId = movieId,
Status = "warning",
StatusMessages = []
};
}
}
@@ -0,0 +1,332 @@
using System.Text.Json;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Shouldly;
using Xunit;
using QueueCleanerJob = Cleanuparr.Infrastructure.Features.Jobs.QueueCleaner;
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs.Integration;
[Collection(IntegrationTestCollection.Name)]
public class QueueCleanerIntegrationTests : IDisposable
{
private readonly IntegrationTestFixture _fixture;
public QueueCleanerIntegrationTests(IntegrationTestFixture fixture)
{
_fixture = fixture;
_fixture.Reset();
}
public void Dispose()
{
Striker.RecurringHashes.Clear();
}
private QueueCleanerJob CreateSut()
{
return new QueueCleanerJob(
Substitute.For<ILogger<QueueCleanerJob>>(),
_fixture.DataContext,
_fixture.Cache,
_fixture.MessageBus,
_fixture.ArrClientFactory,
_fixture.ArrQueueIterator,
_fixture.DownloadServiceFactory,
_fixture.EventPublisher);
}
[Fact]
public async Task StalledTorrent_RemovesFromArr_SavesEvent_SendsNotification_AddsToSearchQueue()
{
// Arrange
var instance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
var downloadClient = TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
TestDataContextFactory.AddStallRule(_fixture.DataContext);
var record = CreateQueueRecord(movieId: 42);
_fixture.SetupArrQueueIterator(record);
_fixture.ArrClient.IsRecordValid(Arg.Any<QueueRecord>()).Returns(true);
_fixture.ArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService.ShouldRemoveFromArrQueueAsync(Arg.Any<string>(), Arg.Any<IReadOnlyList<string>>())
.Returns(new DownloadCheckResult
{
ShouldRemove = true,
Found = true,
DeleteReason = DeleteReason.Stalled,
IsPrivate = false
});
_fixture.DownloadServiceFactory.GetDownloadService(Arg.Any<Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig>())
.Returns(mockDownloadService);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert Phase 1: IBus received a remove request
var removeRequests = _fixture.GetCapturedRemoveRequests();
removeRequests.Count.ShouldBe(1);
// Process the captured messages through the real QueueItemRemover pipeline
_fixture.ArrClient.DeleteQueueItemAsync(
Arg.Any<ArrInstance>(), Arg.Any<QueueRecord>(), Arg.Any<bool>(), Arg.Any<DeleteReason>())
.Returns(Task.CompletedTask);
await _fixture.ProcessCapturedRemoveRequestsAsync();
// Assert Phase 2: Arr client was told to delete the item
await _fixture.ArrClient.Received(1).DeleteQueueItemAsync(
Arg.Is<ArrInstance>(i => i.Id == instance.Id),
Arg.Is<QueueRecord>(r => r.DownloadId == record.DownloadId),
true,
DeleteReason.Stalled);
// Assert Phase 3: Events persisted with full property verification
var events = await _fixture.EventsContext.Events.ToListAsync();
events.Count.ShouldBe(2);
// DownloadMarkedForDeletion event
var markedEvent = events.First(e => e.EventType == EventType.DownloadMarkedForDeletion);
markedEvent.Message.ShouldBe("Download marked for deletion");
markedEvent.Severity.ShouldBe(EventSeverity.Important);
markedEvent.JobRunId.ShouldBe(_fixture.JobRunId);
markedEvent.ArrInstanceId.ShouldBe(instance.Id);
markedEvent.DownloadClientId.ShouldBe(mockDownloadService.ClientConfig.Id);
markedEvent.IsDryRun.ShouldBe(false);
markedEvent.StrikeId.ShouldBeNull();
markedEvent.TrackingId.ShouldBeNull();
markedEvent.SearchStatus.ShouldBeNull();
markedEvent.CompletedAt.ShouldBeNull();
markedEvent.CycleId.ShouldBeNull();
markedEvent.Data.ShouldNotBeNull();
using (var markedData = JsonDocument.Parse(markedEvent.Data!))
{
markedData.RootElement.GetProperty("itemName").GetString().ShouldBe("Test.Movie.2024.1080p");
markedData.RootElement.GetProperty("hash").GetString().ShouldBe("ABC123DEF456");
}
// QueueItemDeleted event
var deletedEvent = events.First(e => e.EventType == EventType.QueueItemDeleted);
deletedEvent.Message.ShouldBe("Deleting item from queue with reason: Stalled");
deletedEvent.Severity.ShouldBe(EventSeverity.Important);
deletedEvent.JobRunId.ShouldBe(_fixture.JobRunId);
deletedEvent.ArrInstanceId.ShouldBe(instance.Id);
deletedEvent.DownloadClientId.ShouldBe(mockDownloadService.ClientConfig.Id);
deletedEvent.IsDryRun.ShouldBe(false);
deletedEvent.StrikeId.ShouldBeNull();
deletedEvent.TrackingId.ShouldBeNull();
deletedEvent.SearchStatus.ShouldBeNull();
deletedEvent.CompletedAt.ShouldBeNull();
deletedEvent.CycleId.ShouldBeNull();
deletedEvent.Data.ShouldNotBeNull();
using (var deletedData = JsonDocument.Parse(deletedEvent.Data!))
{
deletedData.RootElement.GetProperty("itemName").GetString().ShouldBe("Test.Movie.2024.1080p");
deletedData.RootElement.GetProperty("hash").GetString().ShouldBe("ABC123DEF456");
deletedData.RootElement.GetProperty("removeFromClient").GetBoolean().ShouldBe(true);
deletedData.RootElement.GetProperty("deleteReason").GetString().ShouldBe("Stalled");
}
// Assert Phase 4: Notification was triggered
await _fixture.NotificationPublisher.Received(1).NotifyQueueItemDeleted(true, DeleteReason.Stalled);
// Assert Phase 5: Replacement search item was added to SearchQueue
var searchItems = await _fixture.DataContext.SearchQueue.ToListAsync();
searchItems.Count.ShouldBe(1);
searchItems[0].ArrInstanceId.ShouldBe(instance.Id);
searchItems[0].ItemId.ShouldBe(42);
}
[Fact]
public async Task FailedImport_RemovesWithFailedImportReason_SendsNotification()
{
// Arrange
var instance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
var downloadClient = TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
var record = CreateQueueRecord(movieId: 99);
_fixture.SetupArrQueueIterator(record);
_fixture.ArrClient.IsRecordValid(Arg.Any<QueueRecord>()).Returns(true);
_fixture.ArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
_fixture.ArrClient.ShouldRemoveFromQueue(
Arg.Any<InstanceType>(), Arg.Any<QueueRecord>(), Arg.Any<bool>(), Arg.Any<short>())
.Returns(true);
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService.ShouldRemoveFromArrQueueAsync(Arg.Any<string>(), Arg.Any<IReadOnlyList<string>>())
.Returns(new DownloadCheckResult
{
ShouldRemove = false,
Found = true,
IsPrivate = false
});
_fixture.DownloadServiceFactory.GetDownloadService(Arg.Any<Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig>())
.Returns(mockDownloadService);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert: failed import removal published
_fixture.GetCapturedRemoveRequests().Count.ShouldBe(1);
_fixture.ArrClient.DeleteQueueItemAsync(
Arg.Any<ArrInstance>(), Arg.Any<QueueRecord>(), Arg.Any<bool>(), Arg.Any<DeleteReason>())
.Returns(Task.CompletedTask);
await _fixture.ProcessCapturedRemoveRequestsAsync();
// Full event property verification
var events = await _fixture.EventsContext.Events.ToListAsync();
var deletedEvent = events.First(e => e.EventType == EventType.QueueItemDeleted);
deletedEvent.Message.ShouldBe("Deleting item from queue with reason: FailedImport");
deletedEvent.Severity.ShouldBe(EventSeverity.Important);
deletedEvent.JobRunId.ShouldBe(_fixture.JobRunId);
deletedEvent.ArrInstanceId.ShouldBe(instance.Id);
deletedEvent.DownloadClientId.ShouldBe(mockDownloadService.ClientConfig.Id);
deletedEvent.IsDryRun.ShouldBe(false);
deletedEvent.StrikeId.ShouldBeNull();
deletedEvent.SearchStatus.ShouldBeNull();
deletedEvent.Data.ShouldNotBeNull();
using (var data = JsonDocument.Parse(deletedEvent.Data!))
{
data.RootElement.GetProperty("itemName").GetString().ShouldBe("Test.Movie.2024.1080p");
data.RootElement.GetProperty("hash").GetString().ShouldBe("ABC123DEF456");
data.RootElement.GetProperty("removeFromClient").GetBoolean().ShouldBe(true);
data.RootElement.GetProperty("deleteReason").GetString().ShouldBe("FailedImport");
}
// Notification with FailedImport reason
await _fixture.NotificationPublisher.Received(1).NotifyQueueItemDeleted(true, DeleteReason.FailedImport);
}
[Fact]
public async Task IgnoredDownload_IsSkipped_NoEventsOrNotifications()
{
// Arrange
var instance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
var record = CreateQueueRecord(downloadId: "IGNORED_HASH_123");
// Add the download ID to the ignored list
var generalConfig = await _fixture.DataContext.GeneralConfigs.FirstAsync();
generalConfig.IgnoredDownloads.Add("IGNORED_HASH_123");
await _fixture.DataContext.SaveChangesAsync();
_fixture.SetupArrQueueIterator(record);
_fixture.ArrClient.IsRecordValid(Arg.Any<QueueRecord>()).Returns(true);
_fixture.ArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert: No removal requests, no events, no notifications
_fixture.GetCapturedRemoveRequests().ShouldBeEmpty();
var events = await _fixture.EventsContext.Events.ToListAsync();
events.ShouldBeEmpty();
await _fixture.NotificationPublisher.DidNotReceive().NotifyQueueItemDeleted(Arg.Any<bool>(), Arg.Any<DeleteReason>());
}
[Fact]
public async Task PrivateTorrent_RemoveFromClientIsFalse()
{
// Arrange
var instance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
TestDataContextFactory.AddStallRule(_fixture.DataContext);
var record = CreateQueueRecord(movieId: 50);
_fixture.SetupArrQueueIterator(record);
_fixture.ArrClient.IsRecordValid(Arg.Any<QueueRecord>()).Returns(true);
_fixture.ArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService.ShouldRemoveFromArrQueueAsync(Arg.Any<string>(), Arg.Any<IReadOnlyList<string>>())
.Returns(new DownloadCheckResult
{
ShouldRemove = true,
Found = true,
DeleteReason = DeleteReason.Stalled,
IsPrivate = true,
DeleteFromClient = false
});
_fixture.DownloadServiceFactory.GetDownloadService(Arg.Any<Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig>())
.Returns(mockDownloadService);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert: RemoveFromClient should be false for private torrents
_fixture.GetCapturedRemoveRequests().Count.ShouldBe(1);
_fixture.ArrClient.DeleteQueueItemAsync(
Arg.Any<ArrInstance>(), Arg.Any<QueueRecord>(), Arg.Any<bool>(), Arg.Any<DeleteReason>())
.Returns(Task.CompletedTask);
await _fixture.ProcessCapturedRemoveRequestsAsync();
// The arr client should be told NOT to remove from the download client
await _fixture.ArrClient.Received(1).DeleteQueueItemAsync(
Arg.Any<ArrInstance>(),
Arg.Any<QueueRecord>(),
false,
DeleteReason.Stalled);
// Full event property verification
var events = await _fixture.EventsContext.Events.ToListAsync();
var deletedEvent = events.First(e => e.EventType == EventType.QueueItemDeleted);
deletedEvent.Message.ShouldBe("Deleting item from queue with reason: Stalled");
deletedEvent.Severity.ShouldBe(EventSeverity.Important);
deletedEvent.JobRunId.ShouldBe(_fixture.JobRunId);
deletedEvent.ArrInstanceId.ShouldBe(instance.Id);
deletedEvent.IsDryRun.ShouldBe(false);
deletedEvent.Data.ShouldNotBeNull();
using (var data = JsonDocument.Parse(deletedEvent.Data!))
{
data.RootElement.GetProperty("itemName").GetString().ShouldBe("Test.Movie.2024.1080p");
data.RootElement.GetProperty("hash").GetString().ShouldBe("ABC123DEF456");
data.RootElement.GetProperty("removeFromClient").GetBoolean().ShouldBe(false);
data.RootElement.GetProperty("deleteReason").GetString().ShouldBe("Stalled");
}
await _fixture.NotificationPublisher.Received(1).NotifyQueueItemDeleted(false, DeleteReason.Stalled);
}
private static QueueRecord CreateQueueRecord(
long movieId = 1,
string downloadId = "ABC123DEF456",
string title = "Test.Movie.2024.1080p")
{
return new QueueRecord
{
Id = 1,
Title = title,
Protocol = "torrent",
DownloadId = downloadId,
MovieId = movieId,
Status = "warning",
StatusMessages = []
};
}
}
@@ -0,0 +1,221 @@
using Cleanuparr.Domain.Entities.Arr;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
using Cleanuparr.Persistence.Models.State;
using Microsoft.AspNetCore.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Shouldly;
using Xunit;
using SeekerJob = Cleanuparr.Infrastructure.Features.Jobs.Seeker;
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs.Integration;
[Collection(IntegrationTestCollection.Name)]
public class SeekerIntegrationTests : IDisposable
{
private readonly IntegrationTestFixture _fixture;
public SeekerIntegrationTests(IntegrationTestFixture fixture)
{
_fixture = fixture;
_fixture.Reset();
}
public void Dispose()
{
Striker.RecurringHashes.Clear();
}
private SeekerJob CreateSut()
{
var environment = Substitute.For<IHostingEnvironment>();
environment.EnvironmentName.Returns("Development");
return new SeekerJob(
Substitute.For<ILogger<SeekerJob>>(),
_fixture.DataContext,
Substitute.For<IRadarrClient>(),
Substitute.For<ISonarrClient>(),
_fixture.ArrClientFactory,
_fixture.ArrQueueIterator,
_fixture.EventPublisher,
_fixture.DryRunInterceptor,
environment,
_fixture.TimeProvider,
_fixture.HubContext);
}
[Fact]
public async Task ReplacementSearch_TriggersSearch_SavesEvent_SendsNotification_DequesItem()
{
// Arrange
var instance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
// Add a replacement search item to the queue
_fixture.DataContext.SearchQueue.Add(new SearchQueueItem
{
ArrInstanceId = instance.Id,
ItemId = 42,
Title = "Test.Movie.2024.1080p",
});
await _fixture.DataContext.SaveChangesAsync();
// Mock arr client to return command IDs on search
_fixture.ArrClient.SearchItemsAsync(Arg.Any<Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance>(), Arg.Any<HashSet<SearchItem>>())
.Returns([100L]);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert: Full SearchTriggered event property verification
var events = await _fixture.EventsContext.Events.ToListAsync();
events.Count.ShouldBe(1);
var searchEvent = events.First(e => e.EventType == EventType.SearchTriggered);
searchEvent.Message.ShouldBe("Search triggered for Test.Movie.2024.1080p");
searchEvent.Severity.ShouldBe(EventSeverity.Information);
searchEvent.JobRunId.ShouldBe(_fixture.JobRunId);
searchEvent.ArrInstanceId.ShouldBe(instance.Id);
searchEvent.DownloadClientId.ShouldBeNull();
searchEvent.IsDryRun.ShouldBe(false);
searchEvent.SearchStatus.ShouldBe(SearchCommandStatus.Pending);
searchEvent.CompletedAt.ShouldBeNull();
searchEvent.CycleId.ShouldBeNull();
searchEvent.StrikeId.ShouldBeNull();
searchEvent.TrackingId.ShouldBeNull();
searchEvent.Data.ShouldBeNull();
// Assert: SearchEventData was created with correct properties
var searchData = await _fixture.EventsContext.SearchEventData.ToListAsync();
searchData.Count.ShouldBe(1);
searchData[0].AppEventId.ShouldBe(searchEvent.Id);
searchData[0].SearchType.ShouldBe(SeekerSearchType.Replacement);
searchData[0].SearchReason.ShouldBe(SeekerSearchReason.Replacement);
searchData[0].ItemTitle.ShouldBe("Test.Movie.2024.1080p");
searchData[0].GrabbedItems.ShouldBeEmpty();
// Assert: Notification was sent
await _fixture.NotificationPublisher.Received(1).NotifySearchTriggered(
"Test.Movie.2024.1080p",
SeekerSearchType.Replacement,
SeekerSearchReason.Replacement);
// Assert: Item was dequeued from SearchQueue
var remainingItems = await _fixture.DataContext.SearchQueue.CountAsync();
remainingItems.ShouldBe(0);
// Assert: Command tracker was saved for monitoring
var trackers = await _fixture.DataContext.SeekerCommandTrackers.ToListAsync();
trackers.Count.ShouldBe(1);
trackers[0].CommandId.ShouldBe(100L);
}
[Fact]
public async Task SearchDisabled_DoesNothing()
{
// Arrange
var seekerConfig = await _fixture.DataContext.SeekerConfigs.FirstAsync();
seekerConfig.SearchEnabled = false;
await _fixture.DataContext.SaveChangesAsync();
TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
// Add a search queue item that should NOT be processed
var instance = await _fixture.DataContext.ArrInstances.FirstAsync();
_fixture.DataContext.SearchQueue.Add(new SearchQueueItem
{
ArrInstanceId = instance.Id,
ItemId = 99,
Title = "Should.Not.Search",
});
await _fixture.DataContext.SaveChangesAsync();
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert: No search triggered, no events, no notifications
await _fixture.ArrClient.DidNotReceive().SearchItemsAsync(
Arg.Any<Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance>(),
Arg.Any<HashSet<SearchItem>>());
var events = await _fixture.EventsContext.Events.ToListAsync();
events.ShouldBeEmpty();
var searchData = await _fixture.EventsContext.SearchEventData.ToListAsync();
searchData.ShouldBeEmpty();
await _fixture.NotificationPublisher.DidNotReceive().NotifySearchTriggered(
Arg.Any<string>(), Arg.Any<SeekerSearchType>(), Arg.Any<SeekerSearchReason>());
// Item should still be in the queue (not processed)
var remainingItems = await _fixture.DataContext.SearchQueue.CountAsync();
remainingItems.ShouldBe(1);
}
[Fact]
public async Task DryRun_TriggersSearch_ButDoesNotDequeue()
{
// Arrange
_fixture.DryRunInterceptor.IsDryRunEnabled().Returns(true);
var instance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
_fixture.DataContext.SearchQueue.Add(new SearchQueueItem
{
ArrInstanceId = instance.Id,
ItemId = 55,
Title = "DryRun.Movie.2024",
});
await _fixture.DataContext.SaveChangesAsync();
_fixture.ArrClient.SearchItemsAsync(
Arg.Any<Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance>(),
Arg.Any<HashSet<SearchItem>>())
.Returns([200L]);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert: Full SearchTriggered event with IsDryRun = true
var events = await _fixture.EventsContext.Events.ToListAsync();
events.Count.ShouldBe(1);
var searchEvent = events.First(e => e.EventType == EventType.SearchTriggered);
searchEvent.Message.ShouldBe("Search triggered for DryRun.Movie.2024");
searchEvent.Severity.ShouldBe(EventSeverity.Information);
searchEvent.JobRunId.ShouldBe(_fixture.JobRunId);
searchEvent.ArrInstanceId.ShouldBe(instance.Id);
searchEvent.IsDryRun.ShouldBe(true);
searchEvent.SearchStatus.ShouldBe(SearchCommandStatus.Pending);
searchEvent.CompletedAt.ShouldBeNull();
searchEvent.CycleId.ShouldBeNull();
searchEvent.StrikeId.ShouldBeNull();
searchEvent.Data.ShouldBeNull();
// Assert: SearchEventData created
var searchData = await _fixture.EventsContext.SearchEventData.ToListAsync();
searchData.Count.ShouldBe(1);
searchData[0].ItemTitle.ShouldBe("DryRun.Movie.2024");
searchData[0].SearchType.ShouldBe(SeekerSearchType.Replacement);
searchData[0].SearchReason.ShouldBe(SeekerSearchReason.Replacement);
searchData[0].GrabbedItems.ShouldBeEmpty();
// Assert: Item remains in queue (dry run doesn't dequeue)
var remainingItems = await _fixture.DataContext.SearchQueue.CountAsync();
remainingItems.ShouldBe(1);
// Assert: No command tracker saved (dry run)
var trackers = await _fixture.DataContext.SeekerCommandTrackers.ToListAsync();
trackers.ShouldBeEmpty();
}
}
@@ -0,0 +1,503 @@
using System.Text.Json;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using Shouldly;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs.Integration;
[Collection(IntegrationTestCollection.Name)]
public class StrikerIntegrationTests : IDisposable
{
private readonly IntegrationTestFixture _fixture;
private readonly Guid _arrInstanceId = Guid.NewGuid();
public StrikerIntegrationTests(IntegrationTestFixture fixture)
{
_fixture = fixture;
_fixture.Reset();
SetupContext();
}
public void Dispose()
{
Striker.RecurringHashes.Clear();
}
private void SetupContext()
{
ContextProvider.Set(ContextProvider.Keys.ArrInstanceId, _arrInstanceId);
ContextProvider.Set(ContextProvider.Keys.ArrInstanceUrl, new Uri("http://radarr:7878"));
ContextProvider.Set(nameof(InstanceType), (object)InstanceType.Radarr);
}
[Fact]
public async Task StalledStrike_PublishesEvent_CreatesStrike_SendsNotification()
{
// Act
bool shouldRemove = await _fixture.Striker.StrikeAndCheckLimit(
"STALLED_HASH_123", "Stalled.Movie.2024.1080p", maxStrikes: 3, StrikeType.Stalled);
// Assert: Should not remove (1 of 3 strikes)
shouldRemove.ShouldBe(false);
// Assert: Strike record
var strikes = await _fixture.EventsContext.Strikes.ToListAsync();
strikes.Count.ShouldBe(1);
strikes[0].Type.ShouldBe(StrikeType.Stalled);
strikes[0].JobRunId.ShouldBe(_fixture.JobRunId);
strikes[0].IsDryRun.ShouldBe(false);
strikes[0].LastDownloadedBytes.ShouldBeNull();
// Assert: DownloadItem record
var downloadItems = await _fixture.EventsContext.DownloadItems.ToListAsync();
downloadItems.Count.ShouldBe(1);
downloadItems[0].DownloadId.ShouldBe("STALLED_HASH_123");
downloadItems[0].Title.ShouldBe("Stalled.Movie.2024.1080p");
downloadItems[0].IsMarkedForRemoval.ShouldBe(false);
downloadItems[0].IsRemoved.ShouldBe(false);
downloadItems[0].IsReturning.ShouldBe(false);
// Assert: FK relationship
strikes[0].DownloadItemId.ShouldBe(downloadItems[0].Id);
// Assert: Full AppEvent property verification
var events = await _fixture.EventsContext.Events.ToListAsync();
events.Count.ShouldBe(1);
var strikeEvent = events[0];
strikeEvent.EventType.ShouldBe(EventType.StalledStrike);
strikeEvent.Message.ShouldBe("Item 'Stalled.Movie.2024.1080p' has been struck 1 times for reason 'Stalled'");
strikeEvent.Severity.ShouldBe(EventSeverity.Important);
strikeEvent.JobRunId.ShouldBe(_fixture.JobRunId);
strikeEvent.ArrInstanceId.ShouldBe(_arrInstanceId);
strikeEvent.DownloadClientId.ShouldBeNull();
strikeEvent.IsDryRun.ShouldBe(false);
strikeEvent.StrikeId.ShouldBe(strikes[0].Id);
strikeEvent.TrackingId.ShouldBeNull();
strikeEvent.SearchStatus.ShouldBeNull();
strikeEvent.CompletedAt.ShouldBeNull();
strikeEvent.CycleId.ShouldBeNull();
strikeEvent.Data.ShouldNotBeNull();
using (var data = JsonDocument.Parse(strikeEvent.Data!))
{
data.RootElement.GetProperty("hash").GetString().ShouldBe("STALLED_HASH_123");
data.RootElement.GetProperty("itemName").GetString().ShouldBe("Stalled.Movie.2024.1080p");
data.RootElement.GetProperty("strikeCount").GetInt32().ShouldBe(1);
data.RootElement.GetProperty("strikeType").GetString().ShouldBe("Stalled");
}
// Assert: Notification sent
await _fixture.NotificationPublisher.Received(1).NotifyStrike(StrikeType.Stalled, 1);
}
[Fact]
public async Task DownloadingMetadataStrike_PublishesEvent_CreatesStrike_SendsNotification()
{
// Act
bool shouldRemove = await _fixture.Striker.StrikeAndCheckLimit(
"METADATA_HASH_456", "Metadata.Movie.2024.1080p", maxStrikes: 3, StrikeType.DownloadingMetadata);
// Assert: Should not remove (1 of 3 strikes)
shouldRemove.ShouldBe(false);
// Assert: Strike record
var strikes = await _fixture.EventsContext.Strikes.ToListAsync();
strikes.Count.ShouldBe(1);
strikes[0].Type.ShouldBe(StrikeType.DownloadingMetadata);
strikes[0].JobRunId.ShouldBe(_fixture.JobRunId);
strikes[0].IsDryRun.ShouldBe(false);
strikes[0].LastDownloadedBytes.ShouldBeNull();
// Assert: DownloadItem record
var downloadItems = await _fixture.EventsContext.DownloadItems.ToListAsync();
downloadItems.Count.ShouldBe(1);
downloadItems[0].DownloadId.ShouldBe("METADATA_HASH_456");
downloadItems[0].Title.ShouldBe("Metadata.Movie.2024.1080p");
downloadItems[0].IsMarkedForRemoval.ShouldBe(false);
// Assert: FK relationship
strikes[0].DownloadItemId.ShouldBe(downloadItems[0].Id);
// Assert: Full AppEvent property verification
var events = await _fixture.EventsContext.Events.ToListAsync();
events.Count.ShouldBe(1);
var strikeEvent = events[0];
strikeEvent.EventType.ShouldBe(EventType.DownloadingMetadataStrike);
strikeEvent.Message.ShouldBe("Item 'Metadata.Movie.2024.1080p' has been struck 1 times for reason 'DownloadingMetadata'");
strikeEvent.Severity.ShouldBe(EventSeverity.Important);
strikeEvent.JobRunId.ShouldBe(_fixture.JobRunId);
strikeEvent.ArrInstanceId.ShouldBe(_arrInstanceId);
strikeEvent.DownloadClientId.ShouldBeNull();
strikeEvent.IsDryRun.ShouldBe(false);
strikeEvent.StrikeId.ShouldBe(strikes[0].Id);
strikeEvent.TrackingId.ShouldBeNull();
strikeEvent.SearchStatus.ShouldBeNull();
strikeEvent.CompletedAt.ShouldBeNull();
strikeEvent.CycleId.ShouldBeNull();
strikeEvent.Data.ShouldNotBeNull();
using (var data = JsonDocument.Parse(strikeEvent.Data!))
{
data.RootElement.GetProperty("hash").GetString().ShouldBe("METADATA_HASH_456");
data.RootElement.GetProperty("itemName").GetString().ShouldBe("Metadata.Movie.2024.1080p");
data.RootElement.GetProperty("strikeCount").GetInt32().ShouldBe(1);
data.RootElement.GetProperty("strikeType").GetString().ShouldBe("DownloadingMetadata");
}
// Assert: Notification sent
await _fixture.NotificationPublisher.Received(1).NotifyStrike(StrikeType.DownloadingMetadata, 1);
}
[Fact]
public async Task FailedImportStrike_PublishesEvent_WithStatusMessages_SendsNotification()
{
// Arrange: FailedImport reads QueueRecord from ContextProvider for StatusMessages
var queueRecord = new QueueRecord
{
Id = 1,
Title = "FailedImport.Movie.2024.1080p",
Protocol = "torrent",
DownloadId = "FAILED_HASH_789",
StatusMessages =
[
new TrackedDownloadStatusMessage
{
Title = "Import failed",
Messages = ["File not found", "Path does not exist"]
}
]
};
ContextProvider.Set(nameof(QueueRecord), queueRecord);
// Act
bool shouldRemove = await _fixture.Striker.StrikeAndCheckLimit(
"FAILED_HASH_789", "FailedImport.Movie.2024.1080p", maxStrikes: 3, StrikeType.FailedImport);
// Assert: Should not remove (1 of 3 strikes)
shouldRemove.ShouldBe(false);
// Assert: Strike record
var strikes = await _fixture.EventsContext.Strikes.ToListAsync();
strikes.Count.ShouldBe(1);
strikes[0].Type.ShouldBe(StrikeType.FailedImport);
strikes[0].JobRunId.ShouldBe(_fixture.JobRunId);
strikes[0].IsDryRun.ShouldBe(false);
strikes[0].LastDownloadedBytes.ShouldBeNull();
// Assert: DownloadItem record
var downloadItems = await _fixture.EventsContext.DownloadItems.ToListAsync();
downloadItems.Count.ShouldBe(1);
downloadItems[0].DownloadId.ShouldBe("FAILED_HASH_789");
downloadItems[0].Title.ShouldBe("FailedImport.Movie.2024.1080p");
downloadItems[0].IsMarkedForRemoval.ShouldBe(false);
// Assert: FK relationship
strikes[0].DownloadItemId.ShouldBe(downloadItems[0].Id);
// Assert: Full AppEvent property verification
var events = await _fixture.EventsContext.Events.ToListAsync();
events.Count.ShouldBe(1);
var strikeEvent = events[0];
strikeEvent.EventType.ShouldBe(EventType.FailedImportStrike);
strikeEvent.Message.ShouldBe("Item 'FailedImport.Movie.2024.1080p' has been struck 1 times for reason 'FailedImport'");
strikeEvent.Severity.ShouldBe(EventSeverity.Important);
strikeEvent.JobRunId.ShouldBe(_fixture.JobRunId);
strikeEvent.ArrInstanceId.ShouldBe(_arrInstanceId);
strikeEvent.DownloadClientId.ShouldBeNull();
strikeEvent.IsDryRun.ShouldBe(false);
strikeEvent.StrikeId.ShouldBe(strikes[0].Id);
strikeEvent.TrackingId.ShouldBeNull();
strikeEvent.SearchStatus.ShouldBeNull();
strikeEvent.CompletedAt.ShouldBeNull();
strikeEvent.CycleId.ShouldBeNull();
strikeEvent.Data.ShouldNotBeNull();
using (var data = JsonDocument.Parse(strikeEvent.Data!))
{
data.RootElement.GetProperty("hash").GetString().ShouldBe("FAILED_HASH_789");
data.RootElement.GetProperty("itemName").GetString().ShouldBe("FailedImport.Movie.2024.1080p");
data.RootElement.GetProperty("strikeCount").GetInt32().ShouldBe(1);
data.RootElement.GetProperty("strikeType").GetString().ShouldBe("FailedImport");
// FailedImport-specific: includes failedImportReasons from QueueRecord.StatusMessages
var reasons = data.RootElement.GetProperty("failedImportReasons");
reasons.GetArrayLength().ShouldBe(1);
reasons[0].GetProperty("Title").GetString().ShouldBe("Import failed");
var messages = reasons[0].GetProperty("Messages");
messages.GetArrayLength().ShouldBe(2);
messages[0].GetString().ShouldBe("File not found");
messages[1].GetString().ShouldBe("Path does not exist");
}
// Assert: Notification sent
await _fixture.NotificationPublisher.Received(1).NotifyStrike(StrikeType.FailedImport, 1);
}
[Fact]
public async Task SlowSpeedStrike_PublishesEvent_CreatesStrike_SendsNotification()
{
// Act
bool shouldRemove = await _fixture.Striker.StrikeAndCheckLimit(
"SLOW_SPEED_HASH_111", "SlowSpeed.Movie.2024.1080p", maxStrikes: 3, StrikeType.SlowSpeed);
// Assert: Should not remove (1 of 3 strikes)
shouldRemove.ShouldBe(false);
// Assert: Strike record
var strikes = await _fixture.EventsContext.Strikes.ToListAsync();
strikes.Count.ShouldBe(1);
strikes[0].Type.ShouldBe(StrikeType.SlowSpeed);
strikes[0].JobRunId.ShouldBe(_fixture.JobRunId);
strikes[0].IsDryRun.ShouldBe(false);
strikes[0].LastDownloadedBytes.ShouldBeNull();
// Assert: DownloadItem record
var downloadItems = await _fixture.EventsContext.DownloadItems.ToListAsync();
downloadItems.Count.ShouldBe(1);
downloadItems[0].DownloadId.ShouldBe("SLOW_SPEED_HASH_111");
downloadItems[0].Title.ShouldBe("SlowSpeed.Movie.2024.1080p");
downloadItems[0].IsMarkedForRemoval.ShouldBe(false);
// Assert: FK relationship
strikes[0].DownloadItemId.ShouldBe(downloadItems[0].Id);
// Assert: Full AppEvent property verification
var events = await _fixture.EventsContext.Events.ToListAsync();
events.Count.ShouldBe(1);
var strikeEvent = events[0];
strikeEvent.EventType.ShouldBe(EventType.SlowSpeedStrike);
strikeEvent.Message.ShouldBe("Item 'SlowSpeed.Movie.2024.1080p' has been struck 1 times for reason 'SlowSpeed'");
strikeEvent.Severity.ShouldBe(EventSeverity.Important);
strikeEvent.JobRunId.ShouldBe(_fixture.JobRunId);
strikeEvent.ArrInstanceId.ShouldBe(_arrInstanceId);
strikeEvent.DownloadClientId.ShouldBeNull();
strikeEvent.IsDryRun.ShouldBe(false);
strikeEvent.StrikeId.ShouldBe(strikes[0].Id);
strikeEvent.TrackingId.ShouldBeNull();
strikeEvent.SearchStatus.ShouldBeNull();
strikeEvent.CompletedAt.ShouldBeNull();
strikeEvent.CycleId.ShouldBeNull();
strikeEvent.Data.ShouldNotBeNull();
using (var data = JsonDocument.Parse(strikeEvent.Data!))
{
data.RootElement.GetProperty("hash").GetString().ShouldBe("SLOW_SPEED_HASH_111");
data.RootElement.GetProperty("itemName").GetString().ShouldBe("SlowSpeed.Movie.2024.1080p");
data.RootElement.GetProperty("strikeCount").GetInt32().ShouldBe(1);
data.RootElement.GetProperty("strikeType").GetString().ShouldBe("SlowSpeed");
}
// Assert: Notification sent
await _fixture.NotificationPublisher.Received(1).NotifyStrike(StrikeType.SlowSpeed, 1);
}
[Fact]
public async Task SlowTimeStrike_PublishesEvent_CreatesStrike_SendsNotification()
{
// Act
bool shouldRemove = await _fixture.Striker.StrikeAndCheckLimit(
"SLOW_TIME_HASH_222", "SlowTime.Movie.2024.1080p", maxStrikes: 3, StrikeType.SlowTime);
// Assert: Should not remove (1 of 3 strikes)
shouldRemove.ShouldBe(false);
// Assert: Strike record
var strikes = await _fixture.EventsContext.Strikes.ToListAsync();
strikes.Count.ShouldBe(1);
strikes[0].Type.ShouldBe(StrikeType.SlowTime);
strikes[0].JobRunId.ShouldBe(_fixture.JobRunId);
strikes[0].IsDryRun.ShouldBe(false);
strikes[0].LastDownloadedBytes.ShouldBeNull();
// Assert: DownloadItem record
var downloadItems = await _fixture.EventsContext.DownloadItems.ToListAsync();
downloadItems.Count.ShouldBe(1);
downloadItems[0].DownloadId.ShouldBe("SLOW_TIME_HASH_222");
downloadItems[0].Title.ShouldBe("SlowTime.Movie.2024.1080p");
downloadItems[0].IsMarkedForRemoval.ShouldBe(false);
// Assert: FK relationship
strikes[0].DownloadItemId.ShouldBe(downloadItems[0].Id);
// Assert: Full AppEvent property verification
var events = await _fixture.EventsContext.Events.ToListAsync();
events.Count.ShouldBe(1);
var strikeEvent = events[0];
strikeEvent.EventType.ShouldBe(EventType.SlowTimeStrike);
strikeEvent.Message.ShouldBe("Item 'SlowTime.Movie.2024.1080p' has been struck 1 times for reason 'SlowTime'");
strikeEvent.Severity.ShouldBe(EventSeverity.Important);
strikeEvent.JobRunId.ShouldBe(_fixture.JobRunId);
strikeEvent.ArrInstanceId.ShouldBe(_arrInstanceId);
strikeEvent.DownloadClientId.ShouldBeNull();
strikeEvent.IsDryRun.ShouldBe(false);
strikeEvent.StrikeId.ShouldBe(strikes[0].Id);
strikeEvent.TrackingId.ShouldBeNull();
strikeEvent.SearchStatus.ShouldBeNull();
strikeEvent.CompletedAt.ShouldBeNull();
strikeEvent.CycleId.ShouldBeNull();
strikeEvent.Data.ShouldNotBeNull();
using (var data = JsonDocument.Parse(strikeEvent.Data!))
{
data.RootElement.GetProperty("hash").GetString().ShouldBe("SLOW_TIME_HASH_222");
data.RootElement.GetProperty("itemName").GetString().ShouldBe("SlowTime.Movie.2024.1080p");
data.RootElement.GetProperty("strikeCount").GetInt32().ShouldBe(1);
data.RootElement.GetProperty("strikeType").GetString().ShouldBe("SlowTime");
}
// Assert: Notification sent
await _fixture.NotificationPublisher.Received(1).NotifyStrike(StrikeType.SlowTime, 1);
}
[Fact]
public async Task StrikeReachingLimit_MarksDownloadItemForRemoval()
{
// Act: 3 strikes with maxStrikes=3
bool result1 = await _fixture.Striker.StrikeAndCheckLimit(
"LIMIT_HASH_333", "Limit.Movie.2024", maxStrikes: 3, StrikeType.Stalled);
bool result2 = await _fixture.Striker.StrikeAndCheckLimit(
"LIMIT_HASH_333", "Limit.Movie.2024", maxStrikes: 3, StrikeType.Stalled);
bool result3 = await _fixture.Striker.StrikeAndCheckLimit(
"LIMIT_HASH_333", "Limit.Movie.2024", maxStrikes: 3, StrikeType.Stalled);
// Assert: First two return false, third returns true
result1.ShouldBe(false);
result2.ShouldBe(false);
result3.ShouldBe(true);
// Assert: 3 strikes created for same DownloadItem
var strikes = await _fixture.EventsContext.Strikes.ToListAsync();
strikes.Count.ShouldBe(3);
strikes.ShouldAllBe(s => s.Type == StrikeType.Stalled);
// Assert: Single DownloadItem marked for removal
var downloadItems = await _fixture.EventsContext.DownloadItems.ToListAsync();
downloadItems.Count.ShouldBe(1);
downloadItems[0].IsMarkedForRemoval.ShouldBe(true);
// Assert: 3 events with incrementing strike counts
var events = await _fixture.EventsContext.Events.OrderBy(e => e.Timestamp).ToListAsync();
events.Count.ShouldBe(3);
for (int i = 0; i < 3; i++)
{
events[i].EventType.ShouldBe(EventType.StalledStrike);
using var data = JsonDocument.Parse(events[i].Data!);
data.RootElement.GetProperty("strikeCount").GetInt32().ShouldBe(i + 1);
}
// Assert: 3 notifications with incrementing counts
await _fixture.NotificationPublisher.Received(1).NotifyStrike(StrikeType.Stalled, 1);
await _fixture.NotificationPublisher.Received(1).NotifyStrike(StrikeType.Stalled, 2);
await _fixture.NotificationPublisher.Received(1).NotifyStrike(StrikeType.Stalled, 3);
}
[Fact]
public async Task DryRunStrike_PublishesEventWithDryRunFlag()
{
// Arrange
_fixture.DryRunInterceptor.IsDryRunEnabled().Returns(true);
// Act
bool shouldRemove = await _fixture.Striker.StrikeAndCheckLimit(
"DRYRUN_HASH_444", "DryRun.Movie.2024", maxStrikes: 1, StrikeType.Stalled);
// Assert: Should remove (at limit)
shouldRemove.ShouldBe(true);
// Assert: Strike has IsDryRun = true
var strikes = await _fixture.EventsContext.Strikes.ToListAsync();
strikes.Count.ShouldBe(1);
strikes[0].IsDryRun.ShouldBe(true);
// Assert: AppEvent has IsDryRun = true
var events = await _fixture.EventsContext.Events.ToListAsync();
events.Count.ShouldBe(1);
events[0].IsDryRun.ShouldBe(true);
// Assert: DownloadItem marked for removal (striker still marks regardless of dry run)
var downloadItems = await _fixture.EventsContext.DownloadItems.ToListAsync();
downloadItems[0].IsMarkedForRemoval.ShouldBe(true);
}
[Fact]
public async Task RecurringItem_ExceedsMaxStrikes_PublishesManualEvent()
{
// Act: 3 strikes with maxStrikes=2 (strike 3 exceeds limit)
await _fixture.Striker.StrikeAndCheckLimit(
"RECURRING_HASH_555", "Recurring.Movie.2024", maxStrikes: 2, StrikeType.Stalled);
await _fixture.Striker.StrikeAndCheckLimit(
"RECURRING_HASH_555", "Recurring.Movie.2024", maxStrikes: 2, StrikeType.Stalled);
await _fixture.Striker.StrikeAndCheckLimit(
"RECURRING_HASH_555", "Recurring.Movie.2024", maxStrikes: 2, StrikeType.Stalled);
// Assert: Hash added to RecurringHashes (lowercased)
Striker.RecurringHashes.ContainsKey("recurring_hash_555").ShouldBe(true);
// Assert: 3 strike events
var events = await _fixture.EventsContext.Events.ToListAsync();
events.Count.ShouldBe(3);
events.ShouldAllBe(e => e.EventType == EventType.StalledStrike);
// Assert: ManualEvent published for recurring item
var manualEvents = await _fixture.EventsContext.ManualEvents.ToListAsync();
manualEvents.Count.ShouldBe(1);
manualEvents[0].Message.ShouldContain("Download keeps coming back after deletion");
manualEvents[0].Severity.ShouldBe(EventSeverity.Important);
manualEvents[0].JobRunId.ShouldBe(_fixture.JobRunId);
manualEvents[0].Data.ShouldNotBeNull();
using (var data = JsonDocument.Parse(manualEvents[0].Data!))
{
data.RootElement.GetProperty("itemName").GetString().ShouldBe("Recurring.Movie.2024");
data.RootElement.GetProperty("hash").GetString().ShouldBe("RECURRING_HASH_555");
data.RootElement.GetProperty("strikeCount").GetInt32().ShouldBe(3);
}
}
[Fact]
public async Task StrikeWithLastDownloadedBytes_StoresOnStrikeRecord()
{
// Act
await _fixture.Striker.StrikeAndCheckLimit(
"BYTES_HASH_666", "Bytes.Movie.2024", maxStrikes: 3, StrikeType.SlowSpeed, lastDownloadedBytes: 1024000);
// Assert: LastDownloadedBytes persisted
var strikes = await _fixture.EventsContext.Strikes.ToListAsync();
strikes.Count.ShouldBe(1);
strikes[0].LastDownloadedBytes.ShouldBe(1024000);
}
[Fact]
public async Task FailedImportStrike_EmptyStatusMessages_PublishesEmptyReasons()
{
// Arrange: QueueRecord with empty StatusMessages
var queueRecord = new QueueRecord
{
Id = 1,
Title = "EmptyReasons.Movie.2024",
Protocol = "torrent",
DownloadId = "EMPTY_REASONS_HASH_777",
StatusMessages = []
};
ContextProvider.Set(nameof(QueueRecord), queueRecord);
// Act
await _fixture.Striker.StrikeAndCheckLimit(
"EMPTY_REASONS_HASH_777", "EmptyReasons.Movie.2024", maxStrikes: 3, StrikeType.FailedImport);
// Assert: failedImportReasons is an empty array
var events = await _fixture.EventsContext.Events.ToListAsync();
events.Count.ShouldBe(1);
events[0].EventType.ShouldBe(EventType.FailedImportStrike);
events[0].Data.ShouldNotBeNull();
using (var data = JsonDocument.Parse(events[0].Data!))
{
var reasons = data.RootElement.GetProperty("failedImportReasons");
reasons.GetArrayLength().ShouldBe(0);
}
}
}
@@ -9,7 +9,6 @@ using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Moq;
@@ -1049,7 +1048,7 @@ public class QueueCleanerTests : IDisposable
_fixture.MessageBus.Verify(
x => x.Publish(
It.Is<QueueItemRemoveRequest<SearchItem>>(r =>
r.InstanceType == InstanceType.Radarr &&
r.Instance.ArrConfig.Type == InstanceType.Radarr &&
r.SearchItem.Id == 42 &&
r.DeleteReason == DeleteReason.Stalled
),
@@ -1122,7 +1121,7 @@ public class QueueCleanerTests : IDisposable
_fixture.MessageBus.Verify(
x => x.Publish(
It.Is<QueueItemRemoveRequest<SearchItem>>(r =>
r.InstanceType == InstanceType.Lidarr &&
r.Instance.ArrConfig.Type == InstanceType.Lidarr &&
r.SearchItem.Id == 123 &&
r.DeleteReason == DeleteReason.SlowSpeed
),
@@ -1195,7 +1194,7 @@ public class QueueCleanerTests : IDisposable
_fixture.MessageBus.Verify(
x => x.Publish(
It.Is<QueueItemRemoveRequest<SearchItem>>(r =>
r.InstanceType == InstanceType.Readarr &&
r.Instance.ArrConfig.Type == InstanceType.Readarr &&
r.SearchItem.Id == 456 &&
r.DeleteReason == DeleteReason.Stalled
),
@@ -1269,7 +1268,7 @@ public class QueueCleanerTests : IDisposable
_fixture.MessageBus.Verify(
x => x.Publish(
It.Is<QueueItemRemoveRequest<SeriesSearchItem>>(r =>
r.InstanceType == InstanceType.Whisparr &&
r.Instance.ArrConfig.Type == InstanceType.Whisparr &&
r.SearchItem.Id == 100 && // EpisodeId
r.SearchItem.SeriesId == 10 &&
r.SearchItem.SearchType == SeriesSearchType.Episode &&
@@ -1344,7 +1343,7 @@ public class QueueCleanerTests : IDisposable
_fixture.MessageBus.Verify(
x => x.Publish(
It.Is<QueueItemRemoveRequest<SearchItem>>(r =>
r.InstanceType == InstanceType.Whisparr &&
r.Instance.ArrConfig.Type == InstanceType.Whisparr &&
r.SearchItem.Id == 42 && // MovieId
r.DeleteReason == DeleteReason.Stalled
),
@@ -1431,7 +1430,7 @@ public class QueueCleanerTests : IDisposable
_fixture.MessageBus.Verify(
x => x.Publish(
It.Is<QueueItemRemoveRequest<SeriesSearchItem>>(r =>
r.InstanceType == InstanceType.Whisparr &&
r.Instance.ArrConfig.Type == InstanceType.Whisparr &&
r.SearchItem.Id == 3 && // SeasonNumber
r.SearchItem.SeriesId == 10 &&
r.SearchItem.SearchType == SeriesSearchType.Season &&
@@ -0,0 +1,229 @@
using Cleanuparr.Domain.Entities.Arr;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events.Interfaces;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.State;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Time.Testing;
using NSubstitute;
using Shouldly;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs;
public class SeekerCommandMonitorTests : IAsyncDisposable
{
private readonly DataContext _dataContext;
private readonly FakeTimeProvider _timeProvider;
private readonly IArrClient _arrClient;
private readonly IEventPublisher _eventPublisher;
private readonly SeekerCommandMonitor _sut;
private readonly CancellationTokenSource _cts;
public SeekerCommandMonitorTests()
{
_dataContext = TestDataContextFactory.Create();
_timeProvider = new FakeTimeProvider();
_arrClient = Substitute.For<IArrClient>();
_eventPublisher = Substitute.For<IEventPublisher>();
_cts = new CancellationTokenSource();
var logger = Substitute.For<ILogger<SeekerCommandMonitor>>();
var arrClientFactory = Substitute.For<IArrClientFactory>();
var serviceProvider = Substitute.For<IServiceProvider>();
serviceProvider.GetService(typeof(DataContext)).Returns(_dataContext);
serviceProvider.GetService(typeof(IArrClientFactory)).Returns(arrClientFactory);
serviceProvider.GetService(typeof(IEventPublisher)).Returns(_eventPublisher);
var scope = Substitute.For<IServiceScope>();
scope.ServiceProvider.Returns(serviceProvider);
var scopeFactory = Substitute.For<IServiceScopeFactory>();
scopeFactory.CreateScope().Returns(scope);
arrClientFactory.GetClient(Arg.Any<InstanceType>(), Arg.Any<float>()).Returns(_arrClient);
_sut = new SeekerCommandMonitor(logger, scopeFactory, _timeProvider);
}
public async ValueTask DisposeAsync()
{
await _cts.CancelAsync();
try { await _sut.StopAsync(CancellationToken.None); }
catch { /* expected during teardown */ }
_sut.Dispose();
_dataContext.Dispose();
_cts.Dispose();
GC.SuppressFinalize(this);
}
[Fact]
public async Task Deduplicates_grabbed_items_by_download_id_for_sonarr_season_packs()
{
// Arrange
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_dataContext);
var eventId = Guid.NewGuid();
_dataContext.SeekerCommandTrackers.Add(new SeekerCommandTracker
{
ArrInstanceId = sonarrInstance.Id,
CommandId = 1,
EventId = eventId,
ExternalItemId = 100,
ItemTitle = "Test Series - Season 1",
ItemType = InstanceType.Sonarr,
SeasonNumber = 1,
Status = SearchCommandStatus.Pending,
CreatedAt = _timeProvider.GetUtcNow().UtcDateTime
});
await _dataContext.SaveChangesAsync();
_arrClient.GetCommandStatusAsync(Arg.Any<ArrInstance>(), Arg.Any<long>())
.Returns(new ArrCommandStatus(1, "completed", null));
// 3 episodes from same season pack share the same DownloadId
_arrClient.GetQueueItemsAsync(Arg.Any<ArrInstance>(), Arg.Any<int>())
.Returns(new QueueListResponse
{
TotalRecords = 3,
Records =
[
new QueueRecord { Id = 1, SeriesId = 100, SeasonNumber = 1, Title = "Test.Series.S01.1080p", DownloadId = "ABC123", Protocol = "torrent", Status = "downloading" },
new QueueRecord { Id = 2, SeriesId = 100, SeasonNumber = 1, Title = "Test.Series.S01.1080p", DownloadId = "ABC123", Protocol = "torrent", Status = "downloading" },
new QueueRecord { Id = 3, SeriesId = 100, SeasonNumber = 1, Title = "Test.Series.S01.1080p", DownloadId = "ABC123", Protocol = "torrent", Status = "downloading" },
]
});
var publishTcs = new TaskCompletionSource<List<string>?>();
_eventPublisher.PublishSearchCompleted(
Arg.Any<Guid>(), Arg.Any<SearchCommandStatus>(), Arg.Any<List<string>?>())
.Returns(Task.CompletedTask)
.AndDoes(ci => publishTcs.TrySetResult(ci.ArgAt<List<string>?>(2)));
// Act
await _sut.StartAsync(_cts.Token);
_timeProvider.Advance(TimeSpan.FromSeconds(11));
var resultData = await publishTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
// Assert
await _eventPublisher.Received(1).PublishSearchCompleted(
eventId, SearchCommandStatus.Completed, Arg.Any<List<string>?>());
resultData.ShouldNotBeNull();
resultData!.Count.ShouldBe(1);
}
[Fact]
public async Task Filters_out_records_with_empty_download_id()
{
// Arrange
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_dataContext);
var eventId = Guid.NewGuid();
_dataContext.SeekerCommandTrackers.Add(new SeekerCommandTracker
{
ArrInstanceId = sonarrInstance.Id,
CommandId = 1,
EventId = eventId,
ExternalItemId = 100,
ItemTitle = "Test Series - Season 1",
ItemType = InstanceType.Sonarr,
SeasonNumber = 1,
Status = SearchCommandStatus.Pending,
CreatedAt = _timeProvider.GetUtcNow().UtcDateTime
});
await _dataContext.SaveChangesAsync();
_arrClient.GetCommandStatusAsync(Arg.Any<ArrInstance>(), Arg.Any<long>())
.Returns(new ArrCommandStatus(1, "completed", null));
// Queue has records with empty DownloadId and one valid record
_arrClient.GetQueueItemsAsync(Arg.Any<ArrInstance>(), Arg.Any<int>())
.Returns(new QueueListResponse
{
TotalRecords = 3,
Records =
[
new QueueRecord { Id = 1, SeriesId = 100, SeasonNumber = 1, Title = "Empty DL 1", DownloadId = "", Protocol = "torrent", Status = "downloading" },
new QueueRecord { Id = 2, SeriesId = 100, SeasonNumber = 1, Title = "Empty DL 2", DownloadId = "", Protocol = "torrent", Status = "downloading" },
new QueueRecord { Id = 3, SeriesId = 100, SeasonNumber = 1, Title = "Valid Download", DownloadId = "VALID123", Protocol = "torrent", Status = "downloading" },
]
});
var publishTcs = new TaskCompletionSource<List<string>?>();
_eventPublisher.PublishSearchCompleted(
Arg.Any<Guid>(), Arg.Any<SearchCommandStatus>(), Arg.Any<List<string>?>())
.Returns(Task.CompletedTask)
.AndDoes(ci => publishTcs.TrySetResult(ci.ArgAt<List<string>?>(2)));
// Act
await _sut.StartAsync(_cts.Token);
_timeProvider.Advance(TimeSpan.FromSeconds(11));
var resultData = await publishTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
// Assert
resultData.ShouldNotBeNull();
resultData!.Count.ShouldBe(1);
resultData[0].ShouldBe("Valid Download");
}
[Fact]
public async Task Reports_multiple_grabbed_items_with_different_download_ids()
{
// Arrange
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_dataContext);
var eventId = Guid.NewGuid();
_dataContext.SeekerCommandTrackers.Add(new SeekerCommandTracker
{
ArrInstanceId = radarrInstance.Id,
CommandId = 1,
EventId = eventId,
ExternalItemId = 200,
ItemTitle = "Test Movie",
ItemType = InstanceType.Radarr,
SeasonNumber = 0,
Status = SearchCommandStatus.Pending,
CreatedAt = _timeProvider.GetUtcNow().UtcDateTime
});
await _dataContext.SaveChangesAsync();
_arrClient.GetCommandStatusAsync(Arg.Any<ArrInstance>(), Arg.Any<long>())
.Returns(new ArrCommandStatus(1, "completed", null));
// Two different downloads for the same movie
_arrClient.GetQueueItemsAsync(Arg.Any<ArrInstance>(), Arg.Any<int>())
.Returns(new QueueListResponse
{
TotalRecords = 2,
Records =
[
new QueueRecord { Id = 1, MovieId = 200, Title = "Test.Movie.720p", DownloadId = "HASH1", Protocol = "torrent", Status = "downloading" },
new QueueRecord { Id = 2, MovieId = 200, Title = "Test.Movie.1080p", DownloadId = "HASH2", Protocol = "usenet", Status = "downloading" },
]
});
var publishTcs = new TaskCompletionSource<List<string>?>();
_eventPublisher.PublishSearchCompleted(
Arg.Any<Guid>(), Arg.Any<SearchCommandStatus>(), Arg.Any<List<string>?>())
.Returns(Task.CompletedTask)
.AndDoes(ci => publishTcs.TrySetResult(ci.ArgAt<List<string>?>(2)));
// Act
await _sut.StartAsync(_cts.Token);
_timeProvider.Advance(TimeSpan.FromSeconds(11));
var resultData = await publishTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
// Assert
resultData.ShouldNotBeNull();
resultData!.Count.ShouldBe(2);
}
}
File diff suppressed because it is too large. Load diff
@@ -6,6 +6,7 @@ using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Persistence.Models.Configuration.General;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Cleanuparr.Persistence.Models.Configuration.Seeker;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
@@ -87,11 +88,15 @@ public static class TestDataContextFactory
context.DownloadCleanerConfigs.Add(new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
IgnoredDownloads = [],
Categories = [],
UnlinkedEnabled = false,
UnlinkedTargetCategory = "",
UnlinkedCategories = []
IgnoredDownloads = []
});
// Seeker config
context.SeekerConfigs.Add(new SeekerConfig
{
Id = Guid.NewGuid(),
SearchEnabled = true,
ProactiveSearchEnabled = false
});
context.SaveChanges();
@@ -306,9 +311,9 @@ public static class TestDataContextFactory
}
/// <summary>
/// Adds a clean category to the download cleaner config
/// Adds a seeding rule to a download client
/// </summary>
public static SeedingRule AddSeedingRule(
public static QBitSeedingRule AddSeedingRule(
DataContext context,
string name = "completed",
double maxRatio = 1.0,
@@ -316,8 +321,8 @@ public static class TestDataContextFactory
double maxSeedTime = -1,
TorrentPrivacyType privacyType = TorrentPrivacyType.Both)
{
var config = context.DownloadCleanerConfigs.Include(x => x.Categories).First();
var category = new SeedingRule
var downloadClient = context.DownloadClients.First();
var rule = new QBitSeedingRule
{
Id = Guid.NewGuid(),
Name = name,
@@ -326,13 +331,39 @@ public static class TestDataContextFactory
MaxSeedTime = maxSeedTime,
PrivacyType = privacyType,
DeleteSourceFiles = true,
DownloadCleanerConfigId = config.Id
DownloadClientConfigId = downloadClient.Id
};
config.Categories.Add(category);
context.SeedingRules.Add(category);
context.QBitSeedingRules.Add(rule);
context.SaveChanges();
return category;
return rule;
}
/// <summary>
/// Adds an unlinked config for a download client
/// </summary>
public static UnlinkedConfig AddUnlinkedConfig(
DataContext context,
bool enabled = true,
string targetCategory = "unlinked",
List<string>? categories = null,
List<string>? ignoredRootDirs = null)
{
var downloadClient = context.DownloadClients.First();
var config = new UnlinkedConfig
{
Id = Guid.NewGuid(),
DownloadClientConfigId = downloadClient.Id,
Enabled = enabled,
TargetCategory = targetCategory,
Categories = categories ?? ["completed"],
IgnoredRootDirs = ignoredRootDirs ?? []
};
context.UnlinkedConfigs.Add(config);
context.SaveChanges();
return config;
}
}
@@ -0,0 +1,420 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Seeker;
using Cleanuparr.Infrastructure.Features.Seeker.Selectors;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Seeker;
public sealed class ItemSelectorTests
{
private static readonly List<(long Id, DateTime? Added, DateTime? LastSearched)> SampleCandidates =
[
(1, new DateTime(2024, 1, 1), new DateTime(2024, 6, 1)),
(2, new DateTime(2024, 3, 1), new DateTime(2024, 5, 1)),
(3, new DateTime(2024, 5, 1), null),
(4, new DateTime(2024, 2, 1), new DateTime(2024, 7, 1)),
(5, null, new DateTime(2024, 4, 1)),
];
#region ItemSelectorFactory Tests
[Theory]
[InlineData(SelectionStrategy.OldestSearchFirst, typeof(OldestSearchFirstSelector))]
[InlineData(SelectionStrategy.OldestSearchWeighted, typeof(OldestSearchWeightedSelector))]
[InlineData(SelectionStrategy.NewestFirst, typeof(NewestFirstSelector))]
[InlineData(SelectionStrategy.NewestWeighted, typeof(NewestWeightedSelector))]
[InlineData(SelectionStrategy.BalancedWeighted, typeof(BalancedWeightedSelector))]
[InlineData(SelectionStrategy.Random, typeof(RandomSelector))]
public void Factory_Create_ReturnsCorrectSelectorType(SelectionStrategy strategy, Type expectedType)
{
var selector = ItemSelectorFactory.Create(strategy);
Assert.IsType(expectedType, selector);
}
[Fact]
public void Factory_Create_InvalidStrategy_ThrowsArgumentOutOfRangeException()
{
Assert.Throws<ArgumentOutOfRangeException>(() => ItemSelectorFactory.Create((SelectionStrategy)999));
}
#endregion
#region NewestFirstSelector Tests
[Fact]
public void NewestFirst_Select_OrdersByAddedDescending()
{
var selector = new NewestFirstSelector();
var result = selector.Select(SampleCandidates, 3);
// Newest added: 3 (May), 2 (Mar), 4 (Feb)
Assert.Equal([3, 2, 4], result);
}
[Fact]
public void NewestFirst_Select_NullAddedDates_TreatedAsOldest()
{
var selector = new NewestFirstSelector();
// Select all — item 5 (null Added) should be last
var result = selector.Select(SampleCandidates, 5);
Assert.Equal(5, result.Last());
}
[Fact]
public void NewestFirst_Select_ReturnsRequestedCount()
{
var selector = new NewestFirstSelector();
var result = selector.Select(SampleCandidates, 2);
Assert.Equal(2, result.Count);
}
[Fact]
public void NewestFirst_Select_EmptyInput_ReturnsEmptyList()
{
var selector = new NewestFirstSelector();
var result = selector.Select([], 5);
Assert.Empty(result);
}
[Fact]
public void NewestFirst_Select_CountExceedsCandidates_ReturnsAll()
{
var selector = new NewestFirstSelector();
var result = selector.Select(SampleCandidates, 100);
Assert.Equal(5, result.Count);
}
#endregion
#region OldestSearchFirstSelector Tests
[Fact]
public void OldestSearchFirst_Select_OrdersByLastSearchedAscending()
{
var selector = new OldestSearchFirstSelector();
var result = selector.Select(SampleCandidates, 3);
// Never searched first (null → MinValue), then oldest: 3 (null), 5 (Apr), 2 (May)
Assert.Equal([3, 5, 2], result);
}
[Fact]
public void OldestSearchFirst_Select_NullLastSearched_PrioritizedFirst()
{
var selector = new OldestSearchFirstSelector();
var result = selector.Select(SampleCandidates, 1);
// Item 3 has LastSearched = null, should be first
Assert.Equal(3, result[0]);
}
[Fact]
public void OldestSearchFirst_Select_ReturnsRequestedCount()
{
var selector = new OldestSearchFirstSelector();
var result = selector.Select(SampleCandidates, 2);
Assert.Equal(2, result.Count);
}
[Fact]
public void OldestSearchFirst_Select_EmptyInput_ReturnsEmptyList()
{
var selector = new OldestSearchFirstSelector();
var result = selector.Select([], 5);
Assert.Empty(result);
}
#endregion
#region RandomSelector Tests
[Fact]
public void Random_Select_ReturnsRequestedCount()
{
var selector = new RandomSelector();
var result = selector.Select(SampleCandidates, 3);
Assert.Equal(3, result.Count);
}
[Fact]
public void Random_Select_CountExceedsCandidates_ReturnsAll()
{
var selector = new RandomSelector();
var result = selector.Select(SampleCandidates, 100);
Assert.Equal(5, result.Count);
}
[Fact]
public void Random_Select_EmptyInput_ReturnsEmptyList()
{
var selector = new RandomSelector();
var result = selector.Select([], 5);
Assert.Empty(result);
}
[Fact]
public void Random_Select_NoDuplicateIds()
{
var selector = new RandomSelector();
var result = selector.Select(SampleCandidates, 5);
Assert.Equal(result.Count, result.Distinct().Count());
}
[Fact]
public void Random_Select_ResultsAreSubsetOfInput()
{
var selector = new RandomSelector();
var inputIds = SampleCandidates.Select(c => c.Id).ToHashSet();
var result = selector.Select(SampleCandidates, 3);
Assert.All(result, id => Assert.Contains(id, inputIds));
}
#endregion
#region NewestWeightedSelector Tests
[Fact]
public void NewestWeighted_Select_ReturnsRequestedCount()
{
var selector = new NewestWeightedSelector();
var result = selector.Select(SampleCandidates, 3);
Assert.Equal(3, result.Count);
}
[Fact]
public void NewestWeighted_Select_EmptyInput_ReturnsEmptyList()
{
var selector = new NewestWeightedSelector();
var result = selector.Select([], 5);
Assert.Empty(result);
}
[Fact]
public void NewestWeighted_Select_CountExceedsCandidates_ReturnsAll()
{
var selector = new NewestWeightedSelector();
var result = selector.Select(SampleCandidates, 100);
Assert.Equal(5, result.Count);
}
[Fact]
public void NewestWeighted_Select_NoDuplicateIds()
{
var selector = new NewestWeightedSelector();
var result = selector.Select(SampleCandidates, 5);
Assert.Equal(result.Count, result.Distinct().Count());
}
[Fact]
public void NewestWeighted_Select_SingleCandidate_ReturnsThatCandidate()
{
var selector = new NewestWeightedSelector();
List<(long Id, DateTime? Added, DateTime? LastSearched)> single = [(42, DateTime.UtcNow, null)];
var result = selector.Select(single, 1);
Assert.Single(result);
Assert.Equal(42, result[0]);
}
#endregion
#region OldestSearchWeightedSelector Tests
[Fact]
public void OldestSearchWeighted_Select_ReturnsRequestedCount()
{
var selector = new OldestSearchWeightedSelector();
var result = selector.Select(SampleCandidates, 3);
Assert.Equal(3, result.Count);
}
[Fact]
public void OldestSearchWeighted_Select_EmptyInput_ReturnsEmptyList()
{
var selector = new OldestSearchWeightedSelector();
var result = selector.Select([], 5);
Assert.Empty(result);
}
[Fact]
public void OldestSearchWeighted_Select_CountExceedsCandidates_ReturnsAll()
{
var selector = new OldestSearchWeightedSelector();
var result = selector.Select(SampleCandidates, 100);
Assert.Equal(5, result.Count);
}
[Fact]
public void OldestSearchWeighted_Select_NoDuplicateIds()
{
var selector = new OldestSearchWeightedSelector();
var result = selector.Select(SampleCandidates, 5);
Assert.Equal(result.Count, result.Distinct().Count());
}
[Fact]
public void OldestSearchWeighted_Select_SingleCandidate_ReturnsThatCandidate()
{
var selector = new OldestSearchWeightedSelector();
List<(long Id, DateTime? Added, DateTime? LastSearched)> single = [(42, DateTime.UtcNow, null)];
var result = selector.Select(single, 1);
Assert.Single(result);
Assert.Equal(42, result[0]);
}
#endregion
#region BalancedWeightedSelector Tests
[Fact]
public void BalancedWeighted_Select_ReturnsRequestedCount()
{
var selector = new BalancedWeightedSelector();
var result = selector.Select(SampleCandidates, 3);
Assert.Equal(3, result.Count);
}
[Fact]
public void BalancedWeighted_Select_EmptyInput_ReturnsEmptyList()
{
var selector = new BalancedWeightedSelector();
var result = selector.Select([], 5);
Assert.Empty(result);
}
[Fact]
public void BalancedWeighted_Select_CountExceedsCandidates_ReturnsAll()
{
var selector = new BalancedWeightedSelector();
var result = selector.Select(SampleCandidates, 100);
Assert.Equal(5, result.Count);
}
[Fact]
public void BalancedWeighted_Select_NoDuplicateIds()
{
var selector = new BalancedWeightedSelector();
var result = selector.Select(SampleCandidates, 5);
Assert.Equal(result.Count, result.Distinct().Count());
}
[Fact]
public void BalancedWeighted_Select_SingleCandidate_ReturnsThatCandidate()
{
var selector = new BalancedWeightedSelector();
List<(long Id, DateTime? Added, DateTime? LastSearched)> single = [(42, DateTime.UtcNow, null)];
var result = selector.Select(single, 1);
Assert.Single(result);
Assert.Equal(42, result[0]);
}
[Fact]
public void BalancedWeighted_Select_ResultsAreSubsetOfInput()
{
var selector = new BalancedWeightedSelector();
var inputIds = SampleCandidates.Select(c => c.Id).ToHashSet();
var result = selector.Select(SampleCandidates, 3);
Assert.All(result, id => Assert.Contains(id, inputIds));
}
#endregion
#region WeightedRandomByRank Tests
[Fact]
public void WeightedRandomByRank_ReturnsRequestedCount()
{
var ranked = SampleCandidates.OrderBy(c => c.LastSearched ?? DateTime.MinValue).ToList();
var result = OldestSearchWeightedSelector.WeightedRandomByRank(ranked, 3);
Assert.Equal(3, result.Count);
}
[Fact]
public void WeightedRandomByRank_CountExceedsCandidates_ReturnsAll()
{
var ranked = SampleCandidates.OrderBy(c => c.LastSearched ?? DateTime.MinValue).ToList();
var result = OldestSearchWeightedSelector.WeightedRandomByRank(ranked, 100);
Assert.Equal(5, result.Count);
}
[Fact]
public void WeightedRandomByRank_NoDuplicateIds()
{
var ranked = SampleCandidates.OrderBy(c => c.LastSearched ?? DateTime.MinValue).ToList();
var result = OldestSearchWeightedSelector.WeightedRandomByRank(ranked, 5);
Assert.Equal(result.Count, result.Distinct().Count());
}
[Fact]
public void WeightedRandomByRank_EmptyInput_ReturnsEmptyList()
{
var result = OldestSearchWeightedSelector.WeightedRandomByRank([], 5);
Assert.Empty(result);
}
#endregion
}
@@ -1,80 +0,0 @@
// using Common.Configuration;
// using Common.Enums;
// using Infrastructure.Configuration;
// using Infrastructure.Http;
// using Infrastructure.Services;
// using Microsoft.Extensions.Logging;
// using NSubstitute;
//
// namespace Infrastructure.Tests.Http;
//
// public class DynamicHttpClientProviderFixture : IDisposable
// {
// public ILogger<DynamicHttpClientProvider> Logger { get; }
//
// public DynamicHttpClientProviderFixture()
// {
// Logger = Substitute.For<ILogger<DynamicHttpClientProvider>>();
// }
//
// public DynamicHttpClientProvider CreateSut()
// {
// var httpClientFactory = Substitute.For<IHttpClientFactory>();
// var configManager = Substitute.For<IConfigManager>();
// var certificateValidationService = Substitute.For<CertificateValidationService>();
//
// return new DynamicHttpClientProvider(
// Logger,
// httpClientFactory,
// configManager,
// certificateValidationService);
// }
//
// public DownloadClientConfig CreateQBitClientConfig()
// {
// return new DownloadClientConfig
// {
// Id = Guid.NewGuid(),
// Name = "QBit Test",
// Type = DownloadClientType.QBittorrent,
// Enabled = true,
// Host = new("http://localhost:8080"),
// Username = "admin",
// Password = "adminadmin"
// };
// }
//
// public DownloadClientConfig CreateTransmissionClientConfig()
// {
// return new DownloadClientConfig
// {
// Id = Guid.NewGuid(),
// Name = "Transmission Test",
// Type = DownloadClientType.Transmission,
// Enabled = true,
// Host = new("http://localhost:9091"),
// Username = "admin",
// Password = "adminadmin",
// UrlBase = "transmission"
// };
// }
//
// public DownloadClientConfig CreateDelugeClientConfig()
// {
// return new DownloadClientConfig
// {
// Id = Guid.NewGuid(),
// Name = "Deluge Test",
// Type = DownloadClientType.Deluge,
// Enabled = true,
// Host = new("http://localhost:8112"),
// Username = "admin",
// Password = "deluge"
// };
// }
//
// public void Dispose()
// {
// // Cleanup if needed
// }
// }
@@ -1,133 +0,0 @@
// using System.Net;
// using Common.Enums;
// using Infrastructure.Http;
// using Shouldly;
//
// namespace Infrastructure.Tests.Http;
//
// public class DynamicHttpClientProviderTests : IClassFixture<DynamicHttpClientProviderFixture>
// {
// private readonly DynamicHttpClientProviderFixture _fixture;
//
// public DynamicHttpClientProviderTests(DynamicHttpClientProviderFixture fixture)
// {
// _fixture = fixture;
// }
//
// [Fact]
// public void CreateClient_WithQBitConfig_ShouldReturnConfiguredClient()
// {
// // Arrange
// var sut = _fixture.CreateSut();
// var config = _fixture.CreateQBitClientConfig();
//
// // Act
// var httpClient = sut.CreateClient(config);
//
// // Assert
// httpClient.ShouldNotBeNull();
// httpClient.BaseAddress.ShouldBe(config.Url);
// VerifyDefaultHttpClientProperties(httpClient);
// }
//
// [Fact]
// public void CreateClient_WithTransmissionConfig_ShouldReturnConfiguredClient()
// {
// // Arrange
// var sut = _fixture.CreateSut();
// var config = _fixture.CreateTransmissionClientConfig();
//
// // Act
// var httpClient = sut.CreateClient(config);
//
// // Assert
// httpClient.ShouldNotBeNull();
// httpClient.BaseAddress.ShouldBe(config.Url);
// VerifyDefaultHttpClientProperties(httpClient);
// }
//
// [Fact]
// public void CreateClient_WithDelugeConfig_ShouldReturnConfiguredClient()
// {
// // Arrange
// var sut = _fixture.CreateSut();
// var config = _fixture.CreateDelugeClientConfig();
//
// // Act
// var httpClient = sut.CreateClient(config);
//
// // Assert
// httpClient.ShouldNotBeNull();
// httpClient.BaseAddress.ShouldBe(config.Url);
//
// // Deluge client should have additional properties configured
// VerifyDelugeHttpClientProperties(httpClient);
// }
//
// [Fact]
// public void CreateClient_WithSameConfig_ShouldReturnUniqueInstances()
// {
// // Arrange
// var sut = _fixture.CreateSut();
// var config = _fixture.CreateQBitClientConfig();
//
// // Act
// var firstClient = sut.CreateClient(config);
// var secondClient = sut.CreateClient(config);
//
// // Assert
// firstClient.ShouldNotBeNull();
// secondClient.ShouldNotBeNull();
// firstClient.ShouldNotBeSameAs(secondClient); // Should be different instances
// }
//
// [Fact]
// public void CreateClient_WithCustomCertificateValidation_ShouldConfigureHandler()
// {
// // Arrange
// var sut = _fixture.CreateSut();
// var config = _fixture.CreateQBitClientConfig();
//
// // Act
// var httpClient = sut.CreateClient(config);
//
// // Assert
// httpClient.ShouldNotBeNull();
//
// // Since we can't directly access the handler settings after creation,
// // we verify the behavior is working by checking if the client can be created properly
// httpClient.BaseAddress.ShouldBe(config.Url);
// }
//
// [Fact]
// public void CreateClient_WithTimeout_ShouldConfigureTimeout()
// {
// // Arrange
// var sut = _fixture.CreateSut();
// var config = _fixture.CreateQBitClientConfig();
// TimeSpan expectedTimeout = TimeSpan.FromSeconds(30);
//
// // Act
// var httpClient = sut.CreateClient(config);
//
// // Assert
// httpClient.Timeout.ShouldBe(expectedTimeout);
// }
//
// private void VerifyDefaultHttpClientProperties(HttpClient httpClient)
// {
// // Check common properties that should be set for all clients
// httpClient.Timeout.ShouldBe(TimeSpan.FromSeconds(30));
// httpClient.DefaultRequestHeaders.ShouldNotBeNull();
// }
//
// private void VerifyDelugeHttpClientProperties(HttpClient httpClient)
// {
// // Verify Deluge-specific HTTP client configurations
// VerifyDefaultHttpClientProperties(httpClient);
//
// // Using reflection to access the handler is tricky and potentially brittle
// // Instead, we focus on verifying the client itself is properly configured
// httpClient.BaseAddress.ShouldNotBeNull();
// }
// }
@@ -11,6 +11,8 @@ using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Events;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Events;
@@ -57,6 +59,8 @@ public class EventPublisher : IEventPublisher
TrackingId = trackingId,
StrikeId = strikeId,
JobRunId = ContextProvider.TryGetJobRunId(),
ArrInstanceId = ContextProvider.Get(ContextProvider.Keys.ArrInstanceId) as Guid?,
DownloadClientId = ContextProvider.Get(ContextProvider.Keys.DownloadClientId) as Guid?,
InstanceType = ContextProvider.Get(nameof(InstanceType)) is InstanceType it ? it : null,
InstanceUrl = (ContextProvider.Get(ContextProvider.Keys.ArrInstanceUrl) as Uri)?.ToString(),
DownloadClientType = ContextProvider.Get(ContextProvider.Keys.DownloadClientType) is DownloadClientTypeName dct ? dct : null,
@@ -64,7 +68,9 @@ public class EventPublisher : IEventPublisher
};
eventEntity.IsDryRun = isDryRun ?? await _dryRunInterceptor.IsDryRunEnabled();
await SaveEventToDatabase(eventEntity);
_context.Events.Add(eventEntity);
await _context.SaveChangesAsync();
await NotifyClientsAsync(eventEntity);
@@ -89,7 +95,9 @@ public class EventPublisher : IEventPublisher
};
eventEntity.IsDryRun = isDryRun ?? await _dryRunInterceptor.IsDryRunEnabled();
await SaveManualEventToDatabase(eventEntity);
_context.ManualEvents.Add(eventEntity);
await _context.SaveChangesAsync();
await NotifyClientsAsync(eventEntity);
@@ -227,6 +235,85 @@ public class EventPublisher : IEventPublisher
);
}
/// <summary>
/// Publishes a search triggered event with context data and notifications.
/// Returns the event ID so the SeekerCommandMonitor can update it on completion.
/// </summary>
public async Task<Guid> PublishSearchTriggered(string itemTitle, SeekerSearchType searchType, SeekerSearchReason searchReason, Guid? cycleId = null)
{
AppEvent eventEntity = new()
{
EventType = EventType.SearchTriggered,
Message = $"Search triggered for {itemTitle}",
Severity = EventSeverity.Information,
SearchStatus = SearchCommandStatus.Pending,
JobRunId = ContextProvider.TryGetJobRunId(),
ArrInstanceId = ContextProvider.Get(ContextProvider.Keys.ArrInstanceId) as Guid?,
DownloadClientId = ContextProvider.Get(ContextProvider.Keys.DownloadClientId) as Guid?,
InstanceType = ContextProvider.Get(nameof(InstanceType)) is InstanceType it ? it : null,
InstanceUrl = (ContextProvider.Get(ContextProvider.Keys.ArrInstanceUrl) as Uri)?.ToString(),
DownloadClientType = ContextProvider.Get(ContextProvider.Keys.DownloadClientType) is DownloadClientTypeName dct ? dct : null,
DownloadClientName = ContextProvider.Get(ContextProvider.Keys.DownloadClientName) as string,
CycleId = cycleId,
};
eventEntity.IsDryRun = await _dryRunInterceptor.IsDryRunEnabled();
await using IDbContextTransaction transaction = await _context.Database.BeginTransactionAsync();
try
{
_context.Events.Add(eventEntity);
_context.SearchEventData.Add(new SearchEventData
{
AppEventId = eventEntity.Id,
ItemTitle = itemTitle,
SearchType = searchType,
SearchReason = searchReason,
});
await _context.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
await NotifyClientsAsync(eventEntity);
await _notificationPublisher.NotifySearchTriggered(itemTitle, searchType, searchReason);
return eventEntity.Id;
}
/// <summary>
/// Updates an existing search event with completion status and optional grabbed item titles
/// </summary>
public async Task PublishSearchCompleted(Guid eventId, SearchCommandStatus status, List<string>? grabbedItems = null)
{
var existingEvent = await _context.Events
.Include(e => e.SearchEventData)
.FirstOrDefaultAsync(e => e.Id == eventId);
if (existingEvent is null)
{
_logger.LogWarning("Could not find search event {EventId} to update completion status", eventId);
return;
}
existingEvent.SearchStatus = status;
existingEvent.CompletedAt = DateTime.UtcNow;
if (grabbedItems is { Count: > 0 } && existingEvent.SearchEventData is not null)
{
existingEvent.SearchEventData.GrabbedItems = grabbedItems;
}
await _context.SaveChangesAsync();
await NotifyClientsAsync(existingEvent);
}
/// <summary>
/// Publishes an event alerting that search was not triggered for an item
/// </summary>
@@ -239,18 +326,6 @@ public class EventPublisher : IEventPublisher
);
}
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)
{
try
@@ -19,4 +19,8 @@ public interface IEventPublisher
Task PublishRecurringItem(string hash, string itemName, int strikeCount);
Task PublishSearchNotTriggered(string hash, string itemName);
Task<Guid> PublishSearchTriggered(string itemTitle, SeekerSearchType searchType, SeekerSearchReason searchReason, Guid? cycleId = null);
Task PublishSearchCompleted(Guid eventId, SearchCommandStatus status, List<string>? grabbedItems = null);
}
@@ -1,3 +1,4 @@
using Cleanuparr.Domain.Entities.Arr;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
@@ -7,7 +8,6 @@ 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 Microsoft.Extensions.Logging;
using Newtonsoft.Json;
@@ -41,8 +41,8 @@ public abstract class ArrClient : IArrClient
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request);
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
try
{
@@ -53,18 +53,46 @@ public abstract class ArrClient : IArrClient
_logger.LogError("queue list failed | {uri}", uriBuilder.Uri);
throw;
}
string responseBody = await response.Content.ReadAsStringAsync();
QueueListResponse? queueResponse = JsonConvert.DeserializeObject<QueueListResponse>(responseBody);
QueueListResponse? queueResponse = await DeserializeStreamAsync<QueueListResponse>(response);
if (queueResponse is null)
{
throw new Exception($"unrecognized queue list response | {uriBuilder.Uri} | {responseBody}");
throw new Exception($"unrecognized queue list response | {uriBuilder.Uri}");
}
return queueResponse;
}
public async Task<int> GetActiveDownloadCountAsync(ArrInstance arrInstance)
{
int count = 0;
int page = 1;
int processed = 0;
while (true)
{
QueueListResponse response = await GetQueueItemsAsync(arrInstance, page);
if (response.Records.Count == 0)
{
break;
}
count += response.Records.Count(r => r.SizeLeft > 0);
processed += response.Records.Count;
if (processed >= response.TotalRecords)
{
break;
}
page++;
}
return count;
}
public virtual async Task<bool> ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload, short arrMaxStrikes)
{
var queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>();
@@ -166,7 +194,7 @@ public abstract class ArrClient : IArrClient
}
}
public abstract Task SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items);
public abstract Task<List<long>> SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items);
public bool IsRecordValid(QueueRecord record)
{
@@ -197,6 +225,23 @@ public abstract class ArrClient : IArrClient
_logger.LogDebug("Connection test successful for {url}", arrInstance.Url);
}
/// <inheritdoc/>
public async Task<ArrCommandStatus> GetCommandStatusAsync(ArrInstance arrInstance, long commandId)
{
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/command/{commandId}";
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
var result = await DeserializeStreamAsync<ArrCommandStatus>(response);
return result ?? new ArrCommandStatus(commandId, "unknown", null);
}
protected abstract string GetSystemStatusUrlPath();
protected abstract string GetQueueUrlPath();
@@ -221,6 +266,26 @@ public abstract class ArrClient : IArrClient
return response;
}
protected static async Task<T?> DeserializeStreamAsync<T>(HttpResponseMessage response)
{
using Stream stream = await response.Content.ReadAsStreamAsync();
using StreamReader sr = new(stream);
using JsonTextReader reader = new(sr);
return JsonSerializer.CreateDefault().Deserialize<T>(reader);
}
protected static async Task<long?> ReadCommandIdAsync(HttpResponseMessage response)
{
CommandIdResponse? result = await DeserializeStreamAsync<CommandIdResponse>(response);
return result?.Id;
}
private sealed class CommandIdResponse
{
[JsonProperty("id")]
public long? Id { get; init; }
}
/// <summary>
/// Determines whether the failed import record should be skipped
/// </summary>
@@ -264,4 +329,6 @@ public abstract class ArrClient : IArrClient
return true;
}
public abstract Task<List<Tag>> GetAllTagsAsync(ArrInstance arrInstance);
}
@@ -1,7 +1,7 @@
using Cleanuparr.Domain.Entities.Arr;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces;
@@ -12,9 +12,17 @@ public interface IArrClient
Task<bool> ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload, short arrMaxStrikes);
Task DeleteQueueItemAsync(ArrInstance arrInstance, QueueRecord record, bool removeFromClient, DeleteReason deleteReason);
Task SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items);
/// <summary>
/// Triggers a search for the specified items and returns the arr command IDs
/// </summary>
Task<List<long>> SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items);
/// <summary>
/// Gets the status of an arr command by its ID
/// </summary>
Task<ArrCommandStatus> GetCommandStatusAsync(ArrInstance arrInstance, long commandId);
bool IsRecordValid(QueueRecord record);
/// <summary>
@@ -30,4 +38,12 @@ public interface IArrClient
/// <param name="arrInstance">The instance to test connection to</param>
/// <returns>Task that completes when the connection test is done</returns>
Task HealthCheckAsync(ArrInstance arrInstance);
/// <summary>
/// Returns the number of items actively downloading (SizeLeft > 0) across all queue pages.
/// Items that are completed, import-blocked, or otherwise finished are not counted.
/// </summary>
Task<int> GetActiveDownloadCountAsync(ArrInstance arrInstance);
Task<List<Tag>> GetAllTagsAsync(ArrInstance arrInstance);
}
@@ -1,5 +1,22 @@
namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Domain.Entities.Arr;
using Cleanuparr.Persistence.Models.Configuration.Arr;
namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces;
public interface IRadarrClient : IArrClient
{
/// <summary>
/// Fetches all movies from a Radarr instance
/// </summary>
Task<List<SearchableMovie>> GetAllMoviesAsync(ArrInstance arrInstance);
/// <summary>
/// Fetches quality profiles from a Radarr instance
/// </summary>
Task<List<ArrQualityProfile>> GetQualityProfilesAsync(ArrInstance arrInstance);
/// <summary>
/// Fetches custom format scores for movie files in batches
/// </summary>
Task<Dictionary<long, int>> GetMovieFileScoresAsync(ArrInstance arrInstance, List<long> movieFileIds);
}
@@ -1,5 +1,32 @@
namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Domain.Entities.Arr;
using Cleanuparr.Persistence.Models.Configuration.Arr;
namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces;
public interface ISonarrClient : IArrClient
{
/// <summary>
/// Fetches all series from a Sonarr instance
/// </summary>
Task<List<SearchableSeries>> GetAllSeriesAsync(ArrInstance arrInstance);
/// <summary>
/// Fetches all episodes for a specific series from a Sonarr instance
/// </summary>
Task<List<SearchableEpisode>> GetEpisodesAsync(ArrInstance arrInstance, long seriesId);
/// <summary>
/// Fetches quality profiles from a Sonarr instance
/// </summary>
Task<List<ArrQualityProfile>> GetQualityProfilesAsync(ArrInstance arrInstance);
/// <summary>
/// Fetches episode file metadata for a specific series, including quality cutoff status
/// </summary>
Task<List<ArrEpisodeFile>> GetEpisodeFilesAsync(ArrInstance arrInstance, long seriesId);
/// <summary>
/// Fetches custom format scores for episode files in batches
/// </summary>
Task<Dictionary<long, int>> GetEpisodeFileScoresAsync(ArrInstance arrInstance, List<long> episodeFileIds);
}
@@ -1,11 +1,11 @@
using System.Text;
using Cleanuparr.Domain.Entities.Arr;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Entities.Lidarr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
@@ -50,11 +50,11 @@ public class LidarrClient : ArrClient, ILidarrClient
return query;
}
public override async Task SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
public override async Task<List<long>> SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
{
if (items?.Count is null or 0)
{
return;
return [];
}
UriBuilder uriBuilder = new(arrInstance.Url);
@@ -85,6 +85,8 @@ public class LidarrClient : ArrClient, ILidarrClient
throw;
}
}
return [];
}
public override bool HasContentId(QueueRecord record) => record.ArtistId is not 0 && record.AlbumId is not 0;
@@ -137,15 +139,14 @@ public class LidarrClient : ArrClient, ILidarrClient
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v1/album";
uriBuilder.Query = string.Join('&', albumIds.Select(x => $"albumIds={x}"));
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
using var response = await _httpClient.SendAsync(request);
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
string responseBody = await response.Content.ReadAsStringAsync();
return JsonConvert.DeserializeObject<List<Album>>(responseBody);
return await DeserializeStreamAsync<List<Album>>(response);
}
private List<LidarrCommand> GetSearchCommands(HashSet<SearchItem> items)
@@ -154,4 +155,9 @@ public class LidarrClient : ArrClient, ILidarrClient
return [new LidarrCommand { Name = albumSearch, AlbumIds = items.Select(i => i.Id).ToList() }];
}
public override async Task<List<Tag>> GetAllTagsAsync(ArrInstance arrInstance)
{
throw new NotImplementedException();
}
}
@@ -1,11 +1,11 @@
using System.Text;
using Cleanuparr.Domain.Entities.Arr;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Entities.Radarr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
@@ -50,24 +50,24 @@ public class RadarrClient : ArrClient, IRadarrClient
return query;
}
public override async Task SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
public override async Task<List<long>> SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
{
if (items?.Count is null or 0)
{
return;
return [];
}
List<long> ids = items.Select(item => item.Id).ToList();
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/command";
RadarrCommand command = new()
{
Name = "MoviesSearch",
MovieIds = ids,
};
using HttpRequestMessage request = new(HttpMethod.Post, uriBuilder.Uri);
request.Content = new StringContent(
JsonConvert.SerializeObject(command),
@@ -81,9 +81,18 @@ public class RadarrClient : ArrClient, IRadarrClient
try
{
HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync<HttpResponseMessage>(SendRequestAsync, request);
response?.Dispose();
if (response is null)
{
return [];
}
long? commandId = await ReadCommandIdAsync(response);
response.Dispose();
_logger.LogInformation("{log}", GetSearchLog(arrInstance.Url, command, true, logContext));
return commandId.HasValue ? [commandId.Value] : [];
}
catch
{
@@ -130,18 +139,94 @@ public class RadarrClient : ArrClient, IRadarrClient
return null;
}
public async Task<List<SearchableMovie>> GetAllMoviesAsync(ArrInstance arrInstance)
{
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/movie";
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
using Stream stream = await response.Content.ReadAsStreamAsync();
using StreamReader sr = new(stream);
using JsonTextReader reader = new(sr);
JsonSerializer serializer = JsonSerializer.CreateDefault();
return serializer.Deserialize<List<SearchableMovie>>(reader) ?? [];
}
public override async Task<List<Tag>> GetAllTagsAsync(ArrInstance arrInstance)
{
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/tag";
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
using Stream stream = await response.Content.ReadAsStreamAsync();
using StreamReader sr = new(stream);
using JsonTextReader reader = new(sr);
JsonSerializer serializer = JsonSerializer.CreateDefault();
return serializer.Deserialize<List<Tag>>(reader) ?? [];
}
public async Task<List<ArrQualityProfile>> GetQualityProfilesAsync(ArrInstance arrInstance)
{
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/qualityprofile";
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
return await DeserializeStreamAsync<List<ArrQualityProfile>>(response) ?? [];
}
public async Task<Dictionary<long, int>> GetMovieFileScoresAsync(ArrInstance arrInstance, List<long> movieFileIds)
{
Dictionary<long, int> scores = new();
// Batch in chunks of 100 to avoid 414 URI Too Long
foreach (long[] batch in movieFileIds.Chunk(100))
{
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/moviefile";
uriBuilder.Query = string.Join('&', batch.Select(id => $"movieFileIds={id}"));
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
List<MediaFileScore> files = await DeserializeStreamAsync<List<MediaFileScore>>(response) ?? [];
foreach (MediaFileScore file in files)
{
scores[file.Id] = file.CustomFormatScore;
}
}
return scores;
}
private async Task<Movie?> GetMovie(ArrInstance arrInstance, long movieId)
{
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/movie/{movieId}";
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request);
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
string responseBody = await response.Content.ReadAsStringAsync();
return JsonConvert.DeserializeObject<Movie>(responseBody);
return await DeserializeStreamAsync<Movie>(response);
}
}
Loaded 100 of 276 files, more files were not shown because too many files have changed in this diff. Show more