Compare commits

...
5 Commits
73 changed files with 6013 additions and 1741 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()`)
@@ -77,6 +77,24 @@ public class CustomFormatScoreControllerTests : IDisposable
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()
{
@@ -314,7 +332,8 @@ public class CustomFormatScoreControllerTests : IDisposable
int currentScore,
int cutoffScore,
InstanceType itemType = InstanceType.Radarr,
DateTime? lastSynced = null)
DateTime? lastSynced = null,
bool isMonitored = true)
{
_dataContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
{
@@ -327,6 +346,7 @@ public class CustomFormatScoreControllerTests : IDisposable
CurrentScore = currentScore,
CutoffScore = cutoffScore,
QualityProfileName = "HD",
IsMonitored = isMonitored,
LastSyncedAt = lastSynced ?? DateTime.UtcNow
});
_dataContext.SaveChanges();
@@ -87,11 +87,7 @@ public static class SeekerTestDataFactory
context.DownloadCleanerConfigs.Add(new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
IgnoredDownloads = [],
Categories = [],
UnlinkedEnabled = false,
UnlinkedTargetCategory = "",
UnlinkedCategories = []
IgnoredDownloads = []
});
context.SeekerConfigs.Add(new SeekerConfig
@@ -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);
}
}
@@ -28,7 +28,8 @@ public sealed class CustomFormatScoreController : ControllerBase
[FromQuery] Guid? instanceId = null,
[FromQuery] string? search = null,
[FromQuery] string sortBy = "title",
[FromQuery] bool hideMet = false)
[FromQuery] bool hideMet = false,
[FromQuery] bool hideUnmonitored = false)
{
if (page < 1) page = 1;
if (pageSize < 1) pageSize = 50;
@@ -53,6 +54,11 @@ public sealed class CustomFormatScoreController : ControllerBase
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"
@@ -12,7 +12,7 @@ public sealed record SearchableMovie
public MovieFileInfo? MovieFile { get; init; }
public List<string> Tags { get; init; } = [];
public List<long> Tags { get; init; } = [];
public int QualityProfileId { get; init; }
@@ -10,20 +10,11 @@ public sealed record SearchableSeries
public bool Monitored { get; init; }
public List<string> Tags { 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; }
}
public sealed record SeriesStatistics
{
public int EpisodeFileCount { get; init; }
public int EpisodeCount { get; init; }
public double PercentOfEpisodes { get; init; }
}
@@ -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; }
}
@@ -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(
@@ -658,12 +666,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 wrapper = new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Test", Label = "movies", BasePath = "/downloads" });
var downloads = new List<ITorrentItemWrapper> { wrapper };
@@ -680,7 +687,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);
@@ -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
}
@@ -56,6 +56,14 @@ public class SeekerTests : IDisposable
// Default: dry run disabled
_dryRunInterceptor.Setup(x => x.IsDryRunEnabled()).ReturnsAsync(false);
// Default: GetAllTagsAsync returns empty list
_radarrClient
.Setup(x => x.GetAllTagsAsync(It.IsAny<ArrInstance>()))
.ReturnsAsync([]);
_sonarrClient
.Setup(x => x.GetAllTagsAsync(It.IsAny<ArrInstance>()))
.ReturnsAsync([]);
// Default: PublishSearchTriggered returns a Guid
_fixture.EventPublisher
.Setup(x => x.PublishSearchTriggered(
@@ -672,8 +680,16 @@ public class SeekerTests : IDisposable
.Setup(x => x.GetAllMoviesAsync(radarrInstance))
.ReturnsAsync(
[
new SearchableMovie { Id = 1, Title = "Normal Movie", Status = "released", Monitored = true, Tags = ["movies"] },
new SearchableMovie { Id = 2, Title = "Skipped Movie", Status = "released", Monitored = true, Tags = ["no-search", "movies"] }
new SearchableMovie { Id = 1, Title = "Normal Movie", Status = "released", Monitored = true, Tags = [1] },
new SearchableMovie { Id = 2, Title = "Skipped Movie", Status = "released", Monitored = true, Tags = [2, 1] }
]);
_radarrClient
.Setup(x => x.GetAllTagsAsync(radarrInstance))
.ReturnsAsync(
[
new Tag { Id = 1, Label = "movies" },
new Tag { Id = 2, Label = "no-search" }
]);
HashSet<SearchItem>? capturedSearchItems = null;
@@ -88,11 +88,7 @@ public static class TestDataContextFactory
context.DownloadCleanerConfigs.Add(new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
IgnoredDownloads = [],
Categories = [],
UnlinkedEnabled = false,
UnlinkedTargetCategory = "",
UnlinkedCategories = []
IgnoredDownloads = []
});
// Seeker config
@@ -315,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,
@@ -325,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,
@@ -335,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;
}
}
@@ -330,4 +330,6 @@ public abstract class ArrClient : IArrClient
return true;
}
public abstract Task<List<Tag>> GetAllTagsAsync(ArrInstance arrInstance);
}
@@ -45,4 +45,6 @@ public interface IArrClient
/// 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,4 +1,5 @@
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;
@@ -155,4 +156,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();
}
}
@@ -157,6 +157,23 @@ public class RadarrClient : ArrClient, IRadarrClient
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)
{
@@ -1,4 +1,5 @@
using System.Text;
using Cleanuparr.Domain.Entities.Arr;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Entities.Readarr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
@@ -145,4 +146,9 @@ public class ReadarrClient : ArrClient, IReadarrClient
return await DeserializeStreamAsync<Book>(response);
}
public override async Task<List<Tag>> GetAllTagsAsync(ArrInstance arrInstance)
{
throw new NotImplementedException();
}
}
@@ -227,6 +227,23 @@ public class SonarrClient : ArrClient, ISonarrClient
return serializer.Deserialize<List<SearchableSeries>>(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<SearchableEpisode>> GetEpisodesAsync(ArrInstance arrInstance, long seriesId)
{
UriBuilder uriBuilder = new(arrInstance.Url);
@@ -277,4 +277,9 @@ public class WhisparrV2Client : ArrClient, IWhisparrV2Client
return commands;
}
public override async Task<List<Tag>> GetAllTagsAsync(ArrInstance arrInstance)
{
throw new NotImplementedException();
}
}
@@ -1,4 +1,5 @@
using System.Text;
using Cleanuparr.Domain.Entities.Arr;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Entities.Radarr;
using Cleanuparr.Domain.Entities.Whisparr;
@@ -146,4 +147,9 @@ public class WhisparrV3Client : ArrClient, IWhisparrV3Client
return await DeserializeStreamAsync<Movie>(response);
}
public override async Task<List<Tag>> GetAllTagsAsync(ArrInstance arrInstance)
{
throw new NotImplementedException();
}
}
@@ -1,9 +1,10 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.Deluge.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Extensions;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Shared.Helpers;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
@@ -25,15 +26,15 @@ public partial class DelugeService
.ToList();
}
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<SeedingRule> seedingRules) =>
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<ISeedingRule> seedingRules) =>
downloads
?.Where(x => seedingRules.Any(cat => cat.Name.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
.ToList();
public override List<ITorrentItemWrapper>? FilterDownloadsToChangeCategoryAsync(List<ITorrentItemWrapper>? downloads, List<string> categories) =>
public override List<ITorrentItemWrapper>? FilterDownloadsToChangeCategoryAsync(List<ITorrentItemWrapper>? downloads, UnlinkedConfig unlinkedConfig) =>
downloads
?.Where(x => !string.IsNullOrEmpty(x.Hash))
.Where(x => categories.Any(cat => cat.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
.Where(x => unlinkedConfig.Categories.Any(cat => cat.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
.ToList();
/// <inheritdoc/>
@@ -52,28 +53,26 @@ public partial class DelugeService
{
return;
}
_logger.LogDebug("Creating category {name}", name);
await _dryRunInterceptor.InterceptAsync(CreateLabel, name);
}
public override async Task ChangeCategoryForNoHardLinksAsync(List<ITorrentItemWrapper>? downloads)
public override async Task ChangeCategoryForNoHardLinksAsync(List<ITorrentItemWrapper>? downloads, UnlinkedConfig unlinkedConfig)
{
if (downloads?.Count is null or 0)
{
return;
}
var downloadCleanerConfig = ContextProvider.Get<DownloadCleanerConfig>(nameof(DownloadCleanerConfig));
foreach (DelugeItemWrapper torrent in downloads.Cast<DelugeItemWrapper>())
{
if (string.IsNullOrEmpty(torrent.Hash) || string.IsNullOrEmpty(torrent.Name) || string.IsNullOrEmpty(torrent.Category))
{
continue;
}
ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name);
ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash);
ContextProvider.Set(ContextProvider.Keys.DownloadClientUrl, _downloadClientConfig.ExternalOrInternalUrl);
@@ -98,6 +97,8 @@ public partial class DelugeService
{
string filePath = string.Join(Path.DirectorySeparatorChar, Path.Combine(torrent.Info.DownloadLocation, file.Path).Split(['\\', '/']));
filePath = PathHelper.RemapPath(filePath, unlinkedConfig.DownloadDirectorySource, unlinkedConfig.DownloadDirectoryTarget);
if (file.Priority <= 0)
{
_logger.LogDebug("skip | file is not downloaded | {file}", filePath);
@@ -105,7 +106,7 @@ public partial class DelugeService
}
long hardlinkCount = _hardLinkFileService
.GetHardLinkCount(filePath, downloadCleanerConfig.UnlinkedIgnoredRootDirs.Count > 0);
.GetHardLinkCount(filePath, unlinkedConfig.IgnoredRootDirs.Count > 0);
if (hardlinkCount < 0)
{
@@ -131,13 +132,13 @@ public partial class DelugeService
continue;
}
await _dryRunInterceptor.InterceptAsync(ChangeLabel, torrent.Hash, downloadCleanerConfig.UnlinkedTargetCategory);
await _dryRunInterceptor.InterceptAsync(ChangeLabel, torrent.Hash, unlinkedConfig.TargetCategory);
_logger.LogInformation("category changed for {name}", torrent.Name);
await _eventPublisher.PublishCategoryChanged(torrent.Category, downloadCleanerConfig.UnlinkedTargetCategory);
torrent.Category = downloadCleanerConfig.UnlinkedTargetCategory;
await _eventPublisher.PublishCategoryChanged(torrent.Category, unlinkedConfig.TargetCategory);
torrent.Category = unlinkedConfig.TargetCategory;
}
}
@@ -145,9 +146,9 @@ public partial class DelugeService
{
await _client.CreateLabel(name);
}
protected virtual async Task ChangeLabel(string hash, string newLabel)
{
await _client.SetTorrentLabel(hash, newLabel);
}
}
}
@@ -70,13 +70,13 @@ public abstract class DownloadService : IDownloadService
public abstract Task<List<ITorrentItemWrapper>> GetSeedingDownloads();
/// <inheritdoc/>
public abstract List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<SeedingRule> seedingRules);
public abstract List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<ISeedingRule> seedingRules);
/// <inheritdoc/>
public abstract List<ITorrentItemWrapper>? FilterDownloadsToChangeCategoryAsync(List<ITorrentItemWrapper>? downloads, List<string> categories);
public abstract List<ITorrentItemWrapper>? FilterDownloadsToChangeCategoryAsync(List<ITorrentItemWrapper>? downloads, UnlinkedConfig unlinkedConfig);
/// <inheritdoc/>
public virtual async Task CleanDownloadsAsync(List<ITorrentItemWrapper>? downloads, List<SeedingRule> seedingRules)
public virtual async Task CleanDownloadsAsync(List<ITorrentItemWrapper>? downloads, List<ISeedingRule> seedingRules)
{
if (downloads?.Count is null or 0)
{
@@ -90,7 +90,7 @@ public abstract class DownloadService : IDownloadService
continue;
}
SeedingRule? category = seedingRules
ISeedingRule? category = seedingRules
.FirstOrDefault(x =>
(torrent.Category ?? string.Empty).Equals(x.Name, StringComparison.InvariantCultureIgnoreCase) &&
x.PrivacyType switch
@@ -135,7 +135,7 @@ public abstract class DownloadService : IDownloadService
}
/// <inheritdoc/>
public abstract Task ChangeCategoryForNoHardLinksAsync(List<ITorrentItemWrapper>? downloads);
public abstract Task ChangeCategoryForNoHardLinksAsync(List<ITorrentItemWrapper>? downloads, UnlinkedConfig unlinkedConfig);
/// <inheritdoc/>
public abstract Task CreateCategoryAsync(string name);
@@ -151,7 +151,7 @@ public abstract class DownloadService : IDownloadService
/// <param name="deleteSourceFiles">Whether to delete the source files along with the torrent</param>
public abstract Task DeleteDownload(ITorrentItemWrapper torrent, bool deleteSourceFiles);
protected SeedingCheckResult ShouldCleanDownload(double ratio, TimeSpan seedingTime, SeedingRule category)
protected SeedingCheckResult ShouldCleanDownload(double ratio, TimeSpan seedingTime, ISeedingRule category)
{
// check ratio
if (DownloadReachedRatio(ratio, seedingTime, category))
@@ -196,7 +196,7 @@ public abstract class DownloadService : IDownloadService
return parts.Length > 0 ? Path.Combine(root, parts[0]) : root;
}
private bool DownloadReachedRatio(double ratio, TimeSpan seedingTime, SeedingRule category)
private bool DownloadReachedRatio(double ratio, TimeSpan seedingTime, ISeedingRule category)
{
if (category.MaxRatio < 0)
{
@@ -222,7 +222,7 @@ public abstract class DownloadService : IDownloadService
return true;
}
private bool DownloadReachedMaxSeedTime(TimeSpan seedingTime, SeedingRule category)
private bool DownloadReachedMaxSeedTime(TimeSpan seedingTime, ISeedingRule category)
{
if (category.MaxSeedTime < 0)
{
@@ -36,29 +36,30 @@ public interface IDownloadService : IDisposable
/// <param name="downloads">The downloads to filter.</param>
/// <param name="seedingRules">The seeding rules by which to filter the downloads.</param>
/// <returns>A list of downloads for the provided categories.</returns>
List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<SeedingRule> seedingRules);
List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<ISeedingRule> seedingRules);
/// <summary>
/// Filters downloads that should have their category changed.
/// </summary>
/// <param name="downloads">The downloads to filter.</param>
/// <param name="categories">The categories by which to filter the downloads.</param>
/// <param name="unlinkedConfig">The unlinked config for this download client.</param>
/// <returns>A list of downloads for the provided categories.</returns>
List<ITorrentItemWrapper>? FilterDownloadsToChangeCategoryAsync(List<ITorrentItemWrapper>? downloads, List<string> categories);
List<ITorrentItemWrapper>? FilterDownloadsToChangeCategoryAsync(List<ITorrentItemWrapper>? downloads, UnlinkedConfig unlinkedConfig);
/// <summary>
/// Cleans the downloads.
/// </summary>
/// <param name="downloads">The downloads to clean.</param>
/// <param name="seedingRules">The seeding rules.</param>
Task CleanDownloadsAsync(List<ITorrentItemWrapper>? downloads, List<SeedingRule> seedingRules);
Task CleanDownloadsAsync(List<ITorrentItemWrapper>? downloads, List<ISeedingRule> seedingRules);
/// <summary>
/// Changes the category for downloads that have no hardlinks.
/// </summary>
/// <param name="downloads">The downloads to change.</param>
Task ChangeCategoryForNoHardLinksAsync(List<ITorrentItemWrapper>? downloads);
/// <param name="unlinkedConfig">The unlinked config for this download client.</param>
Task ChangeCategoryForNoHardLinksAsync(List<ITorrentItemWrapper>? downloads, UnlinkedConfig unlinkedConfig);
/// <summary>
/// Deletes a download item.
/// </summary>
@@ -71,7 +72,7 @@ public interface IDownloadService : IDisposable
/// </summary>
/// <param name="name">The category name.</param>
public Task CreateCategoryAsync(string name);
/// <summary>
/// Blocks unwanted files from being fully downloaded.
/// </summary>
@@ -1,7 +1,8 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Shared.Helpers;
using Microsoft.Extensions.Logging;
using QBittorrent.Client;
@@ -33,26 +34,24 @@ public partial class QBitService
}
/// <inheritdoc/>
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<SeedingRule> seedingRules) =>
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<ISeedingRule> seedingRules) =>
downloads
?.Where(x => !string.IsNullOrEmpty(x.Hash))
.Where(x => seedingRules.Any(cat => cat.Name.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
.ToList();
/// <inheritdoc/>
public override List<ITorrentItemWrapper>? FilterDownloadsToChangeCategoryAsync(List<ITorrentItemWrapper>? downloads, List<string> categories)
public override List<ITorrentItemWrapper>? FilterDownloadsToChangeCategoryAsync(List<ITorrentItemWrapper>? downloads, UnlinkedConfig unlinkedConfig)
{
var downloadCleanerConfig = ContextProvider.Get<DownloadCleanerConfig>(nameof(DownloadCleanerConfig));
return downloads
?.Where(x => !string.IsNullOrEmpty(x.Hash))
.Where(x => categories.Any(cat => cat.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
.Where(x => unlinkedConfig.Categories.Any(cat => cat.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
.Where(x =>
{
if (downloadCleanerConfig.UnlinkedUseTag && x is QBitItemWrapper qBitItemWrapper)
if (unlinkedConfig.UseTag && x is QBitItemWrapper qBitItemWrapper)
{
return !qBitItemWrapper.Tags.Any(tag =>
tag.Equals(downloadCleanerConfig.UnlinkedTargetCategory, StringComparison.InvariantCultureIgnoreCase));
tag.Equals(unlinkedConfig.TargetCategory, StringComparison.InvariantCultureIgnoreCase));
}
return true;
@@ -74,28 +73,26 @@ public partial class QBitService
{
return;
}
_logger.LogDebug("Creating category {name}", name);
await _dryRunInterceptor.InterceptAsync(CreateCategory, name);
}
public override async Task ChangeCategoryForNoHardLinksAsync(List<ITorrentItemWrapper>? downloads)
public override async Task ChangeCategoryForNoHardLinksAsync(List<ITorrentItemWrapper>? downloads, UnlinkedConfig unlinkedConfig)
{
if (downloads?.Count is null or 0)
{
return;
}
var downloadCleanerConfig = ContextProvider.Get<DownloadCleanerConfig>(nameof(DownloadCleanerConfig));
foreach (QBitItemWrapper torrent in downloads.Cast<QBitItemWrapper>())
{
if (string.IsNullOrEmpty(torrent.Name) || string.IsNullOrEmpty(torrent.Hash) || string.IsNullOrEmpty(torrent.Category))
{
continue;
}
IReadOnlyList<TorrentContent>? files = await _client.GetTorrentContentsAsync(torrent.Hash);
if (files is null)
@@ -123,13 +120,15 @@ public partial class QBitService
string filePath = string.Join(Path.DirectorySeparatorChar, Path.Combine(torrent.Info.SavePath, file.Name).Split(['\\', '/']));
filePath = PathHelper.RemapPath(filePath, unlinkedConfig.DownloadDirectorySource, unlinkedConfig.DownloadDirectoryTarget);
if (file.Priority is TorrentContentPriority.Skip)
{
_logger.LogDebug("skip | file is not downloaded | {file}", filePath);
continue;
}
long hardlinkCount = _hardLinkFileService.GetHardLinkCount(filePath, downloadCleanerConfig.UnlinkedIgnoredRootDirs.Count > 0);
long hardlinkCount = _hardLinkFileService.GetHardLinkCount(filePath, unlinkedConfig.IgnoredRootDirs.Count > 0);
if (hardlinkCount < 0)
{
@@ -156,18 +155,18 @@ public partial class QBitService
continue;
}
await _dryRunInterceptor.InterceptAsync(ChangeCategory, torrent.Hash, downloadCleanerConfig.UnlinkedTargetCategory);
await _dryRunInterceptor.InterceptAsync(ChangeCategory, torrent.Hash, unlinkedConfig.TargetCategory, unlinkedConfig.UseTag);
await _eventPublisher.PublishCategoryChanged(torrent.Category, downloadCleanerConfig.UnlinkedTargetCategory, downloadCleanerConfig.UnlinkedUseTag);
await _eventPublisher.PublishCategoryChanged(torrent.Category, unlinkedConfig.TargetCategory, unlinkedConfig.UseTag);
if (downloadCleanerConfig.UnlinkedUseTag)
if (unlinkedConfig.UseTag)
{
_logger.LogInformation("tag added for {name}", torrent.Name);
}
else
{
_logger.LogInformation("category changed for {name}", torrent.Name);
torrent.Category = downloadCleanerConfig.UnlinkedTargetCategory;
torrent.Category = unlinkedConfig.TargetCategory;
}
}
}
@@ -176,12 +175,10 @@ public partial class QBitService
{
await _client.AddCategoryAsync(name);
}
protected virtual async Task ChangeCategory(string hash, string newCategory)
protected virtual async Task ChangeCategory(string hash, string newCategory, bool useTag)
{
var downloadCleanerConfig = ContextProvider.Get<DownloadCleanerConfig>(nameof(DownloadCleanerConfig));
if (downloadCleanerConfig.UnlinkedUseTag)
if (useTag)
{
await _client.AddTorrentTagAsync([hash], newCategory);
return;
@@ -189,4 +186,4 @@ public partial class QBitService
await _client.SetTorrentCategoryAsync([hash], newCategory);
}
}
}
@@ -2,6 +2,7 @@ using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.RTorrent.Response;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Shared.Helpers;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
@@ -20,15 +21,15 @@ public partial class RTorrentService
.ToList();
}
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<SeedingRule> seedingRules) =>
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<ISeedingRule> seedingRules) =>
downloads
?.Where(x => seedingRules.Any(cat => cat.Name.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
.ToList();
public override List<ITorrentItemWrapper>? FilterDownloadsToChangeCategoryAsync(List<ITorrentItemWrapper>? downloads, List<string> categories) =>
public override List<ITorrentItemWrapper>? FilterDownloadsToChangeCategoryAsync(List<ITorrentItemWrapper>? downloads, UnlinkedConfig unlinkedConfig) =>
downloads
?.Where(x => !string.IsNullOrEmpty(x.Hash))
.Where(x => categories.Any(cat => cat.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
.Where(x => unlinkedConfig.Categories.Any(cat => cat.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
.ToList();
/// <inheritdoc/>
@@ -36,7 +37,7 @@ public partial class RTorrentService
{
string hash = torrent.Hash.ToUpperInvariant();
await _client.DeleteTorrentAsync(hash);
if (deleteSourceFiles)
{
if (!TryDeleteFiles(torrent.SavePath, true))
@@ -55,15 +56,13 @@ public partial class RTorrentService
return Task.CompletedTask;
}
public override async Task ChangeCategoryForNoHardLinksAsync(List<ITorrentItemWrapper>? downloads)
public override async Task ChangeCategoryForNoHardLinksAsync(List<ITorrentItemWrapper>? downloads, UnlinkedConfig unlinkedConfig)
{
if (downloads?.Count is null or 0)
{
return;
}
var downloadCleanerConfig = ContextProvider.Get<DownloadCleanerConfig>(nameof(DownloadCleanerConfig));
foreach (RTorrentItemWrapper torrent in downloads.Cast<RTorrentItemWrapper>())
{
if (string.IsNullOrEmpty(torrent.Hash) || string.IsNullOrEmpty(torrent.Name) || string.IsNullOrEmpty(torrent.Category))
@@ -96,6 +95,8 @@ public partial class RTorrentService
string filePath = string.Join(Path.DirectorySeparatorChar,
Path.Combine(torrent.Info.BasePath ?? "", file.Path).Split(['\\', '/']));
filePath = PathHelper.RemapPath(filePath, unlinkedConfig.DownloadDirectorySource, unlinkedConfig.DownloadDirectoryTarget);
if (file.Priority <= 0)
{
_logger.LogDebug("skip | file is not downloaded | {file}", filePath);
@@ -103,7 +104,7 @@ public partial class RTorrentService
}
long hardlinkCount = _hardLinkFileService
.GetHardLinkCount(filePath, downloadCleanerConfig.UnlinkedIgnoredRootDirs.Count > 0);
.GetHardLinkCount(filePath, unlinkedConfig.IgnoredRootDirs.Count > 0);
if (hardlinkCount < 0)
{
@@ -130,13 +131,13 @@ public partial class RTorrentService
continue;
}
await _dryRunInterceptor.InterceptAsync(ChangeLabel, torrent.Hash, downloadCleanerConfig.UnlinkedTargetCategory);
await _dryRunInterceptor.InterceptAsync(ChangeLabel, torrent.Hash, unlinkedConfig.TargetCategory);
_logger.LogInformation("category changed for {name}", torrent.Name);
await _eventPublisher.PublishCategoryChanged(torrent.Category, downloadCleanerConfig.UnlinkedTargetCategory);
await _eventPublisher.PublishCategoryChanged(torrent.Category, unlinkedConfig.TargetCategory);
torrent.Category = downloadCleanerConfig.UnlinkedTargetCategory;
torrent.Category = unlinkedConfig.TargetCategory;
}
}
@@ -2,6 +2,7 @@ using Cleanuparr.Domain.Entities;
using Cleanuparr.Infrastructure.Extensions;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Shared.Helpers;
using Microsoft.Extensions.Logging;
using Transmission.API.RPC.Entity;
@@ -20,7 +21,7 @@ public partial class TransmissionService
}
/// <inheritdoc/>
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<SeedingRule> seedingRules)
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<ISeedingRule> seedingRules)
{
return downloads
?.Where(x => seedingRules
@@ -29,11 +30,11 @@ public partial class TransmissionService
.ToList();
}
public override List<ITorrentItemWrapper>? FilterDownloadsToChangeCategoryAsync(List<ITorrentItemWrapper>? downloads, List<string> categories)
public override List<ITorrentItemWrapper>? FilterDownloadsToChangeCategoryAsync(List<ITorrentItemWrapper>? downloads, UnlinkedConfig unlinkedConfig)
{
return downloads
?.Where(x => !string.IsNullOrEmpty(x.Hash))
.Where(x => categories.Any(cat => cat.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
.Where(x => unlinkedConfig.Categories.Any(cat => cat.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
.ToList();
}
@@ -43,28 +44,26 @@ public partial class TransmissionService
var transmissionTorrent = (TransmissionItemWrapper)torrent;
await _client.TorrentRemoveAsync([transmissionTorrent.Info.Id], deleteSourceFiles);
}
public override async Task CreateCategoryAsync(string name)
{
await Task.CompletedTask;
}
public override async Task ChangeCategoryForNoHardLinksAsync(List<ITorrentItemWrapper>? downloads)
public override async Task ChangeCategoryForNoHardLinksAsync(List<ITorrentItemWrapper>? downloads, UnlinkedConfig unlinkedConfig)
{
if (downloads?.Count is null or 0)
{
return;
}
var downloadCleanerConfig = ContextProvider.Get<DownloadCleanerConfig>(nameof(DownloadCleanerConfig));
foreach (TransmissionItemWrapper torrent in downloads.Cast<TransmissionItemWrapper>())
{
if (string.IsNullOrEmpty(torrent.Hash) || string.IsNullOrEmpty(torrent.Name) || string.IsNullOrEmpty(torrent.Info.DownloadDir))
{
continue;
}
ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name);
ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash);
ContextProvider.Set(ContextProvider.Keys.DownloadClientUrl, _downloadClientConfig.ExternalOrInternalUrl);
@@ -76,7 +75,7 @@ public partial class TransmissionService
_logger.LogDebug("skip | download has no files | {name}", torrent.Name);
continue;
}
bool hasHardlinks = false;
bool hasErrors = false;
@@ -92,7 +91,9 @@ public partial class TransmissionService
string filePath = string.Join(Path.DirectorySeparatorChar, Path.Combine(torrent.Info.DownloadDir, file.Name).Split(['\\', '/']));
long hardlinkCount = _hardLinkFileService.GetHardLinkCount(filePath, downloadCleanerConfig.UnlinkedIgnoredRootDirs.Count > 0);
filePath = PathHelper.RemapPath(filePath, unlinkedConfig.DownloadDirectorySource, unlinkedConfig.DownloadDirectoryTarget);
long hardlinkCount = _hardLinkFileService.GetHardLinkCount(filePath, unlinkedConfig.IgnoredRootDirs.Count > 0);
if (hardlinkCount < 0)
{
@@ -120,15 +121,15 @@ public partial class TransmissionService
}
string currentCategory = torrent.Category ?? string.Empty;
string newLocation = torrent.Info.GetNewLocationByAppend(downloadCleanerConfig.UnlinkedTargetCategory);
string newLocation = torrent.Info.GetNewLocationByAppend(unlinkedConfig.TargetCategory);
await _dryRunInterceptor.InterceptAsync(ChangeDownloadLocation, torrent.Info.Id, newLocation);
_logger.LogInformation("category changed for {name}", torrent.Name);
await _eventPublisher.PublishCategoryChanged(currentCategory, downloadCleanerConfig.UnlinkedTargetCategory);
torrent.Category = downloadCleanerConfig.UnlinkedTargetCategory;
await _eventPublisher.PublishCategoryChanged(currentCategory, unlinkedConfig.TargetCategory);
torrent.Category = unlinkedConfig.TargetCategory;
}
}
@@ -136,4 +137,4 @@ public partial class TransmissionService
{
await _client.TorrentSetLocationAsync([downloadId], newLocation, true);
}
}
}
@@ -4,6 +4,7 @@ using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent.Extensions;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Shared.Helpers;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
@@ -24,15 +25,15 @@ public partial class UTorrentService
return result;
}
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<SeedingRule> seedingRules) =>
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<ISeedingRule> seedingRules) =>
downloads
?.Where(x => seedingRules.Any(cat => cat.Name.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
.ToList();
public override List<ITorrentItemWrapper>? FilterDownloadsToChangeCategoryAsync(List<ITorrentItemWrapper>? downloads, List<string> categories) =>
public override List<ITorrentItemWrapper>? FilterDownloadsToChangeCategoryAsync(List<ITorrentItemWrapper>? downloads, UnlinkedConfig unlinkedConfig) =>
downloads
?.Where(x => !string.IsNullOrEmpty(x.Hash))
.Where(x => categories.Any(cat => cat.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
.Where(x => unlinkedConfig.Categories.Any(cat => cat.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
.ToList();
/// <inheritdoc/>
@@ -47,22 +48,20 @@ public partial class UTorrentService
await Task.CompletedTask;
}
public override async Task ChangeCategoryForNoHardLinksAsync(List<ITorrentItemWrapper>? downloads)
public override async Task ChangeCategoryForNoHardLinksAsync(List<ITorrentItemWrapper>? downloads, UnlinkedConfig unlinkedConfig)
{
if (downloads?.Count is null or 0)
{
return;
}
var downloadCleanerConfig = ContextProvider.Get<DownloadCleanerConfig>(nameof(DownloadCleanerConfig));
foreach (UTorrentItemWrapper torrent in downloads.Cast<UTorrentItemWrapper>())
{
if (string.IsNullOrEmpty(torrent.Hash) || string.IsNullOrEmpty(torrent.Name) || string.IsNullOrEmpty(torrent.Category))
{
continue;
}
ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name);
ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash);
ContextProvider.Set(ContextProvider.Keys.DownloadClientUrl, _downloadClientConfig.ExternalOrInternalUrl);
@@ -78,6 +77,8 @@ public partial class UTorrentService
{
string filePath = string.Join(Path.DirectorySeparatorChar, Path.Combine(torrent.Info.SavePath, file.Name).Split(['\\', '/']));
filePath = PathHelper.RemapPath(filePath, unlinkedConfig.DownloadDirectorySource, unlinkedConfig.DownloadDirectoryTarget);
if (file.Priority <= 0)
{
_logger.LogDebug("skip | file is not downloaded | {file}", filePath);
@@ -85,7 +86,7 @@ public partial class UTorrentService
}
long hardlinkCount = _hardLinkFileService
.GetHardLinkCount(filePath, downloadCleanerConfig.UnlinkedIgnoredRootDirs.Count > 0);
.GetHardLinkCount(filePath, unlinkedConfig.IgnoredRootDirs.Count > 0);
if (hardlinkCount < 0)
{
@@ -112,18 +113,18 @@ public partial class UTorrentService
continue;
}
await _dryRunInterceptor.InterceptAsync(ChangeLabel, torrent.Hash, downloadCleanerConfig.UnlinkedTargetCategory);
await _dryRunInterceptor.InterceptAsync(ChangeLabel, torrent.Hash, unlinkedConfig.TargetCategory);
await _eventPublisher.PublishCategoryChanged(torrent.Category, downloadCleanerConfig.UnlinkedTargetCategory);
await _eventPublisher.PublishCategoryChanged(torrent.Category, unlinkedConfig.TargetCategory);
_logger.LogInformation("category changed for {name}", torrent.Name);
torrent.Category = downloadCleanerConfig.UnlinkedTargetCategory;
torrent.Category = unlinkedConfig.TargetCategory;
}
}
protected virtual async Task ChangeLabel(string hash, string newLabel)
{
await _client.SetTorrentLabelAsync(hash, newLabel);
}
}
}
@@ -12,6 +12,7 @@ using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Persistence.Models.Configuration.General;
using MassTransit;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using LogContext = Serilog.Context.LogContext;
@@ -43,7 +44,7 @@ public sealed class DownloadCleaner : GenericHandler
_timeProvider = timeProvider;
_hardLinkFileService = hardLinkFileService;
}
protected override async Task ExecuteInternalAsync(CancellationToken cancellationToken = default)
{
var downloadServices = await GetInitializedDownloadServicesAsync();
@@ -55,19 +56,10 @@ public sealed class DownloadCleaner : GenericHandler
}
var config = ContextProvider.Get<DownloadCleanerConfig>();
bool isUnlinkedEnabled = config.UnlinkedEnabled && !string.IsNullOrEmpty(config.UnlinkedTargetCategory) && config.UnlinkedCategories.Count > 0;
bool isCleaningEnabled = config.Categories.Count > 0;
if (!isUnlinkedEnabled && !isCleaningEnabled)
{
_logger.LogWarning("No features are enabled for {name}", nameof(DownloadCleaner));
return;
}
List<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads;
ignoredDownloads.AddRange(ContextProvider.Get<DownloadCleanerConfig>().IgnoredDownloads);
ignoredDownloads.AddRange(config.IgnoredDownloads);
var downloadServiceToDownloadsMap = new Dictionary<IDownloadService, List<ITorrentItemWrapper>>();
foreach (var downloadService in downloadServices)
@@ -112,7 +104,7 @@ public sealed class DownloadCleaner : GenericHandler
foreach (var pair in downloadServiceToDownloadsMap)
{
List<ITorrentItemWrapper> filteredDownloads = [];
foreach (ITorrentItemWrapper download in pair.Value)
{
if (download.IsIgnored(ignoredDownloads))
@@ -120,21 +112,45 @@ public sealed class DownloadCleaner : GenericHandler
_logger.LogDebug("skip | download is ignored | {name}", download.Name);
continue;
}
if (_downloadsProcessedByArrs.Any(x => x.Equals(download.Hash, StringComparison.InvariantCultureIgnoreCase)))
{
_logger.LogDebug("skip | download is used by an arr | {name}", download.Name);
continue;
}
filteredDownloads.Add(download);
}
downloadServiceToDownloadsMap[pair.Key] = filteredDownloads;
}
await ChangeUnlinkedCategoriesAsync(isUnlinkedEnabled, downloadServiceToDownloadsMap, config);
await CleanDownloadsAsync(downloadServiceToDownloadsMap, config);
// Process each client with its own per-client config
foreach (var (downloadService, clientDownloads) in downloadServiceToDownloadsMap)
{
using var dcType = LogContext.PushProperty(LogProperties.DownloadClientType, downloadService.ClientConfig.Type.ToString());
using var dcName = LogContext.PushProperty(LogProperties.DownloadClientName, downloadService.ClientConfig.Name);
var seedingRules = await LoadSeedingRulesForClient(downloadService.ClientConfig);
var unlinkedConfig = await LoadUnlinkedConfigForClient(downloadService.ClientConfig.Id);
if (unlinkedConfig is { Enabled: true })
{
if (unlinkedConfig.Categories.Count > 0)
{
await ChangeUnlinkedCategoriesForClientAsync(downloadService, clientDownloads, unlinkedConfig);
}
else
{
_logger.LogWarning("Unlinked config is enabled but no categories are configured for {name}, skipping", downloadService.ClientConfig.Name);
}
}
if (seedingRules.Count > 0)
{
await CleanDownloadsForClientAsync(downloadService, clientDownloads, seedingRules);
}
}
foreach (var downloadService in downloadServices)
{
@@ -163,138 +179,112 @@ public sealed class DownloadCleaner : GenericHandler
});
}
private async Task ChangeUnlinkedCategoriesAsync(bool isUnlinkedEnabled, Dictionary<IDownloadService, List<ITorrentItemWrapper>> downloadServiceToDownloadsMap, DownloadCleanerConfig config)
private async Task ChangeUnlinkedCategoriesForClientAsync(
IDownloadService downloadService,
List<ITorrentItemWrapper> clientDownloads,
UnlinkedConfig unlinkedConfig)
{
if (!isUnlinkedEnabled)
if (unlinkedConfig.IgnoredRootDirs.Count > 0)
{
return;
_hardLinkFileService.PopulateFileCounts(unlinkedConfig.IgnoredRootDirs);
}
if (config.UnlinkedIgnoredRootDirs.Count > 0)
try
{
_hardLinkFileService.PopulateFileCounts(config.UnlinkedIgnoredRootDirs);
}
var downloadsToChangeCategory = downloadService
.FilterDownloadsToChangeCategoryAsync(clientDownloads, unlinkedConfig);
Dictionary<IDownloadService, List<ITorrentItemWrapper>> downloadServiceWithDownloads = [];
foreach (var (downloadService, clientDownloads) in downloadServiceToDownloadsMap)
{
try
if (downloadsToChangeCategory?.Count is null or 0)
{
var downloadsToChangeCategory = downloadService
.FilterDownloadsToChangeCategoryAsync(clientDownloads, config.UnlinkedCategories);
if (downloadsToChangeCategory?.Count > 0)
{
downloadServiceWithDownloads.Add(downloadService, downloadsToChangeCategory);
}
return;
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Failed to filter downloads for hardlinks evaluation for download client {clientName}",
downloadService.ClientConfig.Name
);
}
}
if (downloadServiceWithDownloads.Count is 0)
{
_logger.LogInformation("No downloads found to evaluate for hardlinks");
return;
}
_logger.LogInformation(
"Evaluating {count} downloads for hardlinks",
downloadServiceWithDownloads.Sum(x => x.Value.Count)
);
// Process each client with its own filtered downloads
foreach (var (downloadService, downloadsToChangeCategory) in downloadServiceWithDownloads)
{
using var dcType = LogContext.PushProperty(LogProperties.DownloadClientType, downloadService.ClientConfig.Type.ToString());
using var dcName = LogContext.PushProperty(LogProperties.DownloadClientName, downloadService.ClientConfig.Name);
_logger.LogInformation("Evaluating {count} downloads for hardlinks", downloadsToChangeCategory.Count);
try
{
await downloadService.CreateCategoryAsync(config.UnlinkedTargetCategory);
await downloadService.CreateCategoryAsync(unlinkedConfig.TargetCategory);
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Failed to create category {category} for download client {clientName}",
config.UnlinkedTargetCategory,
downloadService.ClientConfig.Name
);
_logger.LogError(ex, "Failed to create category {category}", unlinkedConfig.TargetCategory);
}
try
{
await downloadService.ChangeCategoryForNoHardLinksAsync(downloadsToChangeCategory);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to change category for download client {clientName}", downloadService.ClientConfig.Name);
}
await downloadService.ChangeCategoryForNoHardLinksAsync(downloadsToChangeCategory, unlinkedConfig);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to process unlinked downloads for {clientName}", downloadService.ClientConfig.Name);
}
_logger.LogInformation("Finished hardlinks evaluation");
}
private async Task CleanDownloadsAsync(Dictionary<IDownloadService, List<ITorrentItemWrapper>> downloadServiceToDownloadsMap, DownloadCleanerConfig config)
private async Task CleanDownloadsForClientAsync(
IDownloadService downloadService,
List<ITorrentItemWrapper> clientDownloads,
List<ISeedingRule> seedingRules)
{
if (config.Categories.Count is 0)
try
{
return;
}
Dictionary<IDownloadService, List<ITorrentItemWrapper>> downloadServiceWithDownloads = [];
var downloadsToClean = downloadService
.FilterDownloadsToBeCleanedAsync(clientDownloads, seedingRules);
foreach (var (downloadService, clientDownloads) in downloadServiceToDownloadsMap)
{
try
if (downloadsToClean?.Count is null or 0)
{
var downloadsToClean = downloadService
.FilterDownloadsToBeCleanedAsync(clientDownloads, config.Categories);
if (downloadsToClean?.Count > 0)
{
downloadServiceWithDownloads.Add(downloadService, downloadsToClean);
}
return;
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Failed to filter downloads for cleaning for download client {clientName}",
downloadService.ClientConfig.Name
);
}
}
_logger.LogInformation(
"Evaluating {count} downloads for cleanup",
downloadServiceWithDownloads.Sum(x => x.Value.Count)
);
// Process cleaning for each client
foreach (var (downloadService, downloadsToClean) in downloadServiceWithDownloads)
{
using var dcType = LogContext.PushProperty(LogProperties.DownloadClientType, downloadService.ClientConfig.Type.ToString());
using var dcName = LogContext.PushProperty(LogProperties.DownloadClientName, downloadService.ClientConfig.Name);
try
{
await downloadService.CleanDownloadsAsync(downloadsToClean, config.Categories);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to clean downloads for download client {clientName}", downloadService.ClientConfig.Name);
}
_logger.LogInformation("Evaluating {count} downloads for cleanup", downloadsToClean.Count);
await downloadService.CleanDownloadsAsync(downloadsToClean, seedingRules);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to clean downloads for {clientName}", downloadService.ClientConfig.Name);
}
_logger.LogInformation("Finished cleanup evaluation");
}
}
private async Task<List<ISeedingRule>> LoadSeedingRulesForClient(Persistence.Models.Configuration.DownloadClientConfig clientConfig)
{
await DataContext.Lock.WaitAsync();
try
{
return clientConfig.TypeName switch
{
DownloadClientTypeName.qBittorrent => (await _dataContext.QBitSeedingRules
.Where(r => r.DownloadClientConfigId == clientConfig.Id).AsNoTracking().ToListAsync()).Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.Deluge => (await _dataContext.DelugeSeedingRules
.Where(r => r.DownloadClientConfigId == clientConfig.Id).AsNoTracking().ToListAsync()).Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.Transmission => (await _dataContext.TransmissionSeedingRules
.Where(r => r.DownloadClientConfigId == clientConfig.Id).AsNoTracking().ToListAsync()).Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.uTorrent => (await _dataContext.UTorrentSeedingRules
.Where(r => r.DownloadClientConfigId == clientConfig.Id).AsNoTracking().ToListAsync()).Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.rTorrent => (await _dataContext.RTorrentSeedingRules
.Where(r => r.DownloadClientConfigId == clientConfig.Id).AsNoTracking().ToListAsync()).Cast<ISeedingRule>().ToList(),
_ => []
};
}
finally
{
DataContext.Lock.Release();
}
}
private async Task<UnlinkedConfig?> LoadUnlinkedConfigForClient(Guid clientId)
{
await DataContext.Lock.WaitAsync();
try
{
return await _dataContext.UnlinkedConfigs
.AsNoTracking()
.FirstOrDefaultAsync(u => u.DownloadClientConfigId == clientId);
}
finally
{
DataContext.Lock.Release();
}
}
}
@@ -77,7 +77,7 @@ public abstract class GenericHandler : IHandler
.FirstAsync(x => x.Type == InstanceType.Whisparr));
ContextProvider.Set(nameof(QueueCleanerConfig), await _dataContext.QueueCleanerConfigs.AsNoTracking().FirstAsync());
ContextProvider.Set(nameof(ContentBlockerConfig), await _dataContext.ContentBlockerConfigs.AsNoTracking().FirstAsync());
ContextProvider.Set(nameof(DownloadCleanerConfig), await _dataContext.DownloadCleanerConfigs.Include(x => x.Categories).AsNoTracking().FirstAsync());
ContextProvider.Set(nameof(DownloadCleanerConfig), await _dataContext.DownloadCleanerConfigs.AsNoTracking().FirstAsync());
ContextProvider.Set(nameof(DownloadClientConfig), await _dataContext.DownloadClients.AsNoTracking()
.Where(x => x.Enabled)
.ToListAsync());
@@ -422,8 +422,12 @@ public sealed class Seeker : IHandler
HashSet<long> queuedMovieIds)
{
List<SearchableMovie> movies = await _radarrClient.GetAllMoviesAsync(arrInstance);
List<Tag> tags = await _radarrClient.GetAllTagsAsync(arrInstance);
List<long> allLibraryIds = movies.Select(m => m.Id).ToList();
Dictionary<long, string> tagsById = tags.ToDictionary(t => t.Id, t => t.Label);
HashSet<string> skipTagSet = new(instanceConfig.SkipTags, StringComparer.InvariantCultureIgnoreCase);
// Load cached CF scores when custom format score filtering is enabled
Dictionary<long, CustomFormatScoreEntry>? cfScores = null;
if (config.UseCustomFormatScore)
@@ -441,7 +445,11 @@ public sealed class Seeker : IHandler
.Where(m => m.Status is "released")
.Where(m => IsMoviePastGracePeriod(m, graceCutoff))
.Where(m => !config.MonitoredOnly || m.Monitored)
.Where(m => instanceConfig.SkipTags.Count == 0 || !m.Tags.Any(instanceConfig.SkipTags.Contains))
.Where(m => instanceConfig.SkipTags.Count == 0 ||
!m.Tags
.Select(id => tagsById.TryGetValue(id, out var label) ? label : null)
.Any(label => label is not null && skipTagSet.Contains(label))
)
.Where(m => !m.HasFile
|| (!config.UseCutoff && !config.UseCustomFormatScore)
|| (config.UseCutoff && (m.MovieFile?.QualityCutoffNotMet ?? false))
@@ -543,14 +551,22 @@ public sealed class Seeker : IHandler
HashSet<(long SeriesId, long SeasonNumber)>? queuedSeasons = null)
{
List<SearchableSeries> series = await _sonarrClient.GetAllSeriesAsync(arrInstance);
List<Tag> tags = await _sonarrClient.GetAllTagsAsync(arrInstance);
List<long> allLibraryIds = series.Select(s => s.Id).ToList();
DateTime graceCutoff = _timeProvider.GetUtcNow().UtcDateTime.AddHours(-config.PostReleaseGraceHours);
Dictionary<long, string> tagsById = tags.ToDictionary(t => t.Id, t => t.Label);
HashSet<string> skipTagSet = new(instanceConfig.SkipTags, StringComparer.InvariantCultureIgnoreCase);
// Apply filters
var candidates = series
.Where(s => s.Status is "continuing" or "ended" or "released")
.Where(s => !config.MonitoredOnly || s.Monitored)
.Where(s => instanceConfig.SkipTags.Count == 0 || !s.Tags.Any(instanceConfig.SkipTags.Contains))
.Where(s => instanceConfig.SkipTags.Count == 0 ||
!s.Tags
.Select(id => tagsById.TryGetValue(id, out var label) ? label : null)
.Any(label => label is not null && skipTagSet.Contains(label))
)
// Skip fully-downloaded series (unless quality upgrade filters active)
.Where(s => config.UseCutoff || config.UseCustomFormatScore
|| s.Statistics == null || s.Statistics.EpisodeCount == 0
@@ -1,14 +1,12 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Shouldly;
using Xunit;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Tests.Models.Configuration.DownloadCleaner;
public sealed class DownloadCleanerConfigTests
{
#region Validate - Disabled Config
#region Validate
[Fact]
public void Validate_WhenDisabled_DoesNotThrow()
@@ -22,320 +20,11 @@ public sealed class DownloadCleanerConfigTests
}
[Fact]
public void Validate_WhenDisabledWithNoFeatures_DoesNotThrow()
public void Validate_WhenEnabled_DoesNotThrow()
{
var config = new DownloadCleanerConfig
{
Enabled = false,
Categories = [],
UnlinkedEnabled = false
};
Should.NotThrow(() => config.Validate());
}
#endregion
#region Validate - No Features Configured
[Fact]
public void Validate_WhenEnabledWithNoFeatures_ThrowsValidationException()
{
var config = new DownloadCleanerConfig
{
Enabled = true,
Categories = [],
UnlinkedEnabled = false
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
exception.Message.ShouldBe("No features are enabled");
}
[Fact]
public void Validate_WhenEnabledWithUnlinkedEnabledButNoCategories_ThrowsValidationException()
{
var config = new DownloadCleanerConfig
{
Enabled = true,
Categories = [],
UnlinkedEnabled = true,
UnlinkedCategories = [],
UnlinkedTargetCategory = "target"
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
exception.Message.ShouldBe("No features are enabled");
}
#endregion
#region Validate - Categories Feature
[Fact]
public void Validate_WhenEnabledWithValidCategories_DoesNotThrow()
{
var config = new DownloadCleanerConfig
{
Enabled = true,
Categories =
[
new SeedingRule { Name = "movies", MaxRatio = 2.0, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
new SeedingRule { Name = "tv", MaxRatio = 1.5, MinSeedTime = 24, MaxSeedTime = -1, DeleteSourceFiles = true }
],
UnlinkedEnabled = false
};
Should.NotThrow(() => config.Validate());
}
[Fact]
public void Validate_WhenEnabledWithDuplicateCategoryNames_ThrowsValidationException()
{
var config = new DownloadCleanerConfig
{
Enabled = true,
Categories =
[
new SeedingRule { Name = "movies", MaxRatio = 2.0, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
new SeedingRule { Name = "movies", MaxRatio = 1.5, MinSeedTime = 24, MaxSeedTime = -1, DeleteSourceFiles = true }
],
UnlinkedEnabled = false
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
exception.Message.ShouldBe("Duplicated clean category and privacy type combination found");
}
[Fact]
public void Validate_WhenDuplicateCategoryNamesDifferentCase_ThrowsValidationException()
{
var config = new DownloadCleanerConfig
{
Enabled = true,
Categories =
[
new SeedingRule { Name = "Movies", MaxRatio = 2.0, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
new SeedingRule { Name = "movies", MaxRatio = 1.5, MinSeedTime = 24, MaxSeedTime = -1, DeleteSourceFiles = true }
],
UnlinkedEnabled = false
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
exception.Message.ShouldBe("Duplicated clean category and privacy type combination found");
}
[Fact]
public void Validate_WhenSameCategoryWithBothAndPublic_ThrowsValidationException()
{
var config = new DownloadCleanerConfig
{
Enabled = true,
Categories =
[
new SeedingRule { Name = "movies", PrivacyType = TorrentPrivacyType.Both, MaxRatio = 2.0, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
new SeedingRule { Name = "movies", PrivacyType = TorrentPrivacyType.Public, MaxRatio = 1.5, MinSeedTime = 24, MaxSeedTime = -1, DeleteSourceFiles = true }
],
UnlinkedEnabled = false
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
exception.Message.ShouldContain("already covers all torrent types");
}
[Fact]
public void Validate_WhenSameCategoryWithBothAndPrivate_ThrowsValidationException()
{
var config = new DownloadCleanerConfig
{
Enabled = true,
Categories =
[
new SeedingRule { Name = "movies", PrivacyType = TorrentPrivacyType.Both, MaxRatio = 2.0, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
new SeedingRule { Name = "movies", PrivacyType = TorrentPrivacyType.Private, MaxRatio = 1.5, MinSeedTime = 24, MaxSeedTime = -1, DeleteSourceFiles = true }
],
UnlinkedEnabled = false
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
exception.Message.ShouldContain("already covers all torrent types");
}
[Fact]
public void Validate_WhenSameCategoryWithPublicAndPrivate_DoesNotThrow()
{
var config = new DownloadCleanerConfig
{
Enabled = true,
Categories =
[
new SeedingRule { Name = "movies", PrivacyType = TorrentPrivacyType.Public, MaxRatio = 2.0, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
new SeedingRule { Name = "movies", PrivacyType = TorrentPrivacyType.Private, MaxRatio = 1.5, MinSeedTime = 24, MaxSeedTime = -1, DeleteSourceFiles = true }
],
UnlinkedEnabled = false
};
Should.NotThrow(() => config.Validate());
}
[Fact]
public void Validate_WhenEnabledWithInvalidCategory_ThrowsValidationException()
{
var config = new DownloadCleanerConfig
{
Enabled = true,
Categories =
[
new SeedingRule { Name = "", MaxRatio = 2.0, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
],
UnlinkedEnabled = false
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
exception.Message.ShouldBe("Category name can not be empty");
}
#endregion
#region Validate - Unlinked Feature
[Fact]
public void Validate_WhenEnabledWithValidUnlinkedConfig_DoesNotThrow()
{
var config = new DownloadCleanerConfig
{
Enabled = true,
Categories = [],
UnlinkedEnabled = true,
UnlinkedTargetCategory = "cleanuparr-unlinked",
UnlinkedCategories = ["movies", "tv"]
};
Should.NotThrow(() => config.Validate());
}
[Fact]
public void Validate_WhenUnlinkedEnabledWithEmptyTargetCategory_ThrowsValidationException()
{
// Need valid categories to pass the "no features enabled" check first
var config = new DownloadCleanerConfig
{
Enabled = true,
Categories =
[
new SeedingRule { Name = "movies", MaxRatio = 2.0, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
],
UnlinkedEnabled = true,
UnlinkedTargetCategory = "",
UnlinkedCategories = ["tv"]
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
exception.Message.ShouldBe("unlinked target category is required");
}
[Fact]
public void Validate_WhenUnlinkedEnabledWithNoUnlinkedCategories_ThrowsValidationException()
{
// Need valid categories to pass the "no features enabled" check first
var config = new DownloadCleanerConfig
{
Enabled = true,
Categories =
[
new SeedingRule { Name = "movies", MaxRatio = 2.0, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
],
UnlinkedEnabled = true,
UnlinkedTargetCategory = "cleanuparr-unlinked",
UnlinkedCategories = []
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
exception.Message.ShouldBe("No unlinked categories configured");
}
[Fact]
public void Validate_WhenUnlinkedTargetCategoryInUnlinkedCategories_ThrowsValidationException()
{
var config = new DownloadCleanerConfig
{
Enabled = true,
Categories = [],
UnlinkedEnabled = true,
UnlinkedTargetCategory = "cleanuparr-unlinked",
UnlinkedCategories = ["movies", "cleanuparr-unlinked"]
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
exception.Message.ShouldBe("The unlinked target category should not be present in unlinked categories");
}
[Fact]
public void Validate_WhenUnlinkedCategoriesContainsEmpty_ThrowsValidationException()
{
var config = new DownloadCleanerConfig
{
Enabled = true,
Categories = [],
UnlinkedEnabled = true,
UnlinkedTargetCategory = "cleanuparr-unlinked",
UnlinkedCategories = ["movies", ""]
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
exception.Message.ShouldBe("Empty unlinked category filter found");
}
[Fact]
public void Validate_WhenUnlinkedIgnoredRootDirDoesNotExist_ThrowsValidationException()
{
var config = new DownloadCleanerConfig
{
Enabled = true,
Categories = [],
UnlinkedEnabled = true,
UnlinkedTargetCategory = "cleanuparr-unlinked",
UnlinkedCategories = ["movies"],
UnlinkedIgnoredRootDirs = ["/non/existent/directory"]
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
exception.Message.ShouldContain("root directory does not exist");
}
[Fact]
public void Validate_WhenUnlinkedIgnoredRootDirIsEmpty_DoesNotThrow()
{
var config = new DownloadCleanerConfig
{
Enabled = true,
Categories = [],
UnlinkedEnabled = true,
UnlinkedTargetCategory = "cleanuparr-unlinked",
UnlinkedCategories = ["movies"],
UnlinkedIgnoredRootDirs = []
};
Should.NotThrow(() => config.Validate());
}
#endregion
#region Validate - Combined Features
[Fact]
public void Validate_WhenBothFeaturesEnabled_DoesNotThrow()
{
var config = new DownloadCleanerConfig
{
Enabled = true,
Categories =
[
new SeedingRule { Name = "movies", MaxRatio = 2.0, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
],
UnlinkedEnabled = true,
UnlinkedTargetCategory = "cleanuparr-unlinked",
UnlinkedCategories = ["tv"]
Enabled = true
};
Should.NotThrow(() => config.Validate());
@@ -354,11 +43,11 @@ public sealed class DownloadCleanerConfigTests
}
[Fact]
public void UnlinkedTargetCategory_HasDefaultValue()
public void IgnoredDownloads_HasDefaultEmptyList()
{
var config = new DownloadCleanerConfig();
config.UnlinkedTargetCategory.ShouldBe("cleanuparr-unlinked");
config.IgnoredDownloads.ShouldBeEmpty();
}
#endregion
@@ -6,14 +6,14 @@ using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Tests.Models.Configuration.DownloadCleaner;
public sealed class SeedingRuleTests
public sealed class QBitSeedingRuleTests
{
#region Default Values
[Fact]
public void PrivacyType_DefaultsToPublic()
{
var rule = new SeedingRule
var rule = new QBitSeedingRule
{
Name = "test",
MaxRatio = -1,
@@ -32,7 +32,7 @@ public sealed class SeedingRuleTests
[Fact]
public void Validate_WithValidMaxRatio_DoesNotThrow()
{
var config = new SeedingRule
var config = new QBitSeedingRule
{
Name = "test-category",
MaxRatio = 2.0,
@@ -47,7 +47,7 @@ public sealed class SeedingRuleTests
[Fact]
public void Validate_WithValidMaxSeedTime_DoesNotThrow()
{
var config = new SeedingRule
var config = new QBitSeedingRule
{
Name = "test-category",
MaxRatio = -1,
@@ -62,7 +62,7 @@ public sealed class SeedingRuleTests
[Fact]
public void Validate_WithBothMaxRatioAndMaxSeedTime_DoesNotThrow()
{
var config = new SeedingRule
var config = new QBitSeedingRule
{
Name = "test-category",
MaxRatio = 2.0,
@@ -77,7 +77,7 @@ public sealed class SeedingRuleTests
[Fact]
public void Validate_WithZeroMaxRatio_DoesNotThrow()
{
var config = new SeedingRule
var config = new QBitSeedingRule
{
Name = "test-category",
MaxRatio = 0,
@@ -92,7 +92,7 @@ public sealed class SeedingRuleTests
[Fact]
public void Validate_WithZeroMaxSeedTime_DoesNotThrow()
{
var config = new SeedingRule
var config = new QBitSeedingRule
{
Name = "test-category",
MaxRatio = -1,
@@ -111,7 +111,7 @@ public sealed class SeedingRuleTests
[Fact]
public void Validate_WithEmptyName_ThrowsValidationException()
{
var config = new SeedingRule
var config = new QBitSeedingRule
{
Name = "",
MaxRatio = 2.0,
@@ -121,13 +121,13 @@ public sealed class SeedingRuleTests
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
exception.Message.ShouldBe("Category name can not be empty");
exception.Message.ShouldBe("Rule name can not be empty");
}
[Fact]
public void Validate_WithWhitespaceName_ThrowsValidationException()
{
var config = new SeedingRule
var config = new QBitSeedingRule
{
Name = " ",
MaxRatio = 2.0,
@@ -137,13 +137,13 @@ public sealed class SeedingRuleTests
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
exception.Message.ShouldBe("Category name can not be empty");
exception.Message.ShouldBe("Rule name can not be empty");
}
[Fact]
public void Validate_WithTabOnlyName_ThrowsValidationException()
{
var config = new SeedingRule
var config = new QBitSeedingRule
{
Name = "\t",
MaxRatio = 2.0,
@@ -153,7 +153,7 @@ public sealed class SeedingRuleTests
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
exception.Message.ShouldBe("Category name can not be empty");
exception.Message.ShouldBe("Rule name can not be empty");
}
#endregion
@@ -163,7 +163,7 @@ public sealed class SeedingRuleTests
[Fact]
public void Validate_WithBothNegative_ThrowsValidationException()
{
var config = new SeedingRule
var config = new QBitSeedingRule
{
Name = "test-category",
MaxRatio = -1,
@@ -182,7 +182,7 @@ public sealed class SeedingRuleTests
[InlineData(-100, -100)]
public void Validate_WithVariousNegativeValues_ThrowsValidationException(double maxRatio, double maxSeedTime)
{
var config = new SeedingRule
var config = new QBitSeedingRule
{
Name = "test-category",
MaxRatio = maxRatio,
@@ -202,7 +202,7 @@ public sealed class SeedingRuleTests
[Fact]
public void Validate_WithNegativeMinSeedTime_ThrowsValidationException()
{
var config = new SeedingRule
var config = new QBitSeedingRule
{
Name = "test-category",
MaxRatio = 2.0,
@@ -221,7 +221,7 @@ public sealed class SeedingRuleTests
[InlineData(-100)]
public void Validate_WithVariousNegativeMinSeedTime_ThrowsValidationException(double minSeedTime)
{
var config = new SeedingRule
var config = new QBitSeedingRule
{
Name = "test-category",
MaxRatio = 2.0,
@@ -237,7 +237,7 @@ public sealed class SeedingRuleTests
[Fact]
public void Validate_WithZeroMinSeedTime_DoesNotThrow()
{
var config = new SeedingRule
var config = new QBitSeedingRule
{
Name = "test-category",
MaxRatio = 2.0,
@@ -252,7 +252,7 @@ public sealed class SeedingRuleTests
[Fact]
public void Validate_WithPositiveMinSeedTime_DoesNotThrow()
{
var config = new SeedingRule
var config = new QBitSeedingRule
{
Name = "test-category",
MaxRatio = 2.0,
@@ -269,7 +269,7 @@ public sealed class SeedingRuleTests
[Fact]
public void DeleteSourceFiles_CanBeSetToFalse()
{
var config = new SeedingRule
var config = new QBitSeedingRule
{
Name = "test-category",
MaxRatio = 2.0,
@@ -0,0 +1,252 @@
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Shouldly;
using Xunit;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Tests.Models.Configuration.DownloadCleaner;
public sealed class UnlinkedConfigTests
{
#region Default Values
[Fact]
public void Defaults_EnabledIsFalse()
{
var config = new UnlinkedConfig();
config.Enabled.ShouldBeFalse();
}
[Fact]
public void Defaults_TargetCategoryIsSet()
{
var config = new UnlinkedConfig();
config.TargetCategory.ShouldBe("cleanuparr-unlinked");
}
[Fact]
public void Defaults_CategoriesIsEmpty()
{
var config = new UnlinkedConfig();
config.Categories.ShouldBeEmpty();
}
[Fact]
public void Defaults_DownloadDirectorySourceIsNull()
{
var config = new UnlinkedConfig();
config.DownloadDirectorySource.ShouldBeNull();
}
[Fact]
public void Defaults_DownloadDirectoryTargetIsNull()
{
var config = new UnlinkedConfig();
config.DownloadDirectoryTarget.ShouldBeNull();
}
#endregion
#region Validate - Disabled
[Fact]
public void Validate_WhenDisabled_DoesNotThrow()
{
var config = new UnlinkedConfig
{
Enabled = false,
TargetCategory = "",
Categories = []
};
Should.NotThrow(() => config.Validate());
}
#endregion
#region Validate - Enabled
[Fact]
public void Validate_WhenEnabled_WithValidConfig_DoesNotThrow()
{
var config = new UnlinkedConfig
{
Enabled = true,
TargetCategory = "cleanuparr-unlinked",
Categories = ["movies", "tv"]
};
Should.NotThrow(() => config.Validate());
}
[Fact]
public void Validate_WhenEnabled_WithEmptyTargetCategory_ThrowsValidationException()
{
var config = new UnlinkedConfig
{
Enabled = true,
TargetCategory = "",
Categories = ["movies"]
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
exception.Message.ShouldBe("Unlinked target category is required");
}
[Fact]
public void Validate_WhenEnabled_WithEmptyCategories_ThrowsValidationException()
{
var config = new UnlinkedConfig
{
Enabled = true,
TargetCategory = "cleanuparr-unlinked",
Categories = []
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
exception.Message.ShouldBe("No unlinked categories configured");
}
[Fact]
public void Validate_WhenEnabled_WithTargetCategoryInCategories_ThrowsValidationException()
{
var config = new UnlinkedConfig
{
Enabled = true,
TargetCategory = "cleanuparr-unlinked",
Categories = ["movies", "cleanuparr-unlinked"]
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
exception.Message.ShouldBe("The unlinked target category should not be present in unlinked categories");
}
[Fact]
public void Validate_WhenEnabled_WithEmptyCategoryEntry_ThrowsValidationException()
{
var config = new UnlinkedConfig
{
Enabled = true,
TargetCategory = "cleanuparr-unlinked",
Categories = ["movies", ""]
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
exception.Message.ShouldBe("Empty unlinked category filter found");
}
#endregion
#region Validate - Directory Mapping
[Fact]
public void Validate_WhenEnabled_WithOnlySourceSet_ThrowsValidationException()
{
var config = new UnlinkedConfig
{
Enabled = true,
TargetCategory = "cleanuparr-unlinked",
Categories = ["movies"],
DownloadDirectorySource = "/downloads",
DownloadDirectoryTarget = null
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
exception.Message.ShouldBe("Both download directory source and target must be set, or both must be empty");
}
[Fact]
public void Validate_WhenEnabled_WithOnlyTargetSet_ThrowsValidationException()
{
var config = new UnlinkedConfig
{
Enabled = true,
TargetCategory = "cleanuparr-unlinked",
Categories = ["movies"],
DownloadDirectorySource = null,
DownloadDirectoryTarget = "/data/downloads"
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
exception.Message.ShouldBe("Both download directory source and target must be set, or both must be empty");
}
[Fact]
public void Validate_WhenEnabled_WithBothDirsSet_DoesNotThrow()
{
var config = new UnlinkedConfig
{
Enabled = true,
TargetCategory = "cleanuparr-unlinked",
Categories = ["movies"],
DownloadDirectorySource = "/downloads",
DownloadDirectoryTarget = "/data/downloads"
};
Should.NotThrow(() => config.Validate());
}
[Fact]
public void Validate_WhenEnabled_WithBothDirsEmpty_DoesNotThrow()
{
var config = new UnlinkedConfig
{
Enabled = true,
TargetCategory = "cleanuparr-unlinked",
Categories = ["movies"],
DownloadDirectorySource = null,
DownloadDirectoryTarget = null
};
Should.NotThrow(() => config.Validate());
}
#endregion
#region Validate - Ignored Root Dirs
[Fact]
public void Validate_WhenEnabled_WithNonExistentIgnoredRootDir_ThrowsValidationException()
{
var config = new UnlinkedConfig
{
Enabled = true,
TargetCategory = "cleanuparr-unlinked",
Categories = ["movies"],
IgnoredRootDirs = ["/non/existent/path/that/should/not/exist"]
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
exception.Message.ShouldContain("root directory does not exist");
}
[Fact]
public void Validate_WhenEnabled_WithEmptyIgnoredRootDirs_DoesNotThrow()
{
var config = new UnlinkedConfig
{
Enabled = true,
TargetCategory = "cleanuparr-unlinked",
Categories = ["movies"],
IgnoredRootDirs = []
};
Should.NotThrow(() => config.Validate());
}
[Fact]
public void Validate_WhenEnabled_SkipsEmptyStringInIgnoredRootDirs()
{
var config = new UnlinkedConfig
{
Enabled = true,
TargetCategory = "cleanuparr-unlinked",
Categories = ["movies"],
IgnoredRootDirs = [""]
};
// Empty strings are filtered out, so this should not throw
Should.NotThrow(() => config.Validate());
}
#endregion
}
@@ -40,7 +40,17 @@ public class DataContext : DbContext
public DbSet<DownloadCleanerConfig> DownloadCleanerConfigs { get; set; }
public DbSet<SeedingRule> SeedingRules { get; set; }
public DbSet<QBitSeedingRule> QBitSeedingRules { get; set; }
public DbSet<DelugeSeedingRule> DelugeSeedingRules { get; set; }
public DbSet<TransmissionSeedingRule> TransmissionSeedingRules { get; set; }
public DbSet<UTorrentSeedingRule> UTorrentSeedingRules { get; set; }
public DbSet<RTorrentSeedingRule> RTorrentSeedingRules { get; set; }
public DbSet<UnlinkedConfig> UnlinkedConfigs { get; set; }
public DbSet<ArrConfig> ArrConfigs { get; set; }
@@ -284,6 +294,58 @@ public class DataContext : DbContext
entity.Property(s => s.RecordedAt).HasConversion(new UtcDateTimeConverter());
});
// Configure per-client seeding rule relationships
modelBuilder.Entity<QBitSeedingRule>(entity =>
{
entity.HasOne(s => s.DownloadClientConfig)
.WithMany()
.HasForeignKey(s => s.DownloadClientConfigId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<DelugeSeedingRule>(entity =>
{
entity.HasOne(s => s.DownloadClientConfig)
.WithMany()
.HasForeignKey(s => s.DownloadClientConfigId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<TransmissionSeedingRule>(entity =>
{
entity.HasOne(s => s.DownloadClientConfig)
.WithMany()
.HasForeignKey(s => s.DownloadClientConfigId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<UTorrentSeedingRule>(entity =>
{
entity.HasOne(s => s.DownloadClientConfig)
.WithMany()
.HasForeignKey(s => s.DownloadClientConfigId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<RTorrentSeedingRule>(entity =>
{
entity.HasOne(s => s.DownloadClientConfig)
.WithMany()
.HasForeignKey(s => s.DownloadClientConfigId)
.OnDelete(DeleteBehavior.Cascade);
});
// Configure per-client unlinked config relationship
modelBuilder.Entity<UnlinkedConfig>(entity =>
{
entity.HasOne(u => u.DownloadClientConfig)
.WithMany()
.HasForeignKey(u => u.DownloadClientConfigId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasIndex(u => u.DownloadClientConfigId).IsUnique();
});
// Configure BlacklistSyncState relationships and indexes
modelBuilder.Entity<BlacklistSyncHistory>(entity =>
{
@@ -0,0 +1,354 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class AddPerClientDownloadCleanerSettings : Migration
{
/// <inheritdoc />
private const string NewGuid = "hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || substr(hex(randomblob(2)),2) || '-' || substr('89AB',abs(random())%4+1,1) || substr(hex(randomblob(2)),2) || '-' || hex(randomblob(6))";
protected override void Up(MigrationBuilder migrationBuilder)
{
// 1. Create new tables first (before dropping old data)
migrationBuilder.CreateTable(
name: "deluge_seeding_rules",
columns: table => new
{
id = table.Column<Guid>(type: "TEXT", nullable: false),
download_client_config_id = table.Column<Guid>(type: "TEXT", nullable: false),
name = table.Column<string>(type: "TEXT", nullable: false),
privacy_type = table.Column<string>(type: "TEXT", nullable: false),
max_ratio = table.Column<double>(type: "REAL", nullable: false),
min_seed_time = table.Column<double>(type: "REAL", nullable: false),
max_seed_time = table.Column<double>(type: "REAL", nullable: false),
delete_source_files = table.Column<bool>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_deluge_seeding_rules", x => x.id);
table.ForeignKey(
name: "fk_deluge_seeding_rules_download_clients_download_client_config_id",
column: x => x.download_client_config_id,
principalTable: "download_clients",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "q_bit_seeding_rules",
columns: table => new
{
id = table.Column<Guid>(type: "TEXT", nullable: false),
download_client_config_id = table.Column<Guid>(type: "TEXT", nullable: false),
name = table.Column<string>(type: "TEXT", nullable: false),
privacy_type = table.Column<string>(type: "TEXT", nullable: false),
max_ratio = table.Column<double>(type: "REAL", nullable: false),
min_seed_time = table.Column<double>(type: "REAL", nullable: false),
max_seed_time = table.Column<double>(type: "REAL", nullable: false),
delete_source_files = table.Column<bool>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_q_bit_seeding_rules", x => x.id);
table.ForeignKey(
name: "fk_q_bit_seeding_rules_download_clients_download_client_config_id",
column: x => x.download_client_config_id,
principalTable: "download_clients",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "r_torrent_seeding_rules",
columns: table => new
{
id = table.Column<Guid>(type: "TEXT", nullable: false),
download_client_config_id = table.Column<Guid>(type: "TEXT", nullable: false),
name = table.Column<string>(type: "TEXT", nullable: false),
privacy_type = table.Column<string>(type: "TEXT", nullable: false),
max_ratio = table.Column<double>(type: "REAL", nullable: false),
min_seed_time = table.Column<double>(type: "REAL", nullable: false),
max_seed_time = table.Column<double>(type: "REAL", nullable: false),
delete_source_files = table.Column<bool>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_r_torrent_seeding_rules", x => x.id);
table.ForeignKey(
name: "fk_r_torrent_seeding_rules_download_clients_download_client_config_id",
column: x => x.download_client_config_id,
principalTable: "download_clients",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "transmission_seeding_rules",
columns: table => new
{
id = table.Column<Guid>(type: "TEXT", nullable: false),
download_client_config_id = table.Column<Guid>(type: "TEXT", nullable: false),
name = table.Column<string>(type: "TEXT", nullable: false),
privacy_type = table.Column<string>(type: "TEXT", nullable: false),
max_ratio = table.Column<double>(type: "REAL", nullable: false),
min_seed_time = table.Column<double>(type: "REAL", nullable: false),
max_seed_time = table.Column<double>(type: "REAL", nullable: false),
delete_source_files = table.Column<bool>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_transmission_seeding_rules", x => x.id);
table.ForeignKey(
name: "fk_transmission_seeding_rules_download_clients_download_client_config_id",
column: x => x.download_client_config_id,
principalTable: "download_clients",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "u_torrent_seeding_rules",
columns: table => new
{
id = table.Column<Guid>(type: "TEXT", nullable: false),
download_client_config_id = table.Column<Guid>(type: "TEXT", nullable: false),
name = table.Column<string>(type: "TEXT", nullable: false),
privacy_type = table.Column<string>(type: "TEXT", nullable: false),
max_ratio = table.Column<double>(type: "REAL", nullable: false),
min_seed_time = table.Column<double>(type: "REAL", nullable: false),
max_seed_time = table.Column<double>(type: "REAL", nullable: false),
delete_source_files = table.Column<bool>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_u_torrent_seeding_rules", x => x.id);
table.ForeignKey(
name: "fk_u_torrent_seeding_rules_download_clients_download_client_config_id",
column: x => x.download_client_config_id,
principalTable: "download_clients",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "unlinked_configs",
columns: table => new
{
id = table.Column<Guid>(type: "TEXT", nullable: false),
download_client_config_id = table.Column<Guid>(type: "TEXT", nullable: false),
enabled = table.Column<bool>(type: "INTEGER", nullable: false),
target_category = table.Column<string>(type: "TEXT", nullable: false),
use_tag = table.Column<bool>(type: "INTEGER", nullable: false),
ignored_root_dirs = table.Column<string>(type: "TEXT", nullable: false),
categories = table.Column<string>(type: "TEXT", nullable: false),
download_directory_source = table.Column<string>(type: "TEXT", nullable: true),
download_directory_target = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_unlinked_configs", x => x.id);
table.ForeignKey(
name: "fk_unlinked_configs_download_clients_download_client_config_id",
column: x => x.download_client_config_id,
principalTable: "download_clients",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_deluge_seeding_rules_download_client_config_id",
table: "deluge_seeding_rules",
column: "download_client_config_id");
migrationBuilder.CreateIndex(
name: "ix_q_bit_seeding_rules_download_client_config_id",
table: "q_bit_seeding_rules",
column: "download_client_config_id");
migrationBuilder.CreateIndex(
name: "ix_r_torrent_seeding_rules_download_client_config_id",
table: "r_torrent_seeding_rules",
column: "download_client_config_id");
migrationBuilder.CreateIndex(
name: "ix_transmission_seeding_rules_download_client_config_id",
table: "transmission_seeding_rules",
column: "download_client_config_id");
migrationBuilder.CreateIndex(
name: "ix_u_torrent_seeding_rules_download_client_config_id",
table: "u_torrent_seeding_rules",
column: "download_client_config_id");
migrationBuilder.CreateIndex(
name: "ix_unlinked_configs_download_client_config_id",
table: "unlinked_configs",
column: "download_client_config_id",
unique: true);
// 2. Migrate existing seeding rules to per-client tables
// For each download client, copy all global seeding rules to the per-type table matching the client's type_name
migrationBuilder.Sql($@"
INSERT INTO q_bit_seeding_rules (id, download_client_config_id, name, privacy_type, max_ratio, min_seed_time, max_seed_time, delete_source_files)
SELECT {NewGuid}, dc.id, sr.name, sr.privacy_type, sr.max_ratio, sr.min_seed_time, sr.max_seed_time, sr.delete_source_files
FROM download_clients dc
CROSS JOIN seeding_rules sr
WHERE dc.type_name = 'qbittorrent';
");
migrationBuilder.Sql($@"
INSERT INTO deluge_seeding_rules (id, download_client_config_id, name, privacy_type, max_ratio, min_seed_time, max_seed_time, delete_source_files)
SELECT {NewGuid}, dc.id, sr.name, sr.privacy_type, sr.max_ratio, sr.min_seed_time, sr.max_seed_time, sr.delete_source_files
FROM download_clients dc
CROSS JOIN seeding_rules sr
WHERE dc.type_name = 'deluge';
");
migrationBuilder.Sql($@"
INSERT INTO transmission_seeding_rules (id, download_client_config_id, name, privacy_type, max_ratio, min_seed_time, max_seed_time, delete_source_files)
SELECT {NewGuid}, dc.id, sr.name, sr.privacy_type, sr.max_ratio, sr.min_seed_time, sr.max_seed_time, sr.delete_source_files
FROM download_clients dc
CROSS JOIN seeding_rules sr
WHERE dc.type_name = 'transmission';
");
migrationBuilder.Sql($@"
INSERT INTO u_torrent_seeding_rules (id, download_client_config_id, name, privacy_type, max_ratio, min_seed_time, max_seed_time, delete_source_files)
SELECT {NewGuid}, dc.id, sr.name, sr.privacy_type, sr.max_ratio, sr.min_seed_time, sr.max_seed_time, sr.delete_source_files
FROM download_clients dc
CROSS JOIN seeding_rules sr
WHERE dc.type_name = 'utorrent';
");
migrationBuilder.Sql($@"
INSERT INTO r_torrent_seeding_rules (id, download_client_config_id, name, privacy_type, max_ratio, min_seed_time, max_seed_time, delete_source_files)
SELECT {NewGuid}, dc.id, sr.name, sr.privacy_type, sr.max_ratio, sr.min_seed_time, sr.max_seed_time, sr.delete_source_files
FROM download_clients dc
CROSS JOIN seeding_rules sr
WHERE dc.type_name = 'rtorrent';
");
// 3. Migrate unlinked config for each download client
migrationBuilder.Sql($@"
INSERT INTO unlinked_configs (id, download_client_config_id, enabled, target_category, use_tag, ignored_root_dirs, categories)
SELECT {NewGuid}, dc.id, dcc.unlinked_enabled, dcc.unlinked_target_category, dcc.unlinked_use_tag, dcc.unlinked_ignored_root_dirs, dcc.unlinked_categories
FROM download_clients dc
CROSS JOIN download_cleaner_configs dcc;
");
// 4. Drop old tables and columns
migrationBuilder.DropTable(
name: "seeding_rules");
migrationBuilder.DropColumn(
name: "unlinked_categories",
table: "download_cleaner_configs");
migrationBuilder.DropColumn(
name: "unlinked_enabled",
table: "download_cleaner_configs");
migrationBuilder.DropColumn(
name: "unlinked_ignored_root_dirs",
table: "download_cleaner_configs");
migrationBuilder.DropColumn(
name: "unlinked_target_category",
table: "download_cleaner_configs");
migrationBuilder.DropColumn(
name: "unlinked_use_tag",
table: "download_cleaner_configs");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "deluge_seeding_rules");
migrationBuilder.DropTable(
name: "q_bit_seeding_rules");
migrationBuilder.DropTable(
name: "r_torrent_seeding_rules");
migrationBuilder.DropTable(
name: "transmission_seeding_rules");
migrationBuilder.DropTable(
name: "u_torrent_seeding_rules");
migrationBuilder.DropTable(
name: "unlinked_configs");
migrationBuilder.AddColumn<string>(
name: "unlinked_categories",
table: "download_cleaner_configs",
type: "TEXT",
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<bool>(
name: "unlinked_enabled",
table: "download_cleaner_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "unlinked_ignored_root_dirs",
table: "download_cleaner_configs",
type: "TEXT",
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "unlinked_target_category",
table: "download_cleaner_configs",
type: "TEXT",
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<bool>(
name: "unlinked_use_tag",
table: "download_cleaner_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.CreateTable(
name: "seeding_rules",
columns: table => new
{
id = table.Column<Guid>(type: "TEXT", nullable: false),
download_cleaner_config_id = table.Column<Guid>(type: "TEXT", nullable: false),
delete_source_files = table.Column<bool>(type: "INTEGER", nullable: false),
max_ratio = table.Column<double>(type: "REAL", nullable: false),
max_seed_time = table.Column<double>(type: "REAL", nullable: false),
min_seed_time = table.Column<double>(type: "REAL", nullable: false),
name = table.Column<string>(type: "TEXT", nullable: false),
privacy_type = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_seeding_rules", x => x.id);
table.ForeignKey(
name: "fk_seeding_rules_download_cleaner_configs_download_cleaner_config_id",
column: x => x.download_cleaner_config_id,
principalTable: "download_cleaner_configs",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_seeding_rules_download_cleaner_config_id",
table: "seeding_rules",
column: "download_cleaner_config_id");
}
}
}
@@ -113,61 +113,7 @@ namespace Cleanuparr.Persistence.Migrations.Data
b.ToTable("blacklist_sync_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("CronExpression")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("cron_expression");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.PrimitiveCollection<string>("IgnoredDownloads")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("ignored_downloads");
b.PrimitiveCollection<string>("UnlinkedCategories")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("unlinked_categories");
b.Property<bool>("UnlinkedEnabled")
.HasColumnType("INTEGER")
.HasColumnName("unlinked_enabled");
b.PrimitiveCollection<string>("UnlinkedIgnoredRootDirs")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("unlinked_ignored_root_dirs");
b.Property<string>("UnlinkedTargetCategory")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("unlinked_target_category");
b.Property<bool>("UnlinkedUseTag")
.HasColumnType("INTEGER")
.HasColumnName("unlinked_use_tag");
b.Property<bool>("UseAdvancedScheduling")
.HasColumnType("INTEGER")
.HasColumnName("use_advanced_scheduling");
b.HasKey("Id")
.HasName("pk_download_cleaner_configs");
b.ToTable("download_cleaner_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.SeedingRule", b =>
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DelugeSeedingRule", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -178,9 +124,9 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("INTEGER")
.HasColumnName("delete_source_files");
b.Property<Guid>("DownloadCleanerConfigId")
b.Property<Guid>("DownloadClientConfigId")
.HasColumnType("TEXT")
.HasColumnName("download_cleaner_config_id");
.HasColumnName("download_client_config_id");
b.Property<double>("MaxRatio")
.HasColumnType("REAL")
@@ -205,12 +151,279 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnName("privacy_type");
b.HasKey("Id")
.HasName("pk_seeding_rules");
.HasName("pk_deluge_seeding_rules");
b.HasIndex("DownloadCleanerConfigId")
.HasDatabaseName("ix_seeding_rules_download_cleaner_config_id");
b.HasIndex("DownloadClientConfigId")
.HasDatabaseName("ix_deluge_seeding_rules_download_client_config_id");
b.ToTable("seeding_rules", (string)null);
b.ToTable("deluge_seeding_rules", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("CronExpression")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("cron_expression");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.PrimitiveCollection<string>("IgnoredDownloads")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("ignored_downloads");
b.Property<bool>("UseAdvancedScheduling")
.HasColumnType("INTEGER")
.HasColumnName("use_advanced_scheduling");
b.HasKey("Id")
.HasName("pk_download_cleaner_configs");
b.ToTable("download_cleaner_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.QBitSeedingRule", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("DeleteSourceFiles")
.HasColumnType("INTEGER")
.HasColumnName("delete_source_files");
b.Property<Guid>("DownloadClientConfigId")
.HasColumnType("TEXT")
.HasColumnName("download_client_config_id");
b.Property<double>("MaxRatio")
.HasColumnType("REAL")
.HasColumnName("max_ratio");
b.Property<double>("MaxSeedTime")
.HasColumnType("REAL")
.HasColumnName("max_seed_time");
b.Property<double>("MinSeedTime")
.HasColumnType("REAL")
.HasColumnName("min_seed_time");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<string>("PrivacyType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("privacy_type");
b.HasKey("Id")
.HasName("pk_q_bit_seeding_rules");
b.HasIndex("DownloadClientConfigId")
.HasDatabaseName("ix_q_bit_seeding_rules_download_client_config_id");
b.ToTable("q_bit_seeding_rules", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.RTorrentSeedingRule", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("DeleteSourceFiles")
.HasColumnType("INTEGER")
.HasColumnName("delete_source_files");
b.Property<Guid>("DownloadClientConfigId")
.HasColumnType("TEXT")
.HasColumnName("download_client_config_id");
b.Property<double>("MaxRatio")
.HasColumnType("REAL")
.HasColumnName("max_ratio");
b.Property<double>("MaxSeedTime")
.HasColumnType("REAL")
.HasColumnName("max_seed_time");
b.Property<double>("MinSeedTime")
.HasColumnType("REAL")
.HasColumnName("min_seed_time");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<string>("PrivacyType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("privacy_type");
b.HasKey("Id")
.HasName("pk_r_torrent_seeding_rules");
b.HasIndex("DownloadClientConfigId")
.HasDatabaseName("ix_r_torrent_seeding_rules_download_client_config_id");
b.ToTable("r_torrent_seeding_rules", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.TransmissionSeedingRule", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("DeleteSourceFiles")
.HasColumnType("INTEGER")
.HasColumnName("delete_source_files");
b.Property<Guid>("DownloadClientConfigId")
.HasColumnType("TEXT")
.HasColumnName("download_client_config_id");
b.Property<double>("MaxRatio")
.HasColumnType("REAL")
.HasColumnName("max_ratio");
b.Property<double>("MaxSeedTime")
.HasColumnType("REAL")
.HasColumnName("max_seed_time");
b.Property<double>("MinSeedTime")
.HasColumnType("REAL")
.HasColumnName("min_seed_time");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<string>("PrivacyType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("privacy_type");
b.HasKey("Id")
.HasName("pk_transmission_seeding_rules");
b.HasIndex("DownloadClientConfigId")
.HasDatabaseName("ix_transmission_seeding_rules_download_client_config_id");
b.ToTable("transmission_seeding_rules", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.UTorrentSeedingRule", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("DeleteSourceFiles")
.HasColumnType("INTEGER")
.HasColumnName("delete_source_files");
b.Property<Guid>("DownloadClientConfigId")
.HasColumnType("TEXT")
.HasColumnName("download_client_config_id");
b.Property<double>("MaxRatio")
.HasColumnType("REAL")
.HasColumnName("max_ratio");
b.Property<double>("MaxSeedTime")
.HasColumnType("REAL")
.HasColumnName("max_seed_time");
b.Property<double>("MinSeedTime")
.HasColumnType("REAL")
.HasColumnName("min_seed_time");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<string>("PrivacyType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("privacy_type");
b.HasKey("Id")
.HasName("pk_u_torrent_seeding_rules");
b.HasIndex("DownloadClientConfigId")
.HasDatabaseName("ix_u_torrent_seeding_rules_download_client_config_id");
b.ToTable("u_torrent_seeding_rules", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.UnlinkedConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.PrimitiveCollection<string>("Categories")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("categories");
b.Property<Guid>("DownloadClientConfigId")
.HasColumnType("TEXT")
.HasColumnName("download_client_config_id");
b.Property<string>("DownloadDirectorySource")
.HasColumnType("TEXT")
.HasColumnName("download_directory_source");
b.Property<string>("DownloadDirectoryTarget")
.HasColumnType("TEXT")
.HasColumnName("download_directory_target");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.PrimitiveCollection<string>("IgnoredRootDirs")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("ignored_root_dirs");
b.Property<string>("TargetCategory")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("target_category");
b.Property<bool>("UseTag")
.HasColumnType("INTEGER")
.HasColumnName("use_tag");
b.HasKey("Id")
.HasName("pk_unlinked_configs");
b.HasIndex("DownloadClientConfigId")
.IsUnique()
.HasDatabaseName("ix_unlinked_configs_download_client_config_id");
b.ToTable("unlinked_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", b =>
@@ -1498,16 +1711,76 @@ namespace Cleanuparr.Persistence.Migrations.Data
b.Navigation("ArrConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.SeedingRule", b =>
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DelugeSeedingRule", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", "DownloadCleanerConfig")
.WithMany("Categories")
.HasForeignKey("DownloadCleanerConfigId")
b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig")
.WithMany()
.HasForeignKey("DownloadClientConfigId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_seeding_rules_download_cleaner_configs_download_cleaner_config_id");
.HasConstraintName("fk_deluge_seeding_rules_download_clients_download_client_config_id");
b.Navigation("DownloadCleanerConfig");
b.Navigation("DownloadClientConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.QBitSeedingRule", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig")
.WithMany()
.HasForeignKey("DownloadClientConfigId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_q_bit_seeding_rules_download_clients_download_client_config_id");
b.Navigation("DownloadClientConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.RTorrentSeedingRule", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig")
.WithMany()
.HasForeignKey("DownloadClientConfigId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_r_torrent_seeding_rules_download_clients_download_client_config_id");
b.Navigation("DownloadClientConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.TransmissionSeedingRule", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig")
.WithMany()
.HasForeignKey("DownloadClientConfigId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_transmission_seeding_rules_download_clients_download_client_config_id");
b.Navigation("DownloadClientConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.UTorrentSeedingRule", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig")
.WithMany()
.HasForeignKey("DownloadClientConfigId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_u_torrent_seeding_rules_download_clients_download_client_config_id");
b.Navigation("DownloadClientConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.UnlinkedConfig", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig")
.WithMany()
.HasForeignKey("DownloadClientConfigId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_unlinked_configs_download_clients_download_client_config_id");
b.Navigation("DownloadClientConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b =>
@@ -1707,11 +1980,6 @@ namespace Cleanuparr.Persistence.Migrations.Data
b.Navigation("Instances");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b =>
{
b.Navigation("Categories");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b =>
{
b.Navigation("AppriseConfiguration");
@@ -1,52 +1,52 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Cleanuparr.Domain.Enums;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
public sealed record SeedingRule : IConfig
public sealed record DelugeSeedingRule : ISeedingRule
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid Id { get; init; } = Guid.NewGuid();
public Guid DownloadCleanerConfigId { get; set; }
public DownloadCleanerConfig DownloadCleanerConfig { get; set; }
public required string Name { get; init; }
public Guid Id { get; set; } = Guid.NewGuid();
public Guid DownloadClientConfigId { get; set; }
public DownloadClientConfig DownloadClientConfig { get; set; } = null!;
public string Name { get; set; } = string.Empty;
/// <summary>
/// Which torrent privacy types this rule applies to.
/// </summary>
public TorrentPrivacyType PrivacyType { get; init; } = TorrentPrivacyType.Public;
public TorrentPrivacyType PrivacyType { get; set; } = TorrentPrivacyType.Public;
/// <summary>
/// Max ratio before removing a download.
/// </summary>
public required double MaxRatio { get; init; } = -1;
public double MaxRatio { get; set; } = -1;
/// <summary>
/// Min number of hours to seed before removing a download, if the ratio has been met.
/// </summary>
public required double MinSeedTime { get; init; }
public double MinSeedTime { get; set; }
/// <summary>
/// Number of hours to seed before removing a download.
/// </summary>
public required double MaxSeedTime { get; init; } = -1;
public double MaxSeedTime { get; set; } = -1;
/// <summary>
/// Whether to delete the source files when cleaning the download.
/// </summary>
public required bool DeleteSourceFiles { get; init; }
public bool DeleteSourceFiles { get; set; }
public void Validate()
{
if (string.IsNullOrEmpty(Name.Trim()))
{
throw new ValidationException("Category name can not be empty");
throw new ValidationException("Rule name can not be empty");
}
if (MaxRatio < 0 && MaxSeedTime < 0)
@@ -59,4 +59,4 @@ public sealed record SeedingRule : IConfig
throw new ValidationException("Min seed time can not be negative");
}
}
}
}
@@ -1,7 +1,5 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Cleanuparr.Domain.Enums;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
@@ -10,7 +8,7 @@ public sealed record DownloadCleanerConfig : IJobConfig
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid Id { get; set; } = Guid.NewGuid();
public bool Enabled { get; set; }
public string CronExpression { get; set; } = "0 0 * * * ?";
@@ -20,88 +18,9 @@ public sealed record DownloadCleanerConfig : IJobConfig
/// </summary>
public bool UseAdvancedScheduling { get; set; }
public List<SeedingRule> Categories { get; set; } = [];
/// <summary>
/// Indicates whether unlinked download handling is enabled
/// </summary>
public bool UnlinkedEnabled { get; set; } = false;
public string UnlinkedTargetCategory { get; set; } = "cleanuparr-unlinked";
public bool UnlinkedUseTag { get; set; }
public List<string> UnlinkedIgnoredRootDirs { get; set; } = [];
public List<string> UnlinkedCategories { get; set; } = [];
public List<string> IgnoredDownloads { get; set; } = [];
public void Validate()
{
if (!Enabled)
{
return;
}
// Validate that at least one feature is configured
bool hasSeedingCategories = Categories.Count > 0;
bool hasUnlinkedFeature = UnlinkedEnabled && UnlinkedCategories.Count > 0 && !string.IsNullOrWhiteSpace(UnlinkedTargetCategory);
if (!hasSeedingCategories && !hasUnlinkedFeature)
{
throw new ValidationException("No features are enabled");
}
if (Categories.GroupBy(x => new { Name = x.Name.ToUpperInvariant(), x.PrivacyType }).Any(x => x.Count() > 1))
{
throw new ValidationException("Duplicated clean category and privacy type combination found");
}
var categoriesByName = Categories.GroupBy(x => x.Name, StringComparer.InvariantCultureIgnoreCase);
foreach (var group in categoriesByName)
{
if (group.Count() > 1 && group.Any(x => x.PrivacyType == TorrentPrivacyType.Both))
{
throw new ValidationException(
$"Category '{group.Key}' has a rule with privacy type 'Both' which already covers all torrent types");
}
}
Categories.ForEach(x => x.Validate());
// Only validate unlinked settings if unlinked handling is enabled
if (!UnlinkedEnabled)
{
return;
}
if (string.IsNullOrEmpty(UnlinkedTargetCategory))
{
throw new ValidationException("unlinked target category is required");
}
if (UnlinkedCategories.Count is 0)
{
throw new ValidationException("No unlinked categories configured");
}
if (UnlinkedCategories.Contains(UnlinkedTargetCategory))
{
throw new ValidationException("The unlinked target category should not be present in unlinked categories");
}
if (UnlinkedCategories.Any(string.IsNullOrEmpty))
{
throw new ValidationException("Empty unlinked category filter found");
}
foreach (var dir in UnlinkedIgnoredRootDirs.Where(d => !string.IsNullOrEmpty(d)))
{
if (!Directory.Exists(dir))
{
throw new ValidationException($"{dir} root directory does not exist");
}
}
}
}
}
@@ -0,0 +1,24 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
public interface ISeedingRule : IConfig
{
Guid Id { get; set; }
Guid DownloadClientConfigId { get; set; }
DownloadClientConfig DownloadClientConfig { get; set; }
string Name { get; set; }
TorrentPrivacyType PrivacyType { get; set; }
double MaxRatio { get; set; }
double MinSeedTime { get; set; }
double MaxSeedTime { get; set; }
bool DeleteSourceFiles { get; set; }
}
@@ -0,0 +1,62 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Cleanuparr.Domain.Enums;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
public sealed record QBitSeedingRule : ISeedingRule
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid Id { get; set; } = Guid.NewGuid();
public Guid DownloadClientConfigId { get; set; }
public DownloadClientConfig DownloadClientConfig { get; set; } = null!;
public string Name { get; set; } = string.Empty;
/// <summary>
/// Which torrent privacy types this rule applies to.
/// </summary>
public TorrentPrivacyType PrivacyType { get; set; } = TorrentPrivacyType.Public;
/// <summary>
/// Max ratio before removing a download.
/// </summary>
public double MaxRatio { get; set; } = -1;
/// <summary>
/// Min number of hours to seed before removing a download, if the ratio has been met.
/// </summary>
public double MinSeedTime { get; set; }
/// <summary>
/// Number of hours to seed before removing a download.
/// </summary>
public double MaxSeedTime { get; set; } = -1;
/// <summary>
/// Whether to delete the source files when cleaning the download.
/// </summary>
public bool DeleteSourceFiles { get; set; }
public void Validate()
{
if (string.IsNullOrEmpty(Name.Trim()))
{
throw new ValidationException("Rule name can not be empty");
}
if (MaxRatio < 0 && MaxSeedTime < 0)
{
throw new ValidationException("Either max ratio or max seed time must be set to a non-negative value");
}
if (MinSeedTime < 0)
{
throw new ValidationException("Min seed time can not be negative");
}
}
}
@@ -0,0 +1,62 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Cleanuparr.Domain.Enums;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
public sealed record RTorrentSeedingRule : ISeedingRule
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid Id { get; set; } = Guid.NewGuid();
public Guid DownloadClientConfigId { get; set; }
public DownloadClientConfig DownloadClientConfig { get; set; } = null!;
public string Name { get; set; } = string.Empty;
/// <summary>
/// Which torrent privacy types this rule applies to.
/// </summary>
public TorrentPrivacyType PrivacyType { get; set; } = TorrentPrivacyType.Public;
/// <summary>
/// Max ratio before removing a download.
/// </summary>
public double MaxRatio { get; set; } = -1;
/// <summary>
/// Min number of hours to seed before removing a download, if the ratio has been met.
/// </summary>
public double MinSeedTime { get; set; }
/// <summary>
/// Number of hours to seed before removing a download.
/// </summary>
public double MaxSeedTime { get; set; } = -1;
/// <summary>
/// Whether to delete the source files when cleaning the download.
/// </summary>
public bool DeleteSourceFiles { get; set; }
public void Validate()
{
if (string.IsNullOrEmpty(Name.Trim()))
{
throw new ValidationException("Rule name can not be empty");
}
if (MaxRatio < 0 && MaxSeedTime < 0)
{
throw new ValidationException("Either max ratio or max seed time must be set to a non-negative value");
}
if (MinSeedTime < 0)
{
throw new ValidationException("Min seed time can not be negative");
}
}
}
@@ -0,0 +1,62 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Cleanuparr.Domain.Enums;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
public sealed record TransmissionSeedingRule : ISeedingRule
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid Id { get; set; } = Guid.NewGuid();
public Guid DownloadClientConfigId { get; set; }
public DownloadClientConfig DownloadClientConfig { get; set; } = null!;
public string Name { get; set; } = string.Empty;
/// <summary>
/// Which torrent privacy types this rule applies to.
/// </summary>
public TorrentPrivacyType PrivacyType { get; set; } = TorrentPrivacyType.Public;
/// <summary>
/// Max ratio before removing a download.
/// </summary>
public double MaxRatio { get; set; } = -1;
/// <summary>
/// Min number of hours to seed before removing a download, if the ratio has been met.
/// </summary>
public double MinSeedTime { get; set; }
/// <summary>
/// Number of hours to seed before removing a download.
/// </summary>
public double MaxSeedTime { get; set; } = -1;
/// <summary>
/// Whether to delete the source files when cleaning the download.
/// </summary>
public bool DeleteSourceFiles { get; set; }
public void Validate()
{
if (string.IsNullOrEmpty(Name.Trim()))
{
throw new ValidationException("Rule name can not be empty");
}
if (MaxRatio < 0 && MaxSeedTime < 0)
{
throw new ValidationException("Either max ratio or max seed time must be set to a non-negative value");
}
if (MinSeedTime < 0)
{
throw new ValidationException("Min seed time can not be negative");
}
}
}
@@ -0,0 +1,62 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Cleanuparr.Domain.Enums;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
public sealed record UTorrentSeedingRule : ISeedingRule
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid Id { get; set; } = Guid.NewGuid();
public Guid DownloadClientConfigId { get; set; }
public DownloadClientConfig DownloadClientConfig { get; set; } = null!;
public string Name { get; set; } = string.Empty;
/// <summary>
/// Which torrent privacy types this rule applies to.
/// </summary>
public TorrentPrivacyType PrivacyType { get; set; } = TorrentPrivacyType.Public;
/// <summary>
/// Max ratio before removing a download.
/// </summary>
public double MaxRatio { get; set; } = -1;
/// <summary>
/// Min number of hours to seed before removing a download, if the ratio has been met.
/// </summary>
public double MinSeedTime { get; set; }
/// <summary>
/// Number of hours to seed before removing a download.
/// </summary>
public double MaxSeedTime { get; set; } = -1;
/// <summary>
/// Whether to delete the source files when cleaning the download.
/// </summary>
public bool DeleteSourceFiles { get; set; }
public void Validate()
{
if (string.IsNullOrEmpty(Name.Trim()))
{
throw new ValidationException("Rule name can not be empty");
}
if (MaxRatio < 0 && MaxSeedTime < 0)
{
throw new ValidationException("Either max ratio or max seed time must be set to a non-negative value");
}
if (MinSeedTime < 0)
{
throw new ValidationException("Min seed time can not be negative");
}
}
}
@@ -0,0 +1,79 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
public sealed record UnlinkedConfig : IConfig
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid Id { get; set; } = Guid.NewGuid();
public Guid DownloadClientConfigId { get; set; }
public DownloadClientConfig DownloadClientConfig { get; set; } = null!;
public bool Enabled { get; set; } = false;
public string TargetCategory { get; set; } = "cleanuparr-unlinked";
public bool UseTag { get; set; }
public List<string> IgnoredRootDirs { get; set; } = [];
public List<string> Categories { get; set; } = [];
/// <summary>
/// The path prefix reported by the download client (e.g., "/downloads").
/// When set, this prefix is replaced with <see cref="DownloadDirectoryTarget"/> when resolving file paths.
/// </summary>
public string? DownloadDirectorySource { get; set; }
/// <summary>
/// The actual local mount path (e.g., "/downloads-other").
/// Replaces <see cref="DownloadDirectorySource"/> in file paths for hardlink checking.
/// </summary>
public string? DownloadDirectoryTarget { get; set; }
public void Validate()
{
if (!Enabled)
{
return;
}
if (string.IsNullOrWhiteSpace(TargetCategory))
{
throw new ValidationException("Unlinked target category is required");
}
if (Categories.Count is 0)
{
throw new ValidationException("No unlinked categories configured");
}
if (Categories.Contains(TargetCategory, StringComparer.OrdinalIgnoreCase))
{
throw new ValidationException("The unlinked target category should not be present in unlinked categories");
}
if (Categories.Any(string.IsNullOrWhiteSpace))
{
throw new ValidationException("Empty unlinked category filter found");
}
if (!string.IsNullOrEmpty(DownloadDirectorySource) != !string.IsNullOrEmpty(DownloadDirectoryTarget))
{
throw new ValidationException("Both download directory source and target must be set, or both must be empty");
}
foreach (var dir in IgnoredRootDirs.Where(d => !string.IsNullOrEmpty(d)))
{
if (!Directory.Exists(dir))
{
throw new ValidationException($"{dir} root directory does not exist or is not accessible (check permissions)");
}
}
}
}
@@ -16,53 +16,53 @@ public sealed record DownloadClientConfig
/// Unique identifier for this client
/// </summary>
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid Id { get; init; } = Guid.NewGuid();
public Guid Id { get; set; } = Guid.NewGuid();
/// <summary>
/// Whether this client is enabled
/// </summary>
public bool Enabled { get; init; } = false;
public bool Enabled { get; set; } = false;
/// <summary>
/// Friendly name for this client
/// </summary>
public required string Name { get; init; }
public required string Name { get; set; }
/// <summary>
/// Type name of download client
/// </summary>
public required DownloadClientTypeName TypeName { get; init; }
public required DownloadClientTypeName TypeName { get; set; }
/// <summary>
/// Type of download client
/// </summary>
public required DownloadClientType Type { get; init; }
public required DownloadClientType Type { get; set; }
/// <summary>
/// Host address for the download client
/// </summary>
public Uri? Host { get; init; }
public Uri? Host { get; set; }
/// <summary>
/// Username for authentication
/// </summary>
public string? Username { get; init; }
public string? Username { get; set; }
/// <summary>
/// Password for authentication
/// </summary>
[SensitiveData]
public string? Password { get; init; }
public string? Password { get; set; }
/// <summary>
/// The base URL path component, used by clients like Transmission and Deluge
/// </summary>
public string? UrlBase { get; init; }
public string? UrlBase { get; set; }
/// <summary>
/// Optional external URL for notifications when internal Docker URLs are not reachable externally
/// </summary>
public Uri? ExternalUrl { get; init; }
public Uri? ExternalUrl { get; set; }
/// <summary>
/// The computed full URL for the client
@@ -0,0 +1,41 @@
namespace Cleanuparr.Shared.Helpers;
/// <summary>
/// Helpers for working with file system paths.
/// </summary>
public static class PathHelper
{
/// <summary>
/// Remaps a file path by replacing a source directory prefix with a target directory prefix.
/// Checks path-segment boundaries to avoid false matches
/// (e.g. source "/downloads" does not match "/downloads-other/file.mkv").
/// </summary>
/// <param name="filePath">The file path to remap.</param>
/// <param name="source">The directory prefix to replace (e.g. "/downloads").</param>
/// <param name="target">The replacement directory prefix (e.g. "/mnt/media").</param>
/// <returns>The remapped path, or <paramref name="filePath"/> unchanged if no match.</returns>
public static string RemapPath(string filePath, string? source, string? target)
{
if (string.IsNullOrEmpty(source) || string.IsNullOrEmpty(target))
{
return filePath;
}
var normSource = source.TrimEnd('/', '\\') + Path.DirectorySeparatorChar;
var normTarget = target.TrimEnd('/', '\\');
// Exact match: filePath is exactly the source directory (no trailing separator)
if (filePath.Equals(normSource.TrimEnd(Path.DirectorySeparatorChar), StringComparison.OrdinalIgnoreCase))
{
return normTarget;
}
// Prefix match with path-segment boundary: filePath starts with "source/"
if (filePath.StartsWith(normSource, StringComparison.OrdinalIgnoreCase))
{
return normTarget + Path.DirectorySeparatorChar + filePath[normSource.Length..];
}
return filePath;
}
}
@@ -99,12 +99,13 @@ export class CfScoreApi {
return this.http.get<CfScoreUpgradesResponse>('/api/seeker/cf-scores/upgrades', { params });
}
getScores(page = 1, pageSize = 50, search?: string, instanceId?: string, sortBy?: string, hideMet?: boolean): Observable<CfScoreEntriesResponse> {
getScores(page = 1, pageSize = 50, search?: string, instanceId?: string, sortBy?: string, hideMet?: boolean, hideUnmonitored?: boolean): Observable<CfScoreEntriesResponse> {
const params: Record<string, string | number | boolean> = { page, pageSize };
if (search) params['search'] = search;
if (instanceId) params['instanceId'] = instanceId;
if (sortBy) params['sortBy'] = sortBy;
if (hideMet) params['hideMet'] = true;
if (hideUnmonitored) params['hideUnmonitored'] = true;
return this.http.get<CfScoreEntriesResponse>('/api/seeker/cf-scores', { params });
}
@@ -1,7 +1,7 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { DownloadCleanerConfig } from '@shared/models/download-cleaner-config.model';
import { DownloadCleanerConfig, SeedingRule, UnlinkedConfigModel } from '@shared/models/download-cleaner-config.model';
@Injectable({ providedIn: 'root' })
export class DownloadCleanerApi {
@@ -14,4 +14,30 @@ export class DownloadCleanerApi {
updateConfig(config: Partial<DownloadCleanerConfig>): Observable<void> {
return this.http.put<void>('/api/configuration/download_cleaner', config);
}
// Seeding rules CRUD
getSeedingRules(clientId: string): Observable<SeedingRule[]> {
return this.http.get<SeedingRule[]>(`/api/seeding-rules/${clientId}`);
}
createSeedingRule(clientId: string, rule: Partial<SeedingRule>): Observable<SeedingRule> {
return this.http.post<SeedingRule>(`/api/seeding-rules/${clientId}`, rule);
}
updateSeedingRule(id: string, rule: Partial<SeedingRule>): Observable<SeedingRule> {
return this.http.put<SeedingRule>(`/api/seeding-rules/${id}`, rule);
}
deleteSeedingRule(id: string): Observable<void> {
return this.http.delete<void>(`/api/seeding-rules/${id}`);
}
// Unlinked config
getUnlinkedConfig(clientId: string): Observable<UnlinkedConfigModel | null> {
return this.http.get<UnlinkedConfigModel | null>(`/api/unlinked-config/${clientId}`);
}
updateUnlinkedConfig(clientId: string, config: Partial<UnlinkedConfigModel>): Observable<void> {
return this.http.put<void>(`/api/unlinked-config/${clientId}`, config);
}
}
@@ -66,6 +66,8 @@ export class DocumentationService {
'enabled': 'enable-download-cleaner',
'ignoredDownloads': 'ignored-downloads',
'useAdvancedScheduling': 'scheduling-mode',
'scheduleUnit': 'scheduling-mode',
'scheduleEvery': 'scheduling-mode',
'cronExpression': 'cron-expression',
'name': 'category-name',
'privacyType': 'privacy-type',
@@ -76,6 +78,8 @@ export class DocumentationService {
'unlinkedEnabled': 'enable-unlinked-download-handling',
'unlinkedTargetCategory': 'target-category',
'unlinkedUseTag': 'use-tag',
'downloadDirectorySource': 'download-directory-source-and-local-directory-target',
'downloadDirectoryTarget': 'download-directory-source-and-local-directory-target',
'unlinkedIgnoredRootDir': 'ignored-root-directory',
'unlinkedCategories': 'unlinked-categories',
},
@@ -23,6 +23,11 @@
[checked]="hideMet()"
(checkedChange)="onHideMetChange($event)"
/>
<app-toggle
label="Hide unmonitored"
[checked]="hideUnmonitored()"
(checkedChange)="onHideUnmonitoredChange($event)"
/>
</div>
<div class="toolbar__actions">
<app-button variant="ghost" size="sm" (clicked)="refresh()">
@@ -1,4 +1,4 @@
import { Component, ChangeDetectionStrategy, inject, signal, computed, effect, OnInit } from '@angular/core';
import { Component, ChangeDetectionStrategy, inject, signal, computed, effect, untracked, OnInit } from '@angular/core';
import { DatePipe } from '@angular/common';
import { NgIcon } from '@ng-icons/core';
import {
@@ -54,6 +54,7 @@ export class QualityTabComponent implements OnInit {
readonly sortBy = signal<string>('title');
readonly hideMet = signal(false);
readonly hideUnmonitored = signal(false);
readonly sortOptions: SelectOption[] = [
{ label: 'Title', value: 'title' },
{ label: 'Last Synced', value: 'date' },
@@ -80,8 +81,10 @@ export class QualityTabComponent implements OnInit {
this.initialLoad = false;
return;
}
this.loadScores();
this.loadStats();
untracked(() => {
this.loadScores();
this.loadStats();
});
});
}
@@ -93,7 +96,7 @@ export class QualityTabComponent implements OnInit {
loadScores(): void {
this.loading.set(true);
this.api.getScores(this.currentPage(), this.pageSize(), this.searchQuery() || undefined, this.selectedInstanceId() || undefined, this.sortBy(), this.hideMet()).subscribe({
this.api.getScores(this.currentPage(), this.pageSize(), this.searchQuery() || undefined, this.selectedInstanceId() || undefined, this.sortBy(), this.hideMet(), this.hideUnmonitored()).subscribe({
next: (result) => {
this.items.set(result.items);
this.totalRecords.set(result.totalCount);
@@ -122,9 +125,7 @@ export class QualityTabComponent implements OnInit {
}
onInstanceFilterChange(value: string): void {
this.selectedInstanceId.set(value);
this.currentPage.set(1);
this.loadScores();
this.applyFilterChange(this.selectedInstanceId, value);
}
private loadStats(): void {
@@ -140,13 +141,19 @@ export class QualityTabComponent implements OnInit {
}
onSortChange(value: string): void {
this.sortBy.set(value);
this.currentPage.set(1);
this.loadScores();
this.applyFilterChange(this.sortBy, value);
}
onHideMetChange(value: boolean): void {
this.hideMet.set(value);
this.applyFilterChange(this.hideMet, value);
}
onHideUnmonitoredChange(value: boolean): void {
this.applyFilterChange(this.hideUnmonitored, value);
}
private applyFilterChange<T>(setter: { set: (v: T) => void }, value: T): void {
setter.set(value);
this.currentPage.set(1);
this.loadScores();
}
@@ -1,4 +1,4 @@
import { Component, ChangeDetectionStrategy, inject, signal, computed, effect, OnInit } from '@angular/core';
import { Component, ChangeDetectionStrategy, inject, signal, computed, effect, untracked, OnInit } from '@angular/core';
import { DatePipe } from '@angular/common';
import { NgIcon } from '@ng-icons/core';
import {
@@ -79,8 +79,10 @@ export class SearchesTabComponent implements OnInit {
this.initialLoad = false;
return;
}
this.loadSummary();
this.loadEvents();
untracked(() => {
this.loadSummary();
this.loadEvents();
});
});
}
@@ -1,4 +1,4 @@
import { Component, ChangeDetectionStrategy, inject, signal, effect, OnInit } from '@angular/core';
import { Component, ChangeDetectionStrategy, inject, signal, effect, untracked, OnInit } from '@angular/core';
import { DatePipe } from '@angular/common';
import { NgIcon } from '@ng-icons/core';
import {
@@ -59,7 +59,9 @@ export class UpgradesTabComponent implements OnInit {
this.initialLoad = false;
return;
}
this.loadUpgrades();
untracked(() => {
this.loadUpgrades();
});
});
}
@@ -53,93 +53,181 @@
helpKey="download-cleaner:ignoredDownloads"
/>
}
<div class="form-actions">
<app-button variant="primary" [glowing]="dirty()" [loading]="saving()" [disabled]="saving() || saved() || hasGlobalErrors() || !dirty()" (clicked)="save()">
{{ saved() ? 'Saved!' : 'Save Settings' }}
</app-button>
</div>
</div>
</app-card>
@if (enabled()) {
<app-accordion header="Seeding rules" subtitle="Define cleanup rules per category" [(expanded)]="categoriesExpanded" [error]="noFeaturesError()">
@for (cat of categories(); track $index; let i = $index) {
<div class="category-row">
<div class="category-row__header">
<app-input label="Category Name" placeholder="tv-sonarr" [value]="cat.name"
(valueChange)="updateCategory(i, 'name', $event)"
[error]="categoryNameError(cat)"
hint="The category name from your download client (e.g. tv-sonarr, radarr)"
helpKey="download-cleaner:name" />
<app-button variant="destructive" size="sm" (clicked)="removeCategory(i)">Remove</app-button>
</div>
@if (categoryDisabledError(cat)) {
<div class="category-error">{{ categoryDisabledError(cat) }}</div>
@if (clientOptions().length > 0) {
<app-card [header]="selectedClient()?.downloadClientName ?? 'Download Client'">
<div class="form-stack">
<app-select label="Download Client" [options]="clientOptions()" [value]="selectedClientId()"
(valueChange)="onClientChange($event)"
hint="Select which download client to configure"
helpKey="download-cleaner:downloadClient" />
@if (isSelectedClientDisabled()) {
<div class="disabled-client-notice">
<ng-icon name="tablerAlertCircle" size="16" />
This download client is disabled. Enable it in Download Client settings before configuring seeding rules or unlinked downloads.
</div>
}
@if (selectedClient(); as client) {
@if (!isSelectedClientDisabled()) {
<div class="form-divider"></div>
<app-accordion header="Seeding Rules" subtitle="Define cleanup rules per category" [(expanded)]="seedingRulesExpanded">
@if (rulesReloading()) {
<div class="rules-loading">
<app-spinner size="sm" /> Loading seeding rules...
</div>
} @else if (client.seedingRules.length === 0) {
<app-empty-state
icon="tablerListDetails"
heading="No seeding rules"
description="Add a seeding rule to start cleaning downloads for this client"
/>
} @else {
@for (rule of client.seedingRules; track rule.id ?? $index) {
<div class="rule-card">
<div class="rule-card__header">
<h4 class="rule-card__name">{{ rule.name }}</h4>
<div class="rule-card__actions">
<button class="rule-card__action" (click)="openRuleModal(rule)" aria-label="Edit rule">
<ng-icon name="tablerPencil" size="16" />
</button>
<button class="rule-card__action rule-card__action--danger" (click)="deleteRule(rule)" aria-label="Delete rule">
<ng-icon name="tablerTrash" size="16" />
</button>
</div>
</div>
<div class="rule-card__badges">
<app-badge severity="info">{{ rule.privacyType }}</app-badge>
@if (rule.maxRatio >= 0) {
<app-badge>Ratio: {{ rule.maxRatio }}</app-badge>
}
@if (rule.maxSeedTime >= 0) {
<app-badge>Max Seed: {{ rule.maxSeedTime }}h</app-badge>
}
@if (rule.minSeedTime > 0) {
<app-badge>Min Seed: {{ rule.minSeedTime }}h</app-badge>
}
</div>
<div class="rule-card__details">
<span>Delete Files: {{ rule.deleteSourceFiles ? 'Yes' : 'No' }}</span>
</div>
</div>
}
}
<div class="rule-actions">
<app-button variant="secondary" size="sm" (clicked)="openRuleModal()">
Add Seeding Rule
</app-button>
</div>
</app-accordion>
<app-accordion header="Unlinked Downloads" subtitle="Clean up orphaned downloads" [(expanded)]="unlinkedExpanded">
<div class="form-stack">
<app-toggle label="Enabled" [checked]="client.unlinkedConfig?.enabled ?? false"
(checkedChange)="updateUnlinkedField('enabled', $event)"
hint="Enable management of downloads that have no hardlinks"
helpKey="download-cleaner:unlinkedEnabled" />
@if (client.unlinkedConfig?.enabled) {
<app-input label="Target Category" placeholder="unlinked" [value]="client.unlinkedConfig?.targetCategory ?? ''"
(valueChange)="updateUnlinkedField('targetCategory', $event)"
hint="Category to move unlinked downloads to. You have to create a seeding rule for this category if you want to remove the downloads."
helpKey="download-cleaner:unlinkedTargetCategory" />
@if (isSelectedClientQBittorrent()) {
<app-toggle label="Use Tag Instead" [checked]="client.unlinkedConfig?.useTag ?? false"
(checkedChange)="updateUnlinkedField('useTag', $event)"
hint="When enabled, uses a tag instead of category (qBittorrent only)"
helpKey="download-cleaner:unlinkedUseTag" />
}
<div class="form-divider"></div>
<app-input label="Download Directory (Source)" placeholder="/downloads"
[value]="client.unlinkedConfig?.downloadDirectorySource ?? ''"
(valueChange)="updateUnlinkedField('downloadDirectorySource', $event)"
hint="The path prefix as reported by the download client (e.g. /downloads)"
helpKey="download-cleaner:downloadDirectorySource" />
<app-input label="Local Directory (Target)" placeholder="/downloads-other"
[value]="client.unlinkedConfig?.downloadDirectoryTarget ?? ''"
(valueChange)="updateUnlinkedField('downloadDirectoryTarget', $event)"
hint="The actual local mount path that replaces the source prefix (e.g. /downloads-other)"
helpKey="download-cleaner:downloadDirectoryTarget" />
<div class="form-divider"></div>
<app-chip-input label="Ignored Root Directories" placeholder="Add directory path..."
[items]="client.unlinkedConfig?.ignoredRootDirs ?? []"
(itemsChange)="updateUnlinkedField('ignoredRootDirs', $event)"
hint="Root directories to ignore when checking for unlinked downloads (used for cross-seed)"
helpKey="download-cleaner:unlinkedIgnoredRootDir" />
<app-chip-input label="Unlinked Categories" placeholder="Add category..."
[items]="client.unlinkedConfig?.categories ?? []"
(itemsChange)="updateUnlinkedField('categories', $event)"
hint="Categories to check for unlinked downloads"
[error]="unlinkedCategoriesError()"
helpKey="download-cleaner:unlinkedCategories" />
}
<div class="form-actions">
<app-button variant="primary" [glowing]="unlinkedDirty()" [loading]="unlinkedSaving()" [disabled]="unlinkedSaving() || unlinkedSaved() || !unlinkedDirty() || !!unlinkedCategoriesError()" (clicked)="saveUnlinkedConfig()">
{{ unlinkedSaved() ? 'Saved!' : 'Save Unlinked Config' }}
</app-button>
</div>
</div>
</app-accordion>
}
}
<div class="form-row">
<app-number-input label="Max Ratio" [value]="cat.maxRatio"
(valueChange)="updateCategory(i, 'maxRatio', $event)" [step]="0.1" [min]="-1"
hint="Maximum ratio to seed before removing (-1 means disabled)"
helpKey="download-cleaner:maxRatio" />
<app-number-input label="Min Seed Time" [value]="cat.minSeedTime"
(valueChange)="updateCategory(i, 'minSeedTime', $event)" suffix="hours" [min]="0"
hint="Minimum time to seed before removing a download that has reached the max ratio (0 means disabled)"
helpKey="download-cleaner:minSeedTime" />
<app-number-input label="Max Seed Time" [value]="cat.maxSeedTime"
(valueChange)="updateCategory(i, 'maxSeedTime', $event)" suffix="hours" [min]="-1"
hint="Maximum time to seed before removing (-1 means disabled)"
helpKey="download-cleaner:maxSeedTime" />
</div>
<div class="form-row">
<app-select label="Privacy Type" [options]="privacyTypeOptions" [value]="cat.privacyType"
(valueChange)="updateCategory(i, 'privacyType', $event)"
hint="Which torrent types this rule applies to"
helpKey="download-cleaner:privacyType"
/>
</div>
<app-toggle label="Delete Source Files" [checked]="cat.deleteSourceFiles"
(checkedChange)="updateCategory(i, 'deleteSourceFiles', $event)"
hint="When enabled, the source files will be deleted when the download is removed"
helpKey="download-cleaner:deleteSourceFiles"
style="margin-top: var(--space-4)"
/>
</div>
}
<app-button variant="secondary" size="sm" (clicked)="addCategory()">
Add Category
</app-button>
</app-accordion>
<app-accordion header="Unlinked Downloads" subtitle="Clean up orphaned downloads" [(expanded)]="unlinkedExpanded" [error]="noFeaturesError()">
<div class="form-stack">
<app-toggle label="Enabled" [(checked)]="unlinkedEnabled"
hint="Enable management of downloads that have no hardlinks"
helpKey="download-cleaner:unlinkedEnabled" />
@if (unlinkedEnabled()) {
<app-input label="Target Category" placeholder="unlinked" [(value)]="unlinkedTargetCategory"
hint="Category to move unlinked downloads to. You have to create a seeding rule for this category if you want to remove the downloads."
[error]="unlinkedTargetCategoryError()"
helpKey="download-cleaner:unlinkedTargetCategory" />
<app-toggle label="Use Tag Instead" [(checked)]="unlinkedUseTag"
hint="When enabled, uses a tag instead of category (qBittorrent only)"
helpKey="download-cleaner:unlinkedUseTag" />
<div class="form-divider"></div>
<app-chip-input label="Ignored Root Directories" placeholder="Add directory path..."
[(items)]="unlinkedIgnoredRootDirs"
hint="Root directories to ignore when checking for unlinked downloads (used for cross-seed)"
helpKey="download-cleaner:unlinkedIgnoredRootDir" />
<app-chip-input label="Unlinked Categories" placeholder="Add category..."
[(items)]="unlinkedCategories"
hint="Categories to check for unlinked downloads"
[error]="unlinkedCategoriesError()"
helpKey="download-cleaner:unlinkedCategories" />
}
</div>
</app-accordion>
</app-card>
} @else {
<app-empty-state
icon="tablerDownload"
heading="No download clients"
description="Add a download client to configure download cleaner settings"
/>
}
}
</div>
<div class="form-actions">
<app-button variant="primary" [glowing]="dirty()" [loading]="saving()" [disabled]="saving() || saved() || hasErrors()" (clicked)="save()">
{{ saved() ? 'Saved!' : 'Save Settings' }}
<!-- Seeding Rule Modal -->
<app-modal [title]="editingRule() ? 'Edit Seeding Rule' : 'Add Seeding Rule'" [(visible)]="ruleModalVisible" size="lg">
<div class="form-grid">
<app-input label="Category Name" placeholder="tv-sonarr" [(value)]="ruleName"
[error]="ruleNameError()"
hint="The category name from your download client (e.g. tv-sonarr, radarr)"
helpKey="download-cleaner:name" />
<app-select label="Privacy Type" [options]="privacyTypeOptions" [(value)]="rulePrivacyType"
hint="Which torrent types this rule applies to"
helpKey="download-cleaner:privacyType" />
<app-number-input label="Max Ratio" [(value)]="ruleMaxRatio" [step]="0.1" [min]="-1"
hint="Maximum ratio to seed before removing (-1 means disabled)"
helpKey="download-cleaner:maxRatio" />
<app-number-input label="Min Seed Time" [(value)]="ruleMinSeedTime" suffix="hours" [min]="0"
hint="Minimum time to seed before removing a download that has reached the max ratio (0 means disabled)"
helpKey="download-cleaner:minSeedTime" />
<app-number-input label="Max Seed Time" [(value)]="ruleMaxSeedTime" suffix="hours" [min]="-1"
hint="Maximum time to seed before removing (-1 means disabled)"
helpKey="download-cleaner:maxSeedTime" />
@if (ruleDisabledError()) {
<div class="category-error">{{ ruleDisabledError() }}</div>
}
<app-toggle class="full-width" label="Delete Source Files" [(checked)]="ruleDeleteSourceFiles"
hint="When enabled, the source files will be deleted when the download is removed"
helpKey="download-cleaner:deleteSourceFiles" />
</div>
<div modal-footer>
<app-button variant="secondary" (clicked)="ruleModalVisible.set(false)">Cancel</app-button>
<app-button variant="primary" [disabled]="!!ruleNameError() || !!ruleDisabledError()" (clicked)="saveRule()">
{{ editingRule() ? 'Update' : 'Create' }}
</app-button>
</div>
</div>
</app-modal>
}
@@ -1,4 +1,5 @@
@use 'settings-layout' as *;
@use 'glass' as *;
:host { @include settings-page; }
@@ -6,25 +7,11 @@
.form-stack { @include form-stack; }
.form-row { @include form-row; }
.form-divider { @include form-divider; }
.form-grid { @include form-grid; }
.form-actions { @include form-actions; }
.category-row {
padding: var(--space-4) 0;
&:not(:last-of-type) {
border-bottom: 1px solid var(--divider);
}
&__header {
display: flex;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-4);
app-input {
flex: 1;
}
}
.full-width {
grid-column: 1 / -1;
}
.category-error {
@@ -32,3 +19,95 @@
color: var(--color-error);
margin-bottom: var(--space-3);
}
.disabled-client-notice {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
background: var(--color-warning-dim);
border: 1px solid var(--color-warning);
color: var(--color-warning);
font-size: var(--font-size-sm);
}
// Rule cards
.rule-card {
@include glass('subtle');
padding: var(--space-4);
border-radius: var(--radius-md);
margin-bottom: var(--space-3);
&__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-2);
}
&__name {
font-size: var(--font-size-md);
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
&__actions {
display: flex;
gap: var(--space-1);
}
&__action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: var(--radius-sm);
border: 1px solid var(--glass-border);
background: var(--glass-bg);
color: var(--text-secondary);
cursor: pointer;
transition: all var(--duration-fast) var(--ease-default);
padding: 0;
&:hover {
color: var(--color-primary);
border-color: var(--color-primary);
}
&--danger:hover {
color: var(--color-error);
border-color: var(--color-error);
}
}
&__badges {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
&__details {
display: flex;
flex-wrap: wrap;
gap: var(--space-2) var(--space-4);
font-size: var(--font-size-sm);
color: var(--text-tertiary);
}
}
.rule-actions {
margin-top: var(--space-3);
}
.rules-loading {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-4);
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
@@ -1,16 +1,22 @@
import { Component, ChangeDetectionStrategy, inject, signal, computed, OnInit, viewChildren, effect, untracked } from '@angular/core';
import { NgIconComponent } from '@ng-icons/core';
import { PageHeaderComponent } from '@layout/page-header/page-header.component';
import {
CardComponent, ButtonComponent, InputComponent, ToggleComponent,
NumberInputComponent, SelectComponent, ChipInputComponent, AccordionComponent,
EmptyStateComponent, LoadingStateComponent, type SelectOption,
EmptyStateComponent, LoadingStateComponent, ModalComponent, BadgeComponent, SpinnerComponent,
type SelectOption,
} from '@ui';
import { DownloadCleanerApi } from '@core/api/download-cleaner.api';
import { ApiError } from '@core/interceptors/error.interceptor';
import { ToastService } from '@core/services/toast.service';
import { DownloadCleanerConfig, CleanCategory, createDefaultCategory } from '@shared/models/download-cleaner-config.model';
import { ConfirmService } from '@core/services/confirm.service';
import {
DownloadCleanerConfig, SeedingRule, ClientCleanerConfig, UnlinkedConfigModel,
createDefaultUnlinkedConfig,
} from '@shared/models/download-cleaner-config.model';
import { ScheduleOptions } from '@shared/models/queue-cleaner-config.model';
import { ScheduleUnit, TorrentPrivacyType } from '@shared/models/enums';
import { ScheduleUnit, TorrentPrivacyType, DownloadClientTypeName } from '@shared/models/enums';
import { HasPendingChanges } from '@core/guards/pending-changes.guard';
import { DeferredLoader } from '@shared/utils/loading.util';
import { generateCronExpression, parseCronToJobSchedule } from '@shared/utils/schedule.util';
@@ -31,9 +37,10 @@ const PRIVACY_TYPE_OPTIONS: SelectOption[] = [
selector: 'app-download-cleaner',
standalone: true,
imports: [
NgIconComponent,
PageHeaderComponent, CardComponent, ButtonComponent, InputComponent,
ToggleComponent, NumberInputComponent, SelectComponent, ChipInputComponent, AccordionComponent,
EmptyStateComponent, LoadingStateComponent,
EmptyStateComponent, LoadingStateComponent, ModalComponent, BadgeComponent, SpinnerComponent,
],
templateUrl: './download-cleaner.component.html',
styleUrl: './download-cleaner.component.scss',
@@ -42,6 +49,7 @@ const PRIVACY_TYPE_OPTIONS: SelectOption[] = [
export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
private readonly api = inject(DownloadCleanerApi);
private readonly toast = inject(ToastService);
private readonly confirm = inject(ConfirmService);
private readonly chipInputs = viewChildren(ChipInputComponent);
private readonly savedSnapshot = signal('');
@@ -52,29 +60,59 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
readonly loadError = signal(false);
readonly saving = signal(false);
readonly saved = signal(false);
readonly unlinkedSaving = signal(false);
readonly unlinkedSaved = signal(false);
readonly rulesReloading = signal(false);
private readonly unlinkedSnapshots = signal<Record<string, string>>({});
// Global settings
readonly enabled = signal(false);
readonly useAdvancedScheduling = signal(false);
readonly cronExpression = signal('');
readonly scheduleEvery = signal<unknown>(5);
readonly scheduleUnit = signal<unknown>(ScheduleUnit.Minutes);
readonly ignoredDownloads = signal<string[]>([]);
// Per-client settings
readonly clientConfigs = signal<ClientCleanerConfig[]>([]);
readonly selectedClientId = signal<string | null>(null);
readonly selectedClient = computed(() =>
this.clientConfigs().find(c => c.downloadClientId === this.selectedClientId()) ?? null
);
readonly clientOptions = computed<SelectOption[]>(() =>
this.clientConfigs()
.map(c => ({ label: c.downloadClientName, value: c.downloadClientId }))
.sort((a, b) => a.label.localeCompare(b.label))
);
readonly isSelectedClientDisabled = computed(() =>
this.selectedClient()?.downloadClientEnabled === false
);
readonly isSelectedClientQBittorrent = computed(() =>
this.selectedClient()?.downloadClientTypeName === DownloadClientTypeName.qBittorrent
);
readonly seedingRulesExpanded = signal(false);
readonly unlinkedExpanded = signal(false);
// Seeding rule modal
readonly ruleModalVisible = signal(false);
readonly editingRule = signal<SeedingRule | null>(null);
readonly ruleName = signal('');
readonly rulePrivacyType = signal<unknown>(TorrentPrivacyType.Public);
readonly ruleMaxRatio = signal<number | null>(-1);
readonly ruleMinSeedTime = signal<number | null>(0);
readonly ruleMaxSeedTime = signal<number | null>(-1);
readonly ruleDeleteSourceFiles = signal(true);
readonly scheduleIntervalOptions = computed(() => {
const unit = this.scheduleUnit() as ScheduleUnit;
const values = ScheduleOptions[unit] ?? [];
return values.map(v => ({ label: `${v}`, value: v }));
});
readonly ignoredDownloads = signal<string[]>([]);
readonly categories = signal<CleanCategory[]>([]);
readonly categoriesExpanded = signal(true);
// Unlinked
readonly unlinkedEnabled = signal(false);
readonly unlinkedTargetCategory = signal('');
readonly unlinkedUseTag = signal(false);
readonly unlinkedIgnoredRootDirs = signal<string[]>([]);
readonly unlinkedCategories = signal<string[]>([]);
readonly unlinkedExpanded = signal(false);
constructor() {
effect(() => {
@@ -100,52 +138,39 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
return undefined;
});
readonly unlinkedTargetCategoryError = computed(() => {
if (this.unlinkedEnabled() && !this.unlinkedTargetCategory().trim()) return 'Target category is required';
readonly ruleNameError = computed(() => {
if (!this.ruleName().trim()) return 'Name is required';
return undefined;
});
readonly ruleDisabledError = computed(() => {
if ((this.ruleMaxRatio() ?? -1) < 0 && (this.ruleMaxSeedTime() ?? -1) < 0) {
return 'Both max ratio and max seed time cannot be disabled at the same time';
}
return undefined;
});
readonly unlinkedCategoriesError = computed(() => {
if (this.unlinkedEnabled() && this.unlinkedCategories().length === 0) {
return 'At least one category is required when unlinked download handling is enabled';
const client = this.selectedClient();
if (!client?.unlinkedConfig?.enabled) return undefined;
if ((client.unlinkedConfig.categories ?? []).length === 0) {
return 'At least one category is required';
}
return undefined;
});
categoryNameError(cat: CleanCategory): string | undefined {
if (!cat.name?.trim()) return 'Name is required';
return undefined;
}
categoryDisabledError(cat: CleanCategory): string | undefined {
if (cat.maxRatio === -1 && cat.maxSeedTime === -1) {
return 'Both max ratio and max seed time cannot be disabled at the same time';
}
return undefined;
}
readonly noFeaturesError = computed(() => {
if (!this.enabled()) return undefined;
const hasSeedingCategories = this.categories().length > 0;
const hasUnlinkedFeature = this.unlinkedEnabled()
&& !this.unlinkedTargetCategoryError()
&& !this.unlinkedCategoriesError();
if (!hasSeedingCategories && !hasUnlinkedFeature) {
return 'At least one feature must be configured';
}
return undefined;
readonly unlinkedDirty = computed(() => {
const client = this.selectedClient();
if (!client) return false;
const saved = this.unlinkedSnapshots()[client.downloadClientId];
if (!saved) return false;
return saved !== JSON.stringify(client.unlinkedConfig);
});
readonly hasErrors = computed(() => {
if (this.noFeaturesError()) return true;
readonly hasGlobalErrors = computed(() => {
if (this.scheduleEveryError()) return true;
if (this.cronError()) return true;
if (this.unlinkedTargetCategoryError()) return true;
if (this.unlinkedCategoriesError()) return true;
if (this.chipInputs().some(c => c.hasUncommittedInput())) return true;
for (const cat of this.categories()) {
if (this.categoryNameError(cat) || this.categoryDisabledError(cat)) return true;
}
return false;
});
@@ -169,14 +194,24 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
this.scheduleUnit.set(parsed.type);
}
this.ignoredDownloads.set(config.ignoredDownloads ?? []);
this.categories.set(config.categories ?? []);
this.unlinkedEnabled.set(config.unlinkedEnabled);
this.unlinkedTargetCategory.set(config.unlinkedTargetCategory ?? '');
this.unlinkedUseTag.set(config.unlinkedUseTag);
this.unlinkedIgnoredRootDirs.set(config.unlinkedIgnoredRootDirs ?? []);
this.unlinkedCategories.set(config.unlinkedCategories ?? []);
this.clientConfigs.set((config.clients ?? []).map(c => ({
...c,
seedingRules: c.seedingRules ?? [],
unlinkedConfig: c.unlinkedConfig ?? createDefaultUnlinkedConfig(),
})));
if (config.clients?.length > 0) {
this.selectedClientId.set(config.clients[0].downloadClientId);
}
// Save unlinked config snapshots per client
const snapshots: Record<string, string> = {};
for (const c of config.clients ?? []) {
snapshots[c.downloadClientId] = JSON.stringify(c.unlinkedConfig ?? createDefaultUnlinkedConfig());
}
this.unlinkedSnapshots.set(snapshots);
this.loader.stop();
this.savedSnapshot.set(this.buildSnapshot());
// Defer snapshot so constructor effects (e.g. schedule unit clamping) settle first
queueMicrotask(() => this.savedSnapshot.set(this.buildSnapshot()));
},
error: () => {
this.toast.error('Failed to load download cleaner settings');
@@ -191,22 +226,153 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
this.loadConfig();
}
addCategory(): void {
this.categories.update((cats) => [...cats, createDefaultCategory()]);
// --- Seeding rule modal CRUD ---
openRuleModal(rule?: SeedingRule): void {
this.editingRule.set(rule ?? null);
if (rule) {
this.ruleName.set(rule.name);
this.rulePrivacyType.set(rule.privacyType);
this.ruleMaxRatio.set(rule.maxRatio);
this.ruleMinSeedTime.set(rule.minSeedTime);
this.ruleMaxSeedTime.set(rule.maxSeedTime);
this.ruleDeleteSourceFiles.set(rule.deleteSourceFiles);
} else {
this.ruleName.set('');
this.rulePrivacyType.set(TorrentPrivacyType.Public);
this.ruleMaxRatio.set(-1);
this.ruleMinSeedTime.set(0);
this.ruleMaxSeedTime.set(-1);
this.ruleDeleteSourceFiles.set(true);
}
this.ruleModalVisible.set(true);
}
removeCategory(index: number): void {
this.categories.update((cats) => cats.filter((_, i) => i !== index));
}
saveRule(): void {
if (this.ruleNameError() || this.ruleDisabledError()) return;
const clientId = this.selectedClientId();
if (!clientId) return;
updateCategory(index: number, field: keyof CleanCategory, value: any): void {
this.categories.update((cats) => {
const updated = [...cats];
updated[index] = { ...updated[index], [field]: value };
return updated;
const dto: Partial<SeedingRule> = {
name: this.ruleName().trim(),
privacyType: this.rulePrivacyType() as TorrentPrivacyType,
maxRatio: this.ruleMaxRatio() ?? -1,
minSeedTime: this.ruleMinSeedTime() ?? 0,
maxSeedTime: this.ruleMaxSeedTime() ?? -1,
deleteSourceFiles: this.ruleDeleteSourceFiles(),
};
const editing = this.editingRule();
const request = editing?.id
? this.api.updateSeedingRule(editing.id, dto)
: this.api.createSeedingRule(clientId, dto);
request.subscribe({
next: () => {
this.toast.success(editing ? 'Seeding rule updated' : 'Seeding rule created');
this.ruleModalVisible.set(false);
this.reloadSeedingRules(clientId);
},
error: (e: ApiError) => this.toast.error(e.statusCode === 400 ? e.message : 'Failed to save seeding rule'),
});
}
async deleteRule(rule: SeedingRule): Promise<void> {
const confirmed = await this.confirm.confirm({
title: 'Delete Seeding Rule',
message: `Are you sure you want to delete "${rule.name}"?`,
confirmLabel: 'Delete',
destructive: true,
});
if (!confirmed || !rule.id) return;
const clientId = this.selectedClientId();
if (!clientId) return;
this.api.deleteSeedingRule(rule.id).subscribe({
next: () => {
this.toast.success('Seeding rule deleted');
this.reloadSeedingRules(clientId);
},
error: () => this.toast.error('Failed to delete seeding rule'),
});
}
private reloadSeedingRules(clientId: string): void {
this.rulesReloading.set(true);
this.api.getSeedingRules(clientId).subscribe({
next: (rules) => {
this.clientConfigs.update(configs =>
configs.map(c => c.downloadClientId === clientId ? { ...c, seedingRules: rules } : c)
);
this.rulesReloading.set(false);
},
error: () => {
this.toast.error('Failed to reload seeding rules');
this.rulesReloading.set(false);
},
});
}
async onClientChange(newClientId: unknown): Promise<void> {
if (this.unlinkedDirty()) {
const confirmed = await this.confirm.confirm({
title: 'Unsaved Changes',
message: 'You have unsaved unlinked config changes. Discard them?',
confirmLabel: 'Discard',
destructive: true,
});
if (!confirmed) return;
}
this.selectedClientId.set(newClientId as string | null);
}
// --- Unlinked config ---
updateUnlinkedField<K extends keyof UnlinkedConfigModel>(field: K, value: UnlinkedConfigModel[K]): void {
this.updateSelectedClient(client => ({
...client,
unlinkedConfig: {
...(client.unlinkedConfig ?? createDefaultUnlinkedConfig()),
[field]: value,
},
}));
}
saveUnlinkedConfig(): void {
const clientId = this.selectedClientId();
const client = this.selectedClient();
if (!clientId || !client?.unlinkedConfig) return;
this.unlinkedSaving.set(true);
this.api.updateUnlinkedConfig(clientId, client.unlinkedConfig).subscribe({
next: () => {
this.toast.success('Unlinked config saved');
this.unlinkedSaving.set(false);
this.unlinkedSaved.set(true);
setTimeout(() => this.unlinkedSaved.set(false), 1500);
// Update snapshot for this client
this.unlinkedSnapshots.update(s => ({
...s,
[clientId]: JSON.stringify(client.unlinkedConfig),
}));
},
error: (err: ApiError) => {
this.toast.error(err.statusCode === 400 ? err.message : 'Failed to save unlinked config');
this.unlinkedSaving.set(false);
},
});
}
private updateSelectedClient(updater: (client: ClientCleanerConfig) => ClientCleanerConfig): void {
const id = this.selectedClientId();
if (!id) return;
this.clientConfigs.update(configs =>
configs.map(c => c.downloadClientId === id ? updater(c) : c)
);
}
// --- Global config save ---
save(): void {
if (!this.config) return;
@@ -215,18 +381,11 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
? this.cronExpression()
: generateCronExpression(jobSchedule);
const config: DownloadCleanerConfig = {
...this.config,
const config = {
enabled: this.enabled(),
useAdvancedScheduling: this.useAdvancedScheduling(),
cronExpression,
useAdvancedScheduling: this.useAdvancedScheduling(),
ignoredDownloads: this.ignoredDownloads(),
categories: this.categories(),
unlinkedEnabled: this.unlinkedEnabled(),
unlinkedTargetCategory: this.unlinkedTargetCategory(),
unlinkedUseTag: this.unlinkedUseTag(),
unlinkedIgnoredRootDirs: this.unlinkedIgnoredRootDirs(),
unlinkedCategories: this.unlinkedCategories(),
};
this.saving.set(true);
@@ -255,12 +414,6 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
scheduleEvery: this.scheduleEvery(),
scheduleUnit: this.scheduleUnit(),
ignoredDownloads: this.ignoredDownloads(),
categories: this.categories(),
unlinkedEnabled: this.unlinkedEnabled(),
unlinkedTargetCategory: this.unlinkedTargetCategory(),
unlinkedUseTag: this.unlinkedUseTag(),
unlinkedIgnoredRootDirs: this.unlinkedIgnoredRootDirs(),
unlinkedCategories: this.unlinkedCategories(),
});
}
@@ -270,6 +423,6 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
});
hasPendingChanges(): boolean {
return this.dirty();
return this.dirty() || this.unlinkedDirty();
}
}
@@ -14,10 +14,12 @@
<ng-icon [name]="theme() === 'dark' ? 'tablerSun' : 'tablerMoon'" />
</button>
<app-toggle
[label]="'Performance mode'"
[checked]="performanceMode()"
(checkedChange)="onTogglePerformanceMode()"
/>
<app-tooltip text="Disables animations and visual effects for better UI performance. Auto-enabled when your system prefers reduced motion.">
<app-toggle
[label]="'Performance mode'"
[checked]="performanceMode()"
(checkedChange)="onTogglePerformanceMode()"
/>
</app-tooltip>
</div>
</header>
@@ -1,12 +1,12 @@
import { Component, ChangeDetectionStrategy, input, output, inject } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { ThemeService } from '@core/services/theme.service';
import { ToggleComponent } from '@ui';
import { ToggleComponent, TooltipComponent } from '@ui';
@Component({
selector: 'app-toolbar',
standalone: true,
imports: [NgIcon, ToggleComponent],
imports: [NgIcon, ToggleComponent, TooltipComponent],
templateUrl: './toolbar.component.html',
styleUrl: './toolbar.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -1,7 +1,7 @@
import { ScheduleUnit, TorrentPrivacyType } from './enums';
import { JobSchedule } from './queue-cleaner-config.model';
import { TorrentPrivacyType } from './enums';
export interface CleanCategory {
export interface SeedingRule {
id?: string;
name: string;
privacyType: TorrentPrivacyType;
maxRatio: number;
@@ -10,21 +10,34 @@ export interface CleanCategory {
deleteSourceFiles: boolean;
}
export interface UnlinkedConfigModel {
enabled: boolean;
targetCategory: string;
useTag: boolean;
ignoredRootDirs: string[];
categories: string[];
downloadDirectorySource: string | null;
downloadDirectoryTarget: string | null;
}
export interface ClientCleanerConfig {
downloadClientId: string;
downloadClientName: string;
downloadClientEnabled: boolean;
downloadClientTypeName: string;
seedingRules: SeedingRule[];
unlinkedConfig: UnlinkedConfigModel | null;
}
export interface DownloadCleanerConfig {
enabled: boolean;
cronExpression: string;
useAdvancedScheduling: boolean;
jobSchedule?: JobSchedule;
categories: CleanCategory[];
ignoredDownloads: string[];
unlinkedEnabled: boolean;
unlinkedTargetCategory: string;
unlinkedUseTag: boolean;
unlinkedIgnoredRootDirs: string[];
unlinkedCategories: string[];
clients: ClientCleanerConfig[];
}
export function createDefaultCategory(): CleanCategory {
export function createDefaultSeedingRule(): SeedingRule {
return {
name: '',
privacyType: TorrentPrivacyType.Public,
@@ -34,3 +47,15 @@ export function createDefaultCategory(): CleanCategory {
deleteSourceFiles: true,
};
}
export function createDefaultUnlinkedConfig(): UnlinkedConfigModel {
return {
enabled: false,
targetCategory: 'cleanuparr-unlinked',
useTag: false,
ignoredRootDirs: [],
categories: [],
downloadDirectorySource: null,
downloadDirectoryTarget: null,
};
}
@@ -28,7 +28,6 @@ These settings need a download client to be configured.
<ConfigSection
title="Enable Download Cleaner"
icon="🔄"
>
When enabled, the Download Cleaner will run according to the configured schedule to automatically clean completed downloads from your download client.
@@ -37,7 +36,6 @@ When enabled, the Download Cleaner will run according to the configured schedule
<ConfigSection
title="Scheduling Mode"
icon="📅"
>
Choose how to configure the Download Cleaner schedule:
@@ -48,7 +46,6 @@ Choose how to configure the Download Cleaner schedule:
<ConfigSection
title="Cron Expression"
icon="⏲️"
>
Enter a valid Quartz.NET cron expression to control when the Download Cleaner runs. The example above runs every hour.
@@ -62,7 +59,6 @@ Enter a valid Quartz.NET cron expression to control when the Download Cleaner ru
<ConfigSection
title="Ignored Downloads"
icon="🚫"
>
Downloads matching these patterns will be ignored by Download Cleaner. Patterns can match any of these:
@@ -87,7 +83,7 @@ mytracker.com
<div className={styles.section}>
<SectionTitle icon="📏">Seeding Rules</SectionTitle>
<SectionTitle>Seeding Rules</SectionTitle>
<p className={styles.sectionDescription}>
Categories define the cleanup rules for different types of downloads. Each category specifies when downloads should be removed based on ratio and time limits.
@@ -103,7 +99,6 @@ Both Max Ratio and Max Seed Time cannot be disabled (-1) at the same time. At le
<ConfigSection
title="Category Name"
icon="🏷️"
>
The name of the download client category to apply these rules to. Must match the category name exactly as configured in your download client.
@@ -117,7 +112,6 @@ The name of the download client category to apply these rules to. Must match the
<ConfigSection
title="Privacy Type"
icon="🔒"
>
Controls which torrent types this rule applies to:
@@ -135,7 +129,6 @@ Setting privacy type to `Private` or `Both` means private torrents matching this
<ConfigSection
title="Max Ratio"
icon="📊"
>
Maximum ratio to seed before considering the download for removal. Set to `-1` to disable ratio-based cleanup.
@@ -144,7 +137,6 @@ Maximum ratio to seed before considering the download for removal. Set to `-1` t
<ConfigSection
title="Min Seed Time"
icon="⏰"
>
Minimum time in hours to seed before removing a download that has reached the max ratio. Set to `0` to disable minimum time requirements.
@@ -153,7 +145,6 @@ Minimum time in hours to seed before removing a download that has reached the ma
<ConfigSection
title="Max Seed Time"
icon="⏳"
>
Maximum time in hours to seed before removing a download regardless of ratio. Set to `-1` to disable time-based cleanup.
@@ -162,7 +153,6 @@ Maximum time in hours to seed before removing a download regardless of ratio. Se
<ConfigSection
title="Delete Source Files"
icon="🗑️"
>
When enabled, the source files will be deleted from disk when the download is removed from the download client. When disabled, only the torrent entry is removed while preserving the underlying files.
@@ -173,7 +163,7 @@ When enabled, the source files will be deleted from disk when the download is re
<div className={styles.section}>
<SectionTitle icon="🔗">Unlinked Download Settings</SectionTitle>
<SectionTitle>Unlinked Download Settings</SectionTitle>
<p className={styles.sectionDescription}>
Settings for managing downloads that no longer have hardlinks to media files (indicating they may no longer be needed by the *arr applications).
@@ -181,7 +171,6 @@ When enabled, the source files will be deleted from disk when the download is re
<ConfigSection
title="Enable Unlinked Download Handling"
icon="🔍"
>
Enable management of downloads that have no hardlinks remaining. This helps identify downloads that are no longer needed by your *arr applications.
@@ -194,16 +183,14 @@ If you are using Docker, make sure to mount the downloads directory the same way
<ConfigSection
title="Target Category"
icon="🎯"
>
Category to move unlinked downloads to.
Category to move unlinked downloads to. You must create a seeding rule for this category if you want the downloads to eventually be removed.
</ConfigSection>
<ConfigSection
title="Use Tag"
icon="🏷️"
>
When enabled, uses a tag instead of category for marking unlinked downloads (qBittorrent only).
@@ -211,11 +198,56 @@ When enabled, uses a tag instead of category for marking unlinked downloads (qBi
</ConfigSection>
<ConfigSection
title="Ignored Root Directory"
icon="📁"
title="Download Directory (Source) and Local Directory (Target)"
>
Root directory to ignore when checking for unlinked downloads. Useful for cross-seed setups where you want to ignore hardlinks (even though a movie is not in Radarr anymore, it can have hardlinks from cross-seed).
The path prefix as reported by the download client (e.g. `/downloads`). Used for path mapping when the download client's internal paths differ from the paths accessible to Cleanuparr — common in Docker setups where volume mount paths differ between containers. Leave blank if no path translation is needed.
<br/>
**Docker example:**
In a typical Docker Compose setup, each container has its own view of the filesystem. Download clients may each have their own host directory but both report paths starting with `/downloads`. Since Cleanuparr mounts those directories under different paths, you need to configure the mapping per client so Cleanuparr can locate the actual files.
```yaml
services:
qbittorrent:
image: ...
...
volumes:
- /host/data/downloads-qbit:/downloads # qBittorrent sees /downloads
deluge:
image: ...
...
volumes:
- /host/data/downloads-deluge:/downloads # Deluge sees /downloads
cleanuparr:
image: ghcr.io/cleanuparr/cleanuparr
...
volumes:
- /host/data/downloads-qbit:/downloads-qbit # Cleanuparr sees /downloads-qbit
- /host/data/downloads-qbit:/downloads-deluge # Cleanuparr sees /downloads-deluge
```
Both download clients store files in separate host directories but report paths under the same `/downloads` prefix. Cleanuparr mounts each host directory under a different path, so it needs to know the translation per client:
- **qBittorrent** reports `/downloads/movie.mkv` while Cleanuparr finds it at `/downloads-qbit/movie.mkv`
- Download Directory (Source): `/downloads`
- Local Directory (Target): `/downloads-qbit`
- **Deluge** reports `/downloads/movie.mkv` while Cleanuparr finds it at `/downloads-deluge/movie.mkv`
- Download Directory (Source): `/downloads`
- Local Directory (Target): `/downloads-deluge`
</ConfigSection>
<ConfigSection
title="Ignored Root Directory"
>
Root directories to ignore when checking for unlinked downloads. Multiple paths can be added. Useful for cross-seed setups where you want to ignore hardlinks (even though a movie is not in Radarr anymore, it can have hardlinks from cross-seed).
```
/data
@@ -232,7 +264,6 @@ For the example above, the ignored root directory should be set to `/data/downlo
<ConfigSection
title="Unlinked Categories"
icon="📋"
>
Categories to check for unlinked downloads. Only downloads in these categories will be checked for missing hardlinks.
@@ -241,4 +272,4 @@ Categories to check for unlinked downloads. Only downloads in these categories w
</div>
</div>
</div>
+91
View File
@@ -0,0 +1,91 @@
import { test, expect } from '@playwright/test';
import {
loginAndGetToken,
getDownloadCleanerConfig,
updateDownloadCleanerConfig,
} from './helpers/app-api';
test.describe.serial('Download Cleaner Config API', () => {
let token: string;
test.beforeAll(async () => {
token = await loginAndGetToken();
});
test('should return default download cleaner config', async () => {
const res = await getDownloadCleanerConfig(token);
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toHaveProperty('enabled');
expect(body).toHaveProperty('cronExpression');
expect(body).toHaveProperty('useAdvancedScheduling');
expect(body).toHaveProperty('ignoredDownloads');
expect(body).toHaveProperty('clients');
expect(Array.isArray(body.clients)).toBe(true);
});
test('should update global download cleaner config', async () => {
const getRes = await getDownloadCleanerConfig(token);
const current = await getRes.json();
const updateRes = await updateDownloadCleanerConfig(token, {
enabled: !current.enabled,
cronExpression: current.cronExpression,
useAdvancedScheduling: current.useAdvancedScheduling,
ignoredDownloads: current.ignoredDownloads,
});
expect(updateRes.status).toBe(200);
// Verify the update persisted
const verifyRes = await getDownloadCleanerConfig(token);
const updated = await verifyRes.json();
expect(updated.enabled).toBe(!current.enabled);
// Restore original
await updateDownloadCleanerConfig(token, {
enabled: current.enabled,
cronExpression: current.cronExpression,
useAdvancedScheduling: current.useAdvancedScheduling,
ignoredDownloads: current.ignoredDownloads,
});
});
test('should update ignored downloads list', async () => {
const getRes = await getDownloadCleanerConfig(token);
const current = await getRes.json();
const updateRes = await updateDownloadCleanerConfig(token, {
enabled: current.enabled,
cronExpression: current.cronExpression,
useAdvancedScheduling: current.useAdvancedScheduling,
ignoredDownloads: ['test-ignored-hash-123'],
});
expect(updateRes.status).toBe(200);
const verifyRes = await getDownloadCleanerConfig(token);
const updated = await verifyRes.json();
expect(updated.ignoredDownloads).toContain('test-ignored-hash-123');
// Restore original
await updateDownloadCleanerConfig(token, {
enabled: current.enabled,
cronExpression: current.cronExpression,
useAdvancedScheduling: current.useAdvancedScheduling,
ignoredDownloads: current.ignoredDownloads,
});
});
test('should reject invalid cron expression', async () => {
const getRes = await getDownloadCleanerConfig(token);
const current = await getRes.json();
const res = await updateDownloadCleanerConfig(token, {
enabled: current.enabled,
cronExpression: 'not-a-valid-cron',
useAdvancedScheduling: true,
ignoredDownloads: current.ignoredDownloads,
});
expect(res.status).toBeGreaterThanOrEqual(400);
});
});
+86
View File
@@ -152,6 +152,92 @@ export async function getCfScoreStats(accessToken: string): Promise<Response> {
});
}
// --- Download Cleaner API helpers ---
export async function getDownloadCleanerConfig(accessToken: string): Promise<Response> {
return fetch(`${API}/api/configuration/download_cleaner`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
}
export async function updateDownloadCleanerConfig(
accessToken: string,
config: Record<string, unknown>,
): Promise<Response> {
return fetch(`${API}/api/configuration/download_cleaner`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify(config),
});
}
export async function getSeedingRules(accessToken: string, downloadClientId: string): Promise<Response> {
return fetch(`${API}/api/seeding-rules/${downloadClientId}`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
}
export async function createSeedingRule(
accessToken: string,
downloadClientId: string,
rule: Record<string, unknown>,
): Promise<Response> {
return fetch(`${API}/api/seeding-rules/${downloadClientId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify(rule),
});
}
export async function updateSeedingRule(
accessToken: string,
ruleId: string,
rule: Record<string, unknown>,
): Promise<Response> {
return fetch(`${API}/api/seeding-rules/${ruleId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify(rule),
});
}
export async function deleteSeedingRule(accessToken: string, ruleId: string): Promise<Response> {
return fetch(`${API}/api/seeding-rules/${ruleId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${accessToken}` },
});
}
export async function getUnlinkedConfig(accessToken: string, downloadClientId: string): Promise<Response> {
return fetch(`${API}/api/unlinked-config/${downloadClientId}`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
}
export async function updateUnlinkedConfig(
accessToken: string,
downloadClientId: string,
config: Record<string, unknown>,
): Promise<Response> {
return fetch(`${API}/api/unlinked-config/${downloadClientId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify(config),
});
}
export async function configureOidc(accessToken: string): Promise<void> {
const putRes = await fetch(`${API}/api/account/oidc`, {
method: 'PUT',