mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-09-09 20:08:59 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80b46df8e5 | ||
|
|
88aa71c343 | ||
|
|
7b80e038cc | ||
|
|
ef280ec398 | ||
|
|
81f6de03e7 | ||
|
|
17c3a6b02a | ||
|
|
4903b3137b | ||
|
|
868406c95c | ||
|
|
7122b16a7a | ||
|
|
b9fbac4ddc |
No files matched your search
@@ -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()`)
|
||||
+21
-1
@@ -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();
|
||||
|
||||
@@ -36,90 +36,58 @@ public class SearchStatsControllerTests : IDisposable
|
||||
return JsonDocument.Parse(json).RootElement;
|
||||
}
|
||||
|
||||
#region ParseEventData (tested via GetEvents)
|
||||
#region GetEvents with SearchEventData
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithNullEventData_ReturnsUnknownDefaults()
|
||||
public async Task GetEvents_WithNoSearchEventData_ReturnsUnknownDefaults()
|
||||
{
|
||||
AddSearchEvent(data: null);
|
||||
AddSearchEvent();
|
||||
|
||||
var result = await _controller.GetEvents();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
var item = body.GetProperty("Items")[0];
|
||||
item.GetProperty("InstanceName").GetString().ShouldBe("Unknown");
|
||||
item.GetProperty("ItemCount").GetInt32().ShouldBe(0);
|
||||
item.GetProperty("Items").GetArrayLength().ShouldBe(0);
|
||||
item.GetProperty("ItemTitle").GetString().ShouldBe("Unknown");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithValidFullJson_ParsesAllFields()
|
||||
public async Task GetEvents_WithSearchEventData_ReturnsAllFields()
|
||||
{
|
||||
var data = JsonSerializer.Serialize(new
|
||||
{
|
||||
InstanceName = "My Radarr",
|
||||
ItemCount = 3,
|
||||
Items = new[] { "Movie A", "Movie B", "Movie C" },
|
||||
SearchType = "Proactive",
|
||||
GrabbedItems = new[] { new { Title = "Movie A", Quality = "Bluray-1080p" } }
|
||||
});
|
||||
AddSearchEvent(data: data);
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
|
||||
AddSearchEvent(
|
||||
arrInstanceId: radarr.Id,
|
||||
itemTitle: "Movie A",
|
||||
searchType: SeekerSearchType.Proactive,
|
||||
searchReason: SeekerSearchReason.Missing,
|
||||
grabbedItems: ["Movie A (2024)"]);
|
||||
|
||||
var result = await _controller.GetEvents();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
var item = body.GetProperty("Items")[0];
|
||||
item.GetProperty("InstanceName").GetString().ShouldBe("My Radarr");
|
||||
item.GetProperty("ItemCount").GetInt32().ShouldBe(3);
|
||||
item.GetProperty("Items").GetArrayLength().ShouldBe(3);
|
||||
item.GetProperty("Items")[0].GetString().ShouldBe("Movie A");
|
||||
item.GetProperty("ArrInstanceId").GetString().ShouldBe(radarr.Id.ToString());
|
||||
item.GetProperty("InstanceType").GetString().ShouldBe(nameof(InstanceType.Radarr));
|
||||
item.GetProperty("ItemTitle").GetString().ShouldBe("Movie A");
|
||||
item.GetProperty("SearchType").GetString().ShouldBe(nameof(SeekerSearchType.Proactive));
|
||||
item.GetProperty("SearchReason").GetString().ShouldBe(nameof(SeekerSearchReason.Missing));
|
||||
item.GetProperty("GrabbedItems")[0].GetString().ShouldBe("Movie A (2024)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithPartialJson_ReturnsDefaultsForMissingFields()
|
||||
public async Task GetEvents_WithReplacementSearchType_ParsesCorrectEnum()
|
||||
{
|
||||
// Only InstanceName is present, other fields missing
|
||||
var data = JsonSerializer.Serialize(new { InstanceName = "Partial Instance" });
|
||||
AddSearchEvent(data: data);
|
||||
|
||||
var result = await _controller.GetEvents();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
var item = body.GetProperty("Items")[0];
|
||||
item.GetProperty("InstanceName").GetString().ShouldBe("Partial Instance");
|
||||
item.GetProperty("ItemCount").GetInt32().ShouldBe(0);
|
||||
item.GetProperty("Items").GetArrayLength().ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithMalformedJson_ReturnsUnknownDefaults()
|
||||
{
|
||||
AddSearchEvent(data: "not valid json {{{");
|
||||
|
||||
var result = await _controller.GetEvents();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
var item = body.GetProperty("Items")[0];
|
||||
item.GetProperty("InstanceName").GetString().ShouldBe("Unknown");
|
||||
item.GetProperty("ItemCount").GetInt32().ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithSearchTypeReplacement_ParsesCorrectEnum()
|
||||
{
|
||||
var data = JsonSerializer.Serialize(new
|
||||
{
|
||||
InstanceName = "Sonarr",
|
||||
SearchType = "Replacement"
|
||||
});
|
||||
AddSearchEvent(data: data);
|
||||
AddSearchEvent(
|
||||
itemTitle: "Series A",
|
||||
searchType: SeekerSearchType.Replacement,
|
||||
searchReason: SeekerSearchReason.Replacement);
|
||||
|
||||
var result = await _controller.GetEvents();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
var item = body.GetProperty("Items")[0];
|
||||
item.GetProperty("SearchType").GetString().ShouldBe(nameof(SeekerSearchType.Replacement));
|
||||
item.GetProperty("SearchReason").GetString().ShouldBe(nameof(SeekerSearchReason.Replacement));
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -127,23 +95,19 @@ public class SearchStatsControllerTests : IDisposable
|
||||
#region GetEvents Filtering
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithInstanceIdFilter_FiltersViaInstanceUrl()
|
||||
public async Task GetEvents_WithInstanceIdFilter_FiltersByArrInstanceId()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
var sonarr = SeekerTestDataFactory.AddSonarrInstance(_dataContext);
|
||||
|
||||
// Event matching radarr's URL
|
||||
AddSearchEvent(instanceUrl: radarr.Url.ToString(), instanceType: InstanceType.Radarr,
|
||||
data: JsonSerializer.Serialize(new { InstanceName = "Radarr Event" }));
|
||||
// Event matching sonarr's URL
|
||||
AddSearchEvent(instanceUrl: sonarr.Url.ToString(), instanceType: InstanceType.Sonarr,
|
||||
data: JsonSerializer.Serialize(new { InstanceName = "Sonarr Event" }));
|
||||
AddSearchEvent(arrInstanceId: radarr.Id, itemTitle: "Radarr Movie");
|
||||
AddSearchEvent(arrInstanceId: sonarr.Id, itemTitle: "Sonarr Series");
|
||||
|
||||
var result = await _controller.GetEvents(instanceId: radarr.Id);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
body.GetProperty("Items")[0].GetProperty("InstanceName").GetString().ShouldBe("Radarr Event");
|
||||
body.GetProperty("Items")[0].GetProperty("ArrInstanceId").GetString().ShouldBe(radarr.Id.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -152,21 +116,21 @@ public class SearchStatsControllerTests : IDisposable
|
||||
var cycleA = Guid.NewGuid();
|
||||
var cycleB = Guid.NewGuid();
|
||||
|
||||
AddSearchEvent(cycleId: cycleA, data: JsonSerializer.Serialize(new { InstanceName = "Cycle A" }));
|
||||
AddSearchEvent(cycleId: cycleB, data: JsonSerializer.Serialize(new { InstanceName = "Cycle B" }));
|
||||
AddSearchEvent(cycleId: cycleA, itemTitle: "Cycle A Movie");
|
||||
AddSearchEvent(cycleId: cycleB, itemTitle: "Cycle B Movie");
|
||||
|
||||
var result = await _controller.GetEvents(cycleId: cycleA);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
body.GetProperty("Items")[0].GetProperty("InstanceName").GetString().ShouldBe("Cycle A");
|
||||
body.GetProperty("Items")[0].GetProperty("ItemTitle").GetString().ShouldBe("Cycle A Movie");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithSearchFilter_FiltersOnDataField()
|
||||
public async Task GetEvents_WithSearchFilter_FiltersOnItemTitle()
|
||||
{
|
||||
AddSearchEvent(data: JsonSerializer.Serialize(new { InstanceName = "Radarr", Items = new[] { "The Matrix" } }));
|
||||
AddSearchEvent(data: JsonSerializer.Serialize(new { InstanceName = "Sonarr", Items = new[] { "Breaking Bad" } }));
|
||||
AddSearchEvent(itemTitle: "The Matrix");
|
||||
AddSearchEvent(itemTitle: "Breaking Bad");
|
||||
|
||||
var result = await _controller.GetEvents(search: "matrix");
|
||||
var body = GetResponseBody(result);
|
||||
@@ -179,14 +143,14 @@ public class SearchStatsControllerTests : IDisposable
|
||||
{
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
AddSearchEvent(data: JsonSerializer.Serialize(new { InstanceName = $"Event {i}" }));
|
||||
AddSearchEvent(itemTitle: $"Event {i}");
|
||||
}
|
||||
|
||||
var result = await _controller.GetEvents(page: 2, pageSize: 2);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(5);
|
||||
body.GetProperty("TotalPages").GetInt32().ShouldBe(3); // ceil(5/2) = 3
|
||||
body.GetProperty("TotalPages").GetInt32().ShouldBe(3);
|
||||
body.GetProperty("Page").GetInt32().ShouldBe(2);
|
||||
body.GetProperty("Items").GetArrayLength().ShouldBe(2);
|
||||
}
|
||||
@@ -196,25 +160,40 @@ public class SearchStatsControllerTests : IDisposable
|
||||
#region Helpers
|
||||
|
||||
private void AddSearchEvent(
|
||||
string? data = null,
|
||||
string? instanceUrl = null,
|
||||
InstanceType? instanceType = null,
|
||||
string? itemTitle = null,
|
||||
SeekerSearchType searchType = SeekerSearchType.Proactive,
|
||||
SeekerSearchReason searchReason = SeekerSearchReason.Missing,
|
||||
List<string>? grabbedItems = null,
|
||||
Guid? arrInstanceId = null,
|
||||
Guid? cycleId = null,
|
||||
SearchCommandStatus? searchStatus = null)
|
||||
{
|
||||
_eventsContext.Events.Add(new AppEvent
|
||||
var appEvent = new AppEvent
|
||||
{
|
||||
EventType = EventType.SearchTriggered,
|
||||
Message = "Search triggered",
|
||||
Severity = EventSeverity.Information,
|
||||
Data = data,
|
||||
InstanceUrl = instanceUrl,
|
||||
InstanceType = instanceType,
|
||||
ArrInstanceId = arrInstanceId,
|
||||
CycleId = cycleId,
|
||||
SearchStatus = searchStatus,
|
||||
Timestamp = DateTime.UtcNow
|
||||
});
|
||||
};
|
||||
|
||||
_eventsContext.Events.Add(appEvent);
|
||||
_eventsContext.SaveChanges();
|
||||
|
||||
if (itemTitle is not null)
|
||||
{
|
||||
_eventsContext.SearchEventData.Add(new SearchEventData
|
||||
{
|
||||
AppEventId = appEvent.Id,
|
||||
ItemTitle = itemTitle,
|
||||
SearchType = searchType,
|
||||
SearchReason = searchReason,
|
||||
GrabbedItems = grabbedItems ?? [],
|
||||
});
|
||||
_eventsContext.SaveChanges();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
+1
-5
@@ -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
|
||||
|
||||
@@ -90,8 +90,6 @@ public class EventsController : ControllerBase
|
||||
EF.Functions.Like(e.Message, pattern) ||
|
||||
EF.Functions.Like(e.Data, pattern) ||
|
||||
EF.Functions.Like(e.TrackingId.ToString(), pattern) ||
|
||||
EF.Functions.Like(e.InstanceUrl, pattern) ||
|
||||
EF.Functions.Like(e.DownloadClientName, pattern) ||
|
||||
EF.Functions.Like(e.JobRunId.ToString(), pattern)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,9 +68,7 @@ public class ManualEventsController : ControllerBase
|
||||
string pattern = EventsContext.GetLikePattern(search);
|
||||
query = query.Where(e =>
|
||||
EF.Functions.Like(e.Message, pattern) ||
|
||||
EF.Functions.Like(e.Data, pattern) ||
|
||||
EF.Functions.Like(e.InstanceUrl, pattern) ||
|
||||
EF.Functions.Like(e.DownloadClientName, pattern)
|
||||
EF.Functions.Like(e.Data, pattern)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
using Cleanuparr.Infrastructure.Http;
|
||||
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
|
||||
using Data.Models.Arr;
|
||||
using MassTransit;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
|
||||
+18
@@ -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; }
|
||||
}
|
||||
-15
@@ -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; } = [];
|
||||
}
|
||||
+61
-33
@@ -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();
|
||||
|
||||
|
||||
+354
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+123
@@ -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);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -20,7 +20,7 @@ public abstract record QueueRuleDto
|
||||
[Range(0, 100, ErrorMessage = "Minimum completion percentage must be between 0 and 100")]
|
||||
public ushort MinCompletionPercentage { get; set; }
|
||||
|
||||
[Range(0, 100, ErrorMessage = "Maximum completion percentage must be between 0 and 100")]
|
||||
[Range(1, 100, ErrorMessage = "Maximum completion percentage must be between 1 and 100")]
|
||||
public ushort MaxCompletionPercentage { get; set; }
|
||||
|
||||
public bool DeletePrivateTorrentsFromClient { get; set; } = false;
|
||||
|
||||
+4
-4
@@ -6,14 +6,14 @@ public sealed record SearchEventResponse
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public DateTime Timestamp { get; init; }
|
||||
public string InstanceName { get; init; } = string.Empty;
|
||||
public Guid? ArrInstanceId { get; init; }
|
||||
public string? InstanceType { get; init; }
|
||||
public int ItemCount { get; init; }
|
||||
public List<string> Items { get; init; } = [];
|
||||
public string ItemTitle { get; init; } = string.Empty;
|
||||
public SeekerSearchType SearchType { get; init; }
|
||||
public SeekerSearchReason? SearchReason { get; init; }
|
||||
public SearchCommandStatus? SearchStatus { get; init; }
|
||||
public DateTime? CompletedAt { get; init; }
|
||||
public object? GrabbedItems { get; init; }
|
||||
public List<string> GrabbedItems { get; init; } = [];
|
||||
public Guid? CycleId { get; init; }
|
||||
public bool IsDryRun { get; init; }
|
||||
}
|
||||
+7
-1
@@ -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"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using System.Text.Json;
|
||||
using Cleanuparr.Api.Features.Seeker.Contracts.Responses;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
@@ -118,8 +117,7 @@ public sealed class SearchStatsController : ControllerBase
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets paginated search-triggered events with decoded data.
|
||||
/// Supports optional text search across item names in event data.
|
||||
/// Gets paginated search-triggered events
|
||||
/// </summary>
|
||||
[HttpGet("events")]
|
||||
public async Task<IActionResult> GetEvents(
|
||||
@@ -135,20 +133,13 @@ public sealed class SearchStatsController : ControllerBase
|
||||
|
||||
var query = _eventsContext.Events
|
||||
.AsNoTracking()
|
||||
.Include(e => e.SearchEventData)
|
||||
.Where(e => e.EventType == EventType.SearchTriggered);
|
||||
|
||||
// Filter by instance URL if instanceId provided
|
||||
// Filter by instance ID
|
||||
if (instanceId.HasValue)
|
||||
{
|
||||
var instance = await _dataContext.ArrInstances
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(a => a.Id == instanceId.Value);
|
||||
|
||||
if (instance is not null)
|
||||
{
|
||||
string url = (instance.ExternalUrl ?? instance.Url).ToString();
|
||||
query = query.Where(e => e.InstanceUrl == url);
|
||||
}
|
||||
query = query.Where(e => e.ArrInstanceId == instanceId.Value);
|
||||
}
|
||||
|
||||
// Filter by cycle ID
|
||||
@@ -157,10 +148,12 @@ public sealed class SearchStatsController : ControllerBase
|
||||
query = query.Where(e => e.CycleId == cycleId.Value);
|
||||
}
|
||||
|
||||
// Pre-filter by search term on the JSON data field
|
||||
// Search by item title in SearchEventData
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
query = query.Where(e => e.Data != null && e.Data.ToLower().Contains(search.ToLower()));
|
||||
string pattern = EventsContext.GetLikePattern(search);
|
||||
query = query.Where(e => e.SearchEventData != null
|
||||
&& EF.Functions.Like(e.SearchEventData.ItemTitle, pattern));
|
||||
}
|
||||
|
||||
int totalCount = await query.CountAsync();
|
||||
@@ -171,24 +164,37 @@ public sealed class SearchStatsController : ControllerBase
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
var items = rawEvents.Select(e =>
|
||||
// Resolve instance types from DataContext via ArrInstanceId
|
||||
var arrInstanceIds = rawEvents
|
||||
.Where(e => e.ArrInstanceId.HasValue)
|
||||
.Select(e => e.ArrInstanceId!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var instanceTypeMap = arrInstanceIds.Count > 0
|
||||
? await _dataContext.ArrInstances
|
||||
.AsNoTracking()
|
||||
.Include(a => a.ArrConfig)
|
||||
.Where(a => arrInstanceIds.Contains(a.Id))
|
||||
.ToDictionaryAsync(a => a.Id, a => a.ArrConfig.Type)
|
||||
: new Dictionary<Guid, InstanceType>();
|
||||
|
||||
var items = rawEvents.Select(e => new SearchEventResponse
|
||||
{
|
||||
var parsed = ParseEventData(e.Data);
|
||||
return new SearchEventResponse
|
||||
{
|
||||
Id = e.Id,
|
||||
Timestamp = e.Timestamp,
|
||||
InstanceName = parsed.InstanceName,
|
||||
InstanceType = e.InstanceType?.ToString(),
|
||||
ItemCount = parsed.ItemCount,
|
||||
Items = parsed.Items,
|
||||
SearchType = parsed.SearchType,
|
||||
SearchStatus = e.SearchStatus,
|
||||
CompletedAt = e.CompletedAt,
|
||||
GrabbedItems = parsed.GrabbedItems,
|
||||
CycleId = e.CycleId,
|
||||
IsDryRun = e.IsDryRun,
|
||||
};
|
||||
Id = e.Id,
|
||||
Timestamp = e.Timestamp,
|
||||
ArrInstanceId = e.ArrInstanceId,
|
||||
InstanceType = e.ArrInstanceId.HasValue && instanceTypeMap.TryGetValue(e.ArrInstanceId.Value, out var it)
|
||||
? it.ToString()
|
||||
: null,
|
||||
ItemTitle = e.SearchEventData?.ItemTitle ?? "Unknown",
|
||||
SearchType = e.SearchEventData?.SearchType ?? SeekerSearchType.Proactive,
|
||||
SearchReason = e.SearchEventData?.SearchReason,
|
||||
SearchStatus = e.SearchStatus,
|
||||
CompletedAt = e.CompletedAt,
|
||||
GrabbedItems = e.SearchEventData?.GrabbedItems ?? [],
|
||||
CycleId = e.CycleId,
|
||||
IsDryRun = e.IsDryRun,
|
||||
}).ToList();
|
||||
|
||||
return Ok(new
|
||||
@@ -200,51 +206,4 @@ public sealed class SearchStatsController : ControllerBase
|
||||
TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize),
|
||||
});
|
||||
}
|
||||
|
||||
private static (string InstanceName, int ItemCount, List<string> Items, SeekerSearchType SearchType, object? GrabbedItems) ParseEventData(string? data)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(data))
|
||||
{
|
||||
return ("Unknown", 0, [], SeekerSearchType.Proactive, null);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using JsonDocument doc = JsonDocument.Parse(data);
|
||||
JsonElement root = doc.RootElement;
|
||||
|
||||
string instanceName = root.TryGetProperty("InstanceName", out var nameEl)
|
||||
? nameEl.GetString() ?? "Unknown"
|
||||
: "Unknown";
|
||||
|
||||
int itemCount = root.TryGetProperty("ItemCount", out var countEl)
|
||||
? countEl.GetInt32()
|
||||
: 0;
|
||||
|
||||
var items = new List<string>();
|
||||
if (root.TryGetProperty("Items", out var itemsEl) && itemsEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (JsonElement item in itemsEl.EnumerateArray())
|
||||
{
|
||||
string? val = item.GetString();
|
||||
if (val is not null) items.Add(val);
|
||||
}
|
||||
}
|
||||
|
||||
SeekerSearchType searchType = root.TryGetProperty("SearchType", out var typeEl)
|
||||
&& Enum.TryParse<SeekerSearchType>(typeEl.GetString(), out var parsed)
|
||||
? parsed
|
||||
: SeekerSearchType.Proactive;
|
||||
|
||||
object? grabbedItems = root.TryGetProperty("GrabbedItems", out var grabbedEl)
|
||||
? JsonSerializer.Deserialize<object>(grabbedEl.GetRawText())
|
||||
: null;
|
||||
|
||||
return (instanceName, itemCount, items, searchType, grabbedItems);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return ("Unknown", 0, [], SeekerSearchType.Proactive, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Data.Models.Arr;
|
||||
namespace Cleanuparr.Domain.Entities.Arr;
|
||||
|
||||
public class SearchItem
|
||||
{
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Data.Models.Arr;
|
||||
|
||||
namespace Cleanuparr.Domain.Entities.Arr;
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Cleanuparr.Domain.Entities.Arr;
|
||||
|
||||
public sealed record SeriesStatistics
|
||||
{
|
||||
public int EpisodeFileCount { get; init; }
|
||||
|
||||
public int EpisodeCount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Cleanuparr.Domain.Entities.Arr;
|
||||
|
||||
public sealed record Tag
|
||||
{
|
||||
public required long Id { get; init; }
|
||||
|
||||
public required string Label { get; init; }
|
||||
}
|
||||
@@ -61,10 +61,17 @@ public sealed record RTorrentTorrent
|
||||
public string? Label { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Base path where the torrent data is stored
|
||||
/// Base path where the torrent data is stored.
|
||||
/// For multi-file torrents this is the torrent directory; for single-file torrents this is the full file path.
|
||||
/// </summary>
|
||||
public string? BasePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Directory containing the torrent data (from d.directory).
|
||||
/// Unlike BasePath, this always points to a directory for both single-file and multi-file torrents.
|
||||
/// </summary>
|
||||
public string? Directory { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// List of tracker URLs for this torrent
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum SeekerSearchReason
|
||||
{
|
||||
Missing,
|
||||
QualityCutoffNotMet,
|
||||
CustomFormatScoreBelowCutoff,
|
||||
Replacement,
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
using System.Text.Json;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
@@ -6,12 +5,13 @@ using Cleanuparr.Infrastructure.Features.Notifications;
|
||||
using Cleanuparr.Infrastructure.Hubs;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Events;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Events;
|
||||
@@ -19,11 +19,10 @@ namespace Cleanuparr.Infrastructure.Tests.Events;
|
||||
public class EventPublisherTests : IDisposable
|
||||
{
|
||||
private readonly EventsContext _context;
|
||||
private readonly Mock<IHubContext<AppHub>> _hubContextMock;
|
||||
private readonly Mock<ILogger<EventPublisher>> _loggerMock;
|
||||
private readonly Mock<INotificationPublisher> _notificationPublisherMock;
|
||||
private readonly Mock<IDryRunInterceptor> _dryRunInterceptorMock;
|
||||
private readonly Mock<IClientProxy> _clientProxyMock;
|
||||
private readonly IHubContext<AppHub> _hubContext;
|
||||
private readonly INotificationPublisher _notificationPublisher;
|
||||
private readonly IDryRunInterceptor _dryRunInterceptor;
|
||||
private readonly IClientProxy _clientProxy;
|
||||
private readonly EventPublisher _publisher;
|
||||
|
||||
public EventPublisherTests()
|
||||
@@ -31,30 +30,30 @@ public class EventPublisherTests : IDisposable
|
||||
// Setup in-memory database
|
||||
var options = new DbContextOptionsBuilder<EventsContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning))
|
||||
.Options;
|
||||
_context = new EventsContext(options);
|
||||
|
||||
// Setup mocks
|
||||
_hubContextMock = new Mock<IHubContext<AppHub>>();
|
||||
_loggerMock = new Mock<ILogger<EventPublisher>>();
|
||||
_notificationPublisherMock = new Mock<INotificationPublisher>();
|
||||
_dryRunInterceptorMock = new Mock<IDryRunInterceptor>();
|
||||
_clientProxyMock = new Mock<IClientProxy>();
|
||||
_hubContext = Substitute.For<IHubContext<AppHub>>();
|
||||
_notificationPublisher = Substitute.For<INotificationPublisher>();
|
||||
_dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
|
||||
_clientProxy = Substitute.For<IClientProxy>();
|
||||
|
||||
// Setup HubContext to return client proxy
|
||||
var clientsMock = new Mock<IHubClients>();
|
||||
clientsMock.Setup(c => c.All).Returns(_clientProxyMock.Object);
|
||||
_hubContextMock.Setup(h => h.Clients).Returns(clientsMock.Object);
|
||||
var clients = Substitute.For<IHubClients>();
|
||||
clients.All.Returns(_clientProxy);
|
||||
_hubContext.Clients.Returns(clients);
|
||||
|
||||
// Setup dry run interceptor to report dry run as disabled by default
|
||||
_dryRunInterceptorMock.Setup(d => d.IsDryRunEnabled()).ReturnsAsync(false);
|
||||
_dryRunInterceptor.IsDryRunEnabled().Returns(false);
|
||||
|
||||
_publisher = new EventPublisher(
|
||||
_context,
|
||||
_hubContextMock.Object,
|
||||
_loggerMock.Object,
|
||||
_notificationPublisherMock.Object,
|
||||
_dryRunInterceptorMock.Object);
|
||||
_hubContext,
|
||||
Substitute.For<ILogger<EventPublisher>>(),
|
||||
_notificationPublisher,
|
||||
_dryRunInterceptor);
|
||||
|
||||
// Setup JobRunId in context for tests
|
||||
ContextProvider.SetJobRunId(Guid.NewGuid());
|
||||
@@ -137,10 +136,10 @@ public class EventPublisherTests : IDisposable
|
||||
await _publisher.PublishAsync(eventType, message, severity);
|
||||
|
||||
// Assert
|
||||
_clientProxyMock.Verify(c => c.SendCoreAsync(
|
||||
await _clientProxy.Received(1).SendCoreAsync(
|
||||
"EventReceived",
|
||||
It.Is<object[]>(args => args.Length == 1 && args[0] is AppEvent),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
Arg.Is<object[]>(args => args.Length == 1 && args[0] is AppEvent),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -151,10 +150,10 @@ public class EventPublisherTests : IDisposable
|
||||
var message = "Test message";
|
||||
var severity = EventSeverity.Important;
|
||||
|
||||
_clientProxyMock.Setup(c => c.SendCoreAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<object[]>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
_clientProxy.SendCoreAsync(
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<object[]>(),
|
||||
Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(new Exception("SignalR connection failed"));
|
||||
|
||||
// Act - should not throw
|
||||
@@ -233,10 +232,10 @@ public class EventPublisherTests : IDisposable
|
||||
await _publisher.PublishManualAsync(message, severity);
|
||||
|
||||
// Assert
|
||||
_clientProxyMock.Verify(c => c.SendCoreAsync(
|
||||
await _clientProxy.Received(1).SendCoreAsync(
|
||||
"ManualEventReceived",
|
||||
It.Is<object[]>(args => args.Length == 1 && args[0] is ManualEvent),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
Arg.Is<object[]>(args => args.Length == 1 && args[0] is ManualEvent),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -255,7 +254,7 @@ public class EventPublisherTests : IDisposable
|
||||
await _publisher.PublishAsync(eventType, message, severity);
|
||||
|
||||
// Assert
|
||||
_dryRunInterceptorMock.Verify(d => d.IsDryRunEnabled(), Times.Once);
|
||||
await _dryRunInterceptor.Received(1).IsDryRunEnabled();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -269,14 +268,14 @@ public class EventPublisherTests : IDisposable
|
||||
await _publisher.PublishManualAsync(message, severity);
|
||||
|
||||
// Assert
|
||||
_dryRunInterceptorMock.Verify(d => d.IsDryRunEnabled(), Times.Once);
|
||||
await _dryRunInterceptor.Received(1).IsDryRunEnabled();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishAsync_WhenDryRunEnabled_SetsIsDryRunTrue()
|
||||
{
|
||||
// Arrange
|
||||
_dryRunInterceptorMock.Setup(d => d.IsDryRunEnabled()).ReturnsAsync(true);
|
||||
_dryRunInterceptor.IsDryRunEnabled().Returns(true);
|
||||
var eventType = EventType.StalledStrike;
|
||||
var message = "Dry run event";
|
||||
var severity = EventSeverity.Warning;
|
||||
@@ -311,7 +310,7 @@ public class EventPublisherTests : IDisposable
|
||||
public async Task PublishManualAsync_WhenDryRunEnabled_SetsIsDryRunTrue()
|
||||
{
|
||||
// Arrange
|
||||
_dryRunInterceptorMock.Setup(d => d.IsDryRunEnabled()).ReturnsAsync(true);
|
||||
_dryRunInterceptor.IsDryRunEnabled().Returns(true);
|
||||
var message = "Dry run manual event";
|
||||
var severity = EventSeverity.Important;
|
||||
|
||||
@@ -328,7 +327,7 @@ public class EventPublisherTests : IDisposable
|
||||
public async Task PublishAsync_WhenDryRunEnabled_StillSavesToDatabase()
|
||||
{
|
||||
// Arrange
|
||||
_dryRunInterceptorMock.Setup(d => d.IsDryRunEnabled()).ReturnsAsync(true);
|
||||
_dryRunInterceptor.IsDryRunEnabled().Returns(true);
|
||||
var eventType = EventType.StalledStrike;
|
||||
var message = "Should be saved";
|
||||
var severity = EventSeverity.Warning;
|
||||
@@ -426,7 +425,7 @@ public class EventPublisherTests : IDisposable
|
||||
await _publisher.PublishQueueItemDeleted(removeFromClient: false, DeleteReason.FailedImport);
|
||||
|
||||
// Assert
|
||||
_notificationPublisherMock.Verify(n => n.NotifyQueueItemDeleted(false, DeleteReason.FailedImport), Times.Once);
|
||||
await _notificationPublisher.Received(1).NotifyQueueItemDeleted(false, DeleteReason.FailedImport);
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -475,7 +474,7 @@ public class EventPublisherTests : IDisposable
|
||||
await _publisher.PublishDownloadCleaned(ratio, seedingTime, categoryName, reason);
|
||||
|
||||
// Assert
|
||||
_notificationPublisherMock.Verify(n => n.NotifyDownloadCleaned(ratio, seedingTime, categoryName, reason), Times.Once);
|
||||
await _notificationPublisher.Received(1).NotifyDownloadCleaned(ratio, seedingTime, categoryName, reason);
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -575,7 +574,7 @@ public class EventPublisherTests : IDisposable
|
||||
await _publisher.PublishCategoryChanged("old", "new", isTag: true);
|
||||
|
||||
// Assert
|
||||
_notificationPublisherMock.Verify(n => n.NotifyCategoryChanged("old", "new", true), Times.Once);
|
||||
await _notificationPublisher.Received(1).NotifyCategoryChanged("old", "new", true);
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -586,7 +585,7 @@ public class EventPublisherTests : IDisposable
|
||||
public async Task PublishSearchTriggered_SavesEventWithCorrectType()
|
||||
{
|
||||
// Act
|
||||
await _publisher.PublishSearchTriggered("Radarr-1", 2, ["Movie A", "Movie B"], SeekerSearchType.Proactive);
|
||||
await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
@@ -599,7 +598,7 @@ public class EventPublisherTests : IDisposable
|
||||
public async Task PublishSearchTriggered_SetsSearchStatusToPending()
|
||||
{
|
||||
// Act
|
||||
await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive);
|
||||
await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
@@ -614,7 +613,7 @@ public class EventPublisherTests : IDisposable
|
||||
var cycleId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive, cycleId);
|
||||
await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing, cycleId);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
@@ -626,7 +625,7 @@ public class EventPublisherTests : IDisposable
|
||||
public async Task PublishSearchTriggered_ReturnsEventId()
|
||||
{
|
||||
// Act
|
||||
Guid eventId = await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive);
|
||||
Guid eventId = await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing);
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(Guid.Empty, eventId);
|
||||
@@ -635,59 +634,56 @@ public class EventPublisherTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchTriggered_SerializesItemsAndSearchTypeToData()
|
||||
public async Task PublishSearchTriggered_CreatesSearchEventData()
|
||||
{
|
||||
// Act
|
||||
await _publisher.PublishSearchTriggered("Sonarr-1", 2, ["Series A", "Series B"], SeekerSearchType.Replacement);
|
||||
await _publisher.PublishSearchTriggered("Series A", SeekerSearchType.Replacement, SeekerSearchReason.Replacement);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.NotNull(savedEvent.Data);
|
||||
Assert.Contains("Series A", savedEvent.Data);
|
||||
Assert.Contains("Series B", savedEvent.Data);
|
||||
Assert.Contains("Replacement", savedEvent.Data);
|
||||
Assert.Contains("Sonarr-1", savedEvent.Data);
|
||||
|
||||
var searchData = await _context.SearchEventData.FirstOrDefaultAsync(s => s.AppEventId == savedEvent.Id);
|
||||
Assert.NotNull(searchData);
|
||||
Assert.Equal("Series A", searchData.ItemTitle);
|
||||
Assert.Equal(SeekerSearchType.Replacement, searchData.SearchType);
|
||||
Assert.Equal(SeekerSearchReason.Replacement, searchData.SearchReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchTriggered_NotifiesSignalRClients()
|
||||
{
|
||||
// Act
|
||||
await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive);
|
||||
await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing);
|
||||
|
||||
// Assert
|
||||
_clientProxyMock.Verify(c => c.SendCoreAsync(
|
||||
await _clientProxy.Received(1).SendCoreAsync(
|
||||
"EventReceived",
|
||||
It.Is<object[]>(args => args.Length == 1 && args[0] is AppEvent),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
Arg.Is<object[]>(args => args.Length == 1 && args[0] is AppEvent),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchTriggered_SendsNotification()
|
||||
{
|
||||
// Act
|
||||
await _publisher.PublishSearchTriggered("Radarr-1", 2, ["Movie A", "Movie B"], SeekerSearchType.Proactive);
|
||||
await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing);
|
||||
|
||||
// Assert
|
||||
_notificationPublisherMock.Verify(
|
||||
n => n.NotifySearchTriggered("Radarr-1", 2, It.IsAny<IEnumerable<string>>()),
|
||||
Times.Once);
|
||||
await _notificationPublisher.Received(1).NotifySearchTriggered(
|
||||
"Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchTriggered_TruncatesDisplayForMoreThan5Items()
|
||||
public async Task PublishSearchTriggered_IncludesItemTitleInMessage()
|
||||
{
|
||||
// Arrange
|
||||
var items = new[] { "Item 1", "Item 2", "Item 3", "Item 4", "Item 5", "Item 6", "Item 7" };
|
||||
|
||||
// Act
|
||||
await _publisher.PublishSearchTriggered("Radarr-1", 7, items, SeekerSearchType.Proactive);
|
||||
await _publisher.PublishSearchTriggered("The Matrix", SeekerSearchType.Proactive, SeekerSearchReason.Missing);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.Contains("+2 more", savedEvent.Message);
|
||||
Assert.Contains("The Matrix", savedEvent.Message);
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -698,7 +694,7 @@ public class EventPublisherTests : IDisposable
|
||||
public async Task PublishSearchCompleted_UpdatesEventStatus()
|
||||
{
|
||||
// Arrange — create a search event first
|
||||
Guid eventId = await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive);
|
||||
Guid eventId = await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing);
|
||||
|
||||
// Act
|
||||
await _publisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed);
|
||||
@@ -713,7 +709,7 @@ public class EventPublisherTests : IDisposable
|
||||
public async Task PublishSearchCompleted_SetsCompletedAt()
|
||||
{
|
||||
// Arrange
|
||||
Guid eventId = await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive);
|
||||
Guid eventId = await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing);
|
||||
|
||||
// Act
|
||||
await _publisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed);
|
||||
@@ -725,42 +721,35 @@ public class EventPublisherTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchCompleted_MergesResultDataIntoExistingData()
|
||||
public async Task PublishSearchCompleted_UpdatesGrabbedItemsOnSearchEventData()
|
||||
{
|
||||
// Arrange
|
||||
Guid eventId = await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive);
|
||||
Guid eventId = await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing);
|
||||
|
||||
var resultData = new { GrabbedItems = new[] { new { Title = "Movie A (2024)", Status = "downloading" } } };
|
||||
var grabbedItems = new List<string> { "Movie A (2024)" };
|
||||
|
||||
// Act
|
||||
await _publisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed, resultData);
|
||||
await _publisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed, grabbedItems);
|
||||
|
||||
// Assert
|
||||
var updatedEvent = await _context.Events.FindAsync(eventId);
|
||||
Assert.NotNull(updatedEvent);
|
||||
Assert.NotNull(updatedEvent.Data);
|
||||
// Original data should still be present
|
||||
Assert.Contains("Movie A", updatedEvent.Data);
|
||||
// Merged result data should be present
|
||||
Assert.Contains("GrabbedItems", updatedEvent.Data);
|
||||
Assert.Contains("Movie A (2024)", updatedEvent.Data);
|
||||
var searchData = await _context.SearchEventData.FirstOrDefaultAsync(s => s.AppEventId == eventId);
|
||||
Assert.NotNull(searchData);
|
||||
Assert.Contains("Movie A (2024)", searchData.GrabbedItems);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchCompleted_WithNullResultData_DoesNotModifyData()
|
||||
public async Task PublishSearchCompleted_WithNullGrabbedItems_DoesNotModifySearchEventData()
|
||||
{
|
||||
// Arrange
|
||||
Guid eventId = await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive);
|
||||
var originalEvent = await _context.Events.FindAsync(eventId);
|
||||
string? originalData = originalEvent!.Data;
|
||||
Guid eventId = await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing);
|
||||
|
||||
// Act
|
||||
await _publisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed);
|
||||
|
||||
// Assert
|
||||
var updatedEvent = await _context.Events.FindAsync(eventId);
|
||||
Assert.NotNull(updatedEvent);
|
||||
Assert.Equal(originalData, updatedEvent.Data);
|
||||
var searchData = await _context.SearchEventData.FirstOrDefaultAsync(s => s.AppEventId == eventId);
|
||||
Assert.NotNull(searchData);
|
||||
Assert.Empty(searchData.GrabbedItems);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -779,19 +768,19 @@ public class EventPublisherTests : IDisposable
|
||||
public async Task PublishSearchCompleted_NotifiesSignalRClients()
|
||||
{
|
||||
// Arrange
|
||||
Guid eventId = await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive);
|
||||
Guid eventId = await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing);
|
||||
|
||||
// Reset mock to only capture the completion call
|
||||
_clientProxyMock.Invocations.Clear();
|
||||
_clientProxy.ClearReceivedCalls();
|
||||
|
||||
// Act
|
||||
await _publisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed);
|
||||
|
||||
// Assert
|
||||
_clientProxyMock.Verify(c => c.SendCoreAsync(
|
||||
await _clientProxy.Received(1).SendCoreAsync(
|
||||
"EventReceived",
|
||||
It.Is<object[]>(args => args.Length == 1 && args[0] is AppEvent),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
Arg.Is<object[]>(args => args.Length == 1 && args[0] is AppEvent),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
+62
-55
@@ -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(
|
||||
|
||||
+110
-106
@@ -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(
|
||||
|
||||
+159
-63
@@ -1,7 +1,5 @@
|
||||
using Cleanuparr.Domain.Entities;
|
||||
using Cleanuparr.Domain.Entities.RTorrent.Response;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Moq;
|
||||
@@ -112,10 +110,10 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH3", Name = "Torrent 3", Label = "music" })
|
||||
};
|
||||
|
||||
var categories = new List<SeedingRule>
|
||||
var categories = new List<ISeedingRule>
|
||||
{
|
||||
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
|
||||
new SeedingRule { Name = "tv", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
new RTorrentSeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
|
||||
new RTorrentSeedingRule { Name = "tv", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
};
|
||||
|
||||
// Act
|
||||
@@ -139,9 +137,9 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Torrent 1", Label = "Movies" })
|
||||
};
|
||||
|
||||
var categories = new List<SeedingRule>
|
||||
var categories = new List<ISeedingRule>
|
||||
{
|
||||
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
new RTorrentSeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
};
|
||||
|
||||
// Act
|
||||
@@ -163,9 +161,9 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Torrent 1", Label = "music" })
|
||||
};
|
||||
|
||||
var categories = new List<SeedingRule>
|
||||
var categories = new List<ISeedingRule>
|
||||
{
|
||||
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
new RTorrentSeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
};
|
||||
|
||||
// Act
|
||||
@@ -182,9 +180,9 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var categories = new List<SeedingRule>
|
||||
var categories = new List<ISeedingRule>
|
||||
{
|
||||
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
new RTorrentSeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
};
|
||||
|
||||
// Act
|
||||
@@ -214,10 +212,10 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH3", Name = "Torrent 3", Label = "music" })
|
||||
};
|
||||
|
||||
var categories = new List<string> { "movies", "tv" };
|
||||
var unlinkedConfig = new UnlinkedConfig { Categories = ["movies", "tv"] };
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, categories);
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
@@ -236,16 +234,37 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Valid Hash", Label = "movies" })
|
||||
};
|
||||
|
||||
var categories = new List<string> { "movies" };
|
||||
var unlinkedConfig = new UnlinkedConfig { Categories = ["movies"] };
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, categories);
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("HASH1", result[0].Hash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReturnsEmpty_WhenNoCategoriesMatch()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<ITorrentItemWrapper>
|
||||
{
|
||||
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Torrent 1", Label = "tv" })
|
||||
};
|
||||
|
||||
var unlinkedConfig = new UnlinkedConfig { Categories = ["movies"] };
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
}
|
||||
|
||||
public class DeleteDownload_Tests : RTorrentServiceDCTests
|
||||
@@ -311,15 +330,14 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(null);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(null, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
@@ -333,15 +351,14 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(new List<ITorrentItemWrapper>());
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(new List<ITorrentItemWrapper>(), unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
@@ -355,12 +372,11 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<ITorrentItemWrapper>
|
||||
{
|
||||
@@ -368,7 +384,7 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
@@ -382,12 +398,11 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<ITorrentItemWrapper>
|
||||
{
|
||||
@@ -395,7 +410,7 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
@@ -409,12 +424,11 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<ITorrentItemWrapper>
|
||||
{
|
||||
@@ -422,7 +436,7 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
@@ -436,12 +450,11 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<ITorrentItemWrapper>
|
||||
{
|
||||
@@ -453,7 +466,7 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
.ThrowsAsync(new Exception("XML-RPC error"));
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
@@ -467,12 +480,11 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<ITorrentItemWrapper>
|
||||
{
|
||||
@@ -492,7 +504,7 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert - only called for file2.mkv (the active file)
|
||||
_fixture.HardLinkFileService.Verify(
|
||||
@@ -506,12 +518,11 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<ITorrentItemWrapper>
|
||||
{
|
||||
@@ -530,7 +541,7 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert - rTorrent uses SetLabelAsync (not SetTorrentCategoryAsync)
|
||||
_fixture.ClientWrapper.Verify(
|
||||
@@ -544,12 +555,11 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<ITorrentItemWrapper>
|
||||
{
|
||||
@@ -568,7 +578,7 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
.Returns(2); // Has hardlinks
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
@@ -582,12 +592,11 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<ITorrentItemWrapper>
|
||||
{
|
||||
@@ -606,7 +615,7 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
.Returns(-1); // Error / file not found
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
@@ -620,12 +629,11 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<ITorrentItemWrapper>
|
||||
{
|
||||
@@ -644,7 +652,7 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.EventPublisher.Verify(
|
||||
@@ -652,18 +660,106 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UsesDirectoryOverBasePathForFilePath()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
|
||||
// Single-file torrent: BasePath is the full file path, Directory is the containing dir
|
||||
var downloads = new List<ITorrentItemWrapper>
|
||||
{
|
||||
new RTorrentItemWrapper(new RTorrentTorrent
|
||||
{
|
||||
Hash = "HASH1", Name = "movie.mkv", Label = "movies",
|
||||
BasePath = "/downloads/movie.mkv",
|
||||
Directory = "/downloads"
|
||||
})
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync("HASH1"))
|
||||
.ReturnsAsync(new List<RTorrentFile>
|
||||
{
|
||||
new RTorrentFile { Index = 0, Path = "movie.mkv", Priority = 1 }
|
||||
});
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert - path should use Directory (/downloads), not BasePath (/downloads/movie.mkv)
|
||||
var expectedPath = string.Join(Path.DirectorySeparatorChar,
|
||||
Path.Combine("/downloads", "movie.mkv").Split('\\', '/'));
|
||||
_fixture.HardLinkFileService.Verify(
|
||||
x => x.GetHardLinkCount(expectedPath, false),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FallsBackToBasePathWhenDirectoryNull()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
|
||||
var downloads = new List<ITorrentItemWrapper>
|
||||
{
|
||||
new RTorrentItemWrapper(new RTorrentTorrent
|
||||
{
|
||||
Hash = "HASH1", Name = "Test", Label = "movies",
|
||||
BasePath = "/downloads",
|
||||
Directory = null
|
||||
})
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync("HASH1"))
|
||||
.ReturnsAsync(new List<RTorrentFile>
|
||||
{
|
||||
new RTorrentFile { Index = 0, Path = "file1.mkv", Priority = 1 }
|
||||
});
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert - path should fall back to BasePath
|
||||
var expectedPath = string.Join(Path.DirectorySeparatorChar,
|
||||
Path.Combine("/downloads", "file1.mkv").Split('\\', '/'));
|
||||
_fixture.HardLinkFileService.Verify(
|
||||
x => x.GetHardLinkCount(expectedPath, false),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdatesCategoryOnWrapper()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var wrapper = new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Test", Label = "movies", BasePath = "/downloads" });
|
||||
var downloads = new List<ITorrentItemWrapper> { wrapper };
|
||||
@@ -680,7 +776,7 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("unlinked", wrapper.Category);
|
||||
|
||||
+68
-64
@@ -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(
|
||||
|
||||
+62
-56
@@ -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);
|
||||
|
||||
+10
-13
@@ -1,10 +1,10 @@
|
||||
using Cleanuparr.Domain.Entities.Arr;
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover.Consumers;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover.Models;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Data.Models.Arr;
|
||||
using MassTransit;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
@@ -88,7 +88,7 @@ public class DownloadRemoverConsumerTests
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedRequest);
|
||||
Assert.Equal(request.InstanceType, capturedRequest.InstanceType);
|
||||
Assert.Equal(request.Instance.ArrConfig.Type, capturedRequest.Instance.ArrConfig.Type);
|
||||
Assert.Equal(request.SearchItem.Id, capturedRequest.SearchItem.Id);
|
||||
Assert.Equal(request.RemoveFromClient, capturedRequest.RemoveFromClient);
|
||||
Assert.Equal(request.DeleteReason, capturedRequest.DeleteReason);
|
||||
@@ -100,8 +100,7 @@ public class DownloadRemoverConsumerTests
|
||||
// Arrange
|
||||
var request = new QueueItemRemoveRequest<SearchItem>
|
||||
{
|
||||
InstanceType = InstanceType.Sonarr,
|
||||
Instance = CreateArrInstance(),
|
||||
Instance = CreateArrInstance(InstanceType.Sonarr),
|
||||
SearchItem = new SearchItem { Id = 456 },
|
||||
Record = CreateQueueRecord(),
|
||||
RemoveFromClient = true,
|
||||
@@ -130,8 +129,7 @@ public class DownloadRemoverConsumerTests
|
||||
// Arrange
|
||||
var request = new QueueItemRemoveRequest<SearchItem>
|
||||
{
|
||||
InstanceType = InstanceType.Radarr,
|
||||
Instance = CreateArrInstance(),
|
||||
Instance = CreateArrInstance(InstanceType.Radarr),
|
||||
SearchItem = new SearchItem { Id = 789 },
|
||||
Record = CreateQueueRecord(),
|
||||
RemoveFromClient = false,
|
||||
@@ -159,8 +157,7 @@ public class DownloadRemoverConsumerTests
|
||||
// Arrange
|
||||
var request = new QueueItemRemoveRequest<SearchItem>
|
||||
{
|
||||
InstanceType = InstanceType.Readarr,
|
||||
Instance = CreateArrInstance(),
|
||||
Instance = CreateArrInstance(InstanceType.Readarr),
|
||||
SearchItem = new SearchItem { Id = 111 },
|
||||
Record = CreateQueueRecord(),
|
||||
RemoveFromClient = true,
|
||||
@@ -178,7 +175,7 @@ public class DownloadRemoverConsumerTests
|
||||
|
||||
// Assert
|
||||
_queueItemRemoverMock.Verify(r => r.RemoveQueueItemAsync(
|
||||
It.Is<QueueItemRemoveRequest<SearchItem>>(req => req.InstanceType == InstanceType.Readarr)), Times.Once);
|
||||
It.Is<QueueItemRemoveRequest<SearchItem>>(req => req.Instance.ArrConfig.Type == InstanceType.Readarr)), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -189,8 +186,7 @@ public class DownloadRemoverConsumerTests
|
||||
{
|
||||
return new QueueItemRemoveRequest<SearchItem>
|
||||
{
|
||||
InstanceType = InstanceType.Radarr,
|
||||
Instance = CreateArrInstance(),
|
||||
Instance = CreateArrInstance(InstanceType.Radarr),
|
||||
SearchItem = new SearchItem { Id = 123 },
|
||||
Record = CreateQueueRecord(),
|
||||
RemoveFromClient = true,
|
||||
@@ -199,13 +195,14 @@ public class DownloadRemoverConsumerTests
|
||||
};
|
||||
}
|
||||
|
||||
private static ArrInstance CreateArrInstance()
|
||||
private static ArrInstance CreateArrInstance(InstanceType instanceType = InstanceType.Radarr)
|
||||
{
|
||||
return new ArrInstance
|
||||
{
|
||||
Name = "Test Instance",
|
||||
Url = new Uri("http://radarr.local"),
|
||||
ApiKey = "test-api-key"
|
||||
ApiKey = "test-api-key",
|
||||
ArrConfig = new ArrConfig { Type = instanceType }
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+83
-178
@@ -1,4 +1,5 @@
|
||||
using System.Net;
|
||||
using Cleanuparr.Domain.Entities.Arr;
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
@@ -13,23 +14,24 @@ using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Data.Models.Arr;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadRemover;
|
||||
|
||||
public class QueueItemRemoverTests : IDisposable
|
||||
{
|
||||
private readonly Mock<ILogger<QueueItemRemover>> _loggerMock;
|
||||
private readonly ILogger<QueueItemRemover> _logger;
|
||||
private readonly MemoryCache _memoryCache;
|
||||
private readonly Mock<IArrClientFactory> _arrClientFactoryMock;
|
||||
private readonly Mock<IArrClient> _arrClientMock;
|
||||
private readonly IArrClientFactory _arrClientFactory;
|
||||
private readonly IArrClient _arrClient;
|
||||
private readonly EventPublisher _eventPublisher;
|
||||
private readonly EventsContext _eventsContext;
|
||||
private readonly DataContext _dataContext;
|
||||
@@ -38,14 +40,14 @@ public class QueueItemRemoverTests : IDisposable
|
||||
|
||||
public QueueItemRemoverTests()
|
||||
{
|
||||
_loggerMock = new Mock<ILogger<QueueItemRemover>>();
|
||||
_logger = Substitute.For<ILogger<QueueItemRemover>>();
|
||||
_memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions()));
|
||||
_arrClientFactoryMock = new Mock<IArrClientFactory>();
|
||||
_arrClientMock = new Mock<IArrClient>();
|
||||
_arrClientFactory = Substitute.For<IArrClientFactory>();
|
||||
_arrClient = Substitute.For<IArrClient>();
|
||||
|
||||
_arrClientFactoryMock
|
||||
.Setup(f => f.GetClient(It.IsAny<InstanceType>(), It.IsAny<float>()))
|
||||
.Returns(_arrClientMock.Object);
|
||||
_arrClientFactory
|
||||
.GetClient(Arg.Any<InstanceType>(), Arg.Any<float>())
|
||||
.Returns(_arrClient);
|
||||
|
||||
// Create real EventPublisher with mocked dependencies
|
||||
_eventsContext = TestEventsContextFactory.Create();
|
||||
@@ -56,32 +58,32 @@ public class QueueItemRemoverTests : IDisposable
|
||||
_eventsContext.SaveChanges();
|
||||
ContextProvider.SetJobRunId(_jobRunId);
|
||||
|
||||
var hubContextMock = new Mock<IHubContext<AppHub>>();
|
||||
var clientsMock = new Mock<IHubClients>();
|
||||
clientsMock.Setup(c => c.All).Returns(Mock.Of<IClientProxy>());
|
||||
hubContextMock.Setup(h => h.Clients).Returns(clientsMock.Object);
|
||||
var hubContext = Substitute.For<IHubContext<AppHub>>();
|
||||
var clients = Substitute.For<IHubClients>();
|
||||
clients.All.Returns(Substitute.For<IClientProxy>());
|
||||
hubContext.Clients.Returns(clients);
|
||||
|
||||
var dryRunInterceptorMock = new Mock<IDryRunInterceptor>();
|
||||
dryRunInterceptorMock.Setup(d => d.IsDryRunEnabled()).ReturnsAsync(false);
|
||||
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
|
||||
dryRunInterceptor.IsDryRunEnabled().Returns(false);
|
||||
// Setup interceptor for other uses (e.g., ArrClient deletion)
|
||||
dryRunInterceptorMock
|
||||
.Setup(d => d.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
|
||||
dryRunInterceptor
|
||||
.InterceptAsync(Arg.Any<Delegate>(), Arg.Any<object[]>())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
_eventPublisher = new EventPublisher(
|
||||
_eventsContext,
|
||||
hubContextMock.Object,
|
||||
Mock.Of<ILogger<EventPublisher>>(),
|
||||
Mock.Of<INotificationPublisher>(),
|
||||
dryRunInterceptorMock.Object);
|
||||
hubContext,
|
||||
Substitute.For<ILogger<EventPublisher>>(),
|
||||
Substitute.For<INotificationPublisher>(),
|
||||
dryRunInterceptor);
|
||||
|
||||
// Create in-memory DataContext with seeded SeekerConfig
|
||||
_dataContext = TestDataContextFactory.Create();
|
||||
|
||||
_queueItemRemover = new QueueItemRemover(
|
||||
_loggerMock.Object,
|
||||
_logger,
|
||||
_memoryCache,
|
||||
_arrClientFactoryMock.Object,
|
||||
_arrClientFactory,
|
||||
_eventPublisher,
|
||||
_eventsContext,
|
||||
_dataContext
|
||||
@@ -107,23 +109,15 @@ public class QueueItemRemoverTests : IDisposable
|
||||
// Arrange
|
||||
var request = CreateRemoveRequest();
|
||||
|
||||
_arrClientMock
|
||||
.Setup(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<DeleteReason>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _queueItemRemover.RemoveQueueItemAsync(request);
|
||||
|
||||
// Assert
|
||||
_arrClientMock.Verify(c => c.DeleteQueueItemAsync(
|
||||
await _arrClient.Received(1).DeleteQueueItemAsync(
|
||||
request.Instance,
|
||||
request.Record,
|
||||
request.RemoveFromClient,
|
||||
request.DeleteReason), Times.Once);
|
||||
request.DeleteReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -132,23 +126,15 @@ public class QueueItemRemoverTests : IDisposable
|
||||
// Arrange
|
||||
var request = CreateRemoveRequest();
|
||||
|
||||
_arrClientMock
|
||||
.Setup(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<DeleteReason>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _queueItemRemover.RemoveQueueItemAsync(request);
|
||||
|
||||
// Assert
|
||||
var queueItems = await _dataContext.SearchQueue.ToListAsync();
|
||||
Assert.Single(queueItems);
|
||||
Assert.Equal(request.Instance.Id, queueItems[0].ArrInstanceId);
|
||||
Assert.Equal(request.SearchItem.Id, queueItems[0].ItemId);
|
||||
Assert.Equal(request.Record.Title, queueItems[0].Title);
|
||||
queueItems.Count.ShouldBe(1);
|
||||
queueItems[0].ArrInstanceId.ShouldBe(request.Instance.Id);
|
||||
queueItems[0].ItemId.ShouldBe(request.SearchItem.Id);
|
||||
queueItems[0].Title.ShouldBe(request.Record.Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -159,19 +145,11 @@ public class QueueItemRemoverTests : IDisposable
|
||||
var cacheKey = $"remove_{request.Record.DownloadId.ToLowerInvariant()}_{request.Instance.Url}";
|
||||
_memoryCache.Set(cacheKey, true);
|
||||
|
||||
_arrClientMock
|
||||
.Setup(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<DeleteReason>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _queueItemRemover.RemoveQueueItemAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.False(_memoryCache.TryGetValue(cacheKey, out _));
|
||||
_memoryCache.TryGetValue(cacheKey, out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -185,19 +163,11 @@ public class QueueItemRemoverTests : IDisposable
|
||||
// Arrange
|
||||
var request = CreateRemoveRequest(instanceType);
|
||||
|
||||
_arrClientMock
|
||||
.Setup(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<DeleteReason>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _queueItemRemover.RemoveQueueItemAsync(request);
|
||||
|
||||
// Assert
|
||||
_arrClientFactoryMock.Verify(f => f.GetClient(instanceType, It.IsAny<float>()), Times.Once);
|
||||
_arrClientFactory.Received(1).GetClient(instanceType, Arg.Any<float>());
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -212,20 +182,12 @@ public class QueueItemRemoverTests : IDisposable
|
||||
var hash = request.Record.DownloadId.ToLowerInvariant();
|
||||
Striker.RecurringHashes.TryAdd(hash, null);
|
||||
|
||||
_arrClientMock
|
||||
.Setup(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<DeleteReason>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _queueItemRemover.RemoveQueueItemAsync(request);
|
||||
|
||||
// Assert
|
||||
var queueItems = await _dataContext.SearchQueue.ToListAsync();
|
||||
Assert.Empty(queueItems);
|
||||
queueItems.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -236,19 +198,11 @@ public class QueueItemRemoverTests : IDisposable
|
||||
var hash = request.Record.DownloadId.ToLowerInvariant();
|
||||
Striker.RecurringHashes.TryAdd(hash, null);
|
||||
|
||||
_arrClientMock
|
||||
.Setup(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<DeleteReason>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _queueItemRemover.RemoveQueueItemAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.False(Striker.RecurringHashes.ContainsKey(hash));
|
||||
Striker.RecurringHashes.ContainsKey(hash).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -257,20 +211,12 @@ public class QueueItemRemoverTests : IDisposable
|
||||
// Arrange
|
||||
var request = CreateRemoveRequest();
|
||||
|
||||
_arrClientMock
|
||||
.Setup(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<DeleteReason>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _queueItemRemover.RemoveQueueItemAsync(request);
|
||||
|
||||
// Assert
|
||||
var queueItems = await _dataContext.SearchQueue.ToListAsync();
|
||||
Assert.Single(queueItems);
|
||||
queueItems.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -283,20 +229,12 @@ public class QueueItemRemoverTests : IDisposable
|
||||
// Arrange
|
||||
var request = CreateRemoveRequest(skipSearch: true);
|
||||
|
||||
_arrClientMock
|
||||
.Setup(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<DeleteReason>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _queueItemRemover.RemoveQueueItemAsync(request);
|
||||
|
||||
// Assert
|
||||
var queueItems = await _dataContext.SearchQueue.ToListAsync();
|
||||
Assert.Empty(queueItems);
|
||||
queueItems.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -306,19 +244,11 @@ public class QueueItemRemoverTests : IDisposable
|
||||
var request = CreateRemoveRequest(skipSearch: true);
|
||||
var hash = request.Record.DownloadId.ToLowerInvariant();
|
||||
|
||||
_arrClientMock
|
||||
.Setup(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<DeleteReason>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _queueItemRemover.RemoveQueueItemAsync(request);
|
||||
|
||||
// Assert - hash was never in recurring, should still not be there
|
||||
Assert.False(Striker.RecurringHashes.ContainsKey(hash));
|
||||
Striker.RecurringHashes.ContainsKey(hash).ShouldBeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -335,20 +265,12 @@ public class QueueItemRemoverTests : IDisposable
|
||||
|
||||
var request = CreateRemoveRequest();
|
||||
|
||||
_arrClientMock
|
||||
.Setup(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<DeleteReason>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _queueItemRemover.RemoveQueueItemAsync(request);
|
||||
|
||||
// Assert
|
||||
var queueItems = await _dataContext.SearchQueue.ToListAsync();
|
||||
Assert.Empty(queueItems);
|
||||
queueItems.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -361,20 +283,20 @@ public class QueueItemRemoverTests : IDisposable
|
||||
// Arrange
|
||||
var request = CreateRemoveRequest();
|
||||
|
||||
_arrClientMock
|
||||
.Setup(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<DeleteReason>()))
|
||||
_arrClient
|
||||
.DeleteQueueItemAsync(
|
||||
Arg.Any<ArrInstance>(),
|
||||
Arg.Any<QueueRecord>(),
|
||||
Arg.Any<bool>(),
|
||||
Arg.Any<DeleteReason>())
|
||||
.ThrowsAsync(new HttpRequestException("Not found", null, HttpStatusCode.NotFound));
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<Exception>(
|
||||
var exception = await Should.ThrowAsync<Exception>(
|
||||
() => _queueItemRemover.RemoveQueueItemAsync(request));
|
||||
|
||||
Assert.Contains("might have already been deleted", exception.Message);
|
||||
Assert.Contains(request.InstanceType.ToString(), exception.Message);
|
||||
exception.Message.ShouldContain("might have already been deleted");
|
||||
exception.Message.ShouldContain(request.Instance.ArrConfig.Type.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -385,20 +307,20 @@ public class QueueItemRemoverTests : IDisposable
|
||||
var cacheKey = $"remove_{request.Record.DownloadId.ToLowerInvariant()}_{request.Instance.Url}";
|
||||
_memoryCache.Set(cacheKey, true);
|
||||
|
||||
_arrClientMock
|
||||
.Setup(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<DeleteReason>()))
|
||||
_arrClient
|
||||
.DeleteQueueItemAsync(
|
||||
Arg.Any<ArrInstance>(),
|
||||
Arg.Any<QueueRecord>(),
|
||||
Arg.Any<bool>(),
|
||||
Arg.Any<DeleteReason>())
|
||||
.ThrowsAsync(new HttpRequestException("Not found", null, HttpStatusCode.NotFound));
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<Exception>(
|
||||
await Should.ThrowAsync<Exception>(
|
||||
() => _queueItemRemover.RemoveQueueItemAsync(request));
|
||||
|
||||
// Cache should be cleared in finally block
|
||||
Assert.False(_memoryCache.TryGetValue(cacheKey, out _));
|
||||
_memoryCache.TryGetValue(cacheKey, out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -408,19 +330,19 @@ public class QueueItemRemoverTests : IDisposable
|
||||
var request = CreateRemoveRequest();
|
||||
var originalException = new HttpRequestException("Server error", null, HttpStatusCode.InternalServerError);
|
||||
|
||||
_arrClientMock
|
||||
.Setup(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<DeleteReason>()))
|
||||
_arrClient
|
||||
.DeleteQueueItemAsync(
|
||||
Arg.Any<ArrInstance>(),
|
||||
Arg.Any<QueueRecord>(),
|
||||
Arg.Any<bool>(),
|
||||
Arg.Any<DeleteReason>())
|
||||
.ThrowsAsync(originalException);
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<HttpRequestException>(
|
||||
var exception = await Should.ThrowAsync<HttpRequestException>(
|
||||
() => _queueItemRemover.RemoveQueueItemAsync(request));
|
||||
|
||||
Assert.Same(originalException, exception);
|
||||
exception.ShouldBeSameAs(originalException);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -430,19 +352,19 @@ public class QueueItemRemoverTests : IDisposable
|
||||
var request = CreateRemoveRequest();
|
||||
var originalException = new InvalidOperationException("Some other error");
|
||||
|
||||
_arrClientMock
|
||||
.Setup(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<DeleteReason>()))
|
||||
_arrClient
|
||||
.DeleteQueueItemAsync(
|
||||
Arg.Any<ArrInstance>(),
|
||||
Arg.Any<QueueRecord>(),
|
||||
Arg.Any<bool>(),
|
||||
Arg.Any<DeleteReason>())
|
||||
.ThrowsAsync(originalException);
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
var exception = await Should.ThrowAsync<InvalidOperationException>(
|
||||
() => _queueItemRemover.RemoveQueueItemAsync(request));
|
||||
|
||||
Assert.Same(originalException, exception);
|
||||
exception.ShouldBeSameAs(originalException);
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -460,23 +382,15 @@ public class QueueItemRemoverTests : IDisposable
|
||||
// Arrange
|
||||
var request = CreateRemoveRequest(deleteReason: deleteReason);
|
||||
|
||||
_arrClientMock
|
||||
.Setup(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<DeleteReason>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _queueItemRemover.RemoveQueueItemAsync(request);
|
||||
|
||||
// Assert
|
||||
_arrClientMock.Verify(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
deleteReason), Times.Once);
|
||||
await _arrClient.Received(1).DeleteQueueItemAsync(
|
||||
Arg.Any<ArrInstance>(),
|
||||
Arg.Any<QueueRecord>(),
|
||||
Arg.Any<bool>(),
|
||||
deleteReason);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -487,23 +401,15 @@ public class QueueItemRemoverTests : IDisposable
|
||||
// Arrange
|
||||
var request = CreateRemoveRequest(removeFromClient: removeFromClient);
|
||||
|
||||
_arrClientMock
|
||||
.Setup(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<DeleteReason>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _queueItemRemover.RemoveQueueItemAsync(request);
|
||||
|
||||
// Assert
|
||||
_arrClientMock.Verify(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
await _arrClient.Received(1).DeleteQueueItemAsync(
|
||||
Arg.Any<ArrInstance>(),
|
||||
Arg.Any<QueueRecord>(),
|
||||
removeFromClient,
|
||||
It.IsAny<DeleteReason>()), Times.Once);
|
||||
Arg.Any<DeleteReason>());
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -521,7 +427,6 @@ public class QueueItemRemoverTests : IDisposable
|
||||
|
||||
return new QueueItemRemoveRequest<SearchItem>
|
||||
{
|
||||
InstanceType = instanceType,
|
||||
Instance = instance,
|
||||
SearchItem = new SearchItem { Id = 123 },
|
||||
Record = CreateQueueRecord(),
|
||||
|
||||
+197
-76
@@ -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
|
||||
}
|
||||
+351
@@ -0,0 +1,351 @@
|
||||
using System.Text.Json;
|
||||
using Cleanuparr.Domain.Entities;
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.Jobs;
|
||||
using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs.Integration;
|
||||
|
||||
[Collection(IntegrationTestCollection.Name)]
|
||||
public class DownloadCleanerIntegrationTests : IDisposable
|
||||
{
|
||||
private readonly IntegrationTestFixture _fixture;
|
||||
|
||||
public DownloadCleanerIntegrationTests(IntegrationTestFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_fixture.Reset();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Striker.RecurringHashes.Clear();
|
||||
}
|
||||
|
||||
private DownloadCleaner CreateSut()
|
||||
{
|
||||
return new DownloadCleaner(
|
||||
Substitute.For<ILogger<DownloadCleaner>>(),
|
||||
_fixture.DataContext,
|
||||
_fixture.Cache,
|
||||
_fixture.MessageBus,
|
||||
_fixture.ArrClientFactory,
|
||||
_fixture.ArrQueueIterator,
|
||||
_fixture.DownloadServiceFactory,
|
||||
_fixture.EventPublisher,
|
||||
_fixture.TimeProvider,
|
||||
_fixture.HardLinkFileService);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a mock download service that uses the actual DB config (so seeding rules match by ID).
|
||||
/// </summary>
|
||||
private static IDownloadService CreateMockDownloadServiceWithDbConfig(DownloadClientConfig dbConfig)
|
||||
{
|
||||
var mock = Substitute.For<IDownloadService>();
|
||||
mock.ClientConfig.Returns(dbConfig);
|
||||
mock.LoginAsync().Returns(Task.CompletedTask);
|
||||
return mock;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ArrManagedDownloads_AreExcludedFromCleanup()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
var downloadClient = TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
TestDataContextFactory.AddSeedingRule(_fixture.DataContext);
|
||||
|
||||
string arrManagedHash = "arr_managed_hash_123";
|
||||
string orphanedHash = "orphaned_hash_456";
|
||||
|
||||
var arrManagedDownload = CreateMockTorrentItem(arrManagedHash, "Managed.Show.S01E01");
|
||||
var orphanedDownload = CreateMockTorrentItem(orphanedHash, "Orphaned.Movie.2024");
|
||||
|
||||
var mockDownloadService = CreateMockDownloadServiceWithDbConfig(downloadClient);
|
||||
mockDownloadService.GetSeedingDownloads()
|
||||
.Returns([arrManagedDownload, orphanedDownload]);
|
||||
|
||||
_fixture.DownloadServiceFactory.GetDownloadService(Arg.Any<DownloadClientConfig>())
|
||||
.Returns(mockDownloadService);
|
||||
|
||||
// Setup arr queue iterator to return the arr-managed hash
|
||||
var queueRecord = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = "Managed.Show.S01E01",
|
||||
Protocol = "torrent",
|
||||
DownloadId = arrManagedHash
|
||||
};
|
||||
_fixture.SetupArrQueueIterator(queueRecord);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act - advance time past the 10s delay
|
||||
var executeTask = sut.ExecuteAsync();
|
||||
_fixture.TimeProvider.Advance(TimeSpan.FromSeconds(15));
|
||||
await executeTask;
|
||||
|
||||
// Assert: Only the orphaned download should be passed to filter/clean
|
||||
mockDownloadService.Received().FilterDownloadsToBeCleanedAsync(
|
||||
Arg.Is<List<ITorrentItemWrapper>>(list =>
|
||||
list.Count == 1 && list[0].Hash == orphanedHash),
|
||||
Arg.Any<List<ISeedingRule>>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IgnoredDownloads_AreExcludedFromCleanup()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
var downloadClient = TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
TestDataContextFactory.AddSeedingRule(_fixture.DataContext);
|
||||
|
||||
// Add a download name to the ignored list
|
||||
var generalConfig = await _fixture.DataContext.GeneralConfigs.FirstAsync();
|
||||
generalConfig.IgnoredDownloads.Add("ignored_download");
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
|
||||
var ignoredDownload = CreateMockTorrentItem("some_hash", "ignored_download");
|
||||
var normalDownload = CreateMockTorrentItem("normal_hash", "Normal.Movie.2024");
|
||||
|
||||
var mockDownloadService = CreateMockDownloadServiceWithDbConfig(downloadClient);
|
||||
mockDownloadService.GetSeedingDownloads()
|
||||
.Returns([ignoredDownload, normalDownload]);
|
||||
|
||||
_fixture.DownloadServiceFactory.GetDownloadService(Arg.Any<DownloadClientConfig>())
|
||||
.Returns(mockDownloadService);
|
||||
|
||||
// No arr-managed downloads
|
||||
_fixture.ArrQueueIterator.Iterate(
|
||||
Arg.Any<IArrClient>(), Arg.Any<ArrInstance>(),
|
||||
Arg.Any<Func<IReadOnlyList<QueueRecord>, Task>>())
|
||||
.Returns(ci =>
|
||||
{
|
||||
var callback = ci.Arg<Func<IReadOnlyList<QueueRecord>, Task>>();
|
||||
return callback(Array.Empty<QueueRecord>());
|
||||
});
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
var executeTask = sut.ExecuteAsync();
|
||||
_fixture.TimeProvider.Advance(TimeSpan.FromSeconds(15));
|
||||
await executeTask;
|
||||
|
||||
// Assert: Only the non-ignored download should be processed
|
||||
mockDownloadService.Received().FilterDownloadsToBeCleanedAsync(
|
||||
Arg.Is<List<ITorrentItemWrapper>>(list =>
|
||||
list.Count == 1 && list[0].Hash == "normal_hash"),
|
||||
Arg.Any<List<ISeedingRule>>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoDownloadClients_ExitsEarly()
|
||||
{
|
||||
// Arrange: No download clients configured (default seed has none)
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert: No download service interactions, no events
|
||||
_fixture.DownloadServiceFactory.DidNotReceive()
|
||||
.GetDownloadService(Arg.Any<DownloadClientConfig>());
|
||||
var events = await _fixture.EventsContext.Events.ToListAsync();
|
||||
events.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CleanedDownload_PublishesDownloadCleanedEvent()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
var downloadClient = TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
TestDataContextFactory.AddSeedingRule(_fixture.DataContext);
|
||||
|
||||
var torrent = CreateMockTorrentItem("cleaned_hash_abc", "Completed.Movie.2024");
|
||||
|
||||
var mockDownloadService = CreateMockDownloadServiceWithDbConfig(downloadClient);
|
||||
mockDownloadService.GetSeedingDownloads().Returns([torrent]);
|
||||
mockDownloadService.FilterDownloadsToBeCleanedAsync(
|
||||
Arg.Any<List<ITorrentItemWrapper>>(), Arg.Any<List<ISeedingRule>>())
|
||||
.Returns(ci => ci.Arg<List<ITorrentItemWrapper>>());
|
||||
|
||||
// Configure CleanDownloadsAsync to simulate what real DownloadService does:
|
||||
// set ContextProvider keys and call real EventPublisher
|
||||
mockDownloadService.CleanDownloadsAsync(
|
||||
Arg.Any<List<ITorrentItemWrapper>>(), Arg.Any<List<ISeedingRule>>())
|
||||
.Returns(async ci =>
|
||||
{
|
||||
ContextProvider.Set(ContextProvider.Keys.ItemName, "Completed.Movie.2024");
|
||||
ContextProvider.Set(ContextProvider.Keys.Hash, "cleaned_hash_abc");
|
||||
ContextProvider.Set(ContextProvider.Keys.DownloadClientUrl, downloadClient.ExternalOrInternalUrl);
|
||||
ContextProvider.Set(ContextProvider.Keys.DownloadClientId, downloadClient.Id);
|
||||
ContextProvider.Set(ContextProvider.Keys.DownloadClientType, downloadClient.TypeName);
|
||||
ContextProvider.Set(ContextProvider.Keys.DownloadClientName, downloadClient.Name);
|
||||
await _fixture.EventPublisher.PublishDownloadCleaned(
|
||||
1.5, TimeSpan.FromHours(24), "completed", CleanReason.MaxRatioReached);
|
||||
});
|
||||
|
||||
_fixture.DownloadServiceFactory.GetDownloadService(Arg.Any<DownloadClientConfig>())
|
||||
.Returns(mockDownloadService);
|
||||
|
||||
// No arr-managed downloads
|
||||
_fixture.ArrQueueIterator.Iterate(
|
||||
Arg.Any<IArrClient>(), Arg.Any<ArrInstance>(),
|
||||
Arg.Any<Func<IReadOnlyList<QueueRecord>, Task>>())
|
||||
.Returns(ci =>
|
||||
{
|
||||
var callback = ci.Arg<Func<IReadOnlyList<QueueRecord>, Task>>();
|
||||
return callback(Array.Empty<QueueRecord>());
|
||||
});
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
var executeTask = sut.ExecuteAsync();
|
||||
_fixture.TimeProvider.Advance(TimeSpan.FromSeconds(15));
|
||||
await executeTask;
|
||||
|
||||
// Assert: Full DownloadCleaned event property verification
|
||||
var events = await _fixture.EventsContext.Events.ToListAsync();
|
||||
events.Count.ShouldBe(1);
|
||||
|
||||
var cleanedEvent = events.First(e => e.EventType == EventType.DownloadCleaned);
|
||||
cleanedEvent.Message.ShouldBe("Cleaned item from download client with reason: MaxRatioReached");
|
||||
cleanedEvent.Severity.ShouldBe(EventSeverity.Important);
|
||||
cleanedEvent.JobRunId.ShouldBe(_fixture.JobRunId);
|
||||
cleanedEvent.ArrInstanceId.ShouldBeNull();
|
||||
cleanedEvent.DownloadClientId.ShouldBe(downloadClient.Id);
|
||||
cleanedEvent.IsDryRun.ShouldBe(false);
|
||||
cleanedEvent.StrikeId.ShouldBeNull();
|
||||
cleanedEvent.TrackingId.ShouldBeNull();
|
||||
cleanedEvent.SearchStatus.ShouldBeNull();
|
||||
cleanedEvent.CompletedAt.ShouldBeNull();
|
||||
cleanedEvent.CycleId.ShouldBeNull();
|
||||
cleanedEvent.Data.ShouldNotBeNull();
|
||||
using (var data = JsonDocument.Parse(cleanedEvent.Data!))
|
||||
{
|
||||
data.RootElement.GetProperty("itemName").GetString().ShouldBe("Completed.Movie.2024");
|
||||
data.RootElement.GetProperty("hash").GetString().ShouldBe("cleaned_hash_abc");
|
||||
data.RootElement.GetProperty("categoryName").GetString().ShouldBe("completed");
|
||||
data.RootElement.GetProperty("ratio").GetDouble().ShouldBe(1.5);
|
||||
data.RootElement.GetProperty("seedingTime").GetDouble().ShouldBe(24.0);
|
||||
data.RootElement.GetProperty("reason").GetString().ShouldBe("MaxRatioReached");
|
||||
}
|
||||
|
||||
// Assert: Notification sent
|
||||
await _fixture.NotificationPublisher.Received(1)
|
||||
.NotifyDownloadCleaned(1.5, TimeSpan.FromHours(24), "completed", CleanReason.MaxRatioReached);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnlinkedDownload_PublishesCategoryChangedEvent()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
var downloadClient = TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
TestDataContextFactory.AddUnlinkedConfig(_fixture.DataContext,
|
||||
enabled: true, targetCategory: "unlinked", categories: ["completed"]);
|
||||
|
||||
var torrent = CreateMockTorrentItem("unlinked_hash_xyz", "NoLinks.Movie.2024", category: "completed");
|
||||
|
||||
var mockDownloadService = CreateMockDownloadServiceWithDbConfig(downloadClient);
|
||||
mockDownloadService.GetSeedingDownloads().Returns([torrent]);
|
||||
mockDownloadService.FilterDownloadsToChangeCategoryAsync(
|
||||
Arg.Any<List<ITorrentItemWrapper>>(), Arg.Any<UnlinkedConfig>())
|
||||
.Returns(ci => ci.Arg<List<ITorrentItemWrapper>>());
|
||||
|
||||
// Configure ChangeCategoryForNoHardLinksAsync to simulate what real DownloadService does
|
||||
mockDownloadService.ChangeCategoryForNoHardLinksAsync(
|
||||
Arg.Any<List<ITorrentItemWrapper>>(), Arg.Any<UnlinkedConfig>())
|
||||
.Returns(async ci =>
|
||||
{
|
||||
ContextProvider.Set(ContextProvider.Keys.ItemName, "NoLinks.Movie.2024");
|
||||
ContextProvider.Set(ContextProvider.Keys.Hash, "unlinked_hash_xyz");
|
||||
ContextProvider.Set(ContextProvider.Keys.DownloadClientUrl, downloadClient.ExternalOrInternalUrl);
|
||||
ContextProvider.Set(ContextProvider.Keys.DownloadClientId, downloadClient.Id);
|
||||
ContextProvider.Set(ContextProvider.Keys.DownloadClientType, downloadClient.TypeName);
|
||||
ContextProvider.Set(ContextProvider.Keys.DownloadClientName, downloadClient.Name);
|
||||
await _fixture.EventPublisher.PublishCategoryChanged("completed", "unlinked");
|
||||
});
|
||||
|
||||
_fixture.DownloadServiceFactory.GetDownloadService(Arg.Any<DownloadClientConfig>())
|
||||
.Returns(mockDownloadService);
|
||||
|
||||
// No arr-managed downloads
|
||||
_fixture.ArrQueueIterator.Iterate(
|
||||
Arg.Any<IArrClient>(), Arg.Any<ArrInstance>(),
|
||||
Arg.Any<Func<IReadOnlyList<QueueRecord>, Task>>())
|
||||
.Returns(ci =>
|
||||
{
|
||||
var callback = ci.Arg<Func<IReadOnlyList<QueueRecord>, Task>>();
|
||||
return callback(Array.Empty<QueueRecord>());
|
||||
});
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
var executeTask = sut.ExecuteAsync();
|
||||
_fixture.TimeProvider.Advance(TimeSpan.FromSeconds(15));
|
||||
await executeTask;
|
||||
|
||||
// Assert: Full CategoryChanged event property verification
|
||||
var events = await _fixture.EventsContext.Events.ToListAsync();
|
||||
events.Count.ShouldBe(1);
|
||||
|
||||
var categoryEvent = events.First(e => e.EventType == EventType.CategoryChanged);
|
||||
categoryEvent.Message.ShouldBe("Category changed from 'completed' to 'unlinked'");
|
||||
categoryEvent.Severity.ShouldBe(EventSeverity.Information);
|
||||
categoryEvent.JobRunId.ShouldBe(_fixture.JobRunId);
|
||||
categoryEvent.ArrInstanceId.ShouldBeNull();
|
||||
categoryEvent.DownloadClientId.ShouldBe(downloadClient.Id);
|
||||
categoryEvent.IsDryRun.ShouldBe(false);
|
||||
categoryEvent.StrikeId.ShouldBeNull();
|
||||
categoryEvent.TrackingId.ShouldBeNull();
|
||||
categoryEvent.SearchStatus.ShouldBeNull();
|
||||
categoryEvent.CompletedAt.ShouldBeNull();
|
||||
categoryEvent.CycleId.ShouldBeNull();
|
||||
categoryEvent.Data.ShouldNotBeNull();
|
||||
using (var data = JsonDocument.Parse(categoryEvent.Data!))
|
||||
{
|
||||
data.RootElement.GetProperty("itemName").GetString().ShouldBe("NoLinks.Movie.2024");
|
||||
data.RootElement.GetProperty("hash").GetString().ShouldBe("unlinked_hash_xyz");
|
||||
data.RootElement.GetProperty("oldCategory").GetString().ShouldBe("completed");
|
||||
data.RootElement.GetProperty("newCategory").GetString().ShouldBe("unlinked");
|
||||
data.RootElement.GetProperty("isTag").GetBoolean().ShouldBe(false);
|
||||
}
|
||||
|
||||
// Assert: Notification sent
|
||||
await _fixture.NotificationPublisher.Received(1)
|
||||
.NotifyCategoryChanged("completed", "unlinked", false);
|
||||
}
|
||||
|
||||
private static ITorrentItemWrapper CreateMockTorrentItem(string hash, string name, string? category = null)
|
||||
{
|
||||
var mock = Substitute.For<ITorrentItemWrapper>();
|
||||
mock.Hash.Returns(hash);
|
||||
mock.Name.Returns(name);
|
||||
mock.Category.Returns(category);
|
||||
mock.IsIgnored(Arg.Any<List<string>>()).Returns(ci =>
|
||||
{
|
||||
var ignoredList = ci.Arg<List<string>>();
|
||||
return ignoredList.Contains(name, StringComparer.InvariantCultureIgnoreCase);
|
||||
});
|
||||
return mock;
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs.Integration;
|
||||
|
||||
[CollectionDefinition(Name)]
|
||||
public class IntegrationTestCollection : ICollectionFixture<IntegrationTestFixture>
|
||||
{
|
||||
public const string Name = "JobHandlerIntegration";
|
||||
}
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
using Cleanuparr.Domain.Entities.Arr;
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Events.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover.Models;
|
||||
using Cleanuparr.Infrastructure.Features.Files;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications;
|
||||
using Cleanuparr.Infrastructure.Hubs;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.State;
|
||||
using MassTransit;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Shared fixture for integration tests that wires up real services (EventPublisher, QueueItemRemover)
|
||||
/// with NSubstitute mocks at external boundaries (Arr clients, download clients, notifications).
|
||||
/// </summary>
|
||||
public class IntegrationTestFixture : IDisposable
|
||||
{
|
||||
// Real services
|
||||
public DataContext DataContext { get; private set; }
|
||||
public EventsContext EventsContext { get; private set; }
|
||||
public MemoryCache Cache { get; private set; }
|
||||
public EventPublisher EventPublisher { get; private set; } = null!;
|
||||
public QueueItemRemover QueueItemRemover { get; private set; } = null!;
|
||||
public Striker Striker { get; private set; } = null!;
|
||||
public FakeTimeProvider TimeProvider { get; private set; }
|
||||
|
||||
// Mocks
|
||||
public IBus MessageBus { get; private set; }
|
||||
public IArrClientFactory ArrClientFactory { get; private set; }
|
||||
public IArrClient ArrClient { get; private set; }
|
||||
public IArrQueueIterator ArrQueueIterator { get; private set; }
|
||||
public IDownloadServiceFactory DownloadServiceFactory { get; private set; }
|
||||
public IBlocklistProvider BlocklistProvider { get; private set; }
|
||||
public IHardLinkFileService HardLinkFileService { get; private set; }
|
||||
public INotificationPublisher NotificationPublisher { get; private set; }
|
||||
public IDryRunInterceptor DryRunInterceptor { get; private set; }
|
||||
public IEventPublisher EventPublisherInterface { get; private set; } = null!;
|
||||
public IHubContext<AppHub> HubContext { get; private set; }
|
||||
|
||||
// State
|
||||
public Guid JobRunId { get; private set; }
|
||||
public List<object> CapturedMessages { get; } = [];
|
||||
|
||||
public IntegrationTestFixture()
|
||||
{
|
||||
DataContext = TestDataContextFactory.Create();
|
||||
EventsContext = TestEventsContextFactory.Create();
|
||||
Cache = new MemoryCache(new MemoryCacheOptions());
|
||||
TimeProvider = new FakeTimeProvider();
|
||||
|
||||
MessageBus = Substitute.For<IBus>();
|
||||
ArrClientFactory = Substitute.For<IArrClientFactory>();
|
||||
ArrClient = Substitute.For<IArrClient>();
|
||||
ArrQueueIterator = Substitute.For<IArrQueueIterator>();
|
||||
DownloadServiceFactory = Substitute.For<IDownloadServiceFactory>();
|
||||
BlocklistProvider = Substitute.For<IBlocklistProvider>();
|
||||
HardLinkFileService = Substitute.For<IHardLinkFileService>();
|
||||
NotificationPublisher = Substitute.For<INotificationPublisher>();
|
||||
DryRunInterceptor = Substitute.For<IDryRunInterceptor>();
|
||||
HubContext = CreateMockHubContext();
|
||||
|
||||
SetupDefaults();
|
||||
BuildRealServices();
|
||||
}
|
||||
|
||||
private void SetupDefaults()
|
||||
{
|
||||
// ArrClientFactory returns the shared ArrClient mock by default
|
||||
ArrClientFactory.GetClient(Arg.Any<InstanceType>(), Arg.Any<float>()).Returns(ArrClient);
|
||||
|
||||
// DryRunInterceptor returns false (not dry run) by default
|
||||
DryRunInterceptor.IsDryRunEnabled().Returns(false);
|
||||
DryRunInterceptor.InterceptAsync(Arg.Any<Delegate>(), Arg.Any<object[]>()).Returns(Task.CompletedTask);
|
||||
|
||||
// Capture messages published to IBus (generic Publish<T> overloads)
|
||||
MessageBus.Publish(Arg.Any<QueueItemRemoveRequest<SearchItem>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.CompletedTask)
|
||||
.AndDoes(ci => CapturedMessages.Add(ci[0]));
|
||||
MessageBus.Publish(Arg.Any<QueueItemRemoveRequest<SeriesSearchItem>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.CompletedTask)
|
||||
.AndDoes(ci => CapturedMessages.Add(ci[0]));
|
||||
|
||||
// Seed a JobRun so EventPublisher FK constraints are satisfied
|
||||
JobRunId = Guid.NewGuid();
|
||||
EventsContext.JobRuns.Add(new JobRun { Id = JobRunId, Type = JobType.QueueCleaner });
|
||||
EventsContext.SaveChanges();
|
||||
ContextProvider.SetJobRunId(JobRunId);
|
||||
}
|
||||
|
||||
private void BuildRealServices()
|
||||
{
|
||||
EventPublisher = new EventPublisher(
|
||||
EventsContext,
|
||||
HubContext,
|
||||
Substitute.For<ILogger<EventPublisher>>(),
|
||||
NotificationPublisher,
|
||||
DryRunInterceptor);
|
||||
|
||||
// Expose EventPublisher as both concrete and interface
|
||||
EventPublisherInterface = EventPublisher;
|
||||
|
||||
Striker = new Striker(
|
||||
Substitute.For<ILogger<Striker>>(),
|
||||
EventsContext,
|
||||
EventPublisher,
|
||||
DryRunInterceptor);
|
||||
|
||||
QueueItemRemover = new QueueItemRemover(
|
||||
Substitute.For<ILogger<QueueItemRemover>>(),
|
||||
Cache,
|
||||
ArrClientFactory,
|
||||
EventPublisher,
|
||||
EventsContext,
|
||||
DataContext);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets distinct remove requests from captured messages (NSubstitute may capture duplicates
|
||||
/// when both generic type setups match).
|
||||
/// </summary>
|
||||
public List<object> GetCapturedRemoveRequests()
|
||||
{
|
||||
return CapturedMessages
|
||||
.Where(m => m is QueueItemRemoveRequest<SearchItem> or QueueItemRemoveRequest<SeriesSearchItem>)
|
||||
.DistinctBy(m => m switch
|
||||
{
|
||||
QueueItemRemoveRequest<SearchItem> r => r.Record.DownloadId,
|
||||
QueueItemRemoveRequest<SeriesSearchItem> r => r.Record.DownloadId,
|
||||
_ => ""
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes all captured IBus messages through the real QueueItemRemover pipeline.
|
||||
/// This simulates what MassTransit consumers would do. Deduplicates to handle
|
||||
/// NSubstitute's generic type matching behavior.
|
||||
/// </summary>
|
||||
public async Task ProcessCapturedRemoveRequestsAsync()
|
||||
{
|
||||
foreach (var message in GetCapturedRemoveRequests())
|
||||
{
|
||||
switch (message)
|
||||
{
|
||||
case QueueItemRemoveRequest<SearchItem> request:
|
||||
await QueueItemRemover.RemoveQueueItemAsync(request);
|
||||
break;
|
||||
case QueueItemRemoveRequest<SeriesSearchItem> request:
|
||||
await QueueItemRemover.RemoveQueueItemAsync(request);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures the IArrQueueIterator to invoke the callback with the given records
|
||||
/// when Iterate is called for any instance.
|
||||
/// </summary>
|
||||
public void SetupArrQueueIterator(params QueueRecord[] records)
|
||||
{
|
||||
ArrQueueIterator.Iterate(
|
||||
Arg.Any<IArrClient>(),
|
||||
Arg.Any<ArrInstance>(),
|
||||
Arg.Any<Func<IReadOnlyList<QueueRecord>, Task>>())
|
||||
.Returns(ci =>
|
||||
{
|
||||
var callback = ci.Arg<Func<IReadOnlyList<QueueRecord>, Task>>();
|
||||
return callback(records);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a NSubstitute IDownloadService mock with default configuration.
|
||||
/// </summary>
|
||||
public IDownloadService CreateMockDownloadService(
|
||||
string clientName = "Test qBittorrent",
|
||||
DownloadClientTypeName typeName = DownloadClientTypeName.qBittorrent,
|
||||
DownloadClientType type = DownloadClientType.Torrent)
|
||||
{
|
||||
var mock = Substitute.For<IDownloadService>();
|
||||
mock.ClientConfig.Returns(new DownloadClientConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = clientName,
|
||||
TypeName = typeName,
|
||||
Type = type,
|
||||
Enabled = true,
|
||||
Host = new Uri("http://localhost:8080"),
|
||||
Username = "admin",
|
||||
Password = "admin"
|
||||
});
|
||||
mock.LoginAsync().Returns(Task.CompletedTask);
|
||||
return mock;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers mock download services with the factory, matched by their ClientConfig.
|
||||
/// </summary>
|
||||
public void SetupDownloadServices(params IDownloadService[] services)
|
||||
{
|
||||
foreach (var service in services)
|
||||
{
|
||||
DownloadServiceFactory.GetDownloadService(service.ClientConfig).Returns(service);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recreates DataContext, EventsContext, cache, and resets all mocks for a clean test.
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
{
|
||||
DataContext?.Dispose();
|
||||
EventsContext?.Dispose();
|
||||
Cache?.Dispose();
|
||||
|
||||
DataContext = TestDataContextFactory.Create();
|
||||
EventsContext = TestEventsContextFactory.Create();
|
||||
Cache = new MemoryCache(new MemoryCacheOptions());
|
||||
TimeProvider = new FakeTimeProvider();
|
||||
CapturedMessages.Clear();
|
||||
|
||||
// Recreate all NSubstitute mocks to clear received call state
|
||||
MessageBus = Substitute.For<IBus>();
|
||||
ArrClientFactory = Substitute.For<IArrClientFactory>();
|
||||
ArrClient = Substitute.For<IArrClient>();
|
||||
ArrQueueIterator = Substitute.For<IArrQueueIterator>();
|
||||
DownloadServiceFactory = Substitute.For<IDownloadServiceFactory>();
|
||||
BlocklistProvider = Substitute.For<IBlocklistProvider>();
|
||||
HardLinkFileService = Substitute.For<IHardLinkFileService>();
|
||||
NotificationPublisher = Substitute.For<INotificationPublisher>();
|
||||
DryRunInterceptor = Substitute.For<IDryRunInterceptor>();
|
||||
HubContext = CreateMockHubContext();
|
||||
|
||||
// Re-setup defaults and rebuild real services
|
||||
SetupDefaults();
|
||||
BuildRealServices();
|
||||
|
||||
// Clear static state
|
||||
Striker.RecurringHashes.Clear();
|
||||
}
|
||||
|
||||
private static IHubContext<AppHub> CreateMockHubContext()
|
||||
{
|
||||
var hubContext = Substitute.For<IHubContext<AppHub>>();
|
||||
var clients = Substitute.For<IHubClients>();
|
||||
var clientProxy = Substitute.For<IClientProxy>();
|
||||
clients.All.Returns(clientProxy);
|
||||
hubContext.Clients.Returns(clients);
|
||||
return hubContext;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
DataContext?.Dispose();
|
||||
EventsContext?.Dispose();
|
||||
Cache?.Dispose();
|
||||
Striker.RecurringHashes.Clear();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
+268
@@ -0,0 +1,268 @@
|
||||
using System.Text.Json;
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using MalwareBlockerJob = Cleanuparr.Infrastructure.Features.Jobs.MalwareBlocker;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs.Integration;
|
||||
|
||||
[Collection(IntegrationTestCollection.Name)]
|
||||
public class MalwareBlockerIntegrationTests : IDisposable
|
||||
{
|
||||
private readonly IntegrationTestFixture _fixture;
|
||||
|
||||
public MalwareBlockerIntegrationTests(IntegrationTestFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_fixture.Reset();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Striker.RecurringHashes.Clear();
|
||||
}
|
||||
|
||||
private MalwareBlockerJob CreateSut()
|
||||
{
|
||||
return new MalwareBlockerJob(
|
||||
Substitute.For<ILogger<MalwareBlockerJob>>(),
|
||||
_fixture.DataContext,
|
||||
_fixture.Cache,
|
||||
_fixture.MessageBus,
|
||||
_fixture.ArrClientFactory,
|
||||
_fixture.ArrQueueIterator,
|
||||
_fixture.DownloadServiceFactory,
|
||||
_fixture.BlocklistProvider,
|
||||
_fixture.EventPublisher);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MalwareDetected_RemovesFromArr_SavesEvent_SendsNotification()
|
||||
{
|
||||
// Arrange
|
||||
var instance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
|
||||
var downloadClient = TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
|
||||
// Enable Radarr blocklist
|
||||
var contentBlockerConfig = await _fixture.DataContext.ContentBlockerConfigs.FirstAsync();
|
||||
contentBlockerConfig.Radarr = new BlocklistSettings { Enabled = true };
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
|
||||
var record = CreateQueueRecord(movieId: 77);
|
||||
|
||||
_fixture.SetupArrQueueIterator(record);
|
||||
_fixture.ArrClient.IsRecordValid(Arg.Any<QueueRecord>()).Returns(true);
|
||||
_fixture.ArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService.BlockUnwantedFilesAsync(Arg.Any<string>(), Arg.Any<IReadOnlyList<string>>())
|
||||
.Returns(new BlockFilesResult
|
||||
{
|
||||
Found = true,
|
||||
ShouldRemove = true,
|
||||
DeleteReason = DeleteReason.AllFilesBlocked,
|
||||
IsPrivate = false
|
||||
});
|
||||
_fixture.DownloadServiceFactory.GetDownloadService(Arg.Any<Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig>())
|
||||
.Returns(mockDownloadService);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert Phase 1: Remove request was published
|
||||
var removeRequests = _fixture.GetCapturedRemoveRequests();
|
||||
removeRequests.Count.ShouldBe(1);
|
||||
|
||||
// Process through real QueueItemRemover
|
||||
_fixture.ArrClient.DeleteQueueItemAsync(
|
||||
Arg.Any<ArrInstance>(), Arg.Any<QueueRecord>(), Arg.Any<bool>(), Arg.Any<DeleteReason>())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
await _fixture.ProcessCapturedRemoveRequestsAsync();
|
||||
|
||||
// Assert: Arr client was told to delete with AllFilesBlocked reason
|
||||
await _fixture.ArrClient.Received(1).DeleteQueueItemAsync(
|
||||
Arg.Is<ArrInstance>(i => i.Id == instance.Id),
|
||||
Arg.Is<QueueRecord>(r => r.DownloadId == record.DownloadId),
|
||||
true,
|
||||
DeleteReason.AllFilesBlocked);
|
||||
|
||||
// Assert: Full event property verification
|
||||
var events = await _fixture.EventsContext.Events.ToListAsync();
|
||||
events.Count.ShouldBe(2);
|
||||
|
||||
// DownloadMarkedForDeletion event
|
||||
var markedEvent = events.First(e => e.EventType == EventType.DownloadMarkedForDeletion);
|
||||
markedEvent.Message.ShouldBe("Download marked for deletion");
|
||||
markedEvent.Severity.ShouldBe(EventSeverity.Important);
|
||||
markedEvent.JobRunId.ShouldBe(_fixture.JobRunId);
|
||||
markedEvent.ArrInstanceId.ShouldBe(instance.Id);
|
||||
markedEvent.DownloadClientId.ShouldBe(mockDownloadService.ClientConfig.Id);
|
||||
markedEvent.IsDryRun.ShouldBe(false);
|
||||
markedEvent.StrikeId.ShouldBeNull();
|
||||
markedEvent.TrackingId.ShouldBeNull();
|
||||
markedEvent.SearchStatus.ShouldBeNull();
|
||||
markedEvent.CompletedAt.ShouldBeNull();
|
||||
markedEvent.CycleId.ShouldBeNull();
|
||||
markedEvent.Data.ShouldNotBeNull();
|
||||
using (var markedData = JsonDocument.Parse(markedEvent.Data!))
|
||||
{
|
||||
markedData.RootElement.GetProperty("itemName").GetString().ShouldBe("Suspicious.Movie.2024.1080p");
|
||||
markedData.RootElement.GetProperty("hash").GetString().ShouldBe("MALWARE_HASH_789");
|
||||
}
|
||||
|
||||
// QueueItemDeleted event
|
||||
var deletedEvent = events.First(e => e.EventType == EventType.QueueItemDeleted);
|
||||
deletedEvent.Message.ShouldBe("Deleting item from queue with reason: AllFilesBlocked");
|
||||
deletedEvent.Severity.ShouldBe(EventSeverity.Important);
|
||||
deletedEvent.JobRunId.ShouldBe(_fixture.JobRunId);
|
||||
deletedEvent.ArrInstanceId.ShouldBe(instance.Id);
|
||||
deletedEvent.DownloadClientId.ShouldBe(mockDownloadService.ClientConfig.Id);
|
||||
deletedEvent.IsDryRun.ShouldBe(false);
|
||||
deletedEvent.StrikeId.ShouldBeNull();
|
||||
deletedEvent.TrackingId.ShouldBeNull();
|
||||
deletedEvent.SearchStatus.ShouldBeNull();
|
||||
deletedEvent.CompletedAt.ShouldBeNull();
|
||||
deletedEvent.CycleId.ShouldBeNull();
|
||||
deletedEvent.Data.ShouldNotBeNull();
|
||||
using (var deletedData = JsonDocument.Parse(deletedEvent.Data!))
|
||||
{
|
||||
deletedData.RootElement.GetProperty("itemName").GetString().ShouldBe("Suspicious.Movie.2024.1080p");
|
||||
deletedData.RootElement.GetProperty("hash").GetString().ShouldBe("MALWARE_HASH_789");
|
||||
deletedData.RootElement.GetProperty("removeFromClient").GetBoolean().ShouldBe(true);
|
||||
deletedData.RootElement.GetProperty("deleteReason").GetString().ShouldBe("AllFilesBlocked");
|
||||
}
|
||||
|
||||
// Assert: Notification sent
|
||||
await _fixture.NotificationPublisher.Received(1)
|
||||
.NotifyQueueItemDeleted(true, DeleteReason.AllFilesBlocked);
|
||||
|
||||
// Assert: Search queue item added for replacement search
|
||||
var searchItems = await _fixture.DataContext.SearchQueue.ToListAsync();
|
||||
searchItems.Count.ShouldBe(1);
|
||||
searchItems[0].ItemId.ShouldBe(77);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoBlocklistsEnabled_ExitsEarly_NothingProcessed()
|
||||
{
|
||||
// Arrange: Default seed data has all blocklists disabled
|
||||
TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert: Blocklists were never loaded, no processing happened
|
||||
await _fixture.BlocklistProvider.DidNotReceive().LoadBlocklistsAsync();
|
||||
_fixture.GetCapturedRemoveRequests().ShouldBeEmpty();
|
||||
var events = await _fixture.EventsContext.Events.ToListAsync();
|
||||
events.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PrivateTorrent_DeletePrivateFalse_RemoveFromClientIsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var instance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
|
||||
// Enable Radarr blocklist, but keep DeletePrivate = false (default)
|
||||
var contentBlockerConfig = await _fixture.DataContext.ContentBlockerConfigs.FirstAsync();
|
||||
contentBlockerConfig.Radarr = new BlocklistSettings { Enabled = true };
|
||||
contentBlockerConfig.DeletePrivate = false;
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
|
||||
var record = CreateQueueRecord(movieId: 88);
|
||||
|
||||
_fixture.SetupArrQueueIterator(record);
|
||||
_fixture.ArrClient.IsRecordValid(Arg.Any<QueueRecord>()).Returns(true);
|
||||
_fixture.ArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService.BlockUnwantedFilesAsync(Arg.Any<string>(), Arg.Any<IReadOnlyList<string>>())
|
||||
.Returns(new BlockFilesResult
|
||||
{
|
||||
Found = true,
|
||||
ShouldRemove = true,
|
||||
DeleteReason = DeleteReason.AllFilesBlocked,
|
||||
IsPrivate = true
|
||||
});
|
||||
_fixture.DownloadServiceFactory.GetDownloadService(Arg.Any<Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig>())
|
||||
.Returns(mockDownloadService);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert: Remove request has RemoveFromClient = false
|
||||
_fixture.GetCapturedRemoveRequests().Count.ShouldBe(1);
|
||||
|
||||
_fixture.ArrClient.DeleteQueueItemAsync(
|
||||
Arg.Any<ArrInstance>(), Arg.Any<QueueRecord>(), Arg.Any<bool>(), Arg.Any<DeleteReason>())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
await _fixture.ProcessCapturedRemoveRequestsAsync();
|
||||
|
||||
await _fixture.ArrClient.Received(1).DeleteQueueItemAsync(
|
||||
Arg.Any<ArrInstance>(),
|
||||
Arg.Any<QueueRecord>(),
|
||||
false,
|
||||
DeleteReason.AllFilesBlocked);
|
||||
|
||||
// Full event property verification
|
||||
var events = await _fixture.EventsContext.Events.ToListAsync();
|
||||
var deletedEvent = events.First(e => e.EventType == EventType.QueueItemDeleted);
|
||||
deletedEvent.Message.ShouldBe("Deleting item from queue with reason: AllFilesBlocked");
|
||||
deletedEvent.Severity.ShouldBe(EventSeverity.Important);
|
||||
deletedEvent.JobRunId.ShouldBe(_fixture.JobRunId);
|
||||
deletedEvent.ArrInstanceId.ShouldBe(instance.Id);
|
||||
deletedEvent.IsDryRun.ShouldBe(false);
|
||||
deletedEvent.StrikeId.ShouldBeNull();
|
||||
deletedEvent.SearchStatus.ShouldBeNull();
|
||||
deletedEvent.Data.ShouldNotBeNull();
|
||||
using (var data = JsonDocument.Parse(deletedEvent.Data!))
|
||||
{
|
||||
data.RootElement.GetProperty("itemName").GetString().ShouldBe("Suspicious.Movie.2024.1080p");
|
||||
data.RootElement.GetProperty("hash").GetString().ShouldBe("MALWARE_HASH_789");
|
||||
data.RootElement.GetProperty("removeFromClient").GetBoolean().ShouldBe(false);
|
||||
data.RootElement.GetProperty("deleteReason").GetString().ShouldBe("AllFilesBlocked");
|
||||
}
|
||||
|
||||
await _fixture.NotificationPublisher.Received(1)
|
||||
.NotifyQueueItemDeleted(false, DeleteReason.AllFilesBlocked);
|
||||
}
|
||||
|
||||
private static QueueRecord CreateQueueRecord(
|
||||
long movieId = 1,
|
||||
string downloadId = "MALWARE_HASH_789",
|
||||
string title = "Suspicious.Movie.2024.1080p")
|
||||
{
|
||||
return new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = title,
|
||||
Protocol = "torrent",
|
||||
DownloadId = downloadId,
|
||||
MovieId = movieId,
|
||||
Status = "warning",
|
||||
StatusMessages = []
|
||||
};
|
||||
}
|
||||
}
|
||||
+332
@@ -0,0 +1,332 @@
|
||||
using System.Text.Json;
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using QueueCleanerJob = Cleanuparr.Infrastructure.Features.Jobs.QueueCleaner;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs.Integration;
|
||||
|
||||
[Collection(IntegrationTestCollection.Name)]
|
||||
public class QueueCleanerIntegrationTests : IDisposable
|
||||
{
|
||||
private readonly IntegrationTestFixture _fixture;
|
||||
|
||||
public QueueCleanerIntegrationTests(IntegrationTestFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_fixture.Reset();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Striker.RecurringHashes.Clear();
|
||||
}
|
||||
|
||||
private QueueCleanerJob CreateSut()
|
||||
{
|
||||
return new QueueCleanerJob(
|
||||
Substitute.For<ILogger<QueueCleanerJob>>(),
|
||||
_fixture.DataContext,
|
||||
_fixture.Cache,
|
||||
_fixture.MessageBus,
|
||||
_fixture.ArrClientFactory,
|
||||
_fixture.ArrQueueIterator,
|
||||
_fixture.DownloadServiceFactory,
|
||||
_fixture.EventPublisher);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StalledTorrent_RemovesFromArr_SavesEvent_SendsNotification_AddsToSearchQueue()
|
||||
{
|
||||
// Arrange
|
||||
var instance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
|
||||
var downloadClient = TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
TestDataContextFactory.AddStallRule(_fixture.DataContext);
|
||||
|
||||
var record = CreateQueueRecord(movieId: 42);
|
||||
|
||||
_fixture.SetupArrQueueIterator(record);
|
||||
_fixture.ArrClient.IsRecordValid(Arg.Any<QueueRecord>()).Returns(true);
|
||||
_fixture.ArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService.ShouldRemoveFromArrQueueAsync(Arg.Any<string>(), Arg.Any<IReadOnlyList<string>>())
|
||||
.Returns(new DownloadCheckResult
|
||||
{
|
||||
ShouldRemove = true,
|
||||
Found = true,
|
||||
DeleteReason = DeleteReason.Stalled,
|
||||
IsPrivate = false
|
||||
});
|
||||
_fixture.DownloadServiceFactory.GetDownloadService(Arg.Any<Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig>())
|
||||
.Returns(mockDownloadService);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert Phase 1: IBus received a remove request
|
||||
var removeRequests = _fixture.GetCapturedRemoveRequests();
|
||||
removeRequests.Count.ShouldBe(1);
|
||||
|
||||
// Process the captured messages through the real QueueItemRemover pipeline
|
||||
_fixture.ArrClient.DeleteQueueItemAsync(
|
||||
Arg.Any<ArrInstance>(), Arg.Any<QueueRecord>(), Arg.Any<bool>(), Arg.Any<DeleteReason>())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
await _fixture.ProcessCapturedRemoveRequestsAsync();
|
||||
|
||||
// Assert Phase 2: Arr client was told to delete the item
|
||||
await _fixture.ArrClient.Received(1).DeleteQueueItemAsync(
|
||||
Arg.Is<ArrInstance>(i => i.Id == instance.Id),
|
||||
Arg.Is<QueueRecord>(r => r.DownloadId == record.DownloadId),
|
||||
true,
|
||||
DeleteReason.Stalled);
|
||||
|
||||
// Assert Phase 3: Events persisted with full property verification
|
||||
var events = await _fixture.EventsContext.Events.ToListAsync();
|
||||
events.Count.ShouldBe(2);
|
||||
|
||||
// DownloadMarkedForDeletion event
|
||||
var markedEvent = events.First(e => e.EventType == EventType.DownloadMarkedForDeletion);
|
||||
markedEvent.Message.ShouldBe("Download marked for deletion");
|
||||
markedEvent.Severity.ShouldBe(EventSeverity.Important);
|
||||
markedEvent.JobRunId.ShouldBe(_fixture.JobRunId);
|
||||
markedEvent.ArrInstanceId.ShouldBe(instance.Id);
|
||||
markedEvent.DownloadClientId.ShouldBe(mockDownloadService.ClientConfig.Id);
|
||||
markedEvent.IsDryRun.ShouldBe(false);
|
||||
markedEvent.StrikeId.ShouldBeNull();
|
||||
markedEvent.TrackingId.ShouldBeNull();
|
||||
markedEvent.SearchStatus.ShouldBeNull();
|
||||
markedEvent.CompletedAt.ShouldBeNull();
|
||||
markedEvent.CycleId.ShouldBeNull();
|
||||
markedEvent.Data.ShouldNotBeNull();
|
||||
using (var markedData = JsonDocument.Parse(markedEvent.Data!))
|
||||
{
|
||||
markedData.RootElement.GetProperty("itemName").GetString().ShouldBe("Test.Movie.2024.1080p");
|
||||
markedData.RootElement.GetProperty("hash").GetString().ShouldBe("ABC123DEF456");
|
||||
}
|
||||
|
||||
// QueueItemDeleted event
|
||||
var deletedEvent = events.First(e => e.EventType == EventType.QueueItemDeleted);
|
||||
deletedEvent.Message.ShouldBe("Deleting item from queue with reason: Stalled");
|
||||
deletedEvent.Severity.ShouldBe(EventSeverity.Important);
|
||||
deletedEvent.JobRunId.ShouldBe(_fixture.JobRunId);
|
||||
deletedEvent.ArrInstanceId.ShouldBe(instance.Id);
|
||||
deletedEvent.DownloadClientId.ShouldBe(mockDownloadService.ClientConfig.Id);
|
||||
deletedEvent.IsDryRun.ShouldBe(false);
|
||||
deletedEvent.StrikeId.ShouldBeNull();
|
||||
deletedEvent.TrackingId.ShouldBeNull();
|
||||
deletedEvent.SearchStatus.ShouldBeNull();
|
||||
deletedEvent.CompletedAt.ShouldBeNull();
|
||||
deletedEvent.CycleId.ShouldBeNull();
|
||||
deletedEvent.Data.ShouldNotBeNull();
|
||||
using (var deletedData = JsonDocument.Parse(deletedEvent.Data!))
|
||||
{
|
||||
deletedData.RootElement.GetProperty("itemName").GetString().ShouldBe("Test.Movie.2024.1080p");
|
||||
deletedData.RootElement.GetProperty("hash").GetString().ShouldBe("ABC123DEF456");
|
||||
deletedData.RootElement.GetProperty("removeFromClient").GetBoolean().ShouldBe(true);
|
||||
deletedData.RootElement.GetProperty("deleteReason").GetString().ShouldBe("Stalled");
|
||||
}
|
||||
|
||||
// Assert Phase 4: Notification was triggered
|
||||
await _fixture.NotificationPublisher.Received(1).NotifyQueueItemDeleted(true, DeleteReason.Stalled);
|
||||
|
||||
// Assert Phase 5: Replacement search item was added to SearchQueue
|
||||
var searchItems = await _fixture.DataContext.SearchQueue.ToListAsync();
|
||||
searchItems.Count.ShouldBe(1);
|
||||
searchItems[0].ArrInstanceId.ShouldBe(instance.Id);
|
||||
searchItems[0].ItemId.ShouldBe(42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FailedImport_RemovesWithFailedImportReason_SendsNotification()
|
||||
{
|
||||
// Arrange
|
||||
var instance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
|
||||
var downloadClient = TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
|
||||
var record = CreateQueueRecord(movieId: 99);
|
||||
|
||||
_fixture.SetupArrQueueIterator(record);
|
||||
_fixture.ArrClient.IsRecordValid(Arg.Any<QueueRecord>()).Returns(true);
|
||||
_fixture.ArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
|
||||
_fixture.ArrClient.ShouldRemoveFromQueue(
|
||||
Arg.Any<InstanceType>(), Arg.Any<QueueRecord>(), Arg.Any<bool>(), Arg.Any<short>())
|
||||
.Returns(true);
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService.ShouldRemoveFromArrQueueAsync(Arg.Any<string>(), Arg.Any<IReadOnlyList<string>>())
|
||||
.Returns(new DownloadCheckResult
|
||||
{
|
||||
ShouldRemove = false,
|
||||
Found = true,
|
||||
IsPrivate = false
|
||||
});
|
||||
_fixture.DownloadServiceFactory.GetDownloadService(Arg.Any<Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig>())
|
||||
.Returns(mockDownloadService);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert: failed import removal published
|
||||
_fixture.GetCapturedRemoveRequests().Count.ShouldBe(1);
|
||||
|
||||
_fixture.ArrClient.DeleteQueueItemAsync(
|
||||
Arg.Any<ArrInstance>(), Arg.Any<QueueRecord>(), Arg.Any<bool>(), Arg.Any<DeleteReason>())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
await _fixture.ProcessCapturedRemoveRequestsAsync();
|
||||
|
||||
// Full event property verification
|
||||
var events = await _fixture.EventsContext.Events.ToListAsync();
|
||||
var deletedEvent = events.First(e => e.EventType == EventType.QueueItemDeleted);
|
||||
deletedEvent.Message.ShouldBe("Deleting item from queue with reason: FailedImport");
|
||||
deletedEvent.Severity.ShouldBe(EventSeverity.Important);
|
||||
deletedEvent.JobRunId.ShouldBe(_fixture.JobRunId);
|
||||
deletedEvent.ArrInstanceId.ShouldBe(instance.Id);
|
||||
deletedEvent.DownloadClientId.ShouldBe(mockDownloadService.ClientConfig.Id);
|
||||
deletedEvent.IsDryRun.ShouldBe(false);
|
||||
deletedEvent.StrikeId.ShouldBeNull();
|
||||
deletedEvent.SearchStatus.ShouldBeNull();
|
||||
deletedEvent.Data.ShouldNotBeNull();
|
||||
using (var data = JsonDocument.Parse(deletedEvent.Data!))
|
||||
{
|
||||
data.RootElement.GetProperty("itemName").GetString().ShouldBe("Test.Movie.2024.1080p");
|
||||
data.RootElement.GetProperty("hash").GetString().ShouldBe("ABC123DEF456");
|
||||
data.RootElement.GetProperty("removeFromClient").GetBoolean().ShouldBe(true);
|
||||
data.RootElement.GetProperty("deleteReason").GetString().ShouldBe("FailedImport");
|
||||
}
|
||||
|
||||
// Notification with FailedImport reason
|
||||
await _fixture.NotificationPublisher.Received(1).NotifyQueueItemDeleted(true, DeleteReason.FailedImport);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IgnoredDownload_IsSkipped_NoEventsOrNotifications()
|
||||
{
|
||||
// Arrange
|
||||
var instance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
|
||||
var record = CreateQueueRecord(downloadId: "IGNORED_HASH_123");
|
||||
|
||||
// Add the download ID to the ignored list
|
||||
var generalConfig = await _fixture.DataContext.GeneralConfigs.FirstAsync();
|
||||
generalConfig.IgnoredDownloads.Add("IGNORED_HASH_123");
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
|
||||
_fixture.SetupArrQueueIterator(record);
|
||||
_fixture.ArrClient.IsRecordValid(Arg.Any<QueueRecord>()).Returns(true);
|
||||
_fixture.ArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert: No removal requests, no events, no notifications
|
||||
_fixture.GetCapturedRemoveRequests().ShouldBeEmpty();
|
||||
var events = await _fixture.EventsContext.Events.ToListAsync();
|
||||
events.ShouldBeEmpty();
|
||||
await _fixture.NotificationPublisher.DidNotReceive().NotifyQueueItemDeleted(Arg.Any<bool>(), Arg.Any<DeleteReason>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PrivateTorrent_RemoveFromClientIsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var instance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
TestDataContextFactory.AddStallRule(_fixture.DataContext);
|
||||
|
||||
var record = CreateQueueRecord(movieId: 50);
|
||||
|
||||
_fixture.SetupArrQueueIterator(record);
|
||||
_fixture.ArrClient.IsRecordValid(Arg.Any<QueueRecord>()).Returns(true);
|
||||
_fixture.ArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService.ShouldRemoveFromArrQueueAsync(Arg.Any<string>(), Arg.Any<IReadOnlyList<string>>())
|
||||
.Returns(new DownloadCheckResult
|
||||
{
|
||||
ShouldRemove = true,
|
||||
Found = true,
|
||||
DeleteReason = DeleteReason.Stalled,
|
||||
IsPrivate = true,
|
||||
DeleteFromClient = false
|
||||
});
|
||||
_fixture.DownloadServiceFactory.GetDownloadService(Arg.Any<Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig>())
|
||||
.Returns(mockDownloadService);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert: RemoveFromClient should be false for private torrents
|
||||
_fixture.GetCapturedRemoveRequests().Count.ShouldBe(1);
|
||||
|
||||
_fixture.ArrClient.DeleteQueueItemAsync(
|
||||
Arg.Any<ArrInstance>(), Arg.Any<QueueRecord>(), Arg.Any<bool>(), Arg.Any<DeleteReason>())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
await _fixture.ProcessCapturedRemoveRequestsAsync();
|
||||
|
||||
// The arr client should be told NOT to remove from the download client
|
||||
await _fixture.ArrClient.Received(1).DeleteQueueItemAsync(
|
||||
Arg.Any<ArrInstance>(),
|
||||
Arg.Any<QueueRecord>(),
|
||||
false,
|
||||
DeleteReason.Stalled);
|
||||
|
||||
// Full event property verification
|
||||
var events = await _fixture.EventsContext.Events.ToListAsync();
|
||||
|
||||
var deletedEvent = events.First(e => e.EventType == EventType.QueueItemDeleted);
|
||||
deletedEvent.Message.ShouldBe("Deleting item from queue with reason: Stalled");
|
||||
deletedEvent.Severity.ShouldBe(EventSeverity.Important);
|
||||
deletedEvent.JobRunId.ShouldBe(_fixture.JobRunId);
|
||||
deletedEvent.ArrInstanceId.ShouldBe(instance.Id);
|
||||
deletedEvent.IsDryRun.ShouldBe(false);
|
||||
deletedEvent.Data.ShouldNotBeNull();
|
||||
using (var data = JsonDocument.Parse(deletedEvent.Data!))
|
||||
{
|
||||
data.RootElement.GetProperty("itemName").GetString().ShouldBe("Test.Movie.2024.1080p");
|
||||
data.RootElement.GetProperty("hash").GetString().ShouldBe("ABC123DEF456");
|
||||
data.RootElement.GetProperty("removeFromClient").GetBoolean().ShouldBe(false);
|
||||
data.RootElement.GetProperty("deleteReason").GetString().ShouldBe("Stalled");
|
||||
}
|
||||
|
||||
await _fixture.NotificationPublisher.Received(1).NotifyQueueItemDeleted(false, DeleteReason.Stalled);
|
||||
}
|
||||
|
||||
private static QueueRecord CreateQueueRecord(
|
||||
long movieId = 1,
|
||||
string downloadId = "ABC123DEF456",
|
||||
string title = "Test.Movie.2024.1080p")
|
||||
{
|
||||
return new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = title,
|
||||
Protocol = "torrent",
|
||||
DownloadId = downloadId,
|
||||
MovieId = movieId,
|
||||
Status = "warning",
|
||||
StatusMessages = []
|
||||
};
|
||||
}
|
||||
}
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
using Cleanuparr.Domain.Entities.Arr;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
|
||||
using Cleanuparr.Persistence.Models.State;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using SeekerJob = Cleanuparr.Infrastructure.Features.Jobs.Seeker;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs.Integration;
|
||||
|
||||
[Collection(IntegrationTestCollection.Name)]
|
||||
public class SeekerIntegrationTests : IDisposable
|
||||
{
|
||||
private readonly IntegrationTestFixture _fixture;
|
||||
|
||||
public SeekerIntegrationTests(IntegrationTestFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_fixture.Reset();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Striker.RecurringHashes.Clear();
|
||||
}
|
||||
|
||||
private SeekerJob CreateSut()
|
||||
{
|
||||
var environment = Substitute.For<IHostingEnvironment>();
|
||||
environment.EnvironmentName.Returns("Development");
|
||||
|
||||
return new SeekerJob(
|
||||
Substitute.For<ILogger<SeekerJob>>(),
|
||||
_fixture.DataContext,
|
||||
Substitute.For<IRadarrClient>(),
|
||||
Substitute.For<ISonarrClient>(),
|
||||
_fixture.ArrClientFactory,
|
||||
_fixture.ArrQueueIterator,
|
||||
_fixture.EventPublisher,
|
||||
_fixture.DryRunInterceptor,
|
||||
environment,
|
||||
_fixture.TimeProvider,
|
||||
_fixture.HubContext);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReplacementSearch_TriggersSearch_SavesEvent_SendsNotification_DequesItem()
|
||||
{
|
||||
// Arrange
|
||||
var instance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
|
||||
|
||||
// Add a replacement search item to the queue
|
||||
_fixture.DataContext.SearchQueue.Add(new SearchQueueItem
|
||||
{
|
||||
ArrInstanceId = instance.Id,
|
||||
ItemId = 42,
|
||||
Title = "Test.Movie.2024.1080p",
|
||||
});
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
|
||||
// Mock arr client to return command IDs on search
|
||||
_fixture.ArrClient.SearchItemsAsync(Arg.Any<Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance>(), Arg.Any<HashSet<SearchItem>>())
|
||||
.Returns([100L]);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert: Full SearchTriggered event property verification
|
||||
var events = await _fixture.EventsContext.Events.ToListAsync();
|
||||
events.Count.ShouldBe(1);
|
||||
|
||||
var searchEvent = events.First(e => e.EventType == EventType.SearchTriggered);
|
||||
searchEvent.Message.ShouldBe("Search triggered for Test.Movie.2024.1080p");
|
||||
searchEvent.Severity.ShouldBe(EventSeverity.Information);
|
||||
searchEvent.JobRunId.ShouldBe(_fixture.JobRunId);
|
||||
searchEvent.ArrInstanceId.ShouldBe(instance.Id);
|
||||
searchEvent.DownloadClientId.ShouldBeNull();
|
||||
searchEvent.IsDryRun.ShouldBe(false);
|
||||
searchEvent.SearchStatus.ShouldBe(SearchCommandStatus.Pending);
|
||||
searchEvent.CompletedAt.ShouldBeNull();
|
||||
searchEvent.CycleId.ShouldBeNull();
|
||||
searchEvent.StrikeId.ShouldBeNull();
|
||||
searchEvent.TrackingId.ShouldBeNull();
|
||||
searchEvent.Data.ShouldBeNull();
|
||||
|
||||
// Assert: SearchEventData was created with correct properties
|
||||
var searchData = await _fixture.EventsContext.SearchEventData.ToListAsync();
|
||||
searchData.Count.ShouldBe(1);
|
||||
searchData[0].AppEventId.ShouldBe(searchEvent.Id);
|
||||
searchData[0].SearchType.ShouldBe(SeekerSearchType.Replacement);
|
||||
searchData[0].SearchReason.ShouldBe(SeekerSearchReason.Replacement);
|
||||
searchData[0].ItemTitle.ShouldBe("Test.Movie.2024.1080p");
|
||||
searchData[0].GrabbedItems.ShouldBeEmpty();
|
||||
|
||||
// Assert: Notification was sent
|
||||
await _fixture.NotificationPublisher.Received(1).NotifySearchTriggered(
|
||||
"Test.Movie.2024.1080p",
|
||||
SeekerSearchType.Replacement,
|
||||
SeekerSearchReason.Replacement);
|
||||
|
||||
// Assert: Item was dequeued from SearchQueue
|
||||
var remainingItems = await _fixture.DataContext.SearchQueue.CountAsync();
|
||||
remainingItems.ShouldBe(0);
|
||||
|
||||
// Assert: Command tracker was saved for monitoring
|
||||
var trackers = await _fixture.DataContext.SeekerCommandTrackers.ToListAsync();
|
||||
trackers.Count.ShouldBe(1);
|
||||
trackers[0].CommandId.ShouldBe(100L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchDisabled_DoesNothing()
|
||||
{
|
||||
// Arrange
|
||||
var seekerConfig = await _fixture.DataContext.SeekerConfigs.FirstAsync();
|
||||
seekerConfig.SearchEnabled = false;
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
|
||||
TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
|
||||
|
||||
// Add a search queue item that should NOT be processed
|
||||
var instance = await _fixture.DataContext.ArrInstances.FirstAsync();
|
||||
_fixture.DataContext.SearchQueue.Add(new SearchQueueItem
|
||||
{
|
||||
ArrInstanceId = instance.Id,
|
||||
ItemId = 99,
|
||||
Title = "Should.Not.Search",
|
||||
});
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert: No search triggered, no events, no notifications
|
||||
await _fixture.ArrClient.DidNotReceive().SearchItemsAsync(
|
||||
Arg.Any<Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance>(),
|
||||
Arg.Any<HashSet<SearchItem>>());
|
||||
|
||||
var events = await _fixture.EventsContext.Events.ToListAsync();
|
||||
events.ShouldBeEmpty();
|
||||
|
||||
var searchData = await _fixture.EventsContext.SearchEventData.ToListAsync();
|
||||
searchData.ShouldBeEmpty();
|
||||
|
||||
await _fixture.NotificationPublisher.DidNotReceive().NotifySearchTriggered(
|
||||
Arg.Any<string>(), Arg.Any<SeekerSearchType>(), Arg.Any<SeekerSearchReason>());
|
||||
|
||||
// Item should still be in the queue (not processed)
|
||||
var remainingItems = await _fixture.DataContext.SearchQueue.CountAsync();
|
||||
remainingItems.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DryRun_TriggersSearch_ButDoesNotDequeue()
|
||||
{
|
||||
// Arrange
|
||||
_fixture.DryRunInterceptor.IsDryRunEnabled().Returns(true);
|
||||
|
||||
var instance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
|
||||
|
||||
_fixture.DataContext.SearchQueue.Add(new SearchQueueItem
|
||||
{
|
||||
ArrInstanceId = instance.Id,
|
||||
ItemId = 55,
|
||||
Title = "DryRun.Movie.2024",
|
||||
});
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
|
||||
_fixture.ArrClient.SearchItemsAsync(
|
||||
Arg.Any<Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance>(),
|
||||
Arg.Any<HashSet<SearchItem>>())
|
||||
.Returns([200L]);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert: Full SearchTriggered event with IsDryRun = true
|
||||
var events = await _fixture.EventsContext.Events.ToListAsync();
|
||||
events.Count.ShouldBe(1);
|
||||
|
||||
var searchEvent = events.First(e => e.EventType == EventType.SearchTriggered);
|
||||
searchEvent.Message.ShouldBe("Search triggered for DryRun.Movie.2024");
|
||||
searchEvent.Severity.ShouldBe(EventSeverity.Information);
|
||||
searchEvent.JobRunId.ShouldBe(_fixture.JobRunId);
|
||||
searchEvent.ArrInstanceId.ShouldBe(instance.Id);
|
||||
searchEvent.IsDryRun.ShouldBe(true);
|
||||
searchEvent.SearchStatus.ShouldBe(SearchCommandStatus.Pending);
|
||||
searchEvent.CompletedAt.ShouldBeNull();
|
||||
searchEvent.CycleId.ShouldBeNull();
|
||||
searchEvent.StrikeId.ShouldBeNull();
|
||||
searchEvent.Data.ShouldBeNull();
|
||||
|
||||
// Assert: SearchEventData created
|
||||
var searchData = await _fixture.EventsContext.SearchEventData.ToListAsync();
|
||||
searchData.Count.ShouldBe(1);
|
||||
searchData[0].ItemTitle.ShouldBe("DryRun.Movie.2024");
|
||||
searchData[0].SearchType.ShouldBe(SeekerSearchType.Replacement);
|
||||
searchData[0].SearchReason.ShouldBe(SeekerSearchReason.Replacement);
|
||||
searchData[0].GrabbedItems.ShouldBeEmpty();
|
||||
|
||||
// Assert: Item remains in queue (dry run doesn't dequeue)
|
||||
var remainingItems = await _fixture.DataContext.SearchQueue.CountAsync();
|
||||
remainingItems.ShouldBe(1);
|
||||
|
||||
// Assert: No command tracker saved (dry run)
|
||||
var trackers = await _fixture.DataContext.SeekerCommandTrackers.ToListAsync();
|
||||
trackers.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
+503
@@ -0,0 +1,503 @@
|
||||
using System.Text.Json;
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs.Integration;
|
||||
|
||||
[Collection(IntegrationTestCollection.Name)]
|
||||
public class StrikerIntegrationTests : IDisposable
|
||||
{
|
||||
private readonly IntegrationTestFixture _fixture;
|
||||
private readonly Guid _arrInstanceId = Guid.NewGuid();
|
||||
|
||||
public StrikerIntegrationTests(IntegrationTestFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_fixture.Reset();
|
||||
SetupContext();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Striker.RecurringHashes.Clear();
|
||||
}
|
||||
|
||||
private void SetupContext()
|
||||
{
|
||||
ContextProvider.Set(ContextProvider.Keys.ArrInstanceId, _arrInstanceId);
|
||||
ContextProvider.Set(ContextProvider.Keys.ArrInstanceUrl, new Uri("http://radarr:7878"));
|
||||
ContextProvider.Set(nameof(InstanceType), (object)InstanceType.Radarr);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StalledStrike_PublishesEvent_CreatesStrike_SendsNotification()
|
||||
{
|
||||
// Act
|
||||
bool shouldRemove = await _fixture.Striker.StrikeAndCheckLimit(
|
||||
"STALLED_HASH_123", "Stalled.Movie.2024.1080p", maxStrikes: 3, StrikeType.Stalled);
|
||||
|
||||
// Assert: Should not remove (1 of 3 strikes)
|
||||
shouldRemove.ShouldBe(false);
|
||||
|
||||
// Assert: Strike record
|
||||
var strikes = await _fixture.EventsContext.Strikes.ToListAsync();
|
||||
strikes.Count.ShouldBe(1);
|
||||
strikes[0].Type.ShouldBe(StrikeType.Stalled);
|
||||
strikes[0].JobRunId.ShouldBe(_fixture.JobRunId);
|
||||
strikes[0].IsDryRun.ShouldBe(false);
|
||||
strikes[0].LastDownloadedBytes.ShouldBeNull();
|
||||
|
||||
// Assert: DownloadItem record
|
||||
var downloadItems = await _fixture.EventsContext.DownloadItems.ToListAsync();
|
||||
downloadItems.Count.ShouldBe(1);
|
||||
downloadItems[0].DownloadId.ShouldBe("STALLED_HASH_123");
|
||||
downloadItems[0].Title.ShouldBe("Stalled.Movie.2024.1080p");
|
||||
downloadItems[0].IsMarkedForRemoval.ShouldBe(false);
|
||||
downloadItems[0].IsRemoved.ShouldBe(false);
|
||||
downloadItems[0].IsReturning.ShouldBe(false);
|
||||
|
||||
// Assert: FK relationship
|
||||
strikes[0].DownloadItemId.ShouldBe(downloadItems[0].Id);
|
||||
|
||||
// Assert: Full AppEvent property verification
|
||||
var events = await _fixture.EventsContext.Events.ToListAsync();
|
||||
events.Count.ShouldBe(1);
|
||||
|
||||
var strikeEvent = events[0];
|
||||
strikeEvent.EventType.ShouldBe(EventType.StalledStrike);
|
||||
strikeEvent.Message.ShouldBe("Item 'Stalled.Movie.2024.1080p' has been struck 1 times for reason 'Stalled'");
|
||||
strikeEvent.Severity.ShouldBe(EventSeverity.Important);
|
||||
strikeEvent.JobRunId.ShouldBe(_fixture.JobRunId);
|
||||
strikeEvent.ArrInstanceId.ShouldBe(_arrInstanceId);
|
||||
strikeEvent.DownloadClientId.ShouldBeNull();
|
||||
strikeEvent.IsDryRun.ShouldBe(false);
|
||||
strikeEvent.StrikeId.ShouldBe(strikes[0].Id);
|
||||
strikeEvent.TrackingId.ShouldBeNull();
|
||||
strikeEvent.SearchStatus.ShouldBeNull();
|
||||
strikeEvent.CompletedAt.ShouldBeNull();
|
||||
strikeEvent.CycleId.ShouldBeNull();
|
||||
strikeEvent.Data.ShouldNotBeNull();
|
||||
using (var data = JsonDocument.Parse(strikeEvent.Data!))
|
||||
{
|
||||
data.RootElement.GetProperty("hash").GetString().ShouldBe("STALLED_HASH_123");
|
||||
data.RootElement.GetProperty("itemName").GetString().ShouldBe("Stalled.Movie.2024.1080p");
|
||||
data.RootElement.GetProperty("strikeCount").GetInt32().ShouldBe(1);
|
||||
data.RootElement.GetProperty("strikeType").GetString().ShouldBe("Stalled");
|
||||
}
|
||||
|
||||
// Assert: Notification sent
|
||||
await _fixture.NotificationPublisher.Received(1).NotifyStrike(StrikeType.Stalled, 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DownloadingMetadataStrike_PublishesEvent_CreatesStrike_SendsNotification()
|
||||
{
|
||||
// Act
|
||||
bool shouldRemove = await _fixture.Striker.StrikeAndCheckLimit(
|
||||
"METADATA_HASH_456", "Metadata.Movie.2024.1080p", maxStrikes: 3, StrikeType.DownloadingMetadata);
|
||||
|
||||
// Assert: Should not remove (1 of 3 strikes)
|
||||
shouldRemove.ShouldBe(false);
|
||||
|
||||
// Assert: Strike record
|
||||
var strikes = await _fixture.EventsContext.Strikes.ToListAsync();
|
||||
strikes.Count.ShouldBe(1);
|
||||
strikes[0].Type.ShouldBe(StrikeType.DownloadingMetadata);
|
||||
strikes[0].JobRunId.ShouldBe(_fixture.JobRunId);
|
||||
strikes[0].IsDryRun.ShouldBe(false);
|
||||
strikes[0].LastDownloadedBytes.ShouldBeNull();
|
||||
|
||||
// Assert: DownloadItem record
|
||||
var downloadItems = await _fixture.EventsContext.DownloadItems.ToListAsync();
|
||||
downloadItems.Count.ShouldBe(1);
|
||||
downloadItems[0].DownloadId.ShouldBe("METADATA_HASH_456");
|
||||
downloadItems[0].Title.ShouldBe("Metadata.Movie.2024.1080p");
|
||||
downloadItems[0].IsMarkedForRemoval.ShouldBe(false);
|
||||
|
||||
// Assert: FK relationship
|
||||
strikes[0].DownloadItemId.ShouldBe(downloadItems[0].Id);
|
||||
|
||||
// Assert: Full AppEvent property verification
|
||||
var events = await _fixture.EventsContext.Events.ToListAsync();
|
||||
events.Count.ShouldBe(1);
|
||||
|
||||
var strikeEvent = events[0];
|
||||
strikeEvent.EventType.ShouldBe(EventType.DownloadingMetadataStrike);
|
||||
strikeEvent.Message.ShouldBe("Item 'Metadata.Movie.2024.1080p' has been struck 1 times for reason 'DownloadingMetadata'");
|
||||
strikeEvent.Severity.ShouldBe(EventSeverity.Important);
|
||||
strikeEvent.JobRunId.ShouldBe(_fixture.JobRunId);
|
||||
strikeEvent.ArrInstanceId.ShouldBe(_arrInstanceId);
|
||||
strikeEvent.DownloadClientId.ShouldBeNull();
|
||||
strikeEvent.IsDryRun.ShouldBe(false);
|
||||
strikeEvent.StrikeId.ShouldBe(strikes[0].Id);
|
||||
strikeEvent.TrackingId.ShouldBeNull();
|
||||
strikeEvent.SearchStatus.ShouldBeNull();
|
||||
strikeEvent.CompletedAt.ShouldBeNull();
|
||||
strikeEvent.CycleId.ShouldBeNull();
|
||||
strikeEvent.Data.ShouldNotBeNull();
|
||||
using (var data = JsonDocument.Parse(strikeEvent.Data!))
|
||||
{
|
||||
data.RootElement.GetProperty("hash").GetString().ShouldBe("METADATA_HASH_456");
|
||||
data.RootElement.GetProperty("itemName").GetString().ShouldBe("Metadata.Movie.2024.1080p");
|
||||
data.RootElement.GetProperty("strikeCount").GetInt32().ShouldBe(1);
|
||||
data.RootElement.GetProperty("strikeType").GetString().ShouldBe("DownloadingMetadata");
|
||||
}
|
||||
|
||||
// Assert: Notification sent
|
||||
await _fixture.NotificationPublisher.Received(1).NotifyStrike(StrikeType.DownloadingMetadata, 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FailedImportStrike_PublishesEvent_WithStatusMessages_SendsNotification()
|
||||
{
|
||||
// Arrange: FailedImport reads QueueRecord from ContextProvider for StatusMessages
|
||||
var queueRecord = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = "FailedImport.Movie.2024.1080p",
|
||||
Protocol = "torrent",
|
||||
DownloadId = "FAILED_HASH_789",
|
||||
StatusMessages =
|
||||
[
|
||||
new TrackedDownloadStatusMessage
|
||||
{
|
||||
Title = "Import failed",
|
||||
Messages = ["File not found", "Path does not exist"]
|
||||
}
|
||||
]
|
||||
};
|
||||
ContextProvider.Set(nameof(QueueRecord), queueRecord);
|
||||
|
||||
// Act
|
||||
bool shouldRemove = await _fixture.Striker.StrikeAndCheckLimit(
|
||||
"FAILED_HASH_789", "FailedImport.Movie.2024.1080p", maxStrikes: 3, StrikeType.FailedImport);
|
||||
|
||||
// Assert: Should not remove (1 of 3 strikes)
|
||||
shouldRemove.ShouldBe(false);
|
||||
|
||||
// Assert: Strike record
|
||||
var strikes = await _fixture.EventsContext.Strikes.ToListAsync();
|
||||
strikes.Count.ShouldBe(1);
|
||||
strikes[0].Type.ShouldBe(StrikeType.FailedImport);
|
||||
strikes[0].JobRunId.ShouldBe(_fixture.JobRunId);
|
||||
strikes[0].IsDryRun.ShouldBe(false);
|
||||
strikes[0].LastDownloadedBytes.ShouldBeNull();
|
||||
|
||||
// Assert: DownloadItem record
|
||||
var downloadItems = await _fixture.EventsContext.DownloadItems.ToListAsync();
|
||||
downloadItems.Count.ShouldBe(1);
|
||||
downloadItems[0].DownloadId.ShouldBe("FAILED_HASH_789");
|
||||
downloadItems[0].Title.ShouldBe("FailedImport.Movie.2024.1080p");
|
||||
downloadItems[0].IsMarkedForRemoval.ShouldBe(false);
|
||||
|
||||
// Assert: FK relationship
|
||||
strikes[0].DownloadItemId.ShouldBe(downloadItems[0].Id);
|
||||
|
||||
// Assert: Full AppEvent property verification
|
||||
var events = await _fixture.EventsContext.Events.ToListAsync();
|
||||
events.Count.ShouldBe(1);
|
||||
|
||||
var strikeEvent = events[0];
|
||||
strikeEvent.EventType.ShouldBe(EventType.FailedImportStrike);
|
||||
strikeEvent.Message.ShouldBe("Item 'FailedImport.Movie.2024.1080p' has been struck 1 times for reason 'FailedImport'");
|
||||
strikeEvent.Severity.ShouldBe(EventSeverity.Important);
|
||||
strikeEvent.JobRunId.ShouldBe(_fixture.JobRunId);
|
||||
strikeEvent.ArrInstanceId.ShouldBe(_arrInstanceId);
|
||||
strikeEvent.DownloadClientId.ShouldBeNull();
|
||||
strikeEvent.IsDryRun.ShouldBe(false);
|
||||
strikeEvent.StrikeId.ShouldBe(strikes[0].Id);
|
||||
strikeEvent.TrackingId.ShouldBeNull();
|
||||
strikeEvent.SearchStatus.ShouldBeNull();
|
||||
strikeEvent.CompletedAt.ShouldBeNull();
|
||||
strikeEvent.CycleId.ShouldBeNull();
|
||||
strikeEvent.Data.ShouldNotBeNull();
|
||||
using (var data = JsonDocument.Parse(strikeEvent.Data!))
|
||||
{
|
||||
data.RootElement.GetProperty("hash").GetString().ShouldBe("FAILED_HASH_789");
|
||||
data.RootElement.GetProperty("itemName").GetString().ShouldBe("FailedImport.Movie.2024.1080p");
|
||||
data.RootElement.GetProperty("strikeCount").GetInt32().ShouldBe(1);
|
||||
data.RootElement.GetProperty("strikeType").GetString().ShouldBe("FailedImport");
|
||||
|
||||
// FailedImport-specific: includes failedImportReasons from QueueRecord.StatusMessages
|
||||
var reasons = data.RootElement.GetProperty("failedImportReasons");
|
||||
reasons.GetArrayLength().ShouldBe(1);
|
||||
reasons[0].GetProperty("Title").GetString().ShouldBe("Import failed");
|
||||
var messages = reasons[0].GetProperty("Messages");
|
||||
messages.GetArrayLength().ShouldBe(2);
|
||||
messages[0].GetString().ShouldBe("File not found");
|
||||
messages[1].GetString().ShouldBe("Path does not exist");
|
||||
}
|
||||
|
||||
// Assert: Notification sent
|
||||
await _fixture.NotificationPublisher.Received(1).NotifyStrike(StrikeType.FailedImport, 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SlowSpeedStrike_PublishesEvent_CreatesStrike_SendsNotification()
|
||||
{
|
||||
// Act
|
||||
bool shouldRemove = await _fixture.Striker.StrikeAndCheckLimit(
|
||||
"SLOW_SPEED_HASH_111", "SlowSpeed.Movie.2024.1080p", maxStrikes: 3, StrikeType.SlowSpeed);
|
||||
|
||||
// Assert: Should not remove (1 of 3 strikes)
|
||||
shouldRemove.ShouldBe(false);
|
||||
|
||||
// Assert: Strike record
|
||||
var strikes = await _fixture.EventsContext.Strikes.ToListAsync();
|
||||
strikes.Count.ShouldBe(1);
|
||||
strikes[0].Type.ShouldBe(StrikeType.SlowSpeed);
|
||||
strikes[0].JobRunId.ShouldBe(_fixture.JobRunId);
|
||||
strikes[0].IsDryRun.ShouldBe(false);
|
||||
strikes[0].LastDownloadedBytes.ShouldBeNull();
|
||||
|
||||
// Assert: DownloadItem record
|
||||
var downloadItems = await _fixture.EventsContext.DownloadItems.ToListAsync();
|
||||
downloadItems.Count.ShouldBe(1);
|
||||
downloadItems[0].DownloadId.ShouldBe("SLOW_SPEED_HASH_111");
|
||||
downloadItems[0].Title.ShouldBe("SlowSpeed.Movie.2024.1080p");
|
||||
downloadItems[0].IsMarkedForRemoval.ShouldBe(false);
|
||||
|
||||
// Assert: FK relationship
|
||||
strikes[0].DownloadItemId.ShouldBe(downloadItems[0].Id);
|
||||
|
||||
// Assert: Full AppEvent property verification
|
||||
var events = await _fixture.EventsContext.Events.ToListAsync();
|
||||
events.Count.ShouldBe(1);
|
||||
|
||||
var strikeEvent = events[0];
|
||||
strikeEvent.EventType.ShouldBe(EventType.SlowSpeedStrike);
|
||||
strikeEvent.Message.ShouldBe("Item 'SlowSpeed.Movie.2024.1080p' has been struck 1 times for reason 'SlowSpeed'");
|
||||
strikeEvent.Severity.ShouldBe(EventSeverity.Important);
|
||||
strikeEvent.JobRunId.ShouldBe(_fixture.JobRunId);
|
||||
strikeEvent.ArrInstanceId.ShouldBe(_arrInstanceId);
|
||||
strikeEvent.DownloadClientId.ShouldBeNull();
|
||||
strikeEvent.IsDryRun.ShouldBe(false);
|
||||
strikeEvent.StrikeId.ShouldBe(strikes[0].Id);
|
||||
strikeEvent.TrackingId.ShouldBeNull();
|
||||
strikeEvent.SearchStatus.ShouldBeNull();
|
||||
strikeEvent.CompletedAt.ShouldBeNull();
|
||||
strikeEvent.CycleId.ShouldBeNull();
|
||||
strikeEvent.Data.ShouldNotBeNull();
|
||||
using (var data = JsonDocument.Parse(strikeEvent.Data!))
|
||||
{
|
||||
data.RootElement.GetProperty("hash").GetString().ShouldBe("SLOW_SPEED_HASH_111");
|
||||
data.RootElement.GetProperty("itemName").GetString().ShouldBe("SlowSpeed.Movie.2024.1080p");
|
||||
data.RootElement.GetProperty("strikeCount").GetInt32().ShouldBe(1);
|
||||
data.RootElement.GetProperty("strikeType").GetString().ShouldBe("SlowSpeed");
|
||||
}
|
||||
|
||||
// Assert: Notification sent
|
||||
await _fixture.NotificationPublisher.Received(1).NotifyStrike(StrikeType.SlowSpeed, 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SlowTimeStrike_PublishesEvent_CreatesStrike_SendsNotification()
|
||||
{
|
||||
// Act
|
||||
bool shouldRemove = await _fixture.Striker.StrikeAndCheckLimit(
|
||||
"SLOW_TIME_HASH_222", "SlowTime.Movie.2024.1080p", maxStrikes: 3, StrikeType.SlowTime);
|
||||
|
||||
// Assert: Should not remove (1 of 3 strikes)
|
||||
shouldRemove.ShouldBe(false);
|
||||
|
||||
// Assert: Strike record
|
||||
var strikes = await _fixture.EventsContext.Strikes.ToListAsync();
|
||||
strikes.Count.ShouldBe(1);
|
||||
strikes[0].Type.ShouldBe(StrikeType.SlowTime);
|
||||
strikes[0].JobRunId.ShouldBe(_fixture.JobRunId);
|
||||
strikes[0].IsDryRun.ShouldBe(false);
|
||||
strikes[0].LastDownloadedBytes.ShouldBeNull();
|
||||
|
||||
// Assert: DownloadItem record
|
||||
var downloadItems = await _fixture.EventsContext.DownloadItems.ToListAsync();
|
||||
downloadItems.Count.ShouldBe(1);
|
||||
downloadItems[0].DownloadId.ShouldBe("SLOW_TIME_HASH_222");
|
||||
downloadItems[0].Title.ShouldBe("SlowTime.Movie.2024.1080p");
|
||||
downloadItems[0].IsMarkedForRemoval.ShouldBe(false);
|
||||
|
||||
// Assert: FK relationship
|
||||
strikes[0].DownloadItemId.ShouldBe(downloadItems[0].Id);
|
||||
|
||||
// Assert: Full AppEvent property verification
|
||||
var events = await _fixture.EventsContext.Events.ToListAsync();
|
||||
events.Count.ShouldBe(1);
|
||||
|
||||
var strikeEvent = events[0];
|
||||
strikeEvent.EventType.ShouldBe(EventType.SlowTimeStrike);
|
||||
strikeEvent.Message.ShouldBe("Item 'SlowTime.Movie.2024.1080p' has been struck 1 times for reason 'SlowTime'");
|
||||
strikeEvent.Severity.ShouldBe(EventSeverity.Important);
|
||||
strikeEvent.JobRunId.ShouldBe(_fixture.JobRunId);
|
||||
strikeEvent.ArrInstanceId.ShouldBe(_arrInstanceId);
|
||||
strikeEvent.DownloadClientId.ShouldBeNull();
|
||||
strikeEvent.IsDryRun.ShouldBe(false);
|
||||
strikeEvent.StrikeId.ShouldBe(strikes[0].Id);
|
||||
strikeEvent.TrackingId.ShouldBeNull();
|
||||
strikeEvent.SearchStatus.ShouldBeNull();
|
||||
strikeEvent.CompletedAt.ShouldBeNull();
|
||||
strikeEvent.CycleId.ShouldBeNull();
|
||||
strikeEvent.Data.ShouldNotBeNull();
|
||||
using (var data = JsonDocument.Parse(strikeEvent.Data!))
|
||||
{
|
||||
data.RootElement.GetProperty("hash").GetString().ShouldBe("SLOW_TIME_HASH_222");
|
||||
data.RootElement.GetProperty("itemName").GetString().ShouldBe("SlowTime.Movie.2024.1080p");
|
||||
data.RootElement.GetProperty("strikeCount").GetInt32().ShouldBe(1);
|
||||
data.RootElement.GetProperty("strikeType").GetString().ShouldBe("SlowTime");
|
||||
}
|
||||
|
||||
// Assert: Notification sent
|
||||
await _fixture.NotificationPublisher.Received(1).NotifyStrike(StrikeType.SlowTime, 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StrikeReachingLimit_MarksDownloadItemForRemoval()
|
||||
{
|
||||
// Act: 3 strikes with maxStrikes=3
|
||||
bool result1 = await _fixture.Striker.StrikeAndCheckLimit(
|
||||
"LIMIT_HASH_333", "Limit.Movie.2024", maxStrikes: 3, StrikeType.Stalled);
|
||||
bool result2 = await _fixture.Striker.StrikeAndCheckLimit(
|
||||
"LIMIT_HASH_333", "Limit.Movie.2024", maxStrikes: 3, StrikeType.Stalled);
|
||||
bool result3 = await _fixture.Striker.StrikeAndCheckLimit(
|
||||
"LIMIT_HASH_333", "Limit.Movie.2024", maxStrikes: 3, StrikeType.Stalled);
|
||||
|
||||
// Assert: First two return false, third returns true
|
||||
result1.ShouldBe(false);
|
||||
result2.ShouldBe(false);
|
||||
result3.ShouldBe(true);
|
||||
|
||||
// Assert: 3 strikes created for same DownloadItem
|
||||
var strikes = await _fixture.EventsContext.Strikes.ToListAsync();
|
||||
strikes.Count.ShouldBe(3);
|
||||
strikes.ShouldAllBe(s => s.Type == StrikeType.Stalled);
|
||||
|
||||
// Assert: Single DownloadItem marked for removal
|
||||
var downloadItems = await _fixture.EventsContext.DownloadItems.ToListAsync();
|
||||
downloadItems.Count.ShouldBe(1);
|
||||
downloadItems[0].IsMarkedForRemoval.ShouldBe(true);
|
||||
|
||||
// Assert: 3 events with incrementing strike counts
|
||||
var events = await _fixture.EventsContext.Events.OrderBy(e => e.Timestamp).ToListAsync();
|
||||
events.Count.ShouldBe(3);
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
events[i].EventType.ShouldBe(EventType.StalledStrike);
|
||||
using var data = JsonDocument.Parse(events[i].Data!);
|
||||
data.RootElement.GetProperty("strikeCount").GetInt32().ShouldBe(i + 1);
|
||||
}
|
||||
|
||||
// Assert: 3 notifications with incrementing counts
|
||||
await _fixture.NotificationPublisher.Received(1).NotifyStrike(StrikeType.Stalled, 1);
|
||||
await _fixture.NotificationPublisher.Received(1).NotifyStrike(StrikeType.Stalled, 2);
|
||||
await _fixture.NotificationPublisher.Received(1).NotifyStrike(StrikeType.Stalled, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DryRunStrike_PublishesEventWithDryRunFlag()
|
||||
{
|
||||
// Arrange
|
||||
_fixture.DryRunInterceptor.IsDryRunEnabled().Returns(true);
|
||||
|
||||
// Act
|
||||
bool shouldRemove = await _fixture.Striker.StrikeAndCheckLimit(
|
||||
"DRYRUN_HASH_444", "DryRun.Movie.2024", maxStrikes: 1, StrikeType.Stalled);
|
||||
|
||||
// Assert: Should remove (at limit)
|
||||
shouldRemove.ShouldBe(true);
|
||||
|
||||
// Assert: Strike has IsDryRun = true
|
||||
var strikes = await _fixture.EventsContext.Strikes.ToListAsync();
|
||||
strikes.Count.ShouldBe(1);
|
||||
strikes[0].IsDryRun.ShouldBe(true);
|
||||
|
||||
// Assert: AppEvent has IsDryRun = true
|
||||
var events = await _fixture.EventsContext.Events.ToListAsync();
|
||||
events.Count.ShouldBe(1);
|
||||
events[0].IsDryRun.ShouldBe(true);
|
||||
|
||||
// Assert: DownloadItem marked for removal (striker still marks regardless of dry run)
|
||||
var downloadItems = await _fixture.EventsContext.DownloadItems.ToListAsync();
|
||||
downloadItems[0].IsMarkedForRemoval.ShouldBe(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecurringItem_ExceedsMaxStrikes_PublishesManualEvent()
|
||||
{
|
||||
// Act: 3 strikes with maxStrikes=2 (strike 3 exceeds limit)
|
||||
await _fixture.Striker.StrikeAndCheckLimit(
|
||||
"RECURRING_HASH_555", "Recurring.Movie.2024", maxStrikes: 2, StrikeType.Stalled);
|
||||
await _fixture.Striker.StrikeAndCheckLimit(
|
||||
"RECURRING_HASH_555", "Recurring.Movie.2024", maxStrikes: 2, StrikeType.Stalled);
|
||||
await _fixture.Striker.StrikeAndCheckLimit(
|
||||
"RECURRING_HASH_555", "Recurring.Movie.2024", maxStrikes: 2, StrikeType.Stalled);
|
||||
|
||||
// Assert: Hash added to RecurringHashes (lowercased)
|
||||
Striker.RecurringHashes.ContainsKey("recurring_hash_555").ShouldBe(true);
|
||||
|
||||
// Assert: 3 strike events
|
||||
var events = await _fixture.EventsContext.Events.ToListAsync();
|
||||
events.Count.ShouldBe(3);
|
||||
events.ShouldAllBe(e => e.EventType == EventType.StalledStrike);
|
||||
|
||||
// Assert: ManualEvent published for recurring item
|
||||
var manualEvents = await _fixture.EventsContext.ManualEvents.ToListAsync();
|
||||
manualEvents.Count.ShouldBe(1);
|
||||
manualEvents[0].Message.ShouldContain("Download keeps coming back after deletion");
|
||||
manualEvents[0].Severity.ShouldBe(EventSeverity.Important);
|
||||
manualEvents[0].JobRunId.ShouldBe(_fixture.JobRunId);
|
||||
manualEvents[0].Data.ShouldNotBeNull();
|
||||
using (var data = JsonDocument.Parse(manualEvents[0].Data!))
|
||||
{
|
||||
data.RootElement.GetProperty("itemName").GetString().ShouldBe("Recurring.Movie.2024");
|
||||
data.RootElement.GetProperty("hash").GetString().ShouldBe("RECURRING_HASH_555");
|
||||
data.RootElement.GetProperty("strikeCount").GetInt32().ShouldBe(3);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StrikeWithLastDownloadedBytes_StoresOnStrikeRecord()
|
||||
{
|
||||
// Act
|
||||
await _fixture.Striker.StrikeAndCheckLimit(
|
||||
"BYTES_HASH_666", "Bytes.Movie.2024", maxStrikes: 3, StrikeType.SlowSpeed, lastDownloadedBytes: 1024000);
|
||||
|
||||
// Assert: LastDownloadedBytes persisted
|
||||
var strikes = await _fixture.EventsContext.Strikes.ToListAsync();
|
||||
strikes.Count.ShouldBe(1);
|
||||
strikes[0].LastDownloadedBytes.ShouldBe(1024000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FailedImportStrike_EmptyStatusMessages_PublishesEmptyReasons()
|
||||
{
|
||||
// Arrange: QueueRecord with empty StatusMessages
|
||||
var queueRecord = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = "EmptyReasons.Movie.2024",
|
||||
Protocol = "torrent",
|
||||
DownloadId = "EMPTY_REASONS_HASH_777",
|
||||
StatusMessages = []
|
||||
};
|
||||
ContextProvider.Set(nameof(QueueRecord), queueRecord);
|
||||
|
||||
// Act
|
||||
await _fixture.Striker.StrikeAndCheckLimit(
|
||||
"EMPTY_REASONS_HASH_777", "EmptyReasons.Movie.2024", maxStrikes: 3, StrikeType.FailedImport);
|
||||
|
||||
// Assert: failedImportReasons is an empty array
|
||||
var events = await _fixture.EventsContext.Events.ToListAsync();
|
||||
events.Count.ShouldBe(1);
|
||||
events[0].EventType.ShouldBe(EventType.FailedImportStrike);
|
||||
events[0].Data.ShouldNotBeNull();
|
||||
using (var data = JsonDocument.Parse(events[0].Data!))
|
||||
{
|
||||
var reasons = data.RootElement.GetProperty("failedImportReasons");
|
||||
reasons.GetArrayLength().ShouldBe(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ using Cleanuparr.Infrastructure.Helpers;
|
||||
using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Data.Models.Arr;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
@@ -1049,7 +1048,7 @@ public class QueueCleanerTests : IDisposable
|
||||
_fixture.MessageBus.Verify(
|
||||
x => x.Publish(
|
||||
It.Is<QueueItemRemoveRequest<SearchItem>>(r =>
|
||||
r.InstanceType == InstanceType.Radarr &&
|
||||
r.Instance.ArrConfig.Type == InstanceType.Radarr &&
|
||||
r.SearchItem.Id == 42 &&
|
||||
r.DeleteReason == DeleteReason.Stalled
|
||||
),
|
||||
@@ -1122,7 +1121,7 @@ public class QueueCleanerTests : IDisposable
|
||||
_fixture.MessageBus.Verify(
|
||||
x => x.Publish(
|
||||
It.Is<QueueItemRemoveRequest<SearchItem>>(r =>
|
||||
r.InstanceType == InstanceType.Lidarr &&
|
||||
r.Instance.ArrConfig.Type == InstanceType.Lidarr &&
|
||||
r.SearchItem.Id == 123 &&
|
||||
r.DeleteReason == DeleteReason.SlowSpeed
|
||||
),
|
||||
@@ -1195,7 +1194,7 @@ public class QueueCleanerTests : IDisposable
|
||||
_fixture.MessageBus.Verify(
|
||||
x => x.Publish(
|
||||
It.Is<QueueItemRemoveRequest<SearchItem>>(r =>
|
||||
r.InstanceType == InstanceType.Readarr &&
|
||||
r.Instance.ArrConfig.Type == InstanceType.Readarr &&
|
||||
r.SearchItem.Id == 456 &&
|
||||
r.DeleteReason == DeleteReason.Stalled
|
||||
),
|
||||
@@ -1269,7 +1268,7 @@ public class QueueCleanerTests : IDisposable
|
||||
_fixture.MessageBus.Verify(
|
||||
x => x.Publish(
|
||||
It.Is<QueueItemRemoveRequest<SeriesSearchItem>>(r =>
|
||||
r.InstanceType == InstanceType.Whisparr &&
|
||||
r.Instance.ArrConfig.Type == InstanceType.Whisparr &&
|
||||
r.SearchItem.Id == 100 && // EpisodeId
|
||||
r.SearchItem.SeriesId == 10 &&
|
||||
r.SearchItem.SearchType == SeriesSearchType.Episode &&
|
||||
@@ -1344,7 +1343,7 @@ public class QueueCleanerTests : IDisposable
|
||||
_fixture.MessageBus.Verify(
|
||||
x => x.Publish(
|
||||
It.Is<QueueItemRemoveRequest<SearchItem>>(r =>
|
||||
r.InstanceType == InstanceType.Whisparr &&
|
||||
r.Instance.ArrConfig.Type == InstanceType.Whisparr &&
|
||||
r.SearchItem.Id == 42 && // MovieId
|
||||
r.DeleteReason == DeleteReason.Stalled
|
||||
),
|
||||
@@ -1431,7 +1430,7 @@ public class QueueCleanerTests : IDisposable
|
||||
_fixture.MessageBus.Verify(
|
||||
x => x.Publish(
|
||||
It.Is<QueueItemRemoveRequest<SeriesSearchItem>>(r =>
|
||||
r.InstanceType == InstanceType.Whisparr &&
|
||||
r.Instance.ArrConfig.Type == InstanceType.Whisparr &&
|
||||
r.SearchItem.Id == 3 && // SeasonNumber
|
||||
r.SearchItem.SeriesId == 10 &&
|
||||
r.SearchItem.SearchType == SeriesSearchType.Season &&
|
||||
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
using Cleanuparr.Domain.Entities.Arr;
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Events.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Jobs;
|
||||
using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.State;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs;
|
||||
|
||||
public class SeekerCommandMonitorTests : IAsyncDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly IArrClient _arrClient;
|
||||
private readonly IEventPublisher _eventPublisher;
|
||||
private readonly SeekerCommandMonitor _sut;
|
||||
private readonly CancellationTokenSource _cts;
|
||||
|
||||
public SeekerCommandMonitorTests()
|
||||
{
|
||||
_dataContext = TestDataContextFactory.Create();
|
||||
_timeProvider = new FakeTimeProvider();
|
||||
_arrClient = Substitute.For<IArrClient>();
|
||||
_eventPublisher = Substitute.For<IEventPublisher>();
|
||||
_cts = new CancellationTokenSource();
|
||||
|
||||
var logger = Substitute.For<ILogger<SeekerCommandMonitor>>();
|
||||
var arrClientFactory = Substitute.For<IArrClientFactory>();
|
||||
|
||||
var serviceProvider = Substitute.For<IServiceProvider>();
|
||||
serviceProvider.GetService(typeof(DataContext)).Returns(_dataContext);
|
||||
serviceProvider.GetService(typeof(IArrClientFactory)).Returns(arrClientFactory);
|
||||
serviceProvider.GetService(typeof(IEventPublisher)).Returns(_eventPublisher);
|
||||
|
||||
var scope = Substitute.For<IServiceScope>();
|
||||
scope.ServiceProvider.Returns(serviceProvider);
|
||||
|
||||
var scopeFactory = Substitute.For<IServiceScopeFactory>();
|
||||
scopeFactory.CreateScope().Returns(scope);
|
||||
|
||||
arrClientFactory.GetClient(Arg.Any<InstanceType>(), Arg.Any<float>()).Returns(_arrClient);
|
||||
|
||||
_sut = new SeekerCommandMonitor(logger, scopeFactory, _timeProvider);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _cts.CancelAsync();
|
||||
try { await _sut.StopAsync(CancellationToken.None); }
|
||||
catch { /* expected during teardown */ }
|
||||
_sut.Dispose();
|
||||
_dataContext.Dispose();
|
||||
_cts.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deduplicates_grabbed_items_by_download_id_for_sonarr_season_packs()
|
||||
{
|
||||
// Arrange
|
||||
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_dataContext);
|
||||
var eventId = Guid.NewGuid();
|
||||
|
||||
_dataContext.SeekerCommandTrackers.Add(new SeekerCommandTracker
|
||||
{
|
||||
ArrInstanceId = sonarrInstance.Id,
|
||||
CommandId = 1,
|
||||
EventId = eventId,
|
||||
ExternalItemId = 100,
|
||||
ItemTitle = "Test Series - Season 1",
|
||||
ItemType = InstanceType.Sonarr,
|
||||
SeasonNumber = 1,
|
||||
Status = SearchCommandStatus.Pending,
|
||||
CreatedAt = _timeProvider.GetUtcNow().UtcDateTime
|
||||
});
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
_arrClient.GetCommandStatusAsync(Arg.Any<ArrInstance>(), Arg.Any<long>())
|
||||
.Returns(new ArrCommandStatus(1, "completed", null));
|
||||
|
||||
// 3 episodes from same season pack share the same DownloadId
|
||||
_arrClient.GetQueueItemsAsync(Arg.Any<ArrInstance>(), Arg.Any<int>())
|
||||
.Returns(new QueueListResponse
|
||||
{
|
||||
TotalRecords = 3,
|
||||
Records =
|
||||
[
|
||||
new QueueRecord { Id = 1, SeriesId = 100, SeasonNumber = 1, Title = "Test.Series.S01.1080p", DownloadId = "ABC123", Protocol = "torrent", Status = "downloading" },
|
||||
new QueueRecord { Id = 2, SeriesId = 100, SeasonNumber = 1, Title = "Test.Series.S01.1080p", DownloadId = "ABC123", Protocol = "torrent", Status = "downloading" },
|
||||
new QueueRecord { Id = 3, SeriesId = 100, SeasonNumber = 1, Title = "Test.Series.S01.1080p", DownloadId = "ABC123", Protocol = "torrent", Status = "downloading" },
|
||||
]
|
||||
});
|
||||
|
||||
var publishTcs = new TaskCompletionSource<List<string>?>();
|
||||
_eventPublisher.PublishSearchCompleted(
|
||||
Arg.Any<Guid>(), Arg.Any<SearchCommandStatus>(), Arg.Any<List<string>?>())
|
||||
.Returns(Task.CompletedTask)
|
||||
.AndDoes(ci => publishTcs.TrySetResult(ci.ArgAt<List<string>?>(2)));
|
||||
|
||||
// Act
|
||||
await _sut.StartAsync(_cts.Token);
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(11));
|
||||
var resultData = await publishTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
// Assert
|
||||
await _eventPublisher.Received(1).PublishSearchCompleted(
|
||||
eventId, SearchCommandStatus.Completed, Arg.Any<List<string>?>());
|
||||
|
||||
resultData.ShouldNotBeNull();
|
||||
resultData!.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Filters_out_records_with_empty_download_id()
|
||||
{
|
||||
// Arrange
|
||||
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_dataContext);
|
||||
var eventId = Guid.NewGuid();
|
||||
|
||||
_dataContext.SeekerCommandTrackers.Add(new SeekerCommandTracker
|
||||
{
|
||||
ArrInstanceId = sonarrInstance.Id,
|
||||
CommandId = 1,
|
||||
EventId = eventId,
|
||||
ExternalItemId = 100,
|
||||
ItemTitle = "Test Series - Season 1",
|
||||
ItemType = InstanceType.Sonarr,
|
||||
SeasonNumber = 1,
|
||||
Status = SearchCommandStatus.Pending,
|
||||
CreatedAt = _timeProvider.GetUtcNow().UtcDateTime
|
||||
});
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
_arrClient.GetCommandStatusAsync(Arg.Any<ArrInstance>(), Arg.Any<long>())
|
||||
.Returns(new ArrCommandStatus(1, "completed", null));
|
||||
|
||||
// Queue has records with empty DownloadId and one valid record
|
||||
_arrClient.GetQueueItemsAsync(Arg.Any<ArrInstance>(), Arg.Any<int>())
|
||||
.Returns(new QueueListResponse
|
||||
{
|
||||
TotalRecords = 3,
|
||||
Records =
|
||||
[
|
||||
new QueueRecord { Id = 1, SeriesId = 100, SeasonNumber = 1, Title = "Empty DL 1", DownloadId = "", Protocol = "torrent", Status = "downloading" },
|
||||
new QueueRecord { Id = 2, SeriesId = 100, SeasonNumber = 1, Title = "Empty DL 2", DownloadId = "", Protocol = "torrent", Status = "downloading" },
|
||||
new QueueRecord { Id = 3, SeriesId = 100, SeasonNumber = 1, Title = "Valid Download", DownloadId = "VALID123", Protocol = "torrent", Status = "downloading" },
|
||||
]
|
||||
});
|
||||
|
||||
var publishTcs = new TaskCompletionSource<List<string>?>();
|
||||
_eventPublisher.PublishSearchCompleted(
|
||||
Arg.Any<Guid>(), Arg.Any<SearchCommandStatus>(), Arg.Any<List<string>?>())
|
||||
.Returns(Task.CompletedTask)
|
||||
.AndDoes(ci => publishTcs.TrySetResult(ci.ArgAt<List<string>?>(2)));
|
||||
|
||||
// Act
|
||||
await _sut.StartAsync(_cts.Token);
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(11));
|
||||
var resultData = await publishTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
// Assert
|
||||
resultData.ShouldNotBeNull();
|
||||
resultData!.Count.ShouldBe(1);
|
||||
resultData[0].ShouldBe("Valid Download");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reports_multiple_grabbed_items_with_different_download_ids()
|
||||
{
|
||||
// Arrange
|
||||
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_dataContext);
|
||||
var eventId = Guid.NewGuid();
|
||||
|
||||
_dataContext.SeekerCommandTrackers.Add(new SeekerCommandTracker
|
||||
{
|
||||
ArrInstanceId = radarrInstance.Id,
|
||||
CommandId = 1,
|
||||
EventId = eventId,
|
||||
ExternalItemId = 200,
|
||||
ItemTitle = "Test Movie",
|
||||
ItemType = InstanceType.Radarr,
|
||||
SeasonNumber = 0,
|
||||
Status = SearchCommandStatus.Pending,
|
||||
CreatedAt = _timeProvider.GetUtcNow().UtcDateTime
|
||||
});
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
_arrClient.GetCommandStatusAsync(Arg.Any<ArrInstance>(), Arg.Any<long>())
|
||||
.Returns(new ArrCommandStatus(1, "completed", null));
|
||||
|
||||
// Two different downloads for the same movie
|
||||
_arrClient.GetQueueItemsAsync(Arg.Any<ArrInstance>(), Arg.Any<int>())
|
||||
.Returns(new QueueListResponse
|
||||
{
|
||||
TotalRecords = 2,
|
||||
Records =
|
||||
[
|
||||
new QueueRecord { Id = 1, MovieId = 200, Title = "Test.Movie.720p", DownloadId = "HASH1", Protocol = "torrent", Status = "downloading" },
|
||||
new QueueRecord { Id = 2, MovieId = 200, Title = "Test.Movie.1080p", DownloadId = "HASH2", Protocol = "usenet", Status = "downloading" },
|
||||
]
|
||||
});
|
||||
|
||||
var publishTcs = new TaskCompletionSource<List<string>?>();
|
||||
_eventPublisher.PublishSearchCompleted(
|
||||
Arg.Any<Guid>(), Arg.Any<SearchCommandStatus>(), Arg.Any<List<string>?>())
|
||||
.Returns(Task.CompletedTask)
|
||||
.AndDoes(ci => publishTcs.TrySetResult(ci.ArgAt<List<string>?>(2)));
|
||||
|
||||
// Act
|
||||
await _sut.StartAsync(_cts.Token);
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(11));
|
||||
var resultData = await publishTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
// Assert
|
||||
resultData.ShouldNotBeNull();
|
||||
resultData!.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -9,7 +9,6 @@ using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Seeker;
|
||||
using Cleanuparr.Persistence.Models.State;
|
||||
using Data.Models.Arr;
|
||||
using Cleanuparr.Infrastructure.Hubs;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
@@ -56,13 +55,20 @@ 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(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<int>(),
|
||||
It.IsAny<IEnumerable<string>>(),
|
||||
It.IsAny<SeekerSearchType>(),
|
||||
It.IsAny<SeekerSearchReason>(),
|
||||
It.IsAny<Guid?>()))
|
||||
.ReturnsAsync(Guid.NewGuid());
|
||||
}
|
||||
@@ -111,9 +117,8 @@ public class SeekerTests : IDisposable
|
||||
_fixture.EventPublisher.Verify(
|
||||
x => x.PublishSearchTriggered(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<int>(),
|
||||
It.IsAny<IEnumerable<string>>(),
|
||||
It.IsAny<SeekerSearchType>(),
|
||||
It.IsAny<SeekerSearchReason>(),
|
||||
It.IsAny<Guid?>()),
|
||||
Times.Never);
|
||||
}
|
||||
@@ -139,9 +144,8 @@ public class SeekerTests : IDisposable
|
||||
_fixture.EventPublisher.Verify(
|
||||
x => x.PublishSearchTriggered(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<int>(),
|
||||
It.IsAny<IEnumerable<string>>(),
|
||||
It.IsAny<SeekerSearchType>(),
|
||||
It.IsAny<SeekerSearchReason>(),
|
||||
It.IsAny<Guid?>()),
|
||||
Times.Never);
|
||||
}
|
||||
@@ -183,10 +187,9 @@ public class SeekerTests : IDisposable
|
||||
|
||||
_fixture.EventPublisher.Verify(
|
||||
x => x.PublishSearchTriggered(
|
||||
radarrInstance.Name,
|
||||
1,
|
||||
It.Is<IEnumerable<string>>(items => items.Contains("Test Movie")),
|
||||
"Test Movie",
|
||||
SeekerSearchType.Replacement,
|
||||
SeekerSearchReason.Replacement,
|
||||
It.IsAny<Guid?>()),
|
||||
Times.Once);
|
||||
|
||||
@@ -286,9 +289,8 @@ public class SeekerTests : IDisposable
|
||||
_fixture.EventPublisher.Verify(
|
||||
x => x.PublishSearchTriggered(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<int>(),
|
||||
It.IsAny<IEnumerable<string>>(),
|
||||
SeekerSearchType.Proactive,
|
||||
It.IsAny<SeekerSearchReason>(),
|
||||
It.IsAny<Guid?>()),
|
||||
Times.Never);
|
||||
}
|
||||
@@ -672,8 +674,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;
|
||||
@@ -945,9 +955,8 @@ public class SeekerTests : IDisposable
|
||||
_fixture.EventPublisher.Verify(
|
||||
x => x.PublishSearchTriggered(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<int>(),
|
||||
It.IsAny<IEnumerable<string>>(),
|
||||
It.IsAny<SeekerSearchType>(),
|
||||
It.IsAny<SeekerSearchReason>(),
|
||||
It.IsAny<Guid?>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
+35
-13
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
// using Common.Configuration;
|
||||
// using Common.Enums;
|
||||
// using Infrastructure.Configuration;
|
||||
// using Infrastructure.Http;
|
||||
// using Infrastructure.Services;
|
||||
// using Microsoft.Extensions.Logging;
|
||||
// using NSubstitute;
|
||||
//
|
||||
// namespace Infrastructure.Tests.Http;
|
||||
//
|
||||
// public class DynamicHttpClientProviderFixture : IDisposable
|
||||
// {
|
||||
// public ILogger<DynamicHttpClientProvider> Logger { get; }
|
||||
//
|
||||
// public DynamicHttpClientProviderFixture()
|
||||
// {
|
||||
// Logger = Substitute.For<ILogger<DynamicHttpClientProvider>>();
|
||||
// }
|
||||
//
|
||||
// public DynamicHttpClientProvider CreateSut()
|
||||
// {
|
||||
// var httpClientFactory = Substitute.For<IHttpClientFactory>();
|
||||
// var configManager = Substitute.For<IConfigManager>();
|
||||
// var certificateValidationService = Substitute.For<CertificateValidationService>();
|
||||
//
|
||||
// return new DynamicHttpClientProvider(
|
||||
// Logger,
|
||||
// httpClientFactory,
|
||||
// configManager,
|
||||
// certificateValidationService);
|
||||
// }
|
||||
//
|
||||
// public DownloadClientConfig CreateQBitClientConfig()
|
||||
// {
|
||||
// return new DownloadClientConfig
|
||||
// {
|
||||
// Id = Guid.NewGuid(),
|
||||
// Name = "QBit Test",
|
||||
// Type = DownloadClientType.QBittorrent,
|
||||
// Enabled = true,
|
||||
// Host = new("http://localhost:8080"),
|
||||
// Username = "admin",
|
||||
// Password = "adminadmin"
|
||||
// };
|
||||
// }
|
||||
//
|
||||
// public DownloadClientConfig CreateTransmissionClientConfig()
|
||||
// {
|
||||
// return new DownloadClientConfig
|
||||
// {
|
||||
// Id = Guid.NewGuid(),
|
||||
// Name = "Transmission Test",
|
||||
// Type = DownloadClientType.Transmission,
|
||||
// Enabled = true,
|
||||
// Host = new("http://localhost:9091"),
|
||||
// Username = "admin",
|
||||
// Password = "adminadmin",
|
||||
// UrlBase = "transmission"
|
||||
// };
|
||||
// }
|
||||
//
|
||||
// public DownloadClientConfig CreateDelugeClientConfig()
|
||||
// {
|
||||
// return new DownloadClientConfig
|
||||
// {
|
||||
// Id = Guid.NewGuid(),
|
||||
// Name = "Deluge Test",
|
||||
// Type = DownloadClientType.Deluge,
|
||||
// Enabled = true,
|
||||
// Host = new("http://localhost:8112"),
|
||||
// Username = "admin",
|
||||
// Password = "deluge"
|
||||
// };
|
||||
// }
|
||||
//
|
||||
// public void Dispose()
|
||||
// {
|
||||
// // Cleanup if needed
|
||||
// }
|
||||
// }
|
||||
@@ -1,133 +0,0 @@
|
||||
// using System.Net;
|
||||
// using Common.Enums;
|
||||
// using Infrastructure.Http;
|
||||
// using Shouldly;
|
||||
//
|
||||
// namespace Infrastructure.Tests.Http;
|
||||
//
|
||||
// public class DynamicHttpClientProviderTests : IClassFixture<DynamicHttpClientProviderFixture>
|
||||
// {
|
||||
// private readonly DynamicHttpClientProviderFixture _fixture;
|
||||
//
|
||||
// public DynamicHttpClientProviderTests(DynamicHttpClientProviderFixture fixture)
|
||||
// {
|
||||
// _fixture = fixture;
|
||||
// }
|
||||
//
|
||||
// [Fact]
|
||||
// public void CreateClient_WithQBitConfig_ShouldReturnConfiguredClient()
|
||||
// {
|
||||
// // Arrange
|
||||
// var sut = _fixture.CreateSut();
|
||||
// var config = _fixture.CreateQBitClientConfig();
|
||||
//
|
||||
// // Act
|
||||
// var httpClient = sut.CreateClient(config);
|
||||
//
|
||||
// // Assert
|
||||
// httpClient.ShouldNotBeNull();
|
||||
// httpClient.BaseAddress.ShouldBe(config.Url);
|
||||
// VerifyDefaultHttpClientProperties(httpClient);
|
||||
// }
|
||||
//
|
||||
// [Fact]
|
||||
// public void CreateClient_WithTransmissionConfig_ShouldReturnConfiguredClient()
|
||||
// {
|
||||
// // Arrange
|
||||
// var sut = _fixture.CreateSut();
|
||||
// var config = _fixture.CreateTransmissionClientConfig();
|
||||
//
|
||||
// // Act
|
||||
// var httpClient = sut.CreateClient(config);
|
||||
//
|
||||
// // Assert
|
||||
// httpClient.ShouldNotBeNull();
|
||||
// httpClient.BaseAddress.ShouldBe(config.Url);
|
||||
// VerifyDefaultHttpClientProperties(httpClient);
|
||||
// }
|
||||
//
|
||||
// [Fact]
|
||||
// public void CreateClient_WithDelugeConfig_ShouldReturnConfiguredClient()
|
||||
// {
|
||||
// // Arrange
|
||||
// var sut = _fixture.CreateSut();
|
||||
// var config = _fixture.CreateDelugeClientConfig();
|
||||
//
|
||||
// // Act
|
||||
// var httpClient = sut.CreateClient(config);
|
||||
//
|
||||
// // Assert
|
||||
// httpClient.ShouldNotBeNull();
|
||||
// httpClient.BaseAddress.ShouldBe(config.Url);
|
||||
//
|
||||
// // Deluge client should have additional properties configured
|
||||
// VerifyDelugeHttpClientProperties(httpClient);
|
||||
// }
|
||||
//
|
||||
// [Fact]
|
||||
// public void CreateClient_WithSameConfig_ShouldReturnUniqueInstances()
|
||||
// {
|
||||
// // Arrange
|
||||
// var sut = _fixture.CreateSut();
|
||||
// var config = _fixture.CreateQBitClientConfig();
|
||||
//
|
||||
// // Act
|
||||
// var firstClient = sut.CreateClient(config);
|
||||
// var secondClient = sut.CreateClient(config);
|
||||
//
|
||||
// // Assert
|
||||
// firstClient.ShouldNotBeNull();
|
||||
// secondClient.ShouldNotBeNull();
|
||||
// firstClient.ShouldNotBeSameAs(secondClient); // Should be different instances
|
||||
// }
|
||||
//
|
||||
// [Fact]
|
||||
// public void CreateClient_WithCustomCertificateValidation_ShouldConfigureHandler()
|
||||
// {
|
||||
// // Arrange
|
||||
// var sut = _fixture.CreateSut();
|
||||
// var config = _fixture.CreateQBitClientConfig();
|
||||
//
|
||||
// // Act
|
||||
// var httpClient = sut.CreateClient(config);
|
||||
//
|
||||
// // Assert
|
||||
// httpClient.ShouldNotBeNull();
|
||||
//
|
||||
// // Since we can't directly access the handler settings after creation,
|
||||
// // we verify the behavior is working by checking if the client can be created properly
|
||||
// httpClient.BaseAddress.ShouldBe(config.Url);
|
||||
// }
|
||||
//
|
||||
// [Fact]
|
||||
// public void CreateClient_WithTimeout_ShouldConfigureTimeout()
|
||||
// {
|
||||
// // Arrange
|
||||
// var sut = _fixture.CreateSut();
|
||||
// var config = _fixture.CreateQBitClientConfig();
|
||||
// TimeSpan expectedTimeout = TimeSpan.FromSeconds(30);
|
||||
//
|
||||
// // Act
|
||||
// var httpClient = sut.CreateClient(config);
|
||||
//
|
||||
// // Assert
|
||||
// httpClient.Timeout.ShouldBe(expectedTimeout);
|
||||
// }
|
||||
//
|
||||
// private void VerifyDefaultHttpClientProperties(HttpClient httpClient)
|
||||
// {
|
||||
// // Check common properties that should be set for all clients
|
||||
// httpClient.Timeout.ShouldBe(TimeSpan.FromSeconds(30));
|
||||
// httpClient.DefaultRequestHeaders.ShouldNotBeNull();
|
||||
// }
|
||||
//
|
||||
// private void VerifyDelugeHttpClientProperties(HttpClient httpClient)
|
||||
// {
|
||||
// // Verify Deluge-specific HTTP client configurations
|
||||
// VerifyDefaultHttpClientProperties(httpClient);
|
||||
//
|
||||
// // Using reflection to access the handler is tricky and potentially brittle
|
||||
// // Instead, we focus on verifying the client itself is properly configured
|
||||
// httpClient.BaseAddress.ShouldNotBeNull();
|
||||
// }
|
||||
// }
|
||||
@@ -11,6 +11,8 @@ using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Events;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Events;
|
||||
@@ -57,6 +59,8 @@ public class EventPublisher : IEventPublisher
|
||||
TrackingId = trackingId,
|
||||
StrikeId = strikeId,
|
||||
JobRunId = ContextProvider.TryGetJobRunId(),
|
||||
ArrInstanceId = ContextProvider.Get(ContextProvider.Keys.ArrInstanceId) as Guid?,
|
||||
DownloadClientId = ContextProvider.Get(ContextProvider.Keys.DownloadClientId) as Guid?,
|
||||
InstanceType = ContextProvider.Get(nameof(InstanceType)) is InstanceType it ? it : null,
|
||||
InstanceUrl = (ContextProvider.Get(ContextProvider.Keys.ArrInstanceUrl) as Uri)?.ToString(),
|
||||
DownloadClientType = ContextProvider.Get(ContextProvider.Keys.DownloadClientType) is DownloadClientTypeName dct ? dct : null,
|
||||
@@ -64,7 +68,9 @@ public class EventPublisher : IEventPublisher
|
||||
};
|
||||
|
||||
eventEntity.IsDryRun = isDryRun ?? await _dryRunInterceptor.IsDryRunEnabled();
|
||||
await SaveEventToDatabase(eventEntity);
|
||||
|
||||
_context.Events.Add(eventEntity);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
await NotifyClientsAsync(eventEntity);
|
||||
|
||||
@@ -89,7 +95,9 @@ public class EventPublisher : IEventPublisher
|
||||
};
|
||||
|
||||
eventEntity.IsDryRun = isDryRun ?? await _dryRunInterceptor.IsDryRunEnabled();
|
||||
await SaveManualEventToDatabase(eventEntity);
|
||||
|
||||
_context.ManualEvents.Add(eventEntity);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
await NotifyClientsAsync(eventEntity);
|
||||
|
||||
@@ -231,21 +239,17 @@ public class EventPublisher : IEventPublisher
|
||||
/// Publishes a search triggered event with context data and notifications.
|
||||
/// Returns the event ID so the SeekerCommandMonitor can update it on completion.
|
||||
/// </summary>
|
||||
public async Task<Guid> PublishSearchTriggered(string instanceName, int itemCount, IEnumerable<string> items, SeekerSearchType searchType, Guid? cycleId = null)
|
||||
public async Task<Guid> PublishSearchTriggered(string itemTitle, SeekerSearchType searchType, SeekerSearchReason searchReason, Guid? cycleId = null)
|
||||
{
|
||||
var itemList = items as string[] ?? items.ToArray();
|
||||
var itemsDisplay = string.Join(", ", itemList.Take(5)) + (itemList.Length > 5 ? $" (+{itemList.Length - 5} more)" : "");
|
||||
|
||||
AppEvent eventEntity = new()
|
||||
{
|
||||
EventType = EventType.SearchTriggered,
|
||||
Message = $"Searched {itemCount} items on {instanceName}: {itemsDisplay}",
|
||||
Message = $"Search triggered for {itemTitle}",
|
||||
Severity = EventSeverity.Information,
|
||||
Data = JsonSerializer.Serialize(
|
||||
new { InstanceName = instanceName, ItemCount = itemCount, Items = itemList, SearchType = searchType.ToString(), CycleId = cycleId },
|
||||
new JsonSerializerOptions { Converters = { new JsonStringEnumConverter() } }),
|
||||
SearchStatus = SearchCommandStatus.Pending,
|
||||
JobRunId = ContextProvider.TryGetJobRunId(),
|
||||
ArrInstanceId = ContextProvider.Get(ContextProvider.Keys.ArrInstanceId) as Guid?,
|
||||
DownloadClientId = ContextProvider.Get(ContextProvider.Keys.DownloadClientId) as Guid?,
|
||||
InstanceType = ContextProvider.Get(nameof(InstanceType)) is InstanceType it ? it : null,
|
||||
InstanceUrl = (ContextProvider.Get(ContextProvider.Keys.ArrInstanceUrl) as Uri)?.ToString(),
|
||||
DownloadClientType = ContextProvider.Get(ContextProvider.Keys.DownloadClientType) is DownloadClientTypeName dct ? dct : null,
|
||||
@@ -254,19 +258,44 @@ public class EventPublisher : IEventPublisher
|
||||
};
|
||||
|
||||
eventEntity.IsDryRun = await _dryRunInterceptor.IsDryRunEnabled();
|
||||
await SaveEventToDatabase(eventEntity);
|
||||
|
||||
await using IDbContextTransaction transaction = await _context.Database.BeginTransactionAsync();
|
||||
|
||||
try
|
||||
{
|
||||
_context.Events.Add(eventEntity);
|
||||
_context.SearchEventData.Add(new SearchEventData
|
||||
{
|
||||
AppEventId = eventEntity.Id,
|
||||
ItemTitle = itemTitle,
|
||||
SearchType = searchType,
|
||||
SearchReason = searchReason,
|
||||
});
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
await NotifyClientsAsync(eventEntity);
|
||||
await _notificationPublisher.NotifySearchTriggered(instanceName, itemCount, itemList);
|
||||
await _notificationPublisher.NotifySearchTriggered(itemTitle, searchType, searchReason);
|
||||
|
||||
return eventEntity.Id;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing search event with completion status and optional result data
|
||||
/// Updates an existing search event with completion status and optional grabbed item titles
|
||||
/// </summary>
|
||||
public async Task PublishSearchCompleted(Guid eventId, SearchCommandStatus status, object? resultData = null)
|
||||
public async Task PublishSearchCompleted(Guid eventId, SearchCommandStatus status, List<string>? grabbedItems = null)
|
||||
{
|
||||
var existingEvent = await _context.Events.FindAsync(eventId);
|
||||
var existingEvent = await _context.Events
|
||||
.Include(e => e.SearchEventData)
|
||||
.FirstOrDefaultAsync(e => e.Id == eventId);
|
||||
|
||||
if (existingEvent is null)
|
||||
{
|
||||
_logger.LogWarning("Could not find search event {EventId} to update completion status", eventId);
|
||||
@@ -276,31 +305,9 @@ public class EventPublisher : IEventPublisher
|
||||
existingEvent.SearchStatus = status;
|
||||
existingEvent.CompletedAt = DateTime.UtcNow;
|
||||
|
||||
if (resultData is not null)
|
||||
if (grabbedItems is { Count: > 0 } && existingEvent.SearchEventData is not null)
|
||||
{
|
||||
// Merge result data into existing Data JSON
|
||||
var existingData = existingEvent.Data is not null
|
||||
? JsonSerializer.Deserialize<Dictionary<string, object>>(existingEvent.Data)
|
||||
: new Dictionary<string, object>();
|
||||
|
||||
var resultJson = JsonSerializer.Serialize(resultData, new JsonSerializerOptions
|
||||
{
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
});
|
||||
var resultDict = JsonSerializer.Deserialize<Dictionary<string, object>>(resultJson);
|
||||
|
||||
if (existingData is not null && resultDict is not null)
|
||||
{
|
||||
foreach (var kvp in resultDict)
|
||||
{
|
||||
existingData[kvp.Key] = kvp.Value;
|
||||
}
|
||||
|
||||
existingEvent.Data = JsonSerializer.Serialize(existingData, new JsonSerializerOptions
|
||||
{
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
});
|
||||
}
|
||||
existingEvent.SearchEventData.GrabbedItems = grabbedItems;
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
@@ -319,18 +326,6 @@ public class EventPublisher : IEventPublisher
|
||||
);
|
||||
}
|
||||
|
||||
private async Task SaveEventToDatabase(AppEvent eventEntity)
|
||||
{
|
||||
_context.Events.Add(eventEntity);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task SaveManualEventToDatabase(ManualEvent eventEntity)
|
||||
{
|
||||
_context.ManualEvents.Add(eventEntity);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task NotifyClientsAsync(AppEvent appEventEntity)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -20,7 +20,7 @@ public interface IEventPublisher
|
||||
|
||||
Task PublishSearchNotTriggered(string hash, string itemName);
|
||||
|
||||
Task<Guid> PublishSearchTriggered(string instanceName, int itemCount, IEnumerable<string> items, SeekerSearchType searchType, Guid? cycleId = null);
|
||||
Task<Guid> PublishSearchTriggered(string itemTitle, SeekerSearchType searchType, SeekerSearchReason searchReason, Guid? cycleId = null);
|
||||
|
||||
Task PublishSearchCompleted(Guid eventId, SearchCommandStatus status, object? resultData = null);
|
||||
Task PublishSearchCompleted(Guid eventId, SearchCommandStatus status, List<string>? grabbedItems = null);
|
||||
}
|
||||
@@ -8,7 +8,6 @@ using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Data.Models.Arr;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
@@ -330,4 +329,6 @@ public abstract class ArrClient : IArrClient
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public abstract Task<List<Tag>> GetAllTagsAsync(ArrInstance arrInstance);
|
||||
}
|
||||
@@ -2,7 +2,6 @@ using Cleanuparr.Domain.Entities.Arr;
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Data.Models.Arr;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
|
||||
@@ -45,4 +44,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,11 +1,11 @@
|
||||
using System.Text;
|
||||
using Cleanuparr.Domain.Entities.Arr;
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Entities.Lidarr;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Data.Models.Arr;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
@@ -155,4 +155,9 @@ public class LidarrClient : ArrClient, ILidarrClient
|
||||
|
||||
return [new LidarrCommand { Name = albumSearch, AlbumIds = items.Select(i => i.Id).ToList() }];
|
||||
}
|
||||
|
||||
public override async Task<List<Tag>> GetAllTagsAsync(ArrInstance arrInstance)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Data.Models.Arr;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
@@ -157,6 +156,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,11 +1,11 @@
|
||||
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;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Data.Models.Arr;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
@@ -145,4 +145,9 @@ public class ReadarrClient : ArrClient, IReadarrClient
|
||||
|
||||
return await DeserializeStreamAsync<Book>(response);
|
||||
}
|
||||
|
||||
public override async Task<List<Tag>> GetAllTagsAsync(ArrInstance arrInstance)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Data.Models.Arr;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
using Series = Cleanuparr.Domain.Entities.Sonarr.Series;
|
||||
@@ -227,6 +226,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);
|
||||
|
||||
@@ -8,7 +8,6 @@ using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Data.Models.Arr;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
@@ -277,4 +276,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;
|
||||
@@ -6,7 +7,6 @@ using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Data.Models.Arr;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
@@ -146,4 +146,9 @@ public class WhisparrV3Client : ArrClient, IWhisparrV3Client
|
||||
|
||||
return await DeserializeStreamAsync<Movie>(response);
|
||||
}
|
||||
|
||||
public override async Task<List<Tag>> GetAllTagsAsync(ArrInstance arrInstance)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Immutable;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Context;
|
||||
|
||||
@@ -43,14 +44,24 @@ public static class ContextProvider
|
||||
|
||||
public static void SetJobRunId(Guid id) => Set(JobRunIdKey, id);
|
||||
|
||||
public static void SetDownloadClient(DownloadClientConfig config)
|
||||
{
|
||||
Set(Keys.DownloadClientUrl, config.ExternalOrInternalUrl);
|
||||
Set(Keys.DownloadClientId, config.Id);
|
||||
Set(Keys.DownloadClientType, config.TypeName);
|
||||
Set(Keys.DownloadClientName, config.Name);
|
||||
}
|
||||
|
||||
public static class Keys
|
||||
{
|
||||
public const string Version = "version";
|
||||
public const string ItemName = "itemName";
|
||||
public const string Hash = "hash";
|
||||
public const string DownloadClientUrl = "downloadClientUrl";
|
||||
public const string DownloadClientId = "downloadClientId";
|
||||
public const string DownloadClientType = "downloadClientType";
|
||||
public const string DownloadClientName = "downloadClientName";
|
||||
public const string ArrInstanceId = "arrInstanceId";
|
||||
public const string ArrInstanceUrl = "arrInstanceUrl";
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -27,7 +27,8 @@ public partial class DelugeService
|
||||
|
||||
result.IsPrivate = download.Private;
|
||||
result.Found = true;
|
||||
|
||||
SetDownloadClientContext();
|
||||
|
||||
if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads))
|
||||
{
|
||||
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
|
||||
|
||||
+19
-20
@@ -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,33 +53,29 @@ 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);
|
||||
ContextProvider.Set(ContextProvider.Keys.DownloadClientType, _downloadClientConfig.TypeName);
|
||||
ContextProvider.Set(ContextProvider.Keys.DownloadClientName, _downloadClientConfig.Name);
|
||||
SetDownloadClientContext();
|
||||
|
||||
DelugeContents? contents;
|
||||
try
|
||||
@@ -98,6 +95,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 +104,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 +130,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 +144,9 @@ public partial class DelugeService
|
||||
{
|
||||
await _client.CreateLabel(name);
|
||||
}
|
||||
|
||||
|
||||
protected virtual async Task ChangeLabel(string hash, string newLabel)
|
||||
{
|
||||
await _client.SetTorrentLabel(hash, newLabel);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -29,6 +29,7 @@ public partial class DelugeService
|
||||
|
||||
result.IsPrivate = download.Private;
|
||||
result.Found = true;
|
||||
SetDownloadClientContext();
|
||||
|
||||
// Create ITorrentItem wrapper for consistent interface usage
|
||||
DelugeItemWrapper torrent = new(download);
|
||||
|
||||
@@ -58,6 +58,11 @@ public abstract class DownloadService : IDownloadService
|
||||
|
||||
public DownloadClientConfig ClientConfig => _downloadClientConfig;
|
||||
|
||||
protected void SetDownloadClientContext()
|
||||
{
|
||||
ContextProvider.SetDownloadClient(_downloadClientConfig);
|
||||
}
|
||||
|
||||
public abstract void Dispose();
|
||||
|
||||
public abstract Task LoginAsync();
|
||||
@@ -70,13 +75,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 +95,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
|
||||
@@ -107,9 +112,7 @@ public abstract class DownloadService : IDownloadService
|
||||
|
||||
ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name);
|
||||
ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash);
|
||||
ContextProvider.Set(ContextProvider.Keys.DownloadClientUrl, _downloadClientConfig.ExternalOrInternalUrl);
|
||||
ContextProvider.Set(ContextProvider.Keys.DownloadClientType, _downloadClientConfig.TypeName);
|
||||
ContextProvider.Set(ContextProvider.Keys.DownloadClientName, _downloadClientConfig.Name);
|
||||
SetDownloadClientContext();
|
||||
|
||||
TimeSpan seedingTime = TimeSpan.FromSeconds(torrent.SeedingTimeSeconds);
|
||||
SeedingCheckResult result = ShouldCleanDownload(torrent.Ratio, seedingTime, category);
|
||||
@@ -135,7 +138,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 +154,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 +199,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 +225,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
@@ -47,6 +47,7 @@ public partial class QBitService
|
||||
|
||||
result.IsPrivate = isPrivate;
|
||||
result.Found = true;
|
||||
SetDownloadClientContext();
|
||||
|
||||
var malwareBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
|
||||
|
||||
|
||||
+22
-27
@@ -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)
|
||||
@@ -106,9 +103,7 @@ public partial class QBitService
|
||||
|
||||
ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name);
|
||||
ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash);
|
||||
ContextProvider.Set(ContextProvider.Keys.DownloadClientUrl, _downloadClientConfig.ExternalOrInternalUrl);
|
||||
ContextProvider.Set(ContextProvider.Keys.DownloadClientType, _downloadClientConfig.TypeName);
|
||||
ContextProvider.Set(ContextProvider.Keys.DownloadClientName, _downloadClientConfig.Name);
|
||||
SetDownloadClientContext();
|
||||
bool hasHardlinks = false;
|
||||
bool hasErrors = false;
|
||||
|
||||
@@ -123,13 +118,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 +153,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 +173,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 +184,4 @@ public partial class QBitService
|
||||
|
||||
await _client.SetTorrentCategoryAsync([hash], newCategory);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -38,6 +38,7 @@ public partial class QBitService
|
||||
&& boolValue;
|
||||
|
||||
result.Found = true;
|
||||
SetDownloadClientContext();
|
||||
|
||||
// Create ITorrentItem wrapper for consistent interface usage
|
||||
QBitItemWrapper torrent = new(download, trackers, result.IsPrivate);
|
||||
|
||||
+5
-3
@@ -29,7 +29,8 @@ public sealed class RTorrentClient
|
||||
"d.complete=",
|
||||
"d.timestamp.finished=",
|
||||
"d.custom1=",
|
||||
"d.base_path="
|
||||
"d.base_path=",
|
||||
"d.directory="
|
||||
];
|
||||
|
||||
// Fields to request when fetching file data via f.multicall
|
||||
@@ -327,7 +328,7 @@ public sealed class RTorrentClient
|
||||
|
||||
private static RTorrentTorrent? CreateTorrentFromValues(object?[] values)
|
||||
{
|
||||
if (values.Length < 12) return null;
|
||||
if (values.Length < 13) return null;
|
||||
|
||||
return new RTorrentTorrent
|
||||
{
|
||||
@@ -342,7 +343,8 @@ public sealed class RTorrentClient
|
||||
Complete = Convert.ToInt32(values[8] ?? 0),
|
||||
TimestampFinished = Convert.ToInt64(values[9] ?? 0),
|
||||
Label = values[10]?.ToString(),
|
||||
BasePath = values[11]?.ToString()
|
||||
BasePath = values[11]?.ToString(),
|
||||
Directory = values[12]?.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -27,6 +27,7 @@ public partial class RTorrentService
|
||||
|
||||
result.IsPrivate = download.IsPrivate == 1;
|
||||
result.Found = true;
|
||||
SetDownloadClientContext();
|
||||
|
||||
// Get trackers for ignore check
|
||||
var trackers = await _client.GetTrackersAsync(hash);
|
||||
|
||||
+14
-15
@@ -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))
|
||||
@@ -73,9 +72,7 @@ public partial class RTorrentService
|
||||
|
||||
ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name);
|
||||
ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash);
|
||||
ContextProvider.Set(ContextProvider.Keys.DownloadClientUrl, _downloadClientConfig.ExternalOrInternalUrl);
|
||||
ContextProvider.Set(ContextProvider.Keys.DownloadClientType, _downloadClientConfig.TypeName);
|
||||
ContextProvider.Set(ContextProvider.Keys.DownloadClientName, _downloadClientConfig.Name);
|
||||
SetDownloadClientContext();
|
||||
|
||||
List<RTorrentFile> files;
|
||||
try
|
||||
@@ -94,7 +91,9 @@ public partial class RTorrentService
|
||||
foreach (var file in files)
|
||||
{
|
||||
string filePath = string.Join(Path.DirectorySeparatorChar,
|
||||
Path.Combine(torrent.Info.BasePath ?? "", file.Path).Split(['\\', '/']));
|
||||
Path.Combine(torrent.Info.Directory ?? torrent.Info.BasePath ?? "", file.Path).Split(['\\', '/']));
|
||||
|
||||
filePath = PathHelper.RemapPath(filePath, unlinkedConfig.DownloadDirectorySource, unlinkedConfig.DownloadDirectoryTarget);
|
||||
|
||||
if (file.Priority <= 0)
|
||||
{
|
||||
@@ -103,7 +102,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 +129,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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -25,6 +25,7 @@ public partial class RTorrentService
|
||||
|
||||
result.IsPrivate = download.IsPrivate == 1;
|
||||
result.Found = true;
|
||||
SetDownloadClientContext();
|
||||
|
||||
// Get trackers for ignore check
|
||||
var trackers = await _client.GetTrackersAsync(hash);
|
||||
|
||||
+2
-1
@@ -38,7 +38,8 @@ public partial class TransmissionService
|
||||
bool isPrivate = download.IsPrivate ?? false;
|
||||
result.IsPrivate = isPrivate;
|
||||
result.Found = true;
|
||||
|
||||
SetDownloadClientContext();
|
||||
|
||||
var malwareBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
|
||||
|
||||
if (malwareBlockerConfig.IgnorePrivate && isPrivate)
|
||||
|
||||
+17
-18
@@ -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,40 +44,36 @@ 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);
|
||||
ContextProvider.Set(ContextProvider.Keys.DownloadClientType, _downloadClientConfig.TypeName);
|
||||
ContextProvider.Set(ContextProvider.Keys.DownloadClientName, _downloadClientConfig.Name);
|
||||
SetDownloadClientContext();
|
||||
|
||||
if (torrent.Info.Files is null || torrent.Info.FileStats is null)
|
||||
{
|
||||
_logger.LogDebug("skip | download has no files | {name}", torrent.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
bool hasHardlinks = false;
|
||||
bool hasErrors = false;
|
||||
|
||||
@@ -92,7 +89,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 +119,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 +135,4 @@ public partial class TransmissionService
|
||||
{
|
||||
await _client.TorrentSetLocationAsync([downloadId], newLocation, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -27,6 +27,7 @@ public partial class TransmissionService
|
||||
bool isPrivate = download.IsPrivate ?? false;
|
||||
result.IsPrivate = isPrivate;
|
||||
result.Found = true;
|
||||
SetDownloadClientContext();
|
||||
|
||||
// Create ITorrentItem wrapper for consistent interface usage
|
||||
TransmissionItemWrapper torrent = new(download);
|
||||
|
||||
+3
-2
@@ -29,8 +29,9 @@ public partial class UTorrentService
|
||||
var properties = await _client.GetTorrentPropertiesAsync(hash);
|
||||
result.IsPrivate = properties.IsPrivate;
|
||||
result.Found = true;
|
||||
|
||||
if (ignoredDownloads.Count > 0 &&
|
||||
SetDownloadClientContext();
|
||||
|
||||
if (ignoredDownloads.Count > 0 &&
|
||||
(download.ShouldIgnore(ignoredDownloads) || properties.TrackerList.Any(x => x.ShouldIgnore(ignoredDownloads))))
|
||||
{
|
||||
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
|
||||
|
||||
+16
-17
@@ -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,27 +48,23 @@ 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);
|
||||
ContextProvider.Set(ContextProvider.Keys.DownloadClientType, _downloadClientConfig.TypeName);
|
||||
ContextProvider.Set(ContextProvider.Keys.DownloadClientName, _downloadClientConfig.Name);
|
||||
SetDownloadClientContext();
|
||||
|
||||
List<UTorrentFile>? files = await _client.GetTorrentFilesAsync(torrent.Hash);
|
||||
|
||||
@@ -78,6 +75,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 +84,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 +111,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -28,6 +28,7 @@ public partial class UTorrentService
|
||||
var properties = await _client.GetTorrentPropertiesAsync(hash);
|
||||
result.IsPrivate = properties.IsPrivate;
|
||||
result.Found = true;
|
||||
SetDownloadClientContext();
|
||||
|
||||
// Create ITorrentItem wrapper for consistent interface usage
|
||||
UTorrentItemWrapper torrent = new(download, properties);
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces;
|
||||
using Cleanuparr.Domain.Entities.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover.Models;
|
||||
using Data.Models.Arr;
|
||||
using MassTransit;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover.Models;
|
||||
using Data.Models.Arr;
|
||||
using Cleanuparr.Domain.Entities.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover.Models;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces;
|
||||
|
||||
|
||||
+9
-8
@@ -1,21 +1,20 @@
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Entities.Arr;
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Data.Models.Arr;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadRemover.Models;
|
||||
|
||||
public sealed record QueueItemRemoveRequest<T>
|
||||
where T : SearchItem
|
||||
{
|
||||
public required InstanceType InstanceType { get; init; }
|
||||
|
||||
public required ArrInstance Instance { get; init; }
|
||||
|
||||
|
||||
public required T SearchItem { get; init; }
|
||||
|
||||
|
||||
public required QueueRecord Record { get; init; }
|
||||
|
||||
|
||||
public required bool RemoveFromClient { get; init; }
|
||||
|
||||
public required DeleteReason DeleteReason { get; init; }
|
||||
@@ -23,4 +22,6 @@ public sealed record QueueItemRemoveRequest<T>
|
||||
public required Guid JobRunId { get; init; }
|
||||
|
||||
public bool SkipSearch { get; init; }
|
||||
}
|
||||
|
||||
public DownloadClientConfig? DownloadClient { get; init; }
|
||||
}
|
||||
+12
-6
@@ -12,7 +12,6 @@ using Cleanuparr.Infrastructure.Helpers;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Seeker;
|
||||
using Cleanuparr.Persistence.Models.State;
|
||||
using Data.Models.Arr;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -50,7 +49,8 @@ public sealed class QueueItemRemover : IQueueItemRemover
|
||||
{
|
||||
try
|
||||
{
|
||||
var arrClient = _arrClientFactory.GetClient(request.InstanceType, request.Instance.Version);
|
||||
var instanceType = request.Instance.ArrConfig.Type;
|
||||
var arrClient = _arrClientFactory.GetClient(instanceType, request.Instance.Version);
|
||||
await arrClient.DeleteQueueItemAsync(request.Instance, request.Record, request.RemoveFromClient, request.DeleteReason);
|
||||
|
||||
// Mark the download item as removed in the database
|
||||
@@ -61,16 +61,22 @@ public sealed class QueueItemRemover : IQueueItemRemover
|
||||
setter.SetProperty(x => x.IsRemoved, true);
|
||||
setter.SetProperty(x => x.IsMarkedForRemoval, false);
|
||||
});
|
||||
|
||||
|
||||
// Set context for EventPublisher
|
||||
ContextProvider.SetJobRunId(request.JobRunId);
|
||||
ContextProvider.Set(ContextProvider.Keys.ItemName, request.Record.Title);
|
||||
ContextProvider.Set(ContextProvider.Keys.Hash, request.Record.DownloadId);
|
||||
ContextProvider.Set(nameof(QueueRecord), request.Record);
|
||||
ContextProvider.Set(ContextProvider.Keys.ArrInstanceUrl, request.Instance.ExternalUrl ?? request.Instance.Url);
|
||||
ContextProvider.Set(nameof(InstanceType), request.InstanceType);
|
||||
ContextProvider.Set(ContextProvider.Keys.ArrInstanceUrl, request.Instance.ExternalOrInternalUrl);
|
||||
ContextProvider.Set(nameof(InstanceType), instanceType);
|
||||
ContextProvider.Set(ContextProvider.Keys.ArrInstanceId, request.Instance.Id);
|
||||
ContextProvider.Set(ContextProvider.Keys.Version, request.Instance.Version);
|
||||
|
||||
if (request.DownloadClient is not null)
|
||||
{
|
||||
ContextProvider.SetDownloadClient(request.DownloadClient);
|
||||
}
|
||||
|
||||
await _eventPublisher.PublishQueueItemDeleted(request.RemoveFromClient, request.DeleteReason);
|
||||
|
||||
string hash = request.Record.DownloadId.ToLowerInvariant();
|
||||
@@ -116,7 +122,7 @@ public sealed class QueueItemRemover : IQueueItemRemover
|
||||
throw;
|
||||
}
|
||||
|
||||
throw new Exception($"Item might have already been deleted by your {request.InstanceType} instance", exception);
|
||||
throw new Exception($"Item might have already been deleted by your {request.Instance.ArrConfig.Type} instance", exception);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Data.Models.Arr;
|
||||
using MassTransit;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
@@ -77,7 +76,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());
|
||||
@@ -126,13 +125,13 @@ public abstract class GenericHandler : IHandler
|
||||
|
||||
protected async Task PublishQueueItemRemoveRequest(
|
||||
string downloadRemovalKey,
|
||||
InstanceType instanceType,
|
||||
ArrInstance instance,
|
||||
QueueRecord record,
|
||||
bool isPack,
|
||||
bool removeFromClient,
|
||||
DeleteReason deleteReason,
|
||||
bool skipSearch = false
|
||||
bool skipSearch = false,
|
||||
DownloadClientConfig? downloadClient = null
|
||||
)
|
||||
{
|
||||
if (_cache.TryGetValue(downloadRemovalKey, out bool _))
|
||||
@@ -141,18 +140,20 @@ public abstract class GenericHandler : IHandler
|
||||
return;
|
||||
}
|
||||
|
||||
var instanceType = instance.ArrConfig.Type;
|
||||
|
||||
if (instanceType is InstanceType.Sonarr || (instanceType is InstanceType.Whisparr && instance.Version is 2))
|
||||
{
|
||||
QueueItemRemoveRequest<SeriesSearchItem> removeRequest = new()
|
||||
{
|
||||
InstanceType = instanceType,
|
||||
Instance = instance,
|
||||
Record = record,
|
||||
SearchItem = (SeriesSearchItem)GetRecordSearchItem(instanceType, instance.Version, record, isPack),
|
||||
RemoveFromClient = removeFromClient,
|
||||
DeleteReason = deleteReason,
|
||||
JobRunId = ContextProvider.GetJobRunId(),
|
||||
SkipSearch = skipSearch
|
||||
SkipSearch = skipSearch,
|
||||
DownloadClient = downloadClient,
|
||||
};
|
||||
|
||||
await _messageBus.Publish(removeRequest);
|
||||
@@ -161,19 +162,25 @@ public abstract class GenericHandler : IHandler
|
||||
{
|
||||
QueueItemRemoveRequest<SearchItem> removeRequest = new()
|
||||
{
|
||||
InstanceType = instanceType,
|
||||
Instance = instance,
|
||||
Record = record,
|
||||
SearchItem = GetRecordSearchItem(instanceType, instance.Version, record, isPack),
|
||||
RemoveFromClient = removeFromClient,
|
||||
DeleteReason = deleteReason,
|
||||
JobRunId = ContextProvider.GetJobRunId(),
|
||||
SkipSearch = skipSearch
|
||||
SkipSearch = skipSearch,
|
||||
DownloadClient = downloadClient,
|
||||
};
|
||||
|
||||
await _messageBus.Publish(removeRequest);
|
||||
}
|
||||
|
||||
// Set context for event
|
||||
if (downloadClient is not null)
|
||||
{
|
||||
ContextProvider.SetDownloadClient(downloadClient);
|
||||
}
|
||||
|
||||
_logger.LogInformation("item marked for removal | {title} | {url}", record.Title, instance.Url);
|
||||
await _eventPublisher.PublishAsync(EventType.DownloadMarkedForDeletion, "Download marked for deletion", EventSeverity.Important,
|
||||
data: new { itemName = record.Title, hash = record.DownloadId });
|
||||
|
||||
@@ -105,8 +105,9 @@ public sealed class MalwareBlocker : GenericHandler
|
||||
IArrClient arrClient = _arrClientFactory.GetClient(instance.ArrConfig.Type, instance.Version);
|
||||
|
||||
// push to context
|
||||
ContextProvider.Set(ContextProvider.Keys.ArrInstanceUrl, instance.ExternalUrl ?? instance.Url);
|
||||
ContextProvider.Set(ContextProvider.Keys.ArrInstanceUrl, instance.ExternalOrInternalUrl);
|
||||
ContextProvider.Set(nameof(InstanceType), instance.ArrConfig.Type);
|
||||
ContextProvider.Set(ContextProvider.Keys.ArrInstanceId, instance.Id);
|
||||
ContextProvider.Set(ContextProvider.Keys.Version, instance.Version);
|
||||
|
||||
IReadOnlyList<IDownloadService> downloadServices = await GetInitializedDownloadServicesAsync();
|
||||
@@ -162,15 +163,16 @@ public sealed class MalwareBlocker : GenericHandler
|
||||
|
||||
BlockFilesResult result = new();
|
||||
bool isTorrent = record.Protocol.Contains("torrent", StringComparison.InvariantCultureIgnoreCase);
|
||||
DownloadClientConfig? foundInClient = null;
|
||||
|
||||
if (isTorrent)
|
||||
{
|
||||
var torrentClients = downloadServices
|
||||
.Where(x => x.ClientConfig.Type is DownloadClientType.Torrent)
|
||||
.ToList();
|
||||
|
||||
|
||||
_logger.LogDebug("searching unwanted files for {title}", record.Title);
|
||||
|
||||
|
||||
if (torrentClients.Count > 0)
|
||||
{
|
||||
// Check each download client for the download item
|
||||
@@ -181,19 +183,20 @@ public sealed class MalwareBlocker : GenericHandler
|
||||
// stalled download check
|
||||
result = await downloadService
|
||||
.BlockUnwantedFilesAsync(record.DownloadId, ignoredDownloads);
|
||||
|
||||
|
||||
if (result.Found)
|
||||
{
|
||||
foundInClient = downloadService.ClientConfig;
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error checking download {dName} with download client {cName}",
|
||||
_logger.LogError(ex, "Error checking download {dName} with download client {cName}",
|
||||
record.Title, downloadService.ClientConfig.Name);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!result.Found)
|
||||
{
|
||||
_logger.LogWarning("Download not found in any torrent client | {title}", record.Title);
|
||||
@@ -209,23 +212,23 @@ public sealed class MalwareBlocker : GenericHandler
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
bool removeFromClient = true;
|
||||
|
||||
|
||||
if (result.IsPrivate && !config.DeletePrivate)
|
||||
{
|
||||
removeFromClient = false;
|
||||
}
|
||||
|
||||
|
||||
await PublishQueueItemRemoveRequest(
|
||||
downloadRemovalKey,
|
||||
instance.ArrConfig.Type,
|
||||
instance,
|
||||
record,
|
||||
group.Count() > 1,
|
||||
removeFromClient,
|
||||
result.DeleteReason,
|
||||
skipSearch: !hasContentId
|
||||
skipSearch: !hasContentId,
|
||||
downloadClient: foundInClient
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -91,8 +91,9 @@ public sealed class QueueCleaner : GenericHandler
|
||||
IArrClient arrClient = _arrClientFactory.GetClient(instance.ArrConfig.Type, instance.Version);
|
||||
|
||||
// push to context
|
||||
ContextProvider.Set(ContextProvider.Keys.ArrInstanceUrl, instance.ExternalUrl ?? instance.Url);
|
||||
ContextProvider.Set(ContextProvider.Keys.ArrInstanceUrl, instance.ExternalOrInternalUrl);
|
||||
ContextProvider.Set(nameof(InstanceType), instance.ArrConfig.Type);
|
||||
ContextProvider.Set(ContextProvider.Keys.ArrInstanceId, instance.Id);
|
||||
ContextProvider.Set(ContextProvider.Keys.Version, instance.Version);
|
||||
|
||||
IReadOnlyList<IDownloadService> downloadServices = await GetInitializedDownloadServicesAsync();
|
||||
@@ -150,13 +151,14 @@ public sealed class QueueCleaner : GenericHandler
|
||||
|
||||
DownloadCheckResult downloadCheckResult = new();
|
||||
bool isTorrent = record.Protocol.Contains("torrent", StringComparison.InvariantCultureIgnoreCase);
|
||||
DownloadClientConfig? foundInClient = null;
|
||||
|
||||
if (isTorrent)
|
||||
{
|
||||
var torrentClients = downloadServices
|
||||
.Where(x => x.ClientConfig.Type is DownloadClientType.Torrent)
|
||||
.ToList();
|
||||
|
||||
|
||||
if (torrentClients.Count > 0)
|
||||
{
|
||||
// Check each download client for the download item
|
||||
@@ -167,19 +169,20 @@ public sealed class QueueCleaner : GenericHandler
|
||||
// Get torrent info from download service for rule evaluation
|
||||
downloadCheckResult = await downloadService
|
||||
.ShouldRemoveFromArrQueueAsync(record.DownloadId, ignoredDownloads);
|
||||
|
||||
|
||||
if (downloadCheckResult.Found)
|
||||
{
|
||||
foundInClient = downloadService.ClientConfig;
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error checking download {dName} with download client {cName}",
|
||||
_logger.LogError(ex, "Error checking download {dName} with download client {cName}",
|
||||
record.Title, downloadService.ClientConfig.Name);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!downloadCheckResult.Found)
|
||||
{
|
||||
_logger.LogWarning("Download not found in any torrent client | {title}", record.Title);
|
||||
@@ -193,13 +196,13 @@ public sealed class QueueCleaner : GenericHandler
|
||||
|
||||
await PublishQueueItemRemoveRequest(
|
||||
downloadRemovalKey,
|
||||
instance.ArrConfig.Type,
|
||||
instance,
|
||||
record,
|
||||
group.Count() > 1,
|
||||
removeFromClient,
|
||||
downloadCheckResult.DeleteReason,
|
||||
skipSearch: !hasContentId
|
||||
skipSearch: !hasContentId,
|
||||
downloadClient: foundInClient
|
||||
);
|
||||
|
||||
continue;
|
||||
@@ -219,16 +222,16 @@ public sealed class QueueCleaner : GenericHandler
|
||||
if (shouldRemoveFromArr)
|
||||
{
|
||||
bool removeFromClient = !downloadCheckResult.IsPrivate || queueCleanerConfig.FailedImport.DeletePrivate;
|
||||
|
||||
|
||||
await PublishQueueItemRemoveRequest(
|
||||
downloadRemovalKey,
|
||||
instance.ArrConfig.Type,
|
||||
instance,
|
||||
record,
|
||||
group.Count() > 1,
|
||||
removeFromClient,
|
||||
DeleteReason.FailedImport,
|
||||
skipSearch: !hasContentId
|
||||
skipSearch: !hasContentId,
|
||||
downloadClient: foundInClient
|
||||
);
|
||||
|
||||
continue;
|
||||
|
||||
@@ -11,7 +11,6 @@ using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Seeker;
|
||||
using Cleanuparr.Persistence.Models.State;
|
||||
using Cleanuparr.Infrastructure.Hubs;
|
||||
using Data.Models.Arr;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -150,7 +149,8 @@ public sealed class Seeker : IHandler
|
||||
}
|
||||
|
||||
ContextProvider.Set(nameof(InstanceType), item.ArrInstance.ArrConfig.Type);
|
||||
ContextProvider.Set(ContextProvider.Keys.ArrInstanceUrl, arrInstance.ExternalUrl ?? arrInstance.Url);
|
||||
ContextProvider.Set(ContextProvider.Keys.ArrInstanceId, arrInstance.Id);
|
||||
ContextProvider.Set(ContextProvider.Keys.ArrInstanceUrl, arrInstance.ExternalOrInternalUrl);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -159,7 +159,7 @@ public sealed class Seeker : IHandler
|
||||
|
||||
List<long> commandIds = await arrClient.SearchItemsAsync(arrInstance, searchItems);
|
||||
|
||||
Guid eventId = await _eventPublisher.PublishSearchTriggered(arrInstance.Name, 1, [item.Title], SeekerSearchType.Replacement);
|
||||
Guid eventId = await _eventPublisher.PublishSearchTriggered(item.Title, SeekerSearchType.Replacement, SeekerSearchReason.Replacement);
|
||||
|
||||
if (!isDryRun)
|
||||
{
|
||||
@@ -259,7 +259,8 @@ public sealed class Seeker : IHandler
|
||||
|
||||
// Set context for event publishing
|
||||
ContextProvider.Set(nameof(InstanceType), instanceType);
|
||||
ContextProvider.Set(ContextProvider.Keys.ArrInstanceUrl, arrInstance.ExternalUrl ?? arrInstance.Url);
|
||||
ContextProvider.Set(ContextProvider.Keys.ArrInstanceId, arrInstance.Id);
|
||||
ContextProvider.Set(ContextProvider.Keys.ArrInstanceUrl, arrInstance.ExternalOrInternalUrl);
|
||||
|
||||
// Fetch queue once for both active download limit check and queue cross-referencing
|
||||
IArrClient arrClient = _arrClientFactory.GetClient(instanceType, arrInstance.Version);
|
||||
@@ -346,11 +347,7 @@ public sealed class Seeker : IHandler
|
||||
.Where(r => ActiveQueueStates.Contains(r.TrackedDownloadState))
|
||||
.ToList();
|
||||
|
||||
HashSet<SearchItem> searchItems;
|
||||
List<string> selectedNames;
|
||||
List<long> allLibraryIds;
|
||||
List<long> historyIds;
|
||||
int seasonNumber = 0;
|
||||
SeekerProcessResult result;
|
||||
|
||||
if (instanceType == InstanceType.Radarr)
|
||||
{
|
||||
@@ -359,10 +356,7 @@ public sealed class Seeker : IHandler
|
||||
.Select(r => r.MovieId)
|
||||
.ToHashSet();
|
||||
|
||||
List<long> selectedIds;
|
||||
(selectedIds, selectedNames, allLibraryIds) = await ProcessRadarrAsync(config, arrInstance, instanceConfig, itemSearchHistory, isDryRun, queuedMovieIds);
|
||||
searchItems = selectedIds.Select(id => new SearchItem { Id = id }).ToHashSet();
|
||||
historyIds = selectedIds;
|
||||
result = await ProcessRadarrAsync(config, arrInstance, instanceConfig, itemSearchHistory, isDryRun, queuedMovieIds);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -371,49 +365,60 @@ public sealed class Seeker : IHandler
|
||||
.Select(r => (r.SeriesId, r.SeasonNumber))
|
||||
.ToHashSet();
|
||||
|
||||
(searchItems, selectedNames, allLibraryIds, historyIds, seasonNumber) =
|
||||
await ProcessSonarrAsync(config, arrInstance, instanceConfig, itemSearchHistory, currentCycleHistory, isDryRun, queuedSeasons: queuedSeasons);
|
||||
result = await ProcessSonarrAsync(config, arrInstance, instanceConfig, itemSearchHistory, currentCycleHistory, isDryRun, queuedSeasons: queuedSeasons);
|
||||
}
|
||||
|
||||
if (searchItems.Count == 0)
|
||||
if (result.Candidates.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("No items selected for search on {InstanceName}", arrInstance.Name);
|
||||
if (!isDryRun)
|
||||
{
|
||||
await CleanupStaleHistoryAsync(arrInstance.Id, instanceType, allLibraryIds, allHistoryExternalIds);
|
||||
await CleanupStaleHistoryAsync(arrInstance.Id, instanceType, result.AllLibraryIds, allHistoryExternalIds);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Trigger search (arr client guards the HTTP request via dry run interceptor)
|
||||
// Search each item individually so each gets its own event and command tracker
|
||||
IArrClient arrClient = _arrClientFactory.GetClient(instanceType, arrInstance.Version);
|
||||
List<long> commandIds = await arrClient.SearchItemsAsync(arrInstance, searchItems);
|
||||
|
||||
// Publish event (always saved, flagged with IsDryRun in EventPublisher)
|
||||
Guid eventId = await _eventPublisher.PublishSearchTriggered(arrInstance.Name, searchItems.Count, selectedNames, SeekerSearchType.Proactive, instanceConfig.CurrentCycleId);
|
||||
foreach (SeekerSearchCandidate candidate in result.Candidates)
|
||||
{
|
||||
SearchItem searchItem = instanceType == InstanceType.Radarr
|
||||
? new SearchItem { Id = candidate.ItemId }
|
||||
: new SeriesSearchItem
|
||||
{
|
||||
Id = candidate.SeasonNumber,
|
||||
SeriesId = candidate.ItemId,
|
||||
SearchType = SeriesSearchType.Season
|
||||
};
|
||||
|
||||
_logger.LogInformation("Searched {Count} items on {InstanceName}: {Items}",
|
||||
searchItems.Count, arrInstance.Name, string.Join(", ", selectedNames));
|
||||
List<long> commandIds = await arrClient.SearchItemsAsync(arrInstance, [searchItem]);
|
||||
|
||||
// Update search history (always, so stats are accurate during dry run)
|
||||
await UpdateSearchHistoryAsync(arrInstance.Id, instanceType, instanceConfig.CurrentCycleId, historyIds, selectedNames, seasonNumber, isDryRun);
|
||||
Guid eventId = await _eventPublisher.PublishSearchTriggered(
|
||||
candidate.Name, SeekerSearchType.Proactive, candidate.Reason, instanceConfig.CurrentCycleId);
|
||||
|
||||
_logger.LogInformation("Search triggered for {Item} ({Reason}) | {InstanceUrl}", candidate.Name, candidate.Reason, arrInstance.Url);
|
||||
|
||||
await UpdateSearchHistoryAsync(arrInstance.Id, instanceType, instanceConfig.CurrentCycleId,
|
||||
[candidate.ItemId], [candidate.Name], candidate.SeasonNumber, isDryRun);
|
||||
|
||||
if (!isDryRun)
|
||||
{
|
||||
await SaveCommandTrackersAsync(commandIds, eventId, arrInstance.Id, instanceType,
|
||||
candidate.ItemId, candidate.Name, candidate.SeasonNumber);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isDryRun)
|
||||
{
|
||||
// Track commands
|
||||
long externalItemId = historyIds.FirstOrDefault();
|
||||
string itemTitle = selectedNames.FirstOrDefault() ?? string.Empty;
|
||||
await SaveCommandTrackersAsync(commandIds, eventId, arrInstance.Id, instanceType, externalItemId, itemTitle, seasonNumber);
|
||||
|
||||
// Cleanup stale history entries and old cycle history
|
||||
await CleanupStaleHistoryAsync(arrInstance.Id, instanceType, allLibraryIds, allHistoryExternalIds);
|
||||
await CleanupStaleHistoryAsync(arrInstance.Id, instanceType, result.AllLibraryIds, allHistoryExternalIds);
|
||||
await CleanupOldCycleHistoryAsync(arrInstance, instanceConfig.CurrentCycleId);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<(List<long> SelectedIds, List<string> SelectedNames, List<long> AllLibraryIds)> ProcessRadarrAsync(
|
||||
private async Task<SeekerProcessResult> ProcessRadarrAsync(
|
||||
SeekerConfig config,
|
||||
ArrInstance arrInstance,
|
||||
SeekerInstanceConfig instanceConfig,
|
||||
@@ -422,8 +427,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 +450,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))
|
||||
@@ -452,7 +465,7 @@ public sealed class Seeker : IHandler
|
||||
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
return ([], [], allLibraryIds);
|
||||
return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds };
|
||||
}
|
||||
|
||||
// Exclude movies already in the download queue
|
||||
@@ -472,7 +485,7 @@ public sealed class Seeker : IHandler
|
||||
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
return ([], [], allLibraryIds);
|
||||
return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -487,7 +500,7 @@ public sealed class Seeker : IHandler
|
||||
_logger.LogDebug(
|
||||
"skip | cycle complete but min time ({Days}) not elapsed (started {StartedAt}) | {InstanceName}",
|
||||
instanceConfig.MinCycleTimeDays, cycleStartedAt, arrInstance.Name);
|
||||
return ([], [], allLibraryIds);
|
||||
return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds };
|
||||
}
|
||||
|
||||
_logger.LogInformation("All {Count} items on {InstanceName} searched in current cycle, starting new cycle",
|
||||
@@ -512,27 +525,32 @@ public sealed class Seeker : IHandler
|
||||
IItemSelector selector = ItemSelectorFactory.Create(config.SelectionStrategy);
|
||||
List<long> selectedIds = selector.Select(selectionCandidates, 1);
|
||||
|
||||
List<string> selectedNames = candidates
|
||||
.Where(m => selectedIds.Contains(m.Id))
|
||||
.Select(m => m.Title)
|
||||
.ToList();
|
||||
|
||||
List<SeekerSearchCandidate> searchCandidates = [];
|
||||
foreach (long movieId in selectedIds)
|
||||
{
|
||||
SearchableMovie movie = candidates.First(m => m.Id == movieId);
|
||||
string reason = !movie.HasFile
|
||||
? "missing file"
|
||||
SeekerSearchReason reason = !movie.HasFile
|
||||
? SeekerSearchReason.Missing
|
||||
: config.UseCutoff && (movie.MovieFile?.QualityCutoffNotMet ?? false)
|
||||
? "does not meet quality cutoff"
|
||||
: "custom format score below cutoff";
|
||||
? SeekerSearchReason.QualityCutoffNotMet
|
||||
: SeekerSearchReason.CustomFormatScoreBelowCutoff;
|
||||
|
||||
searchCandidates.Add(new SeekerSearchCandidate
|
||||
{
|
||||
ItemId = movieId,
|
||||
Name = movie.Title,
|
||||
SeasonNumber = 0,
|
||||
Reason = reason,
|
||||
});
|
||||
|
||||
_logger.LogDebug("Selected '{Title}' for search on {InstanceName}: {Reason}",
|
||||
movie.Title, arrInstance.Name, reason);
|
||||
}
|
||||
|
||||
return (selectedIds, selectedNames, allLibraryIds);
|
||||
return new SeekerProcessResult { Candidates = searchCandidates, AllLibraryIds = allLibraryIds };
|
||||
}
|
||||
|
||||
private async Task<(HashSet<SearchItem> SearchItems, List<string> SelectedNames, List<long> AllLibraryIds, List<long> HistoryIds, int SeasonNumber)> ProcessSonarrAsync(
|
||||
private async Task<SeekerProcessResult> ProcessSonarrAsync(
|
||||
SeekerConfig config,
|
||||
ArrInstance arrInstance,
|
||||
SeekerInstanceConfig instanceConfig,
|
||||
@@ -543,14 +561,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
|
||||
@@ -561,7 +587,7 @@ public sealed class Seeker : IHandler
|
||||
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
return ([], [], allLibraryIds, [], 0);
|
||||
return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds };
|
||||
}
|
||||
|
||||
// Pass all candidates — BuildSonarrSearchItemAsync handles season-level exclusion
|
||||
@@ -578,7 +604,7 @@ public sealed class Seeker : IHandler
|
||||
foreach (long seriesId in candidateIds)
|
||||
{
|
||||
string seriesTitle = string.Empty;
|
||||
|
||||
|
||||
try
|
||||
{
|
||||
List<SeekerHistory> seriesHistory = currentCycleHistory
|
||||
@@ -587,15 +613,27 @@ public sealed class Seeker : IHandler
|
||||
|
||||
seriesTitle = candidates.First(s => s.Id == seriesId).Title;
|
||||
|
||||
(SeriesSearchItem? searchItem, SearchableEpisode? selectedEpisode) =
|
||||
(SeriesSearchItem? searchItem, SearchableEpisode? selectedEpisode, SeekerSearchReason searchReason) =
|
||||
await BuildSonarrSearchItemAsync(config, arrInstance, seriesId, seriesHistory, seriesTitle, graceCutoff, queuedSeasons);
|
||||
|
||||
if (searchItem is not null)
|
||||
{
|
||||
string displayName = $"{seriesTitle} S{searchItem.Id:D2}";
|
||||
int seasonNumber = (int)searchItem.Id;
|
||||
|
||||
return ([searchItem], [displayName], allLibraryIds, [seriesId], seasonNumber);
|
||||
return new SeekerProcessResult
|
||||
{
|
||||
Candidates =
|
||||
[
|
||||
new SeekerSearchCandidate
|
||||
{
|
||||
ItemId = seriesId,
|
||||
Name = displayName,
|
||||
SeasonNumber = (int)searchItem.Id,
|
||||
Reason = searchReason,
|
||||
}
|
||||
],
|
||||
AllLibraryIds = allLibraryIds,
|
||||
};
|
||||
}
|
||||
|
||||
_logger.LogDebug("Skipping '{SeriesTitle}' — no qualifying seasons found", seriesTitle);
|
||||
@@ -616,7 +654,7 @@ public sealed class Seeker : IHandler
|
||||
_logger.LogDebug(
|
||||
"skip | cycle complete but min time ({Days}) not elapsed (started {StartedAt}) | {InstanceName}",
|
||||
instanceConfig.MinCycleTimeDays, cycleStartedAt, arrInstance.Name);
|
||||
return ([], [], allLibraryIds, [], 0);
|
||||
return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds };
|
||||
}
|
||||
|
||||
_logger.LogInformation("All {Count} series on {InstanceName} searched in current cycle, starting new cycle",
|
||||
@@ -633,14 +671,14 @@ public sealed class Seeker : IHandler
|
||||
new Dictionary<long, DateTime>(), [], isDryRun, isRetry: true, queuedSeasons: queuedSeasons);
|
||||
}
|
||||
|
||||
return ([], [], allLibraryIds, [], 0);
|
||||
return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches episodes for a series and builds a season-level search item.
|
||||
/// Uses search history to prefer least-recently-searched seasons.
|
||||
/// </summary>
|
||||
private async Task<(SeriesSearchItem? SearchItem, SearchableEpisode? SelectedEpisode)> BuildSonarrSearchItemAsync(
|
||||
private async Task<(SeriesSearchItem? SearchItem, SearchableEpisode? SelectedEpisode, SeekerSearchReason SearchReason)> BuildSonarrSearchItemAsync(
|
||||
SeekerConfig config,
|
||||
ArrInstance arrInstance,
|
||||
long seriesId,
|
||||
@@ -689,7 +727,7 @@ public sealed class Seeker : IHandler
|
||||
|
||||
if (qualifying.Count == 0)
|
||||
{
|
||||
return (null, null);
|
||||
return (null, null, default);
|
||||
}
|
||||
|
||||
// Select least-recently-searched season using history
|
||||
@@ -726,7 +764,7 @@ public sealed class Seeker : IHandler
|
||||
if (unsearched.Count == 0)
|
||||
{
|
||||
// All unsearched seasons are either searched or in the queue
|
||||
return (null, null);
|
||||
return (null, null, default);
|
||||
}
|
||||
|
||||
// Pick from unsearched seasons with some randomization
|
||||
@@ -757,6 +795,13 @@ public sealed class Seeker : IHandler
|
||||
reasons.Add($"{cfCount} below CF score cutoff");
|
||||
}
|
||||
|
||||
// Determine the primary search reason
|
||||
SeekerSearchReason searchReason = missingCount > 0
|
||||
? SeekerSearchReason.Missing
|
||||
: cutoffCount > 0
|
||||
? SeekerSearchReason.QualityCutoffNotMet
|
||||
: SeekerSearchReason.CustomFormatScoreBelowCutoff;
|
||||
|
||||
_logger.LogDebug("Selected '{SeriesTitle}' S{Season:D2} for search on {InstanceName}: {Reasons}",
|
||||
seriesTitle, selected.SeasonNumber, arrInstance.Name, string.Join(", ", reasons));
|
||||
|
||||
@@ -767,7 +812,7 @@ public sealed class Seeker : IHandler
|
||||
SearchType = SeriesSearchType.Season
|
||||
};
|
||||
|
||||
return (searchItem, selected.FirstEpisode);
|
||||
return (searchItem, selected.FirstEpisode, searchReason);
|
||||
}
|
||||
|
||||
private async Task UpdateSearchHistoryAsync(
|
||||
|
||||
@@ -40,14 +40,14 @@ public class SeekerCommandMonitor : BackgroundService
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
// Wait for app startup
|
||||
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
|
||||
await Task.Delay(TimeSpan.FromSeconds(10), _timeProvider, stoppingToken);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
bool hadWork = await ProcessPendingCommandsAsync(stoppingToken);
|
||||
await Task.Delay(hadWork ? PollInterval : IdleInterval, stoppingToken);
|
||||
await Task.Delay(hadWork ? PollInterval : IdleInterval, _timeProvider, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
@@ -56,7 +56,7 @@ public class SeekerCommandMonitor : BackgroundService
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in SeekerCommandMonitor");
|
||||
await Task.Delay(IdleInterval, stoppingToken);
|
||||
await Task.Delay(IdleInterval, _timeProvider, stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,8 +152,8 @@ public class SeekerCommandMonitor : BackgroundService
|
||||
else
|
||||
{
|
||||
// All completed — inspect download queue for grabbed items
|
||||
object? resultData = await InspectDownloadQueueAsync(trackers, arrClientFactory);
|
||||
await eventPublisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed, resultData);
|
||||
List<string>? grabbedItems = await InspectDownloadQueueAsync(trackers, arrClientFactory);
|
||||
await eventPublisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed, grabbedItems);
|
||||
_logger.LogDebug("Search command(s) completed for event {EventId}", eventId);
|
||||
}
|
||||
|
||||
@@ -176,11 +176,11 @@ public class SeekerCommandMonitor : BackgroundService
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<object?> InspectDownloadQueueAsync(
|
||||
private async Task<List<string>?> InspectDownloadQueueAsync(
|
||||
List<SeekerCommandTracker> trackers,
|
||||
IArrClientFactory arrClientFactory)
|
||||
{
|
||||
var allGrabbedItems = new List<object>();
|
||||
var allGrabbedTitles = new List<string>();
|
||||
|
||||
// Group by instance to inspect each instance's queue separately
|
||||
foreach (var instanceGroup in trackers.GroupBy(t => t.ArrInstanceId))
|
||||
@@ -197,26 +197,24 @@ public class SeekerCommandMonitor : BackgroundService
|
||||
// Find records matching any tracker in this instance group
|
||||
foreach (var t in instanceGroup)
|
||||
{
|
||||
var grabbedItems = queue.Records
|
||||
var grabbedTitles = queue.Records
|
||||
.Where(r => t.ItemType == InstanceType.Radarr
|
||||
? r.MovieId == t.ExternalItemId
|
||||
: r.SeriesId == t.ExternalItemId
|
||||
&& (t.SeasonNumber == 0 || r.SeasonNumber == t.SeasonNumber))
|
||||
.Select(r => new
|
||||
{
|
||||
r.Title,
|
||||
r.Status,
|
||||
r.Protocol,
|
||||
})
|
||||
.Where(r => !string.IsNullOrEmpty(r.DownloadId))
|
||||
.GroupBy(r => r.DownloadId)
|
||||
.Select(g => g.First())
|
||||
.Select(r => r.Title)
|
||||
.ToList();
|
||||
|
||||
if (grabbedItems.Count > 0)
|
||||
if (grabbedTitles.Count > 0)
|
||||
{
|
||||
_logger.LogInformation("Search for '{Title}' on {Instance} grabbed {Count} items: {Items}",
|
||||
t.ItemTitle, arrInstance.Name, grabbedItems.Count,
|
||||
string.Join(", ", grabbedItems.Select(g => g.Title)));
|
||||
t.ItemTitle, arrInstance.Name, grabbedTitles.Count,
|
||||
string.Join(", ", grabbedTitles));
|
||||
|
||||
allGrabbedItems.AddRange(grabbedItems);
|
||||
allGrabbedTitles.AddRange(grabbedTitles);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -226,6 +224,6 @@ public class SeekerCommandMonitor : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
return allGrabbedItems.Count > 0 ? new { GrabbedItems = allGrabbedItems } : null;
|
||||
return allGrabbedTitles.Count > 0 ? allGrabbedTitles : null;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -12,5 +12,5 @@ public interface INotificationPublisher
|
||||
|
||||
Task NotifyCategoryChanged(string oldCategory, string newCategory, bool isTag = false);
|
||||
|
||||
Task NotifySearchTriggered(string instanceName, int itemCount, IEnumerable<string> items);
|
||||
Task NotifySearchTriggered(string itemTitle, SeekerSearchType searchType, SeekerSearchReason searchReason);
|
||||
}
|
||||
+7
-8
@@ -82,11 +82,11 @@ public class NotificationPublisher : INotificationPublisher
|
||||
}
|
||||
}
|
||||
|
||||
public virtual async Task NotifySearchTriggered(string instanceName, int itemCount, IEnumerable<string> items)
|
||||
public virtual async Task NotifySearchTriggered(string itemTitle, SeekerSearchType searchType, SeekerSearchReason searchReason)
|
||||
{
|
||||
try
|
||||
{
|
||||
var context = BuildSearchTriggeredContext(instanceName, itemCount, items);
|
||||
var context = BuildSearchTriggeredContext(itemTitle, searchType, searchReason);
|
||||
await SendNotificationAsync(NotificationEventType.SearchTriggered, context);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -242,25 +242,24 @@ public class NotificationPublisher : INotificationPublisher
|
||||
return context;
|
||||
}
|
||||
|
||||
private static NotificationContext BuildSearchTriggeredContext(string instanceName, int itemCount, IEnumerable<string> items)
|
||||
private static NotificationContext BuildSearchTriggeredContext(string itemTitle, SeekerSearchType searchType, SeekerSearchReason searchReason)
|
||||
{
|
||||
var instanceType = (InstanceType)ContextProvider.Get<object>(nameof(InstanceType));
|
||||
var instanceUrl = ContextProvider.Get<Uri>(ContextProvider.Keys.ArrInstanceUrl);
|
||||
var itemList = items as string[] ?? items.ToArray();
|
||||
var itemsDisplay = string.Join(", ", itemList.Take(5)) + (itemList.Length > 5 ? $" (+{itemList.Length - 5} more)" : "");
|
||||
|
||||
return new NotificationContext
|
||||
{
|
||||
EventType = NotificationEventType.SearchTriggered,
|
||||
Title = "Search triggered",
|
||||
Description = $"Searched {itemCount} items on {instanceName}",
|
||||
Description = $"Search triggered for {itemTitle}",
|
||||
Severity = EventSeverity.Information,
|
||||
Data = new Dictionary<string, string>
|
||||
{
|
||||
["Instance type"] = instanceType.ToString(),
|
||||
["Url"] = instanceUrl.ToString(),
|
||||
["Item count"] = itemCount.ToString(),
|
||||
["Items"] = itemsDisplay,
|
||||
["Item"] = itemTitle,
|
||||
["Search type"] = searchType.ToString(),
|
||||
["Search reason"] = searchReason.ToString(),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.Seeker;
|
||||
|
||||
/// <summary>
|
||||
/// Result of processing an arr instance for proactive search candidates.
|
||||
/// </summary>
|
||||
internal sealed record SeekerProcessResult
|
||||
{
|
||||
public required List<SeekerSearchCandidate> Candidates { get; init; }
|
||||
public required List<long> AllLibraryIds { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Seeker;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single item selected for proactive search.
|
||||
/// </summary>
|
||||
internal sealed record SeekerSearchCandidate
|
||||
{
|
||||
/// <summary>
|
||||
/// MovieId (Radarr) or SeriesId (Sonarr)
|
||||
/// </summary>
|
||||
public required long ItemId { get; init; }
|
||||
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Season number for Sonarr; 0 for Radarr.
|
||||
/// </summary>
|
||||
public required int SeasonNumber { get; init; }
|
||||
|
||||
public required SeekerSearchReason Reason { get; init; }
|
||||
}
|
||||
+5
-316
@@ -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
|
||||
|
||||
+20
-20
@@ -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,
|
||||
|
||||
+252
@@ -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
|
||||
}
|
||||
+17
-2
@@ -188,11 +188,26 @@ public sealed class QueueRuleTests
|
||||
};
|
||||
|
||||
var exception = Should.Throw<ValidationException>(() => rule.Validate());
|
||||
exception.Message.ShouldBe("Maximum completion percentage must be between 0 and 100");
|
||||
exception.Message.ShouldBe("Maximum completion percentage must be between 1 and 100");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithZeroMaxCompletionPercentage_ThrowsValidationException()
|
||||
{
|
||||
var rule = new StallRule
|
||||
{
|
||||
Name = "test-rule",
|
||||
MaxStrikes = 3,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 0
|
||||
};
|
||||
|
||||
var exception = Should.Throw<ValidationException>(() => rule.Validate());
|
||||
exception.Message.ShouldBe("Maximum completion percentage must be greater than 0");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData((ushort)0)]
|
||||
[InlineData((ushort)1)]
|
||||
[InlineData((ushort)50)]
|
||||
[InlineData((ushort)100)]
|
||||
public void Validate_WithValidMaxCompletionPercentage_DoesNotThrow(ushort maxCompletionPercentage)
|
||||
|
||||
@@ -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 =>
|
||||
{
|
||||
@@ -316,6 +378,7 @@ public class DataContext : DbContext
|
||||
}
|
||||
|
||||
var enumProperties = entityType.ClrType.GetProperties()
|
||||
.Where(p => !p.IsDefined(typeof(System.ComponentModel.DataAnnotations.Schema.NotMappedAttribute), true))
|
||||
.Where(p => p.PropertyType.IsEnum ||
|
||||
(p.PropertyType.IsGenericType &&
|
||||
p.PropertyType.GetGenericTypeDefinition() == typeof(Nullable<>) &&
|
||||
|
||||
@@ -22,6 +22,8 @@ public class EventsContext : DbContext
|
||||
public DbSet<DownloadItem> DownloadItems { get; set; }
|
||||
|
||||
public DbSet<JobRun> JobRuns { get; set; }
|
||||
|
||||
public DbSet<SearchEventData> SearchEventData { get; set; }
|
||||
|
||||
public EventsContext()
|
||||
{
|
||||
@@ -65,6 +67,14 @@ public class EventsContext : DbContext
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<SearchEventData>(entity =>
|
||||
{
|
||||
entity.HasOne(s => s.AppEvent)
|
||||
.WithOne(e => e.SearchEventData)
|
||||
.HasForeignKey<SearchEventData>(s => s.AppEventId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Strike>(entity =>
|
||||
{
|
||||
entity.Property(e => e.CreatedAt)
|
||||
@@ -86,9 +96,10 @@ public class EventsContext : DbContext
|
||||
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
|
||||
{
|
||||
var enumProperties = entityType.ClrType.GetProperties()
|
||||
.Where(p => p.PropertyType.IsEnum ||
|
||||
(p.PropertyType.IsGenericType &&
|
||||
p.PropertyType.GetGenericTypeDefinition() == typeof(Nullable<>) &&
|
||||
.Where(p => !p.IsDefined(typeof(System.ComponentModel.DataAnnotations.Schema.NotMappedAttribute), true))
|
||||
.Where(p => p.PropertyType.IsEnum ||
|
||||
(p.PropertyType.IsGenericType &&
|
||||
p.PropertyType.GetGenericTypeDefinition() == typeof(Nullable<>) &&
|
||||
p.PropertyType.GetGenericArguments()[0].IsEnum));
|
||||
|
||||
foreach (var property in enumProperties)
|
||||
|
||||
+2012
File diff suppressed because it is too large.
Load diff
+354
@@ -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");
|
||||
|
||||
Generated
-1
@@ -2,7 +2,6 @@
|
||||
|
||||
#nullable disable
|
||||
|
||||
using Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
Loaded 100 of 141 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user